1 Commits

Author SHA1 Message Date
vcoppe
9de1933350 first try to show the elevation profile tooltip on layer hover 2024-08-13 15:28:57 +02:00
840 changed files with 35343 additions and 52531 deletions

View File

@@ -36,7 +36,7 @@ jobs:
- name: Build website - name: Build website
env: env:
BASE_PATH: '' BASE_PATH: '/${{ github.event.repository.name }}'
run: | run: |
npm run build --prefix website npm run build --prefix website

View File

@@ -1,16 +0,0 @@
{
"useTabs": false,
"tabWidth": 4,
"singleQuote": true,
"trailingComma": "es5",
"printWidth": 100,
"overrides": [
{
"files": "**/*.svelte",
"options": {
"plugins": ["prettier-plugin-svelte"],
"parser": "svelte"
}
}
]
}

View File

@@ -1,7 +0,0 @@
{
"recommendations": [
"esbenp.prettier-vscode",
"svelte.svelte-vscode"
]
}

13
.vscode/settings.json vendored
View File

@@ -1,13 +0,0 @@
{
"editor.formatOnSave": true,
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[svelte]": {
"editor.defaultFormatter": "svelte.svelte-vscode"
}
}

View File

@@ -3,11 +3,11 @@
<img alt="Logo of gpx.studio." src="website/static/logo.svg"> <img alt="Logo of gpx.studio." src="website/static/logo.svg">
</picture> </picture>
[**gpx.studio**](https://gpx.studio) is an online tool for creating and editing GPX files. **gpx.studio** is an online tool for creating and editing GPX files.
![gpx.studio screenshot](website/src/lib/assets/img/docs/getting-started/interface.png) ![gpx.studio screenshot](website/src/lib/assets/img/docs/getting-started/interface.png)
This repository contains the source code of the website. This repository contains the source code of the new website, currently available [here](https://gpx.studio/gpx.studio).
## Contributing ## Contributing
@@ -72,8 +72,6 @@ This project has been made possible thanks to the following open source projects
- [Mapbox GL JS](https://github.com/mapbox/mapbox-gl-js) — beautiful and fast interactive maps - [Mapbox GL JS](https://github.com/mapbox/mapbox-gl-js) — beautiful and fast interactive maps
- [brouter](https://github.com/abrensch/brouter) — routing engine - [brouter](https://github.com/abrensch/brouter) — routing engine
- [OpenStreetMap](https://www.openstreetmap.org) — map data used by Mapbox and brouter - [OpenStreetMap](https://www.openstreetmap.org) — map data used by Mapbox and brouter
- Search:
- [DocSearch](https://github.com/algolia/docsearch) — search engine for the documentation
## License ## License

View File

@@ -1 +0,0 @@
package-lock.json

1673
gpx/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,21 +11,16 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"fast-xml-parser": "^4.5.0", "fast-xml-parser": "^4.4.0",
"immer": "^10.1.1" "immer": "^10.1.1",
"ts-node": "^10.9.2"
},
"scripts": {
"build": "tsc"
}, },
"devDependencies": { "devDependencies": {
"@types/geojson": "^7946.0.14", "@types/geojson": "^7946.0.14",
"@types/node": "^20.16.10", "@types/node": "^20.14.6",
"@typescript-eslint/parser": "^8.22.0", "typescript": "^5.4.5"
"prettier": "^3.4.2",
"ts-node": "^10.9.2",
"typescript": "^5.6.2"
},
"scripts": {
"build": "tsc",
"postinstall": "npm run build",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -2,3 +2,4 @@ export * from './gpx';
export { Coordinates, LineStyleExtension, WaypointType } from './types'; export { Coordinates, LineStyleExtension, WaypointType } from './types';
export { parseGPX, buildGPX } from './io'; export { parseGPX, buildGPX } from './io';
export * from './simplify'; export * from './simplify';

View File

@@ -1,68 +1,25 @@
import { XMLParser, XMLBuilder } from 'fast-xml-parser'; import { XMLParser, XMLBuilder } from "fast-xml-parser";
import { GPXFileType } from './types'; import { GPXFileType } from "./types";
import { GPXFile } from './gpx'; import { GPXFile } from "./gpx";
const attributesWithNamespace = {
RoutePointExtension: 'gpxx:RoutePointExtension',
rpt: 'gpxx:rpt',
TrackPointExtension: 'gpxtpx:TrackPointExtension',
PowerExtension: 'gpxpx:PowerExtension',
atemp: 'gpxtpx:atemp',
hr: 'gpxtpx:hr',
cad: 'gpxtpx:cad',
Extensions: 'gpxtpx:Extensions',
PowerInWatts: 'gpxpx:PowerInWatts',
power: 'gpxpx:PowerExtension',
line: 'gpx_style:line',
color: 'gpx_style:color',
opacity: 'gpx_style:opacity',
width: 'gpx_style:width',
};
const floatPatterns = [
/[-+]?\d*\.\d+$/, // decimal
/[-+]?\d+$/, // integer
];
function safeParseFloat(value: string): number {
const parsed = parseFloat(value);
if (!isNaN(parsed)) {
return parsed;
}
for (const pattern of floatPatterns) {
const match = value.match(pattern);
if (match) {
return parseFloat(match[0]);
}
}
return 0.0;
}
export function parseGPX(gpxData: string): GPXFile { export function parseGPX(gpxData: string): GPXFile {
const parser = new XMLParser({ const parser = new XMLParser({
ignoreAttributes: false, ignoreAttributes: false,
attributeNamePrefix: '', attributeNamePrefix: "",
attributesGroupName: 'attributes', attributesGroupName: 'attributes',
removeNSPrefix: true,
isArray(name: string) { isArray(name: string) {
return ( return name === 'trk' || name === 'trkseg' || name === 'trkpt' || name === 'wpt' || name === 'rte' || name === 'rtept' || name === 'gpxx:rpt';
name === 'trk' ||
name === 'trkseg' ||
name === 'trkpt' ||
name === 'wpt' ||
name === 'rte' ||
name === 'rtept' ||
name === 'gpxx:rpt'
);
}, },
attributeValueProcessor(attrName, attrValue, jPath) { attributeValueProcessor(attrName, attrValue, jPath) {
if (attrName === 'lat' || attrName === 'lon') { if (attrName === 'lat' || attrName === 'lon') {
return safeParseFloat(attrValue); return parseFloat(attrValue);
} }
return attrValue; return attrValue;
}, },
transformTagName(tagName: string) { transformTagName(tagName: string) {
if (attributesWithNamespace[tagName]) { if (tagName === 'power') {
return attributesWithNamespace[tagName]; // Transform the simple <power> tag to the more complex <gpxpx:PowerExtension> tag, the nested <gpxpx:PowerInWatts> tag is then handled by the tagValueProcessor
return 'gpxpx:PowerExtension';
} }
return tagName; return tagName;
}, },
@@ -70,29 +27,22 @@ export function parseGPX(gpxData: string): GPXFile {
tagValueProcessor(tagName, tagValue, jPath, hasAttributes, isLeafNode) { tagValueProcessor(tagName, tagValue, jPath, hasAttributes, isLeafNode) {
if (isLeafNode) { if (isLeafNode) {
if (tagName === 'ele') { if (tagName === 'ele') {
return safeParseFloat(tagValue); return parseFloat(tagValue);
} }
if (tagName === 'time') { if (tagName === 'time') {
return new Date(tagValue); return new Date(tagValue);
} }
if ( if (tagName === 'gpxtpx:hr' || tagName === 'gpxtpx:cad' || tagName === 'gpxtpx:atemp' || tagName === 'gpxpx:PowerInWatts' || tagName === 'opacity' || tagName === 'weight') {
tagName === 'gpxtpx:atemp' || return parseFloat(tagValue);
tagName === 'gpxtpx:hr' ||
tagName === 'gpxtpx:cad' ||
tagName === 'gpxpx:PowerInWatts' ||
tagName === 'gpx_style:opacity' ||
tagName === 'gpx_style:width'
) {
return safeParseFloat(tagValue);
} }
if (tagName === 'gpxpx:PowerExtension') { if (tagName === 'gpxpx:PowerExtension') {
// Finish the transformation of the simple <power> tag to the more complex <gpxpx:PowerExtension> tag // Finish the transformation of the simple <power> tag to the more complex <gpxpx:PowerExtension> tag
// Note that this only targets the transformed <power> tag, since it must be a leaf node // Note that this only targets the transformed <power> tag, since it must be a leaf node
return { return {
'gpxpx:PowerInWatts': safeParseFloat(tagValue), 'gpxpx:PowerInWatts': parseFloat(tagValue)
}; };
} }
} }
@@ -104,7 +54,7 @@ export function parseGPX(gpxData: string): GPXFile {
const parsed: GPXFileType = parser.parse(gpxData).gpx; const parsed: GPXFileType = parser.parse(gpxData).gpx;
// @ts-ignore // @ts-ignore
if (parsed.metadata === '') { if (parsed.metadata === "") {
parsed.metadata = {}; parsed.metadata = {};
} }
@@ -114,67 +64,49 @@ export function parseGPX(gpxData: string): GPXFile {
export function buildGPX(file: GPXFile, exclude: string[]): string { export function buildGPX(file: GPXFile, exclude: string[]): string {
const gpx = file.toGPXFileType(exclude); const gpx = file.toGPXFileType(exclude);
let lastDate = undefined;
const builder = new XMLBuilder({ const builder = new XMLBuilder({
format: true, format: true,
ignoreAttributes: false, ignoreAttributes: false,
attributeNamePrefix: '', attributeNamePrefix: "",
attributesGroupName: 'attributes', attributesGroupName: 'attributes',
suppressEmptyNode: true, suppressEmptyNode: true,
tagValueProcessor: (tagName: string, tagValue: unknown): string | undefined => { tagValueProcessor: (tagName: string, tagValue: unknown): string => {
if (tagValue instanceof Date) { if (tagValue instanceof Date) {
if (isNaN(tagValue.getTime())) {
return lastDate?.toISOString();
}
lastDate = tagValue;
return tagValue.toISOString(); return tagValue.toISOString();
} }
return tagValue.toString(); return tagValue.toString();
}, },
}); });
if (!gpx.attributes) gpx.attributes = {}; gpx.attributes.creator = gpx.attributes.creator ?? 'https://gpx.studio';
gpx.attributes['creator'] = gpx.attributes['creator'] ?? 'https://gpx.studio';
gpx.attributes['version'] = '1.1'; gpx.attributes['version'] = '1.1';
gpx.attributes['xmlns'] = 'http://www.topografix.com/GPX/1/1'; gpx.attributes['xmlns'] = 'http://www.topografix.com/GPX/1/1';
gpx.attributes['xmlns:xsi'] = 'http://www.w3.org/2001/XMLSchema-instance'; gpx.attributes['xmlns:xsi'] = 'http://www.w3.org/2001/XMLSchema-instance';
gpx.attributes['xsi:schemaLocation'] = gpx.attributes['xsi:schemaLocation'] = 'http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd http://www.garmin.com/xmlschemas/PowerExtension/v1 http://www.garmin.com/xmlschemas/PowerExtensionv1.xsd http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd';
'http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd http://www.garmin.com/xmlschemas/PowerExtension/v1 http://www.garmin.com/xmlschemas/PowerExtensionv1.xsd http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd';
gpx.attributes['xmlns:gpxtpx'] = 'http://www.garmin.com/xmlschemas/TrackPointExtension/v1'; gpx.attributes['xmlns:gpxtpx'] = 'http://www.garmin.com/xmlschemas/TrackPointExtension/v1';
gpx.attributes['xmlns:gpxx'] = 'http://www.garmin.com/xmlschemas/GpxExtensions/v3'; gpx.attributes['xmlns:gpxx'] = 'http://www.garmin.com/xmlschemas/GpxExtensions/v3';
gpx.attributes['xmlns:gpxpx'] = 'http://www.garmin.com/xmlschemas/PowerExtension/v1'; gpx.attributes['xmlns:gpxpx'] = 'http://www.garmin.com/xmlschemas/PowerExtension/v1';
gpx.attributes['xmlns:gpx_style'] = 'http://www.topografix.com/GPX/gpx_style/0/2'; gpx.attributes['xmlns:gpx_style'] = 'http://www.topografix.com/GPX/gpx_style/0/2';
gpx.metadata.author = {
name: 'gpx.studio',
link: {
attributes: {
href: 'https://gpx.studio',
}
}
};
if (gpx.trk.length === 1 && (gpx.trk[0].name === undefined || gpx.trk[0].name === '')) { if (gpx.trk.length === 1 && (gpx.trk[0].name === undefined || gpx.trk[0].name === '')) {
gpx.trk[0].name = gpx.metadata.name; gpx.trk[0].name = gpx.metadata.name;
} }
return builder.build({ return builder.build({
'?xml': { "?xml": {
attributes: { attributes: {
version: '1.0', version: "1.0",
encoding: 'UTF-8', encoding: "UTF-8",
}
}, },
}, gpx
gpx: removeEmptyElements(gpx),
}); });
} }
function removeEmptyElements(obj: GPXFileType): GPXFileType {
for (const key in obj) {
if (
obj[key] === null ||
obj[key] === undefined ||
obj[key] === '' ||
(Array.isArray(obj[key]) && obj[key].length === 0)
) {
delete obj[key];
} else if (typeof obj[key] === 'object' && !(obj[key] instanceof Date)) {
removeEmptyElements(obj[key]);
if (Object.keys(obj[key]).length === 0) {
delete obj[key];
}
}
}
return obj;
}

View File

@@ -1,48 +1,33 @@
import { TrackPoint } from './gpx'; import { TrackPoint } from "./gpx";
import { Coordinates } from './types'; import { Coordinates } from "./types";
export type SimplifiedTrackPoint = { point: TrackPoint; distance?: number }; export type SimplifiedTrackPoint = { point: TrackPoint, distance?: number };
const earthRadius = 6371008.8; const earthRadius = 6371008.8;
export function ramerDouglasPeucker( export function ramerDouglasPeucker(points: TrackPoint[], epsilon: number = 50, measure: (a: TrackPoint, b: TrackPoint, c: TrackPoint) => number = crossarcDistance): SimplifiedTrackPoint[] {
points: TrackPoint[],
epsilon: number = 50,
measure: (a: TrackPoint, b: TrackPoint, c: TrackPoint) => number = crossarcDistance
): SimplifiedTrackPoint[] {
if (points.length == 0) { if (points.length == 0) {
return []; return [];
} else if (points.length == 1) { } else if (points.length == 1) {
return [ return [{
{ point: points[0]
point: points[0], }];
},
];
} }
let simplified = [ let simplified = [{
{ point: points[0]
point: points[0], }];
},
];
ramerDouglasPeuckerRecursive(points, epsilon, measure, 0, points.length - 1, simplified); ramerDouglasPeuckerRecursive(points, epsilon, measure, 0, points.length - 1, simplified);
simplified.push({ simplified.push({
point: points[points.length - 1], point: points[points.length - 1]
}); });
return simplified; return simplified;
} }
function ramerDouglasPeuckerRecursive( function ramerDouglasPeuckerRecursive(points: TrackPoint[], epsilon: number, measure: (a: TrackPoint, b: TrackPoint, c: TrackPoint) => number, start: number, end: number, simplified: SimplifiedTrackPoint[]) {
points: TrackPoint[],
epsilon: number,
measure: (a: TrackPoint, b: TrackPoint, c: TrackPoint) => number,
start: number,
end: number,
simplified: SimplifiedTrackPoint[]
) {
let largest = { let largest = {
index: 0, index: 0,
distance: 0, distance: 0
}; };
for (let i = start + 1; i < end; i++) { for (let i = start + 1; i < end; i++) {
@@ -60,16 +45,8 @@ function ramerDouglasPeuckerRecursive(
} }
} }
export function crossarcDistance( export function crossarcDistance(point1: TrackPoint, point2: TrackPoint, point3: TrackPoint | Coordinates): number {
point1: TrackPoint, return crossarc(point1.getCoordinates(), point2.getCoordinates(), point3 instanceof TrackPoint ? point3.getCoordinates() : point3);
point2: TrackPoint,
point3: TrackPoint | Coordinates
): number {
return crossarc(
point1.getCoordinates(),
point2.getCoordinates(),
point3 instanceof TrackPoint ? point3.getCoordinates() : point3
);
} }
function crossarc(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates): number { function crossarc(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates): number {
@@ -97,7 +74,7 @@ function crossarc(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates)
} }
// Is relative bearing obtuse? // Is relative bearing obtuse?
if (diff > Math.PI / 2) { if (diff > (Math.PI / 2)) {
return dis13; return dis13;
} }
@@ -106,8 +83,7 @@ function crossarc(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates)
// Is p4 beyond the arc? // Is p4 beyond the arc?
let dis12 = distance(lat1, lon1, lat2, lon2); let dis12 = distance(lat1, lon1, lat2, lon2);
let dis14 = let dis14 = Math.acos(Math.cos(dis13 / earthRadius) / Math.cos(dxt / earthRadius)) * earthRadius;
Math.acos(Math.cos(dis13 / earthRadius) / Math.cos(dxt / earthRadius)) * earthRadius;
if (dis14 > dis12) { if (dis14 > dis12) {
return distance(lat2, lon2, lat3, lon3); return distance(lat2, lon2, lat3, lon3);
} else { } else {
@@ -117,85 +93,12 @@ function crossarc(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates)
function distance(latA: number, lonA: number, latB: number, lonB: number): number { function distance(latA: number, lonA: number, latB: number, lonB: number): number {
// Finds the distance between two lat / lon points. // Finds the distance between two lat / lon points.
return ( return Math.acos(Math.sin(latA) * Math.sin(latB) + Math.cos(latA) * Math.cos(latB) * Math.cos(lonB - lonA)) * earthRadius;
Math.acos(
Math.sin(latA) * Math.sin(latB) +
Math.cos(latA) * Math.cos(latB) * Math.cos(lonB - lonA)
) * earthRadius
);
} }
function bearing(latA: number, lonA: number, latB: number, lonB: number): number { function bearing(latA: number, lonA: number, latB: number, lonB: number): number {
// Finds the bearing from one lat / lon point to another. // Finds the bearing from one lat / lon point to another.
return Math.atan2( return Math.atan2(Math.sin(lonB - lonA) * Math.cos(latB),
Math.sin(lonB - lonA) * Math.cos(latB), Math.cos(latA) * Math.sin(latB) - Math.sin(latA) * Math.cos(latB) * Math.cos(lonB - lonA));
Math.cos(latA) * Math.sin(latB) - Math.sin(latA) * Math.cos(latB) * Math.cos(lonB - lonA)
);
}
export function projectedPoint(
point1: TrackPoint,
point2: TrackPoint,
point3: TrackPoint | Coordinates
): Coordinates {
return projected(
point1.getCoordinates(),
point2.getCoordinates(),
point3 instanceof TrackPoint ? point3.getCoordinates() : point3
);
}
function projected(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates): Coordinates {
// Calculates the point on the line defined by p1 and p2
// that is closest to the third point, p3.
// Input lat1,lon1,lat2,lon2,lat3,lon3 in degrees.
const rad = Math.PI / 180;
const lat1 = coord1.lat * rad;
const lat2 = coord2.lat * rad;
const lat3 = coord3.lat * rad;
const lon1 = coord1.lon * rad;
const lon2 = coord2.lon * rad;
const lon3 = coord3.lon * rad;
// Prerequisites for the formulas
const bear12 = bearing(lat1, lon1, lat2, lon2);
const bear13 = bearing(lat1, lon1, lat3, lon3);
let dis13 = distance(lat1, lon1, lat3, lon3);
let diff = Math.abs(bear13 - bear12);
if (diff > Math.PI) {
diff = 2 * Math.PI - diff;
}
// Is relative bearing obtuse?
if (diff > Math.PI / 2) {
return coord1;
}
// Find the cross-track distance.
let dxt = Math.asin(Math.sin(dis13 / earthRadius) * Math.sin(bear13 - bear12)) * earthRadius;
// Is p4 beyond the arc?
let dis12 = distance(lat1, lon1, lat2, lon2);
let dis14 =
Math.acos(Math.cos(dis13 / earthRadius) / Math.cos(dxt / earthRadius)) * earthRadius;
if (dis14 > dis12) {
return coord2;
} else {
// Determine the closest point (p4) on the great circle
const f = dis14 / earthRadius;
const lat4 = Math.asin(
Math.sin(lat1) * Math.cos(f) + Math.cos(lat1) * Math.sin(f) * Math.cos(bear12)
);
const lon4 =
lon1 +
Math.atan2(
Math.sin(bear12) * Math.sin(f) * Math.cos(lat1),
Math.cos(f) - Math.sin(lat1) * Math.sin(lat4)
);
return { lat: lat4 / rad, lon: lon4 / rad };
}
} }

View File

@@ -58,8 +58,8 @@ export type TrackType = {
src?: string; src?: string;
link?: Link; link?: Link;
type?: string; type?: string;
extensions?: TrackExtensions;
trkseg: TrackSegmentType[]; trkseg: TrackSegmentType[];
extensions?: TrackExtensions;
}; };
export type TrackExtensions = { export type TrackExtensions = {
@@ -67,9 +67,9 @@ export type TrackExtensions = {
}; };
export type LineStyleExtension = { export type LineStyleExtension = {
'gpx_style:color'?: string; color?: string;
'gpx_style:opacity'?: number; opacity?: number;
'gpx_style:width'?: number; weight?: number;
}; };
export type TrackSegmentType = { export type TrackSegmentType = {
@@ -89,15 +89,17 @@ export type TrackPointExtensions = {
}; };
export type TrackPointExtension = { export type TrackPointExtension = {
'gpxtpx:atemp'?: number;
'gpxtpx:hr'?: number; 'gpxtpx:hr'?: number;
'gpxtpx:cad'?: number; 'gpxtpx:cad'?: number;
'gpxtpx:Extensions'?: Record<string, string>; 'gpxtpx:atemp'?: number;
}; 'gpxtpx:Extensions'?: {
surface?: string;
};
}
export type PowerExtension = { export type PowerExtension = {
'gpxpx:PowerInWatts'?: number; 'gpxpx:PowerInWatts'?: number;
}; }
export type Author = { export type Author = {
name?: string; name?: string;
@@ -114,12 +116,12 @@ export type RouteType = {
type?: string; type?: string;
extensions?: TrackExtensions; extensions?: TrackExtensions;
rtept: WaypointType[]; rtept: WaypointType[];
}; }
export type RoutePointExtension = { export type RoutePointExtension = {
'gpxx:rpt'?: GPXXRoutePoint[]; 'gpxx:rpt'?: GPXXRoutePoint[];
}; }
export type GPXXRoutePoint = { export type GPXXRoutePoint = {
attributes: Coordinates; attributes: Coordinates;
}; }

View File

@@ -1,253 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.topografix.com/GPX/1/1" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd"
xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1"
xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3"
xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2" version="1.1" creator="https://gpx.studio">
<metadata>
<name>with_routes</name>
<author>
<name>gpx.studio</name>
<link href="https://gpx.studio"></link>
</author>
</metadata>
<rte>
<name>route 1</name>
<type>Cycling</type>
<rtept lat="50.790867" lon="4.404968">
<ele>109.0</ele>
</rtept>
<rtept lat="50.790714" lon="4.405036">
<ele>110.8</ele>
</rtept>
<rtept lat="50.790336" lon="4.405259">
<ele>110.3</ele>
</rtept>
<rtept lat="50.790165" lon="4.405331">
<ele>110.0</ele>
</rtept>
<rtept lat="50.790008" lon="4.405359">
<ele>110.3</ele>
</rtept>
<rtept lat="50.789818" lon="4.405359">
<ele>109.3</ele>
</rtept>
<rtept lat="50.789409" lon="4.40534">
<ele>107.0</ele>
</rtept>
<rtept lat="50.789105" lon="4.405411">
<ele>106.0</ele>
</rtept>
<rtept lat="50.788799" lon="4.405527">
<ele>108.5</ele>
</rtept>
<rtept lat="50.788645" lon="4.405606">
<ele>109.8</ele>
</rtept>
<rtept lat="50.7885" lon="4.405711">
<ele>110.8</ele>
</rtept>
<rtept lat="50.78822" lon="4.405959">
<ele>112.0</ele>
</rtept>
<rtept lat="50.787956" lon="4.406092">
<ele>112.8</ele>
</rtept>
<rtept lat="50.787814" lon="4.406143">
<ele>113.5</ele>
</rtept>
<rtept lat="50.787674" lon="4.406177">
<ele>114.3</ele>
</rtept>
<rtept lat="50.787451" lon="4.406199">
<ele>115.3</ele>
</rtept>
<rtept lat="50.787297" lon="4.406177">
<ele>114.8</ele>
</rtept>
<rtept lat="50.78716" lon="4.406098">
<ele>114.3</ele>
</rtept>
<rtept lat="50.787045" lon="4.405984">
<ele>114.3</ele>
</rtept>
<rtept lat="50.786683" lon="4.405653">
<ele>114.5</ele>
</rtept>
<rtept lat="50.786538" lon="4.405543">
<ele>115.0</ele>
</rtept>
<rtept lat="50.78635" lon="4.405441">
<ele>115.8</ele>
</rtept>
<rtept lat="50.786275" lon="4.40542">
<ele>115.8</ele>
</rtept>
<rtept lat="50.786182" lon="4.405435">
<ele>116.0</ele>
</rtept>
<rtept lat="50.786121" lon="4.405475">
<ele>115.8</ele>
</rtept>
<rtept lat="50.786042" lon="4.405558">
<ele>115.5</ele>
</rtept>
<rtept lat="50.785821" lon="4.405925">
<ele>114.5</ele>
</rtept>
<rtept lat="50.785672" lon="4.406119">
<ele>112.5</ele>
</rtept>
<rtept lat="50.785516" lon="4.406256">
<ele>110.8</ele>
</rtept>
<rtept lat="50.785384" lon="4.406364">
<ele>109.0</ele>
</rtept>
<rtept lat="50.785126" lon="4.406475">
<ele>106.3</ele>
</rtept>
<rtept lat="50.784697" lon="4.406537">
<ele>104.3</ele>
</rtept>
<rtept lat="50.784591" lon="4.40657">
<ele>104.0</ele>
</rtept>
<rtept lat="50.784507" lon="4.406612">
<ele>103.8</ele>
</rtept>
<rtept lat="50.784435" lon="4.40669">
<ele>103.3</ele>
</rtept>
<rtept lat="50.784209" lon="4.407148">
<ele>103.5</ele>
</rtept>
<rtept lat="50.784162" lon="4.407257">
<ele>103.8</ele>
</rtept>
<rtept lat="50.784077" lon="4.407372">
<ele>104.8</ele>
</rtept>
<rtept lat="50.784006" lon="4.407435">
<ele>105.8</ele>
</rtept>
<rtept lat="50.783924" lon="4.407471">
<ele>106.8</ele>
</rtept>
<rtept lat="50.783837" lon="4.407486">
<ele>107.8</ele>
</rtept>
<rtept lat="50.783771" lon="4.407472">
<ele>108.5</ele>
</rtept>
<rtept lat="50.783697" lon="4.407428">
<ele>109.3</ele>
</rtept>
<rtept lat="50.783626" lon="4.407363">
<ele>110.0</ele>
</rtept>
<rtept lat="50.783548" lon="4.407274">
<ele>110.5</ele>
</rtept>
<rtept lat="50.783458" lon="4.407134">
<ele>110.8</ele>
</rtept>
<rtept lat="50.783123" lon="4.406435">
<ele>111.8</ele>
</rtept>
<rtept lat="50.782982" lon="4.406168">
<ele>112.8</ele>
</rtept>
<rtept lat="50.782871" lon="4.406044">
<ele>113.3</ele>
</rtept>
</rte>
<rte>
<name>route 2</name>
<type>Cycling</type>
<rtept lat="50.782212" lon="4.406377">
<ele>115.5</ele>
</rtept>
<rtept lat="50.782175" lon="4.406413">
<ele>115.8</ele>
</rtept>
<rtept lat="50.781749" lon="4.407018">
<ele>118.5</ele>
</rtept>
<rtept lat="50.781654" lon="4.407316">
<ele>119.5</ele>
</rtept>
<rtept lat="50.781563" lon="4.407764">
<ele>121.3</ele>
</rtept>
<rtept lat="50.781487" lon="4.407984">
<ele>122.0</ele>
</rtept>
<rtept lat="50.781422" lon="4.408216">
<ele>122.8</ele>
</rtept>
<rtept lat="50.781395" lon="4.408508">
<ele>123.5</ele>
</rtept>
<rtept lat="50.781399" lon="4.409114">
<ele>126.3</ele>
</rtept>
<rtept lat="50.781367" lon="4.409428">
<ele>128.0</ele>
</rtept>
<rtept lat="50.781286" lon="4.409607">
<ele>129.0</ele>
</rtept>
<rtept lat="50.78116" lon="4.409789">
<ele>130.0</ele>
</rtept>
<rtept lat="50.780804" lon="4.409993">
<ele>130.8</ele>
</rtept>
<rtept lat="50.780389" lon="4.410334">
<ele>131.8</ele>
</rtept>
<rtept lat="50.780232" lon="4.410563">
<ele>132.3</ele>
</rtept>
<rtept lat="50.780094" lon="4.410827">
<ele>132.8</ele>
</rtept>
<rtept lat="50.779723" lon="4.411582">
<ele>135.8</ele>
</rtept>
<rtept lat="50.779591" lon="4.411791">
<ele>135.5</ele>
</rtept>
<rtept lat="50.779125" lon="4.412435">
<ele>132.5</ele>
</rtept>
<rtept lat="50.778676" lon="4.412979">
<ele>134.0</ele>
</rtept>
<rtept lat="50.778194" lon="4.413466">
<ele>136.8</ele>
</rtept>
<rtept lat="50.777427" lon="4.414302">
<ele>137.5</ele>
</rtept>
<rtept lat="50.777165" lon="4.414736">
<ele>137.3</ele>
</rtept>
<rtept lat="50.776927" lon="4.415201">
<ele>137.5</ele>
</rtept>
<rtept lat="50.776778" lon="4.415613">
<ele>137.3</ele>
</rtept>
<rtept lat="50.776553" lon="4.416425">
<ele>134.8</ele>
</rtept>
<rtept lat="50.776326" lon="4.417304">
<ele>132.3</ele>
</rtept>
<rtept lat="50.776129" lon="4.418383">
<ele>129.5</ele>
</rtept>
</rte>
</gpx>

View File

@@ -16,9 +16,9 @@
<type>Cycling</type> <type>Cycling</type>
<extensions> <extensions>
<gpx_style:line> <gpx_style:line>
<gpx_style:color>2d3ee9</gpx_style:color> <color>#2d3ee9</color>
<gpx_style:opacity>0.5</gpx_style:opacity> <opacity>0.5</opacity>
<gpx_style:width>6</gpx_style:width> <weight>6</weight>
</gpx_style:line> </gpx_style:line>
</extensions> </extensions>
<trkseg> <trkseg>

View File

@@ -4,7 +4,9 @@
"target": "ES2015", "target": "ES2015",
"declaration": true, "declaration": true,
"outDir": "./dist", "outDir": "./dist",
"moduleResolution": "node" "moduleResolution": "node",
}, },
"include": ["src"] "include": [
"src"
],
} }

View File

@@ -5,27 +5,27 @@ module.exports = {
'eslint:recommended', 'eslint:recommended',
'plugin:@typescript-eslint/recommended', 'plugin:@typescript-eslint/recommended',
'plugin:svelte/recommended', 'plugin:svelte/recommended',
'prettier', 'prettier'
], ],
parser: '@typescript-eslint/parser', parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'], plugins: ['@typescript-eslint'],
parserOptions: { parserOptions: {
sourceType: 'module', sourceType: 'module',
ecmaVersion: 2020, ecmaVersion: 2020,
extraFileExtensions: ['.svelte'], extraFileExtensions: ['.svelte']
}, },
env: { env: {
browser: true, browser: true,
es2017: true, es2017: true,
node: true, node: true
}, },
overrides: [ overrides: [
{ {
files: ['*.svelte'], files: ['*.svelte'],
parser: 'svelte-eslint-parser', parser: 'svelte-eslint-parser',
parserOptions: { parserOptions: {
parser: '@typescript-eslint/parser', parser: '@typescript-eslint/parser'
}, }
}, }
], ]
}; };

View File

@@ -2,5 +2,3 @@
pnpm-lock.yaml pnpm-lock.yaml
package-lock.json package-lock.json
yarn.lock yarn.lock
src/lib/components/ui
*.mdx

8
website/.prettierrc Normal file
View File

@@ -0,0 +1,8 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

5636
website/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,6 @@
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build",
"prebuild": "npx tsx src/lib/pwa-manifest.ts",
"postbuild": "npx tsx src/lib/sitemap.ts", "postbuild": "npx tsx src/lib/sitemap.ts",
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
@@ -14,69 +13,61 @@
"format": "prettier --write ." "format": "prettier --write ."
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^3.2.5", "@sveltejs/adapter-auto": "^3.2.2",
"@sveltejs/adapter-static": "^3.0.5", "@sveltejs/adapter-static": "^3.0.2",
"@sveltejs/enhanced-img": "^0.3.8", "@sveltejs/enhanced-img": "^0.3.0",
"@sveltejs/kit": "^2.6.1", "@sveltejs/kit": "^2.5.17",
"@sveltejs/vite-plugin-svelte": "^3.1.2", "@sveltejs/vite-plugin-svelte": "^3.1.1",
"@types/eslint": "^8.56.12", "@types/eslint": "^8.56.10",
"@types/events": "^3.0.3", "@types/events": "^3.0.3",
"@types/file-saver": "^2.0.7", "@types/mapbox__mapbox-gl-geocoder": "^5.0.0",
"@types/mapbox__tilebelt": "^1.0.4", "@types/mapbox-gl": "^3.1.0",
"@types/mapbox-gl": "^3.4.0", "@types/node": "^20.14.6",
"@types/node": "^20.16.10", "@types/sanitize-html": "^2.11.0",
"@types/png.js": "^0.2.3",
"@types/sanitize-html": "^2.13.0",
"@types/sortablejs": "^1.15.8", "@types/sortablejs": "^1.15.8",
"@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/eslint-plugin": "^7.13.1",
"@typescript-eslint/parser": "^7.18.0", "@typescript-eslint/parser": "^7.13.1",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.19",
"eslint": "^8.57.1", "eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.44.1", "eslint-plugin-svelte": "^2.40.0",
"events": "^3.3.0", "events": "^3.3.0",
"glob": "^10.4.5", "glob": "^10.4.3",
"mdsvex": "^0.12.6", "mdsvex": "^0.11.2",
"postcss": "^8.4.47", "postcss": "^8.4.38",
"prettier": "^3.3.3", "prettier": "^3.3.2",
"prettier-plugin-svelte": "^3.2.7", "prettier-plugin-svelte": "^3.2.4",
"svelte": "^4.2.19", "svelte": "^4.2.18",
"svelte-check": "^3.8.6", "svelte-check": "^3.8.1",
"tailwindcss": "^3.4.13", "tailwindcss": "^3.4.4",
"tslib": "^2.7.0", "tslib": "^2.6.3",
"tsx": "^4.19.1", "tsx": "^4.15.7",
"typescript": "^5.6.2", "typescript": "^5.4.5",
"vite": "^5.4.8", "vite": "^5.3.1"
"vite-plugin-node-polyfills": "^0.22.0"
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@docsearch/js": "^3.6.2", "@internationalized/date": "^3.5.4",
"@internationalized/date": "^3.5.5", "@mapbox/mapbox-gl-geocoder": "^5.0.2",
"@mapbox/mapbox-gl-geocoder": "^5.0.3",
"@mapbox/sphericalmercator": "^1.2.0", "@mapbox/sphericalmercator": "^1.2.0",
"@mapbox/tilebelt": "^1.0.2",
"@types/mapbox__sphericalmercator": "^1.2.3", "@types/mapbox__sphericalmercator": "^1.2.3",
"bits-ui": "^0.21.15", "bits-ui": "^0.21.12",
"chart.js": "^4.4.4", "chart.js": "^4.4.3",
"chartjs-plugin-zoom": "^2.0.1", "chartjs-plugin-zoom": "^2.0.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dexie": "^4.0.8", "dexie": "^4.0.7",
"file-saver": "^2.0.5",
"gpx": "file:../gpx", "gpx": "file:../gpx",
"immer": "^10.1.1", "immer": "^10.1.1",
"jszip": "^3.10.1", "lucide-static": "^0.427.0",
"lucide-static": "^0.460.0", "lucide-svelte": "^0.427.0",
"lucide-svelte": "^0.460.1", "mapbox-gl": "^3.4.0",
"mapbox-gl": "^3.11.1",
"mapillary-js": "^4.1.2", "mapillary-js": "^4.1.2",
"mode-watcher": "^0.3.1", "mode-watcher": "^0.3.1",
"png.js": "^0.2.1",
"sanitize-html": "^2.13.0", "sanitize-html": "^2.13.0",
"sortablejs": "^1.15.3", "sortablejs": "^1.15.2",
"svelte-i18n": "^4.0.0", "svelte-i18n": "^4.0.0",
"svelte-sonner": "^0.3.28", "svelte-sonner": "^0.3.24",
"tailwind-merge": "^2.5.2", "tailwind-merge": "^2.3.0",
"tailwind-variants": "^0.2.1" "tailwind-variants": "^0.2.1"
} }
} }

View File

@@ -3,4 +3,4 @@ export default {
tailwindcss: {}, tailwindcss: {},
autoprefixer: {}, autoprefixer: {},
}, },
}; }

View File

@@ -1,14 +1,15 @@
<!doctype html> <!doctype html>
<html> <html lang="en">
<head>
<head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" /> <link rel="icon" href="%sveltekit.assets%/favicon.png" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/apple-touch-icon.png" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div> <div style="display: contents">%sveltekit.body%</div>
</body> </body>
</html> </html>

View File

@@ -8,7 +8,7 @@
--foreground: 222.2 84% 4.9%; --foreground: 222.2 84% 4.9%;
--muted: 210 40% 96.1%; --muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 45%; --muted-foreground: 215.4 16.3% 46.9%;
--popover: 0 0% 100%; --popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%; --popover-foreground: 222.2 84% 4.9%;
@@ -33,8 +33,6 @@
--support: 220 15 130; --support: 220 15 130;
--link: 0 110 180;
--ring: 222.2 84% 4.9%; --ring: 222.2 84% 4.9%;
--radius: 0.5rem; --radius: 0.5rem;
@@ -70,9 +68,7 @@
--support: 255 110 190; --support: 255 110 190;
--link: 80 190 255; --ring: hsl(212.7,26.8%,83.9);
--ring: hsl(212.7, 26.8%, 83.9);
} }
} }

View File

@@ -1,55 +0,0 @@
import { base } from '$app/paths';
import { languages } from '$lib/languages';
import { getURLForLanguage } from '$lib/utils';
export async function handle({ event, resolve }) {
const language = event.params.language ?? 'en';
const strings = await import(`./locales/${language}.json`);
const path = event.url.pathname;
const page = event.route.id?.replace('/[[language]]', '').split('/')[1] ?? 'home';
let title = strings.metadata[`${page}_title`];
const description = strings.metadata[`description`];
if (page === 'help' && event.params.guide) {
const [guide, subguide] = event.params.guide.split('/');
const guideModule = subguide
? await import(`./lib/docs/${language}/${guide}/${subguide}.mdx`)
: await import(`./lib/docs/${language}/${guide}.mdx`);
title = `${title} | ${guideModule.metadata.title}`;
}
const htmlTag = `<html lang="${language}" translate="no">`;
let headTag = `<head>
<title>gpx.studio — ${title}</title>
<meta name="description" content="${description}" />
<meta property="og:title" content="gpx.studio — ${title}" />
<meta property="og:description" content="${description}" />
<meta name="twitter:title" content="gpx.studio — ${title}" />
<meta name="twitter:description" content="${description}" />
<meta property="og:image" content="https://gpx.studio${base}/og_logo.png" />
<meta property="og:url" content="https://gpx.studio/" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="gpx.studio" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="https://gpx.studio${base}/og_logo.png" />
<meta name="twitter:url" content="https://gpx.studio/" />
<meta name="twitter:site" content="@gpxstudio" />
<meta name="twitter:creator" content="@gpxstudio" />
<link rel="alternate" hreflang="x-default" href="https://gpx.studio${getURLForLanguage('en', path)}" />
<link rel="manifest" href="/${language}.manifest.webmanifest" />`;
for (let lang of Object.keys(languages)) {
headTag += ` <link rel="alternate" hreflang="${lang}" href="https://gpx.studio${getURLForLanguage(lang, path)}" />
`;
}
const response = await resolve(event, {
transformPageChunk: ({ html }) =>
html.replace('<html>', htmlTag).replace('<head>', headTag),
});
return response;
}

View File

@@ -1,171 +0,0 @@
export const surfaceColors: { [key: string]: string } = {
missing: '#d1d1d1',
paved: '#8c8c8c',
unpaved: '#6b443a',
asphalt: '#8c8c8c',
concrete: '#8c8c8c',
cobblestone: '#ffd991',
paving_stones: '#8c8c8c',
sett: '#ffd991',
metal: '#8c8c8c',
wood: '#6b443a',
compacted: '#ffffa8',
fine_gravel: '#ffffa8',
gravel: '#ffffa8',
pebblestone: '#ffffa8',
rock: '#ffd991',
dirt: '#ffffa8',
ground: '#6b443a',
earth: '#6b443a',
mud: '#6b443a',
sand: '#ffffc4',
grass: '#61b55c',
grass_paver: '#61b55c',
clay: '#6b443a',
stone: '#ffd991',
};
export function getSurfaceColor(surface: string): string {
return surfaceColors[surface] ? surfaceColors[surface] : surfaceColors.missing;
}
export const highwayColors: { [key: string]: string } = {
missing: '#d1d1d1',
motorway: '#ff4d33',
motorway_link: '#ff4d33',
trunk: '#ff5e4d',
trunk_link: '#ff947f',
primary: '#ff6e5c',
primary_link: '#ff6e5c',
secondary: '#ff8d7b',
secondary_link: '#ff8d7b',
tertiary: '#ffd75f',
tertiary_link: '#ffd75f',
unclassified: '#f1f2a5',
road: '#f1f2a5',
residential: '#73b2ff',
living_street: '#73b2ff',
service: '#9c9cd9',
track: '#a8e381',
footway: '#a8e381',
path: '#a8e381',
pedestrian: '#a8e381',
cycleway: '#9de2ff',
construction: '#e09a4a',
bridleway: '#946f43',
raceway: '#ff0000',
rest_area: '#9c9cd9',
services: '#9c9cd9',
corridor: '#474747',
elevator: '#474747',
steps: '#474747',
bus_stop: '#8545a3',
busway: '#8545a3',
via_ferrata: '#474747',
};
export const sacScaleColors: { [key: string]: string } = {
hiking: '#007700',
mountain_hiking: '#1843ad',
demanding_mountain_hiking: '#ffff00',
alpine_hiking: '#ff9233',
demanding_alpine_hiking: '#ff0000',
difficult_alpine_hiking: '#000000',
};
export const mtbScaleColors: { [key: string]: string } = {
'0-': '#007700',
'0': '#007700',
'0+': '#007700',
'1-': '#1843ad',
'1': '#1843ad',
'1+': '#1843ad',
'2-': '#ffff00',
'2': '#ffff00',
'2+': '#ffff00',
'3': '#ff0000',
'4': '#00ff00',
'5': '#000000',
'6': '#b105eb',
};
function createPattern(
backgroundColor: string,
sacScaleColor: string | undefined,
mtbScaleColor: string | undefined,
size: number = 16,
lineWidth: number = 4
) {
let canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
let ctx = canvas.getContext('2d');
if (ctx) {
ctx.fillStyle = backgroundColor;
ctx.fillRect(0, 0, size, size);
ctx.lineWidth = lineWidth;
const halfSize = size / 2;
const halfLineWidth = lineWidth / 2;
if (sacScaleColor) {
ctx.strokeStyle = sacScaleColor;
ctx.beginPath();
ctx.moveTo(halfSize - halfLineWidth, -halfLineWidth);
ctx.lineTo(size + halfLineWidth, halfSize + halfLineWidth);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(-halfLineWidth, halfSize - halfLineWidth);
ctx.lineTo(halfSize + halfLineWidth, size + halfLineWidth);
ctx.stroke();
}
if (mtbScaleColor) {
ctx.strokeStyle = mtbScaleColor;
ctx.beginPath();
ctx.moveTo(halfSize - halfLineWidth, size + halfLineWidth);
ctx.lineTo(size + halfLineWidth, halfSize - halfLineWidth);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(-halfLineWidth, halfSize + halfLineWidth);
ctx.lineTo(halfSize + halfLineWidth, -halfLineWidth);
ctx.stroke();
}
}
return ctx?.createPattern(canvas, 'repeat') || backgroundColor;
}
const patterns: Record<string, string | CanvasPattern> = {};
export function getHighwayColor(
highway: string,
sacScale: string | undefined,
mtbScale: string | undefined
) {
let backgroundColor = highwayColors[highway] ? highwayColors[highway] : highwayColors.missing;
let sacScaleColor = sacScale ? sacScaleColors[sacScale] : undefined;
let mtbScaleColor = mtbScale ? mtbScaleColors[mtbScale] : undefined;
if (sacScale || mtbScale) {
let patternId = `${backgroundColor}-${[sacScale, mtbScale].filter((x) => x).join('-')}`;
if (!patterns[patternId]) {
patterns[patternId] = createPattern(backgroundColor, sacScaleColor, mtbScaleColor);
}
return patterns[patternId];
}
return backgroundColor;
}
const maxSlope = 20;
export function getSlopeColor(slope: number): string {
if (slope > maxSlope) {
slope = maxSlope;
} else if (slope < -maxSlope) {
slope = -maxSlope;
}
let v = slope / maxSlope;
v = 1 / (1 + Math.exp(-6 * v));
v = v - 0.5;
let hue = ((0.5 - v) * 120).toString(10);
let lightness = 90 - Math.abs(v) * 70;
return `hsl(${hue},70%,${lightness}%)`;
}

View File

@@ -1,864 +0,0 @@
{
"_info": "Taken from https://github.com/mjaschen/gravel-overlay, with prior authorization from the author (https://github.com/gpxstudio/gpx.studio/issues/32#issuecomment-2320219804).",
"version": 8,
"name": "Gravel Overlay",
"metadata": {
"mapbox:autocomposite": false,
"mapbox:type": "template",
"maputnik:renderer": "mbgljs",
"openmaptiles:version": "3.x",
"openmaptiles:mapbox:owner": "openmaptiles",
"openmaptiles:mapbox:source:url": "mapbox://openmaptiles.4qljc88t"
},
"sources": {
"openmaptiles": {
"type": "vector",
"url": "https://tiles.bikerouter.de/services/gravel/"
}
},
"sprite": "https://demotiles.maplibre.org/styles/osm-bright-gl-style/sprite",
"glyphs": "https://api.maptiler.com/fonts/{fontstack}/{range}.pbf?key={key}",
"layers": [
{
"id": "background",
"type": "background",
"layout": {
"visibility": "none"
},
"paint": {
"background-color": "rgba(145, 211, 164, 1)"
}
},
{
"id": "debug_rail",
"type": "line",
"source": "openmaptiles",
"source-layer": "transportation",
"filter": ["all", ["==", "$type", "LineString"], ["in", "class", "rail"]],
"layout": {
"visibility": "none"
},
"paint": {
"line-color": "rgba(144, 144, 144, 1)"
}
},
{
"id": "debug_road",
"type": "line",
"source": "openmaptiles",
"source-layer": "transportation",
"filter": [
"all",
["==", "$type", "LineString"],
[
"in",
"class",
"motorway",
"trunk",
"primary",
"secondary",
"tertiary",
"minor",
"residential",
"track",
"path"
]
],
"layout": {
"visibility": "none"
},
"paint": {
"line-color": "rgba(204, 204, 204, 1)",
"line-width": {
"stops": [
[10, 0.5],
[12, 1]
]
}
}
},
{
"id": "tr_X_g45-bg",
"type": "line",
"source": "openmaptiles",
"source-layer": "transportation",
"minzoom": 10,
"filter": [
"all",
["==", ["geometry-type"], "LineString"],
["match", ["get", "class"], ["track"], true, false],
[
"any",
["match", ["get", "tracktype"], ["grade5"], true, false],
[
"all",
["match", ["get", "tracktype"], ["grade4"], true, false],
["match", ["get", "surface"], ["dirt", "grass", "mud", "sand"], true, false]
]
]
],
"layout": {
"line-cap": "square",
"line-join": "bevel",
"visibility": "visible"
},
"paint": {
"line-color": "rgba(255, 0, 0, 0.7)",
"line-width": {
"base": 1.55,
"stops": [
[10, 0.4],
[12, 1.3],
[14, 1.7]
]
},
"line-dasharray": [1],
"line-offset": {
"stops": [
[12, 0],
[13, 1.8],
[15, 3],
[16, 4]
],
"base": 1.55
}
}
},
{
"id": "tr_X_g45",
"type": "line",
"source": "openmaptiles",
"source-layer": "transportation",
"minzoom": 10,
"filter": [
"all",
["==", ["geometry-type"], "LineString"],
["match", ["get", "class"], ["track"], true, false],
[
"any",
["match", ["get", "tracktype"], ["grade5"], true, false],
[
"all",
["match", ["get", "tracktype"], ["grade4"], true, false],
["match", ["get", "surface"], ["dirt", "grass", "mud", "sand"], true, false]
]
]
],
"layout": {
"line-cap": "square",
"line-join": "bevel",
"visibility": "visible"
},
"paint": {
"line-color": "rgba(255, 255, 0, 0.7)",
"line-width": {
"base": 1.55,
"stops": [
[10, 0.4],
[12, 1.3],
[14, 1.7]
]
},
"line-dasharray": [2, 2],
"line-offset": {
"stops": [
[12, 0],
[13, 1.8],
[15, 3],
[16, 4]
],
"base": 1.55
}
}
},
{
"id": "tr_B_g3",
"type": "line",
"source": "openmaptiles",
"source-layer": "transportation",
"minzoom": 10,
"filter": [
"all",
["==", ["geometry-type"], "LineString"],
["==", ["get", "class"], "track"],
["match", ["get", "tracktype"], ["grade3"], true, false],
[
"any",
["match", ["get", "smoothness"], ["bad", "good", "intermediate"], true, false],
[
"match",
["get", "surface"],
["compacted", "fine_gravel", "gravel"],
true,
false
]
]
],
"layout": {
"line-cap": "butt",
"line-join": "miter",
"visibility": "visible"
},
"paint": {
"line-color": "rgba(235, 6, 158, 1)",
"line-width": {
"base": 1.55,
"stops": [
[10, 0.4],
[12, 1.3],
[14, 1.7]
]
},
"line-offset": {
"stops": [
[12, 0],
[13, 2],
[15, 4],
[16, 5]
],
"base": 1.55
},
"line-dasharray": [3, 1.5]
}
},
{
"id": "tr_A_g2-plain-case",
"type": "line",
"source": "openmaptiles",
"source-layer": "transportation",
"minzoom": 10,
"filter": [
"all",
["==", ["geometry-type"], "LineString"],
["==", ["get", "class"], "track"],
["match", ["get", "tracktype"], ["grade2"], true, false],
["!", ["has", "surface"]],
["!", ["has", "smoothness"]]
],
"layout": {
"line-cap": "butt",
"line-join": "miter",
"visibility": "visible"
},
"paint": {
"line-color": "rgba(255, 255, 255, 0.6)",
"line-width": {
"base": 1.55,
"stops": [
[10, 0.8],
[12, 3],
[14, 4]
]
},
"line-offset": {
"stops": [
[12, 0],
[13, 1.5],
[15, 3],
[16, 4]
],
"base": 1.55
}
}
},
{
"id": "tr_A_g2-plain",
"type": "line",
"source": "openmaptiles",
"source-layer": "transportation",
"minzoom": 10,
"filter": [
"all",
["==", ["geometry-type"], "LineString"],
["==", ["get", "class"], "track"],
["match", ["get", "tracktype"], ["grade2"], true, false],
["!", ["has", "surface"]],
["!", ["has", "smoothness"]]
],
"layout": {
"line-cap": "butt",
"line-join": "miter",
"visibility": "visible"
},
"paint": {
"line-color": "rgba(235, 6, 158, 0.6)",
"line-width": {
"base": 1.55,
"stops": [
[10, 0.5],
[12, 1.5],
[14, 2]
]
},
"line-offset": {
"stops": [
[12, 0],
[13, 1.5],
[15, 3],
[16, 4]
],
"base": 1.55
},
"line-dasharray": [5, 1]
}
},
{
"id": "tr_A_g2-case",
"type": "line",
"source": "openmaptiles",
"source-layer": "transportation",
"minzoom": 10,
"filter": [
"all",
["==", ["geometry-type"], "LineString"],
["==", ["get", "class"], "track"],
["match", ["get", "tracktype"], ["grade2"], true, false],
[
"any",
[
"match",
["get", "surface"],
["compacted", "fine_gravel", "gravel"],
true,
false
],
["match", ["get", "smoothness"], ["bad", "good", "intermediate"], true, false]
]
],
"layout": {
"line-cap": "butt",
"line-join": "miter",
"visibility": "visible"
},
"paint": {
"line-color": "rgba(255, 255, 255, 1)",
"line-width": {
"base": 1.55,
"stops": [
[10, 0.8],
[12, 3],
[14, 4]
]
},
"line-offset": {
"stops": [
[12, 0],
[13, 2],
[15, 4],
[16, 5]
],
"base": 1.55
}
}
},
{
"id": "tr_A_g2",
"type": "line",
"source": "openmaptiles",
"source-layer": "transportation",
"minzoom": 10,
"filter": [
"all",
["==", ["geometry-type"], "LineString"],
["==", ["get", "class"], "track"],
["match", ["get", "tracktype"], ["grade2"], true, false],
[
"any",
[
"match",
["get", "surface"],
["compacted", "fine_gravel", "gravel"],
true,
false
],
["match", ["get", "smoothness"], ["bad", "good", "intermediate"], true, false]
]
],
"layout": {
"line-cap": "butt",
"line-join": "miter",
"visibility": "visible"
},
"paint": {
"line-color": "rgba(235, 6, 158, 1)",
"line-width": {
"base": 1.55,
"stops": [
[10, 0.5],
[12, 1.5],
[14, 2]
]
},
"line-offset": {
"stops": [
[12, 0],
[13, 2],
[15, 4],
[16, 5]
],
"base": 1.55
}
}
},
{
"id": "p_X-bg",
"type": "line",
"source": "openmaptiles",
"source-layer": "transportation",
"minzoom": 10,
"filter": [
"all",
["==", "$type", "LineString"],
["in", "class", "path"],
["in", "smoothness", "very_bad", "horrible", "very_horrible", "impassable"],
["!in", "tracktype", "grade5", "grade4"]
],
"layout": {
"line-cap": "square",
"line-join": "bevel",
"visibility": "visible"
},
"paint": {
"line-color": "rgba(255, 0, 0, 0.7)",
"line-width": {
"base": 1.55,
"stops": [
[10, 0.4],
[12, 1.1],
[14, 1.5]
]
},
"line-offset": {
"stops": [
[12, 0],
[13, 1.8],
[15, 3],
[16, 4]
],
"base": 1.55
}
}
},
{
"id": "p_X",
"type": "line",
"source": "openmaptiles",
"source-layer": "transportation",
"minzoom": 10,
"filter": [
"all",
["==", "$type", "LineString"],
["in", "class", "path"],
["in", "smoothness", "very_bad", "horrible", "very_horrible", "impassable"],
["!in", "tracktype", "grade5", "grade4"]
],
"layout": {
"line-cap": "square",
"line-join": "bevel",
"visibility": "visible"
},
"paint": {
"line-color": "rgba(255, 255, 0, 0.7)",
"line-width": {
"base": 1.55,
"stops": [
[10, 0.4],
[12, 1.1],
[14, 1.5]
]
},
"line-dasharray": [2, 2],
"line-offset": {
"stops": [
[12, 0],
[13, 1.8],
[15, 3],
[16, 4]
],
"base": 1.55
}
}
},
{
"id": "p_B",
"type": "line",
"source": "openmaptiles",
"source-layer": "transportation",
"minzoom": 10,
"filter": [
"all",
["==", "$type", "LineString"],
["==", "class", "path"],
["in", "smoothness", "good", "intermediate", "bad"],
[
"!in",
"surface",
"gravel",
"fine_gravel",
"compacted",
"cobblestone",
"sett",
"unhewn_cobblestone",
"paving_stones"
],
["!in", "bicycle", "no"],
["!in", "access", "no"]
],
"layout": {
"line-cap": "butt",
"line-join": "miter",
"visibility": "visible"
},
"paint": {
"line-color": "rgba(235, 6, 158, 1)",
"line-width": {
"base": 1.55,
"stops": [
[10, 0.4],
[12, 1.1],
[14, 1.5]
]
},
"line-offset": {
"stops": [
[12, 0],
[13, 2],
[15, 4],
[16, 5]
],
"base": 1.55
},
"line-dasharray": [1.5, 1]
}
},
{
"id": "p_A-case",
"type": "line",
"metadata": {
"maputnik:comment": "Gravel surface with ok-ish smoothness"
},
"source": "openmaptiles",
"source-layer": "transportation",
"minzoom": 10,
"filter": [
"all",
["==", ["geometry-type"], "LineString"],
["==", ["get", "class"], "path"],
[
"any",
["match", ["get", "surface"], ["compacted", "fine_gravel"], true, false],
[
"all",
["match", ["get", "surface"], ["gravel"], true, false],
[
"match",
["get", "smoothness"],
["bad", "good", "intermediate"],
true,
false
]
]
],
["match", ["get", "bicycle"], ["no"], false, true],
["match", ["get", "access"], ["no"], false, true]
],
"layout": {
"line-cap": "butt",
"line-join": "miter",
"visibility": "visible"
},
"paint": {
"line-color": "rgba(255, 255, 255, 1)",
"line-width": {
"base": 1.55,
"stops": [
[10, 0.7],
[12, 2.5],
[14, 3.2]
]
},
"line-offset": {
"stops": [
[12, 0],
[13, 2],
[15, 4],
[16, 5]
],
"base": 1.55
}
}
},
{
"id": "p_A",
"type": "line",
"metadata": {
"maputnik:comment": "Gravel surface with ok-ish smoothness"
},
"source": "openmaptiles",
"source-layer": "transportation",
"minzoom": 10,
"filter": [
"all",
["==", ["geometry-type"], "LineString"],
["==", ["get", "class"], "path"],
[
"any",
["match", ["get", "surface"], ["compacted", "fine_gravel"], true, false],
[
"all",
["match", ["get", "surface"], ["gravel"], true, false],
[
"match",
["get", "smoothness"],
["bad", "good", "intermediate"],
true,
false
]
]
],
["match", ["get", "bicycle"], ["no"], false, true],
["match", ["get", "access"], ["no"], false, true]
],
"layout": {
"line-cap": "butt",
"line-join": "miter",
"visibility": "visible"
},
"paint": {
"line-color": "rgba(235, 6, 158, 1)",
"line-width": {
"base": 1.55,
"stops": [
[10, 0.5],
[12, 1.5],
[14, 2]
]
},
"line-offset": {
"stops": [
[12, 0],
[13, 2],
[15, 4],
[16, 5]
],
"base": 1.55
}
}
},
{
"id": "r_X_cobbles-case",
"type": "line",
"source": "openmaptiles",
"source-layer": "transportation",
"minzoom": 10,
"filter": [
"all",
["==", "$type", "LineString"],
["in", "class", "minor", "service", "track", "path", "residential"],
["in", "surface", "sett", "cobblestone", "unhewn_cobblestone"],
["!in", "service", "driveway"]
],
"layout": {
"line-cap": "butt",
"line-join": "miter",
"visibility": "visible"
},
"paint": {
"line-color": "rgba(0, 0, 0, 1)",
"line-width": {
"base": 1.55,
"stops": [
[10, 0.5],
[12, 1.5],
[14, 2]
]
}
}
},
{
"id": "r_X_cobbles",
"type": "line",
"source": "openmaptiles",
"source-layer": "transportation",
"minzoom": 10,
"filter": [
"all",
["==", "$type", "LineString"],
["in", "class", "minor", "service", "track", "path", "residential"],
["in", "surface", "sett", "cobblestone", "unhewn_cobblestone"],
["!in", "service", "driveway"]
],
"layout": {
"line-cap": "butt",
"line-join": "miter",
"visibility": "visible"
},
"paint": {
"line-color": "rgba(245, 255, 0, 1)",
"line-width": {
"base": 1.55,
"stops": [
[10, 0.5],
[12, 1.5],
[14, 2]
]
},
"line-dasharray": [1.5, 1]
}
},
{
"id": "r_X-bg",
"type": "line",
"source": "openmaptiles",
"source-layer": "transportation",
"minzoom": 10,
"filter": [
"all",
["==", "$type", "LineString"],
["in", "class", "minor"],
["in", "smoothness", "very_bad", "horrible", "very_horrible", "impassable"],
["!in", "surface", "sett", "cobblestone", "unhewn_cobblestone"]
],
"layout": {
"line-cap": "square",
"line-join": "bevel",
"visibility": "visible"
},
"paint": {
"line-color": "rgba(255, 0, 0, 0.7)",
"line-width": {
"base": 1.55,
"stops": [
[10, 0.4],
[12, 1.1],
[14, 1.5]
]
}
}
},
{
"id": "r_X",
"type": "line",
"source": "openmaptiles",
"source-layer": "transportation",
"minzoom": 10,
"filter": [
"all",
["==", "$type", "LineString"],
["in", "class", "minor"],
["in", "smoothness", "very_bad", "horrible", "very_horrible", "impassable"],
["!in", "surface", "sett", "cobblestone", "unhewn_cobblestone"]
],
"layout": {
"line-cap": "square",
"line-join": "bevel",
"visibility": "visible"
},
"paint": {
"line-color": "rgba(255, 255, 0, 0.7)",
"line-width": {
"base": 1.55,
"stops": [
[10, 0.4],
[12, 1.1],
[14, 1.5]
]
},
"line-dasharray": [2, 2]
}
},
{
"id": "r_A_case",
"type": "line",
"source": "openmaptiles",
"source-layer": "transportation",
"minzoom": 10,
"filter": [
"all",
["==", "$type", "LineString"],
["in", "class", "minor", "residential", "service"],
["in", "surface", "gravel", "compacted", "fine_gravel"],
["!in", "service", "driveway", "parking_aisle", "drive-through", "emergency_access"]
],
"layout": {
"line-cap": "square",
"line-join": "bevel",
"visibility": "visible"
},
"paint": {
"line-color": "rgba(255, 255, 255, 1)",
"line-width": {
"base": 1.55,
"stops": [
[10, 0.8],
[12, 3],
[14, 4]
]
}
}
},
{
"id": "r_A",
"type": "line",
"source": "openmaptiles",
"source-layer": "transportation",
"minzoom": 10,
"filter": [
"all",
["==", "$type", "LineString"],
["in", "class", "minor", "residential", "service"],
["in", "surface", "gravel", "compacted", "fine_gravel"],
["!in", "service", "driveway", "parking_aisle", "drive-through", "emergency_access"]
],
"layout": {
"line-cap": "square",
"line-join": "bevel",
"visibility": "visible"
},
"paint": {
"line-color": "rgba(235, 6, 158, 1)",
"line-width": {
"base": 1.55,
"stops": [
[10, 0.5],
[12, 1.5],
[14, 2]
]
}
}
},
{
"id": "cemetery",
"type": "symbol",
"source": "openmaptiles",
"source-layer": "landuse",
"filter": ["all", ["==", "class", "cemetery"]],
"layout": {
"icon-image": "cemetery_11",
"icon-rotation-alignment": "map",
"icon-size": 1.5
}
},
{
"id": "drinking_water",
"type": "symbol",
"source": "openmaptiles",
"source-layer": "poi",
"minzoom": 9,
"maxzoom": 20,
"filter": [
"any",
["==", "class", "drinking_water"],
["==", "subclass", "drinking_water"]
],
"layout": {
"icon-image": "drinking_water_11",
"visibility": "visible",
"icon-rotation-alignment": "map",
"icon-size": 1.4
}
}
],
"id": "basic",
"owner": "Marcus Jaschen"
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
export const surfaceColors: { [key: string]: string } = {
'missing': '#d1d1d1',
'paved': '#8c8c8c',
'unpaved': '#6b443a',
'asphalt': '#8c8c8c',
'concrete': '#8c8c8c',
'chipseal': '#8c8c8c',
'cobblestone': '#ffd991',
'unhewn_cobblestone': '#ffd991',
'paving_stones': '#8c8c8c',
'stepping_stones': '#c7b2db',
'sett': '#ffd991',
'metal': '#8c8c8c',
'wood': '#6b443a',
'compacted': '#ffffa8',
'fine_gravel': '#ffffa8',
'gravel': '#ffffa8',
'pebblestone': '#ffffa8',
'rock': '#ffd991',
'dirt': '#ffffa8',
'ground': '#6b443a',
'earth': '#6b443a',
'snow': '#bdfffc',
'ice': '#bdfffc',
'salt': '#b6c0f2',
'mud': '#6b443a',
'sand': '#ffffc4',
'woodchips': '#6b443a',
'grass': '#61b55c',
'grass_paver': '#61b55c'
}

View File

@@ -1,67 +1,6 @@
import { import { Landmark, Icon, Shell, Bike, Building, Tent, Car, Wrench, ShoppingBasket, Droplet, DoorOpen, Trees, Fuel, Home, Info, TreeDeciduous, CircleParking, Cross, Utensils, Construction, BrickWall, ShowerHead, Mountain, Phone, TrainFront, Bed, Binoculars, TriangleAlert, Anchor } from "lucide-svelte";
Landmark, import { Landmark as LandmarkSvg, Shell as ShellSvg, Bike as BikeSvg, Building as BuildingSvg, Tent as TentSvg, Car as CarSvg, Wrench as WrenchSvg, ShoppingBasket as ShoppingBasketSvg, Droplet as DropletSvg, DoorOpen as DoorOpenSvg, Trees as TreesSvg, Fuel as FuelSvg, Home as HomeSvg, Info as InfoSvg, TreeDeciduous as TreeDeciduousSvg, CircleParking as CircleParkingSvg, Cross as CrossSvg, Utensils as UtensilsSvg, Construction as ConstructionSvg, BrickWall as BrickWallSvg, ShowerHead as ShowerHeadSvg, Mountain as MountainSvg, Phone as PhoneSvg, TrainFront as TrainFrontSvg, Bed as BedSvg, Binoculars as BinocularsSvg, TriangleAlert as TriangleAlertSvg, Anchor as AnchorSvg } from "lucide-static";
Icon, import type { ComponentType } from "svelte";
Shell,
Bike,
Building,
Tent,
Car,
Wrench,
ShoppingBasket,
Droplet,
DoorOpen,
Trees,
Fuel,
Home,
Info,
TreeDeciduous,
CircleParking,
Cross,
Utensils,
Construction,
BrickWall,
ShowerHead,
Mountain,
Phone,
TrainFront,
Bed,
Binoculars,
TriangleAlert,
Anchor,
Toilet,
} from 'lucide-svelte';
import {
Landmark as LandmarkSvg,
Shell as ShellSvg,
Bike as BikeSvg,
Building as BuildingSvg,
Tent as TentSvg,
Car as CarSvg,
Wrench as WrenchSvg,
ShoppingBasket as ShoppingBasketSvg,
Droplet as DropletSvg,
DoorOpen as DoorOpenSvg,
Trees as TreesSvg,
Fuel as FuelSvg,
Home as HomeSvg,
Info as InfoSvg,
TreeDeciduous as TreeDeciduousSvg,
CircleParking as CircleParkingSvg,
Cross as CrossSvg,
Utensils as UtensilsSvg,
Construction as ConstructionSvg,
BrickWall as BrickWallSvg,
ShowerHead as ShowerHeadSvg,
Mountain as MountainSvg,
Phone as PhoneSvg,
TrainFront as TrainFrontSvg,
Bed as BedSvg,
Binoculars as BinocularsSvg,
TriangleAlert as TriangleAlertSvg,
Anchor as AnchorSvg,
Toilet as ToiletSvg,
} from 'lucide-static';
import type { ComponentType } from 'svelte';
export type Symbol = { export type Symbol = {
value: string; value: string;
@@ -81,28 +20,16 @@ export const symbols: { [key: string]: Symbol } = {
campground: { value: 'Campground', icon: Tent, iconSvg: TentSvg }, campground: { value: 'Campground', icon: Tent, iconSvg: TentSvg },
car: { value: 'Car', icon: Car, iconSvg: CarSvg }, car: { value: 'Car', icon: Car, iconSvg: CarSvg },
car_repair: { value: 'Car Repair', icon: Wrench, iconSvg: WrenchSvg }, car_repair: { value: 'Car Repair', icon: Wrench, iconSvg: WrenchSvg },
convenience_store: { convenience_store: { value: 'Convenience Store', icon: ShoppingBasket, iconSvg: ShoppingBasketSvg },
value: 'Convenience Store',
icon: ShoppingBasket,
iconSvg: ShoppingBasketSvg,
},
crossing: { value: 'Crossing' }, crossing: { value: 'Crossing' },
department_store: { department_store: { value: 'Department Store', icon: ShoppingBasket, iconSvg: ShoppingBasketSvg },
value: 'Department Store',
icon: ShoppingBasket,
iconSvg: ShoppingBasketSvg,
},
drinking_water: { value: 'Drinking Water', icon: Droplet, iconSvg: DropletSvg }, drinking_water: { value: 'Drinking Water', icon: Droplet, iconSvg: DropletSvg },
exit: { value: 'Exit', icon: DoorOpen, iconSvg: DoorOpenSvg }, exit: { value: 'Exit', icon: DoorOpen, iconSvg: DoorOpenSvg },
lodge: { value: 'Lodge', icon: Home, iconSvg: HomeSvg }, lodge: { value: 'Lodge', icon: Home, iconSvg: HomeSvg },
lodging: { value: 'Lodging', icon: Bed, iconSvg: BedSvg }, lodging: { value: 'Lodging', icon: Bed, iconSvg: BedSvg },
forest: { value: 'Forest', icon: Trees, iconSvg: TreesSvg }, forest: { value: 'Forest', icon: Trees, iconSvg: TreesSvg },
gas_station: { value: 'Gas Station', icon: Fuel, iconSvg: FuelSvg }, gas_station: { value: 'Gas Station', icon: Fuel, iconSvg: FuelSvg },
ground_transportation: { ground_transportation: { value: 'Ground Transportation', icon: TrainFront, iconSvg: TrainFrontSvg },
value: 'Ground Transportation',
icon: TrainFront,
iconSvg: TrainFrontSvg,
},
hotel: { value: 'Hotel', icon: Bed, iconSvg: BedSvg }, hotel: { value: 'Hotel', icon: Bed, iconSvg: BedSvg },
house: { value: 'House', icon: Home, iconSvg: HomeSvg }, house: { value: 'House', icon: Home, iconSvg: HomeSvg },
information: { value: 'Information', icon: Info, iconSvg: InfoSvg }, information: { value: 'Information', icon: Info, iconSvg: InfoSvg },
@@ -112,7 +39,7 @@ export const symbols: { [key: string]: Symbol } = {
picnic_area: { value: 'Picnic Area', icon: Utensils, iconSvg: UtensilsSvg }, picnic_area: { value: 'Picnic Area', icon: Utensils, iconSvg: UtensilsSvg },
restaurant: { value: 'Restaurant', icon: Utensils, iconSvg: UtensilsSvg }, restaurant: { value: 'Restaurant', icon: Utensils, iconSvg: UtensilsSvg },
restricted_area: { value: 'Restricted Area', icon: Construction, iconSvg: ConstructionSvg }, restricted_area: { value: 'Restricted Area', icon: Construction, iconSvg: ConstructionSvg },
restroom: { value: 'Restroom', icon: Toilet, iconSvg: ToiletSvg }, restroom: { value: 'Restroom' },
road: { value: 'Road', icon: BrickWall, iconSvg: BrickWallSvg }, road: { value: 'Road', icon: BrickWall, iconSvg: BrickWallSvg },
scenic_area: { value: 'Scenic Area', icon: Binoculars, iconSvg: BinocularsSvg }, scenic_area: { value: 'Scenic Area', icon: Binoculars, iconSvg: BinocularsSvg },
shelter: { value: 'Shelter', icon: Tent, iconSvg: TentSvg }, shelter: { value: 'Shelter', icon: Tent, iconSvg: TentSvg },
@@ -128,6 +55,6 @@ export function getSymbolKey(value: string | undefined): string | undefined {
if (value === undefined) { if (value === undefined) {
return undefined; return undefined;
} else { } else {
return Object.keys(symbols).find((key) => symbols[key].value === value); return Object.keys(symbols).find(key => symbols[key].value === value);
} }
} }

View File

@@ -1,60 +0,0 @@
<script lang="ts">
import docsearch from '@docsearch/js';
import '@docsearch/css';
import { onMount } from 'svelte';
import { _, locale, waitLocale } from 'svelte-i18n';
let mounted = false;
function initDocsearch() {
docsearch({
appId: '21XLD94PE3',
apiKey: 'd2c1ed6cb0ed12adb2bd84eb2a38494d',
indexName: 'gpx',
container: '#docsearch',
searchParameters: {
facetFilters: ['lang:' + ($locale ?? 'en')],
},
placeholder: $_('docs.search.search'),
disableUserPersonalization: true,
translations: {
button: {
buttonText: $_('docs.search.search'),
buttonAriaLabel: $_('docs.search.search'),
},
modal: {
searchBox: {
resetButtonTitle: $_('docs.search.clear'),
resetButtonAriaLabel: $_('docs.search.clear'),
cancelButtonText: $_('docs.search.cancel'),
cancelButtonAriaLabel: $_('docs.search.cancel'),
searchInputLabel: $_('docs.search.search'),
},
footer: {
selectText: $_('docs.search.to_select'),
navigateText: $_('docs.search.to_navigate'),
closeText: $_('docs.search.to_close'),
},
noResultsScreen: {
noResultsText: $_('docs.search.no_results'),
suggestedQueryText: $_('docs.search.no_results_suggestion'),
},
},
},
});
}
onMount(() => {
mounted = true;
});
$: if (mounted && $locale) {
waitLocale().then(initDocsearch);
}
</script>
<svelte:head>
<link rel="preconnect" href="https://21XLD94PE3-dsn.algolia.net" crossorigin />
</svelte:head>
<div id="docsearch" {...$$restProps}></div>

View File

@@ -1,28 +0,0 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button/index.js';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import type { Builder } from 'bits-ui';
export let variant:
| 'default'
| 'secondary'
| 'link'
| 'destructive'
| 'outline'
| 'ghost'
| undefined = 'default';
export let label: string;
export let side: 'top' | 'right' | 'bottom' | 'left' = 'top';
export let builders: Builder[] = [];
</script>
<Tooltip.Root>
<Tooltip.Trigger asChild let:builder>
<Button builders={[...builders, builder]} {variant} {...$$restProps} on:click>
<slot />
</Button>
</Tooltip.Trigger>
<Tooltip.Content {side}>
<span>{label}</span>
</Tooltip.Content>
</Tooltip.Root>

View File

@@ -1,18 +0,0 @@
<script lang="ts">
import { map } from '$lib/stores';
import { trackpointPopup } from '$lib/components/gpx-layer/GPXLayerPopup';
import { TrackPoint } from 'gpx';
$: if ($map) {
$map.on('contextmenu', (e) => {
trackpointPopup?.setItem({
item: new TrackPoint({
attributes: {
lat: e.lngLat.lat,
lon: e.lngLat.lng,
},
}),
});
});
}
</script>

View File

@@ -1,10 +1,9 @@
<script lang="ts"> <script lang="ts">
import ButtonWithTooltip from '$lib/components/ButtonWithTooltip.svelte';
import * as Popover from '$lib/components/ui/popover';
import * as ToggleGroup from '$lib/components/ui/toggle-group'; import * as ToggleGroup from '$lib/components/ui/toggle-group';
import Tooltip from '$lib/components/Tooltip.svelte';
import Chart from 'chart.js/auto'; import Chart from 'chart.js/auto';
import mapboxgl from 'mapbox-gl'; import mapboxgl from 'mapbox-gl';
import { map } from '$lib/stores'; import { hoveredTrackPoint, map } from '$lib/stores';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { import {
BrickWall, BrickWall,
@@ -13,15 +12,12 @@
Orbit, Orbit,
SquareActivity, SquareActivity,
Thermometer, Thermometer,
Zap, Zap
Circle,
Check,
ChartNoAxesColumn,
Construction,
} from 'lucide-svelte'; } from 'lucide-svelte';
import { getSlopeColor, getSurfaceColor, getHighwayColor } from '$lib/assets/colors'; import { surfaceColors } from '$lib/assets/surfaces';
import { _ } from 'svelte-i18n'; import { _, locale } from 'svelte-i18n';
import { import {
getCadenceUnits,
getCadenceWithUnits, getCadenceWithUnits,
getConvertedDistance, getConvertedDistance,
getConvertedElevation, getConvertedElevation,
@@ -30,26 +26,45 @@
getDistanceUnits, getDistanceUnits,
getDistanceWithUnits, getDistanceWithUnits,
getElevationWithUnits, getElevationWithUnits,
getHeartRateUnits,
getHeartRateWithUnits, getHeartRateWithUnits,
getPowerUnits,
getPowerWithUnits, getPowerWithUnits,
getTemperatureUnits,
getTemperatureWithUnits, getTemperatureWithUnits,
getVelocityUnits,
getVelocityWithUnits, getVelocityWithUnits,
secondsToHHMMSS
} from '$lib/units'; } from '$lib/units';
import type { Writable } from 'svelte/store'; import type { Writable } from 'svelte/store';
import { DateFormatter } from '@internationalized/date';
import type { GPXStatistics } from 'gpx'; import type { GPXStatistics } from 'gpx';
import { settings } from '$lib/db'; import { settings } from '$lib/db';
import { mode } from 'mode-watcher'; import { mode } from 'mode-watcher';
import { df } from '$lib/utils';
export let gpxStatistics: Writable<GPXStatistics>; export let gpxStatistics: Writable<GPXStatistics>;
export let slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>; export let slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>;
export let panelSize: number;
export let additionalDatasets: string[]; export let additionalDatasets: string[];
export let elevationFill: 'slope' | 'surface' | 'highway' | undefined; export let elevationFill: 'slope' | 'surface' | undefined;
export let showControls: boolean = true; export let showControls: boolean = true;
const { distanceUnits, velocityUnits, temperatureUnits } = settings; const { distanceUnits, velocityUnits, temperatureUnits } = settings;
let df: DateFormatter;
$: if ($locale) {
df = new DateFormatter($locale, {
dateStyle: 'medium',
timeStyle: 'medium'
});
}
let canvas: HTMLCanvasElement; let canvas: HTMLCanvasElement;
let showAdditionalScales = true;
let updateShowAdditionalScales = () => {
showAdditionalScales = canvas.width / window.devicePixelRatio >= 600;
};
let overlay: HTMLCanvasElement; let overlay: HTMLCanvasElement;
let chart: Chart; let chart: Chart;
@@ -68,41 +83,41 @@
x: { x: {
type: 'linear', type: 'linear',
ticks: { ticks: {
callback: function (value: number) { callback: function (value: number, index: number, ticks: { value: number }[]) {
if (index === ticks.length - 1) {
return `${value.toFixed(1).replace(/\.0+$/, '')}`;
}
return `${value.toFixed(1).replace(/\.0+$/, '')} ${getDistanceUnits()}`; return `${value.toFixed(1).replace(/\.0+$/, '')} ${getDistanceUnits()}`;
}, }
align: 'inner', }
maxRotation: 0,
},
}, },
y: { y: {
type: 'linear', type: 'linear',
ticks: { ticks: {
callback: function (value: number) { callback: function (value: number) {
return getElevationWithUnits(value, false); return getElevationWithUnits(value, false);
}, }
}, }
}, }
}, },
datasets: { datasets: {
line: { line: {
pointRadius: 0, pointRadius: 0,
tension: 0.4, tension: 0.4,
borderWidth: 2, borderWidth: 2
cubicInterpolationMode: 'monotone', }
},
}, },
interaction: { interaction: {
mode: 'nearest', mode: 'nearest',
axis: 'x', axis: 'x',
intersect: false, intersect: false
}, },
plugins: { plugins: {
legend: { legend: {
display: false, display: false
}, },
decimation: { decimation: {
enabled: true, enabled: true
}, },
tooltip: { tooltip: {
enabled: () => !dragging && !panning, enabled: () => !dragging && !panning,
@@ -112,7 +127,7 @@
}, },
label: function (context: Chart.TooltipContext) { label: function (context: Chart.TooltipContext) {
let point = context.raw; let point = context.raw;
if (context.datasetIndex === 0) { if (context.datasetIndex === 0 && $hoveredTrackPoint === undefined) {
if ($map && marker) { if ($map && marker) {
if (dragging) { if (dragging) {
marker.remove(); marker.remove();
@@ -141,20 +156,13 @@
let slope = { let slope = {
at: point.slope.at.toFixed(1), at: point.slope.at.toFixed(1),
segment: point.slope.segment.toFixed(1), segment: point.slope.segment.toFixed(1),
length: getDistanceWithUnits(point.slope.length), length: getDistanceWithUnits(point.slope.length)
}; };
let surface = point.extensions.surface let surface = point.surface ? point.surface : 'unknown';
? point.extensions.surface
: 'unknown';
let highway = point.extensions.highway
? point.extensions.highway
: 'unknown';
let sacScale = point.extensions.sac_scale;
let mtbScale = point.extensions.mtb_scale;
let labels = [ let labels = [
` ${$_('quantities.distance')}: ${getDistanceWithUnits(point.x, false)}`, ` ${$_('quantities.distance')}: ${getDistanceWithUnits(point.x, false)}`,
` ${$_('quantities.slope')}: ${slope.at} %${elevationFill === 'slope' ? ` (${slope.length} @${slope.segment} %)` : ''}`, ` ${$_('quantities.slope')}: ${slope.at} %${elevationFill === 'slope' ? ` (${slope.length} @${slope.segment} %)` : ''}`
]; ];
if (elevationFill === 'surface') { if (elevationFill === 'surface') {
@@ -163,26 +171,13 @@
); );
} }
if (elevationFill === 'highway') {
labels.push(
` ${$_('quantities.highway')}: ${$_(`toolbar.routing.highway.${highway}`)}${
sacScale
? ` (${$_(`toolbar.routing.sac_scale.${sacScale}`)})`
: ''
}`
);
if (mtbScale) {
labels.push(` ${$_('toolbar.routing.mtb_scale')}: ${mtbScale}`);
}
}
if (point.time) { if (point.time) {
labels.push(` ${$_('quantities.time')}: ${df.format(point.time)}`); labels.push(` ${$_('quantities.time')}: ${df.format(point.time)}`);
} }
return labels; return labels;
}, }
}, }
}, },
zoom: { zoom: {
pan: { pan: {
@@ -196,19 +191,18 @@
}, },
onPanComplete: function () { onPanComplete: function () {
panning = false; panning = false;
}, }
}, },
zoom: { zoom: {
wheel: { wheel: {
enabled: true, enabled: true
}, },
mode: 'x', mode: 'x',
onZoomStart: function ({ chart, event }: { chart: Chart; event: any }) { onZoomStart: function ({ chart, event }: { chart: Chart; event: any }) {
if ( if (
event.deltaY < 0 && event.deltaY < 0 &&
Math.abs( Math.abs(
chart.getInitialScaleBounds().x.max / chart.getInitialScaleBounds().x.max / chart.options.plugins.zoom.limits.x.minRange -
chart.options.plugins.zoom.limits.x.minRange -
chart.getZoomLevel() chart.getZoomLevel()
) < 0.01 ) < 0.01
) { ) {
@@ -217,35 +211,86 @@
} }
$slicedGPXStatistics = undefined; $slicedGPXStatistics = undefined;
}, }
}, },
limits: { limits: {
x: { x: {
min: 'original', min: 'original',
max: 'original', max: 'original',
minRange: 1, minRange: 1
}, }
}, }
}, }
}, },
stacked: false, stacked: false,
onResize: function () { onResize: function () {
updateOverlay(); updateOverlay();
}, updateShowAdditionalScales();
}
}; };
let datasets: string[] = ['speed', 'hr', 'cad', 'atemp', 'power']; let datasets: {
datasets.forEach((id) => { [key: string]: {
id: string;
getLabel: () => string;
getUnits: () => string;
};
} = {
speed: {
id: 'speed',
getLabel: () => ($velocityUnits === 'speed' ? $_('quantities.speed') : $_('quantities.pace')),
getUnits: () => getVelocityUnits()
},
hr: {
id: 'hr',
getLabel: () => $_('quantities.heartrate'),
getUnits: () => getHeartRateUnits()
},
cad: {
id: 'cad',
getLabel: () => $_('quantities.cadence'),
getUnits: () => getCadenceUnits()
},
atemp: {
id: 'atemp',
getLabel: () => $_('quantities.temperature'),
getUnits: () => getTemperatureUnits()
},
power: {
id: 'power',
getLabel: () => $_('quantities.power'),
getUnits: () => getPowerUnits()
}
};
for (let [id, dataset] of Object.entries(datasets)) {
options.scales[`y${id}`] = { options.scales[`y${id}`] = {
type: 'linear', type: 'linear',
position: 'right', position: 'right',
title: {
display: true,
text: dataset.getLabel() + ' (' + dataset.getUnits() + ')',
padding: {
top: 6,
bottom: 0
}
},
grid: { grid: {
display: false, display: false
}, },
reverse: () => id === 'speed' && $velocityUnits === 'pace', reverse: () => id === 'speed' && $velocityUnits === 'pace',
display: false, display: false
};
}
options.scales.yspeed['ticks'] = {
callback: function (value: number) {
if ($velocityUnits === 'speed') {
return value;
} else {
return secondsToHHMMSS(value);
}
}
}; };
});
onMount(async () => { onMount(async () => {
Chart.register((await import('chartjs-plugin-zoom')).default); // dynamic import to avoid SSR and 'window is not defined' error Chart.register((await import('chartjs-plugin-zoom')).default); // dynamic import to avoid SSR and 'window is not defined' error
@@ -253,7 +298,7 @@
chart = new Chart(canvas, { chart = new Chart(canvas, {
type: 'line', type: 'line',
data: { data: {
datasets: [], datasets: []
}, },
options, options,
plugins: [ plugins: [
@@ -266,18 +311,20 @@
marker.remove(); marker.remove();
} }
} }
}, }
}, }
], ]
}); });
// Map marker to show on hover // Map marker to show on hover
let element = document.createElement('div'); let element = document.createElement('div');
element.className = 'h-4 w-4 rounded-full bg-cyan-500 border-2 border-white'; element.className = 'h-4 w-4 rounded-full bg-cyan-500 border-2 border-white';
marker = new mapboxgl.Marker({ marker = new mapboxgl.Marker({
element, element
}); });
updateShowAdditionalScales();
let startIndex = 0; let startIndex = 0;
let endIndex = 0; let endIndex = 0;
function getIndex(evt) { function getIndex(evt) {
@@ -285,7 +332,7 @@
evt, evt,
'x', 'x',
{ {
intersect: false, intersect: false
}, },
true true
); );
@@ -328,12 +375,9 @@
startIndex = endIndex; startIndex = endIndex;
} else if (startIndex !== endIndex) { } else if (startIndex !== endIndex) {
$slicedGPXStatistics = [ $slicedGPXStatistics = [
$gpxStatistics.slice( $gpxStatistics.slice(Math.min(startIndex, endIndex), Math.max(startIndex, endIndex)),
Math.min(startIndex, endIndex), Math.min(startIndex, endIndex),
Math.max(startIndex, endIndex) Math.max(startIndex, endIndex)
),
Math.min(startIndex, endIndex),
Math.max(startIndex, endIndex),
]; ];
} }
} }
@@ -367,111 +411,126 @@
slope: { slope: {
at: data.local.slope.at[index], at: data.local.slope.at[index],
segment: data.local.slope.segment[index], segment: data.local.slope.segment[index],
length: data.local.slope.length[index], length: data.local.slope.length[index]
}, },
extensions: point.getExtensions(), surface: point.getSurface(),
coordinates: point.getCoordinates(), coordinates: point.getCoordinates(),
index: index, index: index
}; };
}), }),
normalized: true, normalized: true,
fill: 'start', fill: 'start',
order: 1, order: 1
}; };
chart.data.datasets[1] = { chart.data.datasets[1] = {
label: datasets.speed.getLabel(),
data: data.local.points.map((point, index) => { data: data.local.points.map((point, index) => {
return { return {
x: getConvertedDistance(data.local.distance.total[index]), x: getConvertedDistance(data.local.distance.total[index]),
y: getConvertedVelocity(data.local.speed[index]), y: getConvertedVelocity(data.local.speed[index]),
index: index, index: index
}; };
}), }),
normalized: true, normalized: true,
yAxisID: 'yspeed', yAxisID: `y${datasets.speed.id}`,
hidden: true, hidden: true
}; };
chart.data.datasets[2] = { chart.data.datasets[2] = {
label: datasets.hr.getLabel(),
data: data.local.points.map((point, index) => { data: data.local.points.map((point, index) => {
return { return {
x: getConvertedDistance(data.local.distance.total[index]), x: getConvertedDistance(data.local.distance.total[index]),
y: point.getHeartRate(), y: point.getHeartRate(),
index: index, index: index
}; };
}), }),
normalized: true, normalized: true,
yAxisID: 'yhr', yAxisID: `y${datasets.hr.id}`,
hidden: true, hidden: true
}; };
chart.data.datasets[3] = { chart.data.datasets[3] = {
label: datasets.cad.getLabel(),
data: data.local.points.map((point, index) => { data: data.local.points.map((point, index) => {
return { return {
x: getConvertedDistance(data.local.distance.total[index]), x: getConvertedDistance(data.local.distance.total[index]),
y: point.getCadence(), y: point.getCadence(),
index: index, index: index
}; };
}), }),
normalized: true, normalized: true,
yAxisID: 'ycad', yAxisID: `y${datasets.cad.id}`,
hidden: true, hidden: true
}; };
chart.data.datasets[4] = { chart.data.datasets[4] = {
label: datasets.atemp.getLabel(),
data: data.local.points.map((point, index) => { data: data.local.points.map((point, index) => {
return { return {
x: getConvertedDistance(data.local.distance.total[index]), x: getConvertedDistance(data.local.distance.total[index]),
y: getConvertedTemperature(point.getTemperature()), y: getConvertedTemperature(point.getTemperature()),
index: index, index: index
}; };
}), }),
normalized: true, normalized: true,
yAxisID: 'yatemp', yAxisID: `y${datasets.atemp.id}`,
hidden: true, hidden: true
}; };
chart.data.datasets[5] = { chart.data.datasets[5] = {
label: datasets.power.getLabel(),
data: data.local.points.map((point, index) => { data: data.local.points.map((point, index) => {
return { return {
x: getConvertedDistance(data.local.distance.total[index]), x: getConvertedDistance(data.local.distance.total[index]),
y: point.getPower(), y: point.getPower(),
index: index, index: index
}; };
}), }),
normalized: true, normalized: true,
yAxisID: 'ypower', yAxisID: `y${datasets.power.id}`,
hidden: true, hidden: true
}; };
chart.options.scales.x['min'] = 0; chart.options.scales.x['min'] = 0;
chart.options.scales.x['max'] = getConvertedDistance(data.global.distance.total); chart.options.scales.x['max'] = getConvertedDistance(data.global.distance.total);
// update units
for (let [id, dataset] of Object.entries(datasets)) {
chart.options.scales[`y${id}`].title.text =
dataset.getLabel() + ' (' + dataset.getUnits() + ')';
}
chart.update(); chart.update();
} }
let maxSlope = 20;
function slopeFillCallback(context) { function slopeFillCallback(context) {
return getSlopeColor(context.p0.raw.slope.segment); let slope = context.p0.raw.slope.segment;
if (slope > maxSlope) {
slope = maxSlope;
} else if (slope < -maxSlope) {
slope = -maxSlope;
}
let v = slope / maxSlope;
v = 1 / (1 + Math.exp(-6 * v));
v = v - 0.5;
let hue = ((0.5 - v) * 120).toString(10);
let lightness = 90 - Math.abs(v) * 70;
return ['hsl(', hue, ',70%,', lightness, '%)'].join('');
} }
function surfaceFillCallback(context) { function surfaceFillCallback(context) {
return getSurfaceColor(context.p0.raw.extensions.surface); let surface = context.p0.raw.surface;
} return surfaceColors[surface] ? surfaceColors[surface] : surfaceColors.missing;
function highwayFillCallback(context) {
return getHighwayColor(
context.p0.raw.extensions.highway,
context.p0.raw.extensions.sac_scale,
context.p0.raw.extensions.mtb_scale
);
} }
$: if (chart) { $: if (chart) {
if (elevationFill === 'slope') { if (elevationFill === 'slope') {
chart.data.datasets[0]['segment'] = { chart.data.datasets[0]['segment'] = {
backgroundColor: slopeFillCallback, backgroundColor: slopeFillCallback
}; };
} else if (elevationFill === 'surface') { } else if (elevationFill === 'surface') {
chart.data.datasets[0]['segment'] = { chart.data.datasets[0]['segment'] = {
backgroundColor: surfaceFillCallback, backgroundColor: surfaceFillCallback
};
} else if (elevationFill === 'highway') {
chart.data.datasets[0]['segment'] = {
backgroundColor: highwayFillCallback,
}; };
} else { } else {
chart.data.datasets[0]['segment'] = {}; chart.data.datasets[0]['segment'] = {};
@@ -492,6 +551,12 @@
chart.data.datasets[4].hidden = !includeTemperature; chart.data.datasets[4].hidden = !includeTemperature;
chart.data.datasets[5].hidden = !includePower; chart.data.datasets[5].hidden = !includePower;
} }
chart.options.scales[`y${datasets.speed.id}`].display = includeSpeed && showAdditionalScales;
chart.options.scales[`y${datasets.hr.id}`].display = includeHeartRate && showAdditionalScales;
chart.options.scales[`y${datasets.cad.id}`].display = includeCadence && showAdditionalScales;
chart.options.scales[`y${datasets.atemp.id}`].display =
includeTemperature && showAdditionalScales;
chart.options.scales[`y${datasets.power.id}`].display = includePower && showAdditionalScales;
chart.update(); chart.update();
} }
@@ -502,8 +567,6 @@
overlay.width = canvas.width / window.devicePixelRatio; overlay.width = canvas.width / window.devicePixelRatio;
overlay.height = canvas.height / window.devicePixelRatio; overlay.height = canvas.height / window.devicePixelRatio;
overlay.style.width = `${overlay.width}px`;
overlay.style.height = `${overlay.height}px`;
if ($slicedGPXStatistics) { if ($slicedGPXStatistics) {
let startIndex = $slicedGPXStatistics[1]; let startIndex = $slicedGPXStatistics[1];
@@ -527,7 +590,7 @@
startPixel, startPixel,
chart.chartArea.top, chart.chartArea.top,
endPixel - startPixel, endPixel - startPixel,
chart.chartArea.height chart.chartArea.bottom - chart.chartArea.top
); );
} }
} else if (overlay) { } else if (overlay) {
@@ -540,6 +603,32 @@
$: $slicedGPXStatistics, $mode, updateOverlay(); $: $slicedGPXStatistics, $mode, updateOverlay();
$: if (chart) {
if ($hoveredTrackPoint) {
let index = chart._metasets[0].data.findIndex(
(point) =>
$gpxStatistics.local.points[point.raw.index]._data.index ===
$hoveredTrackPoint.point._data.index &&
$hoveredTrackPoint.point.getLongitude() === point.raw.coordinates.lon &&
$hoveredTrackPoint.point.getLatitude() === point.raw.coordinates.lat
);
if (index >= 0) {
chart.tooltip?.setActiveElements(
[
{
datasetIndex: 0,
index
}
],
{ x: 0, y: 0 }
);
}
} else {
chart.tooltip?.setActiveElements([], { x: 0, y: 0 });
}
chart.update();
}
onDestroy(() => { onDestroy(() => {
if (chart) { if (chart) {
chart.destroy(); chart.destroy();
@@ -547,141 +636,73 @@
}); });
</script> </script>
<div class="h-full grow min-w-0 relative py-2"> <div class="h-full grow min-w-0 flex flex-row gap-4 items-center {$$props.class ?? ''}">
<canvas bind:this={overlay} class="w-full h-full absolute pointer-events-none"></canvas> <div class="grow h-full min-w-0 relative">
<canvas bind:this={canvas} class="w-full h-full absolute"></canvas> <canvas bind:this={overlay} class=" w-full h-full absolute pointer-events-none"></canvas>
<canvas bind:this={canvas} class="w-full h-full"></canvas>
</div>
{#if showControls} {#if showControls}
<div class="absolute bottom-10 right-1.5"> <div class="h-full flex flex-col justify-center" style="width: {panelSize > 158 ? 22 : 42}px">
<Popover.Root>
<Popover.Trigger asChild let:builder>
<ButtonWithTooltip
label={$_('chart.settings')}
builders={[builder]}
variant="outline"
class="w-7 h-7 p-0 flex justify-center opacity-70 hover:opacity-100 transition-opacity duration-300 hover:bg-background"
>
<ChartNoAxesColumn size="18" />
</ButtonWithTooltip>
</Popover.Trigger>
<Popover.Content
class="w-fit p-0 flex flex-col divide-y"
side="top"
sideOffset={-32}
>
<ToggleGroup.Root <ToggleGroup.Root
class="flex flex-col items-start gap-0 p-1" class="{panelSize > 158
? 'flex-col'
: 'flex-row'} flex-wrap gap-0 min-h-0 content-center border rounded-t-md"
type="single" type="single"
bind:value={elevationFill} bind:value={elevationFill}
> >
<ToggleGroup.Item <ToggleGroup.Item class="p-0 w-5 h-5" value="slope">
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground" <Tooltip side="left">
value="slope" <TriangleRight slot="data" size="15" />
> <span slot="tooltip">{$_('chart.show_slope')}</span>
<div class="w-6 flex justify-center items-center"> </Tooltip>
{#if elevationFill === 'slope'}
<Circle class="h-1.5 w-1.5 fill-current text-current" />
{/if}
</div>
<TriangleRight size="15" class="mr-1" />
{$_('quantities.slope')}
</ToggleGroup.Item> </ToggleGroup.Item>
<ToggleGroup.Item <ToggleGroup.Item class="p-0 w-5 h-5" value="surface">
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground" <Tooltip side="left">
value="surface" <BrickWall slot="data" size="15" />
variant="outline" <span slot="tooltip">{$_('chart.show_surface')}</span>
> </Tooltip>
<div class="w-6 flex justify-center items-center">
{#if elevationFill === 'surface'}
<Circle class="h-1.5 w-1.5 fill-current text-current" />
{/if}
</div>
<BrickWall size="15" class="mr-1" />
{$_('quantities.surface')}
</ToggleGroup.Item>
<ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="highway"
variant="outline"
>
<div class="w-6 flex justify-center items-center">
{#if elevationFill === 'highway'}
<Circle class="h-1.5 w-1.5 fill-current text-current" />
{/if}
</div>
<Construction size="15" class="mr-1" />
{$_('quantities.highway')}
</ToggleGroup.Item> </ToggleGroup.Item>
</ToggleGroup.Root> </ToggleGroup.Root>
<ToggleGroup.Root <ToggleGroup.Root
class="flex flex-col items-start gap-0 p-1" class="{panelSize > 158
? 'flex-col'
: 'flex-row'} flex-wrap gap-0 min-h-0 content-center border rounded-b-md -mt-[1px]"
type="multiple" type="multiple"
bind:value={additionalDatasets} bind:value={additionalDatasets}
> >
<ToggleGroup.Item <ToggleGroup.Item class="p-0 w-5 h-5" value="speed">
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground" <Tooltip side="left">
value="speed" <Zap slot="data" size="15" />
<span slot="tooltip"
>{$velocityUnits === 'speed' ? $_('chart.show_speed') : $_('chart.show_pace')}</span
> >
<div class="w-6 flex justify-center items-center"> </Tooltip>
{#if additionalDatasets.includes('speed')}
<Check size="14" />
{/if}
</div>
<Zap size="15" class="mr-1" />
{$velocityUnits === 'speed'
? $_('quantities.speed')
: $_('quantities.pace')}
</ToggleGroup.Item> </ToggleGroup.Item>
<ToggleGroup.Item <ToggleGroup.Item class="p-0 w-5 h-5" value="hr">
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground" <Tooltip side="left">
value="hr" <HeartPulse slot="data" size="15" />
> <span slot="tooltip">{$_('chart.show_heartrate')}</span>
<div class="w-6 flex justify-center items-center"> </Tooltip>
{#if additionalDatasets.includes('hr')}
<Check size="14" />
{/if}
</div>
<HeartPulse size="15" class="mr-1" />
{$_('quantities.heartrate')}
</ToggleGroup.Item> </ToggleGroup.Item>
<ToggleGroup.Item <ToggleGroup.Item class="p-0 w-5 h-5" value="cad">
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground" <Tooltip side="left">
value="cad" <Orbit slot="data" size="15" />
> <span slot="tooltip">{$_('chart.show_cadence')}</span>
<div class="w-6 flex justify-center items-center"> </Tooltip>
{#if additionalDatasets.includes('cad')}
<Check size="14" />
{/if}
</div>
<Orbit size="15" class="mr-1" />
{$_('quantities.cadence')}
</ToggleGroup.Item> </ToggleGroup.Item>
<ToggleGroup.Item <ToggleGroup.Item class="p-0 w-5 h-5" value="atemp">
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground" <Tooltip side="left">
value="atemp" <Thermometer slot="data" size="15" />
> <span slot="tooltip">{$_('chart.show_temperature')}</span>
<div class="w-6 flex justify-center items-center"> </Tooltip>
{#if additionalDatasets.includes('atemp')}
<Check size="14" />
{/if}
</div>
<Thermometer size="15" class="mr-1" />
{$_('quantities.temperature')}
</ToggleGroup.Item> </ToggleGroup.Item>
<ToggleGroup.Item <ToggleGroup.Item class="p-0 w-5 h-5" value="power">
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground" <Tooltip side="left">
value="power" <SquareActivity slot="data" size="15" />
> <span slot="tooltip">{$_('chart.show_power')}</span>
<div class="w-6 flex justify-center items-center"> </Tooltip>
{#if additionalDatasets.includes('power')}
<Check size="14" />
{/if}
</div>
<SquareActivity size="15" class="mr-1" />
{$_('quantities.power')}
</ToggleGroup.Item> </ToggleGroup.Item>
</ToggleGroup.Root> </ToggleGroup.Root>
</Popover.Content>
</Popover.Root>
</div> </div>
{/if} {/if}
</div> </div>

View File

@@ -10,17 +10,17 @@
exportSelectedFiles, exportSelectedFiles,
ExportState, ExportState,
exportState, exportState,
gpxStatistics, gpxStatistics
} from '$lib/stores'; } from '$lib/stores';
import { fileObservers } from '$lib/db'; import { fileObservers } from '$lib/db';
import { import {
Download, Download,
Zap, Zap,
Earth, BrickWall,
HeartPulse, HeartPulse,
Orbit, Orbit,
Thermometer, Thermometer,
SquareActivity, SquareActivity
} from 'lucide-svelte'; } from 'lucide-svelte';
import { _ } from 'svelte-i18n'; import { _ } from 'svelte-i18n';
import { selection } from './file-list/Selection'; import { selection } from './file-list/Selection';
@@ -31,19 +31,19 @@
let open = false; let open = false;
let exportOptions: Record<string, boolean> = { let exportOptions: Record<string, boolean> = {
time: true, time: true,
surface: true,
hr: true, hr: true,
cad: true, cad: true,
atemp: true, atemp: true,
power: true, power: true
extensions: true,
}; };
let hide: Record<string, boolean> = { let hide: Record<string, boolean> = {
time: false, time: false,
surface: false,
hr: false, hr: false,
cad: false, cad: false,
atemp: false, atemp: false,
power: false, power: false
extensions: false,
}; };
$: if ($exportState !== ExportState.NONE) { $: if ($exportState !== ExportState.NONE) {
@@ -67,7 +67,6 @@
hide.cad = statistics.global.cad.count === 0; hide.cad = statistics.global.cad.count === 0;
hide.atemp = statistics.global.atemp.count === 0; hide.atemp = statistics.global.atemp.count === 0;
hide.power = statistics.global.power.count === 0; hide.power = statistics.global.power.count === 0;
hide.extensions = Object.keys(statistics.global.extensions).length === 0;
} }
$: exclude = Object.keys(exportOptions).filter((key) => !exportOptions[key]); $: exclude = Object.keys(exportOptions).filter((key) => !exportOptions[key]);
@@ -87,10 +86,10 @@
class="fixed left-[50%] top-[50%] z-50 w-fit max-w-full translate-x-[-50%] translate-y-[-50%] flex flex-col items-center gap-3 border bg-background p-3 shadow-lg rounded-md" class="fixed left-[50%] top-[50%] z-50 w-fit max-w-full translate-x-[-50%] translate-y-[-50%] flex flex-col items-center gap-3 border bg-background p-3 shadow-lg rounded-md"
> >
<div <div
class="w-full flex flex-row items-center justify-center gap-4 border rounded-md p-2 bg-secondary" class="w-full flex flex-row items-center justify-center gap-4 border rounded-md p-2 bg-accent"
> >
<span>⚠️</span> <span>⚠️</span>
<span class="max-w-[80%] text-sm"> <span class="max-w-96 text-sm">
{$_('menu.support_message')} {$_('menu.support_message')}
</span> </span>
</div> </div>
@@ -120,13 +119,7 @@
{/if} {/if}
</Button> </Button>
</div> </div>
<div <div class="w-full max-w-xl flex flex-col items-center gap-2">
class="w-full max-w-xl flex flex-col items-center gap-2 {Object.values(hide).some(
(v) => !v
)
? ''
: 'hidden'}"
>
<div class="w-full flex flex-row items-center gap-3"> <div class="w-full flex flex-row items-center gap-3">
<div class="grow"> <div class="grow">
<Separator /> <Separator />
@@ -146,13 +139,11 @@
{$_('quantities.time')} {$_('quantities.time')}
</Label> </Label>
</div> </div>
<div <div class="flex flex-row items-center gap-1.5">
class="flex flex-row items-center gap-1.5 {hide.extensions ? 'hidden' : ''}" <Checkbox id="export-surface" bind:checked={exportOptions.surface} />
> <Label for="export-surface" class="flex flex-row items-center gap-1">
<Checkbox id="export-extensions" bind:checked={exportOptions.extensions} /> <BrickWall size="16" />
<Label for="export-extensions" class="flex flex-row items-center gap-1"> {$_('quantities.surface')}
<Earth size="16" />
{$_('quantities.osm_extensions')}
</Label> </Label>
</div> </div>
<div class="flex flex-row items-center gap-1.5 {hide.hr ? 'hidden' : ''}"> <div class="flex flex-row items-center gap-1.5 {hide.hr ? 'hidden' : ''}">

View File

@@ -11,7 +11,7 @@
<div class="mx-6 border-t"> <div class="mx-6 border-t">
<div class="mx-12 py-10 flex flex-row flex-wrap justify-between gap-x-10 gap-y-6"> <div class="mx-12 py-10 flex flex-row flex-wrap justify-between gap-x-10 gap-y-6">
<div class="grow flex flex-col items-start"> <div class="grow flex flex-col items-start">
<Logo class="h-8" width="153" /> <Logo class="h-8" />
<Button <Button
variant="link" variant="link"
class="h-6 px-0 text-muted-foreground" class="h-6 px-0 text-muted-foreground"
@@ -52,15 +52,6 @@
</div> </div>
<div class="flex flex-col items-start gap-1" id="contact"> <div class="flex flex-col items-start gap-1" id="contact">
<span class="font-semibold">{$_('homepage.contact')}</span> <span class="font-semibold">{$_('homepage.contact')}</span>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
href="https://www.reddit.com/r/gpxstudio/"
target="_blank"
>
<Logo company="reddit" class="h-4 mr-1 fill-muted-foreground" />
{$_('homepage.reddit')}
</Button>
<Button <Button
variant="link" variant="link"
class="h-6 px-0 text-muted-foreground" class="h-6 px-0 text-muted-foreground"

View File

@@ -28,7 +28,7 @@
<Card.Root <Card.Root
class="h-full {orientation === 'vertical' class="h-full {orientation === 'vertical'
? 'min-w-40 sm:min-w-44 text-sm sm:text-base' ? 'min-w-44 sm:min-w-52 text-sm sm:text-base'
: 'w-full'} border-none shadow-none" : 'w-full'} border-none shadow-none"
> >
<Card.Content <Card.Content
@@ -36,52 +36,48 @@
? 'flex-col justify-center' ? 'flex-col justify-center'
: 'flex-row w-full justify-between'} gap-4 p-0" : 'flex-row w-full justify-between'} gap-4 p-0"
> >
<Tooltip label={$_('quantities.distance')}> <Tooltip>
<span class="flex flex-row items-center"> <span slot="data" class="flex flex-row items-center">
<Ruler size="16" class="mr-1" /> <Ruler size="18" class="mr-1" />
<WithUnits value={statistics.global.distance.total} type="distance" /> <WithUnits value={statistics.global.distance.total} type="distance" />
</span> </span>
<span slot="tooltip">{$_('quantities.distance')}</span>
</Tooltip> </Tooltip>
<Tooltip label={$_('quantities.elevation_gain_loss')}> <Tooltip>
<span class="flex flex-row items-center"> <span slot="data" class="flex flex-row items-center">
<MoveUpRight size="16" class="mr-1" /> <MoveUpRight size="18" class="mr-1" />
<WithUnits value={statistics.global.elevation.gain} type="elevation" /> <WithUnits value={statistics.global.elevation.gain} type="elevation" />
<MoveDownRight size="16" class="mx-1" /> <MoveDownRight size="18" class="mx-1" />
<WithUnits value={statistics.global.elevation.loss} type="elevation" /> <WithUnits value={statistics.global.elevation.loss} type="elevation" />
</span> </span>
<span slot="tooltip">{$_('quantities.elevation')}</span>
</Tooltip> </Tooltip>
{#if panelSize > 120 || orientation === 'horizontal'} {#if panelSize > 120 || orientation === 'horizontal'}
<Tooltip <Tooltip class={orientation === 'horizontal' ? 'hidden xs:block' : ''}>
class={orientation === 'horizontal' ? 'hidden xs:block' : ''} <span slot="data" class="flex flex-row items-center">
label="{$velocityUnits === 'speed' <Zap size="18" class="mr-1" />
? $_('quantities.speed') <WithUnits value={statistics.global.speed.moving} type="speed" showUnits={false} />
: $_('quantities.pace')} ({$_('quantities.moving')} / {$_('quantities.total')})"
>
<span class="flex flex-row items-center">
<Zap size="16" class="mr-1" />
<WithUnits
value={statistics.global.speed.moving}
type="speed"
showUnits={false}
/>
<span class="mx-1">/</span> <span class="mx-1">/</span>
<WithUnits value={statistics.global.speed.total} type="speed" /> <WithUnits value={statistics.global.speed.total} type="speed" />
</span> </span>
<span slot="tooltip"
>{$velocityUnits === 'speed' ? $_('quantities.speed') : $_('quantities.pace')} ({$_(
'quantities.moving'
)} / {$_('quantities.total')})</span
>
</Tooltip> </Tooltip>
{/if} {/if}
{#if panelSize > 160 || orientation === 'horizontal'} {#if panelSize > 160 || orientation === 'horizontal'}
<Tooltip <Tooltip class={orientation === 'horizontal' ? 'hidden md:block' : ''}>
class={orientation === 'horizontal' ? 'hidden md:block' : ''} <span slot="data" class="flex flex-row items-center">
label="{$_('quantities.time')} ({$_('quantities.moving')} / {$_( <Timer size="18" class="mr-1" />
'quantities.total'
)})"
>
<span class="flex flex-row items-center">
<Timer size="16" class="mr-1" />
<WithUnits value={statistics.global.time.moving} type="time" /> <WithUnits value={statistics.global.time.moving} type="time" />
<span class="mx-1">/</span> <span class="mx-1">/</span>
<WithUnits value={statistics.global.time.total} type="time" /> <WithUnits value={statistics.global.time.total} type="time" />
</span> </span>
<span slot="tooltip"
>{$_('quantities.time')} ({$_('quantities.moving')} / {$_('quantities.total')})</span
>
</Tooltip> </Tooltip>
{/if} {/if}
</Card.Content> </Card.Content>

View File

@@ -0,0 +1,70 @@
<script lang="ts">
import { base } from '$app/paths';
import { page } from '$app/stores';
import { languages } from '$lib/languages';
import { _, isLoading } from 'svelte-i18n';
let location: string;
let title: string;
$: if ($page.route.id) {
location = $page.route.id;
Object.keys($page.params).forEach((param) => {
if (param !== 'language') {
location = location.replace(`[${param}]`, $page.params[param]);
location = location.replace(`[...${param}]`, $page.params[param]);
}
});
title = location.replace('/[...language]', '').split('/')[1] ?? 'home';
}
</script>
<svelte:head>
{#if $isLoading}
<title>gpx.studio — the online GPX file editor</title>
<meta
name="description"
content="View, edit and create GPX files online with advanced route planning capabilities and file processing tools, beautiful maps and detailed data visualizations."
/>
<meta property="og:title" content="gpx.studio — the online GPX file editor" />
<meta
property="og:description"
content="View, edit and create GPX files online with advanced route planning capabilities and file processing tools, beautiful maps and detailed data visualizations."
/>
<meta name="twitter:title" content="gpx.studio — the online GPX file editor" />
<meta
name="twitter:description"
content="View, edit and create GPX files online with advanced route planning capabilities and file processing tools, beautiful maps and detailed data visualizations."
/>
{:else}
<title>gpx.studio — {$_(`metadata.${title}_title`)}</title>
<meta name="description" content={$_('metadata.description')} />
<meta property="og:title" content="gpx.studio — {$_(`metadata.${title}_title`)}" />
<meta property="og:description" content={$_('metadata.description')} />
<meta name="twitter:title" content="gpx.studio — {$_(`metadata.${title}_title`)}" />
<meta name="twitter:description" content={$_('metadata.description')} />
{/if}
<meta property="og:image" content="https://gpx.studio/og_logo.png" />
<meta property="og:url" content="https://gpx.studio/" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="gpx.studio" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="https://gpx.studio/og_logo.png" />
<meta name="twitter:url" content="https://gpx.studio/" />
<meta name="twitter:site" content="@gpxstudio" />
<meta name="twitter:creator" content="@gpxstudio" />
<link
rel="alternate"
hreflang="x-default"
href="https://gpx.studio{base}{location.replace('/[...language]', '')}"
/>
{#each Object.keys(languages) as lang}
<link
rel="alternate"
hreflang={lang}
href="https://gpx.studio{base}{location.replace('[...language]', lang)}"
/>
{/each}
</svelte:head>

View File

@@ -5,14 +5,16 @@
export let link: string | undefined = undefined; export let link: string | undefined = undefined;
</script> </script>
<div <div class="text-sm bg-muted rounded border flex flex-row items-center p-2 {$$props.class || ''}">
class="text-sm bg-secondary rounded border flex flex-row items-center p-2 {$$props.class || ''}"
>
<CircleHelp size="16" class="w-4 mr-2 shrink-0 grow-0" /> <CircleHelp size="16" class="w-4 mr-2 shrink-0 grow-0" />
<div> <div>
<slot /> <slot />
{#if link} {#if link}
<a href={link} target="_blank" class="text-sm text-link hover:underline"> <a
href={link}
target="_blank"
class="text-sm text-blue-500 dark:text-blue-300 hover:underline"
>
{$_('menu.more')} {$_('menu.more')}
</a> </a>
{/if} {/if}

View File

@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores';
import * as Select from '$lib/components/ui/select'; import * as Select from '$lib/components/ui/select';
import { languages } from '$lib/languages'; import { languages } from '$lib/languages';
import { getURLForLanguage } from '$lib/utils'; import { getURLForLanguage } from '$lib/utils';
@@ -8,44 +7,36 @@
let selected = { let selected = {
value: '', value: '',
label: '', label: ''
}; };
$: if ($locale) { $: if ($locale) {
selected = { selected = {
value: $locale, value: $locale,
label: languages[$locale], label: languages[$locale]
}; };
} }
</script> </script>
<Select.Root bind:selected> <Select.Root bind:selected>
<Select.Trigger class="w-[180px] {$$props.class ?? ''}" aria-label={$_('menu.language')}> <Select.Trigger class="w-[180px] {$$props.class ?? ''}">
<Languages size="16" /> <Languages size="16" />
<Select.Value class="ml-2 mr-auto" /> <Select.Value class="ml-2 mr-auto" />
</Select.Trigger> </Select.Trigger>
<Select.Content> <Select.Content>
{#each Object.entries(languages) as [lang, label]} {#each Object.entries(languages) as [lang, label]}
{#if $page.url.pathname.includes('404')} <a href={getURLForLanguage(lang)}>
<a href={getURLForLanguage(lang, '/')}>
<Select.Item value={lang}>{label}</Select.Item> <Select.Item value={lang}>{label}</Select.Item>
</a> </a>
{:else}
<a href={getURLForLanguage(lang, $page.url.pathname)}>
<Select.Item value={lang}>{label}</Select.Item>
</a>
{/if}
{/each} {/each}
</Select.Content> </Select.Content>
</Select.Root> </Select.Root>
<!-- hidden links for svelte crawling --> <!-- hidden links for svelte crawling -->
<div class="hidden"> <div class="hidden">
{#if !$page.url.pathname.includes('404')}
{#each Object.entries(languages) as [lang, label]} {#each Object.entries(languages) as [lang, label]}
<a href={getURLForLanguage(lang, $page.url.pathname)}> <a href={getURLForLanguage(lang)}>
{label} {label}
</a> </a>
{/each} {/each}
{/if}
</div> </div>

View File

@@ -60,14 +60,4 @@
d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z" d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z"
/></svg /></svg
> >
{:else if company === 'reddit'}
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
class="fill-foreground {$$restProps.class ?? ''}"
><title>Reddit</title><path
d="M12 0C5.373 0 0 5.373 0 12c0 3.314 1.343 6.314 3.515 8.485l-2.286 2.286C.775 23.225 1.097 24 1.738 24H12c6.627 0 12-5.373 12-12S18.627 0 12 0Zm4.388 3.199c1.104 0 1.999.895 1.999 1.999 0 1.105-.895 2-1.999 2-.946 0-1.739-.657-1.947-1.539v.002c-1.147.162-2.032 1.15-2.032 2.341v.007c1.776.067 3.4.567 4.686 1.363.473-.363 1.064-.58 1.707-.58 1.547 0 2.802 1.254 2.802 2.802 0 1.117-.655 2.081-1.601 2.531-.088 3.256-3.637 5.876-7.997 5.876-4.361 0-7.905-2.617-7.998-5.87-.954-.447-1.614-1.415-1.614-2.538 0-1.548 1.255-2.802 2.803-2.802.645 0 1.239.218 1.712.585 1.275-.79 2.881-1.291 4.64-1.365v-.01c0-1.663 1.263-3.034 2.88-3.207.188-.911.993-1.595 1.959-1.595Zm-8.085 8.376c-.784 0-1.459.78-1.506 1.797-.047 1.016.64 1.429 1.426 1.429.786 0 1.371-.369 1.418-1.385.047-1.017-.553-1.841-1.338-1.841Zm7.406 0c-.786 0-1.385.824-1.338 1.841.047 1.017.634 1.385 1.418 1.385.785 0 1.473-.413 1.426-1.429-.046-1.017-.721-1.797-1.506-1.797Zm-3.703 4.013c-.974 0-1.907.048-2.77.135-.147.015-.241.168-.183.305.483 1.154 1.622 1.964 2.953 1.964 1.33 0 2.47-.81 2.953-1.964.057-.137-.037-.29-.184-.305-.863-.087-1.795-.135-2.769-.135Z"
/></svg
>
{/if} {/if}

View File

@@ -22,17 +22,16 @@
mapboxgl.accessToken = accessToken; mapboxgl.accessToken = accessToken;
let webgl2Supported = true; let webgl2Supported = true;
let embeddedApp = false;
let fitBoundsOptions: mapboxgl.FitBoundsOptions = { let fitBoundsOptions: mapboxgl.FitBoundsOptions = {
maxZoom: 15, maxZoom: 15,
linear: true, linear: true,
easing: () => 1, easing: () => 1
}; };
const { distanceUnits, elevationProfile, treeFileView, bottomPanelSize, rightPanelSize } = const { distanceUnits, elevationProfile, verticalFileView, bottomPanelSize, rightPanelSize } =
settings; settings;
let scaleControl = new mapboxgl.ScaleControl({ let scaleControl = new mapboxgl.ScaleControl({
unit: $distanceUnits, unit: $distanceUnits
}); });
onMount(() => { onMount(() => {
@@ -41,10 +40,6 @@
webgl2Supported = false; webgl2Supported = false;
return; return;
} }
if (window.top !== window.self && !$page.route.id?.includes('embed')) {
embeddedApp = true;
return;
}
let language = $page.params.language; let language = $page.params.language;
if (language === 'zh') { if (language === 'zh') {
@@ -57,111 +52,52 @@
let newMap = new mapboxgl.Map({ let newMap = new mapboxgl.Map({
container: 'map', container: 'map',
style: { style: { version: 8, sources: {}, layers: [] },
version: 8,
sources: {},
layers: [],
imports: [
{
id: 'glyphs-and-sprite', // make Mapbox glyphs and sprite available to other styles
url: '',
data: {
version: 8,
sources: {},
layers: [],
glyphs: 'mapbox://fonts/mapbox/{fontstack}/{range}.pbf',
sprite: `https://api.mapbox.com/styles/v1/mapbox/outdoors-v12/sprite?access_token=${PUBLIC_MAPBOX_TOKEN}`,
},
},
{
id: 'basemap',
url: '',
},
{
id: 'overlays',
url: '',
data: {
version: 8,
sources: {},
layers: [],
},
},
],
},
projection: 'globe',
zoom: 0, zoom: 0,
hash: hash, hash: hash,
language, language,
attributionControl: false, attributionControl: false,
logoPosition: 'bottom-right', logoPosition: 'bottom-right',
boxZoom: false, boxZoom: false
}); });
newMap.on('load', () => { newMap.on('load', () => {
$map = newMap; // only set the store after the map has loaded $map = newMap; // only set the store after the map has loaded
window._map = newMap; // entry point for extensions
scaleControl.setUnit($distanceUnits); scaleControl.setUnit($distanceUnits);
}); });
newMap.addControl( newMap.addControl(
new mapboxgl.AttributionControl({ new mapboxgl.AttributionControl({
compact: true, compact: true
}) })
); );
newMap.addControl( newMap.addControl(
new mapboxgl.NavigationControl({ new mapboxgl.NavigationControl({
visualizePitch: true, visualizePitch: true
}) })
); );
if (geocoder) { if (geocoder) {
let geocoder = new MapboxGeocoder({ newMap.addControl(
new MapboxGeocoder({
accessToken: mapboxgl.accessToken,
mapboxgl: mapboxgl, mapboxgl: mapboxgl,
enableEventLogging: false,
collapsed: true, collapsed: true,
flyTo: fitBoundsOptions, flyTo: fitBoundsOptions,
language, language
localGeocoder: () => [], })
localGeocoderOnly: true, );
externalGeocoder: (query: string) =>
fetch(
`https://nominatim.openstreetmap.org/search?format=json&q=${query}&limit=5&accept-language=${language}`
)
.then((response) => response.json())
.then((data) => {
return data.map((result: any) => {
return {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [result.lon, result.lat],
},
place_name: result.display_name,
};
});
}),
});
let onKeyDown = geocoder._onKeyDown;
geocoder._onKeyDown = (e: KeyboardEvent) => {
// Trigger search on Enter key only
if (e.key === 'Enter') {
onKeyDown.apply(geocoder, [{ target: geocoder._inputEl }]);
} else if (geocoder._typeahead.data.length > 0) {
geocoder._typeahead.clear();
}
};
newMap.addControl(geocoder);
} }
if (geolocate) { if (geolocate) {
newMap.addControl( newMap.addControl(
new mapboxgl.GeolocateControl({ new mapboxgl.GeolocateControl({
positionOptions: { positionOptions: {
enableHighAccuracy: true, enableHighAccuracy: true
}, },
fitBoundsOptions, fitBoundsOptions,
trackUserLocation: true, trackUserLocation: true,
showUserHeading: true, showUserHeading: true
}) })
); );
} }
@@ -173,28 +109,37 @@
type: 'raster-dem', type: 'raster-dem',
url: 'mapbox://mapbox.mapbox-terrain-dem-v1', url: 'mapbox://mapbox.mapbox-terrain-dem-v1',
tileSize: 512, tileSize: 512,
maxzoom: 14, maxzoom: 14
}); });
if (newMap.getPitch() > 0) {
newMap.setTerrain({ newMap.setTerrain({
source: 'mapbox-dem', source: 'mapbox-dem',
exaggeration: 1, exaggeration: newMap.getPitch() > 0 ? 1 : 0
}); });
}
newMap.setFog({ newMap.setFog({
color: 'rgb(186, 210, 235)', color: 'rgb(186, 210, 235)',
'high-color': 'rgb(36, 92, 223)', 'high-color': 'rgb(36, 92, 223)',
'horizon-blend': 0.1, 'horizon-blend': 0.1,
'space-color': 'rgb(156, 240, 255)', 'space-color': 'rgb(156, 240, 255)'
}); });
newMap.on('pitch', () => { newMap.on('pitch', () => {
if (newMap.getPitch() > 0) { if (newMap.getPitch() > 0) {
newMap.setTerrain({ newMap.setTerrain({
source: 'mapbox-dem', source: 'mapbox-dem',
exaggeration: 1, exaggeration: 1
}); });
} else { } else {
newMap.setTerrain(null); newMap.setTerrain({
source: 'mapbox-dem',
exaggeration: 0
});
}
});
// add dummy layer to place the overlay layers below
newMap.addLayer({
id: 'overlays',
type: 'background',
paint: {
'background-color': 'rgba(0, 0, 0, 0)'
} }
}); });
}); });
@@ -207,30 +152,23 @@
} }
}); });
$: if ($map && (!$treeFileView || !$elevationProfile || $bottomPanelSize || $rightPanelSize)) { $: if (
$map &&
(!$verticalFileView || !$elevationProfile || $bottomPanelSize || $rightPanelSize)
) {
$map.resize(); $map.resize();
} }
</script> </script>
<div {...$$restProps}> <div {...$$restProps}>
<div id="map" class="h-full {webgl2Supported && !embeddedApp ? '' : 'hidden'}"></div> <div id="map" class="h-full {webgl2Supported ? '' : 'hidden'}"></div>
<div <div
class="flex flex-col items-center justify-center gap-3 h-full {webgl2Supported && class="flex flex-col items-center justify-center gap-3 h-full {webgl2Supported ? 'hidden' : ''}"
!embeddedApp
? 'hidden'
: ''} {embeddedApp ? 'z-30' : ''}"
> >
{#if !webgl2Supported}
<p>{$_('webgl2_required')}</p> <p>{$_('webgl2_required')}</p>
<Button href="https://get.webgl.org/webgl2/" target="_blank"> <Button href="https://get.webgl.org/webgl2/" target="_blank">
{$_('enable_webgl2')} {$_('enable_webgl2')}
</Button> </Button>
{:else if embeddedApp}
<p>The app cannot be embedded in an iframe.</p>
<Button href="https://gpx.studio/help/integration" target="_blank">
Learn how to create a map for your website
</Button>
{/if}
</div> </div>
</div> </div>
@@ -347,7 +285,7 @@
div :global(.mapboxgl-popup) { div :global(.mapboxgl-popup) {
@apply w-fit; @apply w-fit;
@apply z-50; @apply z-20;
} }
div :global(.mapboxgl-popup-content) { div :global(.mapboxgl-popup-content) {

View File

@@ -1,25 +0,0 @@
<svelte:options accessors />
<script lang="ts">
import { TrackPoint, Waypoint } from 'gpx';
import type { Writable } from 'svelte/store';
import WaypointPopup from '$lib/components/gpx-layer/WaypointPopup.svelte';
import TrackpointPopup from '$lib/components/gpx-layer/TrackpointPopup.svelte';
import OverpassPopup from '$lib/components/layer-control/OverpassPopup.svelte';
import type { PopupItem } from './MapPopup';
export let item: Writable<PopupItem | null>;
export let container: HTMLDivElement | null = null;
</script>
<div bind:this={container}>
{#if $item}
{#if $item.item instanceof Waypoint}
<WaypointPopup waypoint={$item} />
{:else if $item.item instanceof TrackPoint}
<TrackpointPopup trackpoint={$item} />
{:else}
<OverpassPopup poi={$item} />
{/if}
{/if}
</div>

View File

@@ -1,82 +0,0 @@
import { TrackPoint, Waypoint } from 'gpx';
import mapboxgl from 'mapbox-gl';
import { tick } from 'svelte';
import { get, writable, type Writable } from 'svelte/store';
import MapPopupComponent from './MapPopup.svelte';
export type PopupItem<T = Waypoint | TrackPoint | any> = {
item: T;
fileId?: string;
hide?: () => void;
};
export class MapPopup {
map: mapboxgl.Map;
popup: mapboxgl.Popup;
item: Writable<PopupItem | null> = writable(null);
maybeHideBinded = this.maybeHide.bind(this);
constructor(map: mapboxgl.Map, options?: mapboxgl.PopupOptions) {
this.map = map;
this.popup = new mapboxgl.Popup(options);
let component = new MapPopupComponent({
target: document.body,
props: {
item: this.item,
},
});
tick().then(() => this.popup.setDOMContent(component.container));
}
setItem(item: PopupItem | null) {
if (item) item.hide = () => this.hide();
this.item.set(item);
if (item === null) {
this.hide();
} else {
tick().then(() => this.show());
}
}
show() {
const i = get(this.item);
if (i === null) {
this.hide();
return;
}
this.popup.setLngLat(this.getCoordinates()).addTo(this.map);
this.map.on('mousemove', this.maybeHideBinded);
}
maybeHide(e: mapboxgl.MapMouseEvent) {
const i = get(this.item);
if (i === null) {
this.hide();
return;
}
if (this.map.project(this.getCoordinates()).dist(this.map.project(e.lngLat)) > 60) {
this.hide();
}
}
hide() {
this.popup.remove();
this.map.off('mousemove', this.maybeHideBinded);
}
remove() {
this.popup.remove();
}
getCoordinates() {
const i = get(this.item);
if (i === null) {
return new mapboxgl.LngLat(0, 0);
}
return i.item instanceof Waypoint || i.item instanceof TrackPoint
? i.item.getCoordinates()
: new mapboxgl.LngLat(i.item.lon, i.item.lat);
}
}

View File

@@ -21,8 +21,8 @@
Thermometer, Thermometer,
Sun, Sun,
Moon, Moon,
Layers, Layers3,
ListTree, GalleryVertical,
Languages, Languages,
Settings, Settings,
Info, Info,
@@ -41,8 +41,7 @@
FileStack, FileStack,
FileX, FileX,
BookOpenText, BookOpenText,
ChartArea, ChartArea
Maximize,
} from 'lucide-svelte'; } from 'lucide-svelte';
import { import {
@@ -55,8 +54,7 @@
editMetadata, editMetadata,
editStyle, editStyle,
exportState, exportState,
ExportState, ExportState
centerMapOnSelection,
} from '$lib/stores'; } from '$lib/stores';
import { import {
copied, copied,
@@ -64,7 +62,7 @@
cutSelection, cutSelection,
pasteSelection, pasteSelection,
selectAll, selectAll,
selection, selection
} from '$lib/components/file-list/Selection'; } from '$lib/components/file-list/Selection';
import { derived } from 'svelte/store'; import { derived } from 'svelte/store';
import { canUndo, canRedo, dbUtils, fileObservers, settings } from '$lib/db'; import { canUndo, canRedo, dbUtils, fileObservers, settings } from '$lib/db';
@@ -83,7 +81,7 @@
velocityUnits, velocityUnits,
temperatureUnits, temperatureUnits,
elevationProfile, elevationProfile,
treeFileView, verticalFileView,
currentBasemap, currentBasemap,
previousBasemap, previousBasemap,
currentOverlays, currentOverlays,
@@ -91,7 +89,7 @@
distanceMarkers, distanceMarkers,
directionMarkers, directionMarkers,
streetViewSource, streetViewSource,
routing, routing
} = settings; } = settings;
let undoDisabled = derived(canUndo, ($canUndo) => !$canUndo); let undoDisabled = derived(canUndo, ($canUndo) => !$canUndo);
@@ -128,13 +126,13 @@
<div <div
class="w-fit flex flex-row items-center justify-center p-1 bg-background rounded-b-md md:rounded-md pointer-events-auto shadow-md" class="w-fit flex flex-row items-center justify-center p-1 bg-background rounded-b-md md:rounded-md pointer-events-auto shadow-md"
> >
<a href={getURLForLanguage($locale, '/')} target="_blank" class="shrink-0"> <a href="./" target="_blank">
<Logo class="h-5 mt-0.5 mx-2 md:hidden" iconOnly={true} width="16" /> <Logo class="h-5 mt-0.5 mx-2 md:hidden" iconOnly={true} />
<Logo class="h-5 mt-0.5 mx-2 hidden md:block" width="96" /> <Logo class="h-5 mt-0.5 mx-2 hidden md:block" />
</a> </a>
<Menubar.Root class="border-none h-fit p-0"> <Menubar.Root class="border-none h-fit p-0">
<Menubar.Menu> <Menubar.Menu>
<Menubar.Trigger aria-label={$_('gpx.file')}> <Menubar.Trigger>
<File size="18" class="md:hidden" /> <File size="18" class="md:hidden" />
<span class="hidden md:block">{$_('gpx.file')}</span> <span class="hidden md:block">{$_('gpx.file')}</span>
</Menubar.Trigger> </Menubar.Trigger>
@@ -151,27 +149,18 @@
<Shortcut key="O" ctrl={true} /> <Shortcut key="O" ctrl={true} />
</Menubar.Item> </Menubar.Item>
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Item <Menubar.Item on:click={dbUtils.duplicateSelection} disabled={$selection.size == 0}>
on:click={dbUtils.duplicateSelection}
disabled={$selection.size == 0}
>
<Copy size="16" class="mr-1" /> <Copy size="16" class="mr-1" />
{$_('menu.duplicate')} {$_('menu.duplicate')}
<Shortcut key="D" ctrl={true} /> <Shortcut key="D" ctrl={true} />
</Menubar.Item> </Menubar.Item>
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Item <Menubar.Item on:click={dbUtils.deleteSelectedFiles} disabled={$selection.size == 0}>
on:click={dbUtils.deleteSelectedFiles}
disabled={$selection.size == 0}
>
<FileX size="16" class="mr-1" /> <FileX size="16" class="mr-1" />
{$_('menu.close')} {$_('menu.close')}
<Shortcut key="⌫" ctrl={true} /> <Shortcut key="⌫" ctrl={true} />
</Menubar.Item> </Menubar.Item>
<Menubar.Item <Menubar.Item on:click={dbUtils.deleteAllFiles} disabled={$fileObservers.size == 0}>
on:click={dbUtils.deleteAllFiles}
disabled={$fileObservers.size == 0}
>
<FileX size="16" class="mr-1" /> <FileX size="16" class="mr-1" />
{$_('menu.close_all')} {$_('menu.close_all')}
<Shortcut key="⌫" ctrl={true} shift={true} /> <Shortcut key="⌫" ctrl={true} shift={true} />
@@ -196,7 +185,7 @@
</Menubar.Content> </Menubar.Content>
</Menubar.Menu> </Menubar.Menu>
<Menubar.Menu> <Menubar.Menu>
<Menubar.Trigger aria-label={$_('menu.edit')}> <Menubar.Trigger>
<FilePen size="18" class="md:hidden" /> <FilePen size="18" class="md:hidden" />
<span class="hidden md:block">{$_('menu.edit')}</span> <span class="hidden md:block">{$_('menu.edit')}</span>
</Menubar.Trigger> </Menubar.Trigger>
@@ -216,11 +205,7 @@
disabled={$selection.size !== 1 || disabled={$selection.size !== 1 ||
!$selection !$selection
.getSelected() .getSelected()
.every( .every((item) => item instanceof ListFileItem || item instanceof ListTrackItem)}
(item) =>
item instanceof ListFileItem ||
item instanceof ListTrackItem
)}
on:click={() => ($editMetadata = true)} on:click={() => ($editMetadata = true)}
> >
<Info size="16" class="mr-1" /> <Info size="16" class="mr-1" />
@@ -231,11 +216,7 @@
disabled={$selection.size === 0 || disabled={$selection.size === 0 ||
!$selection !$selection
.getSelected() .getSelected()
.every( .every((item) => item instanceof ListFileItem || item instanceof ListTrackItem)}
(item) =>
item instanceof ListFileItem ||
item instanceof ListTrackItem
)}
on:click={() => ($editStyle = true)} on:click={() => ($editStyle = true)}
> >
<PaintBucket size="16" class="mr-1" /> <PaintBucket size="16" class="mr-1" />
@@ -260,51 +241,13 @@
{/if} {/if}
<Shortcut key="H" ctrl={true} /> <Shortcut key="H" ctrl={true} />
</Menubar.Item> </Menubar.Item>
{#if $treeFileView}
{#if $selection.getSelected().some((item) => item instanceof ListFileItem)}
<Menubar.Separator />
<Menubar.Item
on:click={() =>
dbUtils.addNewTrack($selection.getSelected()[0].getFileId())}
disabled={$selection.size !== 1}
>
<Plus size="16" class="mr-1" />
{$_('menu.new_track')}
</Menubar.Item>
{:else if $selection
.getSelected()
.some((item) => item instanceof ListTrackItem)}
<Menubar.Separator />
<Menubar.Item
on:click={() => {
let item = $selection.getSelected()[0];
dbUtils.addNewSegment(item.getFileId(), item.getTrackIndex());
}}
disabled={$selection.size !== 1}
>
<Plus size="16" class="mr-1" />
{$_('menu.new_segment')}
</Menubar.Item>
{/if}
{/if}
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Item on:click={selectAll} disabled={$fileObservers.size == 0}> <Menubar.Item on:click={selectAll} disabled={$fileObservers.size == 0}>
<FileStack size="16" class="mr-1" /> <FileStack size="16" class="mr-1" />
{$_('menu.select_all')} {$_('menu.select_all')}
<Shortcut key="A" ctrl={true} /> <Shortcut key="A" ctrl={true} />
</Menubar.Item> </Menubar.Item>
<Menubar.Item {#if $verticalFileView}
on:click={() => {
if ($selection.size > 0) {
centerMapOnSelection();
}
}}
>
<Maximize size="16" class="mr-1" />
{$_('menu.center')}
<Shortcut key="⏎" ctrl={true} />
</Menubar.Item>
{#if $treeFileView}
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Item on:click={copySelection} disabled={$selection.size === 0}> <Menubar.Item on:click={copySelection} disabled={$selection.size === 0}>
<ClipboardCopy size="16" class="mr-1" /> <ClipboardCopy size="16" class="mr-1" />
@@ -320,9 +263,7 @@
disabled={$copied === undefined || disabled={$copied === undefined ||
$copied.length === 0 || $copied.length === 0 ||
($selection.size > 0 && ($selection.size > 0 &&
!allowedPastes[$copied[0].level].includes( !allowedPastes[$copied[0].level].includes($selection.getSelected().pop()?.level))}
$selection.getSelected().pop()?.level
))}
on:click={pasteSelection} on:click={pasteSelection}
> >
<ClipboardPaste size="16" class="mr-1" /> <ClipboardPaste size="16" class="mr-1" />
@@ -331,10 +272,7 @@
</Menubar.Item> </Menubar.Item>
{/if} {/if}
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Item <Menubar.Item on:click={dbUtils.deleteSelection} disabled={$selection.size == 0}>
on:click={dbUtils.deleteSelection}
disabled={$selection.size == 0}
>
<Trash2 size="16" class="mr-1" /> <Trash2 size="16" class="mr-1" />
{$_('menu.delete')} {$_('menu.delete')}
<Shortcut key="⌫" ctrl={true} /> <Shortcut key="⌫" ctrl={true} />
@@ -342,7 +280,7 @@
</Menubar.Content> </Menubar.Content>
</Menubar.Menu> </Menubar.Menu>
<Menubar.Menu> <Menubar.Menu>
<Menubar.Trigger aria-label={$_('menu.view')}> <Menubar.Trigger>
<View size="18" class="md:hidden" /> <View size="18" class="md:hidden" />
<span class="hidden md:block">{$_('menu.view')}</span> <span class="hidden md:block">{$_('menu.view')}</span>
</Menubar.Trigger> </Menubar.Trigger>
@@ -352,32 +290,24 @@
{$_('menu.elevation_profile')} {$_('menu.elevation_profile')}
<Shortcut key="P" ctrl={true} /> <Shortcut key="P" ctrl={true} />
</Menubar.CheckboxItem> </Menubar.CheckboxItem>
<Menubar.CheckboxItem bind:checked={$treeFileView}> <Menubar.CheckboxItem bind:checked={$verticalFileView}>
<ListTree size="16" class="mr-1" /> <GalleryVertical size="16" class="mr-1" />
{$_('menu.tree_file_view')} {$_('menu.vertical_file_view')}
<Shortcut key="L" ctrl={true} /> <Shortcut key="L" ctrl={true} />
</Menubar.CheckboxItem> </Menubar.CheckboxItem>
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Item inset on:click={switchBasemaps}> <Menubar.Item inset on:click={switchBasemaps}>
<Map size="16" class="mr-1" />{$_('menu.switch_basemap')}<Shortcut <Map size="16" class="mr-1" />{$_('menu.switch_basemap')}<Shortcut key="F1" />
key="F1"
/>
</Menubar.Item> </Menubar.Item>
<Menubar.Item inset on:click={toggleOverlays}> <Menubar.Item inset on:click={toggleOverlays}>
<Layers2 size="16" class="mr-1" />{$_('menu.toggle_overlays')}<Shortcut <Layers2 size="16" class="mr-1" />{$_('menu.toggle_overlays')}<Shortcut key="F2" />
key="F2"
/>
</Menubar.Item> </Menubar.Item>
<Menubar.Separator /> <Menubar.Separator />
<Menubar.CheckboxItem bind:checked={$distanceMarkers}> <Menubar.CheckboxItem bind:checked={$distanceMarkers}>
<Coins size="16" class="mr-1" />{$_('menu.distance_markers')}<Shortcut <Coins size="16" class="mr-1" />{$_('menu.distance_markers')}<Shortcut key="F3" />
key="F3"
/>
</Menubar.CheckboxItem> </Menubar.CheckboxItem>
<Menubar.CheckboxItem bind:checked={$directionMarkers}> <Menubar.CheckboxItem bind:checked={$directionMarkers}>
<Milestone size="16" class="mr-1" />{$_('menu.direction_markers')}<Shortcut <Milestone size="16" class="mr-1" />{$_('menu.direction_markers')}<Shortcut key="F4" />
key="F4"
/>
</Menubar.CheckboxItem> </Menubar.CheckboxItem>
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Item inset on:click={toggle3D}> <Menubar.Item inset on:click={toggle3D}>
@@ -388,43 +318,32 @@
</Menubar.Content> </Menubar.Content>
</Menubar.Menu> </Menubar.Menu>
<Menubar.Menu> <Menubar.Menu>
<Menubar.Trigger aria-label={$_('menu.settings')}> <Menubar.Trigger>
<Settings size="18" class="md:hidden" /> <Settings size="18" class="md:hidden" />
<span class="hidden md:block"> <span class="hidden md:block">
{$_('menu.settings')} {$_('menu.settings')}
</span> </span>
</Menubar.Trigger> </Menubar.Trigger>
<Menubar.Content class="border-none"> <Menubar.Content class="border-none"
<Menubar.Sub> ><Menubar.Sub>
<Menubar.SubTrigger> <Menubar.SubTrigger>
<Ruler size="16" class="mr-1" />{$_('menu.distance_units')} <Ruler size="16" class="mr-1" />{$_('menu.distance_units')}
</Menubar.SubTrigger> </Menubar.SubTrigger>
<Menubar.SubContent> <Menubar.SubContent>
<Menubar.RadioGroup bind:value={$distanceUnits}> <Menubar.RadioGroup bind:value={$distanceUnits}>
<Menubar.RadioItem value="metric" <Menubar.RadioItem value="metric">{$_('menu.metric')}</Menubar.RadioItem>
>{$_('menu.metric')}</Menubar.RadioItem <Menubar.RadioItem value="imperial">{$_('menu.imperial')}</Menubar.RadioItem>
>
<Menubar.RadioItem value="imperial"
>{$_('menu.imperial')}</Menubar.RadioItem
>
<Menubar.RadioItem value="nautical"
>{$_('menu.nautical')}</Menubar.RadioItem
>
</Menubar.RadioGroup> </Menubar.RadioGroup>
</Menubar.SubContent> </Menubar.SubContent>
</Menubar.Sub> </Menubar.Sub>
<Menubar.Sub> <Menubar.Sub>
<Menubar.SubTrigger> <Menubar.SubTrigger
<Zap size="16" class="mr-1" />{$_('menu.velocity_units')} ><Zap size="16" class="mr-1" />{$_('menu.velocity_units')}</Menubar.SubTrigger
</Menubar.SubTrigger> >
<Menubar.SubContent> <Menubar.SubContent>
<Menubar.RadioGroup bind:value={$velocityUnits}> <Menubar.RadioGroup bind:value={$velocityUnits}>
<Menubar.RadioItem value="speed" <Menubar.RadioItem value="speed">{$_('quantities.speed')}</Menubar.RadioItem>
>{$_('quantities.speed')}</Menubar.RadioItem <Menubar.RadioItem value="pace">{$_('quantities.pace')}</Menubar.RadioItem>
>
<Menubar.RadioItem value="pace"
>{$_('quantities.pace')}</Menubar.RadioItem
>
</Menubar.RadioGroup> </Menubar.RadioGroup>
</Menubar.SubContent> </Menubar.SubContent>
</Menubar.Sub> </Menubar.Sub>
@@ -434,12 +353,8 @@
</Menubar.SubTrigger> </Menubar.SubTrigger>
<Menubar.SubContent> <Menubar.SubContent>
<Menubar.RadioGroup bind:value={$temperatureUnits}> <Menubar.RadioGroup bind:value={$temperatureUnits}>
<Menubar.RadioItem value="celsius" <Menubar.RadioItem value="celsius">{$_('menu.celsius')}</Menubar.RadioItem>
>{$_('menu.celsius')}</Menubar.RadioItem <Menubar.RadioItem value="fahrenheit">{$_('menu.fahrenheit')}</Menubar.RadioItem>
>
<Menubar.RadioItem value="fahrenheit"
>{$_('menu.fahrenheit')}</Menubar.RadioItem
>
</Menubar.RadioGroup> </Menubar.RadioGroup>
</Menubar.SubContent> </Menubar.SubContent>
</Menubar.Sub> </Menubar.Sub>
@@ -452,7 +367,7 @@
<Menubar.SubContent> <Menubar.SubContent>
<Menubar.RadioGroup bind:value={$locale}> <Menubar.RadioGroup bind:value={$locale}>
{#each Object.entries(languages) as [lang, label]} {#each Object.entries(languages) as [lang, label]}
<a href={getURLForLanguage(lang, '/app')}> <a href={getURLForLanguage(lang)}>
<Menubar.RadioItem value={lang}>{label}</Menubar.RadioItem> <Menubar.RadioItem value={lang}>{label}</Menubar.RadioItem>
</a> </a>
{/each} {/each}
@@ -475,11 +390,8 @@
setMode(value); setMode(value);
}} }}
> >
<Menubar.RadioItem value="light" <Menubar.RadioItem value="light">{$_('menu.light')}</Menubar.RadioItem>
>{$_('menu.light')}</Menubar.RadioItem <Menubar.RadioItem value="dark">{$_('menu.dark')}</Menubar.RadioItem>
>
<Menubar.RadioItem value="dark">{$_('menu.dark')}</Menubar.RadioItem
>
</Menubar.RadioGroup> </Menubar.RadioGroup>
</Menubar.SubContent> </Menubar.SubContent>
</Menubar.Sub> </Menubar.Sub>
@@ -491,17 +403,13 @@
</Menubar.SubTrigger> </Menubar.SubTrigger>
<Menubar.SubContent> <Menubar.SubContent>
<Menubar.RadioGroup bind:value={$streetViewSource}> <Menubar.RadioGroup bind:value={$streetViewSource}>
<Menubar.RadioItem value="mapillary" <Menubar.RadioItem value="mapillary">{$_('menu.mapillary')}</Menubar.RadioItem>
>{$_('menu.mapillary')}</Menubar.RadioItem <Menubar.RadioItem value="google">{$_('menu.google')}</Menubar.RadioItem>
>
<Menubar.RadioItem value="google"
>{$_('menu.google')}</Menubar.RadioItem
>
</Menubar.RadioGroup> </Menubar.RadioGroup>
</Menubar.SubContent> </Menubar.SubContent>
</Menubar.Sub> </Menubar.Sub>
<Menubar.Item on:click={() => (layerSettingsOpen = true)}> <Menubar.Item on:click={() => (layerSettingsOpen = true)}>
<Layers size="16" class="mr-1" /> <Layers3 size="16" class="mr-1" />
{$_('menu.layers')} {$_('menu.layers')}
</Menubar.Item> </Menubar.Item>
</Menubar.Content> </Menubar.Content>
@@ -513,7 +421,6 @@
href="./help" href="./help"
target="_blank" target="_blank"
class="cursor-default h-fit rounded-sm px-3 py-0.5" class="cursor-default h-fit rounded-sm px-3 py-0.5"
aria-label={$_('menu.help')}
> >
<BookOpenText size="18" class="md:hidden" /> <BookOpenText size="18" class="md:hidden" />
<span class="hidden md:block"> <span class="hidden md:block">
@@ -525,7 +432,6 @@
href="https://ko-fi.com/gpxstudio" href="https://ko-fi.com/gpxstudio"
target="_blank" target="_blank"
class="cursor-default h-fit rounded-sm font-bold text-support hover:text-support px-3 py-0.5" class="cursor-default h-fit rounded-sm font-bold text-support hover:text-support px-3 py-0.5"
aria-label={$_('menu.donate')}
> >
<HeartHandshake size="18" class="md:hidden" /> <HeartHandshake size="18" class="md:hidden" />
<span class="hidden md:flex flex-row items-center"> <span class="hidden md:flex flex-row items-center">
@@ -592,16 +498,13 @@
} else { } else {
dbUtils.undo(); dbUtils.undo();
} }
e.preventDefault();
} else if ((e.key === 'Backspace' || e.key === 'Delete') && (e.metaKey || e.ctrlKey)) { } else if ((e.key === 'Backspace' || e.key === 'Delete') && (e.metaKey || e.ctrlKey)) {
if (!targetInput) {
if (e.shiftKey) { if (e.shiftKey) {
dbUtils.deleteAllFiles(); dbUtils.deleteAllFiles();
} else { } else {
dbUtils.deleteSelection(); dbUtils.deleteSelection();
} }
e.preventDefault(); e.preventDefault();
}
} else if (e.key === 'a' && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'a' && (e.metaKey || e.ctrlKey)) {
if (!targetInput) { if (!targetInput) {
selectAll(); selectAll();
@@ -621,7 +524,7 @@
$elevationProfile = !$elevationProfile; $elevationProfile = !$elevationProfile;
e.preventDefault(); e.preventDefault();
} else if (e.key === 'l' && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'l' && (e.metaKey || e.ctrlKey)) {
$treeFileView = !$treeFileView; $verticalFileView = !$verticalFileView;
e.preventDefault(); e.preventDefault();
} else if (e.key === 'h' && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'h' && (e.metaKey || e.ctrlKey)) {
if ($allHidden) { if ($allHidden) {
@@ -630,10 +533,6 @@
dbUtils.setHiddenToSelection(true); dbUtils.setHiddenToSelection(true);
} }
e.preventDefault(); e.preventDefault();
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
if ($selection.size > 0) {
centerMapOnSelection();
}
} else if (e.key === 'F1') { } else if (e.key === 'F1') {
switchBasemaps(); switchBasemaps();
e.preventDefault(); e.preventDefault();

View File

@@ -15,7 +15,6 @@
on:click={() => { on:click={() => {
setMode(selectedMode === 'light' ? 'dark' : 'light'); setMode(selectedMode === 'light' ? 'dark' : 'light');
}} }}
aria-label={$_('menu.mode')}
> >
{#if selectedMode === 'light'} {#if selectedMode === 'light'}
<Sun {size} /> <Sun {size} />

View File

@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import Logo from '$lib/components/Logo.svelte'; import Logo from '$lib/components/Logo.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import AlgoliaDocSearch from '$lib/components/AlgoliaDocSearch.svelte';
import ModeSwitch from '$lib/components/ModeSwitch.svelte'; import ModeSwitch from '$lib/components/ModeSwitch.svelte';
import { BookOpenText, Home, Map } from 'lucide-svelte'; import { BookOpenText, Home, Map } from 'lucide-svelte';
import { _, locale } from 'svelte-i18n'; import { _, locale } from 'svelte-i18n';
@@ -11,8 +10,8 @@
<nav class="w-full sticky top-0 bg-background z-50"> <nav class="w-full sticky top-0 bg-background z-50">
<div class="mx-6 py-2 flex flex-row items-center border-b gap-4 sm:gap-8"> <div class="mx-6 py-2 flex flex-row items-center border-b gap-4 sm:gap-8">
<a href={getURLForLanguage($locale, '/')} class="shrink-0 translate-y-0.5"> <a href={getURLForLanguage($locale, '/')} class="shrink-0 translate-y-0.5">
<Logo class="h-8 sm:hidden" iconOnly={true} width="26" /> <Logo class="h-8 sm:hidden" iconOnly={true} />
<Logo class="h-8 hidden sm:block" width="153" /> <Logo class="h-8 hidden sm:block" />
</a> </a>
<Button variant="link" class="text-base px-0" href={getURLForLanguage($locale, '/')}> <Button variant="link" class="text-base px-0" href={getURLForLanguage($locale, '/')}>
<Home size="18" class="mr-1.5" /> <Home size="18" class="mr-1.5" />
@@ -26,7 +25,6 @@
<BookOpenText size="18" class="mr-1.5" /> <BookOpenText size="18" class="mr-1.5" />
{$_('menu.help')} {$_('menu.help')}
</Button> </Button>
<AlgoliaDocSearch class="ml-auto" /> <ModeSwitch class="ml-auto" />
<ModeSwitch class="hidden xs:block" />
</div> </div>
</nav> </nav>

View File

@@ -12,8 +12,7 @@
const handleMouseMove = (event: PointerEvent) => { const handleMouseMove = (event: PointerEvent) => {
const newAfter = const newAfter =
startAfter + startAfter + (orientation === 'col' ? startX - event.clientX : startY - event.clientY);
(orientation === 'col' ? startX - event.clientX : startY - event.clientY);
if (newAfter >= minAfter && newAfter <= maxAfter) { if (newAfter >= minAfter && newAfter <= maxAfter) {
after = newAfter; after = newAfter;
} else if (newAfter < minAfter && after !== minAfter) { } else if (newAfter < minAfter && after !== minAfter) {

View File

@@ -1,36 +1,26 @@
<script lang="ts"> <script lang="ts">
import { isMac, isSafari } from '$lib/utils';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { _ } from 'svelte-i18n'; import { _ } from 'svelte-i18n';
export let key: string | undefined = undefined; export let key: string;
export let shift: boolean = false; export let shift: boolean = false;
export let ctrl: boolean = false; export let ctrl: boolean = false;
export let click: boolean = false; export let click: boolean = false;
let mac = false; let isMac = false;
let safari = false; let isSafari = false;
onMount(() => { onMount(() => {
mac = isMac(); isMac = navigator.userAgent.toUpperCase().indexOf('MAC') >= 0;
safari = isSafari(); isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
}); });
</script> </script>
<div <div
class="ml-auto pl-2 text-xs tracking-widest text-muted-foreground flex flex-row gap-0 items-baseline" class="ml-auto pl-2 text-xs tracking-widest text-muted-foreground flex flex-row gap-0 items-baseline"
{...$$props}
> >
{#if shift} <span>{shift ? '⇧' : ''}</span>
<span></span> <span>{ctrl ? (isMac && !isSafari ? '⌘' : $_('menu.ctrl') + '+') : ''}</span>
{/if}
{#if ctrl}
<span>{mac && !safari ? '⌘' : $_('menu.ctrl') + '+'}</span>
{/if}
{#if key}
<span class={key === '+' ? 'font-medium text-sm/4' : ''}>{key}</span> <span class={key === '+' ? 'font-medium text-sm/4' : ''}>{key}</span>
{/if} <span>{click ? $_('menu.click') : ''}</span>
{#if click}
<span>{$_('menu.click')}</span>
{/if}
</div> </div>

View File

@@ -1,18 +1,14 @@
<script lang="ts"> <script lang="ts">
import * as Tooltip from '$lib/components/ui/tooltip/index.js'; import * as Tooltip from '$lib/components/ui/tooltip/index.js';
export let label: string;
export let side: 'top' | 'right' | 'bottom' | 'left' = 'top'; export let side: 'top' | 'right' | 'bottom' | 'left' = 'top';
</script> </script>
<Tooltip.Root> <Tooltip.Root>
<Tooltip.Trigger {...$$restProps} aria-label={label}> <Tooltip.Trigger {...$$restProps}>
<slot /> <slot name="data" />
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content {side}> <Tooltip.Content {side}>
<div class="flex flex-row items-center"> <slot name="tooltip" />
<span>{label}</span>
<slot name="extra" />
</div>
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { settings } from '$lib/db';
const { showWelcomeMessage } = settings;
</script>
<AlertDialog.Root
open={$showWelcomeMessage === true}
closeOnEscape={false}
closeOnOutsideClick={false}
onOpenChange={() => ($showWelcomeMessage = false)}
>
<AlertDialog.Trigger class="hidden"></AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>
Welcome to the new version of <b>gpx.studio</b>!
</AlertDialog.Title>
<AlertDialog.Description class="space-y-1">
<p>The website is still under development and may contain bugs.</p>
<p>Please report any issues you find by email or on GitHub.</p>
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Action>Let's go!</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>

View File

@@ -2,13 +2,10 @@
import { settings } from '$lib/db'; import { settings } from '$lib/db';
import { import {
celsiusToFahrenheit, celsiusToFahrenheit,
getConvertedDistance, distancePerHourToSecondsPerDistance,
getConvertedElevation, kilometersToMiles,
getConvertedVelocity, metersToFeet,
getDistanceUnits, secondsToHHMMSS
getElevationUnits,
getVelocityUnits,
secondsToHHMMSS,
} from '$lib/units'; } from '$lib/units';
import { _ } from 'svelte-i18n'; import { _ } from 'svelte-i18n';
@@ -23,18 +20,31 @@
<span class={$$props.class}> <span class={$$props.class}>
{#if type === 'distance'} {#if type === 'distance'}
{getConvertedDistance(value, $distanceUnits).toFixed(decimals ?? 2)} {#if $distanceUnits === 'metric'}
{showUnits ? getDistanceUnits($distanceUnits) : ''} {value.toFixed(decimals ?? 2)} {showUnits ? $_('units.kilometers') : ''}
{:else if type === 'elevation'}
{getConvertedElevation(value, $distanceUnits).toFixed(decimals ?? 0)}
{showUnits ? getElevationUnits($distanceUnits) : ''}
{:else if type === 'speed'}
{#if $velocityUnits === 'speed'}
{getConvertedVelocity(value, $velocityUnits, $distanceUnits).toFixed(decimals ?? 2)}
{showUnits ? getVelocityUnits($velocityUnits, $distanceUnits) : ''}
{:else} {:else}
{secondsToHHMMSS(getConvertedVelocity(value, $velocityUnits, $distanceUnits))} {kilometersToMiles(value).toFixed(decimals ?? 2)} {showUnits ? $_('units.miles') : ''}
{showUnits ? getVelocityUnits($velocityUnits, $distanceUnits) : ''} {/if}
{:else if type === 'elevation'}
{#if $distanceUnits === 'metric'}
{value.toFixed(decimals ?? 0)} {showUnits ? $_('units.meters') : ''}
{:else}
{metersToFeet(value).toFixed(decimals ?? 0)} {showUnits ? $_('units.feet') : ''}
{/if}
{:else if type === 'speed'}
{#if $distanceUnits === 'metric'}
{#if $velocityUnits === 'speed'}
{value.toFixed(decimals ?? 2)} {showUnits ? $_('units.kilometers_per_hour') : ''}
{:else}
{secondsToHHMMSS(distancePerHourToSecondsPerDistance(value))}
{showUnits ? $_('units.minutes_per_kilometer') : ''}
{/if}
{:else if $velocityUnits === 'speed'}
{kilometersToMiles(value).toFixed(decimals ?? 2)}
{showUnits ? $_('units.miles_per_hour') : ''}
{:else}
{secondsToHHMMSS(distancePerHourToSecondsPerDistance(kilometersToMiles(value)))}
{showUnits ? $_('units.minutes_per_mile') : ''}
{/if} {/if}
{:else if type === 'temperature'} {:else if type === 'temperature'}
{#if $temperatureUnits === 'celsius'} {#if $temperatureUnits === 'celsius'}

View File

@@ -1,83 +0,0 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
export let module;
</script>
<div class="markdown flex flex-col gap-3">
<svelte:component this={module} />
</div>
<style lang="postcss">
:global(.markdown) {
@apply text-muted-foreground;
}
:global(.markdown h1) {
@apply text-foreground;
@apply text-3xl;
@apply font-semibold;
@apply mb-3 pt-6;
}
:global(.markdown h2) {
@apply text-foreground;
@apply text-2xl;
@apply font-semibold;
@apply pt-3;
}
:global(.markdown h3) {
@apply text-foreground;
@apply text-lg;
@apply font-semibold;
@apply pt-1.5;
}
:global(.markdown p > button, .markdown li > button) {
@apply border;
@apply rounded-md;
@apply px-1;
}
:global(.markdown > a) {
@apply text-link;
@apply hover:underline;
@apply contents;
}
:global(.markdown p > a) {
@apply text-link;
@apply hover:underline;
}
:global(.markdown li > a) {
@apply text-link;
@apply hover:underline;
}
:global(.markdown kbd) {
@apply p-1;
@apply rounded-md;
@apply border;
}
:global(.markdown ul) {
@apply list-disc;
@apply pl-4;
}
:global(.markdown ol) {
@apply list-decimal;
@apply pl-4;
}
:global(.markdown li) {
@apply mt-1;
@apply first:mt-0;
}
:global(.markdown hr) {
@apply my-5;
}
</style>

View File

@@ -1,29 +1,11 @@
<script lang="ts"> <script lang="ts">
export let src: 'getting-started/interface' | 'tools/routing' | 'tools/split'; export let src;
export let alt: string; export let alt: string;
</script> </script>
<div class="flex flex-col items-center py-6 w-full"> <div class="flex flex-col items-center py-6 w-full">
<div class="rounded-md overflow-hidden overflow-clip shadow-xl mx-auto"> <div class="rounded-md overflow-clip shadow-xl mx-auto">
{#if src === 'getting-started/interface'} <enhanced:img {src} {alt} class="w-full max-w-3xl" />
<enhanced:img
src="/src/lib/assets/img/docs/getting-started/interface.png"
{alt}
class="w-full max-w-3xl"
/>
{:else if src === 'tools/routing'}
<enhanced:img
src="/src/lib/assets/img/docs/tools/routing.png"
{alt}
class="w-full max-w-3xl"
/>
{:else if src === 'tools/split'}
<enhanced:img
src="/src/lib/assets/img/docs/tools/split.png"
{alt}
class="w-full max-w-3xl"
/>
{/if}
</div> </div>
<p class="text-center text-sm text-muted-foreground mt-2">{alt}</p> <p class="text-center text-sm text-muted-foreground mt-2">{alt}</p>
</div> </div>

View File

@@ -0,0 +1,112 @@
<script lang="ts">
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { _, locale } from 'svelte-i18n';
export let path: string;
export let titleOnly: boolean = false;
let module = undefined;
let metadata: Record<string, any> = {};
const modules = import.meta.glob('/src/lib/docs/**/*.mdx');
function loadModule(path: string) {
modules[path]?.().then((mod) => {
module = mod.default;
metadata = mod.metadata;
});
}
$: if ($locale) {
if (modules.hasOwnProperty(`/src/lib/docs/${$locale}/${path}`)) {
loadModule(`/src/lib/docs/${$locale}/${path}`);
} else if (browser) {
goto(`${base}/404`);
}
}
</script>
{#if module !== undefined}
{#if titleOnly}
{metadata.title}
{:else}
<div class="markdown flex flex-col gap-3">
<svelte:component this={module} />
</div>
{/if}
{/if}
<style lang="postcss">
:global(.markdown) {
@apply text-muted-foreground;
}
:global(.markdown h1) {
@apply text-foreground;
@apply text-3xl;
@apply font-semibold;
@apply mb-3 pt-6;
}
:global(.markdown h2) {
@apply text-foreground;
@apply text-2xl;
@apply font-semibold;
@apply pt-3;
}
:global(.markdown h3) {
@apply text-foreground;
@apply text-lg;
@apply font-semibold;
@apply pt-1.5;
}
:global(.markdown p > button) {
@apply border;
@apply rounded-md;
@apply px-1;
}
:global(.markdown > a) {
@apply text-blue-500;
@apply hover:underline;
}
:global(.markdown p > a) {
@apply text-blue-500;
@apply hover:underline;
}
:global(.markdown li > a) {
@apply text-blue-500;
@apply hover:underline;
}
:global(.markdown kbd) {
@apply p-1;
@apply rounded-md;
@apply border;
}
:global(.markdown ul) {
@apply list-disc;
@apply pl-4;
}
:global(.markdown ol) {
@apply list-decimal;
@apply pl-4;
}
:global(.markdown li) {
@apply mt-1;
@apply first:mt-0;
}
:global(.markdown hr) {
@apply my-5;
}
</style>

View File

@@ -3,8 +3,8 @@
</script> </script>
<div <div
class="bg-secondary border-l-8 {type === 'note' class="bg-accent border-l-8 {type === 'note'
? 'border-link' ? 'border-blue-500'
: 'border-destructive'} p-2 text-sm rounded-md" : 'border-destructive'} p-2 text-sm rounded-md"
> >
<slot /> <slot />
@@ -12,7 +12,7 @@
<style lang="postcss"> <style lang="postcss">
div :global(a) { div :global(a) {
@apply text-link; @apply text-blue-500;
@apply hover:underline; @apply hover:underline;
} }
</style> </style>

View File

@@ -1,64 +1,36 @@
import { import { File, FilePen, View, type Icon, Settings, Pencil, MapPin, Scissors, CalendarClock, Group, Ungroup, Filter, SquareDashedMousePointer } from "lucide-svelte";
File, import type { ComponentType } from "svelte";
FilePen,
View,
type Icon,
Settings,
Pencil,
MapPin,
Scissors,
CalendarClock,
Group,
Ungroup,
Filter,
SquareDashedMousePointer,
MountainSnow,
} from 'lucide-svelte';
import type { ComponentType } from 'svelte';
export const guides: Record<string, string[]> = { export const guides: Record<string, string[]> = {
'getting-started': [], 'getting-started': [],
menu: ['file', 'edit', 'view', 'settings'], menu: ['file', 'edit', 'view', 'settings'],
'files-and-stats': [], 'files-and-stats': [],
toolbar: [ toolbar: ['routing', 'poi', 'scissors', 'time', 'merge', 'extract', 'minify', 'clean'],
'routing',
'poi',
'scissors',
'time',
'merge',
'extract',
'elevation',
'minify',
'clean',
],
'map-controls': [], 'map-controls': [],
gpx: [], 'gpx': [],
integration: [], 'integration': [],
faq: [],
}; };
export const guideIcons: Record<string, string | ComponentType<Icon>> = { export const guideIcons: Record<string, string | ComponentType<Icon>> = {
'getting-started': '🚀', "getting-started": "🚀",
menu: '📂 ⚙️', "menu": "📂 ⚙️",
file: File, "file": File,
edit: FilePen, "edit": FilePen,
view: View, "view": View,
settings: Settings, "settings": Settings,
'files-and-stats': '🗂 📈', "files-and-stats": "🗂 📈",
toolbar: '🧰', "toolbar": "🧰",
routing: Pencil, "routing": Pencil,
poi: MapPin, "poi": MapPin,
scissors: Scissors, "scissors": Scissors,
time: CalendarClock, "time": CalendarClock,
merge: Group, "merge": Group,
extract: Ungroup, "extract": Ungroup,
elevation: MountainSnow, "minify": Filter,
minify: Filter, "clean": SquareDashedMousePointer,
clean: SquareDashedMousePointer, "map-controls": "🗺",
'map-controls': '🗺', "gpx": "💾",
gpx: '💾', "integration": "{ 👩‍💻 }",
integration: '{ 👩‍💻 }',
faq: '🔮',
}; };
export function getPreviousGuide(currentGuide: string): string | undefined { export function getPreviousGuide(currentGuide: string): string | undefined {

View File

@@ -12,7 +12,7 @@
embedding, embedding,
loadFile, loadFile,
map, map,
updateGPXData, updateGPXData
} from '$lib/stores'; } from '$lib/stores';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { fileObservers, settings, GPXStatisticsTree } from '$lib/db'; import { fileObservers, settings, GPXStatisticsTree } from '$lib/db';
@@ -20,13 +20,8 @@
import type { GPXFile } from 'gpx'; import type { GPXFile } from 'gpx';
import { selection } from '$lib/components/file-list/Selection'; import { selection } from '$lib/components/file-list/Selection';
import { ListFileItem } from '$lib/components/file-list/FileList'; import { ListFileItem } from '$lib/components/file-list/FileList';
import { import { allowedEmbeddingBasemaps, type EmbeddingOptions } from './Embedding';
allowedEmbeddingBasemaps,
getFilesFromEmbeddingOptions,
type EmbeddingOptions,
} from './Embedding';
import { mode, setMode } from 'mode-watcher'; import { mode, setMode } from 'mode-watcher';
import { browser } from '$app/environment';
$embedding = true; $embedding = true;
@@ -37,7 +32,7 @@
temperatureUnits, temperatureUnits,
fileOrder, fileOrder,
distanceMarkers, distanceMarkers,
directionMarkers, directionMarkers
} = settings; } = settings;
export let useHash = true; export let useHash = true;
@@ -50,7 +45,7 @@
distanceUnits: 'metric', distanceUnits: 'metric',
velocityUnits: 'speed', velocityUnits: 'speed',
temperatureUnits: 'celsius', temperatureUnits: 'celsius',
theme: 'system', theme: 'system'
}; };
function applyOptions() { function applyOptions() {
@@ -60,7 +55,7 @@
}); });
let downloads: Promise<GPXFile | null>[] = []; let downloads: Promise<GPXFile | null>[] = [];
getFilesFromEmbeddingOptions(options).forEach((url) => { options.files.forEach((url) => {
downloads.push( downloads.push(
fetch(url) fetch(url)
.then((response) => response.blob()) .then((response) => response.blob())
@@ -74,12 +69,12 @@
let bounds = { let bounds = {
southWest: { southWest: {
lat: 90, lat: 90,
lon: 180, lon: 180
}, },
northEast: { northEast: {
lat: -90, lat: -90,
lon: -180, lon: -180
}, }
}; };
fileObservers.update(($fileObservers) => { fileObservers.update(($fileObservers) => {
@@ -96,13 +91,12 @@
id, id,
readable({ readable({
file, file,
statistics, statistics
}) })
); );
ids.push(id); ids.push(id);
let fileBounds = statistics.getStatisticsFor(new ListFileItem(id)).global let fileBounds = statistics.getStatisticsFor(new ListFileItem(id)).global.bounds;
.bounds;
bounds.southWest.lat = Math.min(bounds.southWest.lat, fileBounds.southWest.lat); bounds.southWest.lat = Math.min(bounds.southWest.lat, fileBounds.southWest.lat);
bounds.southWest.lon = Math.min(bounds.southWest.lon, fileBounds.southWest.lon); bounds.southWest.lon = Math.min(bounds.southWest.lon, fileBounds.southWest.lon);
@@ -131,12 +125,12 @@
bounds.southWest.lon, bounds.southWest.lon,
bounds.southWest.lat, bounds.southWest.lat,
bounds.northEast.lon, bounds.northEast.lon,
bounds.northEast.lat, bounds.northEast.lat
], ],
{ {
padding: 80, padding: 80,
linear: true, linear: true,
easing: () => 1, easing: () => 1
} }
); );
} }
@@ -144,10 +138,7 @@
} }
}); });
if ( if (options.basemap !== $currentBasemap && allowedEmbeddingBasemaps.includes(options.basemap)) {
options.basemap !== $currentBasemap &&
allowedEmbeddingBasemaps.includes(options.basemap)
) {
$currentBasemap = options.basemap; $currentBasemap = options.basemap;
} }
@@ -185,7 +176,7 @@
prevSettings.theme = $mode ?? 'system'; prevSettings.theme = $mode ?? 'system';
}); });
$: if (browser && options) { $: if (options) {
applyOptions(); applyOptions();
} }
@@ -233,7 +224,7 @@
geolocate={false} geolocate={false}
hash={useHash} hash={useHash}
/> />
<OpenIn bind:files={options.files} bind:ids={options.ids} /> <OpenIn bind:files={options.files} />
<LayerControl /> <LayerControl />
<GPXLayers /> <GPXLayers />
{#if $fileObservers.size > 1} {#if $fileObservers.size > 1}
@@ -261,10 +252,12 @@
options.elevation.hr ? 'hr' : null, options.elevation.hr ? 'hr' : null,
options.elevation.cad ? 'cad' : null, options.elevation.cad ? 'cad' : null,
options.elevation.temp ? 'temp' : null, options.elevation.temp ? 'temp' : null,
options.elevation.power ? 'power' : null, options.elevation.power ? 'power' : null
].filter((dataset) => dataset !== null)} ].filter((dataset) => dataset !== null)}
elevationFill={options.elevation.fill} elevationFill={options.elevation.fill}
panelSize={options.elevation.height}
showControls={options.elevation.controls} showControls={options.elevation.controls}
class="py-2"
/> />
{/if} {/if}
</div> </div>

View File

@@ -1,34 +1,31 @@
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public'; import { basemaps } from "$lib/assets/layers";
import { basemaps } from '$lib/assets/layers';
export type EmbeddingOptions = { export type EmbeddingOptions = {
token: string; token: string;
files: string[]; files: string[];
ids: string[];
basemap: string; basemap: string;
elevation: { elevation: {
show: boolean; show: boolean;
height: number; height: number,
controls: boolean; controls: boolean,
fill: 'slope' | 'surface' | 'highway' | undefined; fill: 'slope' | 'surface' | undefined,
speed: boolean; speed: boolean,
hr: boolean; hr: boolean,
cad: boolean; cad: boolean,
temp: boolean; temp: boolean,
power: boolean; power: boolean,
}; },
distanceMarkers: boolean; distanceMarkers: boolean,
directionMarkers: boolean; directionMarkers: boolean,
distanceUnits: 'metric' | 'imperial' | 'nautical'; distanceUnits: 'metric' | 'imperial',
velocityUnits: 'speed' | 'pace'; velocityUnits: 'speed' | 'pace',
temperatureUnits: 'celsius' | 'fahrenheit'; temperatureUnits: 'celsius' | 'fahrenheit',
theme: 'system' | 'light' | 'dark'; theme: 'system' | 'light' | 'dark',
}; };
export const defaultEmbeddingOptions = { export const defaultEmbeddingOptions = {
token: '', token: '',
files: [], files: [],
ids: [],
basemap: 'mapboxOutdoors', basemap: 'mapboxOutdoors',
elevation: { elevation: {
show: true, show: true,
@@ -53,17 +50,10 @@ export function getDefaultEmbeddingOptions(): EmbeddingOptions {
return JSON.parse(JSON.stringify(defaultEmbeddingOptions)); return JSON.parse(JSON.stringify(defaultEmbeddingOptions));
} }
export function getMergedEmbeddingOptions( export function getMergedEmbeddingOptions(options: any, defaultOptions: any = defaultEmbeddingOptions): EmbeddingOptions {
options: any,
defaultOptions: any = defaultEmbeddingOptions
): EmbeddingOptions {
const mergedOptions = JSON.parse(JSON.stringify(defaultOptions)); const mergedOptions = JSON.parse(JSON.stringify(defaultOptions));
for (const key in options) { for (const key in options) {
if ( if (typeof options[key] === 'object' && options[key] !== null && !Array.isArray(options[key])) {
typeof options[key] === 'object' &&
options[key] !== null &&
!Array.isArray(options[key])
) {
mergedOptions[key] = getMergedEmbeddingOptions(options[key], defaultOptions[key]); mergedOptions[key] = getMergedEmbeddingOptions(options[key], defaultOptions[key]);
} else { } else {
mergedOptions[key] = options[key]; mergedOptions[key] = options[key];
@@ -72,21 +62,11 @@ export function getMergedEmbeddingOptions(
return mergedOptions; return mergedOptions;
} }
export function getCleanedEmbeddingOptions( export function getCleanedEmbeddingOptions(options: any, defaultOptions: any = defaultEmbeddingOptions): any {
options: any,
defaultOptions: any = defaultEmbeddingOptions
): any {
const cleanedOptions = JSON.parse(JSON.stringify(options)); const cleanedOptions = JSON.parse(JSON.stringify(options));
for (const key in cleanedOptions) { for (const key in cleanedOptions) {
if ( if (typeof cleanedOptions[key] === 'object' && cleanedOptions[key] !== null && !Array.isArray(cleanedOptions[key])) {
typeof cleanedOptions[key] === 'object' && cleanedOptions[key] = getCleanedEmbeddingOptions(cleanedOptions[key], defaultOptions[key]);
cleanedOptions[key] !== null &&
!Array.isArray(cleanedOptions[key])
) {
cleanedOptions[key] = getCleanedEmbeddingOptions(
cleanedOptions[key],
defaultOptions[key]
);
if (Object.keys(cleanedOptions[key]).length === 0) { if (Object.keys(cleanedOptions[key]).length === 0) {
delete cleanedOptions[key]; delete cleanedOptions[key];
} }
@@ -97,59 +77,4 @@ export function getCleanedEmbeddingOptions(
return cleanedOptions; return cleanedOptions;
} }
export const allowedEmbeddingBasemaps = Object.keys(basemaps).filter( export const allowedEmbeddingBasemaps = Object.keys(basemaps).filter(basemap => !['ordnanceSurvey'].includes(basemap));
(basemap) => !['ordnanceSurvey'].includes(basemap)
);
export function getFilesFromEmbeddingOptions(options: EmbeddingOptions): string[] {
return options.files.concat(options.ids.map((id) => getURLForGoogleDriveFile(id)));
}
export function getURLForGoogleDriveFile(fileId: string): string {
return `https://www.googleapis.com/drive/v3/files/${fileId}?alt=media&key=AIzaSyA2ZadQob_hXiT2VaYIkAyafPvz_4ZMssk`;
}
export function convertOldEmbeddingOptions(options: URLSearchParams): any {
let newOptions: any = {
token: PUBLIC_MAPBOX_TOKEN,
files: [],
ids: [],
};
if (options.has('state')) {
let state = JSON.parse(options.get('state')!);
if (state.ids) {
newOptions.ids.push(...state.ids);
}
if (state.urls) {
newOptions.files.push(...state.urls);
}
}
if (options.has('source')) {
let basemap = options.get('source')!;
if (basemap === 'satellite') {
newOptions.basemap = 'mapboxSatellite';
} else if (basemap === 'otm') {
newOptions.basemap = 'openTopoMap';
} else if (basemap === 'ohm') {
newOptions.basemap = 'openHikingMap';
}
}
if (options.has('imperial')) {
newOptions.distanceUnits = 'imperial';
}
if (options.has('running')) {
newOptions.velocityUnits = 'pace';
}
if (options.has('distance')) {
newOptions.distanceMarkers = true;
}
if (options.has('direction')) {
newOptions.directionMarkers = true;
}
if (options.has('slope')) {
newOptions.elevation = {
fill: 'slope',
};
}
return newOptions;
}

View File

@@ -13,13 +13,13 @@
SquareActivity, SquareActivity,
Coins, Coins,
Milestone, Milestone,
Video, Video
} from 'lucide-svelte'; } from 'lucide-svelte';
import { _ } from 'svelte-i18n'; import { _ } from 'svelte-i18n';
import { import {
allowedEmbeddingBasemaps, allowedEmbeddingBasemaps,
getCleanedEmbeddingOptions, getCleanedEmbeddingOptions,
getDefaultEmbeddingOptions, getDefaultEmbeddingOptions
} from './Embedding'; } from './Embedding';
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public'; import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
import Embedding from './Embedding.svelte'; import Embedding from './Embedding.svelte';
@@ -30,25 +30,17 @@
let options = getDefaultEmbeddingOptions(); let options = getDefaultEmbeddingOptions();
options.token = 'YOUR_MAPBOX_TOKEN'; options.token = 'YOUR_MAPBOX_TOKEN';
options.files = [ options.files = [
'https://raw.githubusercontent.com/gpxstudio/gpx.studio/main/gpx/test-data/simple.gpx', 'https://raw.githubusercontent.com/gpxstudio/gpx.studio/main/gpx/test-data/simple.gpx'
]; ];
let files = options.files[0]; let files = options.files[0];
$: { $: if (files) {
let urls = files.split(','); let urls = files.split(',');
urls = urls.filter((url) => url.length > 0); urls = urls.filter((url) => url.length > 0);
if (JSON.stringify(urls) !== JSON.stringify(options.files)) { if (JSON.stringify(urls) !== JSON.stringify(options.files)) {
options.files = urls; options.files = urls;
} }
} }
let driveIds = '';
$: {
let ids = driveIds.split(',');
ids = ids.filter((id) => id.length > 0);
if (JSON.stringify(ids) !== JSON.stringify(options.ids)) {
options.ids = ids;
}
}
let manualCamera = false; let manualCamera = false;
@@ -92,7 +84,7 @@
} }
</script> </script>
<Card.Root id="embedding-playground"> <Card.Root>
<Card.Header> <Card.Header>
<Card.Title>{$_('embedding.title')}</Card.Title> <Card.Title>{$_('embedding.title')}</Card.Title>
</Card.Header> </Card.Header>
@@ -102,8 +94,6 @@
<Input id="token" type="text" class="h-8" bind:value={options.token} /> <Input id="token" type="text" class="h-8" bind:value={options.token} />
<Label for="file_urls">{$_('embedding.file_urls')}</Label> <Label for="file_urls">{$_('embedding.file_urls')}</Label>
<Input id="file_urls" type="text" class="h-8" bind:value={files} /> <Input id="file_urls" type="text" class="h-8" bind:value={files} />
<Label for="drive_ids">{$_('embedding.drive_ids')}</Label>
<Input id="drive_ids" type="text" class="h-8" bind:value={driveIds} />
<Label for="basemap">{$_('embedding.basemap')}</Label> <Label for="basemap">{$_('embedding.basemap')}</Label>
<Select.Root <Select.Root
selected={{ value: options.basemap, label: $_(`layers.label.${options.basemap}`) }} selected={{ value: options.basemap, label: $_(`layers.label.${options.basemap}`) }}
@@ -130,11 +120,7 @@
<div class="grid grid-cols-2 gap-x-6 gap-y-3 rounded-md border p-3 mt-1"> <div class="grid grid-cols-2 gap-x-6 gap-y-3 rounded-md border p-3 mt-1">
<Label class="flex flex-row items-center gap-2"> <Label class="flex flex-row items-center gap-2">
{$_('embedding.height')} {$_('embedding.height')}
<Input <Input type="number" bind:value={options.elevation.height} class="h-8 w-20" />
type="number"
bind:value={options.elevation.height}
class="h-8 w-20"
/>
</Label> </Label>
<div class="flex flex-row items-center gap-2"> <div class="flex flex-row items-center gap-2">
<span class="shrink-0"> <span class="shrink-0">
@@ -146,11 +132,7 @@
let value = selected?.value; let value = selected?.value;
if (value === 'none') { if (value === 'none') {
options.elevation.fill = undefined; options.elevation.fill = undefined;
} else if ( } else if (value === 'slope' || value === 'surface') {
value === 'slope' ||
value === 'surface' ||
value === 'highway'
) {
options.elevation.fill = value; options.elevation.fill = value;
} }
}} }}
@@ -160,10 +142,7 @@
</Select.Trigger> </Select.Trigger>
<Select.Content> <Select.Content>
<Select.Item value="slope">{$_('quantities.slope')}</Select.Item> <Select.Item value="slope">{$_('quantities.slope')}</Select.Item>
<Select.Item value="surface">{$_('quantities.surface')}</Select.Item <Select.Item value="surface">{$_('quantities.surface')}</Select.Item>
>
<Select.Item value="highway">{$_('quantities.highway')}</Select.Item
>
<Select.Item value="none">{$_('embedding.none')}</Select.Item> <Select.Item value="none">{$_('embedding.none')}</Select.Item>
</Select.Content> </Select.Content>
</Select.Root> </Select.Root>
@@ -176,35 +155,35 @@
<Checkbox id="show-speed" bind:checked={options.elevation.speed} /> <Checkbox id="show-speed" bind:checked={options.elevation.speed} />
<Label for="show-speed" class="flex flex-row items-center gap-1"> <Label for="show-speed" class="flex flex-row items-center gap-1">
<Zap size="16" /> <Zap size="16" />
{$_('quantities.speed')} {$_('chart.show_speed')}
</Label> </Label>
</div> </div>
<div class="flex flex-row items-center gap-2"> <div class="flex flex-row items-center gap-2">
<Checkbox id="show-hr" bind:checked={options.elevation.hr} /> <Checkbox id="show-hr" bind:checked={options.elevation.hr} />
<Label for="show-hr" class="flex flex-row items-center gap-1"> <Label for="show-hr" class="flex flex-row items-center gap-1">
<HeartPulse size="16" /> <HeartPulse size="16" />
{$_('quantities.heartrate')} {$_('chart.show_heartrate')}
</Label> </Label>
</div> </div>
<div class="flex flex-row items-center gap-2"> <div class="flex flex-row items-center gap-2">
<Checkbox id="show-cad" bind:checked={options.elevation.cad} /> <Checkbox id="show-cad" bind:checked={options.elevation.cad} />
<Label for="show-cad" class="flex flex-row items-center gap-1"> <Label for="show-cad" class="flex flex-row items-center gap-1">
<Orbit size="16" /> <Orbit size="16" />
{$_('quantities.cadence')} {$_('chart.show_cadence')}
</Label> </Label>
</div> </div>
<div class="flex flex-row items-center gap-2"> <div class="flex flex-row items-center gap-2">
<Checkbox id="show-temp" bind:checked={options.elevation.temp} /> <Checkbox id="show-temp" bind:checked={options.elevation.temp} />
<Label for="show-temp" class="flex flex-row items-center gap-1"> <Label for="show-temp" class="flex flex-row items-center gap-1">
<Thermometer size="16" /> <Thermometer size="16" />
{$_('quantities.temperature')} {$_('chart.show_temperature')}
</Label> </Label>
</div> </div>
<div class="flex flex-row items-center gap-2"> <div class="flex flex-row items-center gap-2">
<Checkbox id="show-power" bind:checked={options.elevation.power} /> <Checkbox id="show-power" bind:checked={options.elevation.power} />
<Label for="show-power" class="flex flex-row items-center gap-1"> <Label for="show-power" class="flex flex-row items-center gap-1">
<SquareActivity size="16" /> <SquareActivity size="16" />
{$_('quantities.power')} {$_('chart.show_power')}
</Label> </Label>
</div> </div>
</div> </div>
@@ -235,10 +214,6 @@
<RadioGroup.Item value="imperial" id="imperial" /> <RadioGroup.Item value="imperial" id="imperial" />
<Label for="imperial">{$_('menu.imperial')}</Label> <Label for="imperial">{$_('menu.imperial')}</Label>
</div> </div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="nautical" id="nautical" />
<Label for="nautical">{$_('menu.nautical')}</Label>
</div>
</RadioGroup.Root> </RadioGroup.Root>
</Label> </Label>
<Label class="flex flex-col items-start gap-2"> <Label class="flex flex-col items-start gap-2">
@@ -328,8 +303,7 @@
<Label> <Label>
{$_('embedding.code')} {$_('embedding.code')}
</Label> </Label>
<pre <pre class="bg-primary text-primary-foreground p-3 rounded-md whitespace-normal break-all">
class="bg-primary text-primary-foreground p-3 rounded-md whitespace-normal break-all">
<code class="language-html"> <code class="language-html">
{`<iframe src="https://gpx.studio${base}/embed?options=${encodeURIComponent(JSON.stringify(getCleanedEmbeddingOptions(options)))}${hash}" width="100%" height="600px" frameborder="0" style="outline: none;"/>`} {`<iframe src="https://gpx.studio${base}/embed?options=${encodeURIComponent(JSON.stringify(getCleanedEmbeddingOptions(options)))}${hash}" width="100%" height="600px" frameborder="0" style="outline: none;"/>`}
</code> </code>

View File

@@ -5,17 +5,12 @@
import { _, locale } from 'svelte-i18n'; import { _, locale } from 'svelte-i18n';
export let files: string[]; export let files: string[];
export let ids: string[];
</script> </script>
<Button <Button
variant="ghost" variant="ghost"
class="absolute top-0 flex-wrap h-fit bg-background font-semibold rounded-md py-1 px-2 gap-1.5 xs:text-base mt-2.5 ml-2.5 mr-12" class="absolute top-0 flex-wrap h-fit bg-background font-semibold rounded-md py-1 px-2 gap-1.5 xs:text-base mt-2.5 ml-2.5 mr-12"
href="{getURLForLanguage($locale, '/app')}?{files.length > 0 href="{getURLForLanguage($locale, '/app')}?files={encodeURIComponent(JSON.stringify(files))}"
? `files=${encodeURIComponent(JSON.stringify(files))}`
: ''}{files.length > 0 && ids.length > 0 ? '&' : ''}{ids.length > 0
? `ids=${encodeURIComponent(JSON.stringify(ids))}`
: ''}"
target="_blank" target="_blank"
> >
{$_('menu.open_in')} {$_('menu.open_in')}

View File

@@ -17,9 +17,9 @@
setContext('orientation', orientation); setContext('orientation', orientation);
setContext('recursive', recursive); setContext('recursive', recursive);
const { treeFileView } = settings; const { verticalFileView } = settings;
treeFileView.subscribe(($vertical) => { verticalFileView.subscribe(($vertical) => {
if ($vertical) { if ($vertical) {
selection.update(($selection) => { selection.update(($selection) => {
$selection.forEach((item) => { $selection.forEach((item) => {

View File

@@ -1,8 +1,8 @@
import { dbUtils, getFile } from '$lib/db'; import { dbUtils, getFile } from "$lib/db";
import { freeze } from 'immer'; import { freeze } from "immer";
import { GPXFile, Track, TrackSegment, Waypoint } from 'gpx'; import { GPXFile, Track, TrackSegment, Waypoint } from "gpx";
import { selection } from './Selection'; import { selection } from "./Selection";
import { newGPXFile } from '$lib/stores'; import { newGPXFile } from "$lib/stores";
export enum ListLevel { export enum ListLevel {
ROOT, ROOT,
@@ -10,7 +10,7 @@ export enum ListLevel {
TRACK, TRACK,
SEGMENT, SEGMENT,
WAYPOINTS, WAYPOINTS,
WAYPOINT, WAYPOINT
} }
export const allowedMoves: Record<ListLevel, ListLevel[]> = { export const allowedMoves: Record<ListLevel, ListLevel[]> = {
@@ -19,7 +19,7 @@ export const allowedMoves: Record<ListLevel, ListLevel[]> = {
[ListLevel.TRACK]: [ListLevel.FILE, ListLevel.TRACK], [ListLevel.TRACK]: [ListLevel.FILE, ListLevel.TRACK],
[ListLevel.SEGMENT]: [ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT], [ListLevel.SEGMENT]: [ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT],
[ListLevel.WAYPOINTS]: [ListLevel.WAYPOINTS], [ListLevel.WAYPOINTS]: [ListLevel.WAYPOINTS],
[ListLevel.WAYPOINT]: [ListLevel.WAYPOINTS, ListLevel.WAYPOINT], [ListLevel.WAYPOINT]: [ListLevel.WAYPOINTS, ListLevel.WAYPOINT]
}; };
export const allowedPastes: Record<ListLevel, ListLevel[]> = { export const allowedPastes: Record<ListLevel, ListLevel[]> = {
@@ -28,7 +28,7 @@ export const allowedPastes: Record<ListLevel, ListLevel[]> = {
[ListLevel.TRACK]: [ListLevel.ROOT, ListLevel.FILE, ListLevel.TRACK], [ListLevel.TRACK]: [ListLevel.ROOT, ListLevel.FILE, ListLevel.TRACK],
[ListLevel.SEGMENT]: [ListLevel.ROOT, ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT], [ListLevel.SEGMENT]: [ListLevel.ROOT, ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT],
[ListLevel.WAYPOINTS]: [ListLevel.FILE, ListLevel.WAYPOINTS, ListLevel.WAYPOINT], [ListLevel.WAYPOINTS]: [ListLevel.FILE, ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
[ListLevel.WAYPOINT]: [ListLevel.FILE, ListLevel.WAYPOINTS, ListLevel.WAYPOINT], [ListLevel.WAYPOINT]: [ListLevel.FILE, ListLevel.WAYPOINTS, ListLevel.WAYPOINT]
}; };
export abstract class ListItem { export abstract class ListItem {
@@ -322,13 +322,7 @@ export function sortItems(items: ListItem[], reverse: boolean = false) {
} }
} }
export function moveItems( export function moveItems(fromParent: ListItem, toParent: ListItem, fromItems: ListItem[], toItems: ListItem[], remove: boolean = true) {
fromParent: ListItem,
toParent: ListItem,
fromItems: ListItem[],
toItems: ListItem[],
remove: boolean = true
) {
if (fromItems.length === 0) { if (fromItems.length === 0) {
return; return;
} }
@@ -344,18 +338,11 @@ export function moveItems(
context.push(file.clone()); context.push(file.clone());
} else if (item instanceof ListTrackItem && item.getTrackIndex() < file.trk.length) { } else if (item instanceof ListTrackItem && item.getTrackIndex() < file.trk.length) {
context.push(file.trk[item.getTrackIndex()].clone()); context.push(file.trk[item.getTrackIndex()].clone());
} else if ( } else if (item instanceof ListTrackSegmentItem && item.getTrackIndex() < file.trk.length && item.getSegmentIndex() < file.trk[item.getTrackIndex()].trkseg.length) {
item instanceof ListTrackSegmentItem &&
item.getTrackIndex() < file.trk.length &&
item.getSegmentIndex() < file.trk[item.getTrackIndex()].trkseg.length
) {
context.push(file.trk[item.getTrackIndex()].trkseg[item.getSegmentIndex()].clone()); context.push(file.trk[item.getTrackIndex()].trkseg[item.getSegmentIndex()].clone());
} else if (item instanceof ListWaypointsItem) { } else if (item instanceof ListWaypointsItem) {
context.push(file.wpt.map((wpt) => wpt.clone())); context.push(file.wpt.map((wpt) => wpt.clone()));
} else if ( } else if (item instanceof ListWaypointItem && item.getWaypointIndex() < file.wpt.length) {
item instanceof ListWaypointItem &&
item.getWaypointIndex() < file.wpt.length
) {
context.push(file.wpt[item.getWaypointIndex()].clone()); context.push(file.wpt[item.getWaypointIndex()].clone());
} }
} }
@@ -372,12 +359,7 @@ export function moveItems(
if (item instanceof ListTrackItem) { if (item instanceof ListTrackItem) {
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex(), []); file.replaceTracks(item.getTrackIndex(), item.getTrackIndex(), []);
} else if (item instanceof ListTrackSegmentItem) { } else if (item instanceof ListTrackSegmentItem) {
file.replaceTrackSegments( file.replaceTrackSegments(item.getTrackIndex(), item.getSegmentIndex(), item.getSegmentIndex(), []);
item.getTrackIndex(),
item.getSegmentIndex(),
item.getSegmentIndex(),
[]
);
} else if (item instanceof ListWaypointsItem) { } else if (item instanceof ListWaypointsItem) {
file.replaceWaypoints(0, file.wpt.length - 1, []); file.replaceWaypoints(0, file.wpt.length - 1, []);
} else if (item instanceof ListWaypointItem) { } else if (item instanceof ListWaypointItem) {
@@ -389,43 +371,25 @@ export function moveItems(
toItems.forEach((item, i) => { toItems.forEach((item, i) => {
if (item instanceof ListTrackItem) { if (item instanceof ListTrackItem) {
if (context[i] instanceof Track) { if (context[i] instanceof Track) {
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex() - 1, [ file.replaceTracks(item.getTrackIndex(), item.getTrackIndex() - 1, [context[i]]);
context[i],
]);
} else if (context[i] instanceof TrackSegment) { } else if (context[i] instanceof TrackSegment) {
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex() - 1, [ file.replaceTracks(item.getTrackIndex(), item.getTrackIndex() - 1, [new Track({
new Track({ trkseg: [context[i]]
trkseg: [context[i]], })]);
}),
]);
} }
} else if ( } else if (item instanceof ListTrackSegmentItem && context[i] instanceof TrackSegment) {
item instanceof ListTrackSegmentItem && file.replaceTrackSegments(item.getTrackIndex(), item.getSegmentIndex(), item.getSegmentIndex() - 1, [context[i]]);
context[i] instanceof TrackSegment
) {
file.replaceTrackSegments(
item.getTrackIndex(),
item.getSegmentIndex(),
item.getSegmentIndex() - 1,
[context[i]]
);
} else if (item instanceof ListWaypointsItem) { } else if (item instanceof ListWaypointsItem) {
if ( if (Array.isArray(context[i]) && context[i].length > 0 && context[i][0] instanceof Waypoint) {
Array.isArray(context[i]) &&
context[i].length > 0 &&
context[i][0] instanceof Waypoint
) {
file.replaceWaypoints(file.wpt.length, file.wpt.length - 1, context[i]); file.replaceWaypoints(file.wpt.length, file.wpt.length - 1, context[i]);
} else if (context[i] instanceof Waypoint) { } else if (context[i] instanceof Waypoint) {
file.replaceWaypoints(file.wpt.length, file.wpt.length - 1, [context[i]]); file.replaceWaypoints(file.wpt.length, file.wpt.length - 1, [context[i]]);
} }
} else if (item instanceof ListWaypointItem && context[i] instanceof Waypoint) { } else if (item instanceof ListWaypointItem && context[i] instanceof Waypoint) {
file.replaceWaypoints(item.getWaypointIndex(), item.getWaypointIndex() - 1, [ file.replaceWaypoints(item.getWaypointIndex(), item.getWaypointIndex() - 1, [context[i]]);
context[i],
]);
} }
}); });
}, }
]; ];
if (fromParent instanceof ListRootItem) { if (fromParent instanceof ListRootItem) {
@@ -436,10 +400,7 @@ export function moveItems(
callbacks.splice(0, 1); callbacks.splice(0, 1);
} }
dbUtils.applyEachToFilesAndGlobal( dbUtils.applyEachToFilesAndGlobal(files, callbacks, (files, context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[]) => {
files,
callbacks,
(files, context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[]) => {
toItems.forEach((item, i) => { toItems.forEach((item, i) => {
if (item instanceof ListFileItem) { if (item instanceof ListFileItem) {
if (context[i] instanceof GPXFile) { if (context[i] instanceof GPXFile) {
@@ -460,18 +421,14 @@ export function moveItems(
} else if (context[i] instanceof TrackSegment) { } else if (context[i] instanceof TrackSegment) {
let newFile = newGPXFile(); let newFile = newGPXFile();
newFile._data.id = item.getFileId(); newFile._data.id = item.getFileId();
newFile.replaceTracks(0, 0, [ newFile.replaceTracks(0, 0, [new Track({
new Track({ trkseg: [context[i]]
trkseg: [context[i]], })]);
}),
]);
files.set(item.getFileId(), freeze(newFile)); files.set(item.getFileId(), freeze(newFile));
} }
} }
}); });
}, }, context);
context
);
selection.update(($selection) => { selection.update(($selection) => {
$selection.clear(); $selection.clear();

View File

@@ -5,7 +5,7 @@
TrackSegment, TrackSegment,
Waypoint, Waypoint,
type AnyGPXTreeElement, type AnyGPXTreeElement,
type GPXTreeElement, type GPXTreeElement
} from 'gpx'; } from 'gpx';
import { CollapsibleTreeNode } from '$lib/components/collapsible-tree/index'; import { CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
import { settings, type GPXFileWithStatistics } from '$lib/db'; import { settings, type GPXFileWithStatistics } from '$lib/db';
@@ -19,7 +19,7 @@
ListWaypointItem, ListWaypointItem,
ListWaypointsItem, ListWaypointsItem,
type ListItem, type ListItem,
type ListTrackItem, type ListTrackItem
} from './FileList'; } from './FileList';
import { _ } from 'svelte-i18n'; import { _ } from 'svelte-i18n';
import { selection } from './Selection'; import { selection } from './Selection';
@@ -39,20 +39,19 @@
node instanceof GPXFile && item instanceof ListFileItem node instanceof GPXFile && item instanceof ListFileItem
? node.metadata.name ? node.metadata.name
: node instanceof Track : node instanceof Track
? (node.name ?? `${$_('gpx.track')} ${(item as ListTrackItem).trackIndex + 1}`) ? node.name ?? `${$_('gpx.track')} ${(item as ListTrackItem).trackIndex + 1}`
: node instanceof TrackSegment : node instanceof TrackSegment
? `${$_('gpx.segment')} ${(item as ListTrackSegmentItem).segmentIndex + 1}` ? `${$_('gpx.segment')} ${(item as ListTrackSegmentItem).segmentIndex + 1}`
: node instanceof Waypoint : node instanceof Waypoint
? (node.name ?? ? node.name ?? `${$_('gpx.waypoint')} ${(item as ListWaypointItem).waypointIndex + 1}`
`${$_('gpx.waypoint')} ${(item as ListWaypointItem).waypointIndex + 1}`)
: node instanceof GPXFile && item instanceof ListWaypointsItem : node instanceof GPXFile && item instanceof ListWaypointsItem
? $_('gpx.waypoints') ? $_('gpx.waypoints')
: ''; : '';
const { treeFileView } = settings; const { verticalFileView } = settings;
function openIfSelectedChild() { function openIfSelectedChild() {
if (collapsible && get(treeFileView) && $selection.hasAnyChildren(item, false)) { if (collapsible && get(verticalFileView) && $selection.hasAnyChildren(item, false)) {
collapsible.openNode(); collapsible.openNode();
} }
} }

View File

@@ -19,10 +19,9 @@
ListWaypointsItem, ListWaypointsItem,
allowedMoves, allowedMoves,
moveItems, moveItems,
type ListItem, type ListItem
} from './FileList'; } from './FileList';
import { selection } from './Selection'; import { selection } from './Selection';
import { isMac } from '$lib/utils';
import { _ } from 'svelte-i18n'; import { _ } from 'svelte-i18n';
export let node: export let node:
@@ -78,13 +77,8 @@
if ( if (
e.originalEvent && e.originalEvent &&
!( !(e.originalEvent.ctrlKey || e.originalEvent.metaKey || e.originalEvent.shiftKey) &&
e.originalEvent.ctrlKey || ($selection.size > 1 || !$selection.has(item.extend(getRealId(changed[0]))))
e.originalEvent.metaKey ||
e.originalEvent.shiftKey
) &&
($selection.size > 1 ||
!$selection.has(item.extend(getRealId(changed[0]))))
) { ) {
// Fix bug that sometimes causes a single select to be treated as a multi-select // Fix bug that sometimes causes a single select to be treated as a multi-select
$selection.clear(); $selection.clear();
@@ -113,7 +107,7 @@
Sortable.utils.select(element); Sortable.utils.select(element);
element.scrollIntoView({ element.scrollIntoView({
behavior: 'smooth', behavior: 'smooth',
block: 'nearest', block: 'nearest'
}); });
} else { } else {
Sortable.utils.deselect(element); Sortable.utils.deselect(element);
@@ -155,12 +149,12 @@
group: { group: {
name: sortableLevel, name: sortableLevel,
pull: allowedMoves[sortableLevel], pull: allowedMoves[sortableLevel],
put: true, put: true
}, },
direction: orientation, direction: orientation,
forceAutoScrollFallback: true, forceAutoScrollFallback: true,
multiDrag: true, multiDrag: true,
multiDragKey: isMac() ? 'Meta' : 'Ctrl', multiDragKey: 'Meta',
avoidImplicitDeselect: true, avoidImplicitDeselect: true,
onSelect: updateToSelection, onSelect: updateToSelection,
onDeselect: updateToSelection, onDeselect: updateToSelection,
@@ -197,9 +191,7 @@
fromItems = [fromItem.extend('waypoints')]; fromItems = [fromItem.extend('waypoints')];
} else { } else {
let oldIndices: number[] = let oldIndices: number[] =
e.oldIndicies.length > 0 e.oldIndicies.length > 0 ? e.oldIndicies.map((i) => i.index) : [e.oldIndex];
? e.oldIndicies.map((i) => i.index)
: [e.oldIndex];
oldIndices = oldIndices.filter((i) => i >= 0); oldIndices = oldIndices.filter((i) => i >= 0);
oldIndices.sort((a, b) => a - b); oldIndices.sort((a, b) => a - b);
@@ -214,9 +206,7 @@
} }
let newIndices: number[] = let newIndices: number[] =
e.newIndicies.length > 0 e.newIndicies.length > 0 ? e.newIndicies.map((i) => i.index) : [e.newIndex];
? e.newIndicies.map((i) => i.index)
: [e.newIndex];
newIndices = newIndices.filter((i) => i >= 0); newIndices = newIndices.filter((i) => i >= 0);
newIndices.sort((a, b) => a - b); newIndices.sort((a, b) => a - b);
@@ -233,16 +223,16 @@
moveItems(fromItem, toItem, fromItems, toItems); moveItems(fromItem, toItem, fromItems, toItems);
} }
}, }
}); });
Object.defineProperty(sortable, '_item', { Object.defineProperty(sortable, '_item', {
value: item, value: item,
writable: true, writable: true
}); });
Object.defineProperty(sortable, '_waypointRoot', { Object.defineProperty(sortable, '_waypointRoot', {
value: waypointRoot, value: waypointRoot,
writable: true, writable: true
}); });
} }

View File

@@ -15,10 +15,9 @@
EyeOff, EyeOff,
ClipboardCopy, ClipboardCopy,
ClipboardPaste, ClipboardPaste,
Maximize,
Scissors, Scissors,
FileStack, FileStack,
FileX, FileX
} from 'lucide-svelte'; } from 'lucide-svelte';
import { import {
ListFileItem, ListFileItem,
@@ -26,7 +25,7 @@
ListTrackItem, ListTrackItem,
ListWaypointItem, ListWaypointItem,
allowedPastes, allowedPastes,
type ListItem, type ListItem
} from './FileList'; } from './FileList';
import { import {
copied, copied,
@@ -36,25 +35,22 @@
pasteSelection, pasteSelection,
selectAll, selectAll,
selectItem, selectItem,
selection, selection
} from './Selection'; } from './Selection';
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { allHidden, editMetadata, editStyle, embedding, gpxLayers, map } from '$lib/stores';
import { import {
allHidden, GPXTreeElement,
editMetadata, Track,
editStyle, TrackSegment,
embedding, type AnyGPXTreeElement,
centerMapOnSelection, Waypoint,
gpxLayers, GPXFile
map, } from 'gpx';
} from '$lib/stores';
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
import { _ } from 'svelte-i18n'; import { _ } from 'svelte-i18n';
import MetadataDialog from './MetadataDialog.svelte'; import MetadataDialog from './MetadataDialog.svelte';
import StyleDialog from './StyleDialog.svelte'; import StyleDialog from './StyleDialog.svelte';
import { waypointPopup } from '$lib/components/gpx-layer/GPXLayerPopup';
import { getSymbolKey, symbols } from '$lib/assets/symbols';
export let node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint; export let node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
export let item: ListItem; export let item: ListItem;
@@ -70,14 +66,13 @@
nodeColors = []; nodeColors = [];
if (node instanceof GPXFile) { if (node instanceof GPXFile) {
let defaultColor = undefined; let style = node.getStyle();
let layer = gpxLayers.get(item.getFileId()); let layer = gpxLayers.get(item.getFileId());
if (layer) { if (layer) {
defaultColor = layer.layerColor; style.color.push(layer.layerColor);
} }
let style = node.getStyle(defaultColor);
style.color.forEach((c) => { style.color.forEach((c) => {
if (!nodeColors.includes(c)) { if (!nodeColors.includes(c)) {
nodeColors.push(c); nodeColors.push(c);
@@ -86,8 +81,8 @@
} else if (node instanceof Track) { } else if (node instanceof Track) {
let style = node.getStyle(); let style = node.getStyle();
if (style) { if (style) {
if (style['gpx_style:color'] && !nodeColors.includes(style['gpx_style:color'])) { if (style.color && !nodeColors.includes(style.color)) {
nodeColors.push(style['gpx_style:color']); nodeColors.push(style.color);
} }
} }
if (nodeColors.length === 0) { if (nodeColors.length === 0) {
@@ -99,8 +94,6 @@
} }
} }
$: symbolKey = node instanceof Waypoint ? getSymbolKey(node.sym) : undefined;
let openEditMetadata: boolean = false; let openEditMetadata: boolean = false;
let openEditStyle: boolean = false; let openEditStyle: boolean = false;
@@ -177,10 +170,7 @@
if (layer && file) { if (layer && file) {
let waypoint = file.wpt[item.getWaypointIndex()]; let waypoint = file.wpt[item.getWaypointIndex()];
if (waypoint) { if (waypoint) {
waypointPopup?.setItem({ layer.showWaypointPopup(waypoint);
item: waypoint,
fileId: item.getFileId(),
});
} }
} }
} }
@@ -189,7 +179,7 @@
if (item instanceof ListWaypointItem) { if (item instanceof ListWaypointItem) {
let layer = gpxLayers.get(item.getFileId()); let layer = gpxLayers.get(item.getFileId());
if (layer) { if (layer) {
waypointPopup?.setItem(null); layer.hideWaypointPopup();
} }
} }
}} }}
@@ -197,30 +187,16 @@
{#if item.level === ListLevel.SEGMENT} {#if item.level === ListLevel.SEGMENT}
<Waypoints size="16" class="mr-1 shrink-0" /> <Waypoints size="16" class="mr-1 shrink-0" />
{:else if item.level === ListLevel.WAYPOINT} {:else if item.level === ListLevel.WAYPOINT}
{#if symbolKey && symbols[symbolKey].icon}
<svelte:component
this={symbols[symbolKey].icon}
size="16"
class="mr-1 shrink-0"
/>
{:else}
<MapPin size="16" class="mr-1 shrink-0" /> <MapPin size="16" class="mr-1 shrink-0" />
{/if} {/if}
{/if} <span class="grow select-none truncate {orientation === 'vertical' ? 'last:mr-2' : ''}">
<span
class="grow select-none truncate {orientation === 'vertical'
? 'last:mr-2'
: ''}"
>
{label} {label}
</span> </span>
{#if hidden} {#if hidden}
<EyeOff <EyeOff
size="12" size="12"
class="shrink-0 mt-1 ml-1 {orientation === 'vertical' class="shrink-0 mt-1 ml-1 {orientation === 'vertical' ? 'mr-2' : ''} {item.level ===
? 'mr-2' ListLevel.SEGMENT || item.level === ListLevel.WAYPOINT
: ''} {item.level === ListLevel.SEGMENT ||
item.level === ListLevel.WAYPOINT
? 'mr-3' ? 'mr-3'
: ''}" : ''}"
/> />
@@ -263,7 +239,10 @@
{#if item instanceof ListFileItem} {#if item instanceof ListFileItem}
<ContextMenu.Item <ContextMenu.Item
disabled={!singleSelection} disabled={!singleSelection}
on:click={() => dbUtils.addNewTrack(item.getFileId())} on:click={() =>
dbUtils.applyToFile(item.getFileId(), (file) =>
file.replaceTracks(file.trk.length, file.trk.length, [new Track()])
)}
> >
<Plus size="16" class="mr-1" /> <Plus size="16" class="mr-1" />
{$_('menu.new_track')} {$_('menu.new_track')}
@@ -272,7 +251,17 @@
{:else if item instanceof ListTrackItem} {:else if item instanceof ListTrackItem}
<ContextMenu.Item <ContextMenu.Item
disabled={!singleSelection} disabled={!singleSelection}
on:click={() => dbUtils.addNewSegment(item.getFileId(), item.getTrackIndex())} on:click={() => {
let trackIndex = item.getTrackIndex();
dbUtils.applyToFile(item.getFileId(), (file) =>
file.replaceTrackSegments(
trackIndex,
file.trk[trackIndex].trkseg.length,
file.trk[trackIndex].trkseg.length,
[new TrackSegment()]
)
);
}}
> >
<Plus size="16" class="mr-1" /> <Plus size="16" class="mr-1" />
{$_('menu.new_segment')} {$_('menu.new_segment')}
@@ -286,13 +275,9 @@
{$_('menu.select_all')} {$_('menu.select_all')}
<Shortcut key="A" ctrl={true} /> <Shortcut key="A" ctrl={true} />
</ContextMenu.Item> </ContextMenu.Item>
{/if}
<ContextMenu.Item on:click={centerMapOnSelection}>
<Maximize size="16" class="mr-1" />
{$_('menu.center')}
<Shortcut key="⏎" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Separator /> <ContextMenu.Separator />
{/if}
{#if orientation === 'vertical'}
<ContextMenu.Item on:click={dbUtils.duplicateSelection}> <ContextMenu.Item on:click={dbUtils.duplicateSelection}>
<Copy size="16" class="mr-1" /> <Copy size="16" class="mr-1" />
{$_('menu.duplicate')} {$_('menu.duplicate')}
@@ -321,6 +306,7 @@
</ContextMenu.Item> </ContextMenu.Item>
{/if} {/if}
<ContextMenu.Separator /> <ContextMenu.Separator />
{/if}
<ContextMenu.Item on:click={dbUtils.deleteSelection}> <ContextMenu.Item on:click={dbUtils.deleteSelection}>
{#if item instanceof ListFileItem} {#if item instanceof ListFileItem}
<FileX size="16" class="mr-1" /> <FileX size="16" class="mr-1" />

View File

@@ -17,15 +17,15 @@
let name: string = let name: string =
node instanceof GPXFile node instanceof GPXFile
? (node.metadata.name ?? '') ? node.metadata.name ?? ''
: node instanceof Track : node instanceof Track
? (node.name ?? '') ? node.name ?? ''
: ''; : '';
let description: string = let description: string =
node instanceof GPXFile node instanceof GPXFile
? (node.metadata.desc ?? '') ? node.metadata.desc ?? ''
: node instanceof Track : node instanceof Track
? (node.desc ?? '') ? node.desc ?? ''
: ''; : '';
$: if (!open) { $: if (!open) {
@@ -47,9 +47,6 @@
if (item instanceof ListFileItem && node instanceof GPXFile) { if (item instanceof ListFileItem && node instanceof GPXFile) {
file.metadata.name = name; file.metadata.name = name;
file.metadata.desc = description; file.metadata.desc = description;
if (file.trk.length === 1) {
file.trk[0].name = name;
}
} else if (item instanceof ListTrackItem && node instanceof Track) { } else if (item instanceof ListTrackItem && node instanceof Track) {
file.trk[item.getTrackIndex()].name = name; file.trk[item.getTrackIndex()].name = name;
file.trk[item.getTrackIndex()].desc = description; file.trk[item.getTrackIndex()].desc = description;

View File

@@ -1,23 +1,12 @@
import { get, writable } from 'svelte/store'; import { get, writable } from "svelte/store";
import { import { ListFileItem, ListItem, ListRootItem, ListTrackItem, ListTrackSegmentItem, ListWaypointItem, ListLevel, sortItems, ListWaypointsItem, moveItems } from "./FileList";
ListFileItem, import { fileObservers, getFile, getFileIds, settings } from "$lib/db";
ListItem,
ListRootItem,
ListTrackItem,
ListTrackSegmentItem,
ListWaypointItem,
ListLevel,
sortItems,
ListWaypointsItem,
moveItems,
} from './FileList';
import { fileObservers, getFile, getFileIds, settings } from '$lib/db';
export class SelectionTreeType { export class SelectionTreeType {
item: ListItem; item: ListItem;
selected: boolean; selected: boolean;
children: { children: {
[key: string | number]: SelectionTreeType; [key: string | number]: SelectionTreeType
}; };
size: number = 0; size: number = 0;
@@ -78,11 +67,7 @@ export class SelectionTreeType {
} }
hasAnyParent(item: ListItem, self: boolean = true): boolean { hasAnyParent(item: ListItem, self: boolean = true): boolean {
if ( if (this.selected && this.item.level <= item.level && (self || this.item.level < item.level)) {
this.selected &&
this.item.level <= item.level &&
(self || this.item.level < item.level)
) {
return this.selected; return this.selected;
} }
let id = item.getIdAtLevel(this.item.level); let id = item.getIdAtLevel(this.item.level);
@@ -95,11 +80,7 @@ export class SelectionTreeType {
} }
hasAnyChildren(item: ListItem, self: boolean = true, ignoreIds?: (string | number)[]): boolean { hasAnyChildren(item: ListItem, self: boolean = true, ignoreIds?: (string | number)[]): boolean {
if ( if (this.selected && this.item.level >= item.level && (self || this.item.level > item.level)) {
this.selected &&
this.item.level >= item.level &&
(self || this.item.level > item.level)
) {
return this.selected; return this.selected;
} }
let id = item.getIdAtLevel(this.item.level); let id = item.getIdAtLevel(this.item.level);
@@ -150,7 +131,7 @@ export class SelectionTreeType {
delete this.children[id]; delete this.children[id];
} }
} }
} };
export const selection = writable<SelectionTreeType>(new SelectionTreeType(new ListRootItem())); export const selection = writable<SelectionTreeType>(new SelectionTreeType(new ListRootItem()));
@@ -200,10 +181,7 @@ export function selectAll() {
let file = getFile(item.getFileId()); let file = getFile(item.getFileId());
if (file) { if (file) {
file.trk[item.getTrackIndex()].trkseg.forEach((_segment, segmentId) => { file.trk[item.getTrackIndex()].trkseg.forEach((_segment, segmentId) => {
$selection.set( $selection.set(new ListTrackSegmentItem(item.getFileId(), item.getTrackIndex(), segmentId), true);
new ListTrackSegmentItem(item.getFileId(), item.getTrackIndex(), segmentId),
true
);
}); });
} }
} else if (item instanceof ListWaypointItem) { } else if (item instanceof ListWaypointItem) {
@@ -227,24 +205,14 @@ export function getOrderedSelection(reverse: boolean = false): ListItem[] {
return selected; return selected;
} }
export function applyToOrderedItemsFromFile( export function applyToOrderedItemsFromFile(selectedItems: ListItem[], callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void, reverse: boolean = true) {
selectedItems: ListItem[],
callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void,
reverse: boolean = true
) {
get(settings.fileOrder).forEach((fileId) => { get(settings.fileOrder).forEach((fileId) => {
let level: ListLevel | undefined = undefined; let level: ListLevel | undefined = undefined;
let items: ListItem[] = []; let items: ListItem[] = [];
selectedItems.forEach((item) => { selectedItems.forEach((item) => {
if (item.getFileId() === fileId) { if (item.getFileId() === fileId) {
level = item.level; level = item.level;
if ( if (item instanceof ListFileItem || item instanceof ListTrackItem || item instanceof ListTrackSegmentItem || item instanceof ListWaypointsItem || item instanceof ListWaypointItem) {
item instanceof ListFileItem ||
item instanceof ListTrackItem ||
item instanceof ListTrackSegmentItem ||
item instanceof ListWaypointsItem ||
item instanceof ListWaypointItem
) {
items.push(item); items.push(item);
} }
} }
@@ -257,10 +225,7 @@ export function applyToOrderedItemsFromFile(
}); });
} }
export function applyToOrderedSelectedItemsFromFile( export function applyToOrderedSelectedItemsFromFile(callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void, reverse: boolean = true) {
callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void,
reverse: boolean = true
) {
applyToOrderedItemsFromFile(get(selection).getSelected(), callback, reverse); applyToOrderedItemsFromFile(get(selection).getSelected(), callback, reverse);
} }
@@ -305,11 +270,7 @@ export function pasteSelection() {
let startIndex: number | undefined = undefined; let startIndex: number | undefined = undefined;
if (fromItems[0].level === toParent.level) { if (fromItems[0].level === toParent.level) {
if ( if (toParent instanceof ListTrackItem || toParent instanceof ListTrackSegmentItem || toParent instanceof ListWaypointItem) {
toParent instanceof ListTrackItem ||
toParent instanceof ListTrackSegmentItem ||
toParent instanceof ListWaypointItem
) {
startIndex = toParent.getId() + 1; startIndex = toParent.getId() + 1;
} }
toParent = toParent.getParent(); toParent = toParent.getParent();
@@ -327,41 +288,20 @@ export function pasteSelection() {
fromItems.forEach((item, index) => { fromItems.forEach((item, index) => {
if (toParent instanceof ListFileItem) { if (toParent instanceof ListFileItem) {
if (item instanceof ListTrackItem || item instanceof ListTrackSegmentItem) { if (item instanceof ListTrackItem || item instanceof ListTrackSegmentItem) {
toItems.push( toItems.push(new ListTrackItem(toParent.getFileId(), (startIndex ?? toFile.trk.length) + index));
new ListTrackItem(
toParent.getFileId(),
(startIndex ?? toFile.trk.length) + index
)
);
} else if (item instanceof ListWaypointsItem) { } else if (item instanceof ListWaypointsItem) {
toItems.push(new ListWaypointsItem(toParent.getFileId())); toItems.push(new ListWaypointsItem(toParent.getFileId()));
} else if (item instanceof ListWaypointItem) { } else if (item instanceof ListWaypointItem) {
toItems.push( toItems.push(new ListWaypointItem(toParent.getFileId(), (startIndex ?? toFile.wpt.length) + index));
new ListWaypointItem(
toParent.getFileId(),
(startIndex ?? toFile.wpt.length) + index
)
);
} }
} else if (toParent instanceof ListTrackItem) { } else if (toParent instanceof ListTrackItem) {
if (item instanceof ListTrackSegmentItem) { if (item instanceof ListTrackSegmentItem) {
let toTrackIndex = toParent.getTrackIndex(); let toTrackIndex = toParent.getTrackIndex();
toItems.push( toItems.push(new ListTrackSegmentItem(toParent.getFileId(), toTrackIndex, (startIndex ?? toFile.trk[toTrackIndex].trkseg.length) + index));
new ListTrackSegmentItem(
toParent.getFileId(),
toTrackIndex,
(startIndex ?? toFile.trk[toTrackIndex].trkseg.length) + index
)
);
} }
} else if (toParent instanceof ListWaypointsItem) { } else if (toParent instanceof ListWaypointsItem) {
if (item instanceof ListWaypointItem) { if (item instanceof ListWaypointItem) {
toItems.push( toItems.push(new ListWaypointItem(toParent.getFileId(), (startIndex ?? toFile.wpt.length) + index));
new ListWaypointItem(
toParent.getFileId(),
(startIndex ?? toFile.wpt.length) + index
)
);
} }
} }
}); });

View File

@@ -14,20 +14,20 @@
export let item: ListItem; export let item: ListItem;
export let open = false; export let open = false;
const { defaultOpacity, defaultWidth } = settings; const { defaultOpacity, defaultWeight } = settings;
let colors: string[] = []; let colors: string[] = [];
let color: string | undefined = undefined; let color: string | undefined = undefined;
let opacity: number[] = []; let opacity: number[] = [];
let width: number[] = []; let weight: number[] = [];
let colorChanged = false; let colorChanged = false;
let opacityChanged = false; let opacityChanged = false;
let widthChanged = false; let weightChanged = false;
function setStyleInputs() { function setStyleInputs() {
colors = []; colors = [];
opacity = []; opacity = [];
width = []; weight = [];
$selection.forEach((item) => { $selection.forEach((item) => {
if (item instanceof ListFileItem) { if (item instanceof ListFileItem) {
@@ -47,9 +47,9 @@
opacity.push(o); opacity.push(o);
} }
}); });
style.width.forEach((w) => { style.weight.forEach((w) => {
if (!width.includes(w)) { if (!weight.includes(w)) {
width.push(w); weight.push(w);
} }
}); });
} }
@@ -60,20 +60,14 @@
let track = file.trk[item.getTrackIndex()]; let track = file.trk[item.getTrackIndex()];
let style = track.getStyle(); let style = track.getStyle();
if (style) { if (style) {
if ( if (style.color && !colors.includes(style.color)) {
style['gpx_style:color'] && colors.push(style.color);
!colors.includes(style['gpx_style:color'])
) {
colors.push(style['gpx_style:color']);
} }
if ( if (style.opacity && !opacity.includes(style.opacity)) {
style['gpx_style:opacity'] && opacity.push(style.opacity);
!opacity.includes(style['gpx_style:opacity'])
) {
opacity.push(style['gpx_style:opacity']);
} }
if (style['gpx_style:width'] && !width.includes(style['gpx_style:width'])) { if (style.weight && !weight.includes(style.weight)) {
width.push(style['gpx_style:width']); weight.push(style.weight);
} }
} }
if (!colors.includes(layer.layerColor)) { if (!colors.includes(layer.layerColor)) {
@@ -85,11 +79,11 @@
color = colors[0]; color = colors[0];
opacity = [opacity[0] ?? $defaultOpacity]; opacity = [opacity[0] ?? $defaultOpacity];
width = [width[0] ?? $defaultWidth]; weight = [weight[0] ?? $defaultWeight];
colorChanged = false; colorChanged = false;
opacityChanged = false; opacityChanged = false;
widthChanged = false; weightChanged = false;
} }
$: if ($selection && open) { $: if ($selection && open) {
@@ -129,37 +123,37 @@
{$_('menu.style.width')} {$_('menu.style.width')}
<div class="w-40 p-2"> <div class="w-40 p-2">
<Slider <Slider
bind:value={width} bind:value={weight}
id="width" id="weight"
min={1} min={1}
max={10} max={10}
step={1} step={1}
onValueChange={() => (widthChanged = true)} onValueChange={() => (weightChanged = true)}
/> />
</div> </div>
</Label> </Label>
<Button <Button
variant="outline" variant="outline"
disabled={!colorChanged && !opacityChanged && !widthChanged} disabled={!colorChanged && !opacityChanged && !weightChanged}
on:click={() => { on:click={() => {
let style = {}; let style = {};
if (colorChanged) { if (colorChanged) {
style['gpx_style:color'] = color; style.color = color;
} }
if (opacityChanged) { if (opacityChanged) {
style['gpx_style:opacity'] = opacity[0]; style.opacity = opacity[0];
} }
if (widthChanged) { if (weightChanged) {
style['gpx_style:width'] = width[0]; style.weight = weight[0];
} }
dbUtils.setStyleToSelection(style); dbUtils.setStyleToSelection(style);
if (item instanceof ListFileItem && $selection.size === gpxLayers.size) { if (item instanceof ListFileItem && $selection.size === gpxLayers.size) {
if (style['gpx_style:opacity']) { if (style.opacity) {
$defaultOpacity = style['gpx_style:opacity']; $defaultOpacity = style.opacity;
} }
if (style['gpx_style:width']) { if (style.weight) {
$defaultWidth = style['gpx_style:width']; $defaultWeight = style.weight;
} }
} }

View File

@@ -1,23 +0,0 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { ClipboardCopy } from 'lucide-svelte';
import { _ } from 'svelte-i18n';
import type { Coordinates } from 'gpx';
export let coordinates: Coordinates;
export let onCopy: () => void = () => {};
</script>
<Button
class="w-full px-2 py-1 h-8 justify-start {$$props.class}"
variant="outline"
on:click={() => {
navigator.clipboard.writeText(
`${coordinates.lat.toFixed(6)}, ${coordinates.lon.toFixed(6)}`
);
onCopy();
}}
>
<ClipboardCopy size="16" class="mr-1" />
{$_('menu.copy_coordinates')}
</Button>

View File

@@ -1,17 +1,10 @@
import { settings } from '$lib/db';
import { gpxStatistics } from '$lib/stores';
import { get } from 'svelte/store';
const { distanceMarkers, distanceUnits } = settings; import { font } from "$lib/assets/layers";
import { settings } from "$lib/db";
import { gpxStatistics } from "$lib/stores";
import { get } from "svelte/store";
const stops = [ const { distanceMarkers, distanceUnits, currentBasemap } = settings;
[100, 0],
[50, 7],
[25, 8, 10],
[10, 10],
[5, 11],
[1, 13],
];
export class DistanceMarkers { export class DistanceMarkers {
map: mapboxgl.Map; map: mapboxgl.Map;
@@ -24,7 +17,7 @@ export class DistanceMarkers {
this.unsubscribes.push(gpxStatistics.subscribe(this.updateBinded)); this.unsubscribes.push(gpxStatistics.subscribe(this.updateBinded));
this.unsubscribes.push(distanceMarkers.subscribe(this.updateBinded)); this.unsubscribes.push(distanceMarkers.subscribe(this.updateBinded));
this.unsubscribes.push(distanceUnits.subscribe(this.updateBinded)); this.unsubscribes.push(distanceUnits.subscribe(this.updateBinded));
this.map.on('style.import.load', this.updateBinded); this.map.on('style.load', this.updateBinded);
} }
update() { update() {
@@ -36,55 +29,41 @@ export class DistanceMarkers {
} else { } else {
this.map.addSource('distance-markers', { this.map.addSource('distance-markers', {
type: 'geojson', type: 'geojson',
data: this.getDistanceMarkersGeoJSON(), data: this.getDistanceMarkersGeoJSON()
}); });
} }
stops.forEach(([d, minzoom, maxzoom]) => { if (!this.map.getLayer('distance-markers')) {
if (!this.map.getLayer(`distance-markers-${d}`)) {
this.map.addLayer({ this.map.addLayer({
id: `distance-markers-${d}`, id: 'distance-markers',
type: 'symbol', type: 'symbol',
source: 'distance-markers', source: 'distance-markers',
filter:
d === 5
? [
'any',
['==', ['get', 'level'], 5],
['==', ['get', 'level'], 25],
]
: ['==', ['get', 'level'], d],
minzoom: minzoom,
maxzoom: maxzoom ?? 24,
layout: { layout: {
'text-field': ['get', 'distance'], 'text-field': ['get', 'distance'],
'text-size': 14, 'text-size': 14,
'text-font': ['Open Sans Bold'], 'text-font': [font[get(currentBasemap)] ?? 'Open Sans Bold'],
'text-padding': 20,
}, },
paint: { paint: {
'text-color': 'black', 'text-color': 'black',
'text-halo-width': 2, 'text-halo-width': 2,
'text-halo-color': 'white', 'text-halo-color': 'white',
},
});
} else {
this.map.moveLayer(`distance-markers-${d}`);
} }
}); });
} else { } else {
stops.forEach(([d]) => { this.map.moveLayer('distance-markers');
if (this.map.getLayer(`distance-markers-${d}`)) {
this.map.removeLayer(`distance-markers-${d}`);
} }
}); } else {
if (this.map.getLayer('distance-markers')) {
this.map.removeLayer('distance-markers');
} }
} catch (e) { }
// No reliable way to check if the map is ready to add sources and layers } catch (e) { // No reliable way to check if the map is ready to add sources and layers
return; return;
} }
} }
remove() { remove() {
this.unsubscribes.forEach((unsubscribe) => unsubscribe()); this.unsubscribes.forEach(unsubscribe => unsubscribe());
} }
getDistanceMarkersGeoJSON(): GeoJSON.FeatureCollection { getDistanceMarkersGeoJSON(): GeoJSON.FeatureCollection {
@@ -93,28 +72,17 @@ export class DistanceMarkers {
let features = []; let features = [];
let currentTargetDistance = 1; let currentTargetDistance = 1;
for (let i = 0; i < statistics.local.distance.total.length; i++) { for (let i = 0; i < statistics.local.distance.total.length; i++) {
if ( if (statistics.local.distance.total[i] >= currentTargetDistance * (get(distanceUnits) === 'metric' ? 1 : 1.60934)) {
statistics.local.distance.total[i] >=
currentTargetDistance * (get(distanceUnits) === 'metric' ? 1 : 1.60934)
) {
let distance = currentTargetDistance.toFixed(0); let distance = currentTargetDistance.toFixed(0);
let [level, minzoom] = stops.find(([d]) => currentTargetDistance % d === 0) ?? [
0, 0,
];
features.push({ features.push({
type: 'Feature', type: 'Feature',
geometry: { geometry: {
type: 'Point', type: 'Point',
coordinates: [ coordinates: [statistics.local.points[i].getLongitude(), statistics.local.points[i].getLatitude()]
statistics.local.points[i].getLongitude(),
statistics.local.points[i].getLatitude(),
],
}, },
properties: { properties: {
distance, distance,
level, }
minzoom,
},
} as GeoJSON.Feature); } as GeoJSON.Feature);
currentTargetDistance += 1; currentTargetDistance += 1;
} }
@@ -122,7 +90,7 @@ export class DistanceMarkers {
return { return {
type: 'FeatureCollection', type: 'FeatureCollection',
features, features
}; };
} }
} }

View File

@@ -1,28 +1,16 @@
import { currentTool, map, Tool } from '$lib/stores'; import { currentTool, hoveredTrackPoint, map, Tool } from "$lib/stores";
import { settings, type GPXFileWithStatistics, dbUtils } from '$lib/db'; import { settings, type GPXFileWithStatistics, dbUtils } from "$lib/db";
import { get, type Readable } from 'svelte/store'; import { get, type Readable } from "svelte/store";
import mapboxgl from 'mapbox-gl'; import mapboxgl from "mapbox-gl";
import { waypointPopup, deleteWaypoint, trackpointPopup } from './GPXLayerPopup'; import { currentPopupWaypoint, deleteWaypoint, waypointPopup } from "./WaypointPopup";
import { addSelectItem, selectItem, selection } from '$lib/components/file-list/Selection'; import { addSelectItem, selectItem, selection } from "$lib/components/file-list/Selection";
import { import { ListTrackSegmentItem, ListWaypointItem, ListWaypointsItem, ListTrackItem, ListFileItem, ListRootItem } from "$lib/components/file-list/FileList";
ListTrackSegmentItem, import type { Waypoint } from "gpx";
ListWaypointItem, import { getClosestLinePoint, getElevation, resetCursor, setGrabbingCursor, setPointerCursor, setScissorsCursor } from "$lib/utils";
ListWaypointsItem, import { font } from "$lib/assets/layers";
ListTrackItem, import { selectedWaypoint } from "$lib/components/toolbar/tools/Waypoint.svelte";
ListFileItem, import { MapPin, Square } from "lucide-static";
ListRootItem, import { getSymbolKey, symbols } from "$lib/assets/symbols";
} from '$lib/components/file-list/FileList';
import {
getClosestLinePoint,
getElevation,
resetCursor,
setGrabbingCursor,
setPointerCursor,
setScissorsCursor,
} from '$lib/utils';
import { selectedWaypoint } from '$lib/components/toolbar/tools/Waypoint.svelte';
import { MapPin, Square } from 'lucide-static';
import { getSymbolKey, symbols } from '$lib/assets/symbols';
const colors = [ const colors = [
'#ff0000', '#ff0000',
@@ -35,7 +23,7 @@ const colors = [
'#288228', '#288228',
'#9933ff', '#9933ff',
'#50f0be', '#50f0be',
'#8c645a', '#8c645a'
]; ];
const colorCount: { [key: string]: number } = {}; const colorCount: { [key: string]: number } = {};
@@ -59,30 +47,26 @@ function decrementColor(color: string) {
function getMarkerForSymbol(symbol: string | undefined, layerColor: string) { function getMarkerForSymbol(symbol: string | undefined, layerColor: string) {
let symbolSvg = symbol ? symbols[symbol]?.iconSvg : undefined; let symbolSvg = symbol ? symbols[symbol]?.iconSvg : undefined;
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
${Square.replace('width="24"', 'width="12"') ${Square
.replace('width="24"', 'width="12"')
.replace('height="24"', 'height="12"') .replace('height="24"', 'height="12"')
.replace('stroke="currentColor"', 'stroke="SteelBlue"') .replace('stroke="currentColor"', 'stroke="SteelBlue"')
.replace('stroke-width="2"', 'stroke-width="1.5" x="9.6" y="0.4"') .replace('stroke-width="2"', 'stroke-width="1.5" x="9.6" y="0.4"')
.replace('fill="none"', `fill="${layerColor}"`)} .replace('fill="none"', `fill="${layerColor}"`)}
${MapPin.replace('width="24"', '') ${MapPin
.replace('width="24"', '')
.replace('height="24"', '') .replace('height="24"', '')
.replace('stroke="currentColor"', '') .replace('stroke="currentColor"', '')
.replace('path', `path fill="#3fb1ce" stroke="SteelBlue" stroke-width="1"`) .replace('path', `path fill="#3fb1ce" stroke="SteelBlue" stroke-width="1"`)
.replace( .replace('circle', `circle fill="${symbolSvg ? 'none' : 'white'}" stroke="${symbolSvg ? 'none' : 'white'}" stroke-width="2"`)}
'circle', ${symbolSvg?.replace('width="24"', 'width="10"')
`circle fill="${symbolSvg ? 'none' : 'white'}" stroke="${symbolSvg ? 'none' : 'white'}" stroke-width="2"`
)}
${
symbolSvg
?.replace('width="24"', 'width="10"')
.replace('height="24"', 'height="10"') .replace('height="24"', 'height="10"')
.replace('stroke="currentColor"', 'stroke="white"') .replace('stroke="currentColor"', 'stroke="white"')
.replace('stroke-width="2"', 'stroke-width="2.5" x="7" y="5"') ?? '' .replace('stroke-width="2"', 'stroke-width="2.5" x="7" y="5"') ?? ''}
}
</svg>`; </svg>`;
} }
const { directionMarkers, treeFileView, defaultOpacity, defaultWidth } = settings; const { directionMarkers, verticalFileView, currentBasemap, defaultOpacity, defaultWeight } = settings;
export class GPXLayer { export class GPXLayer {
map: mapboxgl.Map; map: mapboxgl.Map;
@@ -96,23 +80,18 @@ export class GPXLayer {
updateBinded: () => void = this.update.bind(this); updateBinded: () => void = this.update.bind(this);
layerOnMouseEnterBinded: (e: any) => void = this.layerOnMouseEnter.bind(this); layerOnMouseEnterBinded: (e: any) => void = this.layerOnMouseEnter.bind(this);
layerOnMouseLeaveBinded: () => void = this.layerOnMouseLeave.bind(this);
layerOnMouseMoveBinded: (e: any) => void = this.layerOnMouseMove.bind(this); layerOnMouseMoveBinded: (e: any) => void = this.layerOnMouseMove.bind(this);
layerOnMouseLeaveBinded: () => void = this.layerOnMouseLeave.bind(this);
layerOnClickBinded: (e: any) => void = this.layerOnClick.bind(this); layerOnClickBinded: (e: any) => void = this.layerOnClick.bind(this);
layerOnContextMenuBinded: (e: any) => void = this.layerOnContextMenu.bind(this); maybeHideWaypointPopupBinded: (e: any) => void = this.maybeHideWaypointPopup.bind(this);
constructor( constructor(map: mapboxgl.Map, fileId: string, file: Readable<GPXFileWithStatistics | undefined>) {
map: mapboxgl.Map,
fileId: string,
file: Readable<GPXFileWithStatistics | undefined>
) {
this.map = map; this.map = map;
this.fileId = fileId; this.fileId = fileId;
this.file = file; this.file = file;
this.layerColor = getColor(); this.layerColor = getColor();
this.unsubscribe.push(file.subscribe(this.updateBinded)); this.unsubscribe.push(file.subscribe(this.updateBinded));
this.unsubscribe.push( this.unsubscribe.push(selection.subscribe($selection => {
selection.subscribe(($selection) => {
let newSelected = $selection.hasAnyChildren(new ListFileItem(this.fileId)); let newSelected = $selection.hasAnyChildren(new ListFileItem(this.fileId));
if (this.selected || newSelected) { if (this.selected || newSelected) {
this.selected = newSelected; this.selected = newSelected;
@@ -121,23 +100,20 @@ export class GPXLayer {
if (newSelected) { if (newSelected) {
this.moveToFront(); this.moveToFront();
} }
}) }));
);
this.unsubscribe.push(directionMarkers.subscribe(this.updateBinded)); this.unsubscribe.push(directionMarkers.subscribe(this.updateBinded));
this.unsubscribe.push( this.unsubscribe.push(currentTool.subscribe(tool => {
currentTool.subscribe((tool) => {
if (tool === Tool.WAYPOINT && !this.draggable) { if (tool === Tool.WAYPOINT && !this.draggable) {
this.draggable = true; this.draggable = true;
this.markers.forEach((marker) => marker.setDraggable(true)); this.markers.forEach(marker => marker.setDraggable(true));
} else if (tool !== Tool.WAYPOINT && this.draggable) { } else if (tool !== Tool.WAYPOINT && this.draggable) {
this.draggable = false; this.draggable = false;
this.markers.forEach((marker) => marker.setDraggable(false)); this.markers.forEach(marker => marker.setDraggable(false));
} }
}) }));
);
this.draggable = get(currentTool) === Tool.WAYPOINT; this.draggable = get(currentTool) === Tool.WAYPOINT;
this.map.on('style.import.load', this.updateBinded); this.map.on('style.load', this.updateBinded);
} }
update() { update() {
@@ -146,11 +122,7 @@ export class GPXLayer {
return; return;
} }
if ( if (file._data.style && file._data.style.color && this.layerColor !== `#${file._data.style.color}`) {
file._data.style &&
file._data.style.color &&
this.layerColor !== `#${file._data.style.color}`
) {
decrementColor(this.layerColor); decrementColor(this.layerColor);
this.layerColor = `#${file._data.style.color}`; this.layerColor = `#${file._data.style.color}`;
} }
@@ -162,7 +134,7 @@ export class GPXLayer {
} else { } else {
this.map.addSource(this.fileId, { this.map.addSource(this.fileId, {
type: 'geojson', type: 'geojson',
data: this.getGeoJSON(), data: this.getGeoJSON()
}); });
} }
@@ -173,26 +145,24 @@ export class GPXLayer {
source: this.fileId, source: this.fileId,
layout: { layout: {
'line-join': 'round', 'line-join': 'round',
'line-cap': 'round', 'line-cap': 'round'
}, },
paint: { paint: {
'line-color': ['get', 'color'], 'line-color': ['get', 'color'],
'line-width': ['get', 'width'], 'line-width': ['get', 'weight'],
'line-opacity': ['get', 'opacity'], 'line-opacity': ['get', 'opacity']
}, }
}); });
this.map.on('click', this.fileId, this.layerOnClickBinded); this.map.on('click', this.fileId, this.layerOnClickBinded);
this.map.on('contextmenu', this.fileId, this.layerOnContextMenuBinded);
this.map.on('mouseenter', this.fileId, this.layerOnMouseEnterBinded); this.map.on('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
this.map.on('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
this.map.on('mousemove', this.fileId, this.layerOnMouseMoveBinded); this.map.on('mousemove', this.fileId, this.layerOnMouseMoveBinded);
this.map.on('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
} }
if (get(directionMarkers)) { if (get(directionMarkers)) {
if (!this.map.getLayer(this.fileId + '-direction')) { if (!this.map.getLayer(this.fileId + '-direction')) {
this.map.addLayer( this.map.addLayer({
{
id: this.fileId + '-direction', id: this.fileId + '-direction',
type: 'symbol', type: 'symbol',
source: this.fileId, source: this.fileId,
@@ -202,7 +172,7 @@ export class GPXLayer {
'text-keep-upright': false, 'text-keep-upright': false,
'text-max-angle': 361, 'text-max-angle': 361,
'text-allow-overlap': true, 'text-allow-overlap': true,
'text-font': ['Open Sans Bold'], 'text-font': [font[get(currentBasemap)] ?? 'Open Sans Bold'],
'symbol-placement': 'line', 'symbol-placement': 'line',
'symbol-spacing': 20, 'symbol-spacing': 20,
}, },
@@ -210,11 +180,9 @@ export class GPXLayer {
'text-color': 'white', 'text-color': 'white',
'text-opacity': 0.7, 'text-opacity': 0.7,
'text-halo-width': 0.2, 'text-halo-width': 0.2,
'text-halo-color': 'white', 'text-halo-color': 'white'
}, }
}, }, this.map.getLayer('distance-markers') ? 'distance-markers' : undefined);
this.map.getLayer('distance-markers') ? 'distance-markers' : undefined
);
} }
} else { } else {
if (this.map.getLayer(this.fileId + '-direction')) { if (this.map.getLayer(this.fileId + '-direction')) {
@@ -229,53 +197,23 @@ export class GPXLayer {
} }
}); });
this.map.setFilter( this.map.setFilter(this.fileId, ['any', ...visibleItems.map(([trackIndex, segmentIndex]) => ['all', ['==', 'trackIndex', trackIndex], ['==', 'segmentIndex', segmentIndex]])], { validate: false });
this.fileId,
[
'any',
...visibleItems.map(([trackIndex, segmentIndex]) => [
'all',
['==', 'trackIndex', trackIndex],
['==', 'segmentIndex', segmentIndex],
]),
],
{ validate: false }
);
if (this.map.getLayer(this.fileId + '-direction')) { if (this.map.getLayer(this.fileId + '-direction')) {
this.map.setFilter( this.map.setFilter(this.fileId + '-direction', ['any', ...visibleItems.map(([trackIndex, segmentIndex]) => ['all', ['==', 'trackIndex', trackIndex], ['==', 'segmentIndex', segmentIndex]])], { validate: false });
this.fileId + '-direction',
[
'any',
...visibleItems.map(([trackIndex, segmentIndex]) => [
'all',
['==', 'trackIndex', trackIndex],
['==', 'segmentIndex', segmentIndex],
]),
],
{ validate: false }
);
} }
} catch (e) { } catch (e) { // No reliable way to check if the map is ready to add sources and layers
// No reliable way to check if the map is ready to add sources and layers
return; return;
} }
let markerIndex = 0; let markerIndex = 0;
if (get(selection).hasAnyChildren(new ListFileItem(this.fileId))) { if (get(selection).hasAnyChildren(new ListFileItem(this.fileId))) {
file.wpt.forEach((waypoint) => { file.wpt.forEach((waypoint) => { // Update markers
// Update markers
let symbolKey = getSymbolKey(waypoint.sym); let symbolKey = getSymbolKey(waypoint.sym);
if (markerIndex < this.markers.length) { if (markerIndex < this.markers.length) {
this.markers[markerIndex].getElement().innerHTML = getMarkerForSymbol( this.markers[markerIndex].getElement().innerHTML = getMarkerForSymbol(symbolKey, this.layerColor);
symbolKey,
this.layerColor
);
this.markers[markerIndex].setLngLat(waypoint.getCoordinates()); this.markers[markerIndex].setLngLat(waypoint.getCoordinates());
Object.defineProperty(this.markers[markerIndex], '_waypoint', { Object.defineProperty(this.markers[markerIndex], '_waypoint', { value: waypoint, writable: true });
value: waypoint,
writable: true,
});
} else { } else {
let element = document.createElement('div'); let element = document.createElement('div');
element.classList.add('w-8', 'h-8', 'drop-shadow-xl'); element.classList.add('w-8', 'h-8', 'drop-shadow-xl');
@@ -283,15 +221,15 @@ export class GPXLayer {
let marker = new mapboxgl.Marker({ let marker = new mapboxgl.Marker({
draggable: this.draggable, draggable: this.draggable,
element, element,
anchor: 'bottom', anchor: 'bottom'
}).setLngLat(waypoint.getCoordinates()); }).setLngLat(waypoint.getCoordinates());
Object.defineProperty(marker, '_waypoint', { value: waypoint, writable: true }); Object.defineProperty(marker, '_waypoint', { value: waypoint, writable: true });
let dragEndTimestamp = 0; let dragEndTimestamp = 0;
marker.getElement().addEventListener('mousemove', (e) => { marker.getElement().addEventListener('mouseover', (e) => {
if (marker._isDragging) { if (marker._isDragging) {
return; return;
} }
waypointPopup?.setItem({ item: marker._waypoint, fileId: this.fileId }); this.showWaypointPopup(marker._waypoint);
e.stopPropagation(); e.stopPropagation();
}); });
marker.getElement().addEventListener('click', (e) => { marker.getElement().addEventListener('click', (e) => {
@@ -305,49 +243,37 @@ export class GPXLayer {
return; return;
} }
if (get(treeFileView)) { if (get(verticalFileView)) {
if ( if ((e.ctrlKey || e.metaKey) && get(selection).hasAnyChildren(new ListWaypointsItem(this.fileId), false)) {
(e.ctrlKey || e.metaKey) && addSelectItem(new ListWaypointItem(this.fileId, marker._waypoint._data.index));
get(selection).hasAnyChildren(
new ListWaypointsItem(this.fileId),
false
)
) {
addSelectItem(
new ListWaypointItem(this.fileId, marker._waypoint._data.index)
);
} else { } else {
selectItem( selectItem(new ListWaypointItem(this.fileId, marker._waypoint._data.index));
new ListWaypointItem(this.fileId, marker._waypoint._data.index)
);
} }
} else if (get(currentTool) === Tool.WAYPOINT) { } else if (get(currentTool) === Tool.WAYPOINT) {
selectedWaypoint.set([marker._waypoint, this.fileId]); selectedWaypoint.set([marker._waypoint, this.fileId]);
} else { } else {
waypointPopup?.setItem({ item: marker._waypoint, fileId: this.fileId }); this.showWaypointPopup(marker._waypoint);
} }
e.stopPropagation(); e.stopPropagation();
}); });
marker.on('dragstart', () => { marker.on('dragstart', () => {
setGrabbingCursor(); setGrabbingCursor();
marker.getElement().style.cursor = 'grabbing'; marker.getElement().style.cursor = 'grabbing';
waypointPopup?.hide(); this.hideWaypointPopup();
}); });
marker.on('dragend', (e) => { marker.on('dragend', (e) => {
resetCursor(); resetCursor();
marker.getElement().style.cursor = ''; marker.getElement().style.cursor = '';
getElevation([marker._waypoint]).then((ele) => {
dbUtils.applyToFile(this.fileId, (file) => { dbUtils.applyToFile(this.fileId, (file) => {
let latLng = marker.getLngLat(); let latLng = marker.getLngLat();
let wpt = file.wpt[marker._waypoint._data.index]; let wpt = file.wpt[marker._waypoint._data.index];
wpt.setCoordinates({ wpt.setCoordinates({
lat: latLng.lat, lat: latLng.lat,
lon: latLng.lng, lon: latLng.lng
}); });
wpt.ele = ele[0]; wpt.ele = getElevation(this.map, wpt.getCoordinates());
}); });
}); dragEndTimestamp = Date.now()
dragEndTimestamp = Date.now();
}); });
this.markers.push(marker); this.markers.push(marker);
} }
@@ -355,8 +281,7 @@ export class GPXLayer {
}); });
} }
while (markerIndex < this.markers.length) { while (markerIndex < this.markers.length) { // Remove extra markers
// Remove extra markers
this.markers.pop()?.remove(); this.markers.pop()?.remove();
} }
@@ -371,18 +296,17 @@ export class GPXLayer {
updateMap(map: mapboxgl.Map) { updateMap(map: mapboxgl.Map) {
this.map = map; this.map = map;
this.map.on('style.import.load', this.updateBinded); this.map.on('style.load', this.updateBinded);
this.update(); this.update();
} }
remove() { remove() {
if (get(map)) { if (get(map)) {
this.map.off('click', this.fileId, this.layerOnClickBinded); this.map.off('click', this.fileId, this.layerOnClickBinded);
this.map.off('contextmenu', this.fileId, this.layerOnContextMenuBinded);
this.map.off('mouseenter', this.fileId, this.layerOnMouseEnterBinded); this.map.off('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
this.map.off('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
this.map.off('mousemove', this.fileId, this.layerOnMouseMoveBinded); this.map.off('mousemove', this.fileId, this.layerOnMouseMoveBinded);
this.map.off('style.import.load', this.updateBinded); this.map.off('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
this.map.off('style.load', this.updateBinded);
if (this.map.getLayer(this.fileId + '-direction')) { if (this.map.getLayer(this.fileId + '-direction')) {
this.map.removeLayer(this.fileId + '-direction'); this.map.removeLayer(this.fileId + '-direction');
@@ -409,10 +333,7 @@ export class GPXLayer {
this.map.moveLayer(this.fileId); this.map.moveLayer(this.fileId);
} }
if (this.map.getLayer(this.fileId + '-direction')) { if (this.map.getLayer(this.fileId + '-direction')) {
this.map.moveLayer( this.map.moveLayer(this.fileId + '-direction', this.map.getLayer('distance-markers') ? 'distance-markers' : undefined);
this.fileId + '-direction',
this.map.getLayer('distance-markers') ? 'distance-markers' : undefined
);
} }
} }
@@ -420,59 +341,47 @@ export class GPXLayer {
let trackIndex = e.features[0].properties.trackIndex; let trackIndex = e.features[0].properties.trackIndex;
let segmentIndex = e.features[0].properties.segmentIndex; let segmentIndex = e.features[0].properties.segmentIndex;
if ( if (get(currentTool) === Tool.SCISSORS && get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex))) {
get(currentTool) === Tool.SCISSORS &&
get(selection).hasAnyParent(
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
)
) {
setScissorsCursor(); setScissorsCursor();
} else { } else {
setPointerCursor(); setPointerCursor();
} }
} }
layerOnMouseLeave() {
resetCursor();
}
layerOnMouseMove(e: any) { layerOnMouseMove(e: any) {
if (e.originalEvent.shiftKey) {
let trackIndex = e.features[0].properties.trackIndex; let trackIndex = e.features[0].properties.trackIndex;
let segmentIndex = e.features[0].properties.segmentIndex; let segmentIndex = e.features[0].properties.segmentIndex;
const file = get(this.file)?.file; if (get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex))) {
let file = get(this.file)?.file;
if (file) { if (file) {
const closest = getClosestLinePoint( let segment = file.trk[trackIndex].trkseg[segmentIndex];
file.trk[trackIndex].trkseg[segmentIndex].trkpt, let point = getClosestLinePoint(segment.trkpt, { lat: e.lngLat.lat, lon: e.lngLat.lng });
{ lat: e.lngLat.lat, lon: e.lngLat.lng } hoveredTrackPoint.set({
); fileId: this.fileId,
trackpointPopup?.setItem({ item: closest, fileId: this.fileId }); trackIndex,
segmentIndex,
point
});
} }
} }
} }
layerOnMouseLeave() {
resetCursor();
hoveredTrackPoint.set(undefined);
}
layerOnClick(e: any) { layerOnClick(e: any) {
if ( if (get(currentTool) === Tool.ROUTING && get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints'])) {
get(currentTool) === Tool.ROUTING &&
get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints'])
) {
return; return;
} }
let trackIndex = e.features[0].properties.trackIndex; let trackIndex = e.features[0].properties.trackIndex;
let segmentIndex = e.features[0].properties.segmentIndex; let segmentIndex = e.features[0].properties.segmentIndex;
if ( if (get(currentTool) === Tool.SCISSORS && get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex))) {
get(currentTool) === Tool.SCISSORS && dbUtils.split(this.fileId, trackIndex, segmentIndex, { lat: e.lngLat.lat, lon: e.lngLat.lng });
get(selection).hasAnyParent(
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
)
) {
dbUtils.split(this.fileId, trackIndex, segmentIndex, {
lat: e.lngLat.lat,
lon: e.lngLat.lng,
});
return; return;
} }
@@ -482,12 +391,8 @@ export class GPXLayer {
} }
let item = undefined; let item = undefined;
if (get(treeFileView) && file.getSegments().length > 1) { if (get(verticalFileView) && file.getSegments().length > 1) { // Select inner item
// Select inner item item = file.children[trackIndex].children.length > 1 ? new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex) : new ListTrackItem(this.fileId, trackIndex);
item =
file.children[trackIndex].children.length > 1
? new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
: new ListTrackItem(this.fileId, trackIndex);
} else { } else {
item = new ListFileItem(this.fileId); item = new ListFileItem(this.fileId);
} }
@@ -499,9 +404,40 @@ export class GPXLayer {
} }
} }
layerOnContextMenu(e: any) { showWaypointPopup(waypoint: Waypoint) {
if (e.originalEvent.ctrlKey) { if (get(currentPopupWaypoint) !== null) {
this.layerOnClick(e); this.hideWaypointPopup();
}
let marker = this.markers[waypoint._data.index];
if (marker) {
currentPopupWaypoint.set([waypoint, this.fileId]);
marker.setPopup(waypointPopup);
marker.togglePopup();
this.map.on('mousemove', this.maybeHideWaypointPopupBinded);
}
}
maybeHideWaypointPopup(e: any) {
let waypoint = get(currentPopupWaypoint)?.[0];
if (waypoint) {
let marker = this.markers[waypoint._data.index];
if (marker) {
if (this.map.project(marker.getLngLat()).dist(this.map.project(e.lngLat)) > 100) {
this.hideWaypointPopup();
}
} else {
this.hideWaypointPopup();
}
}
}
hideWaypointPopup() {
let waypoint = get(currentPopupWaypoint)?.[0];
if (waypoint) {
let marker = this.markers[waypoint._data.index];
marker?.getPopup()?.remove();
currentPopupWaypoint.set(null);
this.map.off('mousemove', this.maybeHideWaypointPopupBinded);
} }
} }
@@ -510,14 +446,13 @@ export class GPXLayer {
if (!file) { if (!file) {
return { return {
type: 'FeatureCollection', type: 'FeatureCollection',
features: [], features: []
}; };
} }
let data = file.toGeoJSON(); let data = file.toGeoJSON();
let trackIndex = 0, let trackIndex = 0, segmentIndex = 0;
segmentIndex = 0;
for (let feature of data.features) { for (let feature of data.features) {
if (!feature.properties) { if (!feature.properties) {
feature.properties = {}; feature.properties = {};
@@ -525,19 +460,14 @@ export class GPXLayer {
if (!feature.properties.color) { if (!feature.properties.color) {
feature.properties.color = this.layerColor; feature.properties.color = this.layerColor;
} }
if (!feature.properties.weight) {
feature.properties.weight = get(defaultWeight);
}
if (!feature.properties.opacity) { if (!feature.properties.opacity) {
feature.properties.opacity = get(defaultOpacity); feature.properties.opacity = get(defaultOpacity);
} }
if (!feature.properties.width) { if (get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)) || get(selection).hasAnyChildren(new ListWaypointsItem(this.fileId), true)) {
feature.properties.width = get(defaultWidth); feature.properties.weight = feature.properties.weight + 2;
}
if (
get(selection).hasAnyParent(
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
) ||
get(selection).hasAnyChildren(new ListWaypointsItem(this.fileId), true)
) {
feature.properties.width = feature.properties.width + 2;
feature.properties.opacity = Math.min(1, feature.properties.opacity + 0.1); feature.properties.opacity = Math.min(1, feature.properties.opacity + 0.1);
} }
feature.properties.trackIndex = trackIndex; feature.properties.trackIndex = trackIndex;

View File

@@ -1,44 +0,0 @@
import { dbUtils } from '$lib/db';
import { MapPopup } from '$lib/components/MapPopup';
export let waypointPopup: MapPopup | null = null;
export let trackpointPopup: MapPopup | null = null;
export function createPopups(map: mapboxgl.Map) {
removePopups();
waypointPopup = new MapPopup(map, {
closeButton: false,
focusAfterOpen: false,
maxWidth: undefined,
offset: {
top: [0, 0],
'top-left': [0, 0],
'top-right': [0, 0],
bottom: [0, -30],
'bottom-left': [0, -30],
'bottom-right': [0, -30],
left: [10, -15],
right: [-10, -15],
},
});
trackpointPopup = new MapPopup(map, {
closeButton: false,
focusAfterOpen: false,
maxWidth: undefined,
});
}
export function removePopups() {
if (waypointPopup !== null) {
waypointPopup.remove();
waypointPopup = null;
}
if (trackpointPopup !== null) {
trackpointPopup.remove();
trackpointPopup = null;
}
}
export function deleteWaypoint(fileId: string, waypointIndex: number) {
dbUtils.applyToFile(fileId, (file) => file.replaceWaypoints(waypointIndex, waypointIndex, []));
}

View File

@@ -1,11 +1,11 @@
<script lang="ts"> <script lang="ts">
import { map, gpxLayers } from '$lib/stores'; import { map, gpxLayers } from '$lib/stores';
import { GPXLayer } from './GPXLayer'; import { GPXLayer } from './GPXLayer';
import WaypointPopup from './WaypointPopup.svelte';
import { fileObservers } from '$lib/db'; import { fileObservers } from '$lib/db';
import { DistanceMarkers } from './DistanceMarkers'; import { DistanceMarkers } from './DistanceMarkers';
import { StartEndMarkers } from './StartEndMarkers'; import { StartEndMarkers } from './StartEndMarkers';
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { createPopups, removePopups } from './GPXLayerPopup';
let distanceMarkers: DistanceMarkers | undefined = undefined; let distanceMarkers: DistanceMarkers | undefined = undefined;
let startEndMarkers: StartEndMarkers | undefined = undefined; let startEndMarkers: StartEndMarkers | undefined = undefined;
@@ -35,7 +35,6 @@
if (startEndMarkers) { if (startEndMarkers) {
startEndMarkers.remove(); startEndMarkers.remove();
} }
createPopups($map);
distanceMarkers = new DistanceMarkers($map); distanceMarkers = new DistanceMarkers($map);
startEndMarkers = new StartEndMarkers($map); startEndMarkers = new StartEndMarkers($map);
} }
@@ -43,14 +42,17 @@
onDestroy(() => { onDestroy(() => {
gpxLayers.forEach((layer) => layer.remove()); gpxLayers.forEach((layer) => layer.remove());
gpxLayers.clear(); gpxLayers.clear();
removePopups();
if (distanceMarkers) { if (distanceMarkers) {
distanceMarkers.remove(); distanceMarkers.remove();
distanceMarkers = undefined; distanceMarkers = undefined;
} }
if (startEndMarkers) { if (startEndMarkers) {
startEndMarkers.remove(); startEndMarkers.remove();
startEndMarkers = undefined; startEndMarkers = undefined;
} }
}); });
</script> </script>
<WaypointPopup />

View File

@@ -1,6 +1,6 @@
import { gpxStatistics, slicedGPXStatistics, currentTool, Tool } from '$lib/stores'; import { gpxStatistics, slicedGPXStatistics, currentTool, Tool } from "$lib/stores";
import mapboxgl from 'mapbox-gl'; import mapboxgl from "mapbox-gl";
import { get } from 'svelte/store'; import { get } from "svelte/store";
export class StartEndMarkers { export class StartEndMarkers {
map: mapboxgl.Map; map: mapboxgl.Map;
@@ -16,8 +16,7 @@ export class StartEndMarkers {
let endElement = document.createElement('div'); let endElement = document.createElement('div');
startElement.className = `h-4 w-4 rounded-full bg-green-500 border-2 border-white`; startElement.className = `h-4 w-4 rounded-full bg-green-500 border-2 border-white`;
endElement.className = `h-4 w-4 rounded-full border-2 border-white`; endElement.className = `h-4 w-4 rounded-full border-2 border-white`;
endElement.style.background = endElement.style.background = 'repeating-conic-gradient(#fff 0 90deg, #000 0 180deg) 0 0/8px 8px round';
'repeating-conic-gradient(#fff 0 90deg, #000 0 180deg) 0 0/8px 8px round';
this.start = new mapboxgl.Marker({ element: startElement }); this.start = new mapboxgl.Marker({ element: startElement });
this.end = new mapboxgl.Marker({ element: endElement }); this.end = new mapboxgl.Marker({ element: endElement });
@@ -32,11 +31,7 @@ export class StartEndMarkers {
let statistics = get(slicedGPXStatistics)?.[0] ?? get(gpxStatistics); let statistics = get(slicedGPXStatistics)?.[0] ?? get(gpxStatistics);
if (statistics.local.points.length > 0 && tool !== Tool.ROUTING) { if (statistics.local.points.length > 0 && tool !== Tool.ROUTING) {
this.start.setLngLat(statistics.local.points[0].getCoordinates()).addTo(this.map); this.start.setLngLat(statistics.local.points[0].getCoordinates()).addTo(this.map);
this.end this.end.setLngLat(statistics.local.points[statistics.local.points.length - 1].getCoordinates()).addTo(this.map);
.setLngLat(
statistics.local.points[statistics.local.points.length - 1].getCoordinates()
)
.addTo(this.map);
} else { } else {
this.start.remove(); this.start.remove();
this.end.remove(); this.end.remove();
@@ -44,7 +39,7 @@ export class StartEndMarkers {
} }
remove() { remove() {
this.unsubscribes.forEach((unsubscribe) => unsubscribe()); this.unsubscribes.forEach(unsubscribe => unsubscribe());
this.start.remove(); this.start.remove();
this.end.remove(); this.end.remove();

View File

@@ -1,43 +0,0 @@
<script lang="ts">
import type { TrackPoint } from 'gpx';
import type { PopupItem } from '$lib/components/MapPopup';
import CopyCoordinates from '$lib/components/gpx-layer/CopyCoordinates.svelte';
import * as Card from '$lib/components/ui/card';
import WithUnits from '$lib/components/WithUnits.svelte';
import { Compass, Mountain, Timer } from 'lucide-svelte';
import { df } from '$lib/utils';
import { _ } from 'svelte-i18n';
export let trackpoint: PopupItem<TrackPoint>;
</script>
<Card.Root class="border-none shadow-md text-base p-2">
<Card.Header class="p-0">
<Card.Title class="text-md"></Card.Title>
</Card.Header>
<Card.Content class="flex flex-col p-0 text-xs gap-1">
<div class="flex flex-row items-center gap-1">
<Compass size="14" />
{trackpoint.item.getLatitude().toFixed(6)}&deg; {trackpoint.item
.getLongitude()
.toFixed(6)}&deg;
</div>
{#if trackpoint.item.ele !== undefined}
<div class="flex flex-row items-center gap-1">
<Mountain size="14" />
<WithUnits value={trackpoint.item.ele} type="elevation" />
</div>
{/if}
{#if trackpoint.item.time}
<div class="flex flex-row items-center gap-1">
<Timer size="14" />
{df.format(trackpoint.item.time)}
</div>
{/if}
<CopyCoordinates
coordinates={trackpoint.item.attributes}
onCopy={() => trackpoint.hide?.()}
class="mt-0.5"
/>
</Card.Content>
</Card.Root>

View File

@@ -2,50 +2,54 @@
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import Shortcut from '$lib/components/Shortcut.svelte'; import Shortcut from '$lib/components/Shortcut.svelte';
import CopyCoordinates from '$lib/components/gpx-layer/CopyCoordinates.svelte'; import { waypointPopup, currentPopupWaypoint, deleteWaypoint } from './WaypointPopup';
import { deleteWaypoint } from './GPXLayerPopup';
import WithUnits from '$lib/components/WithUnits.svelte'; import WithUnits from '$lib/components/WithUnits.svelte';
import { Dot, ExternalLink, Trash2 } from 'lucide-svelte'; import { Dot, ExternalLink, Trash2 } from 'lucide-svelte';
import { onMount } from 'svelte';
import { Tool, currentTool } from '$lib/stores'; import { Tool, currentTool } from '$lib/stores';
import { getSymbolKey, symbols } from '$lib/assets/symbols'; import { getSymbolKey, symbols } from '$lib/assets/symbols';
import { _ } from 'svelte-i18n'; import { _ } from 'svelte-i18n';
import sanitizeHtml from 'sanitize-html'; import sanitizeHtml from 'sanitize-html';
import type { Waypoint } from 'gpx';
import type { PopupItem } from '$lib/components/MapPopup';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
export let waypoint: PopupItem<Waypoint>; let popupElement: HTMLDivElement;
$: symbolKey = waypoint ? getSymbolKey(waypoint.item.sym) : undefined; onMount(() => {
waypointPopup.setDOMContent(popupElement);
popupElement.classList.remove('hidden');
});
$: symbolKey = $currentPopupWaypoint ? getSymbolKey($currentPopupWaypoint[0].sym) : undefined;
function sanitize(text: string | undefined): string { function sanitize(text: string | undefined): string {
if (text === undefined) { if (text === undefined) {
return ''; return '';
} }
return sanitizeHtml(text, { let sanitized = sanitizeHtml(text, {
allowedTags: ['a', 'br', 'img'], allowedTags: ['a', 'br'],
allowedAttributes: { allowedAttributes: {
a: ['href', 'target'], a: ['href', 'target']
img: ['src'], }
},
}).trim(); }).trim();
return sanitized;
} }
</script> </script>
<Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw]"> <div bind:this={popupElement} class="hidden">
{#if $currentPopupWaypoint}
<Card.Root class="border-none shadow-md text-base max-w-80 p-2">
<Card.Header class="p-0"> <Card.Header class="p-0">
<Card.Title class="text-md"> <Card.Title class="text-md">
{#if waypoint.item.link && waypoint.item.link.attributes && waypoint.item.link.attributes.href} {#if $currentPopupWaypoint[0].link && $currentPopupWaypoint[0].link.attributes && $currentPopupWaypoint[0].link.attributes.href}
<a href={waypoint.item.link.attributes.href} target="_blank"> <a href={$currentPopupWaypoint[0].link.attributes.href} target="_blank">
{waypoint.item.name ?? waypoint.item.link.attributes.href} {$currentPopupWaypoint[0].name ?? $currentPopupWaypoint[0].link.attributes.href}
<ExternalLink size="12" class="inline-block mb-1.5" /> <ExternalLink size="12" class="inline-block mb-1.5" />
</a> </a>
{:else} {:else}
{waypoint.item.name ?? $_('gpx.waypoint')} {$currentPopupWaypoint[0].name ?? $_('gpx.waypoint')}
{/if} {/if}
</Card.Title> </Card.Title>
</Card.Header> </Card.Header>
<Card.Content class="flex flex-col text-sm p-0"> <Card.Content class="flex flex-col p-0 text-sm">
<div class="flex flex-row items-center text-muted-foreground text-xs whitespace-nowrap"> <div class="flex flex-row items-center text-muted-foreground text-xs whitespace-nowrap">
{#if symbolKey} {#if symbolKey}
<span> <span>
@@ -62,47 +66,40 @@
</span> </span>
<Dot size="16" /> <Dot size="16" />
{/if} {/if}
{waypoint.item.getLatitude().toFixed(6)}&deg; {waypoint.item {$currentPopupWaypoint[0].getLatitude().toFixed(6)}&deg; {$currentPopupWaypoint[0]
.getLongitude() .getLongitude()
.toFixed(6)}&deg; .toFixed(6)}&deg;
{#if waypoint.item.ele !== undefined} {#if $currentPopupWaypoint[0].ele !== undefined}
<Dot size="16" /> <Dot size="16" />
<WithUnits value={waypoint.item.ele} type="elevation" /> <WithUnits value={$currentPopupWaypoint[0].ele} type="elevation" />
{/if} {/if}
</div> </div>
<ScrollArea class="flex flex-col" viewportClasses="max-h-[30dvh]"> {#if $currentPopupWaypoint[0].desc}
{#if waypoint.item.desc} <span class="whitespace-pre-wrap">{@html sanitize($currentPopupWaypoint[0].desc)}</span>
<span class="whitespace-pre-wrap">{@html sanitize(waypoint.item.desc)}</span>
{/if} {/if}
{#if waypoint.item.cmt && waypoint.item.cmt !== waypoint.item.desc} {#if $currentPopupWaypoint[0].cmt && $currentPopupWaypoint[0].cmt !== $currentPopupWaypoint[0].desc}
<span class="whitespace-pre-wrap">{@html sanitize(waypoint.item.cmt)}</span> <span class="whitespace-pre-wrap">{@html sanitize($currentPopupWaypoint[0].cmt)}</span>
{/if} {/if}
</ScrollArea>
<div class="mt-2 flex flex-col gap-1">
<CopyCoordinates coordinates={waypoint.item.attributes} />
{#if $currentTool === Tool.WAYPOINT} {#if $currentTool === Tool.WAYPOINT}
<Button <Button
class="w-full px-2 py-1 h-8 justify-start" class="mt-2 w-full px-2 py-1 h-8 justify-start"
variant="outline" variant="outline"
on:click={() => deleteWaypoint(waypoint.fileId, waypoint.item._data.index)} on:click={() =>
deleteWaypoint($currentPopupWaypoint[1], $currentPopupWaypoint[0]._data.index)}
> >
<Trash2 size="16" class="mr-1" /> <Trash2 size="16" class="mr-1" />
{$_('menu.delete')} {$_('menu.delete')}
<Shortcut shift={true} click={true} /> <Shortcut key="" shift={true} click={true} />
</Button> </Button>
{/if} {/if}
</div>
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
{/if}
</div>
<style lang="postcss"> <style lang="postcss">
div :global(a) { div :global(a) {
@apply text-link; @apply text-blue-500 dark:text-blue-300;
@apply hover:underline; @apply hover:underline;
} }
div :global(img) {
@apply my-0;
@apply rounded-md;
}
</style> </style>

View File

@@ -0,0 +1,25 @@
import { dbUtils } from "$lib/db";
import type { Waypoint } from "gpx";
import mapboxgl from "mapbox-gl";
import { writable } from "svelte/store";
export const currentPopupWaypoint = writable<[Waypoint, string] | null>(null);
export const waypointPopup = new mapboxgl.Popup({
closeButton: false,
maxWidth: undefined,
offset: {
'top': [0, 0],
'top-left': [0, 0],
'top-right': [0, 0],
'bottom': [0, -30],
'bottom-left': [0, -30],
'bottom-right': [0, -30],
'left': [0, 0],
'right': [0, 0]
},
});
export function deleteWaypoint(fileId: string, waypointIndex: number) {
dbUtils.applyToFile(fileId, (file) => file.replaceWaypoints(waypointIndex, waypointIndex, []));
}

View File

@@ -15,15 +15,14 @@
Trash2, Trash2,
Move, Move,
Map, Map,
Layers2, Layers2
} from 'lucide-svelte'; } from 'lucide-svelte';
import { _ } from 'svelte-i18n'; import { _ } from 'svelte-i18n';
import { settings } from '$lib/db'; import { settings } from '$lib/db';
import { defaultBasemap, type CustomLayer } from '$lib/assets/layers'; import { defaultBasemap, extendBasemap, type CustomLayer } from '$lib/assets/layers';
import { map } from '$lib/stores'; import { map } from '$lib/stores';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import Sortable from 'sortablejs/Sortable'; import Sortable from 'sortablejs/Sortable';
import { customBasemapUpdate } from './utils';
const { const {
customLayers, customLayers,
@@ -34,7 +33,7 @@
currentOverlays, currentOverlays,
previousOverlays, previousOverlays,
customBasemapOrder, customBasemapOrder,
customOverlayOrder, customOverlayOrder
} = settings; } = settings;
let name: string = ''; let name: string = '';
@@ -68,7 +67,7 @@
acc[id] = true; acc[id] = true;
return acc; return acc;
}, {}); }, {});
}, }
}); });
overlaySortable = Sortable.create(overlayContainer, { overlaySortable = Sortable.create(overlayContainer, {
onSort: (e) => { onSort: (e) => {
@@ -77,7 +76,7 @@
acc[id] = true; acc[id] = true;
return acc; return acc;
}, {}); }, {});
}, }
}); });
basemapSortable.sort($customBasemapOrder); basemapSortable.sort($customBasemapOrder);
@@ -95,6 +94,7 @@
(tileUrls[0].includes('api.mapbox.com/styles') && !tileUrls[0].includes('tiles')) (tileUrls[0].includes('api.mapbox.com/styles') && !tileUrls[0].includes('tiles'))
) { ) {
resourceType = 'vector'; resourceType = 'vector';
layerType = 'basemap';
} else { } else {
resourceType = 'raster'; resourceType = 'raster';
} }
@@ -108,41 +108,47 @@
if (typeof maxZoom === 'string') { if (typeof maxZoom === 'string') {
maxZoom = parseInt(maxZoom); maxZoom = parseInt(maxZoom);
} }
let is512 = tileUrls.some((url) => url.includes('512'));
let layerId = selectedLayerId ?? getLayerId(); let layerId = selectedLayerId ?? getLayerId();
let layer: CustomLayer = { let layer: CustomLayer = {
id: layerId, id: layerId,
name: name, name: name,
tileUrls: tileUrls.map((url) => decodeURI(url.trim())), tileUrls: tileUrls,
maxZoom: maxZoom, maxZoom: maxZoom,
layerType: layerType, layerType: layerType,
resourceType: resourceType, resourceType: resourceType,
value: '', value: ''
}; };
if (resourceType === 'vector') { if (resourceType === 'vector') {
layer.value = layer.tileUrls[0]; layer.value = tileUrls[0];
} else { } else {
layer.value = { if (layerType === 'basemap') {
layer.value = extendBasemap({
version: 8, version: 8,
sources: { sources: {
[layerId]: { [layerId]: {
type: 'raster', type: 'raster',
tiles: layer.tileUrls, tiles: tileUrls,
tileSize: is512 ? 512 : 256, maxzoom: maxZoom
maxzoom: maxZoom, }
},
}, },
layers: [ layers: [
{ {
id: layerId, id: layerId,
type: 'raster', type: 'raster',
source: layerId, source: layerId
}, }
], ]
});
} else {
layer.value = {
type: 'raster',
tiles: tileUrls,
maxzoom: maxZoom
}; };
} }
}
$customLayers[layerId] = layer; $customLayers[layerId] = layer;
addLayer(layerId); addLayer(layerId);
selectedLayerId = undefined; selectedLayerId = undefined;
@@ -167,11 +173,7 @@
return $tree; return $tree;
}); });
if ($currentBasemap === layerId) {
$customBasemapUpdate++;
} else {
$currentBasemap = layerId; $currentBasemap = layerId;
}
if (!$customBasemapOrder.includes(layerId)) { if (!$customBasemapOrder.includes(layerId)) {
$customBasemapOrder = [...$customBasemapOrder, layerId]; $customBasemapOrder = [...$customBasemapOrder, layerId];
@@ -185,16 +187,12 @@
return $tree; return $tree;
}); });
if ( if ($map && $map.getSource(layerId)) {
$currentOverlays.overlays['custom'] && // Reset source when updating an existing layer
$currentOverlays.overlays['custom'][layerId] && if ($map.getLayer(layerId)) {
$map $map.removeLayer(layerId);
) {
try {
$map.removeImport(layerId);
} catch (e) {
// No reliable way to check if the map is ready to remove sources and layers
} }
$map.removeSource(layerId);
} }
if (!$currentOverlays.overlays.hasOwnProperty('custom')) { if (!$currentOverlays.overlays.hasOwnProperty('custom')) {
@@ -230,10 +228,7 @@
layerId layerId
); );
if (Object.keys($selectedBasemapTree.basemaps['custom']).length === 0) { if (Object.keys($selectedBasemapTree.basemaps['custom']).length === 0) {
$selectedBasemapTree.basemaps = tryDeleteLayer( $selectedBasemapTree.basemaps = tryDeleteLayer($selectedBasemapTree.basemaps, 'custom');
$selectedBasemapTree.basemaps,
'custom'
);
} }
$customBasemapOrder = $customBasemapOrder.filter((id) => id !== layerId); $customBasemapOrder = $customBasemapOrder.filter((id) => id !== layerId);
} else { } else {
@@ -250,22 +245,16 @@
layerId layerId
); );
if (Object.keys($selectedOverlayTree.overlays['custom']).length === 0) { if (Object.keys($selectedOverlayTree.overlays['custom']).length === 0) {
$selectedOverlayTree.overlays = tryDeleteLayer( $selectedOverlayTree.overlays = tryDeleteLayer($selectedOverlayTree.overlays, 'custom');
$selectedOverlayTree.overlays,
'custom'
);
} }
$customOverlayOrder = $customOverlayOrder.filter((id) => id !== layerId); $customOverlayOrder = $customOverlayOrder.filter((id) => id !== layerId);
if ( if ($map) {
$currentOverlays.overlays['custom'] && if ($map.getLayer(layerId)) {
$currentOverlays.overlays['custom'][layerId] && $map.removeLayer(layerId);
$map }
) { if ($map.getSource(layerId)) {
try { $map.removeSource(layerId);
$map.removeImport(layerId);
} catch (e) {
// No reliable way to check if the map is ready to remove sources and layers
} }
} }
} }
@@ -373,8 +362,7 @@
/> />
{#if tileUrls.length > 1} {#if tileUrls.length > 1}
<Button <Button
on:click={() => on:click={() => (tileUrls = tileUrls.filter((_, index) => index !== i))}
(tileUrls = tileUrls.filter((_, index) => index !== i))}
variant="outline" variant="outline"
class="p-1 h-8" class="p-1 h-8"
> >
@@ -394,14 +382,7 @@
{/each} {/each}
{#if resourceType === 'raster'} {#if resourceType === 'raster'}
<Label for="maxZoom">{$_('layers.custom_layers.max_zoom')}</Label> <Label for="maxZoom">{$_('layers.custom_layers.max_zoom')}</Label>
<Input <Input type="number" bind:value={maxZoom} id="maxZoom" min={0} max={22} class="h-8" />
type="number"
bind:value={maxZoom}
id="maxZoom"
min={0}
max={22}
class="h-8"
/>
{/if} {/if}
<Label>{$_('layers.custom_layers.layer_type')}</Label> <Label>{$_('layers.custom_layers.layer_type')}</Label>
<RadioGroup.Root bind:value={layerType} class="flex flex-row"> <RadioGroup.Root bind:value={layerType} class="flex flex-row">
@@ -410,7 +391,7 @@
<Label for="basemap">{$_('layers.custom_layers.basemap')}</Label> <Label for="basemap">{$_('layers.custom_layers.basemap')}</Label>
</div> </div>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<RadioGroup.Item value="overlay" id="overlay" /> <RadioGroup.Item value="overlay" id="overlay" disabled={resourceType === 'vector'} />
<Label for="overlay">{$_('layers.custom_layers.overlay')}</Label> <Label for="overlay">{$_('layers.custom_layers.overlay')}</Label>
</div> </div>
</RadioGroup.Root> </RadioGroup.Root>

View File

@@ -11,8 +11,9 @@
import { settings } from '$lib/db'; import { settings } from '$lib/db';
import { map } from '$lib/stores'; import { map } from '$lib/stores';
import { get, writable } from 'svelte/store'; import { get, writable } from 'svelte/store';
import { customBasemapUpdate, getLayers } from './utils'; import { getLayers } from './utils';
import { OverpassLayer } from './OverpassLayer'; import { OverpassLayer } from './OverpassLayer';
import OverpassPopup from './OverpassPopup.svelte';
let container: HTMLDivElement; let container: HTMLDivElement;
let overpassLayer: OverpassLayer; let overpassLayer: OverpassLayer;
@@ -26,91 +27,41 @@
selectedOverlayTree, selectedOverlayTree,
selectedOverpassTree, selectedOverpassTree,
customLayers, customLayers,
opacities, opacities
} = settings; } = settings;
function setStyle() { function setStyle() {
if ($map) { if ($map) {
let basemap = basemaps.hasOwnProperty($currentBasemap) let basemap = basemaps.hasOwnProperty($currentBasemap)
? basemaps[$currentBasemap] ? basemaps[$currentBasemap]
: ($customLayers[$currentBasemap]?.value ?? basemaps[defaultBasemap]); : $customLayers[$currentBasemap]?.value ?? basemaps[defaultBasemap];
$map.removeImport('basemap'); $map.setStyle(basemap, {
if (typeof basemap === 'string') { diff: false
$map.addImport({ id: 'basemap', url: basemap }, 'overlays'); });
} else {
$map.addImport(
{
id: 'basemap',
data: basemap,
},
'overlays'
);
}
} }
} }
$: if ($map && ($currentBasemap || $customBasemapUpdate)) { $: if ($map && $currentBasemap) {
setStyle(); setStyle();
} }
function addOverlay(id: string) { $: if ($map && $currentOverlays) {
try { // Add or remove overlay layers depending on the current overlays
let overlay = $customLayers.hasOwnProperty(id) ? $customLayers[id].value : overlays[id];
if (typeof overlay === 'string') {
$map.addImport({ id, url: overlay });
} else {
if ($opacities.hasOwnProperty(id)) {
overlay = {
...overlay,
layers: overlay.layers.map((layer) => {
if (layer.type === 'raster') {
if (!layer.paint) {
layer.paint = {};
}
layer.paint['raster-opacity'] = $opacities[id];
}
return layer;
}),
};
}
$map.addImport({
id,
data: overlay,
});
}
} catch (e) {
// No reliable way to check if the map is ready to add sources and layers
}
}
function updateOverlays() {
if ($map && $currentOverlays && $opacities) {
let overlayLayers = getLayers($currentOverlays); let overlayLayers = getLayers($currentOverlays);
try { Object.keys(overlayLayers).forEach((id) => {
let activeOverlays = $map.getStyle().imports.reduce((acc, i) => { if (overlayLayers[id]) {
if (!['basemap', 'overlays', 'glyphs-and-sprite'].includes(i.id)) { if (!addOverlayLayer.hasOwnProperty(id)) {
acc[i.id] = i; addOverlayLayer[id] = addOverlayLayerForId(id);
}
if (!$map.getLayer(id)) {
addOverlayLayer[id]();
$map.on('style.load', addOverlayLayer[id]);
}
} else if ($map.getLayer(id)) {
$map.removeLayer(id);
$map.off('style.load', addOverlayLayer[id]);
} }
return acc;
}, {});
let toRemove = Object.keys(activeOverlays).filter((id) => !overlayLayers[id]);
toRemove.forEach((id) => {
$map.removeImport(id);
}); });
let toAdd = Object.entries(overlayLayers)
.filter(([id, selected]) => selected && !activeOverlays.hasOwnProperty(id))
.map(([id]) => id);
toAdd.forEach((id) => {
addOverlay(id);
});
} catch (e) {
// No reliable way to check if the map is ready to add sources and layers
}
}
}
$: if ($map && $currentOverlays && $opacities) {
updateOverlays();
} }
$: if ($map) { $: if ($map) {
@@ -119,7 +70,6 @@
} }
overpassLayer = new OverpassLayer($map); overpassLayer = new OverpassLayer($map);
overpassLayer.add(); overpassLayer.add();
$map.on('style.import.load', updateOverlays);
} }
let selectedBasemap = writable(get(currentBasemap)); let selectedBasemap = writable(get(currentBasemap));
@@ -132,11 +82,40 @@
}); });
currentBasemap.subscribe((value) => { currentBasemap.subscribe((value) => {
// Updates coming from the database, or from the user swapping basemaps // Updates coming from the database, or from the user swapping basemaps
if (value !== get(selectedBasemap)) {
selectedBasemap.set(value); selectedBasemap.set(value);
}
}); });
let addOverlayLayer: { [key: string]: () => void } = {};
function addOverlayLayerForId(id: string) {
return () => {
if ($map) {
try {
let overlay = $customLayers.hasOwnProperty(id) ? $customLayers[id].value : overlays[id];
if (!$map.getSource(id)) {
$map.addSource(id, overlay);
}
$map.addLayer(
{
id,
type: overlay.type === 'raster' ? 'raster' : 'line',
source: id,
paint: {
...(id in $opacities
? overlay.type === 'raster'
? { 'raster-opacity': $opacities[id] }
: { 'line-opacity': $opacities[id] }
: {})
}
},
'overlays'
);
} catch (e) {
// No reliable way to check if the map is ready to add sources and layers
}
}
};
}
let open = false; let open = false;
function openLayerControl() { function openLayerControl() {
open = true; open = true;
@@ -213,6 +192,8 @@
</div> </div>
</CustomControl> </CustomControl>
<OverpassPopup />
<svelte:window <svelte:window
on:click={(e) => { on:click={(e) => {
if (open && !cancelEvents && !container.contains(e.target)) { if (open && !cancelEvents && !container.contains(e.target)) {

View File

@@ -9,14 +9,8 @@
import * as Select from '$lib/components/ui/select'; import * as Select from '$lib/components/ui/select';
import { Slider } from '$lib/components/ui/slider'; import { Slider } from '$lib/components/ui/slider';
import { import { basemapTree, overlays, overlayTree, overpassTree } from '$lib/assets/layers';
basemapTree, import { isSelected } from '$lib/components/layer-control/utils';
defaultBasemap,
overlays,
overlayTree,
overpassTree,
} from '$lib/assets/layers';
import { getLayers, isSelected, toggle } from '$lib/components/layer-control/utils';
import { settings } from '$lib/db'; import { settings } from '$lib/db';
import { _ } from 'svelte-i18n'; import { _ } from 'svelte-i18n';
@@ -28,10 +22,9 @@
selectedBasemapTree, selectedBasemapTree,
selectedOverlayTree, selectedOverlayTree,
selectedOverpassTree, selectedOverpassTree,
currentBasemap,
currentOverlays, currentOverlays,
customLayers, customLayers,
opacities, opacities
} = settings; } = settings;
export let open: boolean; export let open: boolean;
@@ -53,30 +46,6 @@
} }
} }
$: if ($selectedBasemapTree && $currentBasemap) {
if (!isSelected($selectedBasemapTree, $currentBasemap)) {
if (!isSelected($selectedBasemapTree, defaultBasemap)) {
$selectedBasemapTree = toggle($selectedBasemapTree, defaultBasemap);
}
$currentBasemap = defaultBasemap;
}
}
$: if ($selectedOverlayTree && $currentOverlays) {
let overlayLayers = getLayers($currentOverlays);
let toRemove = Object.entries(overlayLayers).filter(
([id, checked]) => checked && !isSelected($selectedOverlayTree, id)
);
if (toRemove.length > 0) {
currentOverlays.update((tree) => {
toRemove.forEach(([id]) => {
toggle(tree, id);
});
return tree;
});
}
}
$: if ($selectedOverlay) { $: if ($selectedOverlay) {
setOpacityFromSelection(); setOpacityFromSelection();
} }
@@ -137,9 +106,7 @@
<Select.Content class="h-fit max-h-[40dvh] overflow-y-auto"> <Select.Content class="h-fit max-h-[40dvh] overflow-y-auto">
{#each Object.keys(overlays) as id} {#each Object.keys(overlays) as id}
{#if isSelected($selectedOverlayTree, id)} {#if isSelected($selectedOverlayTree, id)}
<Select.Item value={id} <Select.Item value={id}>{$_(`layers.label.${id}`)}</Select.Item>
>{$_(`layers.label.${id}`)}</Select.Item
>
{/if} {/if}
{/each} {/each}
{#each Object.entries($customLayers) as [id, layer]} {#each Object.entries($customLayers) as [id, layer]}
@@ -159,22 +126,15 @@
max={1} max={1}
step={0.1} step={0.1}
disabled={$selectedOverlay === undefined} disabled={$selectedOverlay === undefined}
onValueChange={(value) => { onValueChange={() => {
if ($selectedOverlay) { if ($selectedOverlay) {
if ( $opacities[$selectedOverlay.value] = $overlayOpacity[0];
$map && if ($map) {
isSelected( if ($map.getLayer($selectedOverlay.value)) {
$currentOverlays, $map.removeLayer($selectedOverlay.value);
$selectedOverlay.value $currentOverlays = $currentOverlays;
)
) {
try {
$map.removeImport($selectedOverlay.value);
} catch (e) {
// No reliable way to check if the map is ready to remove sources and layers
} }
} }
$opacities[$selectedOverlay.value] = value[0];
} }
}} }}
/> />

View File

@@ -46,16 +46,9 @@
value={id} value={id}
bind:checked={checked[id]} bind:checked={checked[id]}
class="scale-90" class="scale-90"
aria-label={$_(`layers.label.${id}`)}
/> />
{:else} {:else}
<input <input id="{name}-{id}" type="radio" {name} value={id} bind:group={selected} />
id="{name}-{id}"
type="radio"
{name}
value={id}
bind:group={selected}
/>
{/if} {/if}
<Label for="{name}-{id}" class="flex flex-row items-center gap-1"> <Label for="{name}-{id}" class="flex flex-row items-center gap-1">
{#if $customLayers.hasOwnProperty(id)} {#if $customLayers.hasOwnProperty(id)}
@@ -70,13 +63,7 @@
<CollapsibleTreeNode {id}> <CollapsibleTreeNode {id}>
<span slot="trigger">{$_(`layers.label.${id}`)}</span> <span slot="trigger">{$_(`layers.label.${id}`)}</span>
<div slot="content"> <div slot="content">
<svelte:self <svelte:self node={node[id]} {name} bind:selected {multiple} bind:checked={checked[id]} />
node={node[id]}
{name}
bind:selected
{multiple}
bind:checked={checked[id]}
/>
</div> </div>
</CollapsibleTreeNode> </CollapsibleTreeNode>
{/if} {/if}

View File

@@ -1,17 +1,27 @@
import SphericalMercator from '@mapbox/sphericalmercator'; import SphericalMercator from "@mapbox/sphericalmercator";
import { getLayers } from './utils'; import { getLayers } from "./utils";
import { get, writable } from 'svelte/store'; import mapboxgl from "mapbox-gl";
import { liveQuery } from 'dexie'; import { get, writable } from "svelte/store";
import { db, settings } from '$lib/db'; import { liveQuery } from "dexie";
import { overpassQueryData } from '$lib/assets/layers'; import { db, settings } from "$lib/db";
import { MapPopup } from '$lib/components/MapPopup'; import { overpassQueryData } from "$lib/assets/layers";
const { currentOverpassQueries } = settings; const {
currentOverpassQueries
} = settings;
const mercator = new SphericalMercator({ const mercator = new SphericalMercator({
size: 256, size: 256,
}); });
export const overpassPopupPOI = writable<Record<string, any> | null>(null);
export const overpassPopup = new mapboxgl.Popup({
closeButton: false,
maxWidth: undefined,
offset: 15,
});
let data = writable<GeoJSON.FeatureCollection>({ type: 'FeatureCollection', features: [] }); let data = writable<GeoJSON.FeatureCollection>({ type: 'FeatureCollection', features: [] });
liveQuery(() => db.overpassdata.toArray()).subscribe((pois) => { liveQuery(() => db.overpassdata.toArray()).subscribe((pois) => {
@@ -24,36 +34,28 @@ export class OverpassLayer {
queryZoom = 12; queryZoom = 12;
expirationTime = 7 * 24 * 3600 * 1000; expirationTime = 7 * 24 * 3600 * 1000;
map: mapboxgl.Map; map: mapboxgl.Map;
popup: MapPopup;
currentQueries: Set<string> = new Set(); currentQueries: Set<string> = new Set();
nextQueries: Map<string, { x: number; y: number; queries: string[] }> = new Map(); nextQueries: Map<string, { x: number, y: number, queries: string[] }> = new Map();
unsubscribes: (() => void)[] = []; unsubscribes: (() => void)[] = [];
queryIfNeededBinded = this.queryIfNeeded.bind(this); queryIfNeededBinded = this.queryIfNeeded.bind(this);
updateBinded = this.update.bind(this); updateBinded = this.update.bind(this);
onHoverBinded = this.onHover.bind(this); onHoverBinded = this.onHover.bind(this);
maybeHidePopupBinded = this.maybeHidePopup.bind(this);
constructor(map: mapboxgl.Map) { constructor(map: mapboxgl.Map) {
this.map = map; this.map = map;
this.popup = new MapPopup(map, {
closeButton: false,
focusAfterOpen: false,
maxWidth: undefined,
offset: 15,
});
} }
add() { add() {
this.map.on('moveend', this.queryIfNeededBinded); this.map.on('moveend', this.queryIfNeededBinded);
this.map.on('style.import.load', this.updateBinded); this.map.on('style.load', this.updateBinded);
this.unsubscribes.push(data.subscribe(this.updateBinded)); this.unsubscribes.push(data.subscribe(this.updateBinded));
this.unsubscribes.push( this.unsubscribes.push(currentOverpassQueries.subscribe(() => {
currentOverpassQueries.subscribe(() => {
this.updateBinded(); this.updateBinded();
this.queryIfNeededBinded(); this.queryIfNeededBinded();
}) }));
);
this.update(); this.update();
} }
@@ -106,10 +108,9 @@ export class OverpassLayer {
remove() { remove() {
this.map.off('moveend', this.queryIfNeededBinded); this.map.off('moveend', this.queryIfNeededBinded);
this.map.off('style.import.load', this.updateBinded); this.map.off('style.load', this.updateBinded);
this.unsubscribes.forEach((unsubscribe) => unsubscribe()); this.unsubscribes.forEach((unsubscribe) => unsubscribe());
try {
if (this.map.getLayer('overpass')) { if (this.map.getLayer('overpass')) {
this.map.removeLayer('overpass'); this.map.removeLayer('overpass');
} }
@@ -117,18 +118,30 @@ export class OverpassLayer {
if (this.map.getSource('overpass')) { if (this.map.getSource('overpass')) {
this.map.removeSource('overpass'); this.map.removeSource('overpass');
} }
} catch (e) {
// No reliable way to check if the map is ready to remove sources and layers
}
} }
onHover(e: any) { onHover(e: any) {
this.popup.setItem({ overpassPopupPOI.set({
item: {
...e.features[0].properties, ...e.features[0].properties,
sym: overpassQueryData[e.features[0].properties.query].symbol ?? '', sym: overpassQueryData[e.features[0].properties.query].symbol ?? ''
},
}); });
overpassPopup.setLngLat(e.features[0].geometry.coordinates);
overpassPopup.addTo(this.map);
this.map.on('mousemove', this.maybeHidePopupBinded);
}
maybeHidePopup(e: any) {
let poi = get(overpassPopupPOI);
if (poi && this.map.project([poi.lon, poi.lat]).dist(this.map.project(e.lngLat)) > 100) {
this.hideWaypointPopup();
}
}
hideWaypointPopup() {
overpassPopupPOI.set(null);
overpassPopup.remove();
this.map.off('mousemove', this.maybeHidePopupBinded);
} }
query(bbox: [number, number, number, number]) { query(bbox: [number, number, number, number]) {
@@ -146,19 +159,8 @@ export class OverpassLayer {
continue; continue;
} }
db.overpasstiles db.overpasstiles.where('[x+y]').equals([x, y]).toArray().then((querytiles) => {
.where('[x+y]') let missingQueries = queries.filter((query) => !querytiles.some((querytile) => querytile.query === query && time - querytile.time < this.expirationTime));
.equals([x, y])
.toArray()
.then((querytiles) => {
let missingQueries = queries.filter(
(query) =>
!querytiles.some(
(querytile) =>
querytile.query === query &&
time - querytile.time < this.expirationTime
)
);
if (missingQueries.length > 0) { if (missingQueries.length > 0) {
this.queryTile(x, y, missingQueries); this.queryTile(x, y, missingQueries);
} }
@@ -176,16 +178,13 @@ export class OverpassLayer {
const bounds = mercator.bbox(x, y, this.queryZoom); const bounds = mercator.bbox(x, y, this.queryZoom);
fetch(`${this.overpassUrl}?data=${getQueryForBounds(bounds, queries)}`) fetch(`${this.overpassUrl}?data=${getQueryForBounds(bounds, queries)}`)
.then( .then((response) => {
(response) => {
if (response.ok) { if (response.ok) {
return response.json(); return response.json();
} }
this.currentQueries.delete(`${x},${y}`); this.currentQueries.delete(`${x},${y}`);
return Promise.reject(); return Promise.reject();
}, }, () => (this.currentQueries.delete(`${x},${y}`)))
() => this.currentQueries.delete(`${x},${y}`)
)
.then((data) => this.storeOverpassData(x, y, queries, data)) .then((data) => this.storeOverpassData(x, y, queries, data))
.catch(() => this.currentQueries.delete(`${x},${y}`)); .catch(() => this.currentQueries.delete(`${x},${y}`));
} }
@@ -193,7 +192,7 @@ export class OverpassLayer {
storeOverpassData(x: number, y: number, queries: string[], data: any) { storeOverpassData(x: number, y: number, queries: string[], data: any) {
let time = Date.now(); let time = Date.now();
let queryTiles = queries.map((query) => ({ x, y, query, time })); let queryTiles = queries.map((query) => ({ x, y, query, time }));
let pois: { query: string; id: number; poi: GeoJSON.Feature }[] = []; let pois: { query: string, id: number, poi: GeoJSON.Feature }[] = [];
if (data.elements === undefined) { if (data.elements === undefined) {
return; return;
@@ -209,9 +208,7 @@ export class OverpassLayer {
type: 'Feature', type: 'Feature',
geometry: { geometry: {
type: 'Point', type: 'Point',
coordinates: element.center coordinates: element.center ? [element.center.lon, element.center.lat] : [element.lon, element.lat],
? [element.center.lon, element.center.lat]
: [element.lon, element.lat],
}, },
properties: { properties: {
id: element.id, id: element.id,
@@ -219,10 +216,9 @@ export class OverpassLayer {
lon: element.center ? element.center.lon : element.lon, lon: element.center ? element.center.lon : element.lon,
query: query, query: query,
icon: `overpass-${query}`, icon: `overpass-${query}`,
tags: element.tags, tags: element.tags
type: element.type,
},
}, },
}
}); });
} }
} }
@@ -245,13 +241,11 @@ export class OverpassLayer {
if (!this.map.hasImage(`overpass-${query}`)) { if (!this.map.hasImage(`overpass-${query}`)) {
this.map.addImage(`overpass-${query}`, icon); this.map.addImage(`overpass-${query}`, icon);
} }
}; }
// Lucide icons are SVG files with a 24x24 viewBox // Lucide icons are SVG files with a 24x24 viewBox
// Create a new SVG with a 32x32 viewBox and center the icon in a circle // Create a new SVG with a 32x32 viewBox and center the icon in a circle
icon.src = icon.src = 'data:image/svg+xml,' + encodeURIComponent(`
'data:image/svg+xml,' +
encodeURIComponent(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
<circle cx="20" cy="20" r="20" fill="${overpassQueryData[query].icon.color}" /> <circle cx="20" cy="20" r="20" fill="${overpassQueryData[query].icon.color}" />
<g transform="translate(8 8)"> <g transform="translate(8 8)">
@@ -283,14 +277,9 @@ function getQuery(query: string) {
function getQueryItem(tags: Record<string, string | boolean | string[]>) { function getQueryItem(tags: Record<string, string | boolean | string[]>) {
let arrayEntry = Object.entries(tags).find(([_, value]) => Array.isArray(value)); let arrayEntry = Object.entries(tags).find(([_, value]) => Array.isArray(value));
if (arrayEntry !== undefined) { if (arrayEntry !== undefined) {
return arrayEntry[1] return arrayEntry[1].map((val) => `nwr${Object.entries(tags)
.map(
(val) =>
`nwr${Object.entries(tags)
.map(([tag, value]) => `[${tag}=${tag === arrayEntry[0] ? val : value}]`) .map(([tag, value]) => `[${tag}=${tag === arrayEntry[0] ? val : value}]`)
.join('')};` .join('')};`).join('');
)
.join('');
} else { } else {
return `nwr${Object.entries(tags) return `nwr${Object.entries(tags)
.map(([tag, value]) => `[${tag}=${value}]`) .map(([tag, value]) => `[${tag}=${value}]`)
@@ -307,9 +296,8 @@ function belongsToQuery(element: any, query: string) {
} }
function belongsToQueryItem(element: any, tags: Record<string, string | boolean | string[]>) { function belongsToQueryItem(element: any, tags: Record<string, string | boolean | string[]>) {
return Object.entries(tags).every(([tag, value]) => return Object.entries(tags)
Array.isArray(value) ? value.includes(element.tags[tag]) : element.tags[tag] === value .every(([tag, value]) => Array.isArray(value) ? value.includes(element.tags[tag]) : element.tags[tag] === value);
);
} }
function getCurrentQueries() { function getCurrentQueries() {
@@ -318,7 +306,5 @@ function getCurrentQueries() {
return []; return [];
} }
return Object.entries(getLayers(currentQueries)) return Object.entries(getLayers(currentQueries)).filter(([_, selected]) => selected).map(([query, _]) => query);
.filter(([_, selected]) => selected)
.map(([query, _]) => query);
} }

View File

@@ -1,67 +1,48 @@
<script lang="ts"> <script lang="ts">
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { overpassPopup, overpassPopupPOI } from './OverpassLayer';
import { selection } from '$lib/components/file-list/Selection'; import { selection } from '$lib/components/file-list/Selection';
import { PencilLine, MapPin } from 'lucide-svelte'; import { PencilLine, MapPin } from 'lucide-svelte';
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n'; import { _ } from 'svelte-i18n';
import { dbUtils } from '$lib/db'; import { dbUtils } from '$lib/db';
import type { PopupItem } from '$lib/components/MapPopup';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import type { WaypointType } from 'gpx';
export let poi: PopupItem<any>; let popupElement: HTMLDivElement;
let tags: { [key: string]: string } = {}; onMount(() => {
overpassPopup.setDOMContent(popupElement);
popupElement.classList.remove('hidden');
});
let tags = {};
let name = ''; let name = '';
$: if (poi) { $: if ($overpassPopupPOI) {
tags = JSON.parse(poi.item.tags); tags = JSON.parse($overpassPopupPOI.tags);
if (tags.name !== undefined && tags.name !== '') { if (tags.name !== undefined && tags.name !== '') {
name = tags.name; name = tags.name;
} else { } else {
name = $_(`layers.label.${poi.item.query}`); name = $_(`layers.label.${$overpassPopupPOI.query}`);
} }
} }
function addToFile() {
const desc = Object.entries(tags)
.map(([key, value]) => `${key}: ${value}`)
.join('\n');
let wpt: WaypointType = {
attributes: {
lat: poi.item.lat,
lon: poi.item.lon,
},
name: name,
desc: desc,
cmt: desc,
sym: poi.item.sym,
};
if (tags.website) {
wpt.link = {
attributes: {
href: tags.website,
},
};
}
dbUtils.addOrUpdateWaypoint(wpt);
}
</script> </script>
<Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw]"> <div bind:this={popupElement} class="hidden">
{#if $overpassPopupPOI}
<Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw]">
<Card.Header class="p-0"> <Card.Header class="p-0">
<Card.Title class="text-md"> <Card.Title class="text-md">
<div class="flex flex-row gap-3"> <div class="flex flex-row gap-3">
<div class="flex flex-col"> <div class="flex flex-col">
{name} {name}
<div class="text-muted-foreground text-sm font-normal"> <div class="text-muted-foreground text-sm font-normal">
{poi.item.lat.toFixed(6)}&deg; {poi.item.lon.toFixed(6)}&deg; {$overpassPopupPOI.lat.toFixed(6)}&deg; {$overpassPopupPOI.lon.toFixed(6)}&deg;
</div> </div>
</div> </div>
<Button <Button
class="ml-auto p-1.5 h-8" class="ml-auto p-1.5 h-8"
variant="outline" variant="outline"
href="https://www.openstreetmap.org/edit?editor=id&{poi.item.type ?? href="https://www.openstreetmap.org/edit?editor=id&node={$overpassPopupPOI.id}"
'node'}={poi.item.id}"
target="_blank" target="_blank"
> >
<PencilLine size="16" /> <PencilLine size="16" />
@@ -69,39 +50,53 @@
</div> </div>
</Card.Title> </Card.Title>
</Card.Header> </Card.Header>
<Card.Content class="flex flex-col p-0 text-sm mt-1 whitespace-normal break-all">
<ScrollArea class="flex flex-col" viewportClasses="max-h-[30dvh]">
{#if tags.image || tags['image:0']} {#if tags.image || tags['image:0']}
<div class="w-full rounded-md overflow-clip my-2 max-w-96 mx-auto"> <div class="w-full rounded-md overflow-clip my-2 max-w-96 mx-auto">
<!-- svelte-ignore a11y-missing-attribute --> <!-- svelte-ignore a11y-missing-attribute -->
<img src={tags.image ?? tags['image:0']} /> <img src={tags.image ?? tags['image:0']} />
</div> </div>
{/if} {/if}
<Card.Content class="flex flex-col p-0 text-sm mt-1 whitespace-normal break-all">
<div class="grid grid-cols-[auto_auto] gap-x-3"> <div class="grid grid-cols-[auto_auto] gap-x-3">
{#each Object.entries(tags) as [key, value]} {#each Object.entries(tags) as [key, value]}
{#if key !== 'name' && !key.includes('image')} {#if key !== 'name' && !key.includes('image')}
<span class="font-mono">{key}</span> <span class="font-mono">{key}</span>
{#if key === 'website' || key.startsWith('website:') || key === 'contact:website' || key === 'contact:facebook' || key === 'contact:instagram' || key === 'contact:twitter'} {#if key === 'website' || key === 'contact:website' || key === 'contact:facebook' || key === 'contact:instagram' || key === 'contact:twitter'}
<a href={value} target="_blank" class="text-link underline">{value}</a> <a href={value} target="_blank" class="text-blue-500 underline">{value}</a>
{:else if key === 'phone' || key === 'contact:phone'} {:else if key === 'phone' || key === 'contact:phone'}
<a href={'tel:' + value} class="text-link underline">{value}</a> <a href={'tel:' + value} class="text-blue-500 underline">{value}</a>
{:else if key === 'email' || key === 'contact:email'} {:else if key === 'email' || key === 'contact:email'}
<a href={'mailto:' + value} class="text-link underline">{value}</a> <a href={'mailto:' + value} class="text-blue-500 underline">{value}</a>
{:else} {:else}
<span>{value}</span> <span>{value}</span>
{/if} {/if}
{/if} {/if}
{/each} {/each}
</div> </div>
</ScrollArea>
<Button <Button
class="mt-2" class="mt-2"
variant="outline" variant="outline"
disabled={$selection.size === 0} disabled={$selection.size === 0}
on:click={addToFile} on:click={() => {
let desc = Object.entries(tags)
.map(([key, value]) => `${key}: ${value}`)
.join('\n');
dbUtils.addOrUpdateWaypoint({
attributes: {
lat: $overpassPopupPOI.lat,
lon: $overpassPopupPOI.lon
},
name: name,
desc: desc,
cmt: desc,
sym: $overpassPopupPOI.sym
});
}}
> >
<MapPin size="16" class="mr-1" /> <MapPin size="16" class="mr-1" />
{$_('toolbar.waypoint.add')} {$_('toolbar.waypoint.add')}
</Button> </Button>
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
{/if}
</div>

View File

@@ -1,10 +1,8 @@
import type { LayerTreeType } from '$lib/assets/layers'; import type { LayerTreeType } from "$lib/assets/layers";
import { writable } from 'svelte/store';
export function anySelectedLayer(node: LayerTreeType) { export function anySelectedLayer(node: LayerTreeType) {
return ( return Object.keys(node).find((id) => {
Object.keys(node).find((id) => { if (typeof node[id] == "boolean") {
if (typeof node[id] == 'boolean') {
if (node[id]) { if (node[id]) {
return true; return true;
} }
@@ -14,16 +12,12 @@ export function anySelectedLayer(node: LayerTreeType) {
} }
} }
return false; return false;
}) !== undefined }) !== undefined;
);
} }
export function getLayers( export function getLayers(node: LayerTreeType, layers: { [key: string]: boolean } = {}): { [key: string]: boolean } {
node: LayerTreeType,
layers: { [key: string]: boolean } = {}
): { [key: string]: boolean } {
Object.keys(node).forEach((id) => { Object.keys(node).forEach((id) => {
if (typeof node[id] == 'boolean') { if (typeof node[id] == "boolean") {
layers[id] = node[id]; layers[id] = node[id];
} else { } else {
getLayers(node[id], layers); getLayers(node[id], layers);
@@ -37,22 +31,9 @@ export function isSelected(node: LayerTreeType, id: string) {
if (key === id) { if (key === id) {
return node[key]; return node[key];
} }
if (typeof node[key] !== 'boolean' && isSelected(node[key], id)) { if (typeof node[key] !== "boolean" && isSelected(node[key], id)) {
return true; return true;
} }
return false; return false;
}); });
} }
export function toggle(node: LayerTreeType, id: string) {
Object.keys(node).forEach((key) => {
if (key === id) {
node[key] = !node[key];
} else if (typeof node[key] !== 'boolean') {
toggle(node[key], id);
}
});
return node;
}
export const customBasemapUpdate = writable(0);

View File

@@ -1,5 +1,5 @@
import { resetCursor, setCrosshairCursor } from '$lib/utils'; import { resetCursor, setCrosshairCursor } from "$lib/utils";
import type mapboxgl from 'mapbox-gl'; import type mapboxgl from "mapbox-gl";
export class GoogleRedirect { export class GoogleRedirect {
map: mapboxgl.Map; map: mapboxgl.Map;

View File

@@ -1,19 +1,16 @@
import mapboxgl, { type LayerSpecification, type VectorSourceSpecification } from 'mapbox-gl'; import mapboxgl from "mapbox-gl";
import { Viewer, type ViewerBearingEvent } from 'mapillary-js/dist/mapillary.module'; import { Viewer } from 'mapillary-js/dist/mapillary.module';
import 'mapillary-js/dist/mapillary.css'; import 'mapillary-js/dist/mapillary.css';
import { resetCursor, setPointerCursor } from '$lib/utils'; import { resetCursor, setPointerCursor } from "$lib/utils";
import type { Writable } from 'svelte/store';
const mapillarySource: VectorSourceSpecification = { const mapillarySource = {
type: 'vector', type: 'vector',
tiles: [ tiles: ['https://tiles.mapillary.com/maps/vtp/mly1_computed_public/2/{z}/{x}/{y}?access_token=MLY|4381405525255083|3204871ec181638c3c31320490f03011'],
'https://tiles.mapillary.com/maps/vtp/mly1_computed_public/2/{z}/{x}/{y}?access_token=MLY|4381405525255083|3204871ec181638c3c31320490f03011',
],
minzoom: 6, minzoom: 6,
maxzoom: 14, maxzoom: 14,
}; };
const mapillarySequenceLayer: LayerSpecification = { const mapillarySequenceLayer = {
id: 'mapillary-sequence', id: 'mapillary-sequence',
type: 'line', type: 'line',
source: 'mapillary', source: 'mapillary',
@@ -29,7 +26,7 @@ const mapillarySequenceLayer: LayerSpecification = {
}, },
}; };
const mapillaryImageLayer: LayerSpecification = { const mapillaryImageLayer = {
id: 'mapillary-image', id: 'mapillary-image',
type: 'circle', type: 'circle',
source: 'mapillary', source: 'mapillary',
@@ -43,56 +40,35 @@ const mapillaryImageLayer: LayerSpecification = {
export class MapillaryLayer { export class MapillaryLayer {
map: mapboxgl.Map; map: mapboxgl.Map;
marker: mapboxgl.Marker; popup: mapboxgl.Popup;
viewer: Viewer; viewer: Viewer;
active = false;
popupOpen: Writable<boolean>;
addBinded = this.add.bind(this); addBinded = this.add.bind(this);
onMouseEnterBinded = this.onMouseEnter.bind(this); onMouseEnterBinded = this.onMouseEnter.bind(this);
onMouseLeaveBinded = this.onMouseLeave.bind(this); onMouseLeaveBinded = this.onMouseLeave.bind(this);
constructor(map: mapboxgl.Map, container: HTMLElement, popupOpen: Writable<boolean>) { constructor(map: mapboxgl.Map, container: HTMLElement) {
this.map = map; this.map = map;
this.viewer = new Viewer({ this.viewer = new Viewer({
accessToken: 'MLY|4381405525255083|3204871ec181638c3c31320490f03011', accessToken: 'MLY|4381405525255083|3204871ec181638c3c31320490f03011',
container, container,
}); });
container.classList.remove('hidden');
const element = document.createElement('div'); this.popup = new mapboxgl.Popup({
element.className = 'mapboxgl-user-location mapboxgl-user-location-show-heading'; closeButton: false,
const dot = document.createElement('div'); maxWidth: container.style.width,
dot.className = 'mapboxgl-user-location-dot'; }).setDOMContent(container);
const heading = document.createElement('div');
heading.className = 'mapboxgl-user-location-heading';
element.appendChild(dot);
element.appendChild(heading);
this.marker = new mapboxgl.Marker({
rotationAlignment: 'map',
element,
});
this.viewer.on('position', async () => { this.viewer.on('position', async () => {
if (this.active) { if (this.popup.isOpen()) {
popupOpen.set(true);
let latLng = await this.viewer.getPosition(); let latLng = await this.viewer.getPosition();
this.marker.setLngLat(latLng).addTo(this.map); this.popup.setLngLat(latLng);
if (!this.map.getBounds()?.contains(latLng)) { if (!this.map.getBounds().contains(latLng)) {
this.map.panTo(latLng); this.map.panTo(latLng);
} }
} }
}); });
this.viewer.on('bearing', (e: ViewerBearingEvent) => {
if (this.active) {
this.marker.setRotation(e.bearing);
}
});
this.popupOpen = popupOpen;
} }
add() { add() {
@@ -125,19 +101,15 @@ export class MapillaryLayer {
this.map.removeSource('mapillary'); this.map.removeSource('mapillary');
} }
this.marker.remove(); this.popup.remove();
this.popupOpen.set(false);
} }
closePopup() { closePopup() {
this.active = false; this.popup.remove();
this.marker.remove();
this.popupOpen.set(false);
} }
onMouseEnter(e: mapboxgl.MapMouseEvent) { onMouseEnter(e: mapboxgl.MapLayerMouseEvent) {
this.active = true; this.popup.addTo(this.map).setLngLat(e.lngLat);
this.viewer.resize(); this.viewer.resize();
this.viewer.moveTo(e.features[0].properties.id); this.viewer.moveTo(e.features[0].properties.id);

View File

@@ -1,25 +1,21 @@
<script lang="ts"> <script lang="ts">
import CustomControl from '$lib/components/custom-control/CustomControl.svelte'; import CustomControl from '$lib/components/custom-control/CustomControl.svelte';
import Tooltip from '$lib/components/Tooltip.svelte';
import { Toggle } from '$lib/components/ui/toggle'; import { Toggle } from '$lib/components/ui/toggle';
import { PersonStanding, X } from 'lucide-svelte'; import { PersonStanding, X } from 'lucide-svelte';
import { MapillaryLayer } from './Mapillary'; import { MapillaryLayer } from './Mapillary';
import { GoogleRedirect } from './Google'; import { GoogleRedirect } from './Google';
import { map, streetViewEnabled } from '$lib/stores'; import { map, streetViewEnabled } from '$lib/stores';
import { settings } from '$lib/db'; import { settings } from '$lib/db';
import { _ } from 'svelte-i18n';
import { writable } from 'svelte/store';
const { streetViewSource } = settings; const { streetViewSource } = settings;
let googleRedirect: GoogleRedirect; let googleRedirect: GoogleRedirect;
let mapillaryLayer: MapillaryLayer; let mapillaryLayer: MapillaryLayer;
let mapillaryOpen = writable(false);
let container: HTMLElement; let container: HTMLElement;
$: if ($map) { $: if ($map) {
googleRedirect = new GoogleRedirect($map); googleRedirect = new GoogleRedirect($map);
mapillaryLayer = new MapillaryLayer($map, container, mapillaryOpen); mapillaryLayer = new MapillaryLayer($map, container);
} }
$: if (mapillaryLayer) { $: if (mapillaryLayer) {
@@ -42,22 +38,14 @@
</script> </script>
<CustomControl class="w-[29px] h-[29px] shrink-0"> <CustomControl class="w-[29px] h-[29px] shrink-0">
<Tooltip class="w-full h-full" side="left" label={$_('menu.toggle_street_view')}> <Toggle bind:pressed={$streetViewEnabled} class="w-full h-full rounded p-0">
<Toggle
bind:pressed={$streetViewEnabled}
class="w-full h-full rounded p-0"
aria-label={$_('menu.toggle_street_view')}
>
<PersonStanding size="22" /> <PersonStanding size="22" />
</Toggle> </Toggle>
</Tooltip>
</CustomControl> </CustomControl>
<div <div
bind:this={container} bind:this={container}
class="{$mapillaryOpen class="hidden relative w-[50vw] h-[40vh] rounded-md border-background border-2"
? ''
: 'hidden'} !absolute bottom-[44px] right-2.5 z-10 w-[40%] h-[40%] bg-background rounded-md overflow-hidden border-background border-2"
> >
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->

View File

@@ -9,8 +9,7 @@
Ungroup, Ungroup,
MapPin, MapPin,
Filter, Filter,
Scissors, Scissors
MountainSnow,
} from 'lucide-svelte'; } from 'lucide-svelte';
import { _ } from 'svelte-i18n'; import { _ } from 'svelte-i18n';
@@ -22,32 +21,37 @@
class="h-fit flex flex-col p-1 gap-1.5 bg-background rounded-r-md pointer-events-auto shadow-md {$$props.class ?? class="h-fit flex flex-col p-1 gap-1.5 bg-background rounded-r-md pointer-events-auto shadow-md {$$props.class ??
''}" ''}"
> >
<ToolbarItem tool={Tool.ROUTING} label={$_('toolbar.routing.tooltip')}> <ToolbarItem tool={Tool.ROUTING}>
<Pencil slot="icon" size="18" /> <Pencil slot="icon" size="18" class="h-" />
<span slot="tooltip">{$_('toolbar.routing.tooltip')}</span>
</ToolbarItem> </ToolbarItem>
<ToolbarItem tool={Tool.WAYPOINT} label={$_('toolbar.waypoint.tooltip')}> <ToolbarItem tool={Tool.WAYPOINT}>
<MapPin slot="icon" size="18" /> <MapPin slot="icon" size="18" />
<span slot="tooltip">{$_('toolbar.waypoint.tooltip')}</span>
</ToolbarItem> </ToolbarItem>
<ToolbarItem tool={Tool.SCISSORS} label={$_('toolbar.scissors.tooltip')}> <ToolbarItem tool={Tool.SCISSORS}>
<Scissors slot="icon" size="18" /> <Scissors slot="icon" size="18" />
<span slot="tooltip">{$_('toolbar.scissors.tooltip')}</span>
</ToolbarItem> </ToolbarItem>
<ToolbarItem tool={Tool.TIME} label={$_('toolbar.time.tooltip')}> <ToolbarItem tool={Tool.TIME}>
<CalendarClock slot="icon" size="18" /> <CalendarClock slot="icon" size="18" />
<span slot="tooltip">{$_('toolbar.time.tooltip')}</span>
</ToolbarItem> </ToolbarItem>
<ToolbarItem tool={Tool.MERGE} label={$_('toolbar.merge.tooltip')}> <ToolbarItem tool={Tool.MERGE}>
<Group slot="icon" size="18" /> <Group slot="icon" size="18" />
<span slot="tooltip">{$_('toolbar.merge.tooltip')}</span>
</ToolbarItem> </ToolbarItem>
<ToolbarItem tool={Tool.EXTRACT} label={$_('toolbar.extract.tooltip')}> <ToolbarItem tool={Tool.EXTRACT}>
<Ungroup slot="icon" size="18" /> <Ungroup slot="icon" size="18" />
<span slot="tooltip">{$_('toolbar.extract.tooltip')}</span>
</ToolbarItem> </ToolbarItem>
<ToolbarItem tool={Tool.ELEVATION} label={$_('toolbar.elevation.button')}> <ToolbarItem tool={Tool.REDUCE}>
<MountainSnow slot="icon" size="18" />
</ToolbarItem>
<ToolbarItem tool={Tool.REDUCE} label={$_('toolbar.reduce.tooltip')}>
<Filter slot="icon" size="18" /> <Filter slot="icon" size="18" />
<span slot="tooltip">{$_('toolbar.reduce.tooltip')}</span>
</ToolbarItem> </ToolbarItem>
<ToolbarItem tool={Tool.CLEAN} label={$_('toolbar.clean.tooltip')}> <ToolbarItem tool={Tool.CLEAN}>
<SquareDashedMousePointer slot="icon" size="18" /> <SquareDashedMousePointer slot="icon" size="18" />
<span slot="tooltip">{$_('toolbar.clean.tooltip')}</span>
</ToolbarItem> </ToolbarItem>
</div> </div>
<ToolbarItemMenu class={$$props.class ?? ''} /> <ToolbarItemMenu class={$$props.class ?? ''} />

View File

@@ -4,7 +4,6 @@
import { currentTool, type Tool } from '$lib/stores'; import { currentTool, type Tool } from '$lib/stores';
export let tool: Tool; export let tool: Tool;
export let label: string;
function toggleTool() { function toggleTool() {
currentTool.update((current) => (current === tool ? null : tool)); currentTool.update((current) => (current === tool ? null : tool));
@@ -18,12 +17,11 @@
variant="ghost" variant="ghost"
class="h-[26px] px-1 py-1.5 {$currentTool === tool ? 'bg-accent' : ''}" class="h-[26px] px-1 py-1.5 {$currentTool === tool ? 'bg-accent' : ''}"
on:click={toggleTool} on:click={toggleTool}
aria-label={label}
> >
<slot name="icon" /> <slot name="icon" />
</Button> </Button>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content side="right"> <Tooltip.Content side="right">
<span>{label}</span> <slot name="tooltip" />
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>

View File

@@ -9,7 +9,6 @@
import Time from '$lib/components/toolbar/tools/Time.svelte'; import Time from '$lib/components/toolbar/tools/Time.svelte';
import Merge from '$lib/components/toolbar/tools/Merge.svelte'; import Merge from '$lib/components/toolbar/tools/Merge.svelte';
import Extract from '$lib/components/toolbar/tools/Extract.svelte'; import Extract from '$lib/components/toolbar/tools/Extract.svelte';
import Elevation from '$lib/components/toolbar/tools/Elevation.svelte';
import Clean from '$lib/components/toolbar/tools/Clean.svelte'; import Clean from '$lib/components/toolbar/tools/Clean.svelte';
import Reduce from '$lib/components/toolbar/tools/Reduce.svelte'; import Reduce from '$lib/components/toolbar/tools/Reduce.svelte';
import RoutingControlPopup from '$lib/components/toolbar/tools/routing/RoutingControlPopup.svelte'; import RoutingControlPopup from '$lib/components/toolbar/tools/routing/RoutingControlPopup.svelte';
@@ -24,7 +23,7 @@
onMount(() => { onMount(() => {
popup = new mapboxgl.Popup({ popup = new mapboxgl.Popup({
closeButton: false, closeButton: false,
maxWidth: undefined, maxWidth: undefined
}); });
popup.setDOMContent(popupElement); popup.setDOMContent(popupElement);
popupElement.classList.remove('hidden'); popupElement.classList.remove('hidden');
@@ -49,8 +48,6 @@
<Time /> <Time />
{:else if $currentTool === Tool.MERGE} {:else if $currentTool === Tool.MERGE}
<Merge /> <Merge />
{:else if $currentTool === Tool.ELEVATION}
<Elevation />
{:else if $currentTool === Tool.EXTRACT} {:else if $currentTool === Tool.EXTRACT}
<Extract /> <Extract />
{:else if $currentTool === Tool.CLEAN} {:else if $currentTool === Tool.CLEAN}

View File

@@ -1,7 +1,7 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
enum CleanType { enum CleanType {
INSIDE = 'inside', INSIDE = 'inside',
OUTSIDE = 'outside', OUTSIDE = 'outside'
} }
</script> </script>
@@ -11,9 +11,9 @@
import * as RadioGroup from '$lib/components/ui/radio-group'; import * as RadioGroup from '$lib/components/ui/radio-group';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import Help from '$lib/components/Help.svelte'; import Help from '$lib/components/Help.svelte';
import { _, locale } from 'svelte-i18n'; import { _ } from 'svelte-i18n';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { getURLForLanguage, resetCursor, setCrosshairCursor } from '$lib/utils'; import { resetCursor, setCrosshairCursor } from '$lib/utils';
import { Trash2 } from 'lucide-svelte'; import { Trash2 } from 'lucide-svelte';
import { map } from '$lib/stores'; import { map } from '$lib/stores';
import { selection } from '$lib/components/file-list/Selection'; import { selection } from '$lib/components/file-list/Selection';
@@ -41,10 +41,10 @@
[rectangleCoordinates[1].lng, rectangleCoordinates[0].lat], [rectangleCoordinates[1].lng, rectangleCoordinates[0].lat],
[rectangleCoordinates[1].lng, rectangleCoordinates[1].lat], [rectangleCoordinates[1].lng, rectangleCoordinates[1].lat],
[rectangleCoordinates[0].lng, rectangleCoordinates[1].lat], [rectangleCoordinates[0].lng, rectangleCoordinates[1].lat],
[rectangleCoordinates[0].lng, rectangleCoordinates[0].lat], [rectangleCoordinates[0].lng, rectangleCoordinates[0].lat]
], ]
], ]
}, }
}; };
let source = $map.getSource('rectangle'); let source = $map.getSource('rectangle');
if (source) { if (source) {
@@ -52,7 +52,7 @@
} else { } else {
$map.addSource('rectangle', { $map.addSource('rectangle', {
type: 'geojson', type: 'geojson',
data: data, data: data
}); });
} }
if (!$map.getLayer('rectangle')) { if (!$map.getLayer('rectangle')) {
@@ -62,8 +62,8 @@
source: 'rectangle', source: 'rectangle',
paint: { paint: {
'fill-color': 'SteelBlue', 'fill-color': 'SteelBlue',
'fill-opacity': 0.5, 'fill-opacity': 0.5
}, }
}); });
} }
} }
@@ -161,12 +161,12 @@
[ [
{ {
lat: Math.min(rectangleCoordinates[0].lat, rectangleCoordinates[1].lat), lat: Math.min(rectangleCoordinates[0].lat, rectangleCoordinates[1].lat),
lon: Math.min(rectangleCoordinates[0].lng, rectangleCoordinates[1].lng), lon: Math.min(rectangleCoordinates[0].lng, rectangleCoordinates[1].lng)
}, },
{ {
lat: Math.max(rectangleCoordinates[0].lat, rectangleCoordinates[1].lat), lat: Math.max(rectangleCoordinates[0].lat, rectangleCoordinates[1].lat),
lon: Math.max(rectangleCoordinates[0].lng, rectangleCoordinates[1].lng), lon: Math.max(rectangleCoordinates[0].lng, rectangleCoordinates[1].lng)
}, }
], ],
cleanType === CleanType.INSIDE, cleanType === CleanType.INSIDE,
deleteTrackpoints, deleteTrackpoints,
@@ -178,7 +178,7 @@
<Trash2 size="16" class="mr-1" /> <Trash2 size="16" class="mr-1" />
{$_('toolbar.clean.button')} {$_('toolbar.clean.button')}
</Button> </Button>
<Help link={getURLForLanguage($locale, '/help/toolbar/clean')}> <Help link="./help/toolbar/clean">
{#if validSelection} {#if validSelection}
{$_('toolbar.clean.help')} {$_('toolbar.clean.help')}
{:else} {:else}

View File

@@ -1,35 +0,0 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { selection } from '$lib/components/file-list/Selection';
import Help from '$lib/components/Help.svelte';
import { MountainSnow } from 'lucide-svelte';
import { dbUtils } from '$lib/db';
import { map } from '$lib/stores';
import { _, locale } from 'svelte-i18n';
import { getURLForLanguage } from '$lib/utils';
$: validSelection = $selection.size > 0;
</script>
<div class="flex flex-col gap-3 w-full max-w-80 {$$props.class ?? ''}">
<Button
variant="outline"
class="whitespace-normal h-fit"
disabled={!validSelection}
on:click={async () => {
if ($map) {
dbUtils.addElevationToSelection($map);
}
}}
>
<MountainSnow size="16" class="mr-1 shrink-0" />
{$_('toolbar.elevation.button')}
</Button>
<Help link={getURLForLanguage($locale, '/help/toolbar/elevation')}>
{#if validSelection}
{$_('toolbar.elevation.help')}
{:else}
{$_('toolbar.elevation.help_no_selection')}
{/if}
</Help>
</div>

View File

@@ -7,12 +7,11 @@
ListTrackItem, ListTrackItem,
ListTrackSegmentItem, ListTrackSegmentItem,
ListWaypointItem, ListWaypointItem,
ListWaypointsItem, ListWaypointsItem
} from '$lib/components/file-list/FileList'; } from '$lib/components/file-list/FileList';
import Help from '$lib/components/Help.svelte'; import Help from '$lib/components/Help.svelte';
import { dbUtils, getFile } from '$lib/db'; import { dbUtils, getFile } from '$lib/db';
import { _, locale } from 'svelte-i18n'; import { _ } from 'svelte-i18n';
import { getURLForLanguage } from '$lib/utils';
$: validSelection = $: validSelection =
$selection.size > 0 && $selection.size > 0 &&
@@ -43,7 +42,7 @@
<Ungroup size="16" class="mr-1" /> <Ungroup size="16" class="mr-1" />
{$_('toolbar.extract.button')} {$_('toolbar.extract.button')}
</Button> </Button>
<Help link={getURLForLanguage($locale, '/help/toolbar/extract')}> <Help link="./help/toolbar/extract">
{#if validSelection} {#if validSelection}
{$_('toolbar.extract.help')} {$_('toolbar.extract.help')}
{:else} {:else}

View File

@@ -1,7 +1,7 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
enum MergeType { enum MergeType {
TRACES = 'traces', TRACES = 'traces',
CONTENTS = 'contents', CONTENTS = 'contents'
} }
</script> </script>
@@ -11,18 +11,13 @@
import { selection } from '$lib/components/file-list/Selection'; import { selection } from '$lib/components/file-list/Selection';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Label } from '$lib/components/ui/label/index.js'; import { Label } from '$lib/components/ui/label/index.js';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as RadioGroup from '$lib/components/ui/radio-group'; import * as RadioGroup from '$lib/components/ui/radio-group';
import { _, locale } from 'svelte-i18n'; import { _ } from 'svelte-i18n';
import { dbUtils, getFile } from '$lib/db'; import { dbUtils, getFile } from '$lib/db';
import { Group } from 'lucide-svelte'; import { Group } from 'lucide-svelte';
import { getURLForLanguage } from '$lib/utils';
import Shortcut from '$lib/components/Shortcut.svelte';
import { gpxStatistics } from '$lib/stores';
let canMergeTraces = false; let canMergeTraces = false;
let canMergeContents = false; let canMergeContents = false;
let removeGaps = false;
$: if ($selection.size > 1) { $: if ($selection.size > 1) {
canMergeTraces = true; canMergeTraces = true;
@@ -59,59 +54,35 @@
<div class="flex flex-col gap-3 w-full max-w-80 {$$props.class ?? ''}"> <div class="flex flex-col gap-3 w-full max-w-80 {$$props.class ?? ''}">
<RadioGroup.Root bind:value={mergeType}> <RadioGroup.Root bind:value={mergeType}>
<Label class="flex flex-row items-center gap-1.5 leading-5"> <Label class="flex flex-row items-center gap-2 leading-5">
<RadioGroup.Item value={MergeType.TRACES} /> <RadioGroup.Item value={MergeType.TRACES} />
{$_('toolbar.merge.merge_traces')} {$_('toolbar.merge.merge_traces')}
</Label> </Label>
<Label class="flex flex-row items-center gap-1.5 leading-5"> <Label class="flex flex-row items-center gap-2 leading-5">
<RadioGroup.Item value={MergeType.CONTENTS} /> <RadioGroup.Item value={MergeType.CONTENTS} />
{$_('toolbar.merge.merge_contents')} {$_('toolbar.merge.merge_contents')}
</Label> </Label>
</RadioGroup.Root> </RadioGroup.Root>
{#if mergeType === MergeType.TRACES && $gpxStatistics.global.time.total > 0}
<div class="flex flex-row items-center gap-1.5">
<Checkbox id="remove-gaps" bind:checked={removeGaps} />
<Label for="remove-gaps">{$_('toolbar.merge.remove_gaps')}</Label>
</div>
{/if}
<Button <Button
variant="outline" variant="outline"
class="whitespace-normal h-fit"
disabled={(mergeType === MergeType.TRACES && !canMergeTraces) || disabled={(mergeType === MergeType.TRACES && !canMergeTraces) ||
(mergeType === MergeType.CONTENTS && !canMergeContents)} (mergeType === MergeType.CONTENTS && !canMergeContents)}
on:click={() => { on:click={() => {
dbUtils.mergeSelection( dbUtils.mergeSelection(mergeType === MergeType.TRACES);
mergeType === MergeType.TRACES,
mergeType === MergeType.TRACES && $gpxStatistics.global.time.total > 0 && removeGaps
);
}} }}
> >
<Group size="16" class="mr-1 shrink-0" /> <Group size="16" class="mr-1" />
{$_('toolbar.merge.merge_selection')} {$_('toolbar.merge.merge_selection')}
</Button> </Button>
<Help link={getURLForLanguage($locale, '/help/toolbar/merge')}> <Help link="./help/toolbar/merge">
{#if mergeType === MergeType.TRACES && canMergeTraces} {#if mergeType === MergeType.TRACES && canMergeTraces}
{$_('toolbar.merge.help_merge_traces')} {$_('toolbar.merge.help_merge_traces')}
{:else if mergeType === MergeType.TRACES && !canMergeTraces} {:else if mergeType === MergeType.TRACES && !canMergeTraces}
{$_('toolbar.merge.help_cannot_merge_traces')} {$_('toolbar.merge.help_cannot_merge_traces')}
{$_('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[0]}
<Shortcut
ctrl={true}
click={true}
class="inline-flex text-muted-foreground text-xs border rounded p-0.5 gap-0"
/>
{$_('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[1]}
{:else if mergeType === MergeType.CONTENTS && canMergeContents} {:else if mergeType === MergeType.CONTENTS && canMergeContents}
{$_('toolbar.merge.help_merge_contents')} {$_('toolbar.merge.help_merge_contents')}
{:else if mergeType === MergeType.CONTENTS && !canMergeContents} {:else if mergeType === MergeType.CONTENTS && !canMergeContents}
{$_('toolbar.merge.help_cannot_merge_contents')} {$_('toolbar.merge.help_cannot_merge_contents')}
{$_('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[0]}
<Shortcut
ctrl={true}
click={true}
class="inline-flex text-muted-foreground text-xs border rounded p-0.5 gap-0"
/>
{$_('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[1]}
{/if} {/if}
</Help> </Help>
</div> </div>

Some files were not shown because too many files have changed in this diff Show More