Compare commits
125 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe8896e870 | |||
| c99517572e | |||
| 0b7f30a7c4 | |||
| fcfaf043c4 | |||
| 0393a330a6 | |||
| 2fdb58bc7d | |||
| c758bda1a9 | |||
| 88e301e2a2 | |||
| 161c664e08 | |||
| a0408ec798 | |||
| 480d5586b9 | |||
| 7b49018593 | |||
| 4b447a9b9d | |||
| 443feb2bfa | |||
| 8710101a78 | |||
| 2c20148e64 | |||
| ffc0e84788 | |||
| 2d2004e447 | |||
| 4a7cfc113d | |||
| f03ad5a0e9 | |||
| 7f5b83f9d8 | |||
| 5a8f93e225 | |||
| 668b0f6b23 | |||
| fc1eb5a408 | |||
| 561c7e22b3 | |||
| ce067d4ff0 | |||
| 7361b54255 | |||
| 9e2334ca83 | |||
| 08d1f1d5e4 | |||
| f3bf4f0096 | |||
| b4094d0a5b | |||
| 36122b4ac5 | |||
| dd9aba3adb | |||
| bd40fbae74 | |||
| 690cbc49cc | |||
| 36b16ddeef | |||
| 16b8988fa7 | |||
| 40f97b7c35 | |||
| 54b3113480 | |||
| 7e9140492a | |||
| 79c0aed54f | |||
| cb5a74de00 | |||
| 31f25f346a | |||
| b3a11125a5 | |||
| 71cdc03da5 | |||
| 694e73a677 | |||
| 5aaacccef9 | |||
| f2bf043900 | |||
| ba251fe407 | |||
| 421aa9dc69 | |||
| b4a7f1353b | |||
| 30de3c6db5 | |||
| 8aafa26238 | |||
| 5768391305 | |||
| 1d204bacf2 | |||
| 4d1d5d48c0 | |||
| 5a6321535e | |||
| e077de9f48 | |||
| 7586f03998 | |||
| af8c22dcda | |||
| 3dce5dc617 | |||
| 84b90e1026 | |||
| d507586eed | |||
| 57afaedf83 | |||
| 48063b9066 | |||
| 452d356599 | |||
| 25eda8041e | |||
| ae4d5356eb | |||
| 3343bb906e | |||
| 7b17900160 | |||
| d5f1fe1c7b | |||
| 553f73f992 | |||
| c8cedf2e2c | |||
| a751817847 | |||
| d1ef12db8d | |||
| 43d73edf29 | |||
| 5a0b8c376c | |||
| 9743fd460e | |||
| f70f92a176 | |||
| 1a4175446c | |||
| ed6dfab4c1 | |||
| 6a6e1105c0 | |||
| 1677fe254b | |||
| 02efe708c2 | |||
| 7dc834f506 | |||
| 57c4958ff2 | |||
| 03e59a8cce | |||
| f3d18f09a0 | |||
| c1dbd984e6 | |||
| e4f227221d | |||
| 34139974aa | |||
| 408b2422e6 | |||
| b59cb9e200 | |||
| bd5cb65d0f | |||
| 4cfe487af0 | |||
| 4da2e39e32 | |||
| 5ff11a32c9 | |||
| 01a7ec916e | |||
| dd94a7d613 | |||
| 089b88c62d | |||
| c9ca75e2e8 | |||
| 091f6a3ed0 | |||
| d6c9fb1025 | |||
| 88abd72a41 | |||
| 1137e851ce | |||
| b8c1500aad | |||
| bfd0d90abc | |||
| dba01e1826 | |||
| 2189c76edd | |||
| 6f8c9d66db | |||
| 9408ce10c7 | |||
| 9895c3c304 | |||
| 0ab3b77db8 | |||
| d13e7e7a0a | |||
| 9c6e03f4a8 | |||
| 2a4dfe010e | |||
| f42a916c25 | |||
| 772b810fa8 | |||
| 4d4d10d5c2 | |||
| 0e4c7dbe64 | |||
| a01ca79a82 | |||
| c91baf7c83 | |||
| 5062de8ddf | |||
| 9ca46b9d35 | |||
| 7c2e24bbc4 |
@@ -1 +1 @@
|
|||||||
ko_fi: gpxstudio
|
open_collective: gpxstudio
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
[**gpx.studio**](https://gpx.studio) is an online tool for creating and editing GPX files.
|
[**gpx.studio**](https://gpx.studio) is an online tool for creating and editing GPX files.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
This repository contains the source code of the website.
|
This repository contains the source code of the website.
|
||||||
|
|
||||||
@@ -69,8 +69,8 @@ This project has been made possible thanks to the following open source projects
|
|||||||
- [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) — fast GPX file parsing
|
- [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) — fast GPX file parsing
|
||||||
- [SortableJS](https://github.com/SortableJS/Sortable) — creating a sortable file tree
|
- [SortableJS](https://github.com/SortableJS/Sortable) — creating a sortable file tree
|
||||||
- Mapping:
|
- Mapping:
|
||||||
- [MapLibre GL JS](https://github.com/maplibre/maplibre-gl-js) — beautiful and fast interactive maps
|
- [MapLibre GL JS](https://github.com/maplibre/maplibre-gl-js) — beautiful and fast interactive map rendering
|
||||||
- [brouter](https://github.com/abrensch/brouter) — routing engine
|
- [GraphHopper](https://github.com/graphhopper/graphhopper) — routing engine
|
||||||
- [OpenStreetMap](https://www.openstreetmap.org) — map data used by most of the map layers, and by the routing engine
|
- [OpenStreetMap](https://www.openstreetmap.org) — map data used by most of the map layers, and by the routing engine
|
||||||
- Search:
|
- Search:
|
||||||
- [DocSearch](https://github.com/algolia/docsearch) — search engine for the documentation
|
- [DocSearch](https://github.com/algolia/docsearch) — search engine for the documentation
|
||||||
|
|||||||
@@ -1398,10 +1398,7 @@ export class TrackPoint {
|
|||||||
: undefined;
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
setExtensions(extensions: Record<string, string>) {
|
setExtension(key: string, value: string) {
|
||||||
if (Object.keys(extensions).length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!this.extensions) {
|
if (!this.extensions) {
|
||||||
this.extensions = {};
|
this.extensions = {};
|
||||||
}
|
}
|
||||||
@@ -1411,8 +1408,12 @@ export class TrackPoint {
|
|||||||
if (!this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions']) {
|
if (!this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions']) {
|
||||||
this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions'] = {};
|
this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions'] = {};
|
||||||
}
|
}
|
||||||
Object.entries(extensions).forEach(([key, value]) => {
|
|
||||||
this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions'][key] = value;
|
this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions'][key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
setExtensions(extensions: Record<string, string>) {
|
||||||
|
Object.entries(extensions).forEach(([key, value]) => {
|
||||||
|
this.setExtension(key, value);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||||
"style": "default",
|
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"css": "src/app.css",
|
"css": "src/app.css",
|
||||||
"baseColor": "slate"
|
"baseColor": "neutral"
|
||||||
},
|
},
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "$lib/components",
|
"components": "$lib/components",
|
||||||
@@ -13,5 +12,9 @@
|
|||||||
"lib": "$lib"
|
"lib": "$lib"
|
||||||
},
|
},
|
||||||
"typescript": true,
|
"typescript": true,
|
||||||
"registry": "https://shadcn-svelte.com/registry"
|
"registry": "https://shadcn-svelte.com/registry",
|
||||||
|
"style": "nova",
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"menuColor": "default",
|
||||||
|
"menuAccent": "subtle"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,9 @@
|
|||||||
"format": "prettier --write . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore"
|
"format": "prettier --write . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lucide/svelte": "^0.544.0",
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
|
"@internationalized/date": "^3.12.0",
|
||||||
|
"@lucide/svelte": "^1.7.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.8",
|
"@sveltejs/adapter-static": "^3.0.8",
|
||||||
"@sveltejs/enhanced-img": "^0.6.0",
|
"@sveltejs/enhanced-img": "^0.6.0",
|
||||||
"@sveltejs/kit": "^2.21.2",
|
"@sveltejs/kit": "^2.21.2",
|
||||||
@@ -30,7 +32,8 @@
|
|||||||
"@types/sortablejs": "^1.15.8",
|
"@types/sortablejs": "^1.15.8",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.33.1",
|
"@typescript-eslint/eslint-plugin": "^8.33.1",
|
||||||
"@typescript-eslint/parser": "^8.33.1",
|
"@typescript-eslint/parser": "^8.33.1",
|
||||||
"bits-ui": "^2.14.4",
|
"bits-ui": "^2.17.2",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"eslint": "^9.28.0",
|
"eslint": "^9.28.0",
|
||||||
"eslint-config-prettier": "^10.1.5",
|
"eslint-config-prettier": "^10.1.5",
|
||||||
"eslint-plugin-svelte": "^3.9.1",
|
"eslint-plugin-svelte": "^3.9.1",
|
||||||
@@ -43,39 +46,37 @@
|
|||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"prettier-plugin-svelte": "^3.4.0",
|
"prettier-plugin-svelte": "^3.4.0",
|
||||||
|
"shadcn-svelte": "^1.2.7",
|
||||||
"svelte": "^5.33.18",
|
"svelte": "^5.33.18",
|
||||||
"svelte-check": "^4.0.0",
|
"svelte-check": "^4.0.0",
|
||||||
"svelte-dnd-action": "^0.9.65",
|
"svelte-dnd-action": "^0.9.65",
|
||||||
"svelte-sonner": "^1.0.5",
|
"svelte-sonner": "^1.1.0",
|
||||||
"tailwind-variants": "^3.1.1",
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tailwind-variants": "^3.2.2",
|
||||||
"tailwindcss": "^4.1.8",
|
"tailwindcss": "^4.1.8",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
"tsx": "^4.19.1",
|
"tsx": "^4.19.1",
|
||||||
"tw-animate-css": "^1.3.4",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"vaul-svelte": "^1.0.0-next.7",
|
"vaul-svelte": "^1.0.0-next.7",
|
||||||
"vite": "^6.3.5",
|
"vite": "^6.3.5"
|
||||||
"vite-plugin-node-polyfills": "^0.23.0"
|
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@docsearch/js": "^3.9.0",
|
"@docsearch/js": "^3.9.0",
|
||||||
"@internationalized/date": "^3.8.2",
|
|
||||||
"@mapbox/sphericalmercator": "^2.0.1",
|
"@mapbox/sphericalmercator": "^2.0.1",
|
||||||
"@mapbox/tilebelt": "^2.0.2",
|
"@mapbox/tilebelt": "^2.0.2",
|
||||||
"@maplibre/maplibre-gl-geocoder": "^1.9.4",
|
"@maplibre/maplibre-gl-geocoder": "^1.9.4",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"chartjs-plugin-zoom": "^2.2.0",
|
"chartjs-plugin-zoom": "^2.2.0",
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"dexie": "^4.0.11",
|
"dexie": "^4.0.11",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"gpx": "file:../gpx",
|
"gpx": "file:../gpx",
|
||||||
"immer": "^10.1.1",
|
"immer": "^10.1.1",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"mapillary-js": "^4.1.2",
|
"mapillary-js": "^4.1.2",
|
||||||
"maplibre-gl": "^5.16.0",
|
"maplibre-gl": "^5.21.1",
|
||||||
"sanitize-html": "^2.17.0",
|
"sanitize-html": "^2.17.0",
|
||||||
"sortablejs": "^1.15.6",
|
"sortablejs": "^1.15.6"
|
||||||
"tailwind-merge": "^3.3.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,76 +1,93 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
@import 'tw-animate-css';
|
@import 'tw-animate-css';
|
||||||
|
@import "shadcn-svelte/tailwind.css";
|
||||||
|
@import "@fontsource-variable/inter";
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: hsl(0 0% 100%) /* <- Wrap in HSL */;
|
--background: oklch(1 0 0);
|
||||||
--foreground: hsl(240 10% 3.9%);
|
--foreground: oklch(0.145 0 0);
|
||||||
--muted: hsl(240 4.8% 95.9%);
|
--muted: oklch(0.97 0 0);
|
||||||
--muted-foreground: hsl(240 3.8% 46.1%);
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
--popover: hsl(0 0% 100%);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: hsl(240 10% 3.9%);
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
--card: hsl(0 0% 100%);
|
--card: oklch(1 0 0);
|
||||||
--card-foreground: hsl(240 10% 3.9%);
|
--card-foreground: oklch(0.145 0 0);
|
||||||
--border: hsl(240 5.9% 90%);
|
--border: oklch(0.922 0 0);
|
||||||
--input: hsl(240 5.9% 90%);
|
--input: oklch(0.922 0 0);
|
||||||
--primary: hsl(240 5.9% 10%);
|
--primary: oklch(0.205 0 0);
|
||||||
--primary-foreground: hsl(0 0% 98%);
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
--secondary: hsl(240 4.8% 95.9%);
|
--secondary: oklch(0.97 0 0);
|
||||||
--secondary-foreground: hsl(240 5.9% 10%);
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
--accent: hsl(240 4.8% 95.9%);
|
--accent: oklch(0.97 0 0);
|
||||||
--accent-foreground: hsl(240 5.9% 10%);
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
--destructive: hsl(0 72.2% 50.6%);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--destructive-foreground: hsl(0 0% 98%);
|
--destructive-foreground: hsl(0 0% 98%);
|
||||||
--ring: hsl(240 10% 3.9%);
|
--ring: oklch(0.708 0 0);
|
||||||
--sidebar: hsl(0 0% 98%);
|
--sidebar: oklch(0.985 0 0);
|
||||||
--sidebar-foreground: hsl(240 5.3% 26.1%);
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
--sidebar-primary: hsl(240 5.9% 10%);
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
--sidebar-primary-foreground: hsl(0 0% 98%);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: hsl(240 4.8% 95.9%);
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
--sidebar-accent-foreground: hsl(240 5.9% 10%);
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
--sidebar-border: hsl(220 13% 91%);
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
|
||||||
--support: rgb(220 15 130);
|
--support: rgb(220 15 130);
|
||||||
--link: rgb(0 110 180);
|
--link: rgb(0 110 180);
|
||||||
--selection: hsl(240 4.8% 93%);
|
--selection: hsl(240 4.8% 93%);
|
||||||
|
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
|
|
||||||
|
--chart-1: oklch(0.87 0 0);
|
||||||
|
|
||||||
|
--chart-2: oklch(0.556 0 0);
|
||||||
|
|
||||||
|
--chart-3: oklch(0.439 0 0);
|
||||||
|
|
||||||
|
--chart-4: oklch(0.371 0 0);
|
||||||
|
|
||||||
|
--chart-5: oklch(0.269 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: hsl(240 10% 3.9%);
|
--background: oklch(0.145 0 0);
|
||||||
--foreground: hsl(0 0% 98%);
|
--foreground: oklch(0.985 0 0);
|
||||||
--muted: hsl(240 3.7% 15.9%);
|
--muted: oklch(0.269 0 0);
|
||||||
--muted-foreground: hsl(240 5% 64.9%);
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
--popover: hsl(240 10% 3.9%);
|
--popover: oklch(0.205 0 0);
|
||||||
--popover-foreground: hsl(0 0% 98%);
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
--card: hsl(240 10% 3.9%);
|
--card: oklch(0.205 0 0);
|
||||||
--card-foreground: hsl(0 0% 98%);
|
--card-foreground: oklch(0.985 0 0);
|
||||||
--border: hsl(240 3.7% 15.9%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: hsl(240 3.7% 15.9%);
|
--input: oklch(1 0 0 / 15%);
|
||||||
--primary: hsl(0 0% 98%);
|
--primary: oklch(0.922 0 0);
|
||||||
--primary-foreground: hsl(240 5.9% 10%);
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
--secondary: hsl(240 3.7% 15.9%);
|
--secondary: oklch(0.269 0 0);
|
||||||
--secondary-foreground: hsl(0 0% 98%);
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
--accent: hsl(240 3.7% 15.9%);
|
--accent: oklch(0.269 0 0);
|
||||||
--accent-foreground: hsl(0 0% 98%);
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
--destructive: hsl(0 62.8% 30.6%);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--destructive-foreground: hsl(0 0% 98%);
|
--destructive-foreground: hsl(0 0% 98%);
|
||||||
--ring: hsl(240 4.9% 83.9%);
|
--ring: oklch(0.556 0 0);
|
||||||
--sidebar: hsl(240 5.9% 10%);
|
--sidebar: oklch(0.205 0 0);
|
||||||
--sidebar-foreground: hsl(240 4.8% 95.9%);
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-primary: hsl(224.3 76.3% 48%);
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
--sidebar-primary-foreground: hsl(0 0% 100%);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: hsl(240 3.7% 15.9%);
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: hsl(240 3.7% 15.9%);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
|
||||||
--support: rgb(255 110 190);
|
--support: rgb(255 110 190);
|
||||||
--link: rgb(80 190 255);
|
--link: rgb(80 190 255);
|
||||||
--selection: hsl(240 3.7% 22%);
|
--selection: hsl(240 3.7% 22%);
|
||||||
|
--chart-1: oklch(0.87 0 0);
|
||||||
|
--chart-2: oklch(0.556 0 0);
|
||||||
|
--chart-3: oklch(0.439 0 0);
|
||||||
|
--chart-4: oklch(0.371 0 0);
|
||||||
|
--chart-5: oklch(0.269 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -113,14 +130,35 @@
|
|||||||
--color-link: var(--link);
|
--color-link: var(--link);
|
||||||
|
|
||||||
--breakpoint-xs: 540px;
|
--breakpoint-xs: 540px;
|
||||||
|
|
||||||
|
--font-sans: 'Inter Variable', sans-serif;
|
||||||
|
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
|
||||||
|
--radius-2xl: calc(var(--radius) * 1.8);
|
||||||
|
|
||||||
|
--radius-3xl: calc(var(--radius) * 2.2);
|
||||||
|
|
||||||
|
--radius-4xl: calc(var(--radius) * 2.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
|
html {
|
||||||
|
@apply font-sans;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 313 KiB |
|
After Width: | Height: | Size: 729 KiB |
|
After Width: | Height: | Size: 308 KiB |
|
Before Width: | Height: | Size: 768 KiB After Width: | Height: | Size: 525 KiB |
|
Before Width: | Height: | Size: 596 KiB After Width: | Height: | Size: 710 KiB |
|
Before Width: | Height: | Size: 4.3 MiB |
|
Before Width: | Height: | Size: 5.4 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 2.9 MiB |
|
Before Width: | Height: | Size: 3.6 MiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 6.9 MiB |
|
Before Width: | Height: | Size: 448 KiB |
@@ -31,9 +31,30 @@ import bikerouterGravel from './custom/bikerouter-gravel.json';
|
|||||||
export const maptilerKeyPlaceHolder = 'MAPTILER_KEY';
|
export const maptilerKeyPlaceHolder = 'MAPTILER_KEY';
|
||||||
|
|
||||||
export const basemaps: { [key: string]: string | StyleSpecification } = {
|
export const basemaps: { [key: string]: string | StyleSpecification } = {
|
||||||
maptilerTopo: `https://api.maptiler.com/maps/topo-v4/style.json?key=${maptilerKeyPlaceHolder}`,
|
topo: 'https://raw.githubusercontent.com/gpxstudio/styles/refs/heads/main/topo.json',
|
||||||
maptilerOutdoors: `https://api.maptiler.com/maps/outdoor-v4/style.json?key=${maptilerKeyPlaceHolder}`,
|
satellite: 'https://raw.githubusercontent.com/gpxstudio/styles/refs/heads/main/satellite.json',
|
||||||
maptilerSatellite: `https://api.maptiler.com/maps/hybrid-v4/style.json?key=${maptilerKeyPlaceHolder}`,
|
esriSatellite: {
|
||||||
|
version: 8,
|
||||||
|
sources: {
|
||||||
|
esriSatellite: {
|
||||||
|
type: 'raster',
|
||||||
|
tiles: [
|
||||||
|
'https://services.arcgisonline.com/arcgis/rest/services/World_Imagery/MapServer/WMTS/tile/1.0.0/World_Imagery/default/default028mm/{z}/{y}/{x}.jpg',
|
||||||
|
],
|
||||||
|
tileSize: 256,
|
||||||
|
maxzoom: 19,
|
||||||
|
attribution:
|
||||||
|
'© <a href="https://www.esri.com/" target="_blank">Esri</a>, Vantor, Earthstar Geographics, and the GIS User Community',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id: 'esriSatellite',
|
||||||
|
type: 'raster',
|
||||||
|
source: 'esriSatellite',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
openStreetMap: {
|
openStreetMap: {
|
||||||
version: 8,
|
version: 8,
|
||||||
sources: {
|
sources: {
|
||||||
@@ -776,9 +797,9 @@ export type LayerTreeType = { [key: string]: LayerTreeType | boolean };
|
|||||||
export const basemapTree: LayerTreeType = {
|
export const basemapTree: LayerTreeType = {
|
||||||
basemaps: {
|
basemaps: {
|
||||||
world: {
|
world: {
|
||||||
maptilerTopo: true,
|
topo: true,
|
||||||
maptilerOutdoors: true,
|
satellite: true,
|
||||||
maptilerSatellite: true,
|
esriSatellite: true,
|
||||||
openStreetMap: true,
|
openStreetMap: true,
|
||||||
openTopoMap: true,
|
openTopoMap: true,
|
||||||
openHikingMap: true,
|
openHikingMap: true,
|
||||||
@@ -911,7 +932,7 @@ export const overpassTree: LayerTreeType = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Default basemap used
|
// Default basemap used
|
||||||
export const defaultBasemap = 'maptilerTopo';
|
export const defaultBasemap = 'topo';
|
||||||
|
|
||||||
// Default overlays used (none)
|
// Default overlays used (none)
|
||||||
export const defaultOverlays: LayerTreeType = {
|
export const defaultOverlays: LayerTreeType = {
|
||||||
@@ -1000,9 +1021,9 @@ export const defaultOverpassQueries: LayerTreeType = {
|
|||||||
export const defaultBasemapTree: LayerTreeType = {
|
export const defaultBasemapTree: LayerTreeType = {
|
||||||
basemaps: {
|
basemaps: {
|
||||||
world: {
|
world: {
|
||||||
maptilerTopo: true,
|
topo: true,
|
||||||
maptilerOutdoors: true,
|
satellite: true,
|
||||||
maptilerSatellite: true,
|
esriSatellite: false,
|
||||||
openStreetMap: true,
|
openStreetMap: true,
|
||||||
openTopoMap: true,
|
openTopoMap: true,
|
||||||
openHikingMap: true,
|
openHikingMap: true,
|
||||||
@@ -1460,14 +1481,11 @@ export const overpassQueryData: Record<string, OverpassQueryData> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const terrainSources: { [key: string]: RasterDEMSourceSpecification } = {
|
export const terrainSources: { [key: string]: RasterDEMSourceSpecification } = {
|
||||||
'maptiler-dem': {
|
|
||||||
type: 'raster-dem',
|
|
||||||
url: `https://api.maptiler.com/tiles/terrain-rgb-v2/tiles.json?key=${maptilerKeyPlaceHolder}`,
|
|
||||||
},
|
|
||||||
mapterhorn: {
|
mapterhorn: {
|
||||||
type: 'raster-dem',
|
type: 'raster-dem',
|
||||||
url: 'https://tiles.mapterhorn.com/tilejson.json',
|
url: 'https://tiles.gpx.studio/mapterhorn.json',
|
||||||
|
encoding: 'terrarium',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const defaultTerrainSource = 'maptiler-dem';
|
export const defaultTerrainSource = 'mapterhorn';
|
||||||
|
|||||||
@@ -64,3 +64,9 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div id="docsearch" class={props.class ?? ''}></div>
|
<div id="docsearch" class={props.class ?? ''}></div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#docsearch :global(button) {
|
||||||
|
margin-left: 0px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
<Tooltip.Root>
|
<Tooltip.Root>
|
||||||
<Tooltip.Trigger>
|
<Tooltip.Trigger>
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
<Button {...props} {variant} class={className} {onclick}>
|
<Button {...props} {variant} class="bg-inherit {className}" {onclick}>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</Button>
|
</Button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import LanguageSelect from '$lib/components/LanguageSelect.svelte';
|
import LanguageSelect from '$lib/components/LanguageSelect.svelte';
|
||||||
|
import ModeSwitch from '$lib/components/ModeSwitch.svelte';
|
||||||
import Logo from '$lib/components/Logo.svelte';
|
import Logo from '$lib/components/Logo.svelte';
|
||||||
import { AtSign, BookOpenText, Heart, House, Map } from '@lucide/svelte';
|
import { AtSign, BookOpenText, Heart, House, Map } from '@lucide/svelte';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<footer class="w-full">
|
<footer class="w-full px-12 py-10 border-t flex flex-col items-center">
|
||||||
<div class="mx-6 border-t">
|
<div class="w-full max-w-5xl 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" width="153" />
|
||||||
<Button
|
<Button
|
||||||
@@ -20,7 +20,10 @@
|
|||||||
>
|
>
|
||||||
MIT © 2026 gpx.studio
|
MIT © 2026 gpx.studio
|
||||||
</Button>
|
</Button>
|
||||||
<LanguageSelect class="w-40 mt-3" />
|
<div class="mt-3 flex flex-row gap-1.5">
|
||||||
|
<LanguageSelect />
|
||||||
|
<ModeSwitch />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grow max-w-2xl flex flex-row flex-wrap justify-between gap-x-10 gap-y-6">
|
<div class="grow max-w-2xl flex flex-row flex-wrap justify-between gap-x-10 gap-y-6">
|
||||||
<div class="flex flex-col items-start gap-1">
|
<div class="flex flex-col items-start gap-1">
|
||||||
@@ -86,7 +89,7 @@
|
|||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||||
href="https://ko-fi.com/gpxstudio"
|
href="https://opencollective.com/gpxstudio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<Heart size="16" />
|
<Heart size="16" />
|
||||||
@@ -113,5 +116,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -12,16 +12,17 @@
|
|||||||
|
|
||||||
const { velocityUnits } = settings;
|
const { velocityUnits } = settings;
|
||||||
|
|
||||||
|
let panelHeight: number = $state(0);
|
||||||
|
let panelWidth: number = $state(0);
|
||||||
|
|
||||||
let {
|
let {
|
||||||
gpxStatistics,
|
gpxStatistics,
|
||||||
slicedGPXStatistics,
|
slicedGPXStatistics,
|
||||||
orientation,
|
orientation,
|
||||||
panelSize,
|
|
||||||
}: {
|
}: {
|
||||||
gpxStatistics: Readable<GPXStatisticsGroup>;
|
gpxStatistics: Readable<GPXStatisticsGroup>;
|
||||||
slicedGPXStatistics: Readable<[GPXGlobalStatistics, number, number] | undefined>;
|
slicedGPXStatistics: Readable<[GPXGlobalStatistics, number, number] | undefined>;
|
||||||
orientation: 'horizontal' | 'vertical';
|
orientation: 'horizontal' | 'vertical';
|
||||||
panelSize: number;
|
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let statistics = $derived(
|
let statistics = $derived(
|
||||||
@@ -31,13 +32,16 @@
|
|||||||
|
|
||||||
<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-40 sm:min-w-44'
|
||||||
: 'w-full'} border-none shadow-none p-0"
|
: 'w-full h-fit my-1'} ring-0 p-0 text-sm sm:text-base bg-transparent"
|
||||||
>
|
>
|
||||||
<Card.Content
|
<Card.Content class="h-full p-0">
|
||||||
class="h-full flex {orientation === 'vertical'
|
<div
|
||||||
? 'flex-col justify-center'
|
bind:clientHeight={panelHeight}
|
||||||
: 'flex-row w-full justify-between'} gap-4 p-0"
|
bind:clientWidth={panelWidth}
|
||||||
|
class="flex {orientation === 'vertical'
|
||||||
|
? 'flex-col h-full justify-center'
|
||||||
|
: 'flex-row w-full justify-evenly'} gap-4"
|
||||||
>
|
>
|
||||||
<Tooltip label={i18n._('quantities.distance')}>
|
<Tooltip label={i18n._('quantities.distance')}>
|
||||||
<span class="flex flex-row items-center">
|
<span class="flex flex-row items-center">
|
||||||
@@ -53,9 +57,8 @@
|
|||||||
<WithUnits value={statistics.elevation.loss} type="elevation" />
|
<WithUnits value={statistics.elevation.loss} type="elevation" />
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{#if panelSize > 120 || orientation === 'horizontal'}
|
{#if panelHeight > 120 || (orientation === 'horizontal' && panelWidth > 450)}
|
||||||
<Tooltip
|
<Tooltip
|
||||||
class={orientation === 'horizontal' ? 'hidden xs:block' : ''}
|
|
||||||
label="{$velocityUnits === 'speed'
|
label="{$velocityUnits === 'speed'
|
||||||
? i18n._('quantities.speed')
|
? i18n._('quantities.speed')
|
||||||
: i18n._('quantities.pace')} ({i18n._('quantities.moving')} / {i18n._(
|
: i18n._('quantities.pace')} ({i18n._('quantities.moving')} / {i18n._(
|
||||||
@@ -70,9 +73,8 @@
|
|||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{/if}
|
{/if}
|
||||||
{#if panelSize > 160 || orientation === 'horizontal'}
|
{#if panelHeight > 150 || (orientation === 'horizontal' && panelWidth > 620)}
|
||||||
<Tooltip
|
<Tooltip
|
||||||
class={orientation === 'horizontal' ? 'hidden md:block' : ''}
|
|
||||||
label="{i18n._('quantities.time')} ({i18n._('quantities.moving')} / {i18n._(
|
label="{i18n._('quantities.time')} ({i18n._('quantities.moving')} / {i18n._(
|
||||||
'quantities.total'
|
'quantities.total'
|
||||||
)})"
|
)})"
|
||||||
@@ -85,5 +87,6 @@
|
|||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|||||||
@@ -14,12 +14,12 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="text-sm bg-secondary rounded border flex flex-row items-center p-2 {className}">
|
<div class="text-[13px] bg-secondary rounded border flex flex-row items-center p-2 {className}">
|
||||||
<CircleQuestionMark size="16" class="w-4 mr-2 shrink-0 grow-0" />
|
<CircleQuestionMark size="16" class="w-4 mr-2 shrink-0 grow-0" />
|
||||||
<div>
|
<div>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
{#if link}
|
{#if link}
|
||||||
<a href={link} target="_blank" class="text-sm text-link hover:underline">
|
<a href={link} target="_blank" class="text-[13px] text-link hover:underline">
|
||||||
{i18n._('menu.more')}
|
{i18n._('menu.more')}
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -5,16 +5,10 @@
|
|||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
import { Languages } from '@lucide/svelte';
|
import { Languages } from '@lucide/svelte';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
|
||||||
let {
|
|
||||||
class: className = '',
|
|
||||||
}: {
|
|
||||||
class?: string;
|
|
||||||
} = $props();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Select.Root type="single" value={i18n.lang}>
|
<Select.Root type="single" value={i18n.lang}>
|
||||||
<Select.Trigger class="min-w-[180px] {className}" aria-label={i18n._('menu.language')}>
|
<Select.Trigger class="w-[180px] px-2" aria-label={i18n._('menu.language')}>
|
||||||
<Languages size="16" />
|
<Languages size="16" />
|
||||||
<span class="mr-auto">
|
<span class="mr-auto">
|
||||||
{languages[i18n.lang]}
|
{languages[i18n.lang]}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
...others
|
...others
|
||||||
}: {
|
}: {
|
||||||
iconOnly?: boolean;
|
iconOnly?: boolean;
|
||||||
company?: 'gpx.studio' | 'maptiler' | 'github' | 'crowdin' | 'facebook' | 'reddit';
|
company?: 'gpx.studio' | 'github' | 'crowdin' | 'facebook' | 'reddit';
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
@@ -19,12 +19,6 @@
|
|||||||
alt="Logo of gpx.studio."
|
alt="Logo of gpx.studio."
|
||||||
{...others}
|
{...others}
|
||||||
/>
|
/>
|
||||||
{:else if company === 'maptiler'}
|
|
||||||
<img
|
|
||||||
src="{base}/maptiler-logo{mode.current === 'dark' ? '-dark' : ''}.svg"
|
|
||||||
alt="Logo of Maptiler."
|
|
||||||
{...others}
|
|
||||||
/>
|
|
||||||
{:else if company === 'github'}
|
{:else if company === 'github'}
|
||||||
<svg
|
<svg
|
||||||
role="img"
|
role="img"
|
||||||
|
|||||||
@@ -43,6 +43,8 @@
|
|||||||
BookOpenText,
|
BookOpenText,
|
||||||
ChartArea,
|
ChartArea,
|
||||||
Maximize,
|
Maximize,
|
||||||
|
Maximize2,
|
||||||
|
Minimize2,
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
import { map } from '$lib/components/map/map';
|
import { map } from '$lib/components/map/map';
|
||||||
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
|
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
|
||||||
@@ -70,7 +72,7 @@
|
|||||||
import { copied, selection } from '$lib/logic/selection';
|
import { copied, selection } from '$lib/logic/selection';
|
||||||
import { allHidden } from '$lib/logic/hidden';
|
import { allHidden } from '$lib/logic/hidden';
|
||||||
import { boundsManager } from '$lib/logic/bounds';
|
import { boundsManager } from '$lib/logic/bounds';
|
||||||
import { tick } from 'svelte';
|
import { tick, onMount } from 'svelte';
|
||||||
import { allowedPastes } from '$lib/components/file-list/sortable-file-list';
|
import { allowedPastes } from '$lib/components/file-list/sortable-file-list';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -105,6 +107,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let layerSettingsOpen = $state(false);
|
let layerSettingsOpen = $state(false);
|
||||||
|
let fullscreen = $state(false);
|
||||||
|
|
||||||
|
function toggleFullscreen() {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
document.documentElement.requestFullscreen?.();
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const handler = () => {
|
||||||
|
fullscreen = document.fullscreenElement !== null;
|
||||||
|
};
|
||||||
|
document.addEventListener('fullscreenchange', handler);
|
||||||
|
return () => document.removeEventListener('fullscreenchange', handler);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="absolute md:top-2 left-0 right-0 z-20 flex flex-row justify-center pointer-events-none">
|
<div class="absolute md:top-2 left-0 right-0 z-20 flex flex-row justify-center pointer-events-none">
|
||||||
@@ -375,8 +394,18 @@
|
|||||||
<Menubar.Item inset onclick={() => map.toggle3D()}>
|
<Menubar.Item inset onclick={() => map.toggle3D()}>
|
||||||
<Box size="16" />
|
<Box size="16" />
|
||||||
{i18n._('menu.toggle_3d')}
|
{i18n._('menu.toggle_3d')}
|
||||||
<Shortcut key="{i18n._('menu.ctrl')} {i18n._('menu.drag')}" />
|
<Shortcut key={i18n._('menu.right_click_drag')} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
|
<Menubar.Separator />
|
||||||
|
<Menubar.CheckboxItem checked={fullscreen} onCheckedChange={toggleFullscreen}>
|
||||||
|
{#if fullscreen}
|
||||||
|
<Minimize2 size="16" />
|
||||||
|
{:else}
|
||||||
|
<Maximize2 size="16" />
|
||||||
|
{/if}
|
||||||
|
{i18n._('menu.fullscreen')}
|
||||||
|
<Shortcut key="F11" />
|
||||||
|
</Menubar.CheckboxItem>
|
||||||
</Menubar.Content>
|
</Menubar.Content>
|
||||||
</Menubar.Menu>
|
</Menubar.Menu>
|
||||||
<Menubar.Menu>
|
<Menubar.Menu>
|
||||||
@@ -389,7 +418,7 @@
|
|||||||
<Menubar.Content class="border-none">
|
<Menubar.Content class="border-none">
|
||||||
<Menubar.Sub>
|
<Menubar.Sub>
|
||||||
<Menubar.SubTrigger>
|
<Menubar.SubTrigger>
|
||||||
<Ruler size="16" class="mr-2" />{i18n._('menu.distance_units')}
|
<Ruler size="16" />{i18n._('menu.distance_units')}
|
||||||
</Menubar.SubTrigger>
|
</Menubar.SubTrigger>
|
||||||
<Menubar.SubContent>
|
<Menubar.SubContent>
|
||||||
<Menubar.RadioGroup bind:value={$distanceUnits}>
|
<Menubar.RadioGroup bind:value={$distanceUnits}>
|
||||||
@@ -407,7 +436,7 @@
|
|||||||
</Menubar.Sub>
|
</Menubar.Sub>
|
||||||
<Menubar.Sub>
|
<Menubar.Sub>
|
||||||
<Menubar.SubTrigger>
|
<Menubar.SubTrigger>
|
||||||
<Zap size="16" class="mr-2" />{i18n._('menu.velocity_units')}
|
<Zap size="16" />{i18n._('menu.velocity_units')}
|
||||||
</Menubar.SubTrigger>
|
</Menubar.SubTrigger>
|
||||||
<Menubar.SubContent>
|
<Menubar.SubContent>
|
||||||
<Menubar.RadioGroup bind:value={$velocityUnits}>
|
<Menubar.RadioGroup bind:value={$velocityUnits}>
|
||||||
@@ -422,7 +451,7 @@
|
|||||||
</Menubar.Sub>
|
</Menubar.Sub>
|
||||||
<Menubar.Sub>
|
<Menubar.Sub>
|
||||||
<Menubar.SubTrigger>
|
<Menubar.SubTrigger>
|
||||||
<Thermometer size="16" class="mr-2" />{i18n._('menu.temperature_units')}
|
<Thermometer size="16" />{i18n._('menu.temperature_units')}
|
||||||
</Menubar.SubTrigger>
|
</Menubar.SubTrigger>
|
||||||
<Menubar.SubContent>
|
<Menubar.SubContent>
|
||||||
<Menubar.RadioGroup bind:value={$temperatureUnits}>
|
<Menubar.RadioGroup bind:value={$temperatureUnits}>
|
||||||
@@ -438,7 +467,7 @@
|
|||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Sub>
|
<Menubar.Sub>
|
||||||
<Menubar.SubTrigger>
|
<Menubar.SubTrigger>
|
||||||
<Languages size="16" class="mr-2" />
|
<Languages size="16" />
|
||||||
{i18n._('menu.language')}
|
{i18n._('menu.language')}
|
||||||
</Menubar.SubTrigger>
|
</Menubar.SubTrigger>
|
||||||
<Menubar.SubContent>
|
<Menubar.SubContent>
|
||||||
@@ -454,9 +483,9 @@
|
|||||||
<Menubar.Sub>
|
<Menubar.Sub>
|
||||||
<Menubar.SubTrigger>
|
<Menubar.SubTrigger>
|
||||||
{#if mode.current === 'light' || !mode.current}
|
{#if mode.current === 'light' || !mode.current}
|
||||||
<Sun size="16" class="mr-2" />
|
<Sun size="16" />
|
||||||
{:else}
|
{:else}
|
||||||
<Moon size="16" class="mr-2" />
|
<Moon size="16" />
|
||||||
{/if}
|
{/if}
|
||||||
{i18n._('menu.mode')}
|
{i18n._('menu.mode')}
|
||||||
</Menubar.SubTrigger>
|
</Menubar.SubTrigger>
|
||||||
@@ -479,7 +508,7 @@
|
|||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Sub>
|
<Menubar.Sub>
|
||||||
<Menubar.SubTrigger>
|
<Menubar.SubTrigger>
|
||||||
<PersonStanding size="16" class="mr-2" />
|
<PersonStanding size="16" />
|
||||||
{i18n._('menu.street_view_source')}
|
{i18n._('menu.street_view_source')}
|
||||||
</Menubar.SubTrigger>
|
</Menubar.SubTrigger>
|
||||||
<Menubar.SubContent>
|
<Menubar.SubContent>
|
||||||
@@ -500,12 +529,12 @@
|
|||||||
</Menubar.Content>
|
</Menubar.Content>
|
||||||
</Menubar.Menu>
|
</Menubar.Menu>
|
||||||
</Menubar.Root>
|
</Menubar.Root>
|
||||||
<div class="h-fit flex flex-row items-center ml-1 gap-1">
|
<div class="h-fit flex flex-row items-center">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
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-md px-3 py-0.5"
|
||||||
aria-label={i18n._('menu.help')}
|
aria-label={i18n._('menu.help')}
|
||||||
>
|
>
|
||||||
<BookOpenText size="18" class="md:hidden" />
|
<BookOpenText size="18" class="md:hidden" />
|
||||||
@@ -515,9 +544,9 @@
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
href="https://ko-fi.com/gpxstudio"
|
href="https://opencollective.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-md font-bold text-support hover:text-support px-3 py-0.5"
|
||||||
aria-label={i18n._('menu.donate')}
|
aria-label={i18n._('menu.donate')}
|
||||||
>
|
>
|
||||||
<HeartHandshake size="18" class="md:hidden" />
|
<HeartHandshake size="18" class="md:hidden" />
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
class={className}
|
class={className}
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
<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 { BookOpenText, House, Map } from '@lucide/svelte';
|
import { BookOpenText, House, Map } from '@lucide/svelte';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav class="w-full sticky top-0 bg-background z-50">
|
<nav class="sticky top-0 w-full px-12 py-2 bg-background z-50 flex flex-col items-center border-b">
|
||||||
<div class="mx-6 py-2 flex flex-row items-center border-b gap-4 sm:gap-8">
|
<div class="w-full max-w-5xl flex flex-row items-center gap-4 sm:gap-8">
|
||||||
<a href={getURLForLanguage(i18n.lang, '/')} class="shrink-0 translate-y-0.5">
|
<a
|
||||||
<Logo class="h-8 sm:hidden" iconOnly={true} width="26" />
|
href={getURLForLanguage(i18n.lang, '/')}
|
||||||
<Logo class="h-8 hidden sm:block" width="153" />
|
class="shrink-0 translate-y-0.25 justify-self-start"
|
||||||
|
>
|
||||||
|
<Logo class="h-8 xs:hidden" iconOnly={true} width="26" />
|
||||||
|
<Logo class="h-8 hidden xs:block" width="153" />
|
||||||
</a>
|
</a>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
class="text-base px-0 has-[>svg]:px-0"
|
class="text-base px-0 has-[>svg]:px-0 ml-auto"
|
||||||
href={getURLForLanguage(i18n.lang, '/')}
|
href={getURLForLanguage(i18n.lang, '/')}
|
||||||
>
|
>
|
||||||
<House size="18" />
|
<House size="18" />
|
||||||
@@ -39,7 +40,5 @@
|
|||||||
<BookOpenText size="18" />
|
<BookOpenText size="18" />
|
||||||
{i18n._('menu.help')}
|
{i18n._('menu.help')}
|
||||||
</Button>
|
</Button>
|
||||||
<AlgoliaDocSearch class="ml-auto" />
|
|
||||||
<ModeSwitch class="hidden xs:inline-flex" />
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="w-full flex flex-row gap-1 {side === 'right'
|
class="w-full flex flex-row gap-1 border-none {side === 'right'
|
||||||
? 'justify-between'
|
? 'justify-between'
|
||||||
: 'justify-start pl-1'} h-fit {nohover
|
: 'justify-start pl-1'} h-fit {nohover
|
||||||
? 'hover:bg-background'
|
? 'hover:bg-background'
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="w-full flex flex-row gap-1 {side === 'right'
|
class="w-full flex flex-row gap-1 border-none {side === 'right'
|
||||||
? 'justify-between'
|
? 'justify-between'
|
||||||
: 'justify-start pl-1'} h-fit {nohover ? 'hover:bg-background' : ''}"
|
: 'justify-start pl-1'} h-fit {nohover ? 'hover:bg-background' : ''}"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
@apply text-foreground;
|
@apply text-foreground;
|
||||||
@apply text-3xl;
|
@apply text-3xl;
|
||||||
@apply font-semibold;
|
@apply font-semibold;
|
||||||
@apply mb-3 pt-6;
|
@apply mb-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.markdown h2) {
|
:global(.markdown h2) {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<div class="rounded-md overflow-hidden overflow-clip shadow-xl mx-auto">
|
<div class="rounded-md overflow-hidden overflow-clip shadow-xl mx-auto">
|
||||||
{#if src === 'getting-started/interface'}
|
{#if src === 'getting-started/interface'}
|
||||||
<enhanced:img
|
<enhanced:img
|
||||||
src="/src/lib/assets/img/docs/getting-started/interface.png"
|
src="/src/lib/assets/img/docs/getting-started/interface.webp"
|
||||||
{alt}
|
{alt}
|
||||||
class="w-full max-w-3xl"
|
class="w-full max-w-3xl"
|
||||||
/>
|
/>
|
||||||
@@ -20,13 +20,13 @@
|
|||||||
<enhanced:img
|
<enhanced:img
|
||||||
src="/src/lib/assets/img/docs/tools/routing.png"
|
src="/src/lib/assets/img/docs/tools/routing.png"
|
||||||
{alt}
|
{alt}
|
||||||
class="w-full max-w-3xl"
|
class="w-full max-w-lg"
|
||||||
/>
|
/>
|
||||||
{:else if src === 'tools/split'}
|
{:else if src === 'tools/split'}
|
||||||
<enhanced:img
|
<enhanced:img
|
||||||
src="/src/lib/assets/img/docs/tools/split.png"
|
src="/src/lib/assets/img/docs/tools/split.png"
|
||||||
{alt}
|
{alt}
|
||||||
class="w-full max-w-3xl"
|
class="w-full max-w-lg"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import maptilerTopoMap from '$lib/assets/img/home/maptiler-topo.png?enhanced';
|
import topoMap from '$lib/assets/img/docs/maps/topo.png?enhanced';
|
||||||
import waymarkedMap from '$lib/assets/img/home/waymarked.png?enhanced';
|
import waymarkedMap from '$lib/assets/img/docs/maps/waymarked.png?enhanced';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative h-80 aspect-square rounded-2xl shadow-xl overflow-clip">
|
<div class="relative h-80 aspect-square rounded-2xl shadow-xl overflow-clip">
|
||||||
<enhanced:img src={maptilerTopoMap} alt="MapTiler Topo map screenshot." class="absolute" />
|
<enhanced:img src={topoMap} alt="Topo map screenshot." class="absolute" />
|
||||||
<enhanced:img
|
<enhanced:img
|
||||||
src={waymarkedMap}
|
src={waymarkedMap}
|
||||||
alt="Waymarked Trails map screenshot."
|
alt="Waymarked Trails map screenshot."
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
Construction,
|
Construction,
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
import type { Readable, Writable } from 'svelte/store';
|
import type { Readable, Writable } from 'svelte/store';
|
||||||
import type { GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
|
import type { Coordinates, GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
|
||||||
import { settings } from '$lib/logic/settings';
|
import { settings } from '$lib/logic/settings';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { ElevationProfile } from '$lib/components/elevation-profile/elevation-profile';
|
import { ElevationProfile } from '$lib/components/elevation-profile/elevation-profile';
|
||||||
@@ -28,12 +28,14 @@
|
|||||||
let {
|
let {
|
||||||
gpxStatistics,
|
gpxStatistics,
|
||||||
slicedGPXStatistics,
|
slicedGPXStatistics,
|
||||||
|
hoveredPoint,
|
||||||
additionalDatasets,
|
additionalDatasets,
|
||||||
elevationFill,
|
elevationFill,
|
||||||
showControls = true,
|
showControls = true,
|
||||||
}: {
|
}: {
|
||||||
gpxStatistics: Readable<GPXStatisticsGroup>;
|
gpxStatistics: Readable<GPXStatisticsGroup>;
|
||||||
slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>;
|
slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>;
|
||||||
|
hoveredPoint: Writable<Coordinates | null>;
|
||||||
additionalDatasets: Writable<string[]>;
|
additionalDatasets: Writable<string[]>;
|
||||||
elevationFill: Writable<'slope' | 'surface' | 'highway' | undefined>;
|
elevationFill: Writable<'slope' | 'surface' | 'highway' | undefined>;
|
||||||
showControls?: boolean;
|
showControls?: boolean;
|
||||||
@@ -47,6 +49,7 @@
|
|||||||
elevationProfile = new ElevationProfile(
|
elevationProfile = new ElevationProfile(
|
||||||
gpxStatistics,
|
gpxStatistics,
|
||||||
slicedGPXStatistics,
|
slicedGPXStatistics,
|
||||||
|
hoveredPoint,
|
||||||
additionalDatasets,
|
additionalDatasets,
|
||||||
elevationFill,
|
elevationFill,
|
||||||
canvas,
|
canvas,
|
||||||
@@ -61,37 +64,35 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="h-full grow min-w-0 relative py-2">
|
<div class="h-full grow min-w-0 min-h-0 relative">
|
||||||
<canvas bind:this={overlay} class="w-full h-full absolute pointer-events-none"></canvas>
|
<canvas bind:this={overlay} class="w-full h-full absolute pointer-events-none"></canvas>
|
||||||
<canvas bind:this={canvas} class="w-full h-full absolute"></canvas>
|
<canvas bind:this={canvas} class="w-full h-full absolute"></canvas>
|
||||||
{#if showControls}
|
{#if showControls}
|
||||||
<div class="absolute bottom-10 right-1.5">
|
<div class="absolute bottom-9 right-2.5">
|
||||||
<Popover.Root>
|
<Popover.Root>
|
||||||
<Popover.Trigger>
|
<Popover.Trigger>
|
||||||
<ButtonWithTooltip
|
<ButtonWithTooltip
|
||||||
label={i18n._('chart.settings')}
|
label={i18n._('chart.settings')}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
side="left"
|
side="left"
|
||||||
class="w-7 h-7 p-0 flex justify-center opacity-70 hover:opacity-100 transition-opacity duration-300 hover:bg-background"
|
class="w-7 h-7 p-0 flex justify-center opacity-70 hover:opacity-100 transition-opacity duration-300 bg-background"
|
||||||
>
|
>
|
||||||
<ChartNoAxesColumn size="18" />
|
<ChartNoAxesColumn size="18" />
|
||||||
</ButtonWithTooltip>
|
</ButtonWithTooltip>
|
||||||
</Popover.Trigger>
|
</Popover.Trigger>
|
||||||
<Popover.Content
|
<Popover.Content
|
||||||
class="w-fit p-0 flex flex-col"
|
class="w-fit p-0 flex flex-col gap-0 overflow-hidden"
|
||||||
side="top"
|
side="top"
|
||||||
align="end"
|
align="end"
|
||||||
sideOffset={-32}
|
sideOffset={-32}
|
||||||
>
|
>
|
||||||
<ToggleGroup.Root
|
<ToggleGroup.Root
|
||||||
class="flex flex-col items-start gap-0 p-1 w-full border-none"
|
class="flex flex-col w-full border-none"
|
||||||
type="single"
|
type="single"
|
||||||
|
size="sm"
|
||||||
bind:value={$elevationFill}
|
bind:value={$elevationFill}
|
||||||
>
|
>
|
||||||
<ToggleGroup.Item
|
<ToggleGroup.Item value="slope" class="w-full flex flex-row justify-start">
|
||||||
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
|
||||||
value="slope"
|
|
||||||
>
|
|
||||||
<div class="w-6 flex justify-center items-center">
|
<div class="w-6 flex justify-center items-center">
|
||||||
{#if $elevationFill === 'slope'}
|
{#if $elevationFill === 'slope'}
|
||||||
<Circle class="size-1.5 fill-current text-current" />
|
<Circle class="size-1.5 fill-current text-current" />
|
||||||
@@ -101,9 +102,8 @@
|
|||||||
{i18n._('quantities.slope')}
|
{i18n._('quantities.slope')}
|
||||||
</ToggleGroup.Item>
|
</ToggleGroup.Item>
|
||||||
<ToggleGroup.Item
|
<ToggleGroup.Item
|
||||||
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
|
||||||
value="surface"
|
value="surface"
|
||||||
variant="outline"
|
class="w-full flex flex-row justify-start"
|
||||||
>
|
>
|
||||||
<div class="w-6 flex justify-center items-center">
|
<div class="w-6 flex justify-center items-center">
|
||||||
{#if $elevationFill === 'surface'}
|
{#if $elevationFill === 'surface'}
|
||||||
@@ -114,9 +114,8 @@
|
|||||||
{i18n._('quantities.surface')}
|
{i18n._('quantities.surface')}
|
||||||
</ToggleGroup.Item>
|
</ToggleGroup.Item>
|
||||||
<ToggleGroup.Item
|
<ToggleGroup.Item
|
||||||
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
|
||||||
value="highway"
|
value="highway"
|
||||||
variant="outline"
|
class="w-full flex flex-row justify-start"
|
||||||
>
|
>
|
||||||
<div class="w-6 flex justify-center items-center">
|
<div class="w-6 flex justify-center items-center">
|
||||||
{#if $elevationFill === 'highway'}
|
{#if $elevationFill === 'highway'}
|
||||||
@@ -129,14 +128,12 @@
|
|||||||
</ToggleGroup.Root>
|
</ToggleGroup.Root>
|
||||||
<Separator />
|
<Separator />
|
||||||
<ToggleGroup.Root
|
<ToggleGroup.Root
|
||||||
class="flex flex-col items-start gap-0 p-1"
|
class="flex flex-col gap-0"
|
||||||
type="multiple"
|
type="multiple"
|
||||||
|
size="sm"
|
||||||
bind:value={$additionalDatasets}
|
bind:value={$additionalDatasets}
|
||||||
>
|
>
|
||||||
<ToggleGroup.Item
|
<ToggleGroup.Item value="speed" class="w-full flex flex-row justify-start">
|
||||||
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
|
||||||
value="speed"
|
|
||||||
>
|
|
||||||
<div class="w-6 flex justify-center items-center">
|
<div class="w-6 flex justify-center items-center">
|
||||||
{#if $additionalDatasets.includes('speed')}
|
{#if $additionalDatasets.includes('speed')}
|
||||||
<Check size="14" />
|
<Check size="14" />
|
||||||
@@ -147,10 +144,7 @@
|
|||||||
? i18n._('quantities.speed')
|
? i18n._('quantities.speed')
|
||||||
: i18n._('quantities.pace')}
|
: i18n._('quantities.pace')}
|
||||||
</ToggleGroup.Item>
|
</ToggleGroup.Item>
|
||||||
<ToggleGroup.Item
|
<ToggleGroup.Item value="hr" class="w-full flex flex-row justify-start">
|
||||||
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
|
||||||
value="hr"
|
|
||||||
>
|
|
||||||
<div class="w-6 flex justify-center items-center">
|
<div class="w-6 flex justify-center items-center">
|
||||||
{#if $additionalDatasets.includes('hr')}
|
{#if $additionalDatasets.includes('hr')}
|
||||||
<Check size="14" />
|
<Check size="14" />
|
||||||
@@ -159,10 +153,7 @@
|
|||||||
<HeartPulse size="15" />
|
<HeartPulse size="15" />
|
||||||
{i18n._('quantities.heartrate')}
|
{i18n._('quantities.heartrate')}
|
||||||
</ToggleGroup.Item>
|
</ToggleGroup.Item>
|
||||||
<ToggleGroup.Item
|
<ToggleGroup.Item value="cad" class="w-full flex flex-row justify-start">
|
||||||
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
|
||||||
value="cad"
|
|
||||||
>
|
|
||||||
<div class="w-6 flex justify-center items-center">
|
<div class="w-6 flex justify-center items-center">
|
||||||
{#if $additionalDatasets.includes('cad')}
|
{#if $additionalDatasets.includes('cad')}
|
||||||
<Check size="14" />
|
<Check size="14" />
|
||||||
@@ -171,10 +162,7 @@
|
|||||||
<Orbit size="15" />
|
<Orbit size="15" />
|
||||||
{i18n._('quantities.cadence')}
|
{i18n._('quantities.cadence')}
|
||||||
</ToggleGroup.Item>
|
</ToggleGroup.Item>
|
||||||
<ToggleGroup.Item
|
<ToggleGroup.Item value="atemp" class="w-full flex flex-row justify-start">
|
||||||
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
|
||||||
value="atemp"
|
|
||||||
>
|
|
||||||
<div class="w-6 flex justify-center items-center">
|
<div class="w-6 flex justify-center items-center">
|
||||||
{#if $additionalDatasets.includes('atemp')}
|
{#if $additionalDatasets.includes('atemp')}
|
||||||
<Check size="14" />
|
<Check size="14" />
|
||||||
@@ -183,10 +171,7 @@
|
|||||||
<Thermometer size="15" />
|
<Thermometer size="15" />
|
||||||
{i18n._('quantities.temperature')}
|
{i18n._('quantities.temperature')}
|
||||||
</ToggleGroup.Item>
|
</ToggleGroup.Item>
|
||||||
<ToggleGroup.Item
|
<ToggleGroup.Item value="power" class="w-full flex flex-row justify-start">
|
||||||
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
|
||||||
value="power"
|
|
||||||
>
|
|
||||||
<div class="w-6 flex justify-center items-center">
|
<div class="w-6 flex justify-center items-center">
|
||||||
{#if $additionalDatasets.includes('power')}
|
{#if $additionalDatasets.includes('power')}
|
||||||
<Check size="14" />
|
<Check size="14" />
|
||||||
|
|||||||
@@ -20,10 +20,8 @@ import Chart, {
|
|||||||
type ScriptableLineSegmentContext,
|
type ScriptableLineSegmentContext,
|
||||||
type TooltipItem,
|
type TooltipItem,
|
||||||
} from 'chart.js/auto';
|
} from 'chart.js/auto';
|
||||||
import maplibregl from 'maplibre-gl';
|
|
||||||
import { get, type Readable, type Writable } from 'svelte/store';
|
import { get, type Readable, type Writable } from 'svelte/store';
|
||||||
import { map } from '$lib/components/map/map';
|
import type { Coordinates, GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
|
||||||
import type { GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
|
|
||||||
import { mode } from 'mode-watcher';
|
import { mode } from 'mode-watcher';
|
||||||
import { getHighwayColor, getSlopeColor, getSurfaceColor } from '$lib/assets/colors';
|
import { getHighwayColor, getSlopeColor, getSurfaceColor } from '$lib/assets/colors';
|
||||||
|
|
||||||
@@ -42,7 +40,7 @@ interface ElevationProfilePoint {
|
|||||||
length: number;
|
length: number;
|
||||||
};
|
};
|
||||||
extensions: Record<string, any>;
|
extensions: Record<string, any>;
|
||||||
coordinates: [number, number];
|
coordinates: Coordinates;
|
||||||
index: number;
|
index: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,18 +48,19 @@ export class ElevationProfile {
|
|||||||
private _chart: Chart | null = null;
|
private _chart: Chart | null = null;
|
||||||
private _canvas: HTMLCanvasElement;
|
private _canvas: HTMLCanvasElement;
|
||||||
private _overlay: HTMLCanvasElement;
|
private _overlay: HTMLCanvasElement;
|
||||||
private _marker: maplibregl.Marker | null = null;
|
|
||||||
private _dragging = false;
|
private _dragging = false;
|
||||||
private _panning = false;
|
private _panning = false;
|
||||||
|
|
||||||
private _gpxStatistics: Readable<GPXStatisticsGroup>;
|
private _gpxStatistics: Readable<GPXStatisticsGroup>;
|
||||||
private _slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>;
|
private _slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>;
|
||||||
|
private _hoveredPoint: Writable<Coordinates | null>;
|
||||||
private _additionalDatasets: Readable<string[]>;
|
private _additionalDatasets: Readable<string[]>;
|
||||||
private _elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>;
|
private _elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
gpxStatistics: Readable<GPXStatisticsGroup>,
|
gpxStatistics: Readable<GPXStatisticsGroup>,
|
||||||
slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>,
|
slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>,
|
||||||
|
hoveredPoint: Writable<Coordinates | null>,
|
||||||
additionalDatasets: Readable<string[]>,
|
additionalDatasets: Readable<string[]>,
|
||||||
elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>,
|
elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>,
|
||||||
canvas: HTMLCanvasElement,
|
canvas: HTMLCanvasElement,
|
||||||
@@ -69,17 +68,12 @@ export class ElevationProfile {
|
|||||||
) {
|
) {
|
||||||
this._gpxStatistics = gpxStatistics;
|
this._gpxStatistics = gpxStatistics;
|
||||||
this._slicedGPXStatistics = slicedGPXStatistics;
|
this._slicedGPXStatistics = slicedGPXStatistics;
|
||||||
|
this._hoveredPoint = hoveredPoint;
|
||||||
this._additionalDatasets = additionalDatasets;
|
this._additionalDatasets = additionalDatasets;
|
||||||
this._elevationFill = elevationFill;
|
this._elevationFill = elevationFill;
|
||||||
this._canvas = canvas;
|
this._canvas = canvas;
|
||||||
this._overlay = overlay;
|
this._overlay = overlay;
|
||||||
|
|
||||||
let element = document.createElement('div');
|
|
||||||
element.className = 'h-4 w-4 rounded-full bg-cyan-500 border-2 border-white';
|
|
||||||
this._marker = new maplibregl.Marker({
|
|
||||||
element,
|
|
||||||
});
|
|
||||||
|
|
||||||
import('chartjs-plugin-zoom').then((module) => {
|
import('chartjs-plugin-zoom').then((module) => {
|
||||||
Chart.register(module.default);
|
Chart.register(module.default);
|
||||||
this.initialize();
|
this.initialize();
|
||||||
@@ -162,14 +156,10 @@ export class ElevationProfile {
|
|||||||
label: (context: TooltipItem<'line'>) => {
|
label: (context: TooltipItem<'line'>) => {
|
||||||
let point = context.raw as ElevationProfilePoint;
|
let point = context.raw as ElevationProfilePoint;
|
||||||
if (context.datasetIndex === 0) {
|
if (context.datasetIndex === 0) {
|
||||||
const map_ = get(map);
|
|
||||||
if (map_ && this._marker) {
|
|
||||||
if (this._dragging) {
|
if (this._dragging) {
|
||||||
this._marker.remove();
|
this._hoveredPoint.set(null);
|
||||||
} else {
|
} else {
|
||||||
this._marker.setLngLat(point.coordinates);
|
this._hoveredPoint.set(point.coordinates);
|
||||||
this._marker.addTo(map_);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return `${i18n._('quantities.elevation')}: ${getElevationWithUnits(point.y, false)}`;
|
return `${i18n._('quantities.elevation')}: ${getElevationWithUnits(point.y, false)}`;
|
||||||
} else if (context.datasetIndex === 1) {
|
} else if (context.datasetIndex === 1) {
|
||||||
@@ -312,10 +302,7 @@ export class ElevationProfile {
|
|||||||
events: ['mouseout'],
|
events: ['mouseout'],
|
||||||
afterEvent: (chart: Chart, args: { event: ChartEvent }) => {
|
afterEvent: (chart: Chart, args: { event: ChartEvent }) => {
|
||||||
if (args.event.type === 'mouseout') {
|
if (args.event.type === 'mouseout') {
|
||||||
const map_ = get(map);
|
this._hoveredPoint.set(null);
|
||||||
if (map_ && this._marker) {
|
|
||||||
this._marker.remove();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -637,8 +624,5 @@ export class ElevationProfile {
|
|||||||
this._chart.destroy();
|
this._chart.destroy();
|
||||||
this._chart = null;
|
this._chart = null;
|
||||||
}
|
}
|
||||||
if (this._marker) {
|
|
||||||
this._marker.remove();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
import { setMode } from 'mode-watcher';
|
import { setMode } from 'mode-watcher';
|
||||||
import { settings } from '$lib/logic/settings';
|
import { settings } from '$lib/logic/settings';
|
||||||
import { fileStateCollection } from '$lib/logic/file-state';
|
import { fileStateCollection } from '$lib/logic/file-state';
|
||||||
import { gpxStatistics, slicedGPXStatistics } from '$lib/logic/statistics';
|
import { gpxStatistics, hoveredPoint, slicedGPXStatistics } from '$lib/logic/statistics';
|
||||||
import { loadFile } from '$lib/logic/file-actions';
|
import { loadFile } from '$lib/logic/file-actions';
|
||||||
import { selection } from '$lib/logic/selection';
|
import { selection } from '$lib/logic/selection';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
<div class="grow relative">
|
<div class="grow relative">
|
||||||
<Map
|
<Map
|
||||||
class="h-full {$fileStateCollection.size > 1 ? 'horizontal' : ''}"
|
class="h-full {$fileStateCollection.size > 1 ? 'horizontal' : ''}"
|
||||||
accessToken={options.token}
|
maptilerKey={options.key}
|
||||||
geocoder={false}
|
geocoder={false}
|
||||||
geolocate={true}
|
geolocate={true}
|
||||||
hash={useHash}
|
hash={useHash}
|
||||||
@@ -117,19 +117,19 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="{options.elevation.show ? '' : 'h-10'} flex flex-row gap-2 px-2 sm:px-4"
|
class="{options.elevation.show ? '' : 'h-10'} flex flex-row gap-2 p-2 sm:px-4"
|
||||||
style={options.elevation.show ? `height: ${options.elevation.height}px` : ''}
|
style={options.elevation.show ? `height: ${options.elevation.height}px` : ''}
|
||||||
>
|
>
|
||||||
<GPXStatistics
|
<GPXStatistics
|
||||||
{gpxStatistics}
|
{gpxStatistics}
|
||||||
{slicedGPXStatistics}
|
{slicedGPXStatistics}
|
||||||
panelSize={options.elevation.height}
|
|
||||||
orientation={options.elevation.show ? 'vertical' : 'horizontal'}
|
orientation={options.elevation.show ? 'vertical' : 'horizontal'}
|
||||||
/>
|
/>
|
||||||
{#if options.elevation.show}
|
{#if options.elevation.show}
|
||||||
<ElevationProfile
|
<ElevationProfile
|
||||||
{gpxStatistics}
|
{gpxStatistics}
|
||||||
{slicedGPXStatistics}
|
{slicedGPXStatistics}
|
||||||
|
{hoveredPoint}
|
||||||
{additionalDatasets}
|
{additionalDatasets}
|
||||||
{elevationFill}
|
{elevationFill}
|
||||||
showControls={options.elevation.controls}
|
showControls={options.elevation.controls}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
let options = $state(
|
let options = $state(
|
||||||
getMergedEmbeddingOptions(
|
getMergedEmbeddingOptions(
|
||||||
{
|
{
|
||||||
token: 'YOUR_MAPTILER_KEY',
|
key: 'YOUR_MAPTILER_KEY',
|
||||||
theme: mode.current,
|
theme: mode.current,
|
||||||
},
|
},
|
||||||
defaultEmbeddingOptions
|
defaultEmbeddingOptions
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
let iframeOptions = $derived(
|
let iframeOptions = $derived(
|
||||||
getMergedEmbeddingOptions(
|
getMergedEmbeddingOptions(
|
||||||
{
|
{
|
||||||
token:
|
key:
|
||||||
options.key.length === 0 || options.key === 'YOUR_MAPTILER_KEY'
|
options.key.length === 0 || options.key === 'YOUR_MAPTILER_KEY'
|
||||||
? PUBLIC_MAPTILER_KEY
|
? PUBLIC_MAPTILER_KEY
|
||||||
: options.key,
|
: options.key,
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const defaultEmbeddingOptions = {
|
|||||||
key: '',
|
key: '',
|
||||||
files: [],
|
files: [],
|
||||||
ids: [],
|
ids: [],
|
||||||
basemap: 'maptilerTopo',
|
basemap: 'topo',
|
||||||
elevation: {
|
elevation: {
|
||||||
show: true,
|
show: true,
|
||||||
height: 170,
|
height: 170,
|
||||||
@@ -90,6 +90,9 @@ export function getCleanedEmbeddingOptions(
|
|||||||
delete cleanedOptions[key];
|
delete cleanedOptions[key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (cleanedOptions['key'] && cleanedOptions['key'] === PUBLIC_MAPTILER_KEY) {
|
||||||
|
delete cleanedOptions['key'];
|
||||||
|
}
|
||||||
return cleanedOptions;
|
return cleanedOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,9 +125,7 @@ export function convertOldEmbeddingOptions(options: URLSearchParams): any {
|
|||||||
}
|
}
|
||||||
if (options.has('source')) {
|
if (options.has('source')) {
|
||||||
let basemap = options.get('source')!;
|
let basemap = options.get('source')!;
|
||||||
if (basemap === 'satellite') {
|
if (basemap === 'otm') {
|
||||||
newOptions.basemap = 'maptilerSatellite';
|
|
||||||
} else if (basemap === 'otm') {
|
|
||||||
newOptions.basemap = 'openTopoMap';
|
newOptions.basemap = 'openTopoMap';
|
||||||
} else if (basemap === 'ohm') {
|
} else if (basemap === 'ohm') {
|
||||||
newOptions.basemap = 'openHikingMap';
|
newOptions.basemap = 'openHikingMap';
|
||||||
|
|||||||
@@ -100,7 +100,11 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full flex flex-row flex-wrap gap-2">
|
<div class="w-full flex flex-row flex-wrap gap-2">
|
||||||
<Button class="bg-support grow" href="https://ko-fi.com/gpxstudio" target="_blank">
|
<Button
|
||||||
|
class="bg-support grow"
|
||||||
|
href="https://opencollective.com/gpxstudio"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
{i18n._('menu.support_button')}
|
{i18n._('menu.support_button')}
|
||||||
<span>🙏</span>
|
<span>🙏</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -114,10 +114,10 @@
|
|||||||
<ContextMenu.Trigger class="grow truncate">
|
<ContextMenu.Trigger class="grow truncate">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="relative w-full p-0 overflow-hidden focus-visible:ring-0 focus-visible:ring-offset-0 {orientation ===
|
class="relative w-full p-0 overflow-hidden border-none focus-visible:ring-0 focus-visible:ring-offset-0 flex flex-row {orientation ===
|
||||||
'vertical'
|
'vertical'
|
||||||
? 'h-fit'
|
? 'h-7'
|
||||||
: 'h-9 px-1.5 shadow-md'} pointer-events-auto"
|
: 'h-9 px-1.5'} pointer-events-auto"
|
||||||
>
|
>
|
||||||
{#if item instanceof ListFileItem || item instanceof ListTrackItem}
|
{#if item instanceof ListFileItem || item instanceof ListTrackItem}
|
||||||
<MetadataDialog bind:open={openEditMetadata} {node} {item} />
|
<MetadataDialog bind:open={openEditMetadata} {node} {item} />
|
||||||
@@ -126,7 +126,7 @@
|
|||||||
{#if item.level === ListLevel.FILE || item.level === ListLevel.TRACK}
|
{#if item.level === ListLevel.FILE || item.level === ListLevel.TRACK}
|
||||||
<div
|
<div
|
||||||
class="absolute {orientation === 'vertical'
|
class="absolute {orientation === 'vertical'
|
||||||
? 'top-0 bottom-0 right-1 w-1'
|
? 'top-0 bottom-0 right-0 w-1'
|
||||||
: 'top-0 h-1 left-0 right-0'}"
|
: 'top-0 h-1 left-0 right-0'}"
|
||||||
style="background:linear-gradient(to {orientation === 'vertical'
|
style="background:linear-gradient(to {orientation === 'vertical'
|
||||||
? 'bottom'
|
? 'bottom'
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
></div>
|
></div>
|
||||||
{/if}
|
{/if}
|
||||||
<span
|
<span
|
||||||
class="w-full text-left truncate py-1 flex flex-row items-center {hidden
|
class="grow text-left truncate ml-1 flex flex-row items-center {hidden
|
||||||
? 'text-muted-foreground'
|
? 'text-muted-foreground'
|
||||||
: ''} {$cut && $copied?.some((i) => i.getFullId() === item.getFullId())
|
: ''} {$cut && $copied?.some((i) => i.getFullId() === item.getFullId())
|
||||||
? 'text-muted-foreground'
|
? 'text-muted-foreground'
|
||||||
|
|||||||
@@ -5,6 +5,16 @@
|
|||||||
|
|
||||||
map.onLoad((map_) => {
|
map.onLoad((map_) => {
|
||||||
map_.on('contextmenu', (e) => {
|
map_.on('contextmenu', (e) => {
|
||||||
|
if (
|
||||||
|
map_.queryRenderedFeatures(e.point, {
|
||||||
|
layers: map_
|
||||||
|
.getLayersOrder()
|
||||||
|
.filter((layerId) => layerId.startsWith('routing-controls')),
|
||||||
|
}).length
|
||||||
|
) {
|
||||||
|
// Clicked on routing control, ignoring
|
||||||
|
return;
|
||||||
|
}
|
||||||
trackpointPopup?.setItem({
|
trackpointPopup?.setItem({
|
||||||
item: new TrackPoint({
|
item: new TrackPoint({
|
||||||
attributes: {
|
attributes: {
|
||||||
|
|||||||
@@ -129,12 +129,21 @@
|
|||||||
@apply relative;
|
@apply relative;
|
||||||
@apply top-0;
|
@apply top-0;
|
||||||
@apply left-0;
|
@apply left-0;
|
||||||
@apply my-2;
|
|
||||||
@apply w-[29px];
|
@apply w-[29px];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
div :global(.maplibregl-ctrl-geocoder--icon-loading) {
|
||||||
|
@apply -mt-1;
|
||||||
|
@apply mb-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.maplibregl-ctrl-geocoder--icon-close) {
|
||||||
|
@apply my-0;
|
||||||
|
}
|
||||||
|
|
||||||
div :global(.maplibregl-ctrl-geocoder--input) {
|
div :global(.maplibregl-ctrl-geocoder--input) {
|
||||||
@apply relative;
|
@apply relative;
|
||||||
|
@apply h-8;
|
||||||
@apply w-64;
|
@apply w-64;
|
||||||
@apply py-0;
|
@apply py-0;
|
||||||
@apply pl-2;
|
@apply pl-2;
|
||||||
|
|||||||
@@ -16,7 +16,6 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
|
||||||
class="justify-start {className}"
|
class="justify-start {className}"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
|
|||||||
@@ -39,7 +39,6 @@
|
|||||||
/>
|
/>
|
||||||
{#if trackpoint.fileId === undefined}
|
{#if trackpoint.fileId === undefined}
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="justify-start"
|
class="justify-start"
|
||||||
href={`https://www.openstreetmap.org/edit?#map=${(($map?.getZoom() ?? 17) + 1).toFixed(0)}/${trackpoint.item.getLatitude().toFixed(5)}/${trackpoint.item.getLongitude().toFixed(5)}`}
|
href={`https://www.openstreetmap.org/edit?#map=${(($map?.getZoom() ?? 17) + 1).toFixed(0)}/${trackpoint.item.getLatitude().toFixed(5)}/${trackpoint.item.getLongitude().toFixed(5)}`}
|
||||||
|
|||||||
@@ -88,7 +88,6 @@
|
|||||||
<CopyCoordinates coordinates={waypoint.item.attributes} />
|
<CopyCoordinates coordinates={waypoint.item.attributes} />
|
||||||
{#if $currentTool === Tool.WAYPOINT && selected}
|
{#if $currentTool === Tool.WAYPOINT && selected}
|
||||||
<Button
|
<Button
|
||||||
class="p-1 has-[>svg]:px-2 h-8"
|
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
if (waypoint.fileId) {
|
if (waypoint.fileId) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { get } from 'svelte/store';
|
|||||||
import { map } from '$lib/components/map/map';
|
import { map } from '$lib/components/map/map';
|
||||||
import { allHidden } from '$lib/logic/hidden';
|
import { allHidden } from '$lib/logic/hidden';
|
||||||
import type { GeoJSONSource } from 'maplibre-gl';
|
import type { GeoJSONSource } from 'maplibre-gl';
|
||||||
import { ANCHOR_LAYER_KEY } from '../style';
|
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||||
|
|
||||||
const { distanceMarkers, distanceUnits } = settings;
|
const { distanceMarkers, distanceUnits } = settings;
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
ListFileItem,
|
ListFileItem,
|
||||||
ListRootItem,
|
ListRootItem,
|
||||||
} from '$lib/components/file-list/file-list';
|
} from '$lib/components/file-list/file-list';
|
||||||
import { getClosestLinePoint, getElevation } from '$lib/utils';
|
import { getClosestLinePoint, getElevation, loadSVGIcon } from '$lib/utils';
|
||||||
import { selectedWaypoint } from '$lib/components/toolbar/tools/waypoint/waypoint';
|
import { selectedWaypoint } from '$lib/components/toolbar/tools/waypoint/waypoint';
|
||||||
import { MapPin, Square } from 'lucide-static';
|
import { MapPin, Square } from 'lucide-static';
|
||||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||||
@@ -28,7 +28,7 @@ import { fileActions } from '$lib/logic/file-actions';
|
|||||||
import { splitAs } from '$lib/components/toolbar/tools/scissors/scissors';
|
import { splitAs } from '$lib/components/toolbar/tools/scissors/scissors';
|
||||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
||||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||||
import { gpxColors } from './gpx-layers';
|
import { gpxColors } from '$lib/components/map/gpx-layer/gpx-layers';
|
||||||
|
|
||||||
const colors = [
|
const colors = [
|
||||||
'#ff0000',
|
'#ff0000',
|
||||||
@@ -174,8 +174,9 @@ export class GPXLayer {
|
|||||||
|
|
||||||
update() {
|
update() {
|
||||||
const _map = get(map);
|
const _map = get(map);
|
||||||
|
const layerEventManager = map.layerEventManager;
|
||||||
let file = get(this.file)?.file;
|
let file = get(this.file)?.file;
|
||||||
if (!_map || !file) {
|
if (!_map || !layerEventManager || !file) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,12 +221,61 @@ export class GPXLayer {
|
|||||||
ANCHOR_LAYER_KEY.tracks
|
ANCHOR_LAYER_KEY.tracks
|
||||||
);
|
);
|
||||||
|
|
||||||
_map.on('click', this.fileId, this.layerOnClickBinded);
|
layerEventManager.on('click', this.fileId, this.layerOnClickBinded);
|
||||||
_map.on('contextmenu', this.fileId, this.layerOnContextMenuBinded);
|
layerEventManager.on('contextmenu', this.fileId, this.layerOnContextMenuBinded);
|
||||||
_map.on('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
|
layerEventManager.on('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
|
||||||
_map.on('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
|
layerEventManager.on('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
|
||||||
_map.on('mousemove', this.fileId, this.layerOnMouseMoveBinded);
|
layerEventManager.on('mousemove', this.fileId, this.layerOnMouseMoveBinded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let visibleTrackSegmentIds: string[] = [];
|
||||||
|
file.forEachSegment((segment, trackIndex, segmentIndex) => {
|
||||||
|
if (!segment._data.hidden) {
|
||||||
|
visibleTrackSegmentIds.push(`${trackIndex}-${segmentIndex}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const segmentFilter: FilterSpecification = [
|
||||||
|
'in',
|
||||||
|
['get', 'trackSegmentId'],
|
||||||
|
['literal', visibleTrackSegmentIds],
|
||||||
|
];
|
||||||
|
|
||||||
|
_map.setFilter(this.fileId, segmentFilter, { validate: false });
|
||||||
|
|
||||||
|
if (get(directionMarkers)) {
|
||||||
|
if (!_map.getLayer(this.fileId + '-direction')) {
|
||||||
|
_map.addLayer(
|
||||||
|
{
|
||||||
|
id: this.fileId + '-direction',
|
||||||
|
type: 'symbol',
|
||||||
|
source: this.fileId,
|
||||||
|
layout: {
|
||||||
|
'text-field': '»',
|
||||||
|
'text-offset': [0, -0.06],
|
||||||
|
'text-keep-upright': false,
|
||||||
|
'text-max-angle': 361,
|
||||||
|
'text-allow-overlap': true,
|
||||||
|
'text-font': ['Open Sans Bold'],
|
||||||
|
'symbol-placement': 'line',
|
||||||
|
'symbol-spacing': 20,
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'text-color': 'white',
|
||||||
|
'text-halo-width': 0.2,
|
||||||
|
'text-halo-color': 'white',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ANCHOR_LAYER_KEY.directionMarkers
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_map.setFilter(this.fileId + '-direction', segmentFilter, { validate: false });
|
||||||
|
} else {
|
||||||
|
if (_map.getLayer(this.fileId + '-direction')) {
|
||||||
|
_map.removeLayer(this.fileId + '-direction');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let waypointSource = _map.getSource(this.fileId + '-waypoints') as
|
let waypointSource = _map.getSource(this.fileId + '-waypoints') as
|
||||||
| GeoJSONSource
|
| GeoJSONSource
|
||||||
| undefined;
|
| undefined;
|
||||||
@@ -236,6 +286,7 @@ export class GPXLayer {
|
|||||||
_map.addSource(this.fileId + '-waypoints', {
|
_map.addSource(this.fileId + '-waypoints', {
|
||||||
type: 'geojson',
|
type: 'geojson',
|
||||||
data: this.currentWaypointData,
|
data: this.currentWaypointData,
|
||||||
|
promoteId: 'waypointIndex',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,80 +307,33 @@ export class GPXLayer {
|
|||||||
ANCHOR_LAYER_KEY.waypoints
|
ANCHOR_LAYER_KEY.waypoints
|
||||||
);
|
);
|
||||||
|
|
||||||
_map.on(
|
layerEventManager.on(
|
||||||
'mouseenter',
|
'mouseenter',
|
||||||
this.fileId + '-waypoints',
|
this.fileId + '-waypoints',
|
||||||
this.waypointLayerOnMouseEnterBinded
|
this.waypointLayerOnMouseEnterBinded
|
||||||
);
|
);
|
||||||
_map.on(
|
layerEventManager.on(
|
||||||
'mouseleave',
|
'mouseleave',
|
||||||
this.fileId + '-waypoints',
|
this.fileId + '-waypoints',
|
||||||
this.waypointLayerOnMouseLeaveBinded
|
this.waypointLayerOnMouseLeaveBinded
|
||||||
);
|
);
|
||||||
_map.on('click', this.fileId + '-waypoints', this.waypointLayerOnClickBinded);
|
layerEventManager.on(
|
||||||
_map.on(
|
'click',
|
||||||
|
this.fileId + '-waypoints',
|
||||||
|
this.waypointLayerOnClickBinded
|
||||||
|
);
|
||||||
|
layerEventManager.on(
|
||||||
'mousedown',
|
'mousedown',
|
||||||
this.fileId + '-waypoints',
|
this.fileId + '-waypoints',
|
||||||
this.waypointLayerOnMouseDownBinded
|
this.waypointLayerOnMouseDownBinded
|
||||||
);
|
);
|
||||||
_map.on(
|
layerEventManager.on(
|
||||||
'touchstart',
|
'touchstart',
|
||||||
this.fileId + '-waypoints',
|
this.fileId + '-waypoints',
|
||||||
this.waypointLayerOnTouchStartBinded
|
this.waypointLayerOnTouchStartBinded
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (get(directionMarkers)) {
|
|
||||||
if (!_map.getLayer(this.fileId + '-direction')) {
|
|
||||||
_map.addLayer(
|
|
||||||
{
|
|
||||||
id: this.fileId + '-direction',
|
|
||||||
type: 'symbol',
|
|
||||||
source: this.fileId,
|
|
||||||
layout: {
|
|
||||||
'text-field': '»',
|
|
||||||
'text-offset': [0, -0.1],
|
|
||||||
'text-keep-upright': false,
|
|
||||||
'text-max-angle': 361,
|
|
||||||
'text-allow-overlap': true,
|
|
||||||
'text-font': ['Open Sans Bold'],
|
|
||||||
'symbol-placement': 'line',
|
|
||||||
'symbol-spacing': 20,
|
|
||||||
},
|
|
||||||
paint: {
|
|
||||||
'text-color': 'white',
|
|
||||||
'text-opacity': 0.7,
|
|
||||||
'text-halo-width': 0.2,
|
|
||||||
'text-halo-color': 'white',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ANCHOR_LAYER_KEY.directionMarkers
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (_map.getLayer(this.fileId + '-direction')) {
|
|
||||||
_map.removeLayer(this.fileId + '-direction');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let visibleTrackSegmentIds: string[] = [];
|
|
||||||
file.forEachSegment((segment, trackIndex, segmentIndex) => {
|
|
||||||
if (!segment._data.hidden) {
|
|
||||||
visibleTrackSegmentIds.push(`${trackIndex}-${segmentIndex}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const segmentFilter: FilterSpecification = [
|
|
||||||
'in',
|
|
||||||
['get', 'trackSegmentId'],
|
|
||||||
['literal', visibleTrackSegmentIds],
|
|
||||||
];
|
|
||||||
|
|
||||||
_map.setFilter(this.fileId, segmentFilter, { validate: false });
|
|
||||||
|
|
||||||
if (_map.getLayer(this.fileId + '-direction')) {
|
|
||||||
_map.setFilter(this.fileId + '-direction', segmentFilter, { validate: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
let visibleWaypoints: number[] = [];
|
let visibleWaypoints: number[] = [];
|
||||||
file.wpt.forEach((waypoint, waypointIndex) => {
|
file.wpt.forEach((waypoint, waypointIndex) => {
|
||||||
if (!waypoint._data.hidden) {
|
if (!waypoint._data.hidden) {
|
||||||
@@ -350,32 +354,47 @@ export class GPXLayer {
|
|||||||
|
|
||||||
remove() {
|
remove() {
|
||||||
const _map = get(map);
|
const _map = get(map);
|
||||||
if (_map) {
|
|
||||||
_map.off('click', this.fileId, this.layerOnClickBinded);
|
|
||||||
_map.off('contextmenu', this.fileId, this.layerOnContextMenuBinded);
|
|
||||||
_map.off('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
|
|
||||||
_map.off('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
|
|
||||||
_map.off('mousemove', this.fileId, this.layerOnMouseMoveBinded);
|
|
||||||
_map.off('style.load', this.updateBinded);
|
|
||||||
|
|
||||||
_map.off(
|
if (_map) {
|
||||||
|
_map.off('style.load', this.updateBinded);
|
||||||
|
}
|
||||||
|
|
||||||
|
const layerEventManager = map.layerEventManager;
|
||||||
|
if (layerEventManager) {
|
||||||
|
layerEventManager.off('click', this.fileId, this.layerOnClickBinded);
|
||||||
|
layerEventManager.off('contextmenu', this.fileId, this.layerOnContextMenuBinded);
|
||||||
|
layerEventManager.off('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
|
||||||
|
layerEventManager.off('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
|
||||||
|
layerEventManager.off('mousemove', this.fileId, this.layerOnMouseMoveBinded);
|
||||||
|
|
||||||
|
layerEventManager.off(
|
||||||
'mouseenter',
|
'mouseenter',
|
||||||
this.fileId + '-waypoints',
|
this.fileId + '-waypoints',
|
||||||
this.waypointLayerOnMouseEnterBinded
|
this.waypointLayerOnMouseEnterBinded
|
||||||
);
|
);
|
||||||
_map.off(
|
layerEventManager.off(
|
||||||
'mouseleave',
|
'mouseleave',
|
||||||
this.fileId + '-waypoints',
|
this.fileId + '-waypoints',
|
||||||
this.waypointLayerOnMouseLeaveBinded
|
this.waypointLayerOnMouseLeaveBinded
|
||||||
);
|
);
|
||||||
_map.off('click', this.fileId + '-waypoints', this.waypointLayerOnClickBinded);
|
layerEventManager.off(
|
||||||
_map.off('mousedown', this.fileId + '-waypoints', this.waypointLayerOnMouseDownBinded);
|
'click',
|
||||||
_map.off(
|
this.fileId + '-waypoints',
|
||||||
|
this.waypointLayerOnClickBinded
|
||||||
|
);
|
||||||
|
layerEventManager.off(
|
||||||
|
'mousedown',
|
||||||
|
this.fileId + '-waypoints',
|
||||||
|
this.waypointLayerOnMouseDownBinded
|
||||||
|
);
|
||||||
|
layerEventManager.off(
|
||||||
'touchstart',
|
'touchstart',
|
||||||
this.fileId + '-waypoints',
|
this.fileId + '-waypoints',
|
||||||
this.waypointLayerOnTouchStartBinded
|
this.waypointLayerOnTouchStartBinded
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_map) {
|
||||||
if (_map.getLayer(this.fileId + '-direction')) {
|
if (_map.getLayer(this.fileId + '-direction')) {
|
||||||
_map.removeLayer(this.fileId + '-direction');
|
_map.removeLayer(this.fileId + '-direction');
|
||||||
}
|
}
|
||||||
@@ -581,6 +600,7 @@ export class GPXLayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
_map.dragPan.disable();
|
||||||
|
|
||||||
this.draggedWaypointIndex = e.features![0].properties!.waypointIndex;
|
this.draggedWaypointIndex = e.features![0].properties!.waypointIndex;
|
||||||
this.draggingStartingPosition = e.point;
|
this.draggingStartingPosition = e.point;
|
||||||
@@ -604,6 +624,7 @@ export class GPXLayer {
|
|||||||
waypointPopup?.hide();
|
waypointPopup?.hide();
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
_map.dragPan.disable();
|
||||||
|
|
||||||
_map.on('touchmove', this.waypointLayerOnMouseMoveBinded);
|
_map.on('touchmove', this.waypointLayerOnMouseMoveBinded);
|
||||||
_map.once('touchend', this.waypointLayerOnMouseUpBinded);
|
_map.once('touchend', this.waypointLayerOnMouseUpBinded);
|
||||||
@@ -624,15 +645,32 @@ export class GPXLayer {
|
|||||||
| GeoJSONSource
|
| GeoJSONSource
|
||||||
| undefined;
|
| undefined;
|
||||||
if (waypointSource) {
|
if (waypointSource) {
|
||||||
waypointSource.setData(this.currentWaypointData!);
|
waypointSource.updateData({
|
||||||
|
update: [
|
||||||
|
{
|
||||||
|
id: this.draggedWaypointIndex,
|
||||||
|
newGeometry: {
|
||||||
|
type: 'Point',
|
||||||
|
coordinates: [e.lngLat.lng, e.lngLat.lat],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
waypointLayerOnMouseUp(e: MapLayerMouseEvent | MapLayerTouchEvent) {
|
waypointLayerOnMouseUp(e: MapLayerMouseEvent | MapLayerTouchEvent) {
|
||||||
mapCursor.notify(MapCursorState.WAYPOINT_DRAGGING, false);
|
mapCursor.notify(MapCursorState.WAYPOINT_DRAGGING, false);
|
||||||
|
|
||||||
get(map)?.off('mousemove', this.waypointLayerOnMouseMoveBinded);
|
const _map = get(map);
|
||||||
get(map)?.off('touchmove', this.waypointLayerOnMouseMoveBinded);
|
if (!_map) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_map.dragPan.enable();
|
||||||
|
|
||||||
|
_map.off('mousemove', this.waypointLayerOnMouseMoveBinded);
|
||||||
|
_map.off('touchmove', this.waypointLayerOnMouseMoveBinded);
|
||||||
|
|
||||||
if (this.draggedWaypointIndex === null) {
|
if (this.draggedWaypointIndex === null) {
|
||||||
return;
|
return;
|
||||||
@@ -755,20 +793,7 @@ export class GPXLayer {
|
|||||||
|
|
||||||
symbols.forEach((symbol) => {
|
symbols.forEach((symbol) => {
|
||||||
const iconId = `waypoint-${symbol ?? 'default'}-${this.layerColor}`;
|
const iconId = `waypoint-${symbol ?? 'default'}-${this.layerColor}`;
|
||||||
if (!_map.hasImage(iconId)) {
|
loadSVGIcon(_map, iconId, getSvgForSymbol(symbol, this.layerColor));
|
||||||
let icon = new Image(100, 100);
|
|
||||||
icon.onload = () => {
|
|
||||||
if (!_map.hasImage(iconId)) {
|
|
||||||
_map.addImage(iconId, icon);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Lucide icons are SVG files with a 24x24 viewBox
|
|
||||||
// Create a new SVG with a 32x32 viewBox and center the icon in a circle
|
|
||||||
icon.src =
|
|
||||||
'data:image/svg+xml,' +
|
|
||||||
encodeURIComponent(getSvgForSymbol(symbol, this.layerColor));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,40 @@
|
|||||||
import { currentTool, Tool } from '$lib/components/toolbar/tools';
|
import { currentTool, Tool } from '$lib/components/toolbar/tools';
|
||||||
import { gpxStatistics, slicedGPXStatistics } from '$lib/logic/statistics';
|
import { gpxStatistics, hoveredPoint, slicedGPXStatistics } from '$lib/logic/statistics';
|
||||||
import maplibregl from 'maplibre-gl';
|
import type { GeoJSONSource } from 'maplibre-gl';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import { map } from '$lib/components/map/map';
|
import { map } from '$lib/components/map/map';
|
||||||
import { allHidden } from '$lib/logic/hidden';
|
import { allHidden } from '$lib/logic/hidden';
|
||||||
|
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||||
|
import { loadSVGIcon } from '$lib/utils';
|
||||||
|
|
||||||
|
const startMarkerSVG = `<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="8" cy="8" r="6" fill="#22c55e" stroke="white" stroke-width="1.5"/>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
const endMarkerSVG = `<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<pattern id="checkerboard" x="0" y="0" width="5" height="5" patternUnits="userSpaceOnUse">
|
||||||
|
<rect x="0" y="0" width="2.5" height="2.5" fill="white"/>
|
||||||
|
<rect x="2.5" y="2.5" width="2.5" height="2.5" fill="white"/>
|
||||||
|
<rect x="2.5" y="0" width="2.5" height="2.5" fill="black"/>
|
||||||
|
<rect x="0" y="2.5" width="2.5" height="2.5" fill="black"/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<circle cx="8" cy="8" r="6" fill="url(#checkerboard)" stroke="white" stroke-width="1.5"/>
|
||||||
|
</svg>`;
|
||||||
|
const hoverMarkerSVG = `<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="8" cy="8" r="6" fill="#00b8db" stroke="white" stroke-width="1.5"/>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
export class StartEndMarkers {
|
export class StartEndMarkers {
|
||||||
start: maplibregl.Marker;
|
|
||||||
end: maplibregl.Marker;
|
|
||||||
updateBinded: () => void = this.update.bind(this);
|
updateBinded: () => void = this.update.bind(this);
|
||||||
unsubscribes: (() => void)[] = [];
|
unsubscribes: (() => void)[] = [];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
let startElement = document.createElement('div');
|
map.onLoad((map_) => map_.on('style.load', this.updateBinded));
|
||||||
let endElement = document.createElement('div');
|
|
||||||
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.style.background =
|
|
||||||
'repeating-conic-gradient(#fff 0 90deg, #000 0 180deg) 0 0/8px 8px round';
|
|
||||||
|
|
||||||
this.start = new maplibregl.Marker({ element: startElement });
|
|
||||||
this.end = new maplibregl.Marker({ element: endElement });
|
|
||||||
|
|
||||||
map.onLoad(() => this.update());
|
|
||||||
this.unsubscribes.push(gpxStatistics.subscribe(this.updateBinded));
|
this.unsubscribes.push(gpxStatistics.subscribe(this.updateBinded));
|
||||||
this.unsubscribes.push(slicedGPXStatistics.subscribe(this.updateBinded));
|
this.unsubscribes.push(slicedGPXStatistics.subscribe(this.updateBinded));
|
||||||
|
this.unsubscribes.push(hoveredPoint.subscribe(this.updateBinded));
|
||||||
this.unsubscribes.push(currentTool.subscribe(this.updateBinded));
|
this.unsubscribes.push(currentTool.subscribe(this.updateBinded));
|
||||||
this.unsubscribes.push(allHidden.subscribe(this.updateBinded));
|
this.unsubscribes.push(allHidden.subscribe(this.updateBinded));
|
||||||
}
|
}
|
||||||
@@ -33,33 +43,115 @@ export class StartEndMarkers {
|
|||||||
const map_ = get(map);
|
const map_ = get(map);
|
||||||
if (!map_) return;
|
if (!map_) return;
|
||||||
|
|
||||||
|
this.loadIcons();
|
||||||
|
|
||||||
const tool = get(currentTool);
|
const tool = get(currentTool);
|
||||||
const statistics = get(gpxStatistics);
|
const statistics = get(gpxStatistics);
|
||||||
const slicedStatistics = get(slicedGPXStatistics);
|
const slicedStatistics = get(slicedGPXStatistics);
|
||||||
|
const hovered = get(hoveredPoint);
|
||||||
const hidden = get(allHidden);
|
const hidden = get(allHidden);
|
||||||
if (statistics.global.length > 0 && tool !== Tool.ROUTING && !hidden) {
|
if (!hidden) {
|
||||||
this.start
|
const data: GeoJSON.FeatureCollection = {
|
||||||
.setLngLat(
|
type: 'FeatureCollection',
|
||||||
statistics.getTrackPoint(slicedStatistics?.[1] ?? 0)!.trkpt.getCoordinates()
|
features: [],
|
||||||
)
|
};
|
||||||
.addTo(map_);
|
|
||||||
this.end
|
if (statistics.global.length > 0 && tool !== Tool.ROUTING) {
|
||||||
.setLngLat(
|
const start = statistics
|
||||||
statistics
|
.getTrackPoint(slicedStatistics?.[1] ?? 0)!
|
||||||
|
.trkpt.getCoordinates();
|
||||||
|
const end = statistics
|
||||||
.getTrackPoint(slicedStatistics?.[2] ?? statistics.global.length - 1)!
|
.getTrackPoint(slicedStatistics?.[2] ?? statistics.global.length - 1)!
|
||||||
.trkpt.getCoordinates()
|
.trkpt.getCoordinates();
|
||||||
)
|
data.features.push({
|
||||||
.addTo(map_);
|
type: 'Feature',
|
||||||
|
geometry: {
|
||||||
|
type: 'Point',
|
||||||
|
coordinates: [start.lon, start.lat],
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
icon: 'start-marker',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
data.features.push({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: {
|
||||||
|
type: 'Point',
|
||||||
|
coordinates: [end.lon, end.lat],
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
icon: 'end-marker',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hovered) {
|
||||||
|
data.features.push({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: {
|
||||||
|
type: 'Point',
|
||||||
|
coordinates: [hovered.lon, hovered.lat],
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
icon: 'hover-marker',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let source = map_.getSource('start-end-markers') as GeoJSONSource | undefined;
|
||||||
|
if (source) {
|
||||||
|
source.setData(data);
|
||||||
} else {
|
} else {
|
||||||
this.start.remove();
|
map_.addSource('start-end-markers', {
|
||||||
this.end.remove();
|
type: 'geojson',
|
||||||
|
data: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map_.getLayer('start-end-markers')) {
|
||||||
|
map_.addLayer(
|
||||||
|
{
|
||||||
|
id: 'start-end-markers',
|
||||||
|
type: 'symbol',
|
||||||
|
source: 'start-end-markers',
|
||||||
|
layout: {
|
||||||
|
'icon-image': ['get', 'icon'],
|
||||||
|
'icon-size': 0.2,
|
||||||
|
'icon-allow-overlap': true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ANCHOR_LAYER_KEY.startEndMarkers
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (map_.getLayer('start-end-markers')) {
|
||||||
|
map_.removeLayer('start-end-markers');
|
||||||
|
}
|
||||||
|
if (map_.getSource('start-end-markers')) {
|
||||||
|
map_.removeSource('start-end-markers');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
remove() {
|
remove() {
|
||||||
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
|
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||||
|
|
||||||
this.start.remove();
|
const map_ = get(map);
|
||||||
this.end.remove();
|
if (!map_) return;
|
||||||
|
|
||||||
|
if (map_.getLayer('start-end-markers')) {
|
||||||
|
map_.removeLayer('start-end-markers');
|
||||||
|
}
|
||||||
|
if (map_.getSource('start-end-markers')) {
|
||||||
|
map_.removeSource('start-end-markers');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadIcons() {
|
||||||
|
const map_ = get(map);
|
||||||
|
if (!map_) return;
|
||||||
|
loadSVGIcon(map_, 'start-marker', startMarkerSVG);
|
||||||
|
loadSVGIcon(map_, 'end-marker', endMarkerSVG);
|
||||||
|
loadSVGIcon(map_, 'hover-marker', hoverMarkerSVG);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,9 +20,8 @@
|
|||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { defaultBasemap, type CustomLayer } from '$lib/assets/layers';
|
import { defaultBasemap, type CustomLayer } from '$lib/assets/layers';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { customBasemapUpdate, isSelected, remove } from './utils';
|
import { remove } from './utils';
|
||||||
import { settings } from '$lib/logic/settings';
|
import { settings } from '$lib/logic/settings';
|
||||||
import { map } from '$lib/components/map/map';
|
|
||||||
import { dndzone } from 'svelte-dnd-action';
|
import { dndzone } from 'svelte-dnd-action';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -129,8 +128,8 @@
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
$customLayers[layerId] = layer;
|
|
||||||
addLayer(layerId);
|
addLayer(layerId);
|
||||||
|
$customLayers[layerId] = layer;
|
||||||
selectedLayerId = undefined;
|
selectedLayerId = undefined;
|
||||||
setDataFromSelectedLayer();
|
setDataFromSelectedLayer();
|
||||||
}
|
}
|
||||||
@@ -153,9 +152,7 @@
|
|||||||
return $tree;
|
return $tree;
|
||||||
});
|
});
|
||||||
|
|
||||||
if ($currentBasemap === layerId) {
|
if ($currentBasemap !== layerId) {
|
||||||
$customBasemapUpdate++;
|
|
||||||
} else {
|
|
||||||
$currentBasemap = layerId;
|
$currentBasemap = layerId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,14 +168,6 @@
|
|||||||
return $tree;
|
return $tree;
|
||||||
});
|
});
|
||||||
|
|
||||||
if ($map && $currentOverlays && isSelected($currentOverlays, layerId)) {
|
|
||||||
try {
|
|
||||||
$map.removeImport(layerId);
|
|
||||||
} catch (e) {
|
|
||||||
// No reliable way to check if the map is ready to remove sources and layers
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
currentOverlays.update(($overlays) => {
|
currentOverlays.update(($overlays) => {
|
||||||
if (!$overlays.overlays.hasOwnProperty('custom')) {
|
if (!$overlays.overlays.hasOwnProperty('custom')) {
|
||||||
$overlays.overlays['custom'] = {};
|
$overlays.overlays['custom'] = {};
|
||||||
@@ -243,16 +232,13 @@
|
|||||||
|
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
{#if $customBasemapOrder.length > 0}
|
{#if $customBasemapOrder.length > 0}
|
||||||
|
<div class="px-3 py-2">
|
||||||
<div class="flex flex-row items-center gap-1 font-semibold mb-2">
|
<div class="flex flex-row items-center gap-1 font-semibold mb-2">
|
||||||
<Map size="16" />
|
<Map size="16" />
|
||||||
{i18n._('layers.label.basemaps')}
|
{i18n._('layers.label.basemaps')}
|
||||||
<div class="grow">
|
|
||||||
<Separator />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div
|
<div
|
||||||
class="ml-1.5 flex flex-col gap-1 {$customBasemapOrder.length > 0 ? 'mb-2' : ''}"
|
class="ml-1.5 flex flex-col gap-1"
|
||||||
use:dndzone={{
|
use:dndzone={{
|
||||||
items: customBasemapItems,
|
items: customBasemapItems,
|
||||||
type: 'basemap',
|
type: 'basemap',
|
||||||
@@ -269,14 +255,17 @@
|
|||||||
onfinalize={(e) => {
|
onfinalize={(e) => {
|
||||||
customBasemapItems = e.detail.items;
|
customBasemapItems = e.detail.items;
|
||||||
$customBasemapOrder = customBasemapItems.map((item) => item.id);
|
$customBasemapOrder = customBasemapItems.map((item) => item.id);
|
||||||
$selectedBasemapTree.basemaps['custom'] = customBasemapItems.reduce((acc, item) => {
|
$selectedBasemapTree.basemaps['custom'] = customBasemapItems.reduce(
|
||||||
|
(acc, item) => {
|
||||||
acc[item.id] = true;
|
acc[item.id] = true;
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
},
|
||||||
|
{}
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#each customBasemapItems as item (item.id)}
|
{#each customBasemapItems as item (item.id)}
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-1">
|
||||||
<Move size="12" />
|
<Move size="12" />
|
||||||
<span class="grow">{item.name}</span>
|
<span class="grow">{item.name}</span>
|
||||||
<Button
|
<Button
|
||||||
@@ -298,17 +287,18 @@
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
{/if}
|
||||||
{#if $customOverlayOrder.length > 0}
|
{#if $customOverlayOrder.length > 0}
|
||||||
|
<div class="px-3 py-2">
|
||||||
<div class="flex flex-row items-center gap-1 font-semibold mb-2">
|
<div class="flex flex-row items-center gap-1 font-semibold mb-2">
|
||||||
<Layers2 size="16" />
|
<Layers2 size="16" />
|
||||||
{i18n._('layers.label.overlays')}
|
{i18n._('layers.label.overlays')}
|
||||||
<div class="grow">
|
<div class="grow"></div>
|
||||||
<Separator />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div
|
<div
|
||||||
class="ml-1.5 flex flex-col gap-1 {$customOverlayOrder.length > 0 ? 'mb-2' : ''}"
|
class="ml-1.5 flex flex-col gap-1"
|
||||||
use:dndzone={{
|
use:dndzone={{
|
||||||
items: customOverlayItems,
|
items: customOverlayItems,
|
||||||
type: 'overlay',
|
type: 'overlay',
|
||||||
@@ -325,14 +315,17 @@
|
|||||||
onfinalize={(e) => {
|
onfinalize={(e) => {
|
||||||
customOverlayItems = e.detail.items;
|
customOverlayItems = e.detail.items;
|
||||||
$customOverlayOrder = customOverlayItems.map((item) => item.id);
|
$customOverlayOrder = customOverlayItems.map((item) => item.id);
|
||||||
$selectedOverlayTree.overlays['custom'] = customOverlayItems.reduce((acc, item) => {
|
$selectedOverlayTree.overlays['custom'] = customOverlayItems.reduce(
|
||||||
|
(acc, item) => {
|
||||||
acc[item.id] = true;
|
acc[item.id] = true;
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
},
|
||||||
|
{}
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#each customOverlayItems as item (item.id)}
|
{#each customOverlayItems as item (item.id)}
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-1">
|
||||||
<Move size="12" />
|
<Move size="12" />
|
||||||
<span class="grow">{item.name}</span>
|
<span class="grow">{item.name}</span>
|
||||||
<Button
|
<Button
|
||||||
@@ -354,9 +347,12 @@
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<Card.Root class="py-0 gap-0 shadow-none">
|
</div>
|
||||||
|
<Separator />
|
||||||
|
{/if}
|
||||||
|
<Card.Root class="py-0 gap-0 shadow-none ring-0">
|
||||||
<Card.Header class="p-3">
|
<Card.Header class="p-3">
|
||||||
<Card.Title class="text-base">
|
<Card.Title class="text-sm font-semibold">
|
||||||
{#if selectedLayerId}
|
{#if selectedLayerId}
|
||||||
{i18n._('layers.custom_layers.edit')}
|
{i18n._('layers.custom_layers.edit')}
|
||||||
{:else}
|
{:else}
|
||||||
@@ -364,7 +360,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Card.Title>
|
</Card.Title>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content class="p-3 pt-0">
|
<Card.Content class="px-3 py-2">
|
||||||
<fieldset class="flex flex-col gap-2">
|
<fieldset class="flex flex-col gap-2">
|
||||||
<Label for="name">{i18n._('menu.metadata.name')}</Label>
|
<Label for="name">{i18n._('menu.metadata.name')}</Label>
|
||||||
<Input bind:value={name} id="name" class="h-8" />
|
<Input bind:value={name} id="name" class="h-8" />
|
||||||
@@ -421,7 +417,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</RadioGroup.Root>
|
</RadioGroup.Root>
|
||||||
{#if selectedLayerId}
|
{#if selectedLayerId}
|
||||||
<div class="mt-2 flex flex-row gap-2">
|
<div class="mt-2 flex flex-row gap-1">
|
||||||
<Button variant="outline" onclick={createLayer} class="grow">
|
<Button variant="outline" onclick={createLayer} class="grow">
|
||||||
<Save size="16" />
|
<Save size="16" />
|
||||||
{i18n._('layers.custom_layers.update')}
|
{i18n._('layers.custom_layers.update')}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
if (overpassLayer) {
|
if (overpassLayer) {
|
||||||
overpassLayer.remove();
|
overpassLayer.remove();
|
||||||
}
|
}
|
||||||
overpassLayer = new OverpassLayer(_map);
|
overpassLayer = new OverpassLayer(_map, map.layerEventManager!);
|
||||||
overpassLayer.add();
|
overpassLayer.add();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -121,7 +121,7 @@
|
|||||||
<Accordion.Root class="flex flex-col" bind:value={accordionValue} type="single">
|
<Accordion.Root class="flex flex-col" bind:value={accordionValue} type="single">
|
||||||
<Accordion.Item value="layer-selection" class="flex flex-col">
|
<Accordion.Item value="layer-selection" class="flex flex-col">
|
||||||
<Accordion.Trigger>{i18n._('layers.selection')}</Accordion.Trigger>
|
<Accordion.Trigger>{i18n._('layers.selection')}</Accordion.Trigger>
|
||||||
<Accordion.Content class="grow flex flex-col border rounded">
|
<Accordion.Content class="grow flex flex-col border rounded-md mb-1.5">
|
||||||
<div class="py-2 pl-3 pr-2">
|
<div class="py-2 pl-3 pr-2">
|
||||||
<LayerTree
|
<LayerTree
|
||||||
layerTree={basemapTree}
|
layerTree={basemapTree}
|
||||||
@@ -152,7 +152,9 @@
|
|||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
<Accordion.Item value="overlay-opacity">
|
<Accordion.Item value="overlay-opacity">
|
||||||
<Accordion.Trigger>{i18n._('layers.opacity')}</Accordion.Trigger>
|
<Accordion.Trigger>{i18n._('layers.opacity')}</Accordion.Trigger>
|
||||||
<Accordion.Content class="flex flex-col gap-3 overflow-visible">
|
<Accordion.Content
|
||||||
|
class="flex flex-col gap-3 overflow-visible border rounded-md px-3 py-2 mb-1.5"
|
||||||
|
>
|
||||||
<div class="flex flex-row gap-6 items-center">
|
<div class="flex flex-row gap-6 items-center">
|
||||||
<Label>
|
<Label>
|
||||||
{i18n._('layers.custom_layers.overlay')}
|
{i18n._('layers.custom_layers.overlay')}
|
||||||
@@ -167,11 +169,11 @@
|
|||||||
{#if isSelected($selectedOverlayTree, selectedOverlay)}
|
{#if isSelected($selectedOverlayTree, selectedOverlay)}
|
||||||
{#if $isLayerFromExtension(selectedOverlay)}
|
{#if $isLayerFromExtension(selectedOverlay)}
|
||||||
{$getLayerName(selectedOverlay)}
|
{$getLayerName(selectedOverlay)}
|
||||||
|
{:else if $customLayers.hasOwnProperty(selectedOverlay)}
|
||||||
|
{$customLayers[selectedOverlay].name}
|
||||||
{:else}
|
{:else}
|
||||||
{i18n._(`layers.label.${selectedOverlay}`)}
|
{i18n._(`layers.label.${selectedOverlay}`)}
|
||||||
{/if}
|
{/if}
|
||||||
{:else if $customLayers.hasOwnProperty(selectedOverlay)}
|
|
||||||
{$customLayers[selectedOverlay].name}
|
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
@@ -231,10 +233,10 @@
|
|||||||
<Accordion.Item value="custom-layers">
|
<Accordion.Item value="custom-layers">
|
||||||
<Accordion.Trigger>{i18n._('layers.custom_layers.title')}</Accordion.Trigger
|
<Accordion.Trigger>{i18n._('layers.custom_layers.title')}</Accordion.Trigger
|
||||||
>
|
>
|
||||||
<Accordion.Content>
|
<Accordion.Content
|
||||||
<ScrollArea>
|
class="flex flex-col overflow-visible border rounded-md p-0 mb-1.5"
|
||||||
|
>
|
||||||
<CustomLayers />
|
<CustomLayers />
|
||||||
</ScrollArea>
|
|
||||||
</Accordion.Content>
|
</Accordion.Content>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
<Accordion.Item value="terrain-source">
|
<Accordion.Item value="terrain-source">
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import { MapPopup } from '$lib/components/map/map-popup';
|
|||||||
import { settings } from '$lib/logic/settings';
|
import { settings } from '$lib/logic/settings';
|
||||||
import { db } from '$lib/db';
|
import { db } from '$lib/db';
|
||||||
import type { GeoJSONSource } from 'maplibre-gl';
|
import type { GeoJSONSource } from 'maplibre-gl';
|
||||||
import { ANCHOR_LAYER_KEY } from '../style';
|
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||||
|
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
|
||||||
|
import { loadSVGIcon } from '$lib/utils';
|
||||||
|
|
||||||
const { currentOverpassQueries } = settings;
|
const { currentOverpassQueries } = settings;
|
||||||
|
|
||||||
@@ -22,11 +24,12 @@ liveQuery(() => db.overpassdata.toArray()).subscribe((pois) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export class OverpassLayer {
|
export class OverpassLayer {
|
||||||
overpassUrl = 'https://maps.mail.ru/osm/tools/overpass/api/interpreter';
|
overpassUrl = 'https://overpass.private.coffee/api/interpreter';
|
||||||
minZoom = 12;
|
minZoom = 12;
|
||||||
queryZoom = 12;
|
queryZoom = 12;
|
||||||
expirationTime = 7 * 24 * 3600 * 1000;
|
expirationTime = 7 * 24 * 3600 * 1000;
|
||||||
map: maplibregl.Map;
|
map: maplibregl.Map;
|
||||||
|
layerEventManager: MapLayerEventManager;
|
||||||
popup: MapPopup;
|
popup: MapPopup;
|
||||||
|
|
||||||
currentQueries: Set<string> = new Set();
|
currentQueries: Set<string> = new Set();
|
||||||
@@ -37,8 +40,9 @@ export class OverpassLayer {
|
|||||||
updateBinded = this.update.bind(this);
|
updateBinded = this.update.bind(this);
|
||||||
onHoverBinded = this.onHover.bind(this);
|
onHoverBinded = this.onHover.bind(this);
|
||||||
|
|
||||||
constructor(map: maplibregl.Map) {
|
constructor(map: maplibregl.Map, layerEventManager: MapLayerEventManager) {
|
||||||
this.map = map;
|
this.map = map;
|
||||||
|
this.layerEventManager = layerEventManager;
|
||||||
this.popup = new MapPopup(map, {
|
this.popup = new MapPopup(map, {
|
||||||
closeButton: false,
|
closeButton: false,
|
||||||
focusAfterOpen: false,
|
focusAfterOpen: false,
|
||||||
@@ -73,7 +77,14 @@ export class OverpassLayer {
|
|||||||
update() {
|
update() {
|
||||||
this.loadIcons();
|
this.loadIcons();
|
||||||
|
|
||||||
let d = get(data);
|
const fullData = get(data);
|
||||||
|
const queries = getCurrentQueries();
|
||||||
|
const d: GeoJSON.FeatureCollection = {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: fullData.features.filter((feature) =>
|
||||||
|
queries.includes(feature.properties!.query)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let source = this.map.getSource('overpass') as GeoJSONSource | undefined;
|
let source = this.map.getSource('overpass') as GeoJSONSource | undefined;
|
||||||
@@ -102,13 +113,9 @@ export class OverpassLayer {
|
|||||||
ANCHOR_LAYER_KEY.overpass
|
ANCHOR_LAYER_KEY.overpass
|
||||||
);
|
);
|
||||||
|
|
||||||
this.map.on('mouseenter', 'overpass', this.onHoverBinded);
|
this.layerEventManager.on('mouseenter', 'overpass', this.onHoverBinded);
|
||||||
this.map.on('click', 'overpass', this.onHoverBinded);
|
this.layerEventManager.on('click', 'overpass', this.onHoverBinded);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.map.setFilter('overpass', ['in', 'query', ...getCurrentQueries()], {
|
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -117,6 +124,8 @@ export class OverpassLayer {
|
|||||||
remove() {
|
remove() {
|
||||||
this.map.off('moveend', this.queryIfNeededBinded);
|
this.map.off('moveend', this.queryIfNeededBinded);
|
||||||
this.map.off('style.load', this.updateBinded);
|
this.map.off('style.load', this.updateBinded);
|
||||||
|
this.layerEventManager.off('mouseenter', 'overpass', this.onHoverBinded);
|
||||||
|
this.layerEventManager.off('click', 'overpass', this.onHoverBinded);
|
||||||
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
|
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -249,27 +258,16 @@ export class OverpassLayer {
|
|||||||
loadIcons() {
|
loadIcons() {
|
||||||
let currentQueries = getCurrentQueries();
|
let currentQueries = getCurrentQueries();
|
||||||
currentQueries.forEach((query) => {
|
currentQueries.forEach((query) => {
|
||||||
if (!this.map.hasImage(`overpass-${query}`)) {
|
loadSVGIcon(
|
||||||
let icon = new Image(100, 100);
|
this.map,
|
||||||
icon.onload = () => {
|
`overpass-${query}`,
|
||||||
if (!this.map.hasImage(`overpass-${query}`)) {
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
|
||||||
this.map.addImage(`overpass-${query}`, icon);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Lucide icons are SVG files with a 24x24 viewBox
|
|
||||||
// Create a new SVG with a 32x32 viewBox and center the icon in a circle
|
|
||||||
icon.src =
|
|
||||||
'data:image/svg+xml,' +
|
|
||||||
encodeURIComponent(`
|
|
||||||
<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)">
|
||||||
${overpassQueryData[query].icon.svg.replace('stroke="currentColor"', 'stroke="white"')}
|
${overpassQueryData[query].icon.svg.replace('stroke="currentColor"', 'stroke="white"')}
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>`
|
||||||
`);
|
);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
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 (
|
||||||
@@ -76,5 +75,3 @@ export function removeAll(node: LayerTreeType, ids: string[]) {
|
|||||||
});
|
});
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const customBasemapUpdate = writable(0);
|
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import { fileStateCollection } from '$lib/logic/file-state';
|
||||||
|
import maplibregl from 'maplibre-gl';
|
||||||
|
|
||||||
|
type MapLayerMouseEventListener = (e: maplibregl.MapLayerMouseEvent) => void;
|
||||||
|
type MapLayerTouchEventListener = (e: maplibregl.MapLayerTouchEvent) => void;
|
||||||
|
type MapLayerListener = {
|
||||||
|
features: maplibregl.MapGeoJSONFeature[];
|
||||||
|
mousemoves: MapLayerMouseEventListener[];
|
||||||
|
mouseenters: MapLayerMouseEventListener[];
|
||||||
|
mouseleaves: MapLayerMouseEventListener[];
|
||||||
|
mousedowns: MapLayerMouseEventListener[];
|
||||||
|
clicks: MapLayerMouseEventListener[];
|
||||||
|
contextmenus: MapLayerMouseEventListener[];
|
||||||
|
touchstarts: MapLayerTouchEventListener[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export class MapLayerEventManager {
|
||||||
|
private _map: maplibregl.Map;
|
||||||
|
private _listeners: Record<string, MapLayerListener> = {};
|
||||||
|
|
||||||
|
constructor(map: maplibregl.Map) {
|
||||||
|
this._map = map;
|
||||||
|
this._map.on('mousemove', this._handleMouseMove.bind(this));
|
||||||
|
this._map.on('click', this._handleMouseClick.bind(this, 'click'));
|
||||||
|
this._map.on('contextmenu', this._handleMouseClick.bind(this, 'contextmenu'));
|
||||||
|
this._map.on('mousedown', this._handleMouseClick.bind(this, 'mousedown'));
|
||||||
|
this._map.on('touchstart', this._handleTouchStart.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
on(
|
||||||
|
eventType:
|
||||||
|
| 'mousemove'
|
||||||
|
| 'mouseenter'
|
||||||
|
| 'mouseleave'
|
||||||
|
| 'mousedown'
|
||||||
|
| 'click'
|
||||||
|
| 'contextmenu'
|
||||||
|
| 'touchstart',
|
||||||
|
|
||||||
|
layerId: string,
|
||||||
|
listener: MapLayerMouseEventListener | MapLayerTouchEventListener
|
||||||
|
) {
|
||||||
|
if (!this._listeners[layerId]) {
|
||||||
|
this._listeners[layerId] = {
|
||||||
|
features: [],
|
||||||
|
mousemoves: [],
|
||||||
|
mouseenters: [],
|
||||||
|
mouseleaves: [],
|
||||||
|
mousedowns: [],
|
||||||
|
clicks: [],
|
||||||
|
contextmenus: [],
|
||||||
|
touchstarts: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
switch (eventType) {
|
||||||
|
case 'mousemove':
|
||||||
|
this._listeners[layerId].mousemoves.push(listener as MapLayerMouseEventListener);
|
||||||
|
break;
|
||||||
|
case 'mouseenter':
|
||||||
|
this._listeners[layerId].mouseenters.push(listener as MapLayerMouseEventListener);
|
||||||
|
break;
|
||||||
|
case 'mouseleave':
|
||||||
|
this._listeners[layerId].mouseleaves.push(listener as MapLayerMouseEventListener);
|
||||||
|
break;
|
||||||
|
case 'mousedown':
|
||||||
|
this._listeners[layerId].mousedowns.push(listener as MapLayerMouseEventListener);
|
||||||
|
break;
|
||||||
|
case 'click':
|
||||||
|
this._listeners[layerId].clicks.push(listener as MapLayerMouseEventListener);
|
||||||
|
break;
|
||||||
|
case 'contextmenu':
|
||||||
|
this._listeners[layerId].contextmenus.push(listener as MapLayerMouseEventListener);
|
||||||
|
break;
|
||||||
|
case 'touchstart':
|
||||||
|
this._listeners[layerId].touchstarts.push(listener as MapLayerTouchEventListener);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
off(
|
||||||
|
eventType:
|
||||||
|
| 'mousemove'
|
||||||
|
| 'mouseenter'
|
||||||
|
| 'mouseleave'
|
||||||
|
| 'mousedown'
|
||||||
|
| 'click'
|
||||||
|
| 'contextmenu'
|
||||||
|
| 'touchstart',
|
||||||
|
layerId: string,
|
||||||
|
listener: MapLayerMouseEventListener | MapLayerTouchEventListener
|
||||||
|
) {
|
||||||
|
if (this._listeners[layerId]) {
|
||||||
|
switch (eventType) {
|
||||||
|
case 'mousemove':
|
||||||
|
this._listeners[layerId].mousemoves = this._listeners[
|
||||||
|
layerId
|
||||||
|
].mousemoves.filter((l) => l !== listener);
|
||||||
|
break;
|
||||||
|
case 'mouseenter':
|
||||||
|
this._listeners[layerId].mouseenters = this._listeners[
|
||||||
|
layerId
|
||||||
|
].mouseenters.filter((l) => l !== listener);
|
||||||
|
break;
|
||||||
|
case 'mouseleave':
|
||||||
|
this._listeners[layerId].mouseleaves = this._listeners[
|
||||||
|
layerId
|
||||||
|
].mouseleaves.filter((l) => l !== listener);
|
||||||
|
break;
|
||||||
|
case 'mousedown':
|
||||||
|
this._listeners[layerId].mousedowns = this._listeners[
|
||||||
|
layerId
|
||||||
|
].mousedowns.filter((l) => l !== listener);
|
||||||
|
break;
|
||||||
|
case 'click':
|
||||||
|
this._listeners[layerId].clicks = this._listeners[layerId].clicks.filter(
|
||||||
|
(l) => l !== listener
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'contextmenu':
|
||||||
|
this._listeners[layerId].contextmenus = this._listeners[
|
||||||
|
layerId
|
||||||
|
].contextmenus.filter((l) => l !== listener);
|
||||||
|
break;
|
||||||
|
case 'touchstart':
|
||||||
|
this._listeners[layerId].touchstarts = this._listeners[
|
||||||
|
layerId
|
||||||
|
].touchstarts.filter((l) => l !== listener);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
this._listeners[layerId].mousemoves.length === 0 &&
|
||||||
|
this._listeners[layerId].mouseenters.length === 0 &&
|
||||||
|
this._listeners[layerId].mouseleaves.length === 0 &&
|
||||||
|
this._listeners[layerId].mousedowns.length === 0 &&
|
||||||
|
this._listeners[layerId].clicks.length === 0 &&
|
||||||
|
this._listeners[layerId].contextmenus.length === 0 &&
|
||||||
|
this._listeners[layerId].touchstarts.length === 0
|
||||||
|
) {
|
||||||
|
delete this._listeners[layerId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleMouseMove(e: maplibregl.MapMouseEvent) {
|
||||||
|
if (e.originalEvent.buttons > 0) return;
|
||||||
|
const featuresByLayer = this._getRenderedFeaturesByLayer(e);
|
||||||
|
Object.keys(this._listeners).forEach((layerId) => {
|
||||||
|
const features = featuresByLayer[layerId] || [];
|
||||||
|
const listener = this._listeners[layerId];
|
||||||
|
if ((features.length == 0) != (listener.features.length == 0)) {
|
||||||
|
if (features.length > 0) {
|
||||||
|
if (listener.mouseenters.length > 0) {
|
||||||
|
const event = new maplibregl.MapMouseEvent(
|
||||||
|
'mouseenter',
|
||||||
|
e.target,
|
||||||
|
e.originalEvent,
|
||||||
|
{
|
||||||
|
features: featuresByLayer[layerId]!,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
listener.mouseenters.forEach((l) => l(event));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (listener.mouseleaves.length > 0) {
|
||||||
|
const event = new maplibregl.MapMouseEvent(
|
||||||
|
'mouseleave',
|
||||||
|
e.target,
|
||||||
|
e.originalEvent
|
||||||
|
);
|
||||||
|
listener.mouseleaves.forEach((l) => l(event));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (features.length > 0 && listener.mousemoves.length > 0) {
|
||||||
|
const event = new maplibregl.MapMouseEvent('mousemove', e.target, e.originalEvent, {
|
||||||
|
features: featuresByLayer[layerId]!,
|
||||||
|
});
|
||||||
|
listener.mousemoves.forEach((l) => l(event));
|
||||||
|
}
|
||||||
|
listener.features = features;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleMouseClick(type: string, e: maplibregl.MapMouseEvent) {
|
||||||
|
const featuresByLayer = this._getRenderedFeaturesByLayer(e);
|
||||||
|
Object.keys(this._listeners).forEach((layerId) => {
|
||||||
|
const features = featuresByLayer[layerId] || [];
|
||||||
|
const listener = this._listeners[layerId];
|
||||||
|
if (features.length > 0) {
|
||||||
|
if (type === 'click' && listener.clicks.length > 0) {
|
||||||
|
const event = new maplibregl.MapMouseEvent('click', e.target, e.originalEvent, {
|
||||||
|
features: features,
|
||||||
|
});
|
||||||
|
listener.clicks.forEach((l) => l(event));
|
||||||
|
} else if (type === 'contextmenu' && listener.contextmenus.length > 0) {
|
||||||
|
const event = new maplibregl.MapMouseEvent(
|
||||||
|
'contextmenu',
|
||||||
|
e.target,
|
||||||
|
e.originalEvent,
|
||||||
|
{
|
||||||
|
features: features,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
listener.contextmenus.forEach((l) => l(event));
|
||||||
|
} else if (type === 'mousedown' && listener.mousedowns.length > 0) {
|
||||||
|
const event = new maplibregl.MapMouseEvent(
|
||||||
|
'mousedown',
|
||||||
|
e.target,
|
||||||
|
e.originalEvent,
|
||||||
|
{
|
||||||
|
features: features,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
listener.mousedowns.forEach((l) => l(event));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleTouchStart(e: maplibregl.MapTouchEvent) {
|
||||||
|
const featuresByLayer = this._getRenderedFeaturesByLayer(e);
|
||||||
|
Object.keys(this._listeners).forEach((layerId) => {
|
||||||
|
const features = featuresByLayer[layerId] || [];
|
||||||
|
const listener = this._listeners[layerId];
|
||||||
|
if (features.length > 0) {
|
||||||
|
const event: maplibregl.MapLayerTouchEvent = new maplibregl.MapTouchEvent(
|
||||||
|
'touchstart',
|
||||||
|
e.target,
|
||||||
|
e.originalEvent
|
||||||
|
);
|
||||||
|
event.features = featuresByLayer[layerId]!;
|
||||||
|
listener.touchstarts.forEach((l) => l(event));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _getBounds(point: maplibregl.Point) {
|
||||||
|
const delta = 30;
|
||||||
|
return new maplibregl.LngLatBounds(
|
||||||
|
this._map.unproject([point.x - delta, point.y + delta]),
|
||||||
|
this._map.unproject([point.x + delta, point.y - delta])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _filterLayersIntersectingBounds(
|
||||||
|
layerIds: string[],
|
||||||
|
bounds: maplibregl.LngLatBounds
|
||||||
|
): string[] {
|
||||||
|
let result = layerIds.filter((layerId) => {
|
||||||
|
if (!this._map.getLayer(layerId)) return false;
|
||||||
|
const fileId = layerId.replace('-waypoints', '');
|
||||||
|
if (fileId === layerId) {
|
||||||
|
return fileStateCollection.getStatistics(fileId)?.intersectsBBox(bounds) ?? true;
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
fileStateCollection.getStatistics(fileId)?.intersectsWaypointBBox(bounds) ??
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _getRenderedFeaturesByLayer(e: maplibregl.MapMouseEvent | maplibregl.MapTouchEvent) {
|
||||||
|
const layerIds = this._filterLayersIntersectingBounds(
|
||||||
|
Object.keys(this._listeners),
|
||||||
|
this._getBounds(e.point)
|
||||||
|
);
|
||||||
|
const features =
|
||||||
|
layerIds.length > 0
|
||||||
|
? this._map.queryRenderedFeatures(e.point, { layers: layerIds })
|
||||||
|
: [];
|
||||||
|
const featuresByLayer: Record<string, maplibregl.MapGeoJSONFeature[]> = {};
|
||||||
|
features.forEach((f) => {
|
||||||
|
if (!featuresByLayer[f.layer.id]) {
|
||||||
|
featuresByLayer[f.layer.id] = [];
|
||||||
|
}
|
||||||
|
featuresByLayer[f.layer.id].push(f);
|
||||||
|
});
|
||||||
|
return featuresByLayer;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { get, writable, type Writable } from 'svelte/store';
|
|||||||
import { settings } from '$lib/logic/settings';
|
import { settings } from '$lib/logic/settings';
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
import { ANCHOR_LAYER_KEY, StyleManager } from '$lib/components/map/style';
|
import { ANCHOR_LAYER_KEY, StyleManager } from '$lib/components/map/style';
|
||||||
|
import { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
|
||||||
|
|
||||||
const { treeFileView, elevationProfile, bottomPanelSize, rightPanelSize, distanceUnits } = settings;
|
const { treeFileView, elevationProfile, bottomPanelSize, rightPanelSize, distanceUnits } = settings;
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ export class MapLibreGLMap {
|
|||||||
private _onLoadCallbacks: ((map: maplibregl.Map) => void)[] = [];
|
private _onLoadCallbacks: ((map: maplibregl.Map) => void)[] = [];
|
||||||
private _unsubscribes: (() => void)[] = [];
|
private _unsubscribes: (() => void)[] = [];
|
||||||
private callOnLoadBinded: () => void = this.callOnLoad.bind(this);
|
private callOnLoadBinded: () => void = this.callOnLoad.bind(this);
|
||||||
|
public layerEventManager: MapLayerEventManager | null = null;
|
||||||
|
|
||||||
subscribe(run: (value: maplibregl.Map | null) => void, invalidate?: () => void) {
|
subscribe(run: (value: maplibregl.Map | null) => void, invalidate?: () => void) {
|
||||||
return this._mapStore.subscribe(run, invalidate);
|
return this._mapStore.subscribe(run, invalidate);
|
||||||
@@ -52,8 +54,9 @@ export class MapLibreGLMap {
|
|||||||
zoom: 0,
|
zoom: 0,
|
||||||
hash: hash,
|
hash: hash,
|
||||||
boxZoom: false,
|
boxZoom: false,
|
||||||
maxPitch: 85,
|
maxPitch: 90,
|
||||||
});
|
});
|
||||||
|
this.layerEventManager = new MapLayerEventManager(map);
|
||||||
map.addControl(
|
map.addControl(
|
||||||
new maplibregl.NavigationControl({
|
new maplibregl.NavigationControl({
|
||||||
visualizePitch: true,
|
visualizePitch: true,
|
||||||
|
|||||||
@@ -20,9 +20,14 @@
|
|||||||
let container: HTMLElement;
|
let container: HTMLElement;
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
map.onLoad((map: maplibregl.Map) => {
|
map.onLoad((map_: maplibregl.Map) => {
|
||||||
googleRedirect = new GoogleRedirect(map);
|
googleRedirect = new GoogleRedirect(map_);
|
||||||
mapillaryLayer = new MapillaryLayer(map, container, mapillaryOpen);
|
mapillaryLayer = new MapillaryLayer(
|
||||||
|
map_,
|
||||||
|
map.layerEventManager!,
|
||||||
|
container,
|
||||||
|
mapillaryOpen
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,7 +53,7 @@
|
|||||||
<CustomControl class="w-[29px] h-[29px] shrink-0">
|
<CustomControl class="w-[29px] h-[29px] shrink-0">
|
||||||
<ButtonWithTooltip
|
<ButtonWithTooltip
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="w-full h-full"
|
class="w-full h-full border-none rounded-sm"
|
||||||
side="left"
|
side="left"
|
||||||
label={i18n._('menu.toggle_street_view')}
|
label={i18n._('menu.toggle_street_view')}
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import maplibregl, { type LayerSpecification, type VectorSourceSpecification } f
|
|||||||
import { Viewer, type ViewerBearingEvent } from 'mapillary-js/dist/mapillary.module';
|
import { Viewer, type ViewerBearingEvent } from 'mapillary-js/dist/mapillary.module';
|
||||||
import 'mapillary-js/dist/mapillary.css';
|
import 'mapillary-js/dist/mapillary.css';
|
||||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
||||||
import { ANCHOR_LAYER_KEY } from '../style';
|
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||||
|
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
|
||||||
|
|
||||||
const mapillarySource: VectorSourceSpecification = {
|
const mapillarySource: VectorSourceSpecification = {
|
||||||
type: 'vector',
|
type: 'vector',
|
||||||
@@ -43,6 +44,7 @@ const mapillaryImageLayer: LayerSpecification = {
|
|||||||
|
|
||||||
export class MapillaryLayer {
|
export class MapillaryLayer {
|
||||||
map: maplibregl.Map;
|
map: maplibregl.Map;
|
||||||
|
layerEventManager: MapLayerEventManager;
|
||||||
marker: maplibregl.Marker;
|
marker: maplibregl.Marker;
|
||||||
viewer: Viewer;
|
viewer: Viewer;
|
||||||
|
|
||||||
@@ -53,8 +55,14 @@ export class MapillaryLayer {
|
|||||||
onMouseEnterBinded = this.onMouseEnter.bind(this);
|
onMouseEnterBinded = this.onMouseEnter.bind(this);
|
||||||
onMouseLeaveBinded = this.onMouseLeave.bind(this);
|
onMouseLeaveBinded = this.onMouseLeave.bind(this);
|
||||||
|
|
||||||
constructor(map: maplibregl.Map, container: HTMLElement, popupOpen: { value: boolean }) {
|
constructor(
|
||||||
|
map: maplibregl.Map,
|
||||||
|
layerEventManager: MapLayerEventManager,
|
||||||
|
container: HTMLElement,
|
||||||
|
popupOpen: { value: boolean }
|
||||||
|
) {
|
||||||
this.map = map;
|
this.map = map;
|
||||||
|
this.layerEventManager = layerEventManager;
|
||||||
|
|
||||||
this.viewer = new Viewer({
|
this.viewer = new Viewer({
|
||||||
accessToken: 'MLY|4381405525255083|3204871ec181638c3c31320490f03011',
|
accessToken: 'MLY|4381405525255083|3204871ec181638c3c31320490f03011',
|
||||||
@@ -103,14 +111,14 @@ export class MapillaryLayer {
|
|||||||
this.map.addLayer(mapillaryImageLayer, ANCHOR_LAYER_KEY.mapillary);
|
this.map.addLayer(mapillaryImageLayer, ANCHOR_LAYER_KEY.mapillary);
|
||||||
}
|
}
|
||||||
this.map.on('style.load', this.addBinded);
|
this.map.on('style.load', this.addBinded);
|
||||||
this.map.on('mouseenter', 'mapillary-image', this.onMouseEnterBinded);
|
this.layerEventManager.on('mouseenter', 'mapillary-image', this.onMouseEnterBinded);
|
||||||
this.map.on('mouseleave', 'mapillary-image', this.onMouseLeaveBinded);
|
this.layerEventManager.on('mouseleave', 'mapillary-image', this.onMouseLeaveBinded);
|
||||||
}
|
}
|
||||||
|
|
||||||
remove() {
|
remove() {
|
||||||
this.map.off('style.load', this.addBinded);
|
this.map.off('style.load', this.addBinded);
|
||||||
this.map.off('mouseenter', 'mapillary-image', this.onMouseEnterBinded);
|
this.layerEventManager.off('mouseenter', 'mapillary-image', this.onMouseEnterBinded);
|
||||||
this.map.off('mouseleave', 'mapillary-image', this.onMouseLeaveBinded);
|
this.layerEventManager.off('mouseleave', 'mapillary-image', this.onMouseLeaveBinded);
|
||||||
|
|
||||||
if (this.map.getLayer('mapillary-image')) {
|
if (this.map.getLayer('mapillary-image')) {
|
||||||
this.map.removeLayer('mapillary-image');
|
this.map.removeLayer('mapillary-image');
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ import {
|
|||||||
overlays,
|
overlays,
|
||||||
terrainSources,
|
terrainSources,
|
||||||
} from '$lib/assets/layers';
|
} from '$lib/assets/layers';
|
||||||
import { customBasemapUpdate, getLayers } from '$lib/components/map/layer-control/utils';
|
import { getLayers } from '$lib/components/map/layer-control/utils';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
|
||||||
const { currentBasemap, currentOverlays, customLayers, opacities, terrainSource } = settings;
|
const { currentBasemap, currentOverlays, customLayers, opacities, terrainSource, distanceUnits } =
|
||||||
|
settings;
|
||||||
|
|
||||||
const emptySource: maplibregl.GeoJSONSourceSpecification = {
|
const emptySource: maplibregl.GeoJSONSourceSpecification = {
|
||||||
type: 'geojson',
|
type: 'geojson',
|
||||||
@@ -25,9 +26,11 @@ export const ANCHOR_LAYER_KEY = {
|
|||||||
tracks: 'tracks-end',
|
tracks: 'tracks-end',
|
||||||
directionMarkers: 'direction-markers-end',
|
directionMarkers: 'direction-markers-end',
|
||||||
distanceMarkers: 'distance-markers-end',
|
distanceMarkers: 'distance-markers-end',
|
||||||
|
startEndMarkers: 'start-end-markers-end',
|
||||||
interactions: 'interactions-end',
|
interactions: 'interactions-end',
|
||||||
overpass: 'overpass-end',
|
overpass: 'overpass-end',
|
||||||
waypoints: 'waypoints-end',
|
waypoints: 'waypoints-end',
|
||||||
|
routingControls: 'routing-controls-end',
|
||||||
};
|
};
|
||||||
const anchorLayers: maplibregl.LayerSpecification[] = Object.values(ANCHOR_LAYER_KEY).map((id) => ({
|
const anchorLayers: maplibregl.LayerSpecification[] = Object.values(ANCHOR_LAYER_KEY).map((id) => ({
|
||||||
id: id,
|
id: id,
|
||||||
@@ -45,22 +48,73 @@ export class StyleManager {
|
|||||||
this._maptilerKey = maptilerKey;
|
this._maptilerKey = maptilerKey;
|
||||||
this._map.subscribe((map_) => {
|
this._map.subscribe((map_) => {
|
||||||
if (map_) {
|
if (map_) {
|
||||||
this.update();
|
this.updateBasemap();
|
||||||
map_.on('style.load', () => this.updateOverlays());
|
map_.on('style.load', () => this.updateOverlays());
|
||||||
map_.on('pitch', () => this.updateTerrain());
|
map_.on('pitch', () => this.updateTerrain());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
currentBasemap.subscribe(() => this.update());
|
currentBasemap.subscribe(() => this.updateBasemap());
|
||||||
customBasemapUpdate.subscribe(() => this.update());
|
|
||||||
currentOverlays.subscribe(() => this.updateOverlays());
|
currentOverlays.subscribe(() => this.updateOverlays());
|
||||||
opacities.subscribe(() => this.updateOverlays());
|
opacities.subscribe(() => this.updateOverlays());
|
||||||
terrainSource.subscribe(() => this.updateTerrain());
|
terrainSource.subscribe(() => this.updateTerrain());
|
||||||
|
customLayers.subscribe(() => this.updateBasemap());
|
||||||
|
distanceUnits.subscribe(() => {
|
||||||
|
if (get(currentBasemap) === 'topo') this.updateBasemap();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
update() {
|
updateBasemap() {
|
||||||
const map_ = get(this._map);
|
const map_ = get(this._map);
|
||||||
if (!map_) return;
|
if (!map_) return;
|
||||||
this.build().then((style) => map_.setStyle(style));
|
let basemap = get(currentBasemap);
|
||||||
|
this.buildStyle(basemap).then((style) => {
|
||||||
|
if (get(currentBasemap) === basemap) map_.setStyle(style);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async buildStyle(basemap: string): Promise<maplibregl.StyleSpecification> {
|
||||||
|
const custom = get(customLayers);
|
||||||
|
|
||||||
|
const style: maplibregl.StyleSpecification = {
|
||||||
|
version: 8,
|
||||||
|
projection: {
|
||||||
|
type: 'globe',
|
||||||
|
},
|
||||||
|
sources: {
|
||||||
|
'empty-source': emptySource,
|
||||||
|
},
|
||||||
|
layers: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const basemapInfo = basemaps[basemap] ?? custom[basemap]?.value ?? basemaps[defaultBasemap];
|
||||||
|
|
||||||
|
let basemapStyle = basemaps.openStreetMap as maplibregl.StyleSpecification;
|
||||||
|
try {
|
||||||
|
basemapStyle = await this.get(basemapInfo);
|
||||||
|
for (const source in basemapStyle.sources) {
|
||||||
|
const src = basemapStyle.sources[source];
|
||||||
|
if (
|
||||||
|
src &&
|
||||||
|
typeof src === 'object' &&
|
||||||
|
'url' in src &&
|
||||||
|
typeof src.url === 'string' &&
|
||||||
|
src.url.includes(maptilerKeyPlaceHolder)
|
||||||
|
) {
|
||||||
|
src.url = src.url.replace(maptilerKeyPlaceHolder, this._maptilerKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e.message);
|
||||||
|
}
|
||||||
|
this.merge(style, basemapStyle);
|
||||||
|
|
||||||
|
const terrain = this.getCurrentTerrain();
|
||||||
|
style.sources[terrain.source] = terrainSources[terrain.source];
|
||||||
|
style.terrain = terrain.exaggeration > 0 ? terrain : undefined;
|
||||||
|
|
||||||
|
style.layers.push(...anchorLayers);
|
||||||
|
|
||||||
|
return style;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateOverlays() {
|
async updateOverlays() {
|
||||||
@@ -76,16 +130,21 @@ export class StyleManager {
|
|||||||
if (!layers[overlay]) {
|
if (!layers[overlay]) {
|
||||||
if (this._pastOverlays.has(overlay)) {
|
if (this._pastOverlays.has(overlay)) {
|
||||||
const overlayInfo = custom[overlay]?.value ?? overlays[overlay];
|
const overlayInfo = custom[overlay]?.value ?? overlays[overlay];
|
||||||
|
try {
|
||||||
const overlayStyle = await this.get(overlayInfo);
|
const overlayStyle = await this.get(overlayInfo);
|
||||||
for (let layer of overlayStyle.layers ?? []) {
|
for (let layer of overlayStyle.layers ?? []) {
|
||||||
if (map_.getLayer(layer.id)) {
|
if (map_.getLayer(layer.id)) {
|
||||||
map_.removeLayer(layer.id);
|
map_.removeLayer(layer.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Should not happen
|
||||||
|
}
|
||||||
this._pastOverlays.delete(overlay);
|
this._pastOverlays.delete(overlay);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const overlayInfo = custom[overlay]?.value ?? overlays[overlay];
|
const overlayInfo = custom[overlay]?.value ?? overlays[overlay];
|
||||||
|
try {
|
||||||
const overlayStyle = await this.get(overlayInfo);
|
const overlayStyle = await this.get(overlayInfo);
|
||||||
const opacity = overlayOpacities[overlay];
|
const opacity = overlayOpacities[overlay];
|
||||||
|
|
||||||
@@ -113,8 +172,10 @@ export class StyleManager {
|
|||||||
map_.addLayer(layer, ANCHOR_LAYER_KEY.overlays);
|
map_.addLayer(layer, ANCHOR_LAYER_KEY.overlays);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this._pastOverlays.add(overlay);
|
this._pastOverlays.add(overlay);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
@@ -127,36 +188,15 @@ export class StyleManager {
|
|||||||
const mapTerrain = map_.getTerrain();
|
const mapTerrain = map_.getTerrain();
|
||||||
const terrain = this.getCurrentTerrain();
|
const terrain = this.getCurrentTerrain();
|
||||||
if (JSON.stringify(mapTerrain) !== JSON.stringify(terrain)) {
|
if (JSON.stringify(mapTerrain) !== JSON.stringify(terrain)) {
|
||||||
|
if (terrain.exaggeration > 0) {
|
||||||
|
if (!map_.getSource(terrain.source)) {
|
||||||
|
map_.addSource(terrain.source, terrainSources[terrain.source]);
|
||||||
|
}
|
||||||
map_.setTerrain(terrain);
|
map_.setTerrain(terrain);
|
||||||
|
} else {
|
||||||
|
map_.setTerrain(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async build(): Promise<maplibregl.StyleSpecification> {
|
|
||||||
const custom = get(customLayers);
|
|
||||||
|
|
||||||
const style: maplibregl.StyleSpecification = {
|
|
||||||
version: 8,
|
|
||||||
projection: {
|
|
||||||
type: 'globe',
|
|
||||||
},
|
|
||||||
sources: {
|
|
||||||
'empty-source': emptySource,
|
|
||||||
},
|
|
||||||
layers: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
let basemap = get(currentBasemap);
|
|
||||||
const basemapInfo = basemaps[basemap] ?? custom[basemap]?.value ?? basemaps[defaultBasemap];
|
|
||||||
const basemapStyle = await this.get(basemapInfo);
|
|
||||||
|
|
||||||
this.merge(style, basemapStyle);
|
|
||||||
|
|
||||||
style.terrain = this.getCurrentTerrain();
|
|
||||||
style.sources[style.terrain.source] = terrainSources[style.terrain.source];
|
|
||||||
|
|
||||||
style.layers.push(...anchorLayers);
|
|
||||||
|
|
||||||
return style;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(
|
async get(
|
||||||
@@ -164,10 +204,10 @@ export class StyleManager {
|
|||||||
): Promise<maplibregl.StyleSpecification> {
|
): Promise<maplibregl.StyleSpecification> {
|
||||||
if (typeof styleInfo === 'string') {
|
if (typeof styleInfo === 'string') {
|
||||||
let styleUrl = styleInfo as string;
|
let styleUrl = styleInfo as string;
|
||||||
if (styleUrl.includes(maptilerKeyPlaceHolder)) {
|
|
||||||
styleUrl = styleUrl.replace(maptilerKeyPlaceHolder, this._maptilerKey);
|
|
||||||
}
|
|
||||||
const response = await fetch(styleUrl, { cache: 'force-cache' });
|
const response = await fetch(styleUrl, { cache: 'force-cache' });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error fetching style "${styleInfo}": ${response.status}`);
|
||||||
|
}
|
||||||
const style = await response.json();
|
const style = await response.json();
|
||||||
return style;
|
return style;
|
||||||
} else {
|
} else {
|
||||||
@@ -177,17 +217,23 @@ export class StyleManager {
|
|||||||
|
|
||||||
merge(style: maplibregl.StyleSpecification, other: maplibregl.StyleSpecification) {
|
merge(style: maplibregl.StyleSpecification, other: maplibregl.StyleSpecification) {
|
||||||
style.sources = { ...style.sources, ...other.sources };
|
style.sources = { ...style.sources, ...other.sources };
|
||||||
|
const units = get(distanceUnits);
|
||||||
for (let layer of other.layers ?? []) {
|
for (let layer of other.layers ?? []) {
|
||||||
|
if ('source' in layer) {
|
||||||
|
if (layer.source == 'contours_m' && units === 'imperial') continue;
|
||||||
|
if (layer.source == 'contours_ft' && units !== 'imperial') continue;
|
||||||
|
}
|
||||||
if (layer.type === 'symbol' && layer.layout && layer.layout['text-field']) {
|
if (layer.type === 'symbol' && layer.layout && layer.layout['text-field']) {
|
||||||
const textField = layer.layout['text-field'];
|
const textField = layer.layout['text-field'];
|
||||||
if (
|
if (
|
||||||
Array.isArray(textField) &&
|
Array.isArray(textField) &&
|
||||||
textField.length >= 2 &&
|
textField.length == 4 &&
|
||||||
textField[0] === 'coalesce' &&
|
Array.isArray(textField[3]) &&
|
||||||
Array.isArray(textField[1]) &&
|
textField[3][0] === 'coalesce' &&
|
||||||
textField[1][0] === 'get' &&
|
Array.isArray(textField[3][1]) &&
|
||||||
typeof textField[1][1] === 'string' &&
|
textField[3][1][0] === 'get' &&
|
||||||
textField[1][1].startsWith('name')
|
typeof textField[3][1][1] === 'string' &&
|
||||||
|
textField[3][1][1].startsWith('name')
|
||||||
) {
|
) {
|
||||||
layer.layout['text-field'] = [
|
layer.layout['text-field'] = [
|
||||||
'coalesce',
|
'coalesce',
|
||||||
@@ -208,10 +254,6 @@ export class StyleManager {
|
|||||||
|
|
||||||
getCurrentTerrain() {
|
getCurrentTerrain() {
|
||||||
const terrain = get(terrainSource);
|
const terrain = get(terrainSource);
|
||||||
const source = terrainSources[terrain];
|
|
||||||
if (source.url && source.url.includes(maptilerKeyPlaceHolder)) {
|
|
||||||
source.url = source.url.replace(maptilerKeyPlaceHolder, this._maptilerKey);
|
|
||||||
}
|
|
||||||
const map_ = get(this._map);
|
const map_ = get(this._map);
|
||||||
return {
|
return {
|
||||||
source: terrain,
|
source: terrain,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<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 ?? ''}">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="whitespace-normal h-fit"
|
class="whitespace-normal h-fit min-h-8 py-1"
|
||||||
disabled={!validSelection}
|
disabled={!validSelection}
|
||||||
onclick={() => fileActions.addElevationToSelection()}
|
onclick={() => fileActions.addElevationToSelection()}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -76,7 +76,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="whitespace-normal h-fit"
|
class="whitespace-normal h-fit min-h-8 py-1"
|
||||||
disabled={(mergeType === MergeType.TRACES && !canMergeTraces) ||
|
disabled={(mergeType === MergeType.TRACES && !canMergeTraces) ||
|
||||||
(mergeType === MergeType.CONTENTS && !canMergeContents)}
|
(mergeType === MergeType.CONTENTS && !canMergeContents)}
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
|
|||||||
@@ -185,8 +185,8 @@
|
|||||||
|
|
||||||
<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 ?? ''}">
|
||||||
<fieldset class="flex flex-col gap-2">
|
<fieldset class="flex flex-col gap-2">
|
||||||
<div class="flex flex-row gap-2 justify-center">
|
<div class="flex flex-row gap-1.5 justify-center">
|
||||||
<div class="flex flex-col gap-2 grow">
|
<div class="flex flex-col gap-1 grow">
|
||||||
<Label for="speed" class="flex flex-row">
|
<Label for="speed" class="flex flex-row">
|
||||||
<Zap size="16" />
|
<Zap size="16" />
|
||||||
{#if $velocityUnits === 'speed'}
|
{#if $velocityUnits === 'speed'}
|
||||||
@@ -239,7 +239,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2 grow">
|
<div class="flex flex-col gap-1 grow">
|
||||||
<Label for="duration" class="flex flex-row">
|
<Label for="duration" class="flex flex-row">
|
||||||
<Timer size="16" />
|
<Timer size="16" />
|
||||||
{i18n._('toolbar.time.total_time')}
|
{i18n._('toolbar.time.total_time')}
|
||||||
@@ -253,11 +253,12 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
<Label class="flex flex-row">
|
<Label class="flex flex-row">
|
||||||
<CirclePlay size="16" />
|
<CirclePlay size="16" />
|
||||||
{i18n._('toolbar.time.start')}
|
{i18n._('toolbar.time.start')}
|
||||||
</Label>
|
</Label>
|
||||||
<div class="flex flex-row gap-2">
|
<div class="flex flex-row gap-1.5">
|
||||||
<DatePicker
|
<DatePicker
|
||||||
bind:value={startDate}
|
bind:value={startDate}
|
||||||
disabled={!canUpdate}
|
disabled={!canUpdate}
|
||||||
@@ -279,11 +280,13 @@
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
<Label class="flex flex-row">
|
<Label class="flex flex-row">
|
||||||
<CircleStop size="16" />
|
<CircleStop size="16" />
|
||||||
{i18n._('toolbar.time.end')}
|
{i18n._('toolbar.time.end')}
|
||||||
</Label>
|
</Label>
|
||||||
<div class="flex flex-row gap-2">
|
<div class="flex flex-row gap-1.5">
|
||||||
<DatePicker
|
<DatePicker
|
||||||
bind:value={endDate}
|
bind:value={endDate}
|
||||||
disabled={!canUpdate}
|
disabled={!canUpdate}
|
||||||
@@ -305,6 +308,7 @@
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{#if $gpxStatistics.global.time.moving === 0 || $gpxStatistics.global.time.moving === undefined}
|
{#if $gpxStatistics.global.time.moving === 0 || $gpxStatistics.global.time.moving === undefined}
|
||||||
<div class="mt-0.5 flex flex-row gap-1 items-center">
|
<div class="mt-0.5 flex flex-row gap-1 items-center">
|
||||||
<Checkbox id="artificial-time" bind:checked={artificial} disabled={!canUpdate} />
|
<Checkbox id="artificial-time" bind:checked={artificial} disabled={!canUpdate} />
|
||||||
@@ -314,11 +318,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<div class="flex flex-row gap-2 items-center">
|
<div class="flex flex-row gap-1.5 items-center">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={!canUpdate}
|
disabled={!canUpdate}
|
||||||
class="grow whitespace-normal h-fit"
|
class="grow shrink whitespace-normal h-fit min-h-8 py-1"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
let effectiveSpeed = getSpeed();
|
let effectiveSpeed = getSpeed();
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
let props: { class?: string } = $props();
|
let props: { class?: string } = $props();
|
||||||
|
|
||||||
let sliderValue = $state([50]);
|
let sliderValue = $state(50);
|
||||||
const maxTolerance = 10000;
|
const maxTolerance = 10000;
|
||||||
|
|
||||||
let validSelection = $derived(
|
let validSelection = $derived(
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
tolerance.set(
|
tolerance.set(
|
||||||
minTolerance * 2 ** (sliderValue[0] / (100 / Math.log2(maxTolerance / minTolerance)))
|
minTolerance * 2 ** (sliderValue / (100 / Math.log2(maxTolerance / minTolerance)))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
|
|
||||||
<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 ?? ''}">
|
||||||
<div class="p-2">
|
<div class="p-2">
|
||||||
<Slider bind:value={sliderValue} min={0} max={100} step={1} type="multiple" />
|
<Slider bind:value={sliderValue} min={0} max={100} step={1} type="single" />
|
||||||
</div>
|
</div>
|
||||||
<Label class="flex flex-row justify-between">
|
<Label class="flex flex-row justify-between">
|
||||||
<span>{i18n._('toolbar.reduce.tolerance')}</span>
|
<span>{i18n._('toolbar.reduce.tolerance')}</span>
|
||||||
|
|||||||
@@ -163,6 +163,9 @@ export class ReducedGPXLayerCollection {
|
|||||||
reduce() {
|
reduce() {
|
||||||
let itemsAndPoints = new Map<ListItem, TrackPoint[]>();
|
let itemsAndPoints = new Map<ListItem, TrackPoint[]>();
|
||||||
this._simplified.forEach(([item, maxPts, points], itemFullId) => {
|
this._simplified.forEach(([item, maxPts, points], itemFullId) => {
|
||||||
|
if (!get(selection).hasAnyParent(item)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
itemsAndPoints.set(
|
itemsAndPoints.set(
|
||||||
item,
|
item,
|
||||||
points
|
points
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
SquareArrowUpLeft,
|
SquareArrowUpLeft,
|
||||||
SquareArrowOutDownRight,
|
SquareArrowOutDownRight,
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
import { brouterProfiles } from '$lib/components/toolbar/tools/routing/routing';
|
import { routingProfiles } from '$lib/components/toolbar/tools/routing/routing';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import {
|
import {
|
||||||
@@ -167,7 +167,7 @@
|
|||||||
{i18n._(`toolbar.routing.activities.${$routingProfile}`)}
|
{i18n._(`toolbar.routing.activities.${$routingProfile}`)}
|
||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
<Select.Content>
|
<Select.Content>
|
||||||
{#each Object.keys(brouterProfiles) as profile}
|
{#each Object.keys(routingProfiles) as profile}
|
||||||
<Select.Item value={profile}
|
<Select.Item value={profile}
|
||||||
>{i18n._(
|
>{i18n._(
|
||||||
`toolbar.routing.activities.${profile}`
|
`toolbar.routing.activities.${profile}`
|
||||||
@@ -191,7 +191,7 @@
|
|||||||
<ButtonWithTooltip
|
<ButtonWithTooltip
|
||||||
label={i18n._('toolbar.routing.reverse.tooltip')}
|
label={i18n._('toolbar.routing.reverse.tooltip')}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="gap-1 text-xs"
|
class="gap-1 text-xs px-1.5 py-1.5 h-fit"
|
||||||
disabled={!validSelection}
|
disabled={!validSelection}
|
||||||
onclick={fileActions.reverseSelection}
|
onclick={fileActions.reverseSelection}
|
||||||
>
|
>
|
||||||
@@ -200,7 +200,7 @@
|
|||||||
<ButtonWithTooltip
|
<ButtonWithTooltip
|
||||||
label={i18n._('toolbar.routing.route_back_to_start.tooltip')}
|
label={i18n._('toolbar.routing.route_back_to_start.tooltip')}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="gap-1 text-xs"
|
class="gap-1 text-xs px-1.5 py-1.5 h-fit"
|
||||||
disabled={!validSelection}
|
disabled={!validSelection}
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
const selected = selection.getOrderedSelection();
|
const selected = selection.getOrderedSelection();
|
||||||
@@ -236,14 +236,14 @@
|
|||||||
<ButtonWithTooltip
|
<ButtonWithTooltip
|
||||||
label={i18n._('toolbar.routing.round_trip.tooltip')}
|
label={i18n._('toolbar.routing.round_trip.tooltip')}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="gap-1 text-xs"
|
class="gap-1 text-xs px-1.5 py-1.5 h-fit"
|
||||||
disabled={!validSelection}
|
disabled={!validSelection}
|
||||||
onclick={fileActions.createRoundTripForSelection}
|
onclick={fileActions.createRoundTripForSelection}
|
||||||
>
|
>
|
||||||
<Repeat class="size-3" />{i18n._('toolbar.routing.round_trip.button')}
|
<Repeat class="size-3" />{i18n._('toolbar.routing.round_trip.button')}
|
||||||
</ButtonWithTooltip>
|
</ButtonWithTooltip>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full flex flex-row gap-2 items-end justify-between">
|
<div class="w-full flex flex-row gap-1 items-end justify-between">
|
||||||
<Help link={getURLForLanguage(i18n.lang, '/help/toolbar/routing')}>
|
<Help link={getURLForLanguage(i18n.lang, '/help/toolbar/routing')}>
|
||||||
{#if !validSelection}
|
{#if !validSelection}
|
||||||
{i18n._('toolbar.routing.help_no_file')}
|
{i18n._('toolbar.routing.help_no_file')}
|
||||||
|
|||||||
@@ -6,37 +6,213 @@ import { get } from 'svelte/store';
|
|||||||
|
|
||||||
const { routing, routingProfile, privateRoads } = settings;
|
const { routing, routingProfile, privateRoads } = settings;
|
||||||
|
|
||||||
export const brouterProfiles: { [key: string]: string } = {
|
export type RoutingProfile = {
|
||||||
bike: 'Trekking-dry',
|
engine: 'graphhopper' | 'brouter';
|
||||||
racing_bike: 'fastbike',
|
profile: string;
|
||||||
gravel_bike: 'gravel',
|
};
|
||||||
mountain_bike: 'MTB',
|
|
||||||
foot: 'Hiking-Alpine-SAC6',
|
export const routingProfiles: { [key: string]: RoutingProfile } = {
|
||||||
motorcycle: 'Car-FastEco',
|
bike: { engine: 'graphhopper', profile: 'bike' },
|
||||||
water: 'river',
|
racing_bike: { engine: 'graphhopper', profile: 'racingbike' },
|
||||||
railway: 'rail',
|
gravel_bike: { engine: 'graphhopper', profile: 'gravelbike' },
|
||||||
|
mountain_bike: { engine: 'graphhopper', profile: 'mtb' },
|
||||||
|
foot: { engine: 'graphhopper', profile: 'foot' },
|
||||||
|
motorcycle: { engine: 'graphhopper', profile: 'motorbike' },
|
||||||
|
water: { engine: 'brouter', profile: 'river' },
|
||||||
|
railway: { engine: 'brouter', profile: 'rail' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function route(points: Coordinates[]): Promise<TrackPoint[]> {
|
export function route(points: Coordinates[]): Promise<TrackPoint[]> {
|
||||||
if (get(routing)) {
|
if (get(routing)) {
|
||||||
return getRoute(points, brouterProfiles[get(routingProfile)], get(privateRoads));
|
const profile = routingProfiles[get(routingProfile)];
|
||||||
|
if (profile.engine === 'graphhopper') {
|
||||||
|
return getGraphHopperRoute(points, profile.profile, get(privateRoads));
|
||||||
|
} else {
|
||||||
|
return getBRouterRoute(points, profile.profile);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return getIntermediatePoints(points);
|
return getIntermediatePoints(points);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getRoute(
|
const graphhopperDetails = ['road_class', 'surface', 'hike_rating', 'mtb_rating'];
|
||||||
|
const hikeRatingToSACScale: { [key: string]: string } = {
|
||||||
|
'1': 'hiking',
|
||||||
|
'2': 'mountain_hiking',
|
||||||
|
'3': 'demanding_mountain_hiking',
|
||||||
|
'4': 'alpine_hiking',
|
||||||
|
'5': 'demanding_alpine_hiking',
|
||||||
|
'6': 'difficult_alpine_hiking',
|
||||||
|
};
|
||||||
|
const mtbRatingToScale: { [key: string]: string } = {
|
||||||
|
'1': '0',
|
||||||
|
'2': '1',
|
||||||
|
'3': '2',
|
||||||
|
'4': '3',
|
||||||
|
'5': '4',
|
||||||
|
'6': '5',
|
||||||
|
'7': '6',
|
||||||
|
};
|
||||||
|
|
||||||
|
const graphhopperBlockPrivateCustomModels: { [key: string]: any } = {
|
||||||
|
bike: {
|
||||||
|
priority: [
|
||||||
|
{
|
||||||
|
if: 'bike_road_access == PRIVATE',
|
||||||
|
multiply_by: '0.0',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
racingbike: {
|
||||||
|
priority: [
|
||||||
|
{
|
||||||
|
if: 'bike_road_access == PRIVATE',
|
||||||
|
multiply_by: '0.0',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
gravelbike: {
|
||||||
|
priority: [
|
||||||
|
{
|
||||||
|
if: 'bike_road_access == PRIVATE',
|
||||||
|
multiply_by: '0.0',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
mtb: {
|
||||||
|
priority: [
|
||||||
|
{
|
||||||
|
if: 'bike_road_access == PRIVATE',
|
||||||
|
multiply_by: '0.0',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
foot: {
|
||||||
|
priority: [
|
||||||
|
{
|
||||||
|
if: 'foot_road_access == PRIVATE',
|
||||||
|
multiply_by: '0.0',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
motorcycle: {
|
||||||
|
priority: [
|
||||||
|
{
|
||||||
|
if: 'road_access == PRIVATE',
|
||||||
|
multiply_by: '0.0',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
async function getGraphHopperRoute(
|
||||||
points: Coordinates[],
|
points: Coordinates[],
|
||||||
brouterProfile: string,
|
graphHopperProfile: string,
|
||||||
privateRoads: boolean
|
privateRoads: boolean
|
||||||
): Promise<TrackPoint[]> {
|
): Promise<TrackPoint[]> {
|
||||||
let url = `https://brouter.gpx.studio?lonlats=${points.map((point) => `${point.lon.toFixed(8)},${point.lat.toFixed(8)}`).join('|')}&profile=${brouterProfile + (privateRoads ? '-private' : '')}&format=geojson&alternativeidx=0`;
|
let response = await fetch('https://graphhopper.gpx.studio/route', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
points: points.map((point) => [point.lon, point.lat]),
|
||||||
|
profile: graphHopperProfile,
|
||||||
|
elevation: true,
|
||||||
|
points_encoded: false,
|
||||||
|
details: graphhopperDetails,
|
||||||
|
custom_model: privateRoads
|
||||||
|
? {}
|
||||||
|
: graphhopperBlockPrivateCustomModels[graphHopperProfile] || {},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
if (error.message.includes('Cannot find point 0')) {
|
||||||
|
throw new Error('toolbar.routing.error.from');
|
||||||
|
} else if (error.message.includes('Cannot find point 1')) {
|
||||||
|
if (points.length == 3) {
|
||||||
|
throw new Error('toolbar.routing.error.via');
|
||||||
|
} else {
|
||||||
|
throw new Error('toolbar.routing.error.to');
|
||||||
|
}
|
||||||
|
} else if (error.hints[0].details.includes('PointDistanceExceededException')) {
|
||||||
|
throw new Error('toolbar.routing.error.distance');
|
||||||
|
} else if (error.hints[0].details.includes('ConnectionNotFoundException')) {
|
||||||
|
throw new Error('toolbar.routing.error.connection');
|
||||||
|
} else {
|
||||||
|
throw new Error(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let json = await response.json();
|
||||||
|
|
||||||
|
let route: TrackPoint[] = [];
|
||||||
|
let coordinates = json.paths[0].points.coordinates;
|
||||||
|
let details = json.paths[0].details;
|
||||||
|
|
||||||
|
for (let i = 0; i < coordinates.length; i++) {
|
||||||
|
route.push(
|
||||||
|
new TrackPoint({
|
||||||
|
attributes: {
|
||||||
|
lat: coordinates[i][1],
|
||||||
|
lon: coordinates[i][0],
|
||||||
|
},
|
||||||
|
ele: coordinates[i][2] ?? (i > 0 ? route[i - 1].ele : 0),
|
||||||
|
extensions: {},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let key of graphhopperDetails) {
|
||||||
|
let detail = details[key];
|
||||||
|
for (let i = 0; i < detail.length; i++) {
|
||||||
|
for (let j = detail[i][0]; j < detail[i][1] + (i == detail.length - 1); j++) {
|
||||||
|
if (detail[i][2] !== undefined && detail[i][2] !== 'missing') {
|
||||||
|
if (key === 'road_class') {
|
||||||
|
route[j].setExtension('highway', detail[i][2]);
|
||||||
|
} else if (key === 'hike_rating') {
|
||||||
|
const sacScale = hikeRatingToSACScale[detail[i][2]];
|
||||||
|
if (sacScale) {
|
||||||
|
route[j].setExtension('sac_scale', sacScale);
|
||||||
|
}
|
||||||
|
} else if (key === 'mtb_rating') {
|
||||||
|
const mtbScale = mtbRatingToScale[detail[i][2]];
|
||||||
|
if (mtbScale) {
|
||||||
|
route[j].setExtension('mtb_scale', mtbScale);
|
||||||
|
}
|
||||||
|
} else if (key === 'surface' && detail[i][2] !== 'other') {
|
||||||
|
route[j].setExtension('surface', detail[i][2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return route;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBRouterRoute(
|
||||||
|
points: Coordinates[],
|
||||||
|
brouterProfile: string
|
||||||
|
): Promise<TrackPoint[]> {
|
||||||
|
let url = `https://brouter.de/brouter?lonlats=${points.map((point) => `${point.lon.toFixed(8)},${point.lat.toFixed(8)}`).join('|')}&profile=${brouterProfile}&format=geojson&alternativeidx=0`;
|
||||||
|
|
||||||
let response = await fetch(url);
|
let response = await fetch(url);
|
||||||
|
|
||||||
// Check if the response is ok
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`${await response.text()}`);
|
const error = await response.text();
|
||||||
|
if (error.includes('from-position not mapped in existing datafile')) {
|
||||||
|
throw new Error('toolbar.routing.error.from');
|
||||||
|
} else if (error.includes('via1-position not mapped in existing datafile')) {
|
||||||
|
throw new Error('toolbar.routing.error.via');
|
||||||
|
} else if (error.includes('to-position not mapped in existing datafile')) {
|
||||||
|
throw new Error('toolbar.routing.error.to');
|
||||||
|
} else if (error.includes('Time-out')) {
|
||||||
|
throw new Error('toolbar.routing.error.timeout');
|
||||||
|
} else {
|
||||||
|
throw new Error(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let geojson = await response.json();
|
let geojson = await response.json();
|
||||||
@@ -52,14 +228,13 @@ async function getRoute(
|
|||||||
let tags = messageIdx < messages.length ? getTags(messages[messageIdx][tagIdx]) : {};
|
let tags = messageIdx < messages.length ? getTags(messages[messageIdx][tagIdx]) : {};
|
||||||
|
|
||||||
for (let i = 0; i < coordinates.length; i++) {
|
for (let i = 0; i < coordinates.length; i++) {
|
||||||
let coord = coordinates[i];
|
|
||||||
route.push(
|
route.push(
|
||||||
new TrackPoint({
|
new TrackPoint({
|
||||||
attributes: {
|
attributes: {
|
||||||
lat: coord[1],
|
lat: coordinates[i][1],
|
||||||
lon: coord[0],
|
lon: coordinates[i][0],
|
||||||
},
|
},
|
||||||
ele: coord[2] ?? (i > 0 ? route[i - 1].ele : 0),
|
ele: coordinates[i][2] ?? (i > 0 ? route[i - 1].ele : 0),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -2,15 +2,21 @@ import { ramerDouglasPeucker, type GPXFile, type TrackSegment } from 'gpx';
|
|||||||
|
|
||||||
const earthRadius = 6371008.8;
|
const earthRadius = 6371008.8;
|
||||||
|
|
||||||
|
export const MIN_ANCHOR_ZOOM = 0;
|
||||||
|
export const MAX_ANCHOR_ZOOM = 22;
|
||||||
|
|
||||||
export function getZoomLevelForDistance(latitude: number, distance?: number): number {
|
export function getZoomLevelForDistance(latitude: number, distance?: number): number {
|
||||||
if (distance === undefined) {
|
if (distance === undefined) {
|
||||||
return 0;
|
return MIN_ANCHOR_ZOOM;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rad = Math.PI / 180;
|
const rad = Math.PI / 180;
|
||||||
const lat = latitude * rad;
|
const lat = latitude * rad;
|
||||||
|
|
||||||
return Math.min(22, Math.max(0, Math.log2((earthRadius * Math.cos(lat)) / distance)));
|
return Math.min(
|
||||||
|
MAX_ANCHOR_ZOOM,
|
||||||
|
Math.max(MIN_ANCHOR_ZOOM, Math.round(Math.log2((earthRadius * Math.cos(lat)) / distance)))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateAnchorPoints(file: GPXFile) {
|
export function updateAnchorPoints(file: GPXFile) {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if ($map) {
|
if ($map) {
|
||||||
splitControls = new SplitControls($map);
|
splitControls = new SplitControls($map, map.layerEventManager!);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,39 +10,31 @@ import { fileActions } from '$lib/logic/file-actions';
|
|||||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
||||||
import type { GeoJSONSource } from 'maplibre-gl';
|
import type { GeoJSONSource } from 'maplibre-gl';
|
||||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||||
|
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
|
||||||
|
import { loadSVGIcon } from '$lib/utils';
|
||||||
|
|
||||||
export class SplitControls {
|
export class SplitControls {
|
||||||
map: maplibregl.Map;
|
map: maplibregl.Map;
|
||||||
|
layerEventManager: MapLayerEventManager;
|
||||||
unsubscribes: Function[] = [];
|
unsubscribes: Function[] = [];
|
||||||
|
|
||||||
layerOnMouseEnterBinded: (e: any) => void = this.layerOnMouseEnter.bind(this);
|
layerOnMouseEnterBinded: (e: any) => void = this.layerOnMouseEnter.bind(this);
|
||||||
layerOnMouseLeaveBinded: () => void = this.layerOnMouseLeave.bind(this);
|
layerOnMouseLeaveBinded: () => void = this.layerOnMouseLeave.bind(this);
|
||||||
layerOnClickBinded: (e: any) => void = this.layerOnClick.bind(this);
|
layerOnClickBinded: (e: any) => void = this.layerOnClick.bind(this);
|
||||||
|
|
||||||
constructor(map: maplibregl.Map) {
|
constructor(map: maplibregl.Map, layerEventManager: MapLayerEventManager) {
|
||||||
this.map = map;
|
this.map = map;
|
||||||
|
this.layerEventManager = layerEventManager;
|
||||||
if (!this.map.hasImage('split-control')) {
|
loadSVGIcon(
|
||||||
let icon = new Image(100, 100);
|
this.map,
|
||||||
icon.onload = () => {
|
'split-control',
|
||||||
if (!this.map.hasImage('split-control')) {
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
|
||||||
this.map.addImage('split-control', icon);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Lucide icons are SVG files with a 24x24 viewBox
|
|
||||||
// Create a new SVG with a 32x32 viewBox and center the icon in a circle
|
|
||||||
icon.src =
|
|
||||||
'data:image/svg+xml,' +
|
|
||||||
encodeURIComponent(`
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
|
|
||||||
<circle cx="20" cy="20" r="20" fill="white" />
|
<circle cx="20" cy="20" r="20" fill="white" />
|
||||||
<g transform="translate(8 8)">
|
<g transform="translate(8 8)">
|
||||||
${Scissors.replace('stroke="currentColor"', 'stroke="black"')}
|
${Scissors.replace('stroke="currentColor"', 'stroke="black"')}
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>`
|
||||||
`);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
this.unsubscribes.push(gpxStatistics.subscribe(this.addIfNeeded.bind(this)));
|
this.unsubscribes.push(gpxStatistics.subscribe(this.addIfNeeded.bind(this)));
|
||||||
this.unsubscribes.push(currentTool.subscribe(this.addIfNeeded.bind(this)));
|
this.unsubscribes.push(currentTool.subscribe(this.addIfNeeded.bind(this)));
|
||||||
@@ -125,9 +117,17 @@ export class SplitControls {
|
|||||||
ANCHOR_LAYER_KEY.interactions
|
ANCHOR_LAYER_KEY.interactions
|
||||||
);
|
);
|
||||||
|
|
||||||
this.map.on('mouseenter', 'split-controls', this.layerOnMouseEnterBinded);
|
this.layerEventManager.on(
|
||||||
this.map.on('mouseleave', 'split-controls', this.layerOnMouseLeaveBinded);
|
'mouseenter',
|
||||||
this.map.on('click', 'split-controls', this.layerOnClickBinded);
|
'split-controls',
|
||||||
|
this.layerOnMouseEnterBinded
|
||||||
|
);
|
||||||
|
this.layerEventManager.on(
|
||||||
|
'mouseleave',
|
||||||
|
'split-controls',
|
||||||
|
this.layerOnMouseLeaveBinded
|
||||||
|
);
|
||||||
|
this.layerEventManager.on('click', 'split-controls', this.layerOnClickBinded);
|
||||||
}
|
}
|
||||||
} 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
|
||||||
@@ -135,9 +135,9 @@ export class SplitControls {
|
|||||||
}
|
}
|
||||||
|
|
||||||
remove() {
|
remove() {
|
||||||
this.map.off('mouseenter', 'split-controls', this.layerOnMouseEnterBinded);
|
this.layerEventManager.off('mouseenter', 'split-controls', this.layerOnMouseEnterBinded);
|
||||||
this.map.off('mouseleave', 'split-controls', this.layerOnMouseLeaveBinded);
|
this.layerEventManager.off('mouseleave', 'split-controls', this.layerOnMouseLeaveBinded);
|
||||||
this.map.off('click', 'split-controls', this.layerOnClickBinded);
|
this.layerEventManager.off('click', 'split-controls', this.layerOnClickBinded);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (this.map.getLayer('split-controls')) {
|
if (this.map.getLayer('split-controls')) {
|
||||||
|
|||||||
@@ -161,14 +161,17 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-3 w-full max-w-96 {props.class ?? ''}">
|
<div class="flex flex-col gap-3 w-full max-w-96 {props.class ?? ''}">
|
||||||
<fieldset class="flex flex-col gap-2">
|
<fieldset class="flex flex-col gap-1.5">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
<Label for="name">{i18n._('menu.metadata.name')}</Label>
|
<Label for="name">{i18n._('menu.metadata.name')}</Label>
|
||||||
<Input
|
<Input
|
||||||
bind:value={name}
|
bind:value={name}
|
||||||
id="name"
|
id="name"
|
||||||
class="font-semibold h-8"
|
class="font-semibold"
|
||||||
disabled={!canCreate && !$selectedWaypoint}
|
disabled={!canCreate && !$selectedWaypoint}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
<Label for="description">{i18n._('menu.metadata.description')}</Label>
|
<Label for="description">{i18n._('menu.metadata.description')}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
bind:value={description}
|
bind:value={description}
|
||||||
@@ -176,11 +179,12 @@
|
|||||||
disabled={!canCreate && !$selectedWaypoint}
|
disabled={!canCreate && !$selectedWaypoint}
|
||||||
class="min-h-8 h-8 py-1 px-3 text-sm"
|
class="min-h-8 h-8 py-1 px-3 text-sm"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
<Label for="symbol">{i18n._('toolbar.waypoint.icon')}</Label>
|
<Label for="symbol">{i18n._('toolbar.waypoint.icon')}</Label>
|
||||||
<Select.Root bind:value={sym} type="single">
|
<Select.Root bind:value={sym} type="single">
|
||||||
<Select.Trigger
|
<Select.Trigger
|
||||||
id="symbol"
|
id="symbol"
|
||||||
size="sm"
|
|
||||||
class="w-full"
|
class="w-full"
|
||||||
disabled={!canCreate && !$selectedWaypoint}
|
disabled={!canCreate && !$selectedWaypoint}
|
||||||
>
|
>
|
||||||
@@ -196,7 +200,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
<Select.Content class="max-h-60 overflow-y-scroll">
|
<Select.Content class="max-h-60">
|
||||||
{#each sortedSymbols as [key, symbol]}
|
{#each sortedSymbols as [key, symbol]}
|
||||||
<Select.Item value={symbol.value}>
|
<Select.Item value={symbol.value}>
|
||||||
<span>
|
<span>
|
||||||
@@ -212,6 +216,8 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</Select.Content>
|
</Select.Content>
|
||||||
</Select.Root>
|
</Select.Root>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
<Label for="link">{i18n._('toolbar.waypoint.link')}</Label>
|
<Label for="link">{i18n._('toolbar.waypoint.link')}</Label>
|
||||||
<Input
|
<Input
|
||||||
bind:value={link}
|
bind:value={link}
|
||||||
@@ -219,8 +225,9 @@
|
|||||||
class="h-8"
|
class="h-8"
|
||||||
disabled={!canCreate && !$selectedWaypoint}
|
disabled={!canCreate && !$selectedWaypoint}
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-row gap-2">
|
</div>
|
||||||
<div class="grow flex flex-col gap-2">
|
<div class="flex flex-row gap-1.5">
|
||||||
|
<div class="grow flex flex-col gap-1">
|
||||||
<Label for="latitude">{i18n._('toolbar.waypoint.latitude')}</Label>
|
<Label for="latitude">{i18n._('toolbar.waypoint.latitude')}</Label>
|
||||||
<Input
|
<Input
|
||||||
bind:value={latitude}
|
bind:value={latitude}
|
||||||
@@ -233,7 +240,7 @@
|
|||||||
disabled={!canCreate && !$selectedWaypoint}
|
disabled={!canCreate && !$selectedWaypoint}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="grow flex flex-col gap-2">
|
<div class="grow flex flex-col gap-1">
|
||||||
<Label for="longitude">{i18n._('toolbar.waypoint.longitude')}</Label>
|
<Label for="longitude">{i18n._('toolbar.waypoint.longitude')}</Label>
|
||||||
<Input
|
<Input
|
||||||
bind:value={longitude}
|
bind:value={longitude}
|
||||||
@@ -248,11 +255,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<div class="flex flex-row gap-2 items-center">
|
<div class="flex flex-row gap-1.5 items-center">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={!canCreate && !$selectedWaypoint}
|
disabled={!canCreate && !$selectedWaypoint}
|
||||||
class="grow whitespace-normal h-fit"
|
class="grow shrink h-fit min-h-8 whitespace-normal py-1"
|
||||||
onclick={createOrUpdateWaypoint}
|
onclick={createOrUpdateWaypoint}
|
||||||
>
|
>
|
||||||
{#if $selectedWaypoint}
|
{#if $selectedWaypoint}
|
||||||
|
|||||||
@@ -13,10 +13,15 @@
|
|||||||
<AccordionPrimitive.Content
|
<AccordionPrimitive.Content
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="accordion-content"
|
data-slot="accordion-content"
|
||||||
class="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
class="data-open:animate-accordion-down data-closed:animate-accordion-up text-sm overflow-hidden"
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
<div class={cn("pb-4 pt-0", className)}>
|
<div
|
||||||
|
class={cn(
|
||||||
|
"pt-0 pb-2.5 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3 [&_p:not(:last-child)]:mb-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
</AccordionPrimitive.Content>
|
</AccordionPrimitive.Content>
|
||||||
|
|||||||
@@ -12,6 +12,6 @@
|
|||||||
<AccordionPrimitive.Item
|
<AccordionPrimitive.Item
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="accordion-item"
|
data-slot="accordion-item"
|
||||||
class={cn("border-b last:border-b-0", className)}
|
class={cn("not-last:border-b", className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Accordion as AccordionPrimitive } from "bits-ui";
|
import { Accordion as AccordionPrimitive } from "bits-ui";
|
||||||
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
|
||||||
import { cn, type WithoutChild } from "$lib/utils.js";
|
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||||
|
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||||
|
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -19,14 +20,13 @@
|
|||||||
data-slot="accordion-trigger"
|
data-slot="accordion-trigger"
|
||||||
bind:ref
|
bind:ref
|
||||||
class={cn(
|
class={cn(
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium outline-none transition-all hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
"focus-visible:ring-ring/50 focus-visible:border-ring focus-visible:after:border-ring **:data-[slot=accordion-trigger-icon]:text-muted-foreground rounded-lg py-2.5 text-left text-sm font-medium hover:underline focus-visible:ring-3 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 group/accordion-trigger relative flex flex-1 items-start justify-between border border-transparent transition-all outline-none disabled:pointer-events-none disabled:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon data-slot="accordion-trigger-icon" class="cn-accordion-trigger-icon pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
|
||||||
class="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200"
|
<ChevronUpIcon data-slot="accordion-trigger-icon" class="cn-accordion-trigger-icon pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
|
||||||
/>
|
|
||||||
</AccordionPrimitive.Trigger>
|
</AccordionPrimitive.Trigger>
|
||||||
</AccordionPrimitive.Header>
|
</AccordionPrimitive.Header>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Accordion as AccordionPrimitive } from "bits-ui";
|
import { Accordion as AccordionPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
value = $bindable(),
|
value = $bindable(),
|
||||||
|
class: className,
|
||||||
...restProps
|
...restProps
|
||||||
}: AccordionPrimitive.RootProps = $props();
|
}: AccordionPrimitive.RootProps = $props();
|
||||||
</script>
|
</script>
|
||||||
@@ -12,5 +14,6 @@
|
|||||||
bind:ref
|
bind:ref
|
||||||
bind:value={value as never}
|
bind:value={value as never}
|
||||||
data-slot="accordion"
|
data-slot="accordion"
|
||||||
|
class={cn("cn-accordion flex w-full flex-col", className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,18 +1,27 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||||
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
import {
|
||||||
|
buttonVariants,
|
||||||
|
type ButtonVariant,
|
||||||
|
type ButtonSize,
|
||||||
|
} from "$lib/components/ui/button/index.js";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
...restProps
|
...restProps
|
||||||
}: AlertDialogPrimitive.ActionProps = $props();
|
}: AlertDialogPrimitive.ActionProps & {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
size?: ButtonSize;
|
||||||
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AlertDialogPrimitive.Action
|
<AlertDialogPrimitive.Action
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="alert-dialog-action"
|
data-slot="alert-dialog-action"
|
||||||
class={cn(buttonVariants(), className)}
|
class={cn(buttonVariants({ variant, size }), "cn-alert-dialog-action", className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,18 +1,27 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||||
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
import {
|
||||||
|
buttonVariants,
|
||||||
|
type ButtonVariant,
|
||||||
|
type ButtonSize,
|
||||||
|
} from "$lib/components/ui/button/index.js";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "default",
|
||||||
...restProps
|
...restProps
|
||||||
}: AlertDialogPrimitive.CancelProps = $props();
|
}: AlertDialogPrimitive.CancelProps & {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
size?: ButtonSize;
|
||||||
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AlertDialogPrimitive.Cancel
|
<AlertDialogPrimitive.Cancel
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="alert-dialog-cancel"
|
data-slot="alert-dialog-cancel"
|
||||||
class={cn(buttonVariants({ variant: "outline" }), className)}
|
class={cn(buttonVariants({ variant, size }), "cn-alert-dialog-cancel", className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,27 +1,32 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||||
|
import AlertDialogPortal from "./alert-dialog-portal.svelte";
|
||||||
import AlertDialogOverlay from "./alert-dialog-overlay.svelte";
|
import AlertDialogOverlay from "./alert-dialog-overlay.svelte";
|
||||||
import { cn, type WithoutChild, type WithoutChildrenOrChild } from "$lib/utils.js";
|
import { cn, type WithoutChild, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
|
import type { ComponentProps } from "svelte";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
|
size = "default",
|
||||||
portalProps,
|
portalProps,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithoutChild<AlertDialogPrimitive.ContentProps> & {
|
}: WithoutChild<AlertDialogPrimitive.ContentProps> & {
|
||||||
portalProps?: WithoutChildrenOrChild<AlertDialogPrimitive.PortalProps>;
|
size?: "default" | "sm";
|
||||||
|
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof AlertDialogPortal>>;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AlertDialogPrimitive.Portal {...portalProps}>
|
<AlertDialogPortal {...portalProps}>
|
||||||
<AlertDialogOverlay />
|
<AlertDialogOverlay />
|
||||||
<AlertDialogPrimitive.Content
|
<AlertDialogPrimitive.Content
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="alert-dialog-content"
|
data-slot="alert-dialog-content"
|
||||||
|
data-size={size}
|
||||||
class={cn(
|
class={cn(
|
||||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-popover text-popover-foreground ring-foreground/10 gap-4 rounded-xl p-4 ring-1 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 outline-none",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
</AlertDialogPrimitive.Portal>
|
</AlertDialogPortal>
|
||||||
|
|||||||
@@ -12,6 +12,6 @@
|
|||||||
<AlertDialogPrimitive.Description
|
<AlertDialogPrimitive.Description
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="alert-dialog-description"
|
data-slot="alert-dialog-description"
|
||||||
class={cn("text-muted-foreground text-sm", className)}
|
class={cn("text-muted-foreground *:[a]:hover:text-foreground text-sm text-balance md:text-pretty *:[a]:underline *:[a]:underline-offset-3", className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -13,7 +13,10 @@
|
|||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="alert-dialog-footer"
|
data-slot="alert-dialog-footer"
|
||||||
class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
class={cn(
|
||||||
|
"bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="alert-dialog-header"
|
data-slot="alert-dialog-header"
|
||||||
class={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
class={cn("grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]", className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="alert-dialog-media"
|
||||||
|
class={cn("bg-muted mb-2 inline-flex size-10 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -12,9 +12,6 @@
|
|||||||
<AlertDialogPrimitive.Overlay
|
<AlertDialogPrimitive.Overlay
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="alert-dialog-overlay"
|
data-slot="alert-dialog-overlay"
|
||||||
class={cn(
|
class={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50", className)}
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||||
|
|
||||||
type $$Props = AlertDialogPrimitive.PortalProps;
|
let { ...restProps }: AlertDialogPrimitive.PortalProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AlertDialogPrimitive.Portal {...$$restProps}>
|
<AlertDialogPrimitive.Portal {...restProps} />
|
||||||
<slot />
|
|
||||||
</AlertDialogPrimitive.Portal>
|
|
||||||
|
|||||||
@@ -12,6 +12,6 @@
|
|||||||
<AlertDialogPrimitive.Title
|
<AlertDialogPrimitive.Title
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="alert-dialog-title"
|
data-slot="alert-dialog-title"
|
||||||
class={cn("text-lg font-semibold", className)}
|
class={cn("text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2", className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { open = $bindable(false), ...restProps }: AlertDialogPrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AlertDialogPrimitive.Root bind:open {...restProps} />
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
import Root from "./alert-dialog.svelte";
|
||||||
|
import Portal from "./alert-dialog-portal.svelte";
|
||||||
import Trigger from "./alert-dialog-trigger.svelte";
|
import Trigger from "./alert-dialog-trigger.svelte";
|
||||||
import Title from "./alert-dialog-title.svelte";
|
import Title from "./alert-dialog-title.svelte";
|
||||||
import Action from "./alert-dialog-action.svelte";
|
import Action from "./alert-dialog-action.svelte";
|
||||||
@@ -8,9 +9,7 @@ import Header from "./alert-dialog-header.svelte";
|
|||||||
import Overlay from "./alert-dialog-overlay.svelte";
|
import Overlay from "./alert-dialog-overlay.svelte";
|
||||||
import Content from "./alert-dialog-content.svelte";
|
import Content from "./alert-dialog-content.svelte";
|
||||||
import Description from "./alert-dialog-description.svelte";
|
import Description from "./alert-dialog-description.svelte";
|
||||||
|
import Media from "./alert-dialog-media.svelte";
|
||||||
const Root = AlertDialogPrimitive.Root;
|
|
||||||
const Portal = AlertDialogPrimitive.Portal;
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
@@ -24,6 +23,7 @@ export {
|
|||||||
Overlay,
|
Overlay,
|
||||||
Content,
|
Content,
|
||||||
Description,
|
Description,
|
||||||
|
Media,
|
||||||
//
|
//
|
||||||
Root as AlertDialog,
|
Root as AlertDialog,
|
||||||
Title as AlertDialogTitle,
|
Title as AlertDialogTitle,
|
||||||
@@ -36,4 +36,5 @@ export {
|
|||||||
Overlay as AlertDialogOverlay,
|
Overlay as AlertDialogOverlay,
|
||||||
Content as AlertDialogContent,
|
Content as AlertDialogContent,
|
||||||
Description as AlertDialogDescription,
|
Description as AlertDialogDescription,
|
||||||
|
Media as AlertDialogMedia,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="alert-action"
|
||||||
|
class={cn("absolute top-2 right-2", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="alert-description"
|
data-slot="alert-description"
|
||||||
class={cn(
|
class={cn(
|
||||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
"text-muted-foreground text-sm text-balance md:text-pretty [&_p:not(:last-child)]:mb-4 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|||||||
@@ -13,7 +13,10 @@
|
|||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="alert-title"
|
data-slot="alert-title"
|
||||||
class={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)}
|
class={cn(
|
||||||
|
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|||||||
@@ -2,12 +2,11 @@
|
|||||||
import { type VariantProps, tv } from "tailwind-variants";
|
import { type VariantProps, tv } from "tailwind-variants";
|
||||||
|
|
||||||
export const alertVariants = tv({
|
export const alertVariants = tv({
|
||||||
base: "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
base: "grid gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4 group/alert relative w-full",
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-card text-card-foreground",
|
default: "bg-card text-card-foreground",
|
||||||
destructive:
|
destructive: "text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
|
||||||
"text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
@@ -36,9 +35,9 @@
|
|||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="alert"
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
class={cn(alertVariants({ variant }), className)}
|
class={cn(alertVariants({ variant }), className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
role="alert"
|
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import Root from "./alert.svelte";
|
import Root from "./alert.svelte";
|
||||||
import Description from "./alert-description.svelte";
|
import Description from "./alert-description.svelte";
|
||||||
import Title from "./alert-title.svelte";
|
import Title from "./alert-title.svelte";
|
||||||
|
import Action from "./alert-action.svelte";
|
||||||
export { alertVariants, type AlertVariant } from "./alert.svelte";
|
export { alertVariants, type AlertVariant } from "./alert.svelte";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
Description,
|
Description,
|
||||||
Title,
|
Title,
|
||||||
|
Action,
|
||||||
//
|
//
|
||||||
Root as Alert,
|
Root as Alert,
|
||||||
Description as AlertDescription,
|
Description as AlertDescription,
|
||||||
Title as AlertTitle,
|
Title as AlertTitle,
|
||||||
|
Action as AlertAction,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,25 +4,25 @@
|
|||||||
import { type VariantProps, tv } from "tailwind-variants";
|
import { type VariantProps, tv } from "tailwind-variants";
|
||||||
|
|
||||||
export const buttonVariants = tv({
|
export const buttonVariants = tv({
|
||||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 active:not-aria-[haspopup]:translate-y-px aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
destructive:
|
outline: "border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||||
"bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white",
|
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||||
outline:
|
ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||||
"bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border",
|
destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
|
||||||
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
icon: "size-9",
|
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
"icon-sm": "size-8",
|
icon: "size-8",
|
||||||
"icon-lg": "size-10",
|
"icon-xs": "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||||
|
"icon-lg": "size-9",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<CalendarPrimitive.Cell
|
<CalendarPrimitive.Cell
|
||||||
bind:ref
|
bind:ref
|
||||||
class={cn(
|
class={cn(
|
||||||
"size-(--cell-size) relative p-0 text-center text-sm focus-within:z-20 [&:first-child[data-selected]_[data-bits-day]]:rounded-l-md [&:last-child[data-selected]_[data-bits-day]]:rounded-r-md",
|
"relative size-(--cell-size) p-0 text-center text-sm focus-within:z-20 [&:first-child[data-selected]_[data-bits-day]]:rounded-s-(--cell-radius) [&:last-child[data-selected]_[data-bits-day]]:rounded-e-(--cell-radius)",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
|
||||||
@@ -13,18 +12,17 @@
|
|||||||
<CalendarPrimitive.Day
|
<CalendarPrimitive.Day
|
||||||
bind:ref
|
bind:ref
|
||||||
class={cn(
|
class={cn(
|
||||||
buttonVariants({ variant: "ghost" }),
|
"flex size-(--cell-size) flex-col items-center justify-center gap-1 rounded-(--cell-radius) p-0 leading-none font-normal whitespace-nowrap select-none",
|
||||||
"size-(--cell-size) flex select-none flex-col items-center justify-center gap-1 whitespace-nowrap p-0 font-normal leading-none",
|
"[&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
|
||||||
|
"not-data-selected:hover:bg-accent/50 not-data-selected:hover:text-accent-foreground",
|
||||||
"[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground [&[data-today][data-disabled]]:text-muted-foreground",
|
"[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground [&[data-today][data-disabled]]:text-muted-foreground",
|
||||||
"data-[selected]:bg-primary dark:data-[selected]:hover:bg-accent/50 data-[selected]:text-primary-foreground",
|
"data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:hover:text-foreground",
|
||||||
// Outside months
|
// Outside months
|
||||||
"[&[data-outside-month]:not([data-selected])]:text-muted-foreground [&[data-outside-month]:not([data-selected])]:hover:text-accent-foreground",
|
"[&[data-outside-month]:not([data-selected])]:text-muted-foreground [&[data-outside-month]:not([data-selected])]:hover:text-accent-foreground",
|
||||||
// Disabled
|
// Disabled
|
||||||
"data-[disabled]:text-muted-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
"data-[disabled]:text-muted-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
// Unavailable
|
// Unavailable
|
||||||
"data-[unavailable]:text-muted-foreground data-[unavailable]:line-through",
|
"data-[unavailable]:text-muted-foreground data-[unavailable]:line-through",
|
||||||
// hover
|
|
||||||
"dark:hover:text-accent-foreground",
|
|
||||||
// focus
|
// focus
|
||||||
"focus:border-ring focus:ring-ring/50 focus:relative",
|
"focus:border-ring focus:ring-ring/50 focus:relative",
|
||||||
// inner spans
|
// inner spans
|
||||||
|
|||||||
@@ -11,6 +11,6 @@
|
|||||||
|
|
||||||
<CalendarPrimitive.Grid
|
<CalendarPrimitive.Grid
|
||||||
bind:ref
|
bind:ref
|
||||||
class={cn("mt-4 flex w-full border-collapse flex-col gap-1", className)}
|
class={cn("flex w-full border-collapse flex-col", className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<CalendarPrimitive.Header
|
<CalendarPrimitive.Header
|
||||||
bind:ref
|
bind:ref
|
||||||
class={cn(
|
class={cn(
|
||||||
"h-(--cell-size) flex w-full items-center justify-center gap-1.5 text-sm font-medium",
|
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|||||||
@@ -14,11 +14,15 @@
|
|||||||
|
|
||||||
<span
|
<span
|
||||||
class={cn(
|
class={cn(
|
||||||
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative flex rounded-md border",
|
"has-focus:border-ring border-input has-focus:ring-ring/50 relative flex rounded-md border shadow-xs has-focus:ring-[3px]",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CalendarPrimitive.MonthSelect bind:ref class="absolute inset-0 opacity-0" {...restProps}>
|
<CalendarPrimitive.MonthSelect
|
||||||
|
bind:ref
|
||||||
|
class="bg-background dark:bg-popover dark:text-popover-foreground absolute inset-0 opacity-0"
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
{#snippet child({ props, monthItems, selectedMonthItem })}
|
{#snippet child({ props, monthItems, selectedMonthItem })}
|
||||||
<select {...props} {value} {onchange}>
|
<select {...props} {value} {onchange}>
|
||||||
{#each monthItems as monthItem (monthItem.value)}
|
{#each monthItems as monthItem (monthItem.value)}
|
||||||
@@ -33,7 +37,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
<span
|
<span
|
||||||
class="[&>svg]:text-muted-foreground flex h-8 select-none items-center gap-1 rounded-md pl-2 pr-1 text-sm font-medium [&>svg]:size-3.5"
|
class="[&>svg]:text-muted-foreground flex h-(--cell-size) items-center gap-1 rounded-md ps-2 pe-1 text-sm font-medium select-none [&>svg]:size-3.5"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{monthItems.find((item) => item.value === value)?.label || selectedMonthItem.label}
|
{monthItems.find((item) => item.value === value)?.label || selectedMonthItem.label}
|
||||||
|
|||||||