mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2025-08-30 23:30:04 +00:00
progress
This commit is contained in:
@@ -9,7 +9,7 @@
|
|||||||
"files": "**/*.svelte",
|
"files": "**/*.svelte",
|
||||||
"options": {
|
"options": {
|
||||||
"plugins": ["prettier-plugin-svelte"],
|
"plugins": ["prettier-plugin-svelte"],
|
||||||
"parser": "svelte"
|
"parser": "svelte"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
44
README.md
44
README.md
@@ -26,8 +26,9 @@ Any help is greatly appreciated!
|
|||||||
## Development
|
## Development
|
||||||
|
|
||||||
The code is split into two parts:
|
The code is split into two parts:
|
||||||
- `gpx`: a Typescript library for parsing and manipulating GPX files,
|
|
||||||
- `website`: the website itself, which is a [SvelteKit](https://kit.svelte.dev/) application.
|
- `gpx`: a Typescript library for parsing and manipulating GPX files,
|
||||||
|
- `website`: the website itself, which is a [SvelteKit](https://kit.svelte.dev/) application.
|
||||||
|
|
||||||
You will need [Node.js](https://nodejs.org/) to build and run these two parts.
|
You will need [Node.js](https://nodejs.org/) to build and run these two parts.
|
||||||
|
|
||||||
@@ -54,26 +55,25 @@ npm run dev
|
|||||||
|
|
||||||
This project has been made possible thanks to the following open source projects:
|
This project has been made possible thanks to the following open source projects:
|
||||||
|
|
||||||
- Development:
|
- Development:
|
||||||
- [Svelte](https://github.com/sveltejs/svelte) and [SvelteKit](https://github.com/sveltejs/kit) — seamless development experience
|
- [Svelte](https://github.com/sveltejs/svelte) and [SvelteKit](https://github.com/sveltejs/kit) — seamless development experience
|
||||||
- [MDsveX](https://github.com/pngwn/MDsveX) — allowing a Markdown-based documentation
|
- [MDsveX](https://github.com/pngwn/MDsveX) — allowing a Markdown-based documentation
|
||||||
- [svelte-i18n](https://github.com/kaisermann/svelte-i18n) — easy localization
|
- Design:
|
||||||
- Design:
|
- [shadcn-svelte](https://github.com/huntabyte/shadcn-svelte) — beautiful components
|
||||||
- [shadcn-svelte](https://github.com/huntabyte/shadcn-svelte) — beautiful components
|
- [@lucide/svelte](https://github.com/lucide-icons/lucide/tree/main/packages/svelte) — beautiful icons
|
||||||
- [lucide-svelte](https://github.com/lucide-icons/lucide/tree/main/packages/lucide-svelte) — beautiful icons
|
- [tailwindcss](https://github.com/tailwindlabs/tailwindcss) — easy styling
|
||||||
- [tailwindcss](https://github.com/tailwindlabs/tailwindcss) — easy styling
|
- [Chart.js](https://github.com/chartjs/Chart.js) — beautiful and fast charts
|
||||||
- [Chart.js](https://github.com/chartjs/Chart.js) — beautiful and fast charts
|
- Logic:
|
||||||
- Logic:
|
- [immer](https://github.com/immerjs/immer) — complex state management
|
||||||
- [immer](https://github.com/immerjs/immer) — complex state management
|
- [Dexie.js](https://github.com/dexie/Dexie.js) — IndexedDB wrapper
|
||||||
- [Dexie.js](https://github.com/dexie/Dexie.js) — IndexedDB wrapper
|
- [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:
|
- [Mapbox GL JS](https://github.com/mapbox/mapbox-gl-js) — beautiful and fast interactive maps
|
||||||
- [Mapbox GL JS](https://github.com/mapbox/mapbox-gl-js) — beautiful and fast interactive maps
|
- [brouter](https://github.com/abrensch/brouter) — routing engine
|
||||||
- [brouter](https://github.com/abrensch/brouter) — routing engine
|
- [OpenStreetMap](https://www.openstreetmap.org) — map data used by Mapbox and brouter
|
||||||
- [OpenStreetMap](https://www.openstreetmap.org) — map data used by Mapbox and brouter
|
- Search:
|
||||||
- Search:
|
- [DocSearch](https://github.com/algolia/docsearch) — search engine for the documentation
|
||||||
- [DocSearch](https://github.com/algolia/docsearch) — search engine for the documentation
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
@@ -1 +0,0 @@
|
|||||||
package-lock.json
|
|
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://next.shadcn-svelte.com/schema.json",
|
"$schema": "https://next.shadcn-svelte.com/schema.json",
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"css": "src/app.pcss",
|
"css": "src/app.css",
|
||||||
"baseColor": "slate"
|
"baseColor": "slate"
|
||||||
},
|
},
|
||||||
"aliases": {
|
"aliases": {
|
||||||
@@ -12,5 +12,5 @@
|
|||||||
"lib": "$lib"
|
"lib": "$lib"
|
||||||
},
|
},
|
||||||
"typescript": true,
|
"typescript": true,
|
||||||
"registry": "https://tw3.shadcn-svelte.com/registry/default"
|
"registry": "https://next.shadcn-svelte.com/registry"
|
||||||
}
|
}
|
||||||
|
1464
website/package-lock.json
generated
1464
website/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,8 +5,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"prebuild": "npx tsx src/lib/pwa-manifest.ts",
|
"prebuild": "npx tsx src/lib/scripts/pwa-manifest.ts",
|
||||||
"postbuild": "npx tsx src/lib/sitemap.ts",
|
"postbuild": "npx tsx src/lib/scripts/sitemap.ts",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
@@ -14,11 +14,12 @@
|
|||||||
"format": "prettier --write ."
|
"format": "prettier --write ."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lucide/svelte": "^0.482.0",
|
"@lucide/svelte": "^0.513.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",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.1.0",
|
"@sveltejs/vite-plugin-svelte": "^5.1.0",
|
||||||
|
"@tailwindcss/vite": "^4.1.8",
|
||||||
"@types/eslint": "^9.6.1",
|
"@types/eslint": "^9.6.1",
|
||||||
"@types/events": "^3.0.3",
|
"@types/events": "^3.0.3",
|
||||||
"@types/file-saver": "^2.0.7",
|
"@types/file-saver": "^2.0.7",
|
||||||
@@ -30,8 +31,7 @@
|
|||||||
"@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",
|
||||||
"autoprefixer": "^10.4.21",
|
"bits-ui": "^2.5.0",
|
||||||
"bits-ui": "^1.8.0",
|
|
||||||
"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",
|
||||||
@@ -46,10 +46,11 @@
|
|||||||
"prettier-plugin-svelte": "^3.4.0",
|
"prettier-plugin-svelte": "^3.4.0",
|
||||||
"svelte": "^5.33.18",
|
"svelte": "^5.33.18",
|
||||||
"svelte-check": "^4.0.0",
|
"svelte-check": "^4.0.0",
|
||||||
"svelte-sonner": "^0.3.28",
|
"svelte-sonner": "^1.0.4",
|
||||||
"tailwindcss": "^3.4.13",
|
"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",
|
||||||
"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",
|
||||||
@@ -76,8 +77,7 @@
|
|||||||
"png.js": "^0.2.1",
|
"png.js": "^0.2.1",
|
||||||
"sanitize-html": "^2.17.0",
|
"sanitize-html": "^2.17.0",
|
||||||
"sortablejs": "^1.15.6",
|
"sortablejs": "^1.15.6",
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwind-merge": "^3.3.0",
|
||||||
"tailwind-variants": "^0.2.1",
|
"tailwind-variants": "^1.0.0"
|
||||||
"tailwindcss-animate": "^1.0.7"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
122
website/src/app.css
Normal file
122
website/src/app.css
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: hsl(0 0% 100%) /* <- Wrap in HSL */;
|
||||||
|
--foreground: hsl(240 10% 3.9%);
|
||||||
|
--muted: hsl(240 4.8% 95.9%);
|
||||||
|
--muted-foreground: hsl(240 3.8% 46.1%);
|
||||||
|
--popover: hsl(0 0% 100%);
|
||||||
|
--popover-foreground: hsl(240 10% 3.9%);
|
||||||
|
--card: hsl(0 0% 100%);
|
||||||
|
--card-foreground: hsl(240 10% 3.9%);
|
||||||
|
--border: hsl(240 5.9% 90%);
|
||||||
|
--input: hsl(240 5.9% 90%);
|
||||||
|
--primary: hsl(240 5.9% 10%);
|
||||||
|
--primary-foreground: hsl(0 0% 98%);
|
||||||
|
--secondary: hsl(240 4.8% 95.9%);
|
||||||
|
--secondary-foreground: hsl(240 5.9% 10%);
|
||||||
|
--accent: hsl(240 4.8% 95.9%);
|
||||||
|
--accent-foreground: hsl(240 5.9% 10%);
|
||||||
|
--destructive: hsl(0 72.2% 50.6%);
|
||||||
|
--destructive-foreground: hsl(0 0% 98%);
|
||||||
|
--ring: hsl(240 10% 3.9%);
|
||||||
|
--sidebar: hsl(0 0% 98%);
|
||||||
|
--sidebar-foreground: hsl(240 5.3% 26.1%);
|
||||||
|
--sidebar-primary: hsl(240 5.9% 10%);
|
||||||
|
--sidebar-primary-foreground: hsl(0 0% 98%);
|
||||||
|
--sidebar-accent: hsl(240 4.8% 95.9%);
|
||||||
|
--sidebar-accent-foreground: hsl(240 5.9% 10%);
|
||||||
|
--sidebar-border: hsl(220 13% 91%);
|
||||||
|
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||||
|
|
||||||
|
--support: rgb(220 15 130);
|
||||||
|
--link: rgb(0 110 180);
|
||||||
|
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: hsl(240 10% 3.9%);
|
||||||
|
--foreground: hsl(0 0% 98%);
|
||||||
|
--muted: hsl(240 3.7% 15.9%);
|
||||||
|
--muted-foreground: hsl(240 5% 64.9%);
|
||||||
|
--popover: hsl(240 10% 3.9%);
|
||||||
|
--popover-foreground: hsl(0 0% 98%);
|
||||||
|
--card: hsl(240 10% 3.9%);
|
||||||
|
--card-foreground: hsl(0 0% 98%);
|
||||||
|
--border: hsl(240 3.7% 15.9%);
|
||||||
|
--input: hsl(240 3.7% 15.9%);
|
||||||
|
--primary: hsl(0 0% 98%);
|
||||||
|
--primary-foreground: hsl(240 5.9% 10%);
|
||||||
|
--secondary: hsl(240 3.7% 15.9%);
|
||||||
|
--secondary-foreground: hsl(0 0% 98%);
|
||||||
|
--accent: hsl(240 3.7% 15.9%);
|
||||||
|
--accent-foreground: hsl(0 0% 98%);
|
||||||
|
--destructive: hsl(0 62.8% 30.6%);
|
||||||
|
--destructive-foreground: hsl(0 0% 98%);
|
||||||
|
--ring: hsl(240 4.9% 83.9%);
|
||||||
|
--sidebar: hsl(240 5.9% 10%);
|
||||||
|
--sidebar-foreground: hsl(240 4.8% 95.9%);
|
||||||
|
--sidebar-primary: hsl(224.3 76.3% 48%);
|
||||||
|
--sidebar-primary-foreground: hsl(0 0% 100%);
|
||||||
|
--sidebar-accent: hsl(240 3.7% 15.9%);
|
||||||
|
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
|
||||||
|
--sidebar-border: hsl(240 3.7% 15.9%);
|
||||||
|
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||||
|
|
||||||
|
--support: rgb(255 110 190);
|
||||||
|
--link: rgb(80 190 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
/* Radius (for rounded-*) */
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
|
||||||
|
/* Colors */
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-radius: var(--radius);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-support: var(--support);
|
||||||
|
--color-link: var(--link);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
@@ -1,86 +0,0 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
:root {
|
|
||||||
--background: 0 0% 100%;
|
|
||||||
--foreground: 222.2 84% 4.9%;
|
|
||||||
|
|
||||||
--muted: 210 40% 96.1%;
|
|
||||||
--muted-foreground: 215.4 16.3% 45%;
|
|
||||||
|
|
||||||
--popover: 0 0% 100%;
|
|
||||||
--popover-foreground: 222.2 84% 4.9%;
|
|
||||||
|
|
||||||
--card: 0 0% 100%;
|
|
||||||
--card-foreground: 222.2 84% 4.9%;
|
|
||||||
|
|
||||||
--border: 214.3 31.8% 91.4%;
|
|
||||||
--input: 214.3 31.8% 91.4%;
|
|
||||||
|
|
||||||
--primary: 222.2 47.4% 11.2%;
|
|
||||||
--primary-foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--secondary: 210 40% 96.1%;
|
|
||||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
|
||||||
|
|
||||||
--accent: 210 40% 92%;
|
|
||||||
--accent-foreground: 222.2 47.4% 11.2%;
|
|
||||||
|
|
||||||
--destructive: 0 72.2% 50.6%;
|
|
||||||
--destructive-foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--support: 220 15 130;
|
|
||||||
|
|
||||||
--link: 0 110 180;
|
|
||||||
|
|
||||||
--ring: 222.2 84% 4.9%;
|
|
||||||
|
|
||||||
--radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
--background: 222.2 84% 4.9%;
|
|
||||||
--foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--muted: 217.2 32.6% 17.5%;
|
|
||||||
--muted-foreground: 215 20.2% 65.1%;
|
|
||||||
|
|
||||||
--popover: 222.2 84% 4.9%;
|
|
||||||
--popover-foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--card: 222.2 84% 4.9%;
|
|
||||||
--card-foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--border: 217.2 32.6% 17.5%;
|
|
||||||
--input: 217.2 32.6% 17.5%;
|
|
||||||
|
|
||||||
--primary: 210 40% 98%;
|
|
||||||
--primary-foreground: 222.2 47.4% 11.2%;
|
|
||||||
|
|
||||||
--secondary: 217.2 32.6% 17.5%;
|
|
||||||
--secondary-foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--accent: 217.2 32.6% 30%;
|
|
||||||
--accent-foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--destructive: 0 62.8% 30.6%;
|
|
||||||
--destructive-foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--support: 255 110 190;
|
|
||||||
|
|
||||||
--link: 80 190 255;
|
|
||||||
|
|
||||||
--ring: hsl(212.7, 26.8%, 83.9);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
* {
|
|
||||||
@apply border-border;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
@apply bg-background text-foreground;
|
|
||||||
}
|
|
||||||
}
|
|
@@ -41,9 +41,11 @@ export async function handle({ event, resolve }) {
|
|||||||
<link rel="alternate" hreflang="x-default" href="https://gpx.studio${getURLForLanguage('en', path)}" />
|
<link rel="alternate" hreflang="x-default" href="https://gpx.studio${getURLForLanguage('en', path)}" />
|
||||||
<link rel="manifest" href="/${language}.manifest.webmanifest" />`;
|
<link rel="manifest" href="/${language}.manifest.webmanifest" />`;
|
||||||
|
|
||||||
for (let lang of Object.keys(languages)) {
|
if (page !== '404') {
|
||||||
headTag += ` <link rel="alternate" hreflang="${lang}" href="https://gpx.studio${getURLForLanguage(lang, path)}" />
|
for (let lang of Object.keys(languages)) {
|
||||||
|
headTag += ` <link rel="alternate" hreflang="${lang}" href="https://gpx.studio${getURLForLanguage(lang, path)}" />
|
||||||
`;
|
`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await resolve(event, {
|
const response = await resolve(event, {
|
||||||
|
@@ -29,7 +29,8 @@ import {
|
|||||||
TriangleAlert,
|
TriangleAlert,
|
||||||
Anchor,
|
Anchor,
|
||||||
Toilet,
|
Toilet,
|
||||||
} from 'lucide-svelte';
|
type IconProps,
|
||||||
|
} from '@lucide/svelte';
|
||||||
import {
|
import {
|
||||||
Landmark as LandmarkSvg,
|
Landmark as LandmarkSvg,
|
||||||
Shell as ShellSvg,
|
Shell as ShellSvg,
|
||||||
@@ -61,11 +62,11 @@ import {
|
|||||||
Anchor as AnchorSvg,
|
Anchor as AnchorSvg,
|
||||||
Toilet as ToiletSvg,
|
Toilet as ToiletSvg,
|
||||||
} from 'lucide-static';
|
} from 'lucide-static';
|
||||||
import type { ComponentType } from 'svelte';
|
import type { Component } from 'svelte';
|
||||||
|
|
||||||
export type Symbol = {
|
export type Symbol = {
|
||||||
value: string;
|
value: string;
|
||||||
icon?: ComponentType<Icon>;
|
icon?: Component<IconProps>;
|
||||||
iconSvg?: string;
|
iconSvg?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -2,7 +2,11 @@
|
|||||||
import docsearch from '@docsearch/js';
|
import docsearch from '@docsearch/js';
|
||||||
import '@docsearch/css';
|
import '@docsearch/css';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { _, locale, isLoadingLocale } from '$lib/i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
|
||||||
|
let props: {
|
||||||
|
class?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let mounted = false;
|
let mounted = false;
|
||||||
|
|
||||||
@@ -13,31 +17,31 @@
|
|||||||
indexName: 'gpx',
|
indexName: 'gpx',
|
||||||
container: '#docsearch',
|
container: '#docsearch',
|
||||||
searchParameters: {
|
searchParameters: {
|
||||||
facetFilters: ['lang:' + $locale],
|
facetFilters: ['lang:' + i18n.lang],
|
||||||
},
|
},
|
||||||
placeholder: $_('docs.search.search'),
|
placeholder: i18n._('docs.search.search'),
|
||||||
disableUserPersonalization: true,
|
disableUserPersonalization: true,
|
||||||
translations: {
|
translations: {
|
||||||
button: {
|
button: {
|
||||||
buttonText: $_('docs.search.search'),
|
buttonText: i18n._('docs.search.search'),
|
||||||
buttonAriaLabel: $_('docs.search.search'),
|
buttonAriaLabel: i18n._('docs.search.search'),
|
||||||
},
|
},
|
||||||
modal: {
|
modal: {
|
||||||
searchBox: {
|
searchBox: {
|
||||||
resetButtonTitle: $_('docs.search.clear'),
|
resetButtonTitle: i18n._('docs.search.clear'),
|
||||||
resetButtonAriaLabel: $_('docs.search.clear'),
|
resetButtonAriaLabel: i18n._('docs.search.clear'),
|
||||||
cancelButtonText: $_('docs.search.cancel'),
|
cancelButtonText: i18n._('docs.search.cancel'),
|
||||||
cancelButtonAriaLabel: $_('docs.search.cancel'),
|
cancelButtonAriaLabel: i18n._('docs.search.cancel'),
|
||||||
searchInputLabel: $_('docs.search.search'),
|
searchInputLabel: i18n._('docs.search.search'),
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
selectText: $_('docs.search.to_select'),
|
selectText: i18n._('docs.search.to_select'),
|
||||||
navigateText: $_('docs.search.to_navigate'),
|
navigateText: i18n._('docs.search.to_navigate'),
|
||||||
closeText: $_('docs.search.to_close'),
|
closeText: i18n._('docs.search.to_close'),
|
||||||
},
|
},
|
||||||
noResultsScreen: {
|
noResultsScreen: {
|
||||||
noResultsText: $_('docs.search.no_results'),
|
noResultsText: i18n._('docs.search.no_results'),
|
||||||
suggestedQueryText: $_('docs.search.no_results_suggestion'),
|
suggestedQueryText: i18n._('docs.search.no_results_suggestion'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -48,13 +52,15 @@
|
|||||||
mounted = true;
|
mounted = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
$: if (mounted && $locale && !$isLoadingLocale) {
|
$effect(() => {
|
||||||
initDocsearch();
|
if (mounted && i18n.lang && !i18n.isLoading) {
|
||||||
}
|
initDocsearch();
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="preconnect" href="https://21XLD94PE3-dsn.algolia.net" crossorigin />
|
<link rel="preconnect" href="https://21XLD94PE3-dsn.algolia.net" crossorigin />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div id="docsearch" {...$$restProps}></div>
|
<div id="docsearch" class={props.class ?? ''}></div>
|
||||||
|
@@ -1,28 +1,36 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||||
import type { Builder } from 'bits-ui';
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
export let variant:
|
const {
|
||||||
| 'default'
|
variant = 'default',
|
||||||
| 'secondary'
|
label,
|
||||||
| 'link'
|
side = 'top',
|
||||||
| 'destructive'
|
class: className = '',
|
||||||
| 'outline'
|
children,
|
||||||
| 'ghost'
|
onclick,
|
||||||
| undefined = 'default';
|
}: {
|
||||||
export let label: string;
|
variant?: 'default' | 'secondary' | 'link' | 'destructive' | 'outline' | 'ghost';
|
||||||
export let side: 'top' | 'right' | 'bottom' | 'left' = 'top';
|
label: string;
|
||||||
export let builders: Builder[] = [];
|
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||||
|
class?: string;
|
||||||
|
children: Snippet;
|
||||||
|
onclick?: (event: MouseEvent) => void;
|
||||||
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Tooltip.Root>
|
<Tooltip.Provider>
|
||||||
<Tooltip.Trigger asChild let:builder>
|
<Tooltip.Root>
|
||||||
<Button builders={[...builders, builder]} {variant} {...$$restProps} on:click>
|
<Tooltip.Trigger>
|
||||||
<slot />
|
{#snippet child({ props })}
|
||||||
</Button>
|
<Button {...props} {variant} class={className} {onclick}>
|
||||||
</Tooltip.Trigger>
|
{@render children()}
|
||||||
<Tooltip.Content {side}>
|
</Button>
|
||||||
<span>{label}</span>
|
{/snippet}
|
||||||
</Tooltip.Content>
|
</Tooltip.Trigger>
|
||||||
</Tooltip.Root>
|
<Tooltip.Content {side}>
|
||||||
|
<span>{label}</span>
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Root>
|
||||||
|
</Tooltip.Provider>
|
||||||
|
@@ -1,18 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { map } from '$lib/stores';
|
|
||||||
import { trackpointPopup } from '$lib/components/gpx-layer/GPXLayerPopup';
|
|
||||||
import { TrackPoint } from 'gpx';
|
|
||||||
|
|
||||||
$: if ($map) {
|
|
||||||
$map.on('contextmenu', (e) => {
|
|
||||||
trackpointPopup?.setItem({
|
|
||||||
item: new TrackPoint({
|
|
||||||
attributes: {
|
|
||||||
lat: e.lngLat.lat,
|
|
||||||
lon: e.lngLat.lng,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
@@ -18,9 +18,9 @@
|
|||||||
Check,
|
Check,
|
||||||
ChartNoAxesColumn,
|
ChartNoAxesColumn,
|
||||||
Construction,
|
Construction,
|
||||||
} from 'lucide-svelte';
|
} from '@lucide/svelte';
|
||||||
import { getSlopeColor, getSurfaceColor, getHighwayColor } from '$lib/assets/colors';
|
import { getSlopeColor, getSurfaceColor, getHighwayColor } from '$lib/assets/colors';
|
||||||
import { _, df } from '$lib/i18n';
|
import { _, df } from '$lib/i18n.svelte';
|
||||||
import {
|
import {
|
||||||
getCadenceWithUnits,
|
getCadenceWithUnits,
|
||||||
getConvertedDistance,
|
getConvertedDistance,
|
||||||
@@ -120,17 +120,17 @@
|
|||||||
marker.addTo($map);
|
marker.addTo($map);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return `${$_('quantities.elevation')}: ${getElevationWithUnits(point.y, false)}`;
|
return `${i18n._('quantities.elevation')}: ${getElevationWithUnits(point.y, false)}`;
|
||||||
} else if (context.datasetIndex === 1) {
|
} else if (context.datasetIndex === 1) {
|
||||||
return `${$velocityUnits === 'speed' ? $_('quantities.speed') : $_('quantities.pace')}: ${getVelocityWithUnits(point.y, false)}`;
|
return `${$velocityUnits === 'speed' ? i18n._('quantities.speed') : i18n._('quantities.pace')}: ${getVelocityWithUnits(point.y, false)}`;
|
||||||
} else if (context.datasetIndex === 2) {
|
} else if (context.datasetIndex === 2) {
|
||||||
return `${$_('quantities.heartrate')}: ${getHeartRateWithUnits(point.y)}`;
|
return `${i18n._('quantities.heartrate')}: ${getHeartRateWithUnits(point.y)}`;
|
||||||
} else if (context.datasetIndex === 3) {
|
} else if (context.datasetIndex === 3) {
|
||||||
return `${$_('quantities.cadence')}: ${getCadenceWithUnits(point.y)}`;
|
return `${i18n._('quantities.cadence')}: ${getCadenceWithUnits(point.y)}`;
|
||||||
} else if (context.datasetIndex === 4) {
|
} else if (context.datasetIndex === 4) {
|
||||||
return `${$_('quantities.temperature')}: ${getTemperatureWithUnits(point.y, false)}`;
|
return `${i18n._('quantities.temperature')}: ${getTemperatureWithUnits(point.y, false)}`;
|
||||||
} else if (context.datasetIndex === 5) {
|
} else if (context.datasetIndex === 5) {
|
||||||
return `${$_('quantities.power')}: ${getPowerWithUnits(point.y)}`;
|
return `${i18n._('quantities.power')}: ${getPowerWithUnits(point.y)}`;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
afterBody: function (contexts: Chart.TooltipContext[]) {
|
afterBody: function (contexts: Chart.TooltipContext[]) {
|
||||||
@@ -152,31 +152,35 @@
|
|||||||
let mtbScale = point.extensions.mtb_scale;
|
let mtbScale = point.extensions.mtb_scale;
|
||||||
|
|
||||||
let labels = [
|
let labels = [
|
||||||
` ${$_('quantities.distance')}: ${getDistanceWithUnits(point.x, false)}`,
|
` ${i18n._('quantities.distance')}: ${getDistanceWithUnits(point.x, false)}`,
|
||||||
` ${$_('quantities.slope')}: ${slope.at} %${elevationFill === 'slope' ? ` (${slope.length} @${slope.segment} %)` : ''}`,
|
` ${i18n._('quantities.slope')}: ${slope.at} %${elevationFill === 'slope' ? ` (${slope.length} @${slope.segment} %)` : ''}`,
|
||||||
];
|
];
|
||||||
|
|
||||||
if (elevationFill === 'surface') {
|
if (elevationFill === 'surface') {
|
||||||
labels.push(
|
labels.push(
|
||||||
` ${$_('quantities.surface')}: ${$_(`toolbar.routing.surface.${surface}`)}`
|
` ${i18n._('quantities.surface')}: ${i18n._(`toolbar.routing.surface.${surface}`)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (elevationFill === 'highway') {
|
if (elevationFill === 'highway') {
|
||||||
labels.push(
|
labels.push(
|
||||||
` ${$_('quantities.highway')}: ${$_(`toolbar.routing.highway.${highway}`)}${
|
` ${i18n._('quantities.highway')}: ${i18n._(`toolbar.routing.highway.${highway}`)}${
|
||||||
sacScale
|
sacScale
|
||||||
? ` (${$_(`toolbar.routing.sac_scale.${sacScale}`)})`
|
? ` (${i18n._(`toolbar.routing.sac_scale.${sacScale}`)})`
|
||||||
: ''
|
: ''
|
||||||
}`
|
}`
|
||||||
);
|
);
|
||||||
if (mtbScale) {
|
if (mtbScale) {
|
||||||
labels.push(` ${$_('toolbar.routing.mtb_scale')}: ${mtbScale}`);
|
labels.push(
|
||||||
|
` ${i18n._('toolbar.routing.mtb_scale')}: ${mtbScale}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (point.time) {
|
if (point.time) {
|
||||||
labels.push(` ${$_('quantities.time')}: ${$df.format(point.time)}`);
|
labels.push(
|
||||||
|
` ${i18n._('quantities.time')}: ${$df.format(point.time)}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return labels;
|
return labels;
|
||||||
@@ -357,7 +361,7 @@
|
|||||||
|
|
||||||
// update data
|
// update data
|
||||||
chart.data.datasets[0] = {
|
chart.data.datasets[0] = {
|
||||||
label: $_('quantities.elevation'),
|
label: i18n._('quantities.elevation'),
|
||||||
data: data.local.points.map((point, index) => {
|
data: data.local.points.map((point, index) => {
|
||||||
return {
|
return {
|
||||||
x: getConvertedDistance(data.local.distance.total[index]),
|
x: getConvertedDistance(data.local.distance.total[index]),
|
||||||
@@ -552,15 +556,17 @@
|
|||||||
{#if showControls}
|
{#if showControls}
|
||||||
<div class="absolute bottom-10 right-1.5">
|
<div class="absolute bottom-10 right-1.5">
|
||||||
<Popover.Root>
|
<Popover.Root>
|
||||||
<Popover.Trigger asChild let:builder>
|
<Popover.Trigger>
|
||||||
<ButtonWithTooltip
|
{#snippet child({ props })}
|
||||||
label={$_('chart.settings')}
|
<ButtonWithTooltip
|
||||||
builders={[builder]}
|
{...props}
|
||||||
variant="outline"
|
label={i18n._('chart.settings')}
|
||||||
class="w-7 h-7 p-0 flex justify-center opacity-70 hover:opacity-100 transition-opacity duration-300 hover:bg-background"
|
variant="outline"
|
||||||
>
|
class="w-7 h-7 p-0 flex justify-center opacity-70 hover:opacity-100 transition-opacity duration-300 hover:bg-background"
|
||||||
<ChartNoAxesColumn size="18" />
|
>
|
||||||
</ButtonWithTooltip>
|
<ChartNoAxesColumn size="18" />
|
||||||
|
</ButtonWithTooltip>
|
||||||
|
{/snippet}
|
||||||
</Popover.Trigger>
|
</Popover.Trigger>
|
||||||
<Popover.Content
|
<Popover.Content
|
||||||
class="w-fit p-0 flex flex-col divide-y"
|
class="w-fit p-0 flex flex-col divide-y"
|
||||||
@@ -582,7 +588,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<TriangleRight size="15" class="mr-1" />
|
<TriangleRight size="15" class="mr-1" />
|
||||||
{$_('quantities.slope')}
|
{i18n._('quantities.slope')}
|
||||||
</ToggleGroup.Item>
|
</ToggleGroup.Item>
|
||||||
<ToggleGroup.Item
|
<ToggleGroup.Item
|
||||||
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
||||||
@@ -595,7 +601,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<BrickWall size="15" class="mr-1" />
|
<BrickWall size="15" class="mr-1" />
|
||||||
{$_('quantities.surface')}
|
{i18n._('quantities.surface')}
|
||||||
</ToggleGroup.Item>
|
</ToggleGroup.Item>
|
||||||
<ToggleGroup.Item
|
<ToggleGroup.Item
|
||||||
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
||||||
@@ -608,7 +614,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<Construction size="15" class="mr-1" />
|
<Construction size="15" class="mr-1" />
|
||||||
{$_('quantities.highway')}
|
{i18n._('quantities.highway')}
|
||||||
</ToggleGroup.Item>
|
</ToggleGroup.Item>
|
||||||
</ToggleGroup.Root>
|
</ToggleGroup.Root>
|
||||||
<ToggleGroup.Root
|
<ToggleGroup.Root
|
||||||
@@ -627,8 +633,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<Zap size="15" class="mr-1" />
|
<Zap size="15" class="mr-1" />
|
||||||
{$velocityUnits === 'speed'
|
{$velocityUnits === 'speed'
|
||||||
? $_('quantities.speed')
|
? i18n._('quantities.speed')
|
||||||
: $_('quantities.pace')}
|
: i18n._('quantities.pace')}
|
||||||
</ToggleGroup.Item>
|
</ToggleGroup.Item>
|
||||||
<ToggleGroup.Item
|
<ToggleGroup.Item
|
||||||
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
||||||
@@ -640,7 +646,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<HeartPulse size="15" class="mr-1" />
|
<HeartPulse size="15" class="mr-1" />
|
||||||
{$_('quantities.heartrate')}
|
{i18n._('quantities.heartrate')}
|
||||||
</ToggleGroup.Item>
|
</ToggleGroup.Item>
|
||||||
<ToggleGroup.Item
|
<ToggleGroup.Item
|
||||||
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
||||||
@@ -652,7 +658,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<Orbit size="15" class="mr-1" />
|
<Orbit size="15" class="mr-1" />
|
||||||
{$_('quantities.cadence')}
|
{i18n._('quantities.cadence')}
|
||||||
</ToggleGroup.Item>
|
</ToggleGroup.Item>
|
||||||
<ToggleGroup.Item
|
<ToggleGroup.Item
|
||||||
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
||||||
@@ -664,7 +670,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<Thermometer size="15" class="mr-1" />
|
<Thermometer size="15" class="mr-1" />
|
||||||
{$_('quantities.temperature')}
|
{i18n._('quantities.temperature')}
|
||||||
</ToggleGroup.Item>
|
</ToggleGroup.Item>
|
||||||
<ToggleGroup.Item
|
<ToggleGroup.Item
|
||||||
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
||||||
@@ -676,7 +682,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<SquareActivity size="15" class="mr-1" />
|
<SquareActivity size="15" class="mr-1" />
|
||||||
{$_('quantities.power')}
|
{i18n._('quantities.power')}
|
||||||
</ToggleGroup.Item>
|
</ToggleGroup.Item>
|
||||||
</ToggleGroup.Root>
|
</ToggleGroup.Root>
|
||||||
</Popover.Content>
|
</Popover.Content>
|
||||||
|
@@ -2,8 +2,8 @@
|
|||||||
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 Logo from '$lib/components/Logo.svelte';
|
import Logo from '$lib/components/Logo.svelte';
|
||||||
import { AtSign, BookOpenText, Heart, Home, Map } from 'lucide-svelte';
|
import { AtSign, BookOpenText, Heart, Home, Map } from '@lucide/svelte';
|
||||||
import { _, locale } from '$lib/i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -24,42 +24,42 @@
|
|||||||
</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">
|
||||||
<span class="font-semibold">{$_('homepage.website')}</span>
|
<span class="font-semibold">{i18n._('homepage.website')}</span>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
class="h-6 px-0 text-muted-foreground"
|
class="h-6 px-0 text-muted-foreground"
|
||||||
href={getURLForLanguage($locale, '/')}
|
href={getURLForLanguage(i18n.lang, '/')}
|
||||||
>
|
>
|
||||||
<Home size="16" class="mr-1" />
|
<Home size="16" />
|
||||||
{$_('homepage.home')}
|
{i18n._('homepage.home')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
class="h-6 px-0 text-muted-foreground"
|
class="h-6 px-0 text-muted-foreground"
|
||||||
href={getURLForLanguage($locale, '/app')}
|
href={getURLForLanguage(i18n.lang, '/app')}
|
||||||
>
|
>
|
||||||
<Map size="16" class="mr-1" />
|
<Map size="16" />
|
||||||
{$_('homepage.app')}
|
{i18n._('homepage.app')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
class="h-6 px-0 text-muted-foreground"
|
class="h-6 px-0 text-muted-foreground"
|
||||||
href={getURLForLanguage($locale, '/help')}
|
href={getURLForLanguage(i18n.lang, '/help')}
|
||||||
>
|
>
|
||||||
<BookOpenText size="16" class="mr-1" />
|
<BookOpenText size="16" />
|
||||||
{$_('menu.help')}
|
{i18n._('menu.help')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col items-start gap-1" id="contact">
|
<div class="flex flex-col items-start gap-1" id="contact">
|
||||||
<span class="font-semibold">{$_('homepage.contact')}</span>
|
<span class="font-semibold">{i18n._('homepage.contact')}</span>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
class="h-6 px-0 text-muted-foreground"
|
class="h-6 px-0 text-muted-foreground"
|
||||||
href="https://www.reddit.com/r/gpxstudio/"
|
href="https://www.reddit.com/r/gpxstudio/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<Logo company="reddit" class="h-4 mr-1 fill-muted-foreground" />
|
<Logo company="reddit" class="h-4 fill-muted-foreground" />
|
||||||
{$_('homepage.reddit')}
|
{i18n._('homepage.reddit')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
@@ -67,8 +67,8 @@
|
|||||||
href="https://facebook.com/gpx.studio"
|
href="https://facebook.com/gpx.studio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<Logo company="facebook" class="h-4 mr-1 fill-muted-foreground" />
|
<Logo company="facebook" class="h-4 fill-muted-foreground" />
|
||||||
{$_('homepage.facebook')}
|
{i18n._('homepage.facebook')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
@@ -76,8 +76,8 @@
|
|||||||
href="https://x.com/gpxstudio"
|
href="https://x.com/gpxstudio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<Logo company="x" class="h-4 mr-1 fill-muted-foreground" />
|
<Logo company="x" class="h-4 fill-muted-foreground" />
|
||||||
{$_('homepage.x')}
|
{i18n._('homepage.x')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
@@ -85,20 +85,20 @@
|
|||||||
href="mailto:hello@gpx.studio"
|
href="mailto:hello@gpx.studio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<AtSign size="16" class="mr-1" />
|
<AtSign size="16" />
|
||||||
{$_('homepage.email')}
|
{i18n._('homepage.email')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col items-start gap-1">
|
<div class="flex flex-col items-start gap-1">
|
||||||
<span class="font-semibold">{$_('homepage.contribute')}</span>
|
<span class="font-semibold">{i18n._('homepage.contribute')}</span>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
class="h-6 px-0 text-muted-foreground"
|
class="h-6 px-0 text-muted-foreground"
|
||||||
href="https://ko-fi.com/gpxstudio"
|
href="https://ko-fi.com/gpxstudio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<Heart size="16" class="mr-1" />
|
<Heart size="16" />
|
||||||
{$_('menu.donate')}
|
{i18n._('menu.donate')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
@@ -106,8 +106,8 @@
|
|||||||
href="https://crowdin.com/project/gpxstudio"
|
href="https://crowdin.com/project/gpxstudio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<Logo company="crowdin" class="h-4 mr-1 fill-muted-foreground" />
|
<Logo company="crowdin" class="h-4 fill-muted-foreground" />
|
||||||
{$_('homepage.crowdin')}
|
{i18n._('homepage.crowdin')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
@@ -115,8 +115,8 @@
|
|||||||
href="https://github.com/gpxstudio/gpx.studio"
|
href="https://github.com/gpxstudio/gpx.studio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<Logo company="github" class="h-4 mr-1 fill-muted-foreground" />
|
<Logo company="github" class="h-4 fill-muted-foreground" />
|
||||||
{$_('homepage.github')}
|
{i18n._('homepage.github')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -3,9 +3,9 @@
|
|||||||
import Tooltip from '$lib/components/Tooltip.svelte';
|
import Tooltip from '$lib/components/Tooltip.svelte';
|
||||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
import WithUnits from '$lib/components/WithUnits.svelte';
|
||||||
|
|
||||||
import { MoveDownRight, MoveUpRight, Ruler, Timer, Zap } from 'lucide-svelte';
|
import { MoveDownRight, MoveUpRight, Ruler, Timer, Zap } from '@lucide/svelte';
|
||||||
|
|
||||||
import { _ } from '$lib/i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import type { GPXStatistics } from 'gpx';
|
import type { GPXStatistics } from 'gpx';
|
||||||
import type { Writable } from 'svelte/store';
|
import type { Writable } from 'svelte/store';
|
||||||
import { settings } from '$lib/db';
|
import { settings } from '$lib/db';
|
||||||
@@ -36,13 +36,13 @@
|
|||||||
? 'flex-col justify-center'
|
? 'flex-col justify-center'
|
||||||
: 'flex-row w-full justify-between'} gap-4 p-0"
|
: 'flex-row w-full justify-between'} gap-4 p-0"
|
||||||
>
|
>
|
||||||
<Tooltip label={$_('quantities.distance')}>
|
<Tooltip label={i18n._('quantities.distance')}>
|
||||||
<span class="flex flex-row items-center">
|
<span class="flex flex-row items-center">
|
||||||
<Ruler size="16" class="mr-1" />
|
<Ruler size="16" class="mr-1" />
|
||||||
<WithUnits value={statistics.global.distance.total} type="distance" />
|
<WithUnits value={statistics.global.distance.total} type="distance" />
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip label={$_('quantities.elevation_gain_loss')}>
|
<Tooltip label={i18n._('quantities.elevation_gain_loss')}>
|
||||||
<span class="flex flex-row items-center">
|
<span class="flex flex-row items-center">
|
||||||
<MoveUpRight size="16" class="mr-1" />
|
<MoveUpRight size="16" class="mr-1" />
|
||||||
<WithUnits value={statistics.global.elevation.gain} type="elevation" />
|
<WithUnits value={statistics.global.elevation.gain} type="elevation" />
|
||||||
@@ -54,8 +54,10 @@
|
|||||||
<Tooltip
|
<Tooltip
|
||||||
class={orientation === 'horizontal' ? 'hidden xs:block' : ''}
|
class={orientation === 'horizontal' ? 'hidden xs:block' : ''}
|
||||||
label="{$velocityUnits === 'speed'
|
label="{$velocityUnits === 'speed'
|
||||||
? $_('quantities.speed')
|
? i18n._('quantities.speed')
|
||||||
: $_('quantities.pace')} ({$_('quantities.moving')} / {$_('quantities.total')})"
|
: i18n._('quantities.pace')} ({i18n._('quantities.moving')} / {i18n._(
|
||||||
|
'quantities.total'
|
||||||
|
)})"
|
||||||
>
|
>
|
||||||
<span class="flex flex-row items-center">
|
<span class="flex flex-row items-center">
|
||||||
<Zap size="16" class="mr-1" />
|
<Zap size="16" class="mr-1" />
|
||||||
@@ -72,7 +74,7 @@
|
|||||||
{#if panelSize > 160 || orientation === 'horizontal'}
|
{#if panelSize > 160 || orientation === 'horizontal'}
|
||||||
<Tooltip
|
<Tooltip
|
||||||
class={orientation === 'horizontal' ? 'hidden md:block' : ''}
|
class={orientation === 'horizontal' ? 'hidden md:block' : ''}
|
||||||
label="{$_('quantities.time')} ({$_('quantities.moving')} / {$_(
|
label="{i18n._('quantities.time')} ({i18n._('quantities.moving')} / {i18n._(
|
||||||
'quantities.total'
|
'quantities.total'
|
||||||
)})"
|
)})"
|
||||||
>
|
>
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CircleHelp } from 'lucide-svelte';
|
import { CircleHelp } from '@lucide/svelte';
|
||||||
import { _ } from '$lib/i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
|
||||||
export let link: string | undefined = undefined;
|
export let link: string | undefined = undefined;
|
||||||
</script>
|
</script>
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<slot />
|
<slot />
|
||||||
{#if link}
|
{#if link}
|
||||||
<a href={link} target="_blank" class="text-sm text-link hover:underline">
|
<a href={link} target="_blank" class="text-sm text-link hover:underline">
|
||||||
{$_('menu.more')}
|
{i18n._('menu.more')}
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
@@ -1,37 +1,27 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/state';
|
||||||
import * as Select from '$lib/components/ui/select';
|
import * as Select from '$lib/components/ui/select';
|
||||||
import { languages } from '$lib/languages';
|
import { languages } from '$lib/languages';
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
import { Languages } from 'lucide-svelte';
|
import { Languages } from '@lucide/svelte';
|
||||||
import { _, locale } from '$lib/i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
|
||||||
let selected = {
|
|
||||||
value: '',
|
|
||||||
label: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
$: if ($locale) {
|
|
||||||
selected = {
|
|
||||||
value: $locale,
|
|
||||||
label: languages[$locale],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Select.Root bind:selected>
|
<Select.Root type="single" value={i18n.lang}>
|
||||||
<Select.Trigger class="w-[180px] {$$props.class ?? ''}" aria-label={$_('menu.language')}>
|
<Select.Trigger class="w-[180px] {$$props.class ?? ''}" aria-label={i18n._('menu.language')}>
|
||||||
<Languages size="16" />
|
<Languages size="16" />
|
||||||
<Select.Value class="ml-2 mr-auto" />
|
<span class="ml-2 mr-auto">
|
||||||
|
{languages[i18n.lang]}
|
||||||
|
</span>
|
||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
<Select.Content>
|
<Select.Content>
|
||||||
{#each Object.entries(languages) as [lang, label]}
|
{#each Object.entries(languages) as [lang, label]}
|
||||||
{#if $page.url.pathname.includes('404')}
|
{#if page.url.pathname.includes('404')}
|
||||||
<a href={getURLForLanguage(lang, '/')}>
|
<a href={getURLForLanguage(lang, '/')}>
|
||||||
<Select.Item value={lang}>{label}</Select.Item>
|
<Select.Item value={lang}>{label}</Select.Item>
|
||||||
</a>
|
</a>
|
||||||
{:else}
|
{:else}
|
||||||
<a href={getURLForLanguage(lang, $page.url.pathname)}>
|
<a href={getURLForLanguage(lang, page.url.pathname)}>
|
||||||
<Select.Item value={lang}>{label}</Select.Item>
|
<Select.Item value={lang}>{label}</Select.Item>
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -41,9 +31,9 @@
|
|||||||
|
|
||||||
<!-- hidden links for svelte crawling -->
|
<!-- hidden links for svelte crawling -->
|
||||||
<div class="hidden">
|
<div class="hidden">
|
||||||
{#if !$page.url.pathname.includes('404')}
|
{#if !page.url.pathname.includes('404')}
|
||||||
{#each Object.entries(languages) as [lang, label]}
|
{#each Object.entries(languages) as [lang, label]}
|
||||||
<a href={getURLForLanguage(lang, $page.url.pathname)}>
|
<a href={getURLForLanguage(lang, page.url.pathname)}>
|
||||||
{label}
|
{label}
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
|
@@ -1,394 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onDestroy, onMount } from 'svelte';
|
|
||||||
|
|
||||||
import mapboxgl from 'mapbox-gl';
|
|
||||||
import 'mapbox-gl/dist/mapbox-gl.css';
|
|
||||||
|
|
||||||
import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder';
|
|
||||||
import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
|
|
||||||
|
|
||||||
import { Button } from '$lib/components/ui/button';
|
|
||||||
import { map } from '$lib/stores';
|
|
||||||
import { settings } from '$lib/db';
|
|
||||||
import { _ } from '$lib/i18n';
|
|
||||||
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
|
|
||||||
import { page } from '$app/stores';
|
|
||||||
|
|
||||||
export let accessToken = PUBLIC_MAPBOX_TOKEN;
|
|
||||||
export let geolocate = true;
|
|
||||||
export let geocoder = true;
|
|
||||||
export let hash = true;
|
|
||||||
|
|
||||||
mapboxgl.accessToken = accessToken;
|
|
||||||
|
|
||||||
let webgl2Supported = true;
|
|
||||||
let embeddedApp = false;
|
|
||||||
let fitBoundsOptions: mapboxgl.FitBoundsOptions = {
|
|
||||||
maxZoom: 15,
|
|
||||||
linear: true,
|
|
||||||
easing: () => 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { distanceUnits, elevationProfile, treeFileView, bottomPanelSize, rightPanelSize } =
|
|
||||||
settings;
|
|
||||||
let scaleControl = new mapboxgl.ScaleControl({
|
|
||||||
unit: $distanceUnits,
|
|
||||||
});
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
let gl = document.createElement('canvas').getContext('webgl2');
|
|
||||||
if (!gl) {
|
|
||||||
webgl2Supported = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (window.top !== window.self && !$page.route.id?.includes('embed')) {
|
|
||||||
embeddedApp = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let language = $page.params.language;
|
|
||||||
if (language === 'zh') {
|
|
||||||
language = 'zh-Hans';
|
|
||||||
} else if (language?.includes('-')) {
|
|
||||||
language = language.split('-')[0];
|
|
||||||
} else if (language === '' || language === undefined) {
|
|
||||||
language = 'en';
|
|
||||||
}
|
|
||||||
|
|
||||||
let newMap = new mapboxgl.Map({
|
|
||||||
container: 'map',
|
|
||||||
style: {
|
|
||||||
version: 8,
|
|
||||||
sources: {},
|
|
||||||
layers: [],
|
|
||||||
imports: [
|
|
||||||
{
|
|
||||||
id: 'glyphs-and-sprite', // make Mapbox glyphs and sprite available to other styles
|
|
||||||
url: '',
|
|
||||||
data: {
|
|
||||||
version: 8,
|
|
||||||
sources: {},
|
|
||||||
layers: [],
|
|
||||||
glyphs: 'mapbox://fonts/mapbox/{fontstack}/{range}.pbf',
|
|
||||||
sprite: `https://api.mapbox.com/styles/v1/mapbox/outdoors-v12/sprite?access_token=${PUBLIC_MAPBOX_TOKEN}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'basemap',
|
|
||||||
url: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'overlays',
|
|
||||||
url: '',
|
|
||||||
data: {
|
|
||||||
version: 8,
|
|
||||||
sources: {},
|
|
||||||
layers: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
projection: 'globe',
|
|
||||||
zoom: 0,
|
|
||||||
hash: hash,
|
|
||||||
language,
|
|
||||||
attributionControl: false,
|
|
||||||
logoPosition: 'bottom-right',
|
|
||||||
boxZoom: false,
|
|
||||||
});
|
|
||||||
newMap.on('load', () => {
|
|
||||||
$map = newMap; // only set the store after the map has loaded
|
|
||||||
window._map = newMap; // entry point for extensions
|
|
||||||
scaleControl.setUnit($distanceUnits);
|
|
||||||
});
|
|
||||||
|
|
||||||
newMap.addControl(
|
|
||||||
new mapboxgl.AttributionControl({
|
|
||||||
compact: true,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
newMap.addControl(
|
|
||||||
new mapboxgl.NavigationControl({
|
|
||||||
visualizePitch: true,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
if (geocoder) {
|
|
||||||
let geocoder = new MapboxGeocoder({
|
|
||||||
mapboxgl: mapboxgl,
|
|
||||||
enableEventLogging: false,
|
|
||||||
collapsed: true,
|
|
||||||
flyTo: fitBoundsOptions,
|
|
||||||
language,
|
|
||||||
localGeocoder: () => [],
|
|
||||||
localGeocoderOnly: true,
|
|
||||||
externalGeocoder: (query: string) =>
|
|
||||||
fetch(
|
|
||||||
`https://nominatim.openstreetmap.org/search?format=json&q=${query}&limit=5&accept-language=${language}`
|
|
||||||
)
|
|
||||||
.then((response) => response.json())
|
|
||||||
.then((data) => {
|
|
||||||
return data.map((result: any) => {
|
|
||||||
return {
|
|
||||||
type: 'Feature',
|
|
||||||
geometry: {
|
|
||||||
type: 'Point',
|
|
||||||
coordinates: [result.lon, result.lat],
|
|
||||||
},
|
|
||||||
place_name: result.display_name,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
let onKeyDown = geocoder._onKeyDown;
|
|
||||||
geocoder._onKeyDown = (e: KeyboardEvent) => {
|
|
||||||
// Trigger search on Enter key only
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
onKeyDown.apply(geocoder, [{ target: geocoder._inputEl }]);
|
|
||||||
} else if (geocoder._typeahead.data.length > 0) {
|
|
||||||
geocoder._typeahead.clear();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
newMap.addControl(geocoder);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (geolocate) {
|
|
||||||
newMap.addControl(
|
|
||||||
new mapboxgl.GeolocateControl({
|
|
||||||
positionOptions: {
|
|
||||||
enableHighAccuracy: true,
|
|
||||||
},
|
|
||||||
fitBoundsOptions,
|
|
||||||
trackUserLocation: true,
|
|
||||||
showUserHeading: true,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
newMap.addControl(scaleControl);
|
|
||||||
|
|
||||||
newMap.on('style.load', () => {
|
|
||||||
newMap.addSource('mapbox-dem', {
|
|
||||||
type: 'raster-dem',
|
|
||||||
url: 'mapbox://mapbox.mapbox-terrain-dem-v1',
|
|
||||||
tileSize: 512,
|
|
||||||
maxzoom: 14,
|
|
||||||
});
|
|
||||||
if (newMap.getPitch() > 0) {
|
|
||||||
newMap.setTerrain({
|
|
||||||
source: 'mapbox-dem',
|
|
||||||
exaggeration: 1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
newMap.setFog({
|
|
||||||
color: 'rgb(186, 210, 235)',
|
|
||||||
'high-color': 'rgb(36, 92, 223)',
|
|
||||||
'horizon-blend': 0.1,
|
|
||||||
'space-color': 'rgb(156, 240, 255)',
|
|
||||||
});
|
|
||||||
newMap.on('pitch', () => {
|
|
||||||
if (newMap.getPitch() > 0) {
|
|
||||||
newMap.setTerrain({
|
|
||||||
source: 'mapbox-dem',
|
|
||||||
exaggeration: 1,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
newMap.setTerrain(null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
if ($map) {
|
|
||||||
$map.remove();
|
|
||||||
$map = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$: if ($map && (!$treeFileView || !$elevationProfile || $bottomPanelSize || $rightPanelSize)) {
|
|
||||||
$map.resize();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div {...$$restProps}>
|
|
||||||
<div id="map" class="h-full {webgl2Supported && !embeddedApp ? '' : 'hidden'}"></div>
|
|
||||||
<div
|
|
||||||
class="flex flex-col items-center justify-center gap-3 h-full {webgl2Supported &&
|
|
||||||
!embeddedApp
|
|
||||||
? 'hidden'
|
|
||||||
: ''} {embeddedApp ? 'z-30' : ''}"
|
|
||||||
>
|
|
||||||
{#if !webgl2Supported}
|
|
||||||
<p>{$_('webgl2_required')}</p>
|
|
||||||
<Button href="https://get.webgl.org/webgl2/" target="_blank">
|
|
||||||
{$_('enable_webgl2')}
|
|
||||||
</Button>
|
|
||||||
{:else if embeddedApp}
|
|
||||||
<p>The app cannot be embedded in an iframe.</p>
|
|
||||||
<Button href="https://gpx.studio/help/integration" target="_blank">
|
|
||||||
Learn how to create a map for your website
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="postcss">
|
|
||||||
div :global(.mapboxgl-map) {
|
|
||||||
@apply font-sans;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-top-right > .mapboxgl-ctrl) {
|
|
||||||
@apply shadow-md;
|
|
||||||
@apply bg-background;
|
|
||||||
@apply text-foreground;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-icon) {
|
|
||||||
@apply dark:brightness-[4.7];
|
|
||||||
@apply dark:bg-white;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-geocoder) {
|
|
||||||
@apply flex;
|
|
||||||
@apply flex-row;
|
|
||||||
@apply w-fit;
|
|
||||||
@apply min-w-fit;
|
|
||||||
@apply items-center;
|
|
||||||
@apply shadow-md;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.suggestions) {
|
|
||||||
@apply shadow-md;
|
|
||||||
@apply bg-background;
|
|
||||||
@apply text-foreground;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-geocoder .suggestions > li > a) {
|
|
||||||
@apply text-foreground;
|
|
||||||
@apply hover:text-accent-foreground;
|
|
||||||
@apply hover:bg-accent;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-geocoder .suggestions > .active > a) {
|
|
||||||
@apply bg-background;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-geocoder--button) {
|
|
||||||
@apply bg-transparent;
|
|
||||||
@apply hover:bg-transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-geocoder--icon) {
|
|
||||||
@apply fill-foreground;
|
|
||||||
@apply hover:fill-accent-foreground;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-geocoder--icon-search) {
|
|
||||||
@apply relative;
|
|
||||||
@apply top-0;
|
|
||||||
@apply left-0;
|
|
||||||
@apply my-2;
|
|
||||||
@apply w-[29px];
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-geocoder--input) {
|
|
||||||
@apply relative;
|
|
||||||
@apply w-64;
|
|
||||||
@apply py-0;
|
|
||||||
@apply pl-2;
|
|
||||||
@apply focus:outline-none;
|
|
||||||
@apply transition-[width];
|
|
||||||
@apply duration-200;
|
|
||||||
@apply text-foreground;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-geocoder--collapsed .mapboxgl-ctrl-geocoder--input) {
|
|
||||||
@apply w-0;
|
|
||||||
@apply p-0;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-top-right) {
|
|
||||||
@apply z-40;
|
|
||||||
@apply flex;
|
|
||||||
@apply flex-col;
|
|
||||||
@apply items-end;
|
|
||||||
@apply h-full;
|
|
||||||
@apply overflow-hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.horizontal :global(.mapboxgl-ctrl-bottom-left) {
|
|
||||||
@apply bottom-[42px];
|
|
||||||
}
|
|
||||||
|
|
||||||
.horizontal :global(.mapboxgl-ctrl-bottom-right) {
|
|
||||||
@apply bottom-[42px];
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-attrib) {
|
|
||||||
@apply dark:bg-transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-compact-show.mapboxgl-ctrl-attrib) {
|
|
||||||
@apply dark:bg-background;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-attrib-button) {
|
|
||||||
@apply dark:bg-foreground;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-compact-show .mapboxgl-ctrl-attrib-button) {
|
|
||||||
@apply dark:bg-foreground;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-attrib a) {
|
|
||||||
@apply text-foreground;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-popup) {
|
|
||||||
@apply w-fit;
|
|
||||||
@apply z-50;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-popup-content) {
|
|
||||||
@apply p-0;
|
|
||||||
@apply bg-transparent;
|
|
||||||
@apply shadow-none;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-popup-anchor-top .mapboxgl-popup-tip) {
|
|
||||||
@apply border-b-background;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-popup-anchor-top-left .mapboxgl-popup-tip) {
|
|
||||||
@apply border-b-background;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-popup-anchor-top-right .mapboxgl-popup-tip) {
|
|
||||||
@apply border-b-background;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip) {
|
|
||||||
@apply border-t-background;
|
|
||||||
@apply drop-shadow-md;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-tip) {
|
|
||||||
@apply border-t-background;
|
|
||||||
@apply drop-shadow-md;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-tip) {
|
|
||||||
@apply border-t-background;
|
|
||||||
@apply drop-shadow-md;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-popup-anchor-left .mapboxgl-popup-tip) {
|
|
||||||
@apply border-r-background;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-popup-anchor-right .mapboxgl-popup-tip) {
|
|
||||||
@apply border-l-background;
|
|
||||||
}
|
|
||||||
</style>
|
|
@@ -1,25 +0,0 @@
|
|||||||
<svelte:options accessors />
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { TrackPoint, Waypoint } from 'gpx';
|
|
||||||
import type { Writable } from 'svelte/store';
|
|
||||||
import WaypointPopup from '$lib/components/gpx-layer/WaypointPopup.svelte';
|
|
||||||
import TrackpointPopup from '$lib/components/gpx-layer/TrackpointPopup.svelte';
|
|
||||||
import OverpassPopup from '$lib/components/layer-control/OverpassPopup.svelte';
|
|
||||||
import type { PopupItem } from './MapPopup';
|
|
||||||
|
|
||||||
export let item: Writable<PopupItem | null>;
|
|
||||||
export let container: HTMLDivElement | null = null;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div bind:this={container}>
|
|
||||||
{#if $item}
|
|
||||||
{#if $item.item instanceof Waypoint}
|
|
||||||
<WaypointPopup waypoint={$item} />
|
|
||||||
{:else if $item.item instanceof TrackPoint}
|
|
||||||
<TrackpointPopup trackpoint={$item} />
|
|
||||||
{:else}
|
|
||||||
<OverpassPopup poi={$item} />
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
@@ -1,82 +0,0 @@
|
|||||||
import { TrackPoint, Waypoint } from 'gpx';
|
|
||||||
import mapboxgl from 'mapbox-gl';
|
|
||||||
import { tick, mount } from 'svelte';
|
|
||||||
import { get, writable, type Writable } from 'svelte/store';
|
|
||||||
import MapPopupComponent from './MapPopup.svelte';
|
|
||||||
|
|
||||||
export type PopupItem<T = Waypoint | TrackPoint | any> = {
|
|
||||||
item: T;
|
|
||||||
fileId?: string;
|
|
||||||
hide?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class MapPopup {
|
|
||||||
map: mapboxgl.Map;
|
|
||||||
popup: mapboxgl.Popup;
|
|
||||||
item: Writable<PopupItem | null> = writable(null);
|
|
||||||
maybeHideBinded = this.maybeHide.bind(this);
|
|
||||||
|
|
||||||
constructor(map: mapboxgl.Map, options?: mapboxgl.PopupOptions) {
|
|
||||||
this.map = map;
|
|
||||||
this.popup = new mapboxgl.Popup(options);
|
|
||||||
|
|
||||||
let component = mount(MapPopupComponent, {
|
|
||||||
target: document.body,
|
|
||||||
props: {
|
|
||||||
item: this.item,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
tick().then(() => this.popup.setDOMContent(component.container));
|
|
||||||
}
|
|
||||||
|
|
||||||
setItem(item: PopupItem | null) {
|
|
||||||
if (item) item.hide = () => this.hide();
|
|
||||||
this.item.set(item);
|
|
||||||
if (item === null) {
|
|
||||||
this.hide();
|
|
||||||
} else {
|
|
||||||
tick().then(() => this.show());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
show() {
|
|
||||||
const i = get(this.item);
|
|
||||||
if (i === null) {
|
|
||||||
this.hide();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.popup.setLngLat(this.getCoordinates()).addTo(this.map);
|
|
||||||
this.map.on('mousemove', this.maybeHideBinded);
|
|
||||||
}
|
|
||||||
|
|
||||||
maybeHide(e: mapboxgl.MapMouseEvent) {
|
|
||||||
const i = get(this.item);
|
|
||||||
if (i === null) {
|
|
||||||
this.hide();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.map.project(this.getCoordinates()).dist(this.map.project(e.lngLat)) > 60) {
|
|
||||||
this.hide();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
hide() {
|
|
||||||
this.popup.remove();
|
|
||||||
this.map.off('mousemove', this.maybeHideBinded);
|
|
||||||
}
|
|
||||||
|
|
||||||
remove() {
|
|
||||||
this.popup.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
getCoordinates() {
|
|
||||||
const i = get(this.item);
|
|
||||||
if (i === null) {
|
|
||||||
return new mapboxgl.LngLat(0, 0);
|
|
||||||
}
|
|
||||||
return i.item instanceof Waypoint || i.item instanceof TrackPoint
|
|
||||||
? i.item.getCoordinates()
|
|
||||||
: new mapboxgl.LngLat(i.item.lon, i.item.lat);
|
|
||||||
}
|
|
||||||
}
|
|
@@ -43,21 +43,18 @@
|
|||||||
BookOpenText,
|
BookOpenText,
|
||||||
ChartArea,
|
ChartArea,
|
||||||
Maximize,
|
Maximize,
|
||||||
} from 'lucide-svelte';
|
} from '@lucide/svelte';
|
||||||
|
import { map } from '$lib/components/map/utils.svelte';
|
||||||
import {
|
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
|
||||||
map,
|
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
|
||||||
triggerFileInput,
|
import { exportState, ExportState } from '$lib/components/export/utils.svelte';
|
||||||
createFile,
|
// import {
|
||||||
loadFiles,
|
// triggerFileInput,
|
||||||
updateSelectionFromKey,
|
// createFile,
|
||||||
allHidden,
|
// loadFiles,
|
||||||
editMetadata,
|
// updateSelectionFromKey,
|
||||||
editStyle,
|
// allHidden,
|
||||||
exportState,
|
// } from '$lib/stores';
|
||||||
ExportState,
|
|
||||||
centerMapOnSelection,
|
|
||||||
} from '$lib/stores';
|
|
||||||
import {
|
import {
|
||||||
copied,
|
copied,
|
||||||
copySelection,
|
copySelection,
|
||||||
@@ -66,17 +63,17 @@
|
|||||||
selectAll,
|
selectAll,
|
||||||
selection,
|
selection,
|
||||||
} from '$lib/components/file-list/Selection';
|
} from '$lib/components/file-list/Selection';
|
||||||
import { derived } from 'svelte/store';
|
// import { canUndo, canRedo, dbUtils, fileObservers, settings } from '$lib/db';
|
||||||
import { canUndo, canRedo, dbUtils, fileObservers, settings } from '$lib/db';
|
import { anySelectedLayer } from '$lib/components/map/layer-control/utils.svelte';
|
||||||
import { anySelectedLayer } from '$lib/components/layer-control/utils';
|
|
||||||
import { defaultOverlays } from '$lib/assets/layers';
|
import { defaultOverlays } from '$lib/assets/layers';
|
||||||
import LayerControlSettings from '$lib/components/layer-control/LayerControlSettings.svelte';
|
// import LayerControlSettings from '$lib/components/map/layer-control/LayerControlSettings.svelte';
|
||||||
import { allowedPastes, ListFileItem, ListTrackItem } from '$lib/components/file-list/FileList';
|
import { allowedPastes, ListFileItem, ListTrackItem } from '$lib/components/file-list/FileList';
|
||||||
import Export from '$lib/components/Export.svelte';
|
import Export from '$lib/components/export/Export.svelte';
|
||||||
import { mode, setMode } from 'mode-watcher';
|
import { mode, setMode } from 'mode-watcher';
|
||||||
import { _, locale } from '$lib/i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { languages } from '$lib/languages';
|
import { languages } from '$lib/languages';
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
|
import { settings } from '$lib/logic/settings.svelte';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
distanceUnits,
|
distanceUnits,
|
||||||
@@ -94,119 +91,118 @@
|
|||||||
routing,
|
routing,
|
||||||
} = settings;
|
} = settings;
|
||||||
|
|
||||||
let undoDisabled = derived(canUndo, ($canUndo) => !$canUndo);
|
// let undoDisabled = derived(canUndo, ($canUndo) => !$canUndo);
|
||||||
let redoDisabled = derived(canRedo, ($canRedo) => !$canRedo);
|
// let redoDisabled = derived(canRedo, ($canRedo) => !$canRedo);
|
||||||
|
|
||||||
function switchBasemaps() {
|
function switchBasemaps() {
|
||||||
[$currentBasemap, $previousBasemap] = [$previousBasemap, $currentBasemap];
|
[currentBasemap.value, previousBasemap.value] = [
|
||||||
|
previousBasemap.value,
|
||||||
|
currentBasemap.value,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleOverlays() {
|
function toggleOverlays() {
|
||||||
if (anySelectedLayer($currentOverlays)) {
|
if (currentOverlays.value && anySelectedLayer(currentOverlays.value)) {
|
||||||
[$currentOverlays, $previousOverlays] = [defaultOverlays, $currentOverlays];
|
[currentOverlays.value, previousOverlays.value] = [
|
||||||
|
defaultOverlays,
|
||||||
|
currentOverlays.value,
|
||||||
|
];
|
||||||
} else {
|
} else {
|
||||||
[$currentOverlays, $previousOverlays] = [$previousOverlays, defaultOverlays];
|
[currentOverlays.value, previousOverlays.value] = [
|
||||||
|
previousOverlays.value,
|
||||||
|
defaultOverlays,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggle3D() {
|
let layerSettingsOpen = $state(false);
|
||||||
if ($map) {
|
|
||||||
if ($map.getPitch() === 0) {
|
|
||||||
$map.easeTo({ pitch: 70 });
|
|
||||||
} else {
|
|
||||||
$map.easeTo({ pitch: 0 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let layerSettingsOpen = false;
|
|
||||||
</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">
|
||||||
<div
|
<div
|
||||||
class="w-fit flex flex-row items-center justify-center p-1 bg-background rounded-b-md md:rounded-md pointer-events-auto shadow-md"
|
class="w-fit flex flex-row items-center justify-center p-1 bg-background rounded-b-md md:rounded-md pointer-events-auto shadow-md"
|
||||||
>
|
>
|
||||||
<a href={getURLForLanguage($locale, '/')} target="_blank" class="shrink-0">
|
<a href={getURLForLanguage(i18n.lang, '/')} target="_blank" class="shrink-0">
|
||||||
<Logo class="h-5 mt-0.5 mx-2 md:hidden" iconOnly={true} width="16" />
|
<Logo class="h-5 mt-0.5 mx-2 md:hidden" iconOnly={true} width="16" />
|
||||||
<Logo class="h-5 mt-0.5 mx-2 hidden md:block" width="96" />
|
<Logo class="h-5 mt-0.5 mx-2 hidden md:block" width="96" />
|
||||||
</a>
|
</a>
|
||||||
<Menubar.Root class="border-none h-fit p-0">
|
<Menubar.Root class="border-none h-fit p-0">
|
||||||
<Menubar.Menu>
|
<Menubar.Menu>
|
||||||
<Menubar.Trigger aria-label={$_('gpx.file')}>
|
<Menubar.Trigger aria-label={i18n._('gpx.file')}>
|
||||||
<File size="18" class="md:hidden" />
|
<File size="18" class="md:hidden" />
|
||||||
<span class="hidden md:block">{$_('gpx.file')}</span>
|
<span class="hidden md:block">{i18n._('gpx.file')}</span>
|
||||||
</Menubar.Trigger>
|
</Menubar.Trigger>
|
||||||
<Menubar.Content class="border-none">
|
<Menubar.Content class="border-none">
|
||||||
<Menubar.Item on:click={createFile}>
|
<Menubar.Item onclick={createFile}>
|
||||||
<Plus size="16" class="mr-1" />
|
<Plus size="16" class="mr-1" />
|
||||||
{$_('menu.new')}
|
{i18n._('menu.new')}
|
||||||
<Shortcut key="+" ctrl={true} />
|
<Shortcut key="+" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Item on:click={triggerFileInput}>
|
<Menubar.Item onclick={triggerFileInput}>
|
||||||
<FolderOpen size="16" class="mr-1" />
|
<FolderOpen size="16" class="mr-1" />
|
||||||
{$_('menu.open')}
|
{i18n._('menu.open')}
|
||||||
<Shortcut key="O" ctrl={true} />
|
<Shortcut key="O" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Item
|
<Menubar.Item
|
||||||
on:click={dbUtils.duplicateSelection}
|
onclick={dbUtils.duplicateSelection}
|
||||||
disabled={$selection.size == 0}
|
disabled={$selection.size == 0}
|
||||||
>
|
>
|
||||||
<Copy size="16" class="mr-1" />
|
<Copy size="16" class="mr-1" />
|
||||||
{$_('menu.duplicate')}
|
{i18n._('menu.duplicate')}
|
||||||
<Shortcut key="D" ctrl={true} />
|
<Shortcut key="D" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Item
|
<Menubar.Item
|
||||||
on:click={dbUtils.deleteSelectedFiles}
|
onclick={dbUtils.deleteSelectedFiles}
|
||||||
disabled={$selection.size == 0}
|
disabled={$selection.size == 0}
|
||||||
>
|
>
|
||||||
<FileX size="16" class="mr-1" />
|
<FileX size="16" class="mr-1" />
|
||||||
{$_('menu.close')}
|
{i18n._('menu.close')}
|
||||||
<Shortcut key="⌫" ctrl={true} />
|
<Shortcut key="⌫" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Item
|
<Menubar.Item
|
||||||
on:click={dbUtils.deleteAllFiles}
|
onclick={dbUtils.deleteAllFiles}
|
||||||
disabled={$fileObservers.size == 0}
|
disabled={$fileObservers.size == 0}
|
||||||
>
|
>
|
||||||
<FileX size="16" class="mr-1" />
|
<FileX size="16" class="mr-1" />
|
||||||
{$_('menu.close_all')}
|
{i18n._('menu.close_all')}
|
||||||
<Shortcut key="⌫" ctrl={true} shift={true} />
|
<Shortcut key="⌫" ctrl={true} shift={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Item
|
<Menubar.Item
|
||||||
on:click={() => ($exportState = ExportState.SELECTION)}
|
onclick={() => (exportState.current = ExportState.SELECTION)}
|
||||||
disabled={$selection.size == 0}
|
disabled={$selection.size == 0}
|
||||||
>
|
>
|
||||||
<Download size="16" class="mr-1" />
|
<Download size="16" class="mr-1" />
|
||||||
{$_('menu.export')}
|
{i18n._('menu.export')}
|
||||||
<Shortcut key="S" ctrl={true} />
|
<Shortcut key="S" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Item
|
<Menubar.Item
|
||||||
on:click={() => ($exportState = ExportState.ALL)}
|
onclick={() => (exportState.current = ExportState.ALL)}
|
||||||
disabled={$fileObservers.size == 0}
|
disabled={$fileObservers.size == 0}
|
||||||
>
|
>
|
||||||
<Download size="16" class="mr-1" />
|
<Download size="16" class="mr-1" />
|
||||||
{$_('menu.export_all')}
|
{i18n._('menu.export_all')}
|
||||||
<Shortcut key="S" ctrl={true} shift={true} />
|
<Shortcut key="S" ctrl={true} shift={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
</Menubar.Content>
|
</Menubar.Content>
|
||||||
</Menubar.Menu>
|
</Menubar.Menu>
|
||||||
<Menubar.Menu>
|
<Menubar.Menu>
|
||||||
<Menubar.Trigger aria-label={$_('menu.edit')}>
|
<Menubar.Trigger aria-label={i18n._('menu.edit')}>
|
||||||
<FilePen size="18" class="md:hidden" />
|
<FilePen size="18" class="md:hidden" />
|
||||||
<span class="hidden md:block">{$_('menu.edit')}</span>
|
<span class="hidden md:block">{i18n._('menu.edit')}</span>
|
||||||
</Menubar.Trigger>
|
</Menubar.Trigger>
|
||||||
<Menubar.Content class="border-none">
|
<Menubar.Content class="border-none">
|
||||||
<Menubar.Item on:click={dbUtils.undo} disabled={$undoDisabled}>
|
<Menubar.Item onclick={dbUtils.undo} disabled={$undoDisabled}>
|
||||||
<Undo2 size="16" class="mr-1" />
|
<Undo2 size="16" class="mr-1" />
|
||||||
{$_('menu.undo')}
|
{i18n._('menu.undo')}
|
||||||
<Shortcut key="Z" ctrl={true} />
|
<Shortcut key="Z" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Item on:click={dbUtils.redo} disabled={$redoDisabled}>
|
<Menubar.Item onclick={dbUtils.redo} disabled={$redoDisabled}>
|
||||||
<Redo2 size="16" class="mr-1" />
|
<Redo2 size="16" class="mr-1" />
|
||||||
{$_('menu.redo')}
|
{i18n._('menu.redo')}
|
||||||
<Shortcut key="Z" ctrl={true} shift={true} />
|
<Shortcut key="Z" ctrl={true} shift={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
@@ -219,10 +215,10 @@
|
|||||||
item instanceof ListFileItem ||
|
item instanceof ListFileItem ||
|
||||||
item instanceof ListTrackItem
|
item instanceof ListTrackItem
|
||||||
)}
|
)}
|
||||||
on:click={() => ($editMetadata = true)}
|
onclick={() => (editMetadata.current = true)}
|
||||||
>
|
>
|
||||||
<Info size="16" class="mr-1" />
|
<Info size="16" class="mr-1" />
|
||||||
{$_('menu.metadata.button')}
|
{i18n._('menu.metadata.button')}
|
||||||
<Shortcut key="I" ctrl={true} />
|
<Shortcut key="I" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Item
|
<Menubar.Item
|
||||||
@@ -234,13 +230,13 @@
|
|||||||
item instanceof ListFileItem ||
|
item instanceof ListFileItem ||
|
||||||
item instanceof ListTrackItem
|
item instanceof ListTrackItem
|
||||||
)}
|
)}
|
||||||
on:click={() => ($editStyle = true)}
|
onclick={() => (editStyle.current = true)}
|
||||||
>
|
>
|
||||||
<PaintBucket size="16" class="mr-1" />
|
<PaintBucket size="16" class="mr-1" />
|
||||||
{$_('menu.style.button')}
|
{i18n._('menu.style.button')}
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Item
|
<Menubar.Item
|
||||||
on:click={() => {
|
onclick={() => {
|
||||||
if ($allHidden) {
|
if ($allHidden) {
|
||||||
dbUtils.setHiddenToSelection(false);
|
dbUtils.setHiddenToSelection(false);
|
||||||
} else {
|
} else {
|
||||||
@@ -251,67 +247,67 @@
|
|||||||
>
|
>
|
||||||
{#if $allHidden}
|
{#if $allHidden}
|
||||||
<Eye size="16" class="mr-1" />
|
<Eye size="16" class="mr-1" />
|
||||||
{$_('menu.unhide')}
|
{i18n._('menu.unhide')}
|
||||||
{:else}
|
{:else}
|
||||||
<EyeOff size="16" class="mr-1" />
|
<EyeOff size="16" class="mr-1" />
|
||||||
{$_('menu.hide')}
|
{i18n._('menu.hide')}
|
||||||
{/if}
|
{/if}
|
||||||
<Shortcut key="H" ctrl={true} />
|
<Shortcut key="H" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
{#if $treeFileView}
|
{#if treeFileView.value}
|
||||||
{#if $selection.getSelected().some((item) => item instanceof ListFileItem)}
|
{#if $selection.getSelected().some((item) => item instanceof ListFileItem)}
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Item
|
<Menubar.Item
|
||||||
on:click={() =>
|
onclick={() =>
|
||||||
dbUtils.addNewTrack($selection.getSelected()[0].getFileId())}
|
dbUtils.addNewTrack($selection.getSelected()[0].getFileId())}
|
||||||
disabled={$selection.size !== 1}
|
disabled={$selection.size !== 1}
|
||||||
>
|
>
|
||||||
<Plus size="16" class="mr-1" />
|
<Plus size="16" class="mr-1" />
|
||||||
{$_('menu.new_track')}
|
{i18n._('menu.new_track')}
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
{:else if $selection
|
{:else if $selection
|
||||||
.getSelected()
|
.getSelected()
|
||||||
.some((item) => item instanceof ListTrackItem)}
|
.some((item) => item instanceof ListTrackItem)}
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Item
|
<Menubar.Item
|
||||||
on:click={() => {
|
onclick={() => {
|
||||||
let item = $selection.getSelected()[0];
|
let item = $selection.getSelected()[0];
|
||||||
dbUtils.addNewSegment(item.getFileId(), item.getTrackIndex());
|
dbUtils.addNewSegment(item.getFileId(), item.getTrackIndex());
|
||||||
}}
|
}}
|
||||||
disabled={$selection.size !== 1}
|
disabled={$selection.size !== 1}
|
||||||
>
|
>
|
||||||
<Plus size="16" class="mr-1" />
|
<Plus size="16" class="mr-1" />
|
||||||
{$_('menu.new_segment')}
|
{i18n._('menu.new_segment')}
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Item on:click={selectAll} disabled={$fileObservers.size == 0}>
|
<Menubar.Item onclick={selectAll} disabled={$fileObservers.size == 0}>
|
||||||
<FileStack size="16" class="mr-1" />
|
<FileStack size="16" class="mr-1" />
|
||||||
{$_('menu.select_all')}
|
{i18n._('menu.select_all')}
|
||||||
<Shortcut key="A" ctrl={true} />
|
<Shortcut key="A" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Item
|
<Menubar.Item
|
||||||
on:click={() => {
|
onclick={() => {
|
||||||
if ($selection.size > 0) {
|
if ($selection.size > 0) {
|
||||||
centerMapOnSelection();
|
centerMapOnSelection();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Maximize size="16" class="mr-1" />
|
<Maximize size="16" class="mr-1" />
|
||||||
{$_('menu.center')}
|
{i18n._('menu.center')}
|
||||||
<Shortcut key="⏎" ctrl={true} />
|
<Shortcut key="⏎" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
{#if $treeFileView}
|
{#if treeFileView.value}
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Item on:click={copySelection} disabled={$selection.size === 0}>
|
<Menubar.Item onclick={copySelection} disabled={$selection.size === 0}>
|
||||||
<ClipboardCopy size="16" class="mr-1" />
|
<ClipboardCopy size="16" class="mr-1" />
|
||||||
{$_('menu.copy')}
|
{i18n._('menu.copy')}
|
||||||
<Shortcut key="C" ctrl={true} />
|
<Shortcut key="C" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Item on:click={cutSelection} disabled={$selection.size === 0}>
|
<Menubar.Item onclick={cutSelection} disabled={$selection.size === 0}>
|
||||||
<Scissors size="16" class="mr-1" />
|
<Scissors size="16" class="mr-1" />
|
||||||
{$_('menu.cut')}
|
{i18n._('menu.cut')}
|
||||||
<Shortcut key="X" ctrl={true} />
|
<Shortcut key="X" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Item
|
<Menubar.Item
|
||||||
@@ -321,122 +317,119 @@
|
|||||||
!allowedPastes[$copied[0].level].includes(
|
!allowedPastes[$copied[0].level].includes(
|
||||||
$selection.getSelected().pop()?.level
|
$selection.getSelected().pop()?.level
|
||||||
))}
|
))}
|
||||||
on:click={pasteSelection}
|
onclick={pasteSelection}
|
||||||
>
|
>
|
||||||
<ClipboardPaste size="16" class="mr-1" />
|
<ClipboardPaste size="16" class="mr-1" />
|
||||||
{$_('menu.paste')}
|
{i18n._('menu.paste')}
|
||||||
<Shortcut key="V" ctrl={true} />
|
<Shortcut key="V" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
{/if}
|
{/if}
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Item
|
<Menubar.Item onclick={dbUtils.deleteSelection} disabled={$selection.size == 0}>
|
||||||
on:click={dbUtils.deleteSelection}
|
|
||||||
disabled={$selection.size == 0}
|
|
||||||
>
|
|
||||||
<Trash2 size="16" class="mr-1" />
|
<Trash2 size="16" class="mr-1" />
|
||||||
{$_('menu.delete')}
|
{i18n._('menu.delete')}
|
||||||
<Shortcut key="⌫" ctrl={true} />
|
<Shortcut key="⌫" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
</Menubar.Content>
|
</Menubar.Content>
|
||||||
</Menubar.Menu>
|
</Menubar.Menu>
|
||||||
<Menubar.Menu>
|
<Menubar.Menu>
|
||||||
<Menubar.Trigger aria-label={$_('menu.view')}>
|
<Menubar.Trigger aria-label={i18n._('menu.view')}>
|
||||||
<View size="18" class="md:hidden" />
|
<View size="18" class="md:hidden" />
|
||||||
<span class="hidden md:block">{$_('menu.view')}</span>
|
<span class="hidden md:block">{i18n._('menu.view')}</span>
|
||||||
</Menubar.Trigger>
|
</Menubar.Trigger>
|
||||||
<Menubar.Content class="border-none">
|
<Menubar.Content class="border-none">
|
||||||
<Menubar.CheckboxItem bind:checked={$elevationProfile}>
|
<Menubar.CheckboxItem bind:checked={elevationProfile.value}>
|
||||||
<ChartArea size="16" class="mr-1" />
|
<ChartArea size="16" class="mr-1" />
|
||||||
{$_('menu.elevation_profile')}
|
{i18n._('menu.elevation_profile')}
|
||||||
<Shortcut key="P" ctrl={true} />
|
<Shortcut key="P" ctrl={true} />
|
||||||
</Menubar.CheckboxItem>
|
</Menubar.CheckboxItem>
|
||||||
<Menubar.CheckboxItem bind:checked={$treeFileView}>
|
<Menubar.CheckboxItem bind:checked={treeFileView.value}>
|
||||||
<ListTree size="16" class="mr-1" />
|
<ListTree size="16" class="mr-1" />
|
||||||
{$_('menu.tree_file_view')}
|
{i18n._('menu.tree_file_view')}
|
||||||
<Shortcut key="L" ctrl={true} />
|
<Shortcut key="L" ctrl={true} />
|
||||||
</Menubar.CheckboxItem>
|
</Menubar.CheckboxItem>
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Item inset on:click={switchBasemaps}>
|
<Menubar.Item inset onclick={switchBasemaps}>
|
||||||
<Map size="16" class="mr-1" />{$_('menu.switch_basemap')}<Shortcut
|
<Map size="16" class="mr-1" />{i18n._('menu.switch_basemap')}<Shortcut
|
||||||
key="F1"
|
key="F1"
|
||||||
/>
|
/>
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Item inset on:click={toggleOverlays}>
|
<Menubar.Item inset onclick={toggleOverlays}>
|
||||||
<Layers2 size="16" class="mr-1" />{$_('menu.toggle_overlays')}<Shortcut
|
<Layers2 size="16" class="mr-1" />{i18n._('menu.toggle_overlays')}<Shortcut
|
||||||
key="F2"
|
key="F2"
|
||||||
/>
|
/>
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.CheckboxItem bind:checked={$distanceMarkers}>
|
<Menubar.CheckboxItem bind:checked={distanceMarkers.value}>
|
||||||
<Coins size="16" class="mr-1" />{$_('menu.distance_markers')}<Shortcut
|
<Coins size="16" class="mr-1" />{i18n._('menu.distance_markers')}<Shortcut
|
||||||
key="F3"
|
key="F3"
|
||||||
/>
|
/>
|
||||||
</Menubar.CheckboxItem>
|
</Menubar.CheckboxItem>
|
||||||
<Menubar.CheckboxItem bind:checked={$directionMarkers}>
|
<Menubar.CheckboxItem bind:checked={directionMarkers.value}>
|
||||||
<Milestone size="16" class="mr-1" />{$_('menu.direction_markers')}<Shortcut
|
<Milestone size="16" class="mr-1" />{i18n._(
|
||||||
key="F4"
|
'menu.direction_markers'
|
||||||
/>
|
)}<Shortcut key="F4" />
|
||||||
</Menubar.CheckboxItem>
|
</Menubar.CheckboxItem>
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Item inset on:click={toggle3D}>
|
<Menubar.Item inset onclick={map.toggle3D}>
|
||||||
<Box size="16" class="mr-1" />
|
<Box size="16" class="mr-1" />
|
||||||
{$_('menu.toggle_3d')}
|
{i18n._('menu.toggle_3d')}
|
||||||
<Shortcut key="{$_('menu.ctrl')}+{$_('menu.drag')}" />
|
<Shortcut key="{i18n._('menu.ctrl')}+{i18n._('menu.drag')}" />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
</Menubar.Content>
|
</Menubar.Content>
|
||||||
</Menubar.Menu>
|
</Menubar.Menu>
|
||||||
<Menubar.Menu>
|
<Menubar.Menu>
|
||||||
<Menubar.Trigger aria-label={$_('menu.settings')}>
|
<Menubar.Trigger aria-label={i18n._('menu.settings')}>
|
||||||
<Settings size="18" class="md:hidden" />
|
<Settings size="18" class="md:hidden" />
|
||||||
<span class="hidden md:block">
|
<span class="hidden md:block">
|
||||||
{$_('menu.settings')}
|
{i18n._('menu.settings')}
|
||||||
</span>
|
</span>
|
||||||
</Menubar.Trigger>
|
</Menubar.Trigger>
|
||||||
<Menubar.Content class="border-none">
|
<Menubar.Content class="border-none">
|
||||||
<Menubar.Sub>
|
<Menubar.Sub>
|
||||||
<Menubar.SubTrigger>
|
<Menubar.SubTrigger>
|
||||||
<Ruler size="16" class="mr-1" />{$_('menu.distance_units')}
|
<Ruler size="16" class="mr-1" />{i18n._('menu.distance_units')}
|
||||||
</Menubar.SubTrigger>
|
</Menubar.SubTrigger>
|
||||||
<Menubar.SubContent>
|
<Menubar.SubContent>
|
||||||
<Menubar.RadioGroup bind:value={$distanceUnits}>
|
<Menubar.RadioGroup bind:value={distanceUnits.value}>
|
||||||
<Menubar.RadioItem value="metric"
|
<Menubar.RadioItem value="metric"
|
||||||
>{$_('menu.metric')}</Menubar.RadioItem
|
>{i18n._('menu.metric')}</Menubar.RadioItem
|
||||||
>
|
>
|
||||||
<Menubar.RadioItem value="imperial"
|
<Menubar.RadioItem value="imperial"
|
||||||
>{$_('menu.imperial')}</Menubar.RadioItem
|
>{i18n._('menu.imperial')}</Menubar.RadioItem
|
||||||
>
|
>
|
||||||
<Menubar.RadioItem value="nautical"
|
<Menubar.RadioItem value="nautical"
|
||||||
>{$_('menu.nautical')}</Menubar.RadioItem
|
>{i18n._('menu.nautical')}</Menubar.RadioItem
|
||||||
>
|
>
|
||||||
</Menubar.RadioGroup>
|
</Menubar.RadioGroup>
|
||||||
</Menubar.SubContent>
|
</Menubar.SubContent>
|
||||||
</Menubar.Sub>
|
</Menubar.Sub>
|
||||||
<Menubar.Sub>
|
<Menubar.Sub>
|
||||||
<Menubar.SubTrigger>
|
<Menubar.SubTrigger>
|
||||||
<Zap size="16" class="mr-1" />{$_('menu.velocity_units')}
|
<Zap size="16" class="mr-1" />{i18n._('menu.velocity_units')}
|
||||||
</Menubar.SubTrigger>
|
</Menubar.SubTrigger>
|
||||||
<Menubar.SubContent>
|
<Menubar.SubContent>
|
||||||
<Menubar.RadioGroup bind:value={$velocityUnits}>
|
<Menubar.RadioGroup bind:value={velocityUnits.value}>
|
||||||
<Menubar.RadioItem value="speed"
|
<Menubar.RadioItem value="speed"
|
||||||
>{$_('quantities.speed')}</Menubar.RadioItem
|
>{i18n._('quantities.speed')}</Menubar.RadioItem
|
||||||
>
|
>
|
||||||
<Menubar.RadioItem value="pace"
|
<Menubar.RadioItem value="pace"
|
||||||
>{$_('quantities.pace')}</Menubar.RadioItem
|
>{i18n._('quantities.pace')}</Menubar.RadioItem
|
||||||
>
|
>
|
||||||
</Menubar.RadioGroup>
|
</Menubar.RadioGroup>
|
||||||
</Menubar.SubContent>
|
</Menubar.SubContent>
|
||||||
</Menubar.Sub>
|
</Menubar.Sub>
|
||||||
<Menubar.Sub>
|
<Menubar.Sub>
|
||||||
<Menubar.SubTrigger>
|
<Menubar.SubTrigger>
|
||||||
<Thermometer size="16" class="mr-1" />{$_('menu.temperature_units')}
|
<Thermometer size="16" class="mr-1" />{i18n._('menu.temperature_units')}
|
||||||
</Menubar.SubTrigger>
|
</Menubar.SubTrigger>
|
||||||
<Menubar.SubContent>
|
<Menubar.SubContent>
|
||||||
<Menubar.RadioGroup bind:value={$temperatureUnits}>
|
<Menubar.RadioGroup bind:value={temperatureUnits.value}>
|
||||||
<Menubar.RadioItem value="celsius"
|
<Menubar.RadioItem value="celsius"
|
||||||
>{$_('menu.celsius')}</Menubar.RadioItem
|
>{i18n._('menu.celsius')}</Menubar.RadioItem
|
||||||
>
|
>
|
||||||
<Menubar.RadioItem value="fahrenheit"
|
<Menubar.RadioItem value="fahrenheit"
|
||||||
>{$_('menu.fahrenheit')}</Menubar.RadioItem
|
>{i18n._('menu.fahrenheit')}</Menubar.RadioItem
|
||||||
>
|
>
|
||||||
</Menubar.RadioGroup>
|
</Menubar.RadioGroup>
|
||||||
</Menubar.SubContent>
|
</Menubar.SubContent>
|
||||||
@@ -445,10 +438,10 @@
|
|||||||
<Menubar.Sub>
|
<Menubar.Sub>
|
||||||
<Menubar.SubTrigger>
|
<Menubar.SubTrigger>
|
||||||
<Languages size="16" class="mr-1" />
|
<Languages size="16" class="mr-1" />
|
||||||
{$_('menu.language')}
|
{i18n._('menu.language')}
|
||||||
</Menubar.SubTrigger>
|
</Menubar.SubTrigger>
|
||||||
<Menubar.SubContent>
|
<Menubar.SubContent>
|
||||||
<Menubar.RadioGroup value={$locale}>
|
<Menubar.RadioGroup value={i18n.lang}>
|
||||||
{#each Object.entries(languages) as [lang, label]}
|
{#each Object.entries(languages) as [lang, label]}
|
||||||
<a href={getURLForLanguage(lang, '/app')}>
|
<a href={getURLForLanguage(lang, '/app')}>
|
||||||
<Menubar.RadioItem value={lang}>{label}</Menubar.RadioItem>
|
<Menubar.RadioItem value={lang}>{label}</Menubar.RadioItem>
|
||||||
@@ -464,19 +457,20 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<Moon size="16" class="mr-1" />
|
<Moon size="16" class="mr-1" />
|
||||||
{/if}
|
{/if}
|
||||||
{$_('menu.mode')}
|
{i18n._('menu.mode')}
|
||||||
</Menubar.SubTrigger>
|
</Menubar.SubTrigger>
|
||||||
<Menubar.SubContent>
|
<Menubar.SubContent>
|
||||||
<Menubar.RadioGroup
|
<Menubar.RadioGroup
|
||||||
value={mode.current ?? 'light'}
|
value={mode.current ?? 'light'}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
setMode(value);
|
setMode(value as 'light' | 'dark');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Menubar.RadioItem value="light"
|
<Menubar.RadioItem value="light"
|
||||||
>{$_('menu.light')}</Menubar.RadioItem
|
>{i18n._('menu.light')}</Menubar.RadioItem
|
||||||
>
|
>
|
||||||
<Menubar.RadioItem value="dark">{$_('menu.dark')}</Menubar.RadioItem
|
<Menubar.RadioItem value="dark"
|
||||||
|
>{i18n._('menu.dark')}</Menubar.RadioItem
|
||||||
>
|
>
|
||||||
</Menubar.RadioGroup>
|
</Menubar.RadioGroup>
|
||||||
</Menubar.SubContent>
|
</Menubar.SubContent>
|
||||||
@@ -485,22 +479,22 @@
|
|||||||
<Menubar.Sub>
|
<Menubar.Sub>
|
||||||
<Menubar.SubTrigger>
|
<Menubar.SubTrigger>
|
||||||
<PersonStanding size="16" class="mr-1" />
|
<PersonStanding size="16" class="mr-1" />
|
||||||
{$_('menu.street_view_source')}
|
{i18n._('menu.street_view_source')}
|
||||||
</Menubar.SubTrigger>
|
</Menubar.SubTrigger>
|
||||||
<Menubar.SubContent>
|
<Menubar.SubContent>
|
||||||
<Menubar.RadioGroup bind:value={$streetViewSource}>
|
<Menubar.RadioGroup bind:value={streetViewSource.value}>
|
||||||
<Menubar.RadioItem value="mapillary"
|
<Menubar.RadioItem value="mapillary"
|
||||||
>{$_('menu.mapillary')}</Menubar.RadioItem
|
>{i18n._('menu.mapillary')}</Menubar.RadioItem
|
||||||
>
|
>
|
||||||
<Menubar.RadioItem value="google"
|
<Menubar.RadioItem value="google"
|
||||||
>{$_('menu.google')}</Menubar.RadioItem
|
>{i18n._('menu.google')}</Menubar.RadioItem
|
||||||
>
|
>
|
||||||
</Menubar.RadioGroup>
|
</Menubar.RadioGroup>
|
||||||
</Menubar.SubContent>
|
</Menubar.SubContent>
|
||||||
</Menubar.Sub>
|
</Menubar.Sub>
|
||||||
<Menubar.Item on:click={() => (layerSettingsOpen = true)}>
|
<Menubar.Item onclick={() => (layerSettingsOpen = true)}>
|
||||||
<Layers size="16" class="mr-1" />
|
<Layers size="16" class="mr-1" />
|
||||||
{$_('menu.layers')}
|
{i18n._('menu.layers')}
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
</Menubar.Content>
|
</Menubar.Content>
|
||||||
</Menubar.Menu>
|
</Menubar.Menu>
|
||||||
@@ -511,11 +505,11 @@
|
|||||||
href="./help"
|
href="./help"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="cursor-default h-fit rounded-sm px-3 py-0.5"
|
class="cursor-default h-fit rounded-sm px-3 py-0.5"
|
||||||
aria-label={$_('menu.help')}
|
aria-label={i18n._('menu.help')}
|
||||||
>
|
>
|
||||||
<BookOpenText size="18" class="md:hidden" />
|
<BookOpenText size="18" class="md:hidden" />
|
||||||
<span class="hidden md:block">
|
<span class="hidden md:block">
|
||||||
{$_('menu.help')}
|
{i18n._('menu.help')}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -523,11 +517,11 @@
|
|||||||
href="https://ko-fi.com/gpxstudio"
|
href="https://ko-fi.com/gpxstudio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="cursor-default h-fit rounded-sm font-bold text-support hover:text-support px-3 py-0.5"
|
class="cursor-default h-fit rounded-sm font-bold text-support hover:text-support px-3 py-0.5"
|
||||||
aria-label={$_('menu.donate')}
|
aria-label={i18n._('menu.donate')}
|
||||||
>
|
>
|
||||||
<HeartHandshake size="18" class="md:hidden" />
|
<HeartHandshake size="18" class="md:hidden" />
|
||||||
<span class="hidden md:flex flex-row items-center">
|
<span class="hidden md:flex flex-row items-center">
|
||||||
{$_('menu.donate')}
|
{i18n._('menu.donate')}
|
||||||
<Heart size="16" class="ml-1" fill="rgb(var(--support))" />
|
<Heart size="16" class="ml-1" fill="rgb(var(--support))" />
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -536,7 +530,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Export />
|
<Export />
|
||||||
<LayerControlSettings bind:open={layerSettingsOpen} />
|
<!-- <LayerControlSettings bind:open={layerSettingsOpen} /> -->
|
||||||
|
|
||||||
<svelte:window
|
<svelte:window
|
||||||
on:keydown={(e) => {
|
on:keydown={(e) => {
|
||||||
@@ -578,10 +572,10 @@
|
|||||||
} else if ((e.key === 's' || e.key == 'S') && (e.metaKey || e.ctrlKey)) {
|
} else if ((e.key === 's' || e.key == 'S') && (e.metaKey || e.ctrlKey)) {
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
if ($fileObservers.size > 0) {
|
if ($fileObservers.size > 0) {
|
||||||
$exportState = ExportState.ALL;
|
exportState.current = ExportState.ALL;
|
||||||
}
|
}
|
||||||
} else if ($selection.size > 0) {
|
} else if ($selection.size > 0) {
|
||||||
$exportState = ExportState.SELECTION;
|
exportState.current = ExportState.SELECTION;
|
||||||
}
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
} else if ((e.key === 'z' || e.key == 'Z') && (e.metaKey || e.ctrlKey)) {
|
} else if ((e.key === 'z' || e.key == 'Z') && (e.metaKey || e.ctrlKey)) {
|
||||||
@@ -612,7 +606,7 @@
|
|||||||
.getSelected()
|
.getSelected()
|
||||||
.every((item) => item instanceof ListFileItem || item instanceof ListTrackItem)
|
.every((item) => item instanceof ListFileItem || item instanceof ListTrackItem)
|
||||||
) {
|
) {
|
||||||
$editMetadata = true;
|
editMetadata.current = true;
|
||||||
}
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
} else if (e.key === 'p' && (e.metaKey || e.ctrlKey)) {
|
} else if (e.key === 'p' && (e.metaKey || e.ctrlKey)) {
|
||||||
@@ -669,6 +663,8 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
|
@reference "../../app.css";
|
||||||
|
|
||||||
div :global(button) {
|
div :global(button) {
|
||||||
@apply hover:bg-accent;
|
@apply hover:bg-accent;
|
||||||
@apply px-3;
|
@apply px-3;
|
||||||
|
@@ -1,19 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Moon, Sun } from 'lucide-svelte';
|
import { Moon, Sun } from '@lucide/svelte';
|
||||||
import { mode, setMode } from 'mode-watcher';
|
import { mode, setMode } from 'mode-watcher';
|
||||||
import { _ } from '$lib/i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
|
||||||
export let size = '20';
|
export let size = '20';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
class="h-8 px-1.5 {$$props.class ?? ''}"
|
class="h-8 px-1.5 {$$props.class ?? ''}"
|
||||||
on:click={() => {
|
onclick={() => {
|
||||||
setMode(mode.current === 'light' ? 'dark' : 'light');
|
setMode(mode.current === 'light' ? 'dark' : 'light');
|
||||||
}}
|
}}
|
||||||
aria-label={$_('menu.mode')}
|
aria-label={i18n._('menu.mode')}
|
||||||
>
|
>
|
||||||
{#if mode.current === 'light'}
|
{#if mode.current === 'light'}
|
||||||
<Sun {size} />
|
<Sun {size} />
|
||||||
|
@@ -3,28 +3,28 @@
|
|||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import AlgoliaDocSearch from '$lib/components/AlgoliaDocSearch.svelte';
|
import AlgoliaDocSearch from '$lib/components/AlgoliaDocSearch.svelte';
|
||||||
import ModeSwitch from '$lib/components/ModeSwitch.svelte';
|
import ModeSwitch from '$lib/components/ModeSwitch.svelte';
|
||||||
import { BookOpenText, Home, Map } from 'lucide-svelte';
|
import { BookOpenText, Home, Map } from '@lucide/svelte';
|
||||||
import { _, locale } from '$lib/i18n';
|
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="w-full sticky top-0 bg-background z-50">
|
||||||
<div class="mx-6 py-2 flex flex-row items-center border-b gap-4 sm:gap-8">
|
<div class="mx-6 py-2 flex flex-row items-center border-b gap-4 sm:gap-8">
|
||||||
<a href={getURLForLanguage($locale, '/')} class="shrink-0 translate-y-0.5">
|
<a href={getURLForLanguage(i18n.lang, '/')} class="shrink-0 translate-y-0.5">
|
||||||
<Logo class="h-8 sm:hidden" iconOnly={true} width="26" />
|
<Logo class="h-8 sm:hidden" iconOnly={true} width="26" />
|
||||||
<Logo class="h-8 hidden sm:block" width="153" />
|
<Logo class="h-8 hidden sm:block" width="153" />
|
||||||
</a>
|
</a>
|
||||||
<Button variant="link" class="text-base px-0" href={getURLForLanguage($locale, '/')}>
|
<Button variant="link" class="text-base px-0" href={getURLForLanguage(i18n.lang, '/')}>
|
||||||
<Home size="18" class="mr-1.5" />
|
<Home size="18" />
|
||||||
{$_('homepage.home')}
|
{i18n._('homepage.home')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="link" class="text-base px-0" href={getURLForLanguage($locale, '/app')}>
|
<Button variant="link" class="text-base px-0" href={getURLForLanguage(i18n.lang, '/app')}>
|
||||||
<Map size="18" class="mr-1.5" />
|
<Map size="18" />
|
||||||
{$_('homepage.app')}
|
{i18n._('homepage.app')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="link" class="text-base px-0" href={getURLForLanguage($locale, '/help')}>
|
<Button variant="link" class="text-base px-0" href={getURLForLanguage(i18n.lang, '/help')}>
|
||||||
<BookOpenText size="18" class="mr-1.5" />
|
<BookOpenText size="18" />
|
||||||
{$_('menu.help')}
|
{i18n._('menu.help')}
|
||||||
</Button>
|
</Button>
|
||||||
<AlgoliaDocSearch class="ml-auto" />
|
<AlgoliaDocSearch class="ml-auto" />
|
||||||
<ModeSwitch class="hidden xs:block" />
|
<ModeSwitch class="hidden xs:block" />
|
||||||
|
@@ -1,9 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export let orientation: 'col' | 'row' = 'col';
|
let {
|
||||||
|
orientation = 'col',
|
||||||
export let after: number;
|
after = $bindable(),
|
||||||
export let minAfter: number = 0;
|
minAfter = 0,
|
||||||
export let maxAfter: number = Number.MAX_SAFE_INTEGER;
|
maxAfter = Number.MAX_SAFE_INTEGER,
|
||||||
|
}: {
|
||||||
|
orientation?: 'col' | 'row';
|
||||||
|
after: number;
|
||||||
|
minAfter?: number;
|
||||||
|
maxAfter?: number;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
function handleMouseDown(event: PointerEvent) {
|
function handleMouseDown(event: PointerEvent) {
|
||||||
const startX = event.clientX;
|
const startX = event.clientX;
|
||||||
@@ -33,10 +39,10 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="{orientation === 'col'
|
class="{orientation === 'col'
|
||||||
? 'w-1 h-full cursor-col-resize border-l'
|
? 'w-1 h-full cursor-col-resize border-l'
|
||||||
: 'w-full h-1 cursor-row-resize border-t'} {orientation}"
|
: 'w-full h-1 cursor-row-resize border-t'} {orientation}"
|
||||||
on:pointerdown={handleMouseDown}
|
onpointerdown={handleMouseDown}
|
||||||
></div>
|
></div>
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { isMac, isSafari } from '$lib/utils';
|
import { isMac, isSafari } from '$lib/utils';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { _ } from '$lib/i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
|
||||||
export let key: string | undefined = undefined;
|
export let key: string | undefined = undefined;
|
||||||
export let shift: boolean = false;
|
export let shift: boolean = false;
|
||||||
@@ -25,12 +25,12 @@
|
|||||||
<span>⇧</span>
|
<span>⇧</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if ctrl}
|
{#if ctrl}
|
||||||
<span>{mac && !safari ? '⌘' : $_('menu.ctrl') + '+'}</span>
|
<span>{mac && !safari ? '⌘' : i18n._('menu.ctrl') + '+'}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if key}
|
{#if key}
|
||||||
<span class={key === '+' ? 'font-medium text-sm/4' : ''}>{key}</span>
|
<span class={key === '+' ? 'font-medium text-sm/4' : ''}>{key}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if click}
|
{#if click}
|
||||||
<span>{$_('menu.click')}</span>
|
<span>{i18n._('menu.click')}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
@@ -5,14 +5,16 @@
|
|||||||
export let side: 'top' | 'right' | 'bottom' | 'left' = 'top';
|
export let side: 'top' | 'right' | 'bottom' | 'left' = 'top';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Tooltip.Root>
|
<Tooltip.Provider>
|
||||||
<Tooltip.Trigger {...$$restProps} aria-label={label}>
|
<Tooltip.Root>
|
||||||
<slot />
|
<Tooltip.Trigger {...$$restProps} aria-label={label}>
|
||||||
</Tooltip.Trigger>
|
<slot />
|
||||||
<Tooltip.Content {side}>
|
</Tooltip.Trigger>
|
||||||
<div class="flex flex-row items-center">
|
<Tooltip.Content {side}>
|
||||||
<span>{label}</span>
|
<div class="flex flex-row items-center">
|
||||||
<slot name="extra" />
|
<span>{label}</span>
|
||||||
</div>
|
<slot name="extra" />
|
||||||
</Tooltip.Content>
|
</div>
|
||||||
</Tooltip.Root>
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Root>
|
||||||
|
</Tooltip.Provider>
|
||||||
|
@@ -10,18 +10,26 @@
|
|||||||
getVelocityUnits,
|
getVelocityUnits,
|
||||||
secondsToHHMMSS,
|
secondsToHHMMSS,
|
||||||
} from '$lib/units';
|
} from '$lib/units';
|
||||||
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
|
||||||
import { _ } from '$lib/i18n';
|
let {
|
||||||
|
value,
|
||||||
export let value: number;
|
type,
|
||||||
export let type: 'distance' | 'elevation' | 'speed' | 'temperature' | 'time';
|
showUnits = true,
|
||||||
export let showUnits: boolean = true;
|
decimals = undefined,
|
||||||
export let decimals: number | undefined = undefined;
|
class: className = '',
|
||||||
|
}: {
|
||||||
|
value: number;
|
||||||
|
type: 'distance' | 'elevation' | 'speed' | 'temperature' | 'time';
|
||||||
|
showUnits?: boolean;
|
||||||
|
decimals?: number;
|
||||||
|
class?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
const { distanceUnits, velocityUnits, temperatureUnits } = settings;
|
const { distanceUnits, velocityUnits, temperatureUnits } = settings;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<span class={$$props.class}>
|
<span class={className}>
|
||||||
{#if type === 'distance'}
|
{#if type === 'distance'}
|
||||||
{getConvertedDistance(value, $distanceUnits).toFixed(decimals ?? 2)}
|
{getConvertedDistance(value, $distanceUnits).toFixed(decimals ?? 2)}
|
||||||
{showUnits ? getDistanceUnits($distanceUnits) : ''}
|
{showUnits ? getDistanceUnits($distanceUnits) : ''}
|
||||||
@@ -38,9 +46,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{:else if type === 'temperature'}
|
{:else if type === 'temperature'}
|
||||||
{#if $temperatureUnits === 'celsius'}
|
{#if $temperatureUnits === 'celsius'}
|
||||||
{value} {showUnits ? $_('units.celsius') : ''}
|
{value} {showUnits ? i18n._('units.celsius') : ''}
|
||||||
{:else}
|
{:else}
|
||||||
{celsiusToFahrenheit(value)} {showUnits ? $_('units.fahrenheit') : ''}
|
{celsiusToFahrenheit(value)} {showUnits ? i18n._('units.fahrenheit') : ''}
|
||||||
{/if}
|
{/if}
|
||||||
{:else if type === 'time'}
|
{:else if type === 'time'}
|
||||||
{secondsToHHMMSS(value)}
|
{secondsToHHMMSS(value)}
|
||||||
|
@@ -1,15 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { setContext } from 'svelte';
|
import { setContext, type Snippet } from 'svelte';
|
||||||
import { writable } from 'svelte/store';
|
import { CollapsibleTreeState } from './utils.svelte';
|
||||||
|
|
||||||
export let defaultState: 'open' | 'closed' = 'open';
|
const {
|
||||||
export let side: 'left' | 'right' = 'right';
|
defaultState = 'open',
|
||||||
export let nohover: boolean = false;
|
side = 'right',
|
||||||
export let slotInsideTrigger: boolean = true;
|
nohover = false,
|
||||||
|
slotInsideTrigger = true,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
defaultState?: 'open' | 'closed';
|
||||||
|
side?: 'left' | 'right';
|
||||||
|
nohover?: boolean;
|
||||||
|
slotInsideTrigger?: boolean;
|
||||||
|
children: Snippet;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let open = writable<Record<string, boolean>>({});
|
let open = $state(new CollapsibleTreeState(defaultState));
|
||||||
|
|
||||||
setContext('collapsible-tree-default-state', defaultState);
|
|
||||||
setContext('collapsible-tree-state', open);
|
setContext('collapsible-tree-state', open);
|
||||||
setContext('collapsible-tree-side', side);
|
setContext('collapsible-tree-side', side);
|
||||||
setContext('collapsible-tree-nohover', nohover);
|
setContext('collapsible-tree-nohover', nohover);
|
||||||
@@ -17,4 +25,4 @@
|
|||||||
setContext('collapsible-tree-slot-inside-trigger', slotInsideTrigger);
|
setContext('collapsible-tree-slot-inside-trigger', slotInsideTrigger);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<slot />
|
{@render children()}
|
||||||
|
@@ -1,40 +1,35 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Collapsible from '$lib/components/ui/collapsible';
|
import * as Collapsible from '$lib/components/ui/collapsible';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-svelte';
|
import { ChevronDown, ChevronLeft, ChevronRight } from '@lucide/svelte';
|
||||||
import { getContext, onMount, setContext } from 'svelte';
|
import { getContext, setContext, type Snippet } from 'svelte';
|
||||||
import { get, type Writable } from 'svelte/store';
|
import type { ClassValue } from 'svelte/elements';
|
||||||
|
import type { CollapsibleTreeState } from './utils.svelte';
|
||||||
|
|
||||||
export let id: string | number;
|
const props: {
|
||||||
|
id: string | number;
|
||||||
|
class?: ClassValue;
|
||||||
|
trigger: Snippet;
|
||||||
|
content: Snippet;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let defaultState = getContext<'open' | 'closed'>('collapsible-tree-default-state');
|
let state = getContext<CollapsibleTreeState>('collapsible-tree-state');
|
||||||
let open = getContext<Writable<Record<string, boolean>>>('collapsible-tree-state');
|
|
||||||
let side = getContext<'left' | 'right'>('collapsible-tree-side');
|
let side = getContext<'left' | 'right'>('collapsible-tree-side');
|
||||||
let nohover = getContext<boolean>('collapsible-tree-nohover');
|
let nohover = getContext<boolean>('collapsible-tree-nohover');
|
||||||
let slotInsideTrigger = getContext<boolean>('collapsible-tree-slot-inside-trigger');
|
let slotInsideTrigger = getContext<boolean>('collapsible-tree-slot-inside-trigger');
|
||||||
let parentId = getContext<string>('collapsible-tree-parent-id');
|
let parentId = getContext<string>('collapsible-tree-parent-id');
|
||||||
|
|
||||||
let fullId = `${parentId}.${id}`;
|
let fullId = `${parentId}.${props.id}`;
|
||||||
setContext('collapsible-tree-parent-id', fullId);
|
setContext('collapsible-tree-parent-id', fullId);
|
||||||
|
|
||||||
onMount(() => {
|
let open = state.get(fullId);
|
||||||
if (!get(open).hasOwnProperty(fullId)) {
|
|
||||||
open.update((value) => {
|
|
||||||
value[fullId] = defaultState === 'open';
|
|
||||||
return value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export function openNode() {
|
export function openNode() {
|
||||||
open.update((value) => {
|
open.current = true;
|
||||||
value[fullId] = true;
|
|
||||||
return value;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Collapsible.Root bind:open={$open[fullId]} class={$$props.class ?? ''}>
|
<Collapsible.Root bind:open={open.current} class={props.class}>
|
||||||
{#if slotInsideTrigger}
|
{#if slotInsideTrigger}
|
||||||
<Collapsible.Trigger class="w-full">
|
<Collapsible.Trigger class="w-full">
|
||||||
<Button
|
<Button
|
||||||
@@ -46,15 +41,15 @@
|
|||||||
: ''} pointer-events-none"
|
: ''} pointer-events-none"
|
||||||
>
|
>
|
||||||
{#if side === 'left'}
|
{#if side === 'left'}
|
||||||
{#if $open[fullId]}
|
{#if open.current}
|
||||||
<ChevronDown size="16" class="shrink-0" />
|
<ChevronDown size="16" class="shrink-0" />
|
||||||
{:else}
|
{:else}
|
||||||
<ChevronRight size="16" class="shrink-0" />
|
<ChevronRight size="16" class="shrink-0" />
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
<slot name="trigger" />
|
{@render props.trigger()}
|
||||||
{#if side === 'right'}
|
{#if side === 'right'}
|
||||||
{#if $open[fullId]}
|
{#if open.current}
|
||||||
<ChevronDown size="16" class="shrink-0" />
|
<ChevronDown size="16" class="shrink-0" />
|
||||||
{:else}
|
{:else}
|
||||||
<ChevronLeft size="16" class="shrink-0" />
|
<ChevronLeft size="16" class="shrink-0" />
|
||||||
@@ -71,17 +66,17 @@
|
|||||||
>
|
>
|
||||||
{#if side === 'left'}
|
{#if side === 'left'}
|
||||||
<Collapsible.Trigger>
|
<Collapsible.Trigger>
|
||||||
{#if $open[fullId]}
|
{#if open.current}
|
||||||
<ChevronDown size="16" class="shrink-0" />
|
<ChevronDown size="16" class="shrink-0" />
|
||||||
{:else}
|
{:else}
|
||||||
<ChevronRight size="16" class="shrink-0" />
|
<ChevronRight size="16" class="shrink-0" />
|
||||||
{/if}
|
{/if}
|
||||||
</Collapsible.Trigger>
|
</Collapsible.Trigger>
|
||||||
{/if}
|
{/if}
|
||||||
<slot name="trigger" />
|
{@render props.trigger()}
|
||||||
{#if side === 'right'}
|
{#if side === 'right'}
|
||||||
<Collapsible.Trigger>
|
<Collapsible.Trigger>
|
||||||
{#if $open[fullId]}
|
{#if open.current}
|
||||||
<ChevronDown size="16" class="shrink-0" />
|
<ChevronDown size="16" class="shrink-0" />
|
||||||
{:else}
|
{:else}
|
||||||
<ChevronLeft size="16" class="shrink-0" />
|
<ChevronLeft size="16" class="shrink-0" />
|
||||||
@@ -92,6 +87,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<Collapsible.Content class="ml-2">
|
<Collapsible.Content class="ml-2">
|
||||||
<slot name="content" />
|
{@render props.content()}
|
||||||
</Collapsible.Content>
|
</Collapsible.Content>
|
||||||
</Collapsible.Root>
|
</Collapsible.Root>
|
||||||
|
31
website/src/lib/components/collapsible-tree/utils.svelte.ts
Normal file
31
website/src/lib/components/collapsible-tree/utils.svelte.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
export class CollapsibleNodeState {
|
||||||
|
private _open: boolean;
|
||||||
|
|
||||||
|
constructor(defaultState: 'open' | 'closed') {
|
||||||
|
this._open = $state(defaultState === 'open');
|
||||||
|
}
|
||||||
|
|
||||||
|
get current(): boolean {
|
||||||
|
return this._open;
|
||||||
|
}
|
||||||
|
|
||||||
|
set current(value: boolean) {
|
||||||
|
this._open = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CollapsibleTreeState {
|
||||||
|
private _open: Record<string, CollapsibleNodeState> = {};
|
||||||
|
private _defaultState: 'open' | 'closed';
|
||||||
|
|
||||||
|
constructor(defaultState: 'open' | 'closed') {
|
||||||
|
this._defaultState = defaultState;
|
||||||
|
}
|
||||||
|
|
||||||
|
get(id: string): CollapsibleNodeState {
|
||||||
|
if (this._open[id] === undefined) {
|
||||||
|
this._open[id] = new CollapsibleNodeState(this._defaultState);
|
||||||
|
}
|
||||||
|
return this._open[id];
|
||||||
|
}
|
||||||
|
}
|
@@ -1,27 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import CustomControl from './CustomControl';
|
|
||||||
import { map } from '$lib/stores';
|
|
||||||
|
|
||||||
export let position: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' = 'top-right';
|
|
||||||
|
|
||||||
let container: HTMLDivElement;
|
|
||||||
let control: CustomControl | undefined = undefined;
|
|
||||||
|
|
||||||
$: if ($map && container) {
|
|
||||||
if (position.includes('right')) container.classList.add('float-right');
|
|
||||||
else container.classList.add('float-left');
|
|
||||||
container.classList.remove('hidden');
|
|
||||||
if (control === undefined) {
|
|
||||||
control = new CustomControl(container);
|
|
||||||
}
|
|
||||||
$map.addControl(control, position);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
bind:this={container}
|
|
||||||
class="{$$props.class ||
|
|
||||||
''} clear-both translate-0 m-[10px] mb-0 last:mb-[10px] pointer-events-auto bg-background rounded shadow-md hidden"
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
@@ -1,12 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export let module;
|
import type { Component } from 'svelte';
|
||||||
|
|
||||||
|
let { module: Module }: { module: Component } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="markdown flex flex-col gap-3">
|
<div class="markdown flex flex-col gap-3">
|
||||||
<svelte:component this={module} />
|
<Module />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
|
@reference "../../../app.css";
|
||||||
|
|
||||||
:global(.markdown) {
|
:global(.markdown) {
|
||||||
@apply text-muted-foreground;
|
@apply text-muted-foreground;
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export let src: 'getting-started/interface' | 'tools/routing' | 'tools/split';
|
let {
|
||||||
export let alt: string;
|
src,
|
||||||
|
alt,
|
||||||
|
}: {
|
||||||
|
src: 'getting-started/interface' | 'tools/routing' | 'tools/split';
|
||||||
|
alt: string;
|
||||||
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col items-center py-6 w-full">
|
<div class="flex flex-col items-center py-6 w-full">
|
||||||
|
@@ -1,5 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export let type: 'note' | 'warning' = 'note';
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let { type = 'note', children }: { type?: 'note' | 'warning'; children: Snippet } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -7,10 +9,12 @@
|
|||||||
? 'border-link'
|
? 'border-link'
|
||||||
: 'border-destructive'} p-2 text-sm rounded-md"
|
: 'border-destructive'} p-2 text-sm rounded-md"
|
||||||
>
|
>
|
||||||
<slot />
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
|
@reference "../../../app.css";
|
||||||
|
|
||||||
div :global(a) {
|
div :global(a) {
|
||||||
@apply text-link;
|
@apply text-link;
|
||||||
@apply hover:underline;
|
@apply hover:underline;
|
||||||
|
@@ -2,7 +2,6 @@ import {
|
|||||||
File,
|
File,
|
||||||
FilePen,
|
FilePen,
|
||||||
View,
|
View,
|
||||||
type Icon,
|
|
||||||
Settings,
|
Settings,
|
||||||
Pencil,
|
Pencil,
|
||||||
MapPin,
|
MapPin,
|
||||||
@@ -10,11 +9,12 @@ import {
|
|||||||
CalendarClock,
|
CalendarClock,
|
||||||
Group,
|
Group,
|
||||||
Ungroup,
|
Ungroup,
|
||||||
Filter,
|
Funnel,
|
||||||
SquareDashedMousePointer,
|
SquareDashedMousePointer,
|
||||||
MountainSnow,
|
MountainSnow,
|
||||||
} from 'lucide-svelte';
|
type IconProps,
|
||||||
import type { ComponentType } from 'svelte';
|
} from '@lucide/svelte';
|
||||||
|
import type { Component } from 'svelte';
|
||||||
|
|
||||||
export const guides: Record<string, string[]> = {
|
export const guides: Record<string, string[]> = {
|
||||||
'getting-started': [],
|
'getting-started': [],
|
||||||
@@ -37,7 +37,7 @@ export const guides: Record<string, string[]> = {
|
|||||||
faq: [],
|
faq: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const guideIcons: Record<string, string | ComponentType<Icon>> = {
|
export const guideIcons: Record<string, string | Component<IconProps>> = {
|
||||||
'getting-started': '🚀',
|
'getting-started': '🚀',
|
||||||
menu: '📂 ⚙️',
|
menu: '📂 ⚙️',
|
||||||
file: File,
|
file: File,
|
||||||
@@ -53,7 +53,7 @@ export const guideIcons: Record<string, string | ComponentType<Icon>> = {
|
|||||||
merge: Group,
|
merge: Group,
|
||||||
extract: Ungroup,
|
extract: Ungroup,
|
||||||
elevation: MountainSnow,
|
elevation: MountainSnow,
|
||||||
minify: Filter,
|
minify: Funnel,
|
||||||
clean: SquareDashedMousePointer,
|
clean: SquareDashedMousePointer,
|
||||||
'map-controls': '🗺',
|
'map-controls': '🗺',
|
||||||
gpx: '💾',
|
gpx: '💾',
|
||||||
|
@@ -1,17 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import GPXLayers from '$lib/components/gpx-layer/GPXLayers.svelte';
|
import GPXLayers from '$lib/components/map/gpx-layer/GPXLayers.svelte';
|
||||||
import ElevationProfile from '$lib/components/ElevationProfile.svelte';
|
import ElevationProfile from '$lib/components/ElevationProfile.svelte';
|
||||||
import FileList from '$lib/components/file-list/FileList.svelte';
|
import FileList from '$lib/components/file-list/FileList.svelte';
|
||||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||||
import Map from '$lib/components/Map.svelte';
|
import Map from '$lib/components/map/Map.svelte';
|
||||||
import LayerControl from '$lib/components/layer-control/LayerControl.svelte';
|
import { map } from '$lib/components/map/map.svelte';
|
||||||
|
import LayerControl from '$lib/components/map/layer-control/LayerControl.svelte';
|
||||||
import OpenIn from '$lib/components/embedding/OpenIn.svelte';
|
import OpenIn from '$lib/components/embedding/OpenIn.svelte';
|
||||||
import {
|
import {
|
||||||
gpxStatistics,
|
gpxStatistics,
|
||||||
slicedGPXStatistics,
|
slicedGPXStatistics,
|
||||||
embedding,
|
embedding,
|
||||||
loadFile,
|
loadFile,
|
||||||
map,
|
|
||||||
updateGPXData,
|
updateGPXData,
|
||||||
} from '$lib/stores';
|
} from '$lib/stores';
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
@@ -14,8 +14,8 @@
|
|||||||
Coins,
|
Coins,
|
||||||
Milestone,
|
Milestone,
|
||||||
Video,
|
Video,
|
||||||
} from 'lucide-svelte';
|
} from '@lucide/svelte';
|
||||||
import { _ } from '$lib/i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import {
|
import {
|
||||||
allowedEmbeddingBasemaps,
|
allowedEmbeddingBasemaps,
|
||||||
getCleanedEmbeddingOptions,
|
getCleanedEmbeddingOptions,
|
||||||
@@ -94,19 +94,22 @@
|
|||||||
|
|
||||||
<Card.Root id="embedding-playground">
|
<Card.Root id="embedding-playground">
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<Card.Title>{$_('embedding.title')}</Card.Title>
|
<Card.Title>{i18n._('embedding.title')}</Card.Title>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content>
|
<Card.Content>
|
||||||
<fieldset class="flex flex-col gap-3">
|
<fieldset class="flex flex-col gap-3">
|
||||||
<Label for="token">{$_('embedding.mapbox_token')}</Label>
|
<Label for="token">{i18n._('embedding.mapbox_token')}</Label>
|
||||||
<Input id="token" type="text" class="h-8" bind:value={options.token} />
|
<Input id="token" type="text" class="h-8" bind:value={options.token} />
|
||||||
<Label for="file_urls">{$_('embedding.file_urls')}</Label>
|
<Label for="file_urls">{i18n._('embedding.file_urls')}</Label>
|
||||||
<Input id="file_urls" type="text" class="h-8" bind:value={files} />
|
<Input id="file_urls" type="text" class="h-8" bind:value={files} />
|
||||||
<Label for="drive_ids">{$_('embedding.drive_ids')}</Label>
|
<Label for="drive_ids">{i18n._('embedding.drive_ids')}</Label>
|
||||||
<Input id="drive_ids" type="text" class="h-8" bind:value={driveIds} />
|
<Input id="drive_ids" type="text" class="h-8" bind:value={driveIds} />
|
||||||
<Label for="basemap">{$_('embedding.basemap')}</Label>
|
<Label for="basemap">{i18n._('embedding.basemap')}</Label>
|
||||||
<Select.Root
|
<Select.Root
|
||||||
selected={{ value: options.basemap, label: $_(`layers.label.${options.basemap}`) }}
|
selected={{
|
||||||
|
value: options.basemap,
|
||||||
|
label: i18n._(`layers.label.${options.basemap}`),
|
||||||
|
}}
|
||||||
onSelectedChange={(selected) => {
|
onSelectedChange={(selected) => {
|
||||||
if (selected?.value) {
|
if (selected?.value) {
|
||||||
options.basemap = selected?.value;
|
options.basemap = selected?.value;
|
||||||
@@ -118,18 +121,20 @@
|
|||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
<Select.Content class="max-h-60 overflow-y-scroll">
|
<Select.Content class="max-h-60 overflow-y-scroll">
|
||||||
{#each allowedEmbeddingBasemaps as basemap}
|
{#each allowedEmbeddingBasemaps as basemap}
|
||||||
<Select.Item value={basemap}>{$_(`layers.label.${basemap}`)}</Select.Item>
|
<Select.Item value={basemap}
|
||||||
|
>{i18n._(`layers.label.${basemap}`)}</Select.Item
|
||||||
|
>
|
||||||
{/each}
|
{/each}
|
||||||
</Select.Content>
|
</Select.Content>
|
||||||
</Select.Root>
|
</Select.Root>
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<Label for="profile">{$_('menu.elevation_profile')}</Label>
|
<Label for="profile">{i18n._('menu.elevation_profile')}</Label>
|
||||||
<Checkbox id="profile" bind:checked={options.elevation.show} />
|
<Checkbox id="profile" bind:checked={options.elevation.show} />
|
||||||
</div>
|
</div>
|
||||||
{#if options.elevation.show}
|
{#if options.elevation.show}
|
||||||
<div class="grid grid-cols-2 gap-x-6 gap-y-3 rounded-md border p-3 mt-1">
|
<div class="grid grid-cols-2 gap-x-6 gap-y-3 rounded-md border p-3 mt-1">
|
||||||
<Label class="flex flex-row items-center gap-2">
|
<Label class="flex flex-row items-center gap-2">
|
||||||
{$_('embedding.height')}
|
{i18n._('embedding.height')}
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
bind:value={options.elevation.height}
|
bind:value={options.elevation.height}
|
||||||
@@ -138,10 +143,10 @@
|
|||||||
</Label>
|
</Label>
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<span class="shrink-0">
|
<span class="shrink-0">
|
||||||
{$_('embedding.fill_by')}
|
{i18n._('embedding.fill_by')}
|
||||||
</span>
|
</span>
|
||||||
<Select.Root
|
<Select.Root
|
||||||
selected={{ value: 'none', label: $_('embedding.none') }}
|
selected={{ value: 'none', label: i18n._('embedding.none') }}
|
||||||
onSelectedChange={(selected) => {
|
onSelectedChange={(selected) => {
|
||||||
let value = selected?.value;
|
let value = selected?.value;
|
||||||
if (value === 'none') {
|
if (value === 'none') {
|
||||||
@@ -159,52 +164,55 @@
|
|||||||
<Select.Value />
|
<Select.Value />
|
||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
<Select.Content>
|
<Select.Content>
|
||||||
<Select.Item value="slope">{$_('quantities.slope')}</Select.Item>
|
<Select.Item value="slope">{i18n._('quantities.slope')}</Select.Item
|
||||||
<Select.Item value="surface">{$_('quantities.surface')}</Select.Item
|
|
||||||
>
|
>
|
||||||
<Select.Item value="highway">{$_('quantities.highway')}</Select.Item
|
<Select.Item value="surface"
|
||||||
|
>{i18n._('quantities.surface')}</Select.Item
|
||||||
>
|
>
|
||||||
<Select.Item value="none">{$_('embedding.none')}</Select.Item>
|
<Select.Item value="highway"
|
||||||
|
>{i18n._('quantities.highway')}</Select.Item
|
||||||
|
>
|
||||||
|
<Select.Item value="none">{i18n._('embedding.none')}</Select.Item>
|
||||||
</Select.Content>
|
</Select.Content>
|
||||||
</Select.Root>
|
</Select.Root>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<Checkbox id="controls" bind:checked={options.elevation.controls} />
|
<Checkbox id="controls" bind:checked={options.elevation.controls} />
|
||||||
<Label for="controls">{$_('embedding.show_controls')}</Label>
|
<Label for="controls">{i18n._('embedding.show_controls')}</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<Checkbox id="show-speed" bind:checked={options.elevation.speed} />
|
<Checkbox id="show-speed" bind:checked={options.elevation.speed} />
|
||||||
<Label for="show-speed" class="flex flex-row items-center gap-1">
|
<Label for="show-speed" class="flex flex-row items-center gap-1">
|
||||||
<Zap size="16" />
|
<Zap size="16" />
|
||||||
{$_('quantities.speed')}
|
{i18n._('quantities.speed')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<Checkbox id="show-hr" bind:checked={options.elevation.hr} />
|
<Checkbox id="show-hr" bind:checked={options.elevation.hr} />
|
||||||
<Label for="show-hr" class="flex flex-row items-center gap-1">
|
<Label for="show-hr" class="flex flex-row items-center gap-1">
|
||||||
<HeartPulse size="16" />
|
<HeartPulse size="16" />
|
||||||
{$_('quantities.heartrate')}
|
{i18n._('quantities.heartrate')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<Checkbox id="show-cad" bind:checked={options.elevation.cad} />
|
<Checkbox id="show-cad" bind:checked={options.elevation.cad} />
|
||||||
<Label for="show-cad" class="flex flex-row items-center gap-1">
|
<Label for="show-cad" class="flex flex-row items-center gap-1">
|
||||||
<Orbit size="16" />
|
<Orbit size="16" />
|
||||||
{$_('quantities.cadence')}
|
{i18n._('quantities.cadence')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<Checkbox id="show-temp" bind:checked={options.elevation.temp} />
|
<Checkbox id="show-temp" bind:checked={options.elevation.temp} />
|
||||||
<Label for="show-temp" class="flex flex-row items-center gap-1">
|
<Label for="show-temp" class="flex flex-row items-center gap-1">
|
||||||
<Thermometer size="16" />
|
<Thermometer size="16" />
|
||||||
{$_('quantities.temperature')}
|
{i18n._('quantities.temperature')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<Checkbox id="show-power" bind:checked={options.elevation.power} />
|
<Checkbox id="show-power" bind:checked={options.elevation.power} />
|
||||||
<Label for="show-power" class="flex flex-row items-center gap-1">
|
<Label for="show-power" class="flex flex-row items-center gap-1">
|
||||||
<SquareActivity size="16" />
|
<SquareActivity size="16" />
|
||||||
{$_('quantities.power')}
|
{i18n._('quantities.power')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -213,75 +221,75 @@
|
|||||||
<Checkbox id="distance-markers" bind:checked={options.distanceMarkers} />
|
<Checkbox id="distance-markers" bind:checked={options.distanceMarkers} />
|
||||||
<Label for="distance-markers" class="flex flex-row items-center gap-1">
|
<Label for="distance-markers" class="flex flex-row items-center gap-1">
|
||||||
<Coins size="16" />
|
<Coins size="16" />
|
||||||
{$_('menu.distance_markers')}
|
{i18n._('menu.distance_markers')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<Checkbox id="direction-markers" bind:checked={options.directionMarkers} />
|
<Checkbox id="direction-markers" bind:checked={options.directionMarkers} />
|
||||||
<Label for="direction-markers" class="flex flex-row items-center gap-1">
|
<Label for="direction-markers" class="flex flex-row items-center gap-1">
|
||||||
<Milestone size="16" />
|
<Milestone size="16" />
|
||||||
{$_('menu.direction_markers')}
|
{i18n._('menu.direction_markers')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row flex-wrap justify-between gap-3">
|
<div class="flex flex-row flex-wrap justify-between gap-3">
|
||||||
<Label class="flex flex-col items-start gap-2">
|
<Label class="flex flex-col items-start gap-2">
|
||||||
{$_('menu.distance_units')}
|
{i18n._('menu.distance_units')}
|
||||||
<RadioGroup.Root bind:value={options.distanceUnits}>
|
<RadioGroup.Root bind:value={options.distanceUnits}>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<RadioGroup.Item value="metric" id="metric" />
|
<RadioGroup.Item value="metric" id="metric" />
|
||||||
<Label for="metric">{$_('menu.metric')}</Label>
|
<Label for="metric">{i18n._('menu.metric')}</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<RadioGroup.Item value="imperial" id="imperial" />
|
<RadioGroup.Item value="imperial" id="imperial" />
|
||||||
<Label for="imperial">{$_('menu.imperial')}</Label>
|
<Label for="imperial">{i18n._('menu.imperial')}</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<RadioGroup.Item value="nautical" id="nautical" />
|
<RadioGroup.Item value="nautical" id="nautical" />
|
||||||
<Label for="nautical">{$_('menu.nautical')}</Label>
|
<Label for="nautical">{i18n._('menu.nautical')}</Label>
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup.Root>
|
</RadioGroup.Root>
|
||||||
</Label>
|
</Label>
|
||||||
<Label class="flex flex-col items-start gap-2">
|
<Label class="flex flex-col items-start gap-2">
|
||||||
{$_('menu.velocity_units')}
|
{i18n._('menu.velocity_units')}
|
||||||
<RadioGroup.Root bind:value={options.velocityUnits}>
|
<RadioGroup.Root bind:value={options.velocityUnits}>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<RadioGroup.Item value="speed" id="speed" />
|
<RadioGroup.Item value="speed" id="speed" />
|
||||||
<Label for="speed">{$_('quantities.speed')}</Label>
|
<Label for="speed">{i18n._('quantities.speed')}</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<RadioGroup.Item value="pace" id="pace" />
|
<RadioGroup.Item value="pace" id="pace" />
|
||||||
<Label for="pace">{$_('quantities.pace')}</Label>
|
<Label for="pace">{i18n._('quantities.pace')}</Label>
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup.Root>
|
</RadioGroup.Root>
|
||||||
</Label>
|
</Label>
|
||||||
<Label class="flex flex-col items-start gap-2">
|
<Label class="flex flex-col items-start gap-2">
|
||||||
{$_('menu.temperature_units')}
|
{i18n._('menu.temperature_units')}
|
||||||
<RadioGroup.Root bind:value={options.temperatureUnits}>
|
<RadioGroup.Root bind:value={options.temperatureUnits}>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<RadioGroup.Item value="celsius" id="celsius" />
|
<RadioGroup.Item value="celsius" id="celsius" />
|
||||||
<Label for="celsius">{$_('menu.celsius')}</Label>
|
<Label for="celsius">{i18n._('menu.celsius')}</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<RadioGroup.Item value="fahrenheit" id="fahrenheit" />
|
<RadioGroup.Item value="fahrenheit" id="fahrenheit" />
|
||||||
<Label for="fahrenheit">{$_('menu.fahrenheit')}</Label>
|
<Label for="fahrenheit">{i18n._('menu.fahrenheit')}</Label>
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup.Root>
|
</RadioGroup.Root>
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<Label class="flex flex-col items-start gap-2">
|
<Label class="flex flex-col items-start gap-2">
|
||||||
{$_('menu.mode')}
|
{i18n._('menu.mode')}
|
||||||
<RadioGroup.Root bind:value={options.theme} class="flex flex-row">
|
<RadioGroup.Root bind:value={options.theme} class="flex flex-row">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<RadioGroup.Item value="system" id="system" />
|
<RadioGroup.Item value="system" id="system" />
|
||||||
<Label for="system">{$_('menu.system')}</Label>
|
<Label for="system">{i18n._('menu.system')}</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<RadioGroup.Item value="light" id="light" />
|
<RadioGroup.Item value="light" id="light" />
|
||||||
<Label for="light">{$_('menu.light')}</Label>
|
<Label for="light">{i18n._('menu.light')}</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<RadioGroup.Item value="dark" id="dark" />
|
<RadioGroup.Item value="dark" id="dark" />
|
||||||
<Label for="dark">{$_('menu.dark')}</Label>
|
<Label for="dark">{i18n._('menu.dark')}</Label>
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup.Root>
|
</RadioGroup.Root>
|
||||||
</Label>
|
</Label>
|
||||||
@@ -290,43 +298,43 @@
|
|||||||
<Checkbox id="manual-camera" bind:checked={manualCamera} />
|
<Checkbox id="manual-camera" bind:checked={manualCamera} />
|
||||||
<Label for="manual-camera" class="flex flex-row items-center gap-1">
|
<Label for="manual-camera" class="flex flex-row items-center gap-1">
|
||||||
<Video size="16" />
|
<Video size="16" />
|
||||||
{$_('embedding.manual_camera')}
|
{i18n._('embedding.manual_camera')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-muted-foreground">
|
<p class="text-sm text-muted-foreground">
|
||||||
{$_('embedding.manual_camera_description')}
|
{i18n._('embedding.manual_camera_description')}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex flex-row flex-wrap items-center gap-6">
|
<div class="flex flex-row flex-wrap items-center gap-6">
|
||||||
<Label class="flex flex-col gap-1">
|
<Label class="flex flex-col gap-1">
|
||||||
<span>{$_('embedding.latitude')}</span>
|
<span>{i18n._('embedding.latitude')}</span>
|
||||||
<span>{lat}</span>
|
<span>{lat}</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Label class="flex flex-col gap-1">
|
<Label class="flex flex-col gap-1">
|
||||||
<span>{$_('embedding.longitude')}</span>
|
<span>{i18n._('embedding.longitude')}</span>
|
||||||
<span>{lon}</span>
|
<span>{lon}</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Label class="flex flex-col gap-1">
|
<Label class="flex flex-col gap-1">
|
||||||
<span>{$_('embedding.zoom')}</span>
|
<span>{i18n._('embedding.zoom')}</span>
|
||||||
<span>{zoom}</span>
|
<span>{zoom}</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Label class="flex flex-col gap-1">
|
<Label class="flex flex-col gap-1">
|
||||||
<span>{$_('embedding.bearing')}</span>
|
<span>{i18n._('embedding.bearing')}</span>
|
||||||
<span>{bearing}</span>
|
<span>{bearing}</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Label class="flex flex-col gap-1">
|
<Label class="flex flex-col gap-1">
|
||||||
<span>{$_('embedding.pitch')}</span>
|
<span>{i18n._('embedding.pitch')}</span>
|
||||||
<span>{pitch}</span>
|
<span>{pitch}</span>
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Label>
|
<Label>
|
||||||
{$_('embedding.preview')}
|
{i18n._('embedding.preview')}
|
||||||
</Label>
|
</Label>
|
||||||
<div class="relative h-[600px]">
|
<div class="relative h-[600px]">
|
||||||
<Embedding bind:options={iframeOptions} bind:hash useHash={false} />
|
<Embedding bind:options={iframeOptions} bind:hash useHash={false} />
|
||||||
</div>
|
</div>
|
||||||
<Label>
|
<Label>
|
||||||
{$_('embedding.code')}
|
{i18n._('embedding.code')}
|
||||||
</Label>
|
</Label>
|
||||||
<pre
|
<pre
|
||||||
class="bg-primary text-primary-foreground p-3 rounded-md whitespace-normal break-all">
|
class="bg-primary text-primary-foreground p-3 rounded-md whitespace-normal break-all">
|
||||||
|
@@ -2,22 +2,27 @@
|
|||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import Logo from '$lib/components/Logo.svelte';
|
import Logo from '$lib/components/Logo.svelte';
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
import { _, locale } from '$lib/i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
|
||||||
export let files: string[];
|
let {
|
||||||
export let ids: string[];
|
files,
|
||||||
|
ids,
|
||||||
|
}: {
|
||||||
|
files: string[];
|
||||||
|
ids: string[];
|
||||||
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="absolute top-0 flex-wrap h-fit bg-background font-semibold rounded-md py-1 px-2 gap-1.5 xs:text-base mt-2.5 ml-2.5 mr-12"
|
class="absolute top-0 flex-wrap h-fit bg-background font-semibold rounded-md py-1 px-2 gap-1.5 xs:text-base mt-2.5 ml-2.5 mr-12"
|
||||||
href="{getURLForLanguage($locale, '/app')}?{files.length > 0
|
href="{getURLForLanguage(i18n.lang, '/app')}?{files.length > 0
|
||||||
? `files=${encodeURIComponent(JSON.stringify(files))}`
|
? `files=${encodeURIComponent(JSON.stringify(files))}`
|
||||||
: ''}{files.length > 0 && ids.length > 0 ? '&' : ''}{ids.length > 0
|
: ''}{files.length > 0 && ids.length > 0 ? '&' : ''}{ids.length > 0
|
||||||
? `ids=${encodeURIComponent(JSON.stringify(ids))}`
|
? `ids=${encodeURIComponent(JSON.stringify(ids))}`
|
||||||
: ''}"
|
: ''}"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
{$_('menu.open_in')}
|
{i18n._('menu.open_in')}
|
||||||
<Logo class="h-[18px] xs:h-5 translate-y-[1px]" />
|
<Logo class="h-[18px] xs:h-5 translate-y-[1px]" />
|
||||||
</Button>
|
</Button>
|
||||||
|
@@ -5,13 +5,13 @@
|
|||||||
import { Separator } from '$lib/components/ui/separator';
|
import { Separator } from '$lib/components/ui/separator';
|
||||||
import { Dialog } from 'bits-ui';
|
import { Dialog } from 'bits-ui';
|
||||||
import {
|
import {
|
||||||
currentTool,
|
|
||||||
exportAllFiles,
|
exportAllFiles,
|
||||||
exportSelectedFiles,
|
exportSelectedFiles,
|
||||||
ExportState,
|
ExportState,
|
||||||
exportState,
|
exportState,
|
||||||
gpxStatistics,
|
} from '$lib/components/export/utils.svelte';
|
||||||
} from '$lib/stores';
|
import { tool } from '$lib/components/toolbar/utils.svelte';
|
||||||
|
import { gpxStatistics } from '$lib/stores';
|
||||||
import { fileObservers } from '$lib/db';
|
import { fileObservers } from '$lib/db';
|
||||||
import {
|
import {
|
||||||
Download,
|
Download,
|
||||||
@@ -21,63 +21,68 @@
|
|||||||
Orbit,
|
Orbit,
|
||||||
Thermometer,
|
Thermometer,
|
||||||
SquareActivity,
|
SquareActivity,
|
||||||
} from 'lucide-svelte';
|
} from '@lucide/svelte';
|
||||||
import { _ } from '$lib/i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { selection } from './file-list/Selection';
|
import { selection } from '$lib/components/file-list/Selection';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import { GPXStatistics } from 'gpx';
|
import { GPXStatistics } from 'gpx';
|
||||||
import { ListRootItem } from './file-list/FileList';
|
import { ListRootItem } from '$lib/components/file-list/FileList';
|
||||||
|
|
||||||
let open = false;
|
let open = $derived(exportState.current !== ExportState.NONE);
|
||||||
let exportOptions: Record<string, boolean> = {
|
let exportOptions: Record<string, boolean> = $state({
|
||||||
time: true,
|
time: true,
|
||||||
hr: true,
|
hr: true,
|
||||||
cad: true,
|
cad: true,
|
||||||
atemp: true,
|
atemp: true,
|
||||||
power: true,
|
power: true,
|
||||||
extensions: true,
|
|
||||||
};
|
|
||||||
let hide: Record<string, boolean> = {
|
|
||||||
time: false,
|
|
||||||
hr: false,
|
|
||||||
cad: false,
|
|
||||||
atemp: false,
|
|
||||||
power: false,
|
|
||||||
extensions: false,
|
extensions: false,
|
||||||
};
|
});
|
||||||
|
let hide: Record<string, boolean> = $derived.by(() => {
|
||||||
$: if ($exportState !== ExportState.NONE) {
|
if (exportState.current === ExportState.NONE) {
|
||||||
open = true;
|
return {
|
||||||
$currentTool = null;
|
time: false,
|
||||||
|
hr: false,
|
||||||
let statistics = $gpxStatistics;
|
cad: false,
|
||||||
if ($exportState === ExportState.ALL) {
|
atemp: false,
|
||||||
statistics = Array.from($fileObservers.values())
|
power: false,
|
||||||
.map((file) => get(file)?.statistics)
|
extensions: false,
|
||||||
.reduce((acc, cur) => {
|
};
|
||||||
if (cur !== undefined) {
|
} else {
|
||||||
acc.mergeWith(cur.getStatisticsFor(new ListRootItem()));
|
let statistics = $gpxStatistics;
|
||||||
}
|
if (exportState.current === ExportState.ALL) {
|
||||||
return acc;
|
statistics = Array.from($fileObservers.values())
|
||||||
}, new GPXStatistics());
|
.map((file) => get(file)?.statistics)
|
||||||
|
.reduce((acc, cur) => {
|
||||||
|
if (cur !== undefined) {
|
||||||
|
acc.mergeWith(cur.getStatisticsFor(new ListRootItem()));
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, new GPXStatistics());
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
time: statistics.global.time.total === 0,
|
||||||
|
hr: statistics.global.hr.count === 0,
|
||||||
|
cad: statistics.global.cad.count === 0,
|
||||||
|
atemp: statistics.global.atemp.count === 0,
|
||||||
|
power: statistics.global.power.count === 0,
|
||||||
|
extensions: Object.keys(statistics.global.extensions).length === 0,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
let exclude = $derived(Object.keys(exportOptions).filter((key) => !exportOptions[key]));
|
||||||
|
|
||||||
hide.time = statistics.global.time.total === 0;
|
$effect(() => {
|
||||||
hide.hr = statistics.global.hr.count === 0;
|
if (open) {
|
||||||
hide.cad = statistics.global.cad.count === 0;
|
tool.current = null;
|
||||||
hide.atemp = statistics.global.atemp.count === 0;
|
}
|
||||||
hide.power = statistics.global.power.count === 0;
|
});
|
||||||
hide.extensions = Object.keys(statistics.global.extensions).length === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
$: exclude = Object.keys(exportOptions).filter((key) => !exportOptions[key]);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog.Root
|
<Dialog.Root
|
||||||
bind:open
|
bind:open
|
||||||
onOpenChange={(isOpen) => {
|
onOpenChange={(isOpen) => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
$exportState = ExportState.NONE;
|
exportState.current = ExportState.NONE;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -91,32 +96,32 @@
|
|||||||
>
|
>
|
||||||
<span>⚠️</span>
|
<span>⚠️</span>
|
||||||
<span class="max-w-[80%] text-sm">
|
<span class="max-w-[80%] text-sm">
|
||||||
{$_('menu.support_message')}
|
{i18n._('menu.support_message')}
|
||||||
</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://ko-fi.com/gpxstudio" target="_blank">
|
||||||
{$_('menu.support_button')}
|
{i18n._('menu.support_button')}
|
||||||
<span class="ml-2">🙏</span>
|
<span class="ml-2">🙏</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="grow"
|
class="grow"
|
||||||
on:click={() => {
|
onclick={() => {
|
||||||
if ($exportState === ExportState.SELECTION) {
|
if (exportState.current === ExportState.SELECTION) {
|
||||||
exportSelectedFiles(exclude);
|
exportSelectedFiles(exclude);
|
||||||
} else if ($exportState === ExportState.ALL) {
|
} else if (exportState.current === ExportState.ALL) {
|
||||||
exportAllFiles(exclude);
|
exportAllFiles(exclude);
|
||||||
}
|
}
|
||||||
open = false;
|
open = false;
|
||||||
$exportState = ExportState.NONE;
|
exportState.current = ExportState.NONE;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Download size="16" class="mr-1" />
|
<Download size="16" class="mr-1" />
|
||||||
{#if $fileObservers.size === 1 || ($exportState === ExportState.SELECTION && $selection.size === 1)}
|
{#if $fileObservers.size === 1 || (exportState.current === ExportState.SELECTION && $selection.size === 1)}
|
||||||
{$_('menu.download_file')}
|
{i18n._('menu.download_file')}
|
||||||
{:else}
|
{:else}
|
||||||
{$_('menu.download_files')}
|
{i18n._('menu.download_files')}
|
||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -132,7 +137,7 @@
|
|||||||
<Separator />
|
<Separator />
|
||||||
</div>
|
</div>
|
||||||
<Label class="shrink-0">
|
<Label class="shrink-0">
|
||||||
{$_('menu.export_options')}
|
{i18n._('menu.export_options')}
|
||||||
</Label>
|
</Label>
|
||||||
<div class="grow">
|
<div class="grow">
|
||||||
<Separator />
|
<Separator />
|
||||||
@@ -143,7 +148,7 @@
|
|||||||
<Checkbox id="export-time" bind:checked={exportOptions.time} />
|
<Checkbox id="export-time" bind:checked={exportOptions.time} />
|
||||||
<Label for="export-time" class="flex flex-row items-center gap-1">
|
<Label for="export-time" class="flex flex-row items-center gap-1">
|
||||||
<Zap size="16" />
|
<Zap size="16" />
|
||||||
{$_('quantities.time')}
|
{i18n._('quantities.time')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -152,35 +157,35 @@
|
|||||||
<Checkbox id="export-extensions" bind:checked={exportOptions.extensions} />
|
<Checkbox id="export-extensions" bind:checked={exportOptions.extensions} />
|
||||||
<Label for="export-extensions" class="flex flex-row items-center gap-1">
|
<Label for="export-extensions" class="flex flex-row items-center gap-1">
|
||||||
<Earth size="16" />
|
<Earth size="16" />
|
||||||
{$_('quantities.osm_extensions')}
|
{i18n._('quantities.osm_extensions')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row items-center gap-1.5 {hide.hr ? 'hidden' : ''}">
|
<div class="flex flex-row items-center gap-1.5 {hide.hr ? 'hidden' : ''}">
|
||||||
<Checkbox id="export-heartrate" bind:checked={exportOptions.hr} />
|
<Checkbox id="export-heartrate" bind:checked={exportOptions.hr} />
|
||||||
<Label for="export-heartrate" class="flex flex-row items-center gap-1">
|
<Label for="export-heartrate" class="flex flex-row items-center gap-1">
|
||||||
<HeartPulse size="16" />
|
<HeartPulse size="16" />
|
||||||
{$_('quantities.heartrate')}
|
{i18n._('quantities.heartrate')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row items-center gap-1.5 {hide.cad ? 'hidden' : ''}">
|
<div class="flex flex-row items-center gap-1.5 {hide.cad ? 'hidden' : ''}">
|
||||||
<Checkbox id="export-cadence" bind:checked={exportOptions.cad} />
|
<Checkbox id="export-cadence" bind:checked={exportOptions.cad} />
|
||||||
<Label for="export-cadence" class="flex flex-row items-center gap-1">
|
<Label for="export-cadence" class="flex flex-row items-center gap-1">
|
||||||
<Orbit size="16" />
|
<Orbit size="16" />
|
||||||
{$_('quantities.cadence')}
|
{i18n._('quantities.cadence')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row items-center gap-1.5 {hide.atemp ? 'hidden' : ''}">
|
<div class="flex flex-row items-center gap-1.5 {hide.atemp ? 'hidden' : ''}">
|
||||||
<Checkbox id="export-temperature" bind:checked={exportOptions.atemp} />
|
<Checkbox id="export-temperature" bind:checked={exportOptions.atemp} />
|
||||||
<Label for="export-temperature" class="flex flex-row items-center gap-1">
|
<Label for="export-temperature" class="flex flex-row items-center gap-1">
|
||||||
<Thermometer size="16" />
|
<Thermometer size="16" />
|
||||||
{$_('quantities.temperature')}
|
{i18n._('quantities.temperature')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row items-center gap-1.5 {hide.power ? 'hidden' : ''}">
|
<div class="flex flex-row items-center gap-1.5 {hide.power ? 'hidden' : ''}">
|
||||||
<Checkbox id="export-power" bind:checked={exportOptions.power} />
|
<Checkbox id="export-power" bind:checked={exportOptions.power} />
|
||||||
<Label for="export-power" class="flex flex-row items-center gap-1">
|
<Label for="export-power" class="flex flex-row items-center gap-1">
|
||||||
<SquareActivity size="16" />
|
<SquareActivity size="16" />
|
||||||
{$_('quantities.power')}
|
{i18n._('quantities.power')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
67
website/src/lib/components/export/utils.svelte.ts
Normal file
67
website/src/lib/components/export/utils.svelte.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { getFile, settings } from '$lib/db';
|
||||||
|
import { applyToOrderedSelectedItemsFromFile } from '$lib/components/file-list/Selection';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
import { buildGPX, type GPXFile } from 'gpx';
|
||||||
|
import FileSaver from 'file-saver';
|
||||||
|
import JSZip from 'jszip';
|
||||||
|
|
||||||
|
const { fileOrder } = settings;
|
||||||
|
|
||||||
|
export enum ExportState {
|
||||||
|
NONE,
|
||||||
|
SELECTION,
|
||||||
|
ALL,
|
||||||
|
}
|
||||||
|
export const exportState = $state({
|
||||||
|
current: ExportState.NONE,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function exportFiles(fileIds: string[], exclude: string[]) {
|
||||||
|
if (fileIds.length > 1) {
|
||||||
|
await exportFilesAsZip(fileIds, exclude);
|
||||||
|
} else {
|
||||||
|
const firstFileId = fileIds.at(0);
|
||||||
|
if (firstFileId != null) {
|
||||||
|
const file = getFile(firstFileId);
|
||||||
|
if (file) {
|
||||||
|
exportFile(file, exclude);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportSelectedFiles(exclude: string[]) {
|
||||||
|
const fileIds: string[] = [];
|
||||||
|
applyToOrderedSelectedItemsFromFile(async (fileId, level, items) => {
|
||||||
|
fileIds.push(fileId);
|
||||||
|
});
|
||||||
|
await exportFiles(fileIds, exclude);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportAllFiles(exclude: string[]) {
|
||||||
|
await exportFiles(get(fileOrder), exclude);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportFile(file: GPXFile, exclude: string[]) {
|
||||||
|
const blob = new Blob([buildGPX(file, exclude)], { type: 'application/gpx+xml' });
|
||||||
|
FileSaver.saveAs(blob, `${file.metadata.name}.gpx`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportFilesAsZip(fileIds: string[], exclude: string[]) {
|
||||||
|
const zip = new JSZip();
|
||||||
|
for (const fileId of fileIds) {
|
||||||
|
const file = getFile(fileId);
|
||||||
|
if (file) {
|
||||||
|
const gpx = buildGPX(file, exclude);
|
||||||
|
let filename = file.metadata.name;
|
||||||
|
for (let i = 1; zip.files[filename + '.gpx']; i++) {
|
||||||
|
filename = file.metadata.name + `-${i}`;
|
||||||
|
}
|
||||||
|
zip.file(filename + '.gpx', gpx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(zip.files).length > 0) {
|
||||||
|
const blob = await zip.generateAsync({ type: 'blob' });
|
||||||
|
FileSaver.saveAs(blob, 'gpx-files.zip');
|
||||||
|
}
|
||||||
|
}
|
@@ -6,13 +6,22 @@
|
|||||||
import { setContext } from 'svelte';
|
import { setContext } from 'svelte';
|
||||||
import { ListFileItem, ListLevel, ListRootItem, allowedPastes } from './FileList';
|
import { ListFileItem, ListLevel, ListRootItem, allowedPastes } from './FileList';
|
||||||
import { copied, pasteSelection, selectAll, selection } from './Selection';
|
import { copied, pasteSelection, selectAll, selection } from './Selection';
|
||||||
import { ClipboardPaste, FileStack, Plus } from 'lucide-svelte';
|
import { ClipboardPaste, FileStack, Plus } from '@lucide/svelte';
|
||||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||||
import { _ } from '$lib/i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { createFile } from '$lib/stores';
|
import { createFile } from '$lib/stores';
|
||||||
|
|
||||||
export let orientation: 'vertical' | 'horizontal';
|
let {
|
||||||
export let recursive = false;
|
orientation,
|
||||||
|
recursive = false,
|
||||||
|
class: className = '',
|
||||||
|
style = '',
|
||||||
|
}: {
|
||||||
|
orientation: 'vertical' | 'horizontal';
|
||||||
|
recursive?: boolean;
|
||||||
|
class?: string;
|
||||||
|
style?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
setContext('orientation', orientation);
|
setContext('orientation', orientation);
|
||||||
setContext('recursive', recursive);
|
setContext('recursive', recursive);
|
||||||
@@ -52,23 +61,23 @@
|
|||||||
<div
|
<div
|
||||||
class="flex {orientation === 'vertical'
|
class="flex {orientation === 'vertical'
|
||||||
? 'flex-col py-1 pl-1 min-h-screen'
|
? 'flex-col py-1 pl-1 min-h-screen'
|
||||||
: 'flex-row'} {$$props.class ?? ''}"
|
: 'flex-row'} {className ?? ''}"
|
||||||
{...$$restProps}
|
{style}
|
||||||
>
|
>
|
||||||
<FileListNode bind:node={$fileObservers} item={new ListRootItem()} />
|
<FileListNode bind:node={$fileObservers} item={new ListRootItem()} />
|
||||||
{#if orientation === 'vertical'}
|
{#if orientation === 'vertical'}
|
||||||
<ContextMenu.Root>
|
<ContextMenu.Root>
|
||||||
<ContextMenu.Trigger class="grow" />
|
<ContextMenu.Trigger class="grow" />
|
||||||
<ContextMenu.Content>
|
<ContextMenu.Content>
|
||||||
<ContextMenu.Item on:click={createFile}>
|
<ContextMenu.Item onclick={createFile}>
|
||||||
<Plus size="16" class="mr-1" />
|
<Plus size="16" class="mr-1" />
|
||||||
{$_('menu.new_file')}
|
{i18n._('menu.new_file')}
|
||||||
<Shortcut key="+" ctrl={true} />
|
<Shortcut key="+" ctrl={true} />
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
<ContextMenu.Separator />
|
<ContextMenu.Separator />
|
||||||
<ContextMenu.Item on:click={selectAll} disabled={$fileObservers.size === 0}>
|
<ContextMenu.Item onclick={selectAll} disabled={$fileObservers.size === 0}>
|
||||||
<FileStack size="16" class="mr-1" />
|
<FileStack size="16" class="mr-1" />
|
||||||
{$_('menu.select_all')}
|
{i18n._('menu.select_all')}
|
||||||
<Shortcut key="A" ctrl={true} />
|
<Shortcut key="A" ctrl={true} />
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
<ContextMenu.Separator />
|
<ContextMenu.Separator />
|
||||||
@@ -76,10 +85,10 @@
|
|||||||
disabled={$copied === undefined ||
|
disabled={$copied === undefined ||
|
||||||
$copied.length === 0 ||
|
$copied.length === 0 ||
|
||||||
!allowedPastes[$copied[0].level].includes(ListLevel.ROOT)}
|
!allowedPastes[$copied[0].level].includes(ListLevel.ROOT)}
|
||||||
on:click={pasteSelection}
|
onclick={pasteSelection}
|
||||||
>
|
>
|
||||||
<ClipboardPaste size="16" class="mr-1" />
|
<ClipboardPaste size="16" class="mr-1" />
|
||||||
{$_('menu.paste')}
|
{i18n._('menu.paste')}
|
||||||
<Shortcut key="V" ctrl={true} />
|
<Shortcut key="V" ctrl={true} />
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
</ContextMenu.Content>
|
</ContextMenu.Content>
|
||||||
|
@@ -21,7 +21,7 @@
|
|||||||
type ListItem,
|
type ListItem,
|
||||||
type ListTrackItem,
|
type ListTrackItem,
|
||||||
} from './FileList';
|
} from './FileList';
|
||||||
import { _ } from '$lib/i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { selection } from './Selection';
|
import { selection } from './Selection';
|
||||||
|
|
||||||
export let node:
|
export let node:
|
||||||
@@ -39,14 +39,14 @@
|
|||||||
node instanceof GPXFile && item instanceof ListFileItem
|
node instanceof GPXFile && item instanceof ListFileItem
|
||||||
? node.metadata.name
|
? node.metadata.name
|
||||||
: node instanceof Track
|
: node instanceof Track
|
||||||
? (node.name ?? `${$_('gpx.track')} ${(item as ListTrackItem).trackIndex + 1}`)
|
? (node.name ?? `${i18n._('gpx.track')} ${(item as ListTrackItem).trackIndex + 1}`)
|
||||||
: node instanceof TrackSegment
|
: node instanceof TrackSegment
|
||||||
? `${$_('gpx.segment')} ${(item as ListTrackSegmentItem).segmentIndex + 1}`
|
? `${i18n._('gpx.segment')} ${(item as ListTrackSegmentItem).segmentIndex + 1}`
|
||||||
: node instanceof Waypoint
|
: node instanceof Waypoint
|
||||||
? (node.name ??
|
? (node.name ??
|
||||||
`${$_('gpx.waypoint')} ${(item as ListWaypointItem).waypointIndex + 1}`)
|
`${i18n._('gpx.waypoint')} ${(item as ListWaypointItem).waypointIndex + 1}`)
|
||||||
: node instanceof GPXFile && item instanceof ListWaypointsItem
|
: node instanceof GPXFile && item instanceof ListWaypointsItem
|
||||||
? $_('gpx.waypoints')
|
? i18n._('gpx.waypoints')
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
const { treeFileView } = settings;
|
const { treeFileView } = settings;
|
||||||
@@ -72,12 +72,16 @@
|
|||||||
<FileListNodeLabel {node} {item} {label} />
|
<FileListNodeLabel {node} {item} {label} />
|
||||||
{:else if recursive}
|
{:else if recursive}
|
||||||
<CollapsibleTreeNode id={item.getId()} bind:this={collapsible}>
|
<CollapsibleTreeNode id={item.getId()} bind:this={collapsible}>
|
||||||
<FileListNodeLabel {node} {item} {label} slot="trigger" />
|
{#snippet trigger()}
|
||||||
<div slot="content" class="ml-2">
|
<FileListNodeLabel {node} {item} {label} />
|
||||||
{#key node}
|
{/snippet}
|
||||||
<FileListNodeContent {node} {item} />
|
{#snippet content()}
|
||||||
{/key}
|
<div class="ml-2">
|
||||||
</div>
|
{#key node}
|
||||||
|
<FileListNodeContent {node} {item} />
|
||||||
|
{/key}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
</CollapsibleTreeNode>
|
</CollapsibleTreeNode>
|
||||||
{:else}
|
{:else}
|
||||||
<FileListNodeLabel {node} {item} {label} />
|
<FileListNodeLabel {node} {item} {label} />
|
||||||
|
@@ -23,7 +23,6 @@
|
|||||||
} from './FileList';
|
} from './FileList';
|
||||||
import { selection } from './Selection';
|
import { selection } from './Selection';
|
||||||
import { isMac } from '$lib/utils';
|
import { isMac } from '$lib/utils';
|
||||||
import { _ } from '$lib/i18n';
|
|
||||||
|
|
||||||
export let node:
|
export let node:
|
||||||
| Map<string, Readable<GPXFileWithStatistics | undefined>>
|
| Map<string, Readable<GPXFileWithStatistics | undefined>>
|
||||||
@@ -345,6 +344,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
|
@reference "../../../app.css";
|
||||||
|
|
||||||
.sortable > div {
|
.sortable > div {
|
||||||
@apply rounded-md;
|
@apply rounded-md;
|
||||||
@apply h-fit;
|
@apply h-fit;
|
||||||
|
@@ -19,7 +19,7 @@
|
|||||||
Scissors,
|
Scissors,
|
||||||
FileStack,
|
FileStack,
|
||||||
FileX,
|
FileX,
|
||||||
} from 'lucide-svelte';
|
} from '@lucide/svelte';
|
||||||
import {
|
import {
|
||||||
ListFileItem,
|
ListFileItem,
|
||||||
ListLevel,
|
ListLevel,
|
||||||
@@ -40,80 +40,92 @@
|
|||||||
} from './Selection';
|
} from './Selection';
|
||||||
import { getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import {
|
import { allHidden, embedding, gpxLayers } from '$lib/stores';
|
||||||
allHidden,
|
import { map, centerMapOnSelection } from '$lib/components/map/map.svelte';
|
||||||
editMetadata,
|
|
||||||
editStyle,
|
|
||||||
embedding,
|
|
||||||
centerMapOnSelection,
|
|
||||||
gpxLayers,
|
|
||||||
map,
|
|
||||||
} from '$lib/stores';
|
|
||||||
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
|
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
|
||||||
import { _ } from '$lib/i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import MetadataDialog from './MetadataDialog.svelte';
|
import MetadataDialog from '$lib/components/file-list/metadata/MetadataDialog.svelte';
|
||||||
import StyleDialog from './StyleDialog.svelte';
|
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
|
||||||
import { waypointPopup } from '$lib/components/gpx-layer/GPXLayerPopup';
|
import StyleDialog from './style/StyleDialog.svelte';
|
||||||
|
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
|
||||||
|
import { waypointPopup } from '$lib/components/map/gpx-layer/GPXLayerPopup';
|
||||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||||
|
|
||||||
export let node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
|
let {
|
||||||
export let item: ListItem;
|
node,
|
||||||
export let label: string | undefined;
|
item,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
|
||||||
|
item: ListItem;
|
||||||
|
label: string | undefined;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
|
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
|
||||||
|
|
||||||
$: singleSelection = $selection.size === 1;
|
let singleSelection = $derived($selection.size === 1);
|
||||||
|
|
||||||
let nodeColors: string[] = [];
|
let nodeColors: string[] = $derived.by(() => {
|
||||||
|
let colors: string[] = [];
|
||||||
|
if (node && map.current) {
|
||||||
|
if (node instanceof GPXFile) {
|
||||||
|
let defaultColor = undefined;
|
||||||
|
|
||||||
$: if (node && $map) {
|
|
||||||
nodeColors = [];
|
|
||||||
|
|
||||||
if (node instanceof GPXFile) {
|
|
||||||
let defaultColor = undefined;
|
|
||||||
|
|
||||||
let layer = gpxLayers.get(item.getFileId());
|
|
||||||
if (layer) {
|
|
||||||
defaultColor = layer.layerColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
let style = node.getStyle(defaultColor);
|
|
||||||
style.color.forEach((c) => {
|
|
||||||
if (!nodeColors.includes(c)) {
|
|
||||||
nodeColors.push(c);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if (node instanceof Track) {
|
|
||||||
let style = node.getStyle();
|
|
||||||
if (style) {
|
|
||||||
if (style['gpx_style:color'] && !nodeColors.includes(style['gpx_style:color'])) {
|
|
||||||
nodeColors.push(style['gpx_style:color']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (nodeColors.length === 0) {
|
|
||||||
let layer = gpxLayers.get(item.getFileId());
|
let layer = gpxLayers.get(item.getFileId());
|
||||||
if (layer) {
|
if (layer) {
|
||||||
nodeColors.push(layer.layerColor);
|
defaultColor = layer.layerColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
let style = node.getStyle(defaultColor);
|
||||||
|
style.color.forEach((c) => {
|
||||||
|
if (!colors.includes(c)) {
|
||||||
|
colors.push(c);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (node instanceof Track) {
|
||||||
|
let style = node.getStyle();
|
||||||
|
if (style) {
|
||||||
|
if (
|
||||||
|
style['gpx_style:color'] &&
|
||||||
|
!nodeColors.includes(style['gpx_style:color'])
|
||||||
|
) {
|
||||||
|
nodeColors.push(style['gpx_style:color']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (colors.length === 0) {
|
||||||
|
let layer = gpxLayers.get(item.getFileId());
|
||||||
|
if (layer) {
|
||||||
|
colors.push(layer.layerColor);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
return colors;
|
||||||
|
});
|
||||||
|
|
||||||
$: symbolKey = node instanceof Waypoint ? getSymbolKey(node.sym) : undefined;
|
let symbolKey = $derived(node instanceof Waypoint ? getSymbolKey(node.sym) : undefined);
|
||||||
|
|
||||||
let openEditMetadata: boolean = false;
|
let openEditMetadata: boolean = $state(false);
|
||||||
let openEditStyle: boolean = false;
|
let openEditStyle: boolean = $state(false);
|
||||||
|
|
||||||
$: openEditMetadata = $editMetadata && singleSelection && $selection.has(item);
|
$effect(() => {
|
||||||
$: openEditStyle =
|
openEditMetadata = editMetadata.current && singleSelection && $selection.has(item);
|
||||||
$editStyle &&
|
});
|
||||||
$selection.has(item) &&
|
|
||||||
$selection.getSelected().findIndex((i) => i.getFullId() === item.getFullId()) === 0;
|
$effect(() => {
|
||||||
$: hidden = item.level === ListLevel.WAYPOINTS ? node._data.hiddenWpt : node._data.hidden;
|
openEditStyle =
|
||||||
|
editStyle.current &&
|
||||||
|
$selection.has(item) &&
|
||||||
|
$selection.getSelected().findIndex((i) => i.getFullId() === item.getFullId()) === 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
let hidden = $derived(
|
||||||
|
item.level === ListLevel.WAYPOINTS ? node._data.hiddenWpt : node._data.hidden
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<ContextMenu.Root
|
<ContextMenu.Root
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
@@ -156,7 +168,7 @@
|
|||||||
: ''} {$cut && $copied?.some((i) => i.getFullId() === item.getFullId())
|
: ''} {$cut && $copied?.some((i) => i.getFullId() === item.getFullId())
|
||||||
? 'text-muted-foreground'
|
? 'text-muted-foreground'
|
||||||
: ''}"
|
: ''}"
|
||||||
on:contextmenu={(e) => {
|
oncontextmenu={(e) => {
|
||||||
if ($embedding) {
|
if ($embedding) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -170,7 +182,7 @@
|
|||||||
$selection = $selection;
|
$selection = $selection;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
on:mouseenter={() => {
|
onmouseenter={() => {
|
||||||
if (item instanceof ListWaypointItem) {
|
if (item instanceof ListWaypointItem) {
|
||||||
let layer = gpxLayers.get(item.getFileId());
|
let layer = gpxLayers.get(item.getFileId());
|
||||||
let file = getFile(item.getFileId());
|
let file = getFile(item.getFileId());
|
||||||
@@ -185,7 +197,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
on:mouseleave={() => {
|
onmouseleave={() => {
|
||||||
if (item instanceof ListWaypointItem) {
|
if (item instanceof ListWaypointItem) {
|
||||||
let layer = gpxLayers.get(item.getFileId());
|
let layer = gpxLayers.get(item.getFileId());
|
||||||
if (layer) {
|
if (layer) {
|
||||||
@@ -198,11 +210,8 @@
|
|||||||
<Waypoints size="16" class="mr-1 shrink-0" />
|
<Waypoints size="16" class="mr-1 shrink-0" />
|
||||||
{:else if item.level === ListLevel.WAYPOINT}
|
{:else if item.level === ListLevel.WAYPOINT}
|
||||||
{#if symbolKey && symbols[symbolKey].icon}
|
{#if symbolKey && symbols[symbolKey].icon}
|
||||||
<svelte:component
|
{@const SymbolIcon = symbols[symbolKey].icon}
|
||||||
this={symbols[symbolKey].icon}
|
<SymbolIcon size="16" class="mr-1 shrink-0" />
|
||||||
size="16"
|
|
||||||
class="mr-1 shrink-0"
|
|
||||||
/>
|
|
||||||
{:else}
|
{:else}
|
||||||
<MapPin size="16" class="mr-1 shrink-0" />
|
<MapPin size="16" class="mr-1 shrink-0" />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -230,18 +239,21 @@
|
|||||||
</ContextMenu.Trigger>
|
</ContextMenu.Trigger>
|
||||||
<ContextMenu.Content>
|
<ContextMenu.Content>
|
||||||
{#if item instanceof ListFileItem || item instanceof ListTrackItem}
|
{#if item instanceof ListFileItem || item instanceof ListTrackItem}
|
||||||
<ContextMenu.Item disabled={!singleSelection} on:click={() => ($editMetadata = true)}>
|
<ContextMenu.Item
|
||||||
|
disabled={!singleSelection}
|
||||||
|
onclick={() => (editMetadata.current = true)}
|
||||||
|
>
|
||||||
<Info size="16" class="mr-1" />
|
<Info size="16" class="mr-1" />
|
||||||
{$_('menu.metadata.button')}
|
{i18n._('menu.metadata.button')}
|
||||||
<Shortcut key="I" ctrl={true} />
|
<Shortcut key="I" ctrl={true} />
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
<ContextMenu.Item on:click={() => ($editStyle = true)}>
|
<ContextMenu.Item onclick={() => (editStyle.current = true)}>
|
||||||
<PaintBucket size="16" class="mr-1" />
|
<PaintBucket size="16" class="mr-1" />
|
||||||
{$_('menu.style.button')}
|
{i18n._('menu.style.button')}
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
{/if}
|
{/if}
|
||||||
<ContextMenu.Item
|
<ContextMenu.Item
|
||||||
on:click={() => {
|
onclick={() => {
|
||||||
if ($allHidden) {
|
if ($allHidden) {
|
||||||
dbUtils.setHiddenToSelection(false);
|
dbUtils.setHiddenToSelection(false);
|
||||||
} else {
|
} else {
|
||||||
@@ -251,10 +263,10 @@
|
|||||||
>
|
>
|
||||||
{#if $allHidden}
|
{#if $allHidden}
|
||||||
<Eye size="16" class="mr-1" />
|
<Eye size="16" class="mr-1" />
|
||||||
{$_('menu.unhide')}
|
{i18n._('menu.unhide')}
|
||||||
{:else}
|
{:else}
|
||||||
<EyeOff size="16" class="mr-1" />
|
<EyeOff size="16" class="mr-1" />
|
||||||
{$_('menu.hide')}
|
{i18n._('menu.hide')}
|
||||||
{/if}
|
{/if}
|
||||||
<Shortcut key="H" ctrl={true} />
|
<Shortcut key="H" ctrl={true} />
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
@@ -263,71 +275,71 @@
|
|||||||
{#if item instanceof ListFileItem}
|
{#if item instanceof ListFileItem}
|
||||||
<ContextMenu.Item
|
<ContextMenu.Item
|
||||||
disabled={!singleSelection}
|
disabled={!singleSelection}
|
||||||
on:click={() => dbUtils.addNewTrack(item.getFileId())}
|
onclick={() => dbUtils.addNewTrack(item.getFileId())}
|
||||||
>
|
>
|
||||||
<Plus size="16" class="mr-1" />
|
<Plus size="16" class="mr-1" />
|
||||||
{$_('menu.new_track')}
|
{i18n._('menu.new_track')}
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
<ContextMenu.Separator />
|
<ContextMenu.Separator />
|
||||||
{:else if item instanceof ListTrackItem}
|
{:else if item instanceof ListTrackItem}
|
||||||
<ContextMenu.Item
|
<ContextMenu.Item
|
||||||
disabled={!singleSelection}
|
disabled={!singleSelection}
|
||||||
on:click={() => dbUtils.addNewSegment(item.getFileId(), item.getTrackIndex())}
|
onclick={() => dbUtils.addNewSegment(item.getFileId(), item.getTrackIndex())}
|
||||||
>
|
>
|
||||||
<Plus size="16" class="mr-1" />
|
<Plus size="16" class="mr-1" />
|
||||||
{$_('menu.new_segment')}
|
{i18n._('menu.new_segment')}
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
<ContextMenu.Separator />
|
<ContextMenu.Separator />
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{#if item.level !== ListLevel.WAYPOINTS}
|
{#if item.level !== ListLevel.WAYPOINTS}
|
||||||
<ContextMenu.Item on:click={selectAll}>
|
<ContextMenu.Item onclick={selectAll}>
|
||||||
<FileStack size="16" class="mr-1" />
|
<FileStack size="16" class="mr-1" />
|
||||||
{$_('menu.select_all')}
|
{i18n._('menu.select_all')}
|
||||||
<Shortcut key="A" ctrl={true} />
|
<Shortcut key="A" ctrl={true} />
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
{/if}
|
{/if}
|
||||||
<ContextMenu.Item on:click={centerMapOnSelection}>
|
<ContextMenu.Item onclick={centerMapOnSelection}>
|
||||||
<Maximize size="16" class="mr-1" />
|
<Maximize size="16" class="mr-1" />
|
||||||
{$_('menu.center')}
|
{i18n._('menu.center')}
|
||||||
<Shortcut key="⏎" ctrl={true} />
|
<Shortcut key="⏎" ctrl={true} />
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
<ContextMenu.Separator />
|
<ContextMenu.Separator />
|
||||||
<ContextMenu.Item on:click={dbUtils.duplicateSelection}>
|
<ContextMenu.Item onclick={dbUtils.duplicateSelection}>
|
||||||
<Copy size="16" class="mr-1" />
|
<Copy size="16" class="mr-1" />
|
||||||
{$_('menu.duplicate')}
|
{i18n._('menu.duplicate')}
|
||||||
<Shortcut key="D" ctrl={true} /></ContextMenu.Item
|
<Shortcut key="D" ctrl={true} /></ContextMenu.Item
|
||||||
>
|
>
|
||||||
{#if orientation === 'vertical'}
|
{#if orientation === 'vertical'}
|
||||||
<ContextMenu.Item on:click={copySelection}>
|
<ContextMenu.Item onclick={copySelection}>
|
||||||
<ClipboardCopy size="16" class="mr-1" />
|
<ClipboardCopy size="16" class="mr-1" />
|
||||||
{$_('menu.copy')}
|
{i18n._('menu.copy')}
|
||||||
<Shortcut key="C" ctrl={true} />
|
<Shortcut key="C" ctrl={true} />
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
<ContextMenu.Item on:click={cutSelection}>
|
<ContextMenu.Item onclick={cutSelection}>
|
||||||
<Scissors size="16" class="mr-1" />
|
<Scissors size="16" class="mr-1" />
|
||||||
{$_('menu.cut')}
|
{i18n._('menu.cut')}
|
||||||
<Shortcut key="X" ctrl={true} />
|
<Shortcut key="X" ctrl={true} />
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
<ContextMenu.Item
|
<ContextMenu.Item
|
||||||
disabled={$copied === undefined ||
|
disabled={$copied === undefined ||
|
||||||
$copied.length === 0 ||
|
$copied.length === 0 ||
|
||||||
!allowedPastes[$copied[0].level].includes(item.level)}
|
!allowedPastes[$copied[0].level].includes(item.level)}
|
||||||
on:click={pasteSelection}
|
onclick={pasteSelection}
|
||||||
>
|
>
|
||||||
<ClipboardPaste size="16" class="mr-1" />
|
<ClipboardPaste size="16" class="mr-1" />
|
||||||
{$_('menu.paste')}
|
{i18n._('menu.paste')}
|
||||||
<Shortcut key="V" ctrl={true} />
|
<Shortcut key="V" ctrl={true} />
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
{/if}
|
{/if}
|
||||||
<ContextMenu.Separator />
|
<ContextMenu.Separator />
|
||||||
<ContextMenu.Item on:click={dbUtils.deleteSelection}>
|
<ContextMenu.Item onclick={dbUtils.deleteSelection}>
|
||||||
{#if item instanceof ListFileItem}
|
{#if item instanceof ListFileItem}
|
||||||
<FileX size="16" class="mr-1" />
|
<FileX size="16" class="mr-1" />
|
||||||
{$_('menu.close')}
|
{i18n._('menu.close')}
|
||||||
{:else}
|
{:else}
|
||||||
<Trash2 size="16" class="mr-1" />
|
<Trash2 size="16" class="mr-1" />
|
||||||
{$_('menu.delete')}
|
{i18n._('menu.delete')}
|
||||||
{/if}
|
{/if}
|
||||||
<Shortcut key="⌫" ctrl={true} />
|
<Shortcut key="⌫" ctrl={true} />
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
|
@@ -7,7 +7,11 @@
|
|||||||
import type { Readable } from 'svelte/store';
|
import type { Readable } from 'svelte/store';
|
||||||
import { ListFileItem } from './FileList';
|
import { ListFileItem } from './FileList';
|
||||||
|
|
||||||
export let file: Readable<GPXFileWithStatistics | undefined>;
|
let {
|
||||||
|
file,
|
||||||
|
}: {
|
||||||
|
file: Readable<GPXFileWithStatistics | undefined>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let recursive = getContext<boolean>('recursive');
|
let recursive = getContext<boolean>('recursive');
|
||||||
</script>
|
</script>
|
||||||
|
@@ -11,7 +11,8 @@ import {
|
|||||||
ListWaypointsItem,
|
ListWaypointsItem,
|
||||||
moveItems,
|
moveItems,
|
||||||
} from './FileList';
|
} from './FileList';
|
||||||
import { fileObservers, getFile, getFileIds, settings } from '$lib/db';
|
import { fileObservers, getFile, getFileIds } from '$lib/db';
|
||||||
|
// import { settings } from '$lib/logic/settings.svelte';
|
||||||
|
|
||||||
export class SelectionTreeType {
|
export class SelectionTreeType {
|
||||||
item: ListItem;
|
item: ListItem;
|
||||||
@@ -232,29 +233,28 @@ export function applyToOrderedItemsFromFile(
|
|||||||
callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void,
|
callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void,
|
||||||
reverse: boolean = true
|
reverse: boolean = true
|
||||||
) {
|
) {
|
||||||
get(settings.fileOrder).forEach((fileId) => {
|
// settings.fileOrder.value.forEach((fileId) => {
|
||||||
let level: ListLevel | undefined = undefined;
|
// let level: ListLevel | undefined = undefined;
|
||||||
let items: ListItem[] = [];
|
// let items: ListItem[] = [];
|
||||||
selectedItems.forEach((item) => {
|
// selectedItems.forEach((item) => {
|
||||||
if (item.getFileId() === fileId) {
|
// if (item.getFileId() === fileId) {
|
||||||
level = item.level;
|
// level = item.level;
|
||||||
if (
|
// if (
|
||||||
item instanceof ListFileItem ||
|
// item instanceof ListFileItem ||
|
||||||
item instanceof ListTrackItem ||
|
// item instanceof ListTrackItem ||
|
||||||
item instanceof ListTrackSegmentItem ||
|
// item instanceof ListTrackSegmentItem ||
|
||||||
item instanceof ListWaypointsItem ||
|
// item instanceof ListWaypointsItem ||
|
||||||
item instanceof ListWaypointItem
|
// item instanceof ListWaypointItem
|
||||||
) {
|
// ) {
|
||||||
items.push(item);
|
// items.push(item);
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
});
|
// });
|
||||||
|
// if (items.length > 0) {
|
||||||
if (items.length > 0) {
|
// sortItems(items, reverse);
|
||||||
sortItems(items, reverse);
|
// callback(fileId, level, items);
|
||||||
callback(fileId, level, items);
|
// }
|
||||||
}
|
// });
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyToOrderedSelectedItemsFromFile(
|
export function applyToOrderedSelectedItemsFromFile(
|
||||||
|
@@ -1,173 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Button } from '$lib/components/ui/button';
|
|
||||||
import { Input } from '$lib/components/ui/input';
|
|
||||||
import { Label } from '$lib/components/ui/label/index.js';
|
|
||||||
import { Slider } from '$lib/components/ui/slider';
|
|
||||||
import * as Popover from '$lib/components/ui/popover';
|
|
||||||
import { dbUtils, getFile, settings } from '$lib/db';
|
|
||||||
import { Save } from 'lucide-svelte';
|
|
||||||
import { ListFileItem, ListTrackItem, type ListItem } from './FileList';
|
|
||||||
import { selection } from './Selection';
|
|
||||||
import { editStyle, gpxLayers } from '$lib/stores';
|
|
||||||
import { _ } from '$lib/i18n';
|
|
||||||
|
|
||||||
export let item: ListItem;
|
|
||||||
export let open = false;
|
|
||||||
|
|
||||||
const { defaultOpacity, defaultWidth } = settings;
|
|
||||||
|
|
||||||
let colors: string[] = [];
|
|
||||||
let color: string | undefined = undefined;
|
|
||||||
let opacity: number[] = [];
|
|
||||||
let width: number[] = [];
|
|
||||||
let colorChanged = false;
|
|
||||||
let opacityChanged = false;
|
|
||||||
let widthChanged = false;
|
|
||||||
|
|
||||||
function setStyleInputs() {
|
|
||||||
colors = [];
|
|
||||||
opacity = [];
|
|
||||||
width = [];
|
|
||||||
|
|
||||||
$selection.forEach((item) => {
|
|
||||||
if (item instanceof ListFileItem) {
|
|
||||||
let file = getFile(item.getFileId());
|
|
||||||
let layer = gpxLayers.get(item.getFileId());
|
|
||||||
if (file && layer) {
|
|
||||||
let style = file.getStyle();
|
|
||||||
style.color.push(layer.layerColor);
|
|
||||||
|
|
||||||
style.color.forEach((c) => {
|
|
||||||
if (!colors.includes(c)) {
|
|
||||||
colors.push(c);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
style.opacity.forEach((o) => {
|
|
||||||
if (!opacity.includes(o)) {
|
|
||||||
opacity.push(o);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
style.width.forEach((w) => {
|
|
||||||
if (!width.includes(w)) {
|
|
||||||
width.push(w);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (item instanceof ListTrackItem) {
|
|
||||||
let file = getFile(item.getFileId());
|
|
||||||
let layer = gpxLayers.get(item.getFileId());
|
|
||||||
if (file && layer) {
|
|
||||||
let track = file.trk[item.getTrackIndex()];
|
|
||||||
let style = track.getStyle();
|
|
||||||
if (style) {
|
|
||||||
if (
|
|
||||||
style['gpx_style:color'] &&
|
|
||||||
!colors.includes(style['gpx_style:color'])
|
|
||||||
) {
|
|
||||||
colors.push(style['gpx_style:color']);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
style['gpx_style:opacity'] &&
|
|
||||||
!opacity.includes(style['gpx_style:opacity'])
|
|
||||||
) {
|
|
||||||
opacity.push(style['gpx_style:opacity']);
|
|
||||||
}
|
|
||||||
if (style['gpx_style:width'] && !width.includes(style['gpx_style:width'])) {
|
|
||||||
width.push(style['gpx_style:width']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!colors.includes(layer.layerColor)) {
|
|
||||||
colors.push(layer.layerColor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
color = colors[0];
|
|
||||||
opacity = [opacity[0] ?? $defaultOpacity];
|
|
||||||
width = [width[0] ?? $defaultWidth];
|
|
||||||
|
|
||||||
colorChanged = false;
|
|
||||||
opacityChanged = false;
|
|
||||||
widthChanged = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$: if ($selection && open) {
|
|
||||||
setStyleInputs();
|
|
||||||
}
|
|
||||||
|
|
||||||
$: if (!open) {
|
|
||||||
$editStyle = false;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Popover.Root bind:open>
|
|
||||||
<Popover.Trigger />
|
|
||||||
<Popover.Content side="top" sideOffset={22} alignOffset={30} class="flex flex-col gap-3">
|
|
||||||
<Label class="flex flex-row gap-2 items-center justify-between">
|
|
||||||
{$_('menu.style.color')}
|
|
||||||
<Input
|
|
||||||
bind:value={color}
|
|
||||||
type="color"
|
|
||||||
class="p-0 h-6 w-40"
|
|
||||||
on:change={() => (colorChanged = true)}
|
|
||||||
/>
|
|
||||||
</Label>
|
|
||||||
<Label class="flex flex-row gap-2 items-center justify-between">
|
|
||||||
{$_('menu.style.opacity')}
|
|
||||||
<div class="w-40 p-2">
|
|
||||||
<Slider
|
|
||||||
bind:value={opacity}
|
|
||||||
min={0.3}
|
|
||||||
max={1}
|
|
||||||
step={0.1}
|
|
||||||
onValueChange={() => (opacityChanged = true)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Label>
|
|
||||||
<Label class="flex flex-row gap-2 items-center justify-between">
|
|
||||||
{$_('menu.style.width')}
|
|
||||||
<div class="w-40 p-2">
|
|
||||||
<Slider
|
|
||||||
bind:value={width}
|
|
||||||
id="width"
|
|
||||||
min={1}
|
|
||||||
max={10}
|
|
||||||
step={1}
|
|
||||||
onValueChange={() => (widthChanged = true)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Label>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
disabled={!colorChanged && !opacityChanged && !widthChanged}
|
|
||||||
on:click={() => {
|
|
||||||
let style = {};
|
|
||||||
if (colorChanged) {
|
|
||||||
style['gpx_style:color'] = color;
|
|
||||||
}
|
|
||||||
if (opacityChanged) {
|
|
||||||
style['gpx_style:opacity'] = opacity[0];
|
|
||||||
}
|
|
||||||
if (widthChanged) {
|
|
||||||
style['gpx_style:width'] = width[0];
|
|
||||||
}
|
|
||||||
dbUtils.setStyleToSelection(style);
|
|
||||||
|
|
||||||
if (item instanceof ListFileItem && $selection.size === gpxLayers.size) {
|
|
||||||
if (style['gpx_style:opacity']) {
|
|
||||||
$defaultOpacity = style['gpx_style:opacity'];
|
|
||||||
}
|
|
||||||
if (style['gpx_style:width']) {
|
|
||||||
$defaultWidth = style['gpx_style:width'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
open = false;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Save size="16" class="mr-1" />
|
|
||||||
{$_('menu.metadata.save')}
|
|
||||||
</Button>
|
|
||||||
</Popover.Content>
|
|
||||||
</Popover.Root>
|
|
@@ -5,44 +5,54 @@
|
|||||||
import { Label } from '$lib/components/ui/label/index.js';
|
import { Label } from '$lib/components/ui/label/index.js';
|
||||||
import * as Popover from '$lib/components/ui/popover';
|
import * as Popover from '$lib/components/ui/popover';
|
||||||
import { dbUtils } from '$lib/db';
|
import { dbUtils } from '$lib/db';
|
||||||
import { Save } from 'lucide-svelte';
|
import { Save } from '@lucide/svelte';
|
||||||
import { ListFileItem, ListTrackItem, type ListItem } from './FileList';
|
import { ListFileItem, ListTrackItem, type ListItem } from '../FileList';
|
||||||
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
|
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
|
||||||
import { _ } from '$lib/i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { editMetadata } from '$lib/stores';
|
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
|
||||||
|
|
||||||
export let node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
|
let {
|
||||||
export let item: ListItem;
|
node,
|
||||||
export let open = false;
|
item,
|
||||||
|
open = $bindable(),
|
||||||
|
}: {
|
||||||
|
node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
|
||||||
|
item: ListItem;
|
||||||
|
open: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let name: string =
|
let name: string = $derived(
|
||||||
node instanceof GPXFile
|
node instanceof GPXFile
|
||||||
? (node.metadata.name ?? '')
|
? (node.metadata.name ?? '')
|
||||||
: node instanceof Track
|
: node instanceof Track
|
||||||
? (node.name ?? '')
|
? (node.name ?? '')
|
||||||
: '';
|
: ''
|
||||||
let description: string =
|
);
|
||||||
|
let description: string = $derived(
|
||||||
node instanceof GPXFile
|
node instanceof GPXFile
|
||||||
? (node.metadata.desc ?? '')
|
? (node.metadata.desc ?? '')
|
||||||
: node instanceof Track
|
: node instanceof Track
|
||||||
? (node.desc ?? '')
|
? (node.desc ?? '')
|
||||||
: '';
|
: ''
|
||||||
|
);
|
||||||
|
|
||||||
$: if (!open) {
|
$effect(() => {
|
||||||
$editMetadata = false;
|
if (!open) {
|
||||||
}
|
editMetadata.current = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Popover.Root bind:open>
|
<Popover.Root bind:open>
|
||||||
<Popover.Trigger />
|
<Popover.Trigger />
|
||||||
<Popover.Content side="top" sideOffset={22} alignOffset={30} class="flex flex-col gap-3">
|
<Popover.Content side="top" sideOffset={22} alignOffset={30} class="flex flex-col gap-3">
|
||||||
<Label for="name">{$_('menu.metadata.name')}</Label>
|
<Label for="name">{i18n._('menu.metadata.name')}</Label>
|
||||||
<Input bind:value={name} id="name" class="font-semibold h-8" />
|
<Input bind:value={name} id="name" class="font-semibold h-8" />
|
||||||
<Label for="description">{$_('menu.metadata.description')}</Label>
|
<Label for="description">{i18n._('menu.metadata.description')}</Label>
|
||||||
<Textarea bind:value={description} id="description" />
|
<Textarea bind:value={description} id="description" />
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
on:click={() => {
|
onclick={() => {
|
||||||
dbUtils.applyToFile(item.getFileId(), (file) => {
|
dbUtils.applyToFile(item.getFileId(), (file) => {
|
||||||
if (item instanceof ListFileItem && node instanceof GPXFile) {
|
if (item instanceof ListFileItem && node instanceof GPXFile) {
|
||||||
file.metadata.name = name;
|
file.metadata.name = name;
|
||||||
@@ -59,7 +69,7 @@
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Save size="16" class="mr-1" />
|
<Save size="16" class="mr-1" />
|
||||||
{$_('menu.metadata.save')}
|
{i18n._('menu.metadata.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</Popover.Content>
|
</Popover.Content>
|
||||||
</Popover.Root>
|
</Popover.Root>
|
@@ -0,0 +1,3 @@
|
|||||||
|
export const editMetadata = $state({
|
||||||
|
current: false,
|
||||||
|
});
|
164
website/src/lib/components/file-list/style/StyleDialog.svelte
Normal file
164
website/src/lib/components/file-list/style/StyleDialog.svelte
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Label } from '$lib/components/ui/label/index.js';
|
||||||
|
import { Slider } from '$lib/components/ui/slider';
|
||||||
|
import * as Popover from '$lib/components/ui/popover';
|
||||||
|
import { dbUtils, getFile, settings } from '$lib/db';
|
||||||
|
import { Save } from '@lucide/svelte';
|
||||||
|
import { ListFileItem, ListTrackItem, type ListItem } from '$lib/components/file-list/FileList';
|
||||||
|
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
|
||||||
|
import { selection } from '../Selection';
|
||||||
|
import { gpxLayers } from '$lib/stores';
|
||||||
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
import type { LineStyleExtension } from 'gpx';
|
||||||
|
|
||||||
|
let {
|
||||||
|
item,
|
||||||
|
open = $bindable(),
|
||||||
|
}: {
|
||||||
|
item: ListItem;
|
||||||
|
open: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const { defaultOpacity, defaultWidth } = settings;
|
||||||
|
|
||||||
|
let color: string = $state('');
|
||||||
|
let opacity: number = $state(0);
|
||||||
|
let width: number = $state(0);
|
||||||
|
let colorChanged = $state(false);
|
||||||
|
let opacityChanged = $state(false);
|
||||||
|
let widthChanged = $state(false);
|
||||||
|
|
||||||
|
function setStyleInputs() {
|
||||||
|
opacity = $defaultOpacity;
|
||||||
|
width = $defaultWidth;
|
||||||
|
|
||||||
|
$selection.forEach((item) => {
|
||||||
|
if (item instanceof ListFileItem) {
|
||||||
|
let file = getFile(item.getFileId());
|
||||||
|
let layer = gpxLayers.get(item.getFileId());
|
||||||
|
if (file && layer) {
|
||||||
|
let style = file.getStyle();
|
||||||
|
color = layer.layerColor;
|
||||||
|
if (style.opacity.length > 0) {
|
||||||
|
opacity = style.opacity[0];
|
||||||
|
}
|
||||||
|
if (style.width.length > 0) {
|
||||||
|
width = style.width[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (item instanceof ListTrackItem) {
|
||||||
|
let file = getFile(item.getFileId());
|
||||||
|
let layer = gpxLayers.get(item.getFileId());
|
||||||
|
if (file && layer) {
|
||||||
|
color = layer.layerColor;
|
||||||
|
let track = file.trk[item.getTrackIndex()];
|
||||||
|
let style = track.getStyle();
|
||||||
|
if (style) {
|
||||||
|
if (style['gpx_style:color']) {
|
||||||
|
color = style['gpx_style:color'];
|
||||||
|
}
|
||||||
|
if (style['gpx_style:opacity']) {
|
||||||
|
opacity = style['gpx_style:opacity'];
|
||||||
|
}
|
||||||
|
if (style['gpx_style:width']) {
|
||||||
|
width = style['gpx_style:width'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
colorChanged = false;
|
||||||
|
opacityChanged = false;
|
||||||
|
widthChanged = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if ($selection && open) {
|
||||||
|
setStyleInputs();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!open) {
|
||||||
|
editStyle.current = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function applyStyle() {
|
||||||
|
let style: LineStyleExtension = {};
|
||||||
|
if (colorChanged) {
|
||||||
|
style['gpx_style:color'] = color;
|
||||||
|
}
|
||||||
|
if (opacityChanged) {
|
||||||
|
style['gpx_style:opacity'] = opacity;
|
||||||
|
}
|
||||||
|
if (widthChanged) {
|
||||||
|
style['gpx_style:width'] = width;
|
||||||
|
}
|
||||||
|
dbUtils.setStyleToSelection(style);
|
||||||
|
|
||||||
|
if (item instanceof ListFileItem && $selection.size === gpxLayers.size) {
|
||||||
|
if (style['gpx_style:opacity']) {
|
||||||
|
$defaultOpacity = style['gpx_style:opacity'];
|
||||||
|
}
|
||||||
|
if (style['gpx_style:width']) {
|
||||||
|
$defaultWidth = style['gpx_style:width'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Popover.Root bind:open>
|
||||||
|
<Popover.Trigger />
|
||||||
|
<Popover.Content side="top" sideOffset={22} alignOffset={30} class="flex flex-col gap-3">
|
||||||
|
<Label class="flex flex-row gap-2 items-center justify-between">
|
||||||
|
{i18n._('menu.style.color')}
|
||||||
|
<Input
|
||||||
|
bind:value={color}
|
||||||
|
type="color"
|
||||||
|
class="p-0 h-6 w-40"
|
||||||
|
onchange={() => (colorChanged = true)}
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
<Label class="flex flex-row gap-2 items-center justify-between">
|
||||||
|
{i18n._('menu.style.opacity')}
|
||||||
|
<div class="w-40 p-2">
|
||||||
|
<Slider
|
||||||
|
bind:value={opacity}
|
||||||
|
min={0.3}
|
||||||
|
max={1}
|
||||||
|
step={0.1}
|
||||||
|
onValueChange={() => (opacityChanged = true)}
|
||||||
|
type="single"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
<Label class="flex flex-row gap-2 items-center justify-between">
|
||||||
|
{i18n._('menu.style.width')}
|
||||||
|
<div class="w-40 p-2">
|
||||||
|
<Slider
|
||||||
|
bind:value={width}
|
||||||
|
id="width"
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
step={1}
|
||||||
|
onValueChange={() => (widthChanged = true)}
|
||||||
|
type="single"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={!colorChanged && !opacityChanged && !widthChanged}
|
||||||
|
onclick={applyStyle}
|
||||||
|
>
|
||||||
|
<Save size="16" class="mr-1" />
|
||||||
|
{i18n._('menu.metadata.save')}
|
||||||
|
</Button>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Root>
|
@@ -0,0 +1,3 @@
|
|||||||
|
export const editStyle = $state({
|
||||||
|
current: false,
|
||||||
|
});
|
@@ -1,23 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Button } from '$lib/components/ui/button';
|
|
||||||
import { ClipboardCopy } from 'lucide-svelte';
|
|
||||||
import { _ } from '$lib/i18n';
|
|
||||||
import type { Coordinates } from 'gpx';
|
|
||||||
|
|
||||||
export let coordinates: Coordinates;
|
|
||||||
export let onCopy: () => void = () => {};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
class="w-full px-2 py-1 h-8 justify-start {$$props.class}"
|
|
||||||
variant="outline"
|
|
||||||
on:click={() => {
|
|
||||||
navigator.clipboard.writeText(
|
|
||||||
`${coordinates.lat.toFixed(6)}, ${coordinates.lon.toFixed(6)}`
|
|
||||||
);
|
|
||||||
onCopy();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ClipboardCopy size="16" class="mr-1" />
|
|
||||||
{$_('menu.copy_coordinates')}
|
|
||||||
</Button>
|
|
@@ -1,222 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import CustomControl from '$lib/components/custom-control/CustomControl.svelte';
|
|
||||||
import LayerTree from './LayerTree.svelte';
|
|
||||||
|
|
||||||
import { Separator } from '$lib/components/ui/separator';
|
|
||||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
|
||||||
|
|
||||||
import { Layers } from 'lucide-svelte';
|
|
||||||
|
|
||||||
import { basemaps, defaultBasemap, overlays } from '$lib/assets/layers';
|
|
||||||
import { settings } from '$lib/db';
|
|
||||||
import { map } from '$lib/stores';
|
|
||||||
import { get, writable } from 'svelte/store';
|
|
||||||
import { customBasemapUpdate, getLayers } from './utils';
|
|
||||||
import { OverpassLayer } from './OverpassLayer';
|
|
||||||
|
|
||||||
let container: HTMLDivElement;
|
|
||||||
let overpassLayer: OverpassLayer;
|
|
||||||
|
|
||||||
const {
|
|
||||||
currentBasemap,
|
|
||||||
previousBasemap,
|
|
||||||
currentOverlays,
|
|
||||||
currentOverpassQueries,
|
|
||||||
selectedBasemapTree,
|
|
||||||
selectedOverlayTree,
|
|
||||||
selectedOverpassTree,
|
|
||||||
customLayers,
|
|
||||||
opacities,
|
|
||||||
} = settings;
|
|
||||||
|
|
||||||
function setStyle() {
|
|
||||||
if ($map) {
|
|
||||||
let basemap = basemaps.hasOwnProperty($currentBasemap)
|
|
||||||
? basemaps[$currentBasemap]
|
|
||||||
: ($customLayers[$currentBasemap]?.value ?? basemaps[defaultBasemap]);
|
|
||||||
$map.removeImport('basemap');
|
|
||||||
if (typeof basemap === 'string') {
|
|
||||||
$map.addImport({ id: 'basemap', url: basemap }, 'overlays');
|
|
||||||
} else {
|
|
||||||
$map.addImport(
|
|
||||||
{
|
|
||||||
id: 'basemap',
|
|
||||||
data: basemap,
|
|
||||||
},
|
|
||||||
'overlays'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$: if ($map && ($currentBasemap || $customBasemapUpdate)) {
|
|
||||||
setStyle();
|
|
||||||
}
|
|
||||||
|
|
||||||
function addOverlay(id: string) {
|
|
||||||
try {
|
|
||||||
let overlay = $customLayers.hasOwnProperty(id) ? $customLayers[id].value : overlays[id];
|
|
||||||
if (typeof overlay === 'string') {
|
|
||||||
$map.addImport({ id, url: overlay });
|
|
||||||
} else {
|
|
||||||
if ($opacities.hasOwnProperty(id)) {
|
|
||||||
overlay = {
|
|
||||||
...overlay,
|
|
||||||
layers: overlay.layers.map((layer) => {
|
|
||||||
if (layer.type === 'raster') {
|
|
||||||
if (!layer.paint) {
|
|
||||||
layer.paint = {};
|
|
||||||
}
|
|
||||||
layer.paint['raster-opacity'] = $opacities[id];
|
|
||||||
}
|
|
||||||
return layer;
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
$map.addImport({
|
|
||||||
id,
|
|
||||||
data: overlay,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// No reliable way to check if the map is ready to add sources and layers
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateOverlays() {
|
|
||||||
if ($map && $currentOverlays && $opacities) {
|
|
||||||
let overlayLayers = getLayers($currentOverlays);
|
|
||||||
try {
|
|
||||||
let activeOverlays = $map.getStyle().imports.reduce((acc, i) => {
|
|
||||||
if (!['basemap', 'overlays', 'glyphs-and-sprite'].includes(i.id)) {
|
|
||||||
acc[i.id] = i;
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
let toRemove = Object.keys(activeOverlays).filter((id) => !overlayLayers[id]);
|
|
||||||
toRemove.forEach((id) => {
|
|
||||||
$map.removeImport(id);
|
|
||||||
});
|
|
||||||
let toAdd = Object.entries(overlayLayers)
|
|
||||||
.filter(([id, selected]) => selected && !activeOverlays.hasOwnProperty(id))
|
|
||||||
.map(([id]) => id);
|
|
||||||
toAdd.forEach((id) => {
|
|
||||||
addOverlay(id);
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
// No reliable way to check if the map is ready to add sources and layers
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$: if ($map && $currentOverlays && $opacities) {
|
|
||||||
updateOverlays();
|
|
||||||
}
|
|
||||||
|
|
||||||
$: if ($map) {
|
|
||||||
if (overpassLayer) {
|
|
||||||
overpassLayer.remove();
|
|
||||||
}
|
|
||||||
overpassLayer = new OverpassLayer($map);
|
|
||||||
overpassLayer.add();
|
|
||||||
$map.on('style.import.load', updateOverlays);
|
|
||||||
}
|
|
||||||
|
|
||||||
let selectedBasemap = writable(get(currentBasemap));
|
|
||||||
selectedBasemap.subscribe((value) => {
|
|
||||||
// Updates coming from radio buttons
|
|
||||||
if (value !== get(currentBasemap)) {
|
|
||||||
previousBasemap.set(get(currentBasemap));
|
|
||||||
currentBasemap.set(value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
currentBasemap.subscribe((value) => {
|
|
||||||
// Updates coming from the database, or from the user swapping basemaps
|
|
||||||
if (value !== get(selectedBasemap)) {
|
|
||||||
selectedBasemap.set(value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let open = false;
|
|
||||||
function openLayerControl() {
|
|
||||||
open = true;
|
|
||||||
}
|
|
||||||
function closeLayerControl() {
|
|
||||||
open = false;
|
|
||||||
}
|
|
||||||
let cancelEvents = false;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<CustomControl class="group min-w-[29px] min-h-[29px] overflow-hidden">
|
|
||||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
|
||||||
<div
|
|
||||||
bind:this={container}
|
|
||||||
class="h-full w-full"
|
|
||||||
on:mouseenter={openLayerControl}
|
|
||||||
on:mouseleave={closeLayerControl}
|
|
||||||
on:pointerenter={() => {
|
|
||||||
if (!open) {
|
|
||||||
cancelEvents = true;
|
|
||||||
openLayerControl();
|
|
||||||
setTimeout(() => {
|
|
||||||
cancelEvents = false;
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex flex-row justify-center items-center delay-100 transition-[opacity] duration-0 {open
|
|
||||||
? 'opacity-0 w-0 h-0 delay-0'
|
|
||||||
: 'w-[29px] h-[29px]'}"
|
|
||||||
>
|
|
||||||
<Layers size="20" />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="transition-[grid-template-rows grid-template-cols] grid grid-rows-[0fr] grid-cols-[0fr] duration-150 h-full {open
|
|
||||||
? 'grid-rows-[1fr] grid-cols-[1fr]'
|
|
||||||
: ''} {cancelEvents ? 'pointer-events-none' : ''}"
|
|
||||||
>
|
|
||||||
<ScrollArea>
|
|
||||||
<div class="h-fit">
|
|
||||||
<div class="p-2">
|
|
||||||
<LayerTree
|
|
||||||
layerTree={$selectedBasemapTree}
|
|
||||||
name="basemaps"
|
|
||||||
bind:selected={$selectedBasemap}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Separator class="w-full" />
|
|
||||||
<div class="p-2">
|
|
||||||
{#if $currentOverlays}
|
|
||||||
<LayerTree
|
|
||||||
layerTree={$selectedOverlayTree}
|
|
||||||
name="overlays"
|
|
||||||
multiple={true}
|
|
||||||
bind:checked={$currentOverlays}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<Separator class="w-full" />
|
|
||||||
<div class="p-2">
|
|
||||||
{#if $currentOverpassQueries}
|
|
||||||
<LayerTree
|
|
||||||
layerTree={$selectedOverpassTree}
|
|
||||||
name="overpass"
|
|
||||||
multiple={true}
|
|
||||||
bind:checked={$currentOverpassQueries}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CustomControl>
|
|
||||||
|
|
||||||
<svelte:window
|
|
||||||
on:click={(e) => {
|
|
||||||
if (open && !cancelEvents && !container.contains(e.target)) {
|
|
||||||
closeLayerControl();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
@@ -1,20 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import LayerTreeNode from './LayerTreeNode.svelte';
|
|
||||||
import { type LayerTreeType } from '$lib/assets/layers';
|
|
||||||
import CollapsibleTree from '$lib/components/collapsible-tree/CollapsibleTree.svelte';
|
|
||||||
|
|
||||||
export let layerTree: LayerTreeType;
|
|
||||||
export let name: string;
|
|
||||||
export let selected: string | undefined = undefined;
|
|
||||||
export let multiple: boolean = false;
|
|
||||||
|
|
||||||
export let checked: LayerTreeType = {};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<form>
|
|
||||||
<fieldset class="min-w-64 mb-1">
|
|
||||||
<CollapsibleTree nohover={true}>
|
|
||||||
<LayerTreeNode {name} node={layerTree} bind:selected {multiple} bind:checked />
|
|
||||||
</CollapsibleTree>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
20
website/src/lib/components/map/CoordinatesPopup.svelte
Normal file
20
website/src/lib/components/map/CoordinatesPopup.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { map } from '$lib/components/map/map.svelte';
|
||||||
|
import { trackpointPopup } from '$lib/components/map/gpx-layer/GPXLayerPopup';
|
||||||
|
import { TrackPoint } from 'gpx';
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (map.current) {
|
||||||
|
map.current.on('contextmenu', (e) => {
|
||||||
|
trackpointPopup?.setItem({
|
||||||
|
item: new TrackPoint({
|
||||||
|
attributes: {
|
||||||
|
lat: e.lngLat.lat,
|
||||||
|
lon: e.lngLat.lng,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
254
website/src/lib/components/map/Map.svelte
Normal file
254
website/src/lib/components/map/Map.svelte
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import mapboxgl from 'mapbox-gl';
|
||||||
|
import 'mapbox-gl/dist/mapbox-gl.css';
|
||||||
|
import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { settings } from '$lib/logic/settings.svelte';
|
||||||
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { map } from '$lib/components/map/utils.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
accessToken = PUBLIC_MAPBOX_TOKEN,
|
||||||
|
geolocate = true,
|
||||||
|
geocoder = true,
|
||||||
|
hash = true,
|
||||||
|
class: className = '',
|
||||||
|
}: {
|
||||||
|
accessToken?: string;
|
||||||
|
geolocate?: boolean;
|
||||||
|
geocoder?: boolean;
|
||||||
|
hash?: boolean;
|
||||||
|
class?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
mapboxgl.accessToken = accessToken;
|
||||||
|
|
||||||
|
let webgl2Supported = $state(true);
|
||||||
|
let embeddedApp = $state(false);
|
||||||
|
|
||||||
|
const { distanceUnits, elevationProfile, treeFileView, bottomPanelSize, rightPanelSize } =
|
||||||
|
settings;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
let gl = document.createElement('canvas').getContext('webgl2');
|
||||||
|
if (!gl) {
|
||||||
|
webgl2Supported = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (window.top !== window.self && !page.route.id?.includes('embed')) {
|
||||||
|
embeddedApp = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let language = page.params.language;
|
||||||
|
if (language === 'zh') {
|
||||||
|
language = 'zh-Hans';
|
||||||
|
} else if (language?.includes('-')) {
|
||||||
|
language = language.split('-')[0];
|
||||||
|
} else if (language === '' || language === undefined) {
|
||||||
|
language = 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
map.init(PUBLIC_MAPBOX_TOKEN, language, distanceUnits.value, hash, geocoder, geolocate);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
map.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (
|
||||||
|
!treeFileView.value ||
|
||||||
|
!elevationProfile.value ||
|
||||||
|
bottomPanelSize.value ||
|
||||||
|
rightPanelSize.value
|
||||||
|
) {
|
||||||
|
map.resize();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={className}>
|
||||||
|
<div id="map" class="h-full {webgl2Supported && !embeddedApp ? '' : 'hidden'}"></div>
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center justify-center gap-3 h-full {webgl2Supported &&
|
||||||
|
!embeddedApp
|
||||||
|
? 'hidden'
|
||||||
|
: ''} {embeddedApp ? 'z-30' : ''}"
|
||||||
|
>
|
||||||
|
{#if !webgl2Supported}
|
||||||
|
<p>{i18n._('webgl2_required')}</p>
|
||||||
|
<Button href="https://get.webgl.org/webgl2/" target="_blank">
|
||||||
|
{i18n._('enable_webgl2')}
|
||||||
|
</Button>
|
||||||
|
{:else if embeddedApp}
|
||||||
|
<p>The app cannot be embedded in an iframe.</p>
|
||||||
|
<Button href="https://gpx.studio/help/integration" target="_blank">
|
||||||
|
Learn how to create a map for your website
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="postcss">
|
||||||
|
@reference "../../../app.css";
|
||||||
|
|
||||||
|
div :global(.mapboxgl-map) {
|
||||||
|
@apply font-sans;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-top-right > .mapboxgl-ctrl) {
|
||||||
|
@apply shadow-md;
|
||||||
|
@apply bg-background;
|
||||||
|
@apply text-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-icon) {
|
||||||
|
@apply dark:brightness-[4.7];
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-geocoder) {
|
||||||
|
@apply flex;
|
||||||
|
@apply flex-row;
|
||||||
|
@apply w-fit;
|
||||||
|
@apply min-w-fit;
|
||||||
|
@apply items-center;
|
||||||
|
@apply shadow-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.suggestions) {
|
||||||
|
@apply shadow-md;
|
||||||
|
@apply bg-background;
|
||||||
|
@apply text-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-geocoder .suggestions > li > a) {
|
||||||
|
@apply text-foreground;
|
||||||
|
@apply hover:text-accent-foreground;
|
||||||
|
@apply hover:bg-accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-geocoder .suggestions > .active > a) {
|
||||||
|
@apply bg-background;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-geocoder--button) {
|
||||||
|
@apply bg-transparent;
|
||||||
|
@apply hover:bg-transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-geocoder--icon) {
|
||||||
|
@apply fill-foreground;
|
||||||
|
@apply hover:fill-accent-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-geocoder--icon-search) {
|
||||||
|
@apply relative;
|
||||||
|
@apply top-0;
|
||||||
|
@apply left-0;
|
||||||
|
@apply my-2;
|
||||||
|
@apply w-[29px];
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-geocoder--input) {
|
||||||
|
@apply relative;
|
||||||
|
@apply w-64;
|
||||||
|
@apply py-0;
|
||||||
|
@apply pl-2;
|
||||||
|
@apply focus:outline-none;
|
||||||
|
@apply transition-[width];
|
||||||
|
@apply duration-200;
|
||||||
|
@apply text-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-geocoder--collapsed .mapboxgl-ctrl-geocoder--input) {
|
||||||
|
@apply w-0;
|
||||||
|
@apply p-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-top-right) {
|
||||||
|
@apply z-40;
|
||||||
|
@apply flex;
|
||||||
|
@apply flex-col;
|
||||||
|
@apply items-end;
|
||||||
|
@apply h-full;
|
||||||
|
@apply overflow-hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizontal :global(.mapboxgl-ctrl-bottom-left) {
|
||||||
|
@apply bottom-[42px];
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizontal :global(.mapboxgl-ctrl-bottom-right) {
|
||||||
|
@apply bottom-[42px];
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-attrib) {
|
||||||
|
@apply dark:bg-transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-compact-show.mapboxgl-ctrl-attrib) {
|
||||||
|
@apply dark:bg-background;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-attrib-button) {
|
||||||
|
@apply dark:bg-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-compact-show .mapboxgl-ctrl-attrib-button) {
|
||||||
|
@apply dark:bg-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-attrib a) {
|
||||||
|
@apply text-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-popup) {
|
||||||
|
@apply w-fit;
|
||||||
|
@apply z-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-popup-content) {
|
||||||
|
@apply p-0;
|
||||||
|
@apply bg-transparent;
|
||||||
|
@apply shadow-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-popup-anchor-top .mapboxgl-popup-tip) {
|
||||||
|
@apply border-b-background;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-popup-anchor-top-left .mapboxgl-popup-tip) {
|
||||||
|
@apply border-b-background;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-popup-anchor-top-right .mapboxgl-popup-tip) {
|
||||||
|
@apply border-b-background;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip) {
|
||||||
|
@apply border-t-background;
|
||||||
|
@apply drop-shadow-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-tip) {
|
||||||
|
@apply border-t-background;
|
||||||
|
@apply drop-shadow-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-tip) {
|
||||||
|
@apply border-t-background;
|
||||||
|
@apply drop-shadow-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-popup-anchor-left .mapboxgl-popup-tip) {
|
||||||
|
@apply border-r-background;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-popup-anchor-right .mapboxgl-popup-tip) {
|
||||||
|
@apply border-l-background;
|
||||||
|
}
|
||||||
|
</style>
|
22
website/src/lib/components/map/MapPopup.svelte
Normal file
22
website/src/lib/components/map/MapPopup.svelte
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { TrackPoint, Waypoint } from 'gpx';
|
||||||
|
import WaypointPopup from '$lib/components/map/gpx-layer/WaypointPopup.svelte';
|
||||||
|
import TrackpointPopup from '$lib/components/map/gpx-layer/TrackpointPopup.svelte';
|
||||||
|
import OverpassPopup from '$lib/components/map/layer-control/OverpassPopup.svelte';
|
||||||
|
import type { PopupItem } from '$lib/components/map/map.svelte';
|
||||||
|
|
||||||
|
let { item, container = null }: { item: PopupItem | null; container: HTMLDivElement | null } =
|
||||||
|
$props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={container}>
|
||||||
|
{#if item}
|
||||||
|
{#if item.item instanceof Waypoint}
|
||||||
|
<WaypointPopup waypoint={item} />
|
||||||
|
{:else if item.item instanceof TrackPoint}
|
||||||
|
<TrackpointPopup trackpoint={item} />
|
||||||
|
{:else}
|
||||||
|
<OverpassPopup poi={item} />
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
@@ -0,0 +1,38 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import CustomControl from './CustomControl';
|
||||||
|
import { map } from '$lib/components/map/utils.svelte';
|
||||||
|
import { onMount, type Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
position = 'top-right',
|
||||||
|
class: className = '',
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
||||||
|
class: string;
|
||||||
|
children: Snippet;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let control: CustomControl | null = null;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
map.onLoad((map: mapboxgl.Map) => {
|
||||||
|
if (position.includes('right')) container.classList.add('float-right');
|
||||||
|
else container.classList.add('float-left');
|
||||||
|
container.classList.remove('hidden');
|
||||||
|
if (control === null) {
|
||||||
|
control = new CustomControl(container);
|
||||||
|
}
|
||||||
|
map.addControl(control, position);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={container}
|
||||||
|
class="{className ||
|
||||||
|
''} clear-both translate-0 m-[10px] mb-0 last:mb-[10px] pointer-events-auto bg-background rounded shadow-md hidden"
|
||||||
|
>
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
@@ -0,0 +1,30 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { ClipboardCopy } from '@lucide/svelte';
|
||||||
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
import type { Coordinates } from 'gpx';
|
||||||
|
|
||||||
|
let {
|
||||||
|
coordinates,
|
||||||
|
onCopy = () => {},
|
||||||
|
class: className = '',
|
||||||
|
}: {
|
||||||
|
coordinates: Coordinates;
|
||||||
|
onCopy: () => void;
|
||||||
|
class?: string;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
class="w-full px-2 py-1 h-8 justify-start {className}"
|
||||||
|
variant="outline"
|
||||||
|
onclick={() => {
|
||||||
|
navigator.clipboard.writeText(
|
||||||
|
`${coordinates.lat.toFixed(6)}, ${coordinates.lon.toFixed(6)}`
|
||||||
|
);
|
||||||
|
onCopy();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ClipboardCopy size="16" class="mr-1" />
|
||||||
|
{i18n._('menu.copy_coordinates')}
|
||||||
|
</Button>
|
@@ -1,8 +1,9 @@
|
|||||||
import { settings } from '$lib/db';
|
import { settings } from '$lib/db';
|
||||||
import { gpxStatistics } from '$lib/stores';
|
import { gpxStatistics } from '$lib/stores';
|
||||||
|
import type { GeoJSONSource } from 'mapbox-gl';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
const { distanceMarkers, distanceUnits } = settings;
|
// const { distanceMarkers, distanceUnits } = settings;
|
||||||
|
|
||||||
const stops = [
|
const stops = [
|
||||||
[100, 0],
|
[100, 0],
|
||||||
@@ -30,7 +31,8 @@ export class DistanceMarkers {
|
|||||||
update() {
|
update() {
|
||||||
try {
|
try {
|
||||||
if (get(distanceMarkers)) {
|
if (get(distanceMarkers)) {
|
||||||
let distanceSource = this.map.getSource('distance-markers');
|
let distanceSource: GeoJSONSource | undefined =
|
||||||
|
this.map.getSource('distance-markers');
|
||||||
if (distanceSource) {
|
if (distanceSource) {
|
||||||
distanceSource.setData(this.getDistanceMarkersGeoJSON());
|
distanceSource.setData(this.getDistanceMarkersGeoJSON());
|
||||||
} else {
|
} else {
|
@@ -1,4 +1,4 @@
|
|||||||
import { currentTool, map, Tool } from '$lib/stores';
|
import { currentTool, map, splitAs, Tool } from '$lib/stores';
|
||||||
import { settings, type GPXFileWithStatistics, dbUtils } from '$lib/db';
|
import { settings, type GPXFileWithStatistics, dbUtils } from '$lib/db';
|
||||||
import { get, type Readable } from 'svelte/store';
|
import { get, type Readable } from 'svelte/store';
|
||||||
import mapboxgl from 'mapbox-gl';
|
import mapboxgl from 'mapbox-gl';
|
||||||
@@ -82,7 +82,7 @@ function getMarkerForSymbol(symbol: string | undefined, layerColor: string) {
|
|||||||
</svg>`;
|
</svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { directionMarkers, treeFileView, defaultOpacity, defaultWidth } = settings;
|
// const { directionMarkers, treeFileView, defaultOpacity, defaultWidth } = settings;
|
||||||
|
|
||||||
export class GPXLayer {
|
export class GPXLayer {
|
||||||
map: mapboxgl.Map;
|
map: mapboxgl.Map;
|
||||||
@@ -469,7 +469,7 @@ export class GPXLayer {
|
|||||||
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
|
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
dbUtils.split(this.fileId, trackIndex, segmentIndex, {
|
dbUtils.split(splitAs.current, this.fileId, trackIndex, segmentIndex, {
|
||||||
lat: e.lngLat.lat,
|
lat: e.lngLat.lat,
|
||||||
lon: e.lngLat.lng,
|
lon: e.lngLat.lng,
|
||||||
});
|
});
|
@@ -1,5 +1,5 @@
|
|||||||
import { dbUtils } from '$lib/db';
|
import { dbUtils } from '$lib/db';
|
||||||
import { MapPopup } from '$lib/components/MapPopup';
|
import { MapPopup } from '$lib/components/map/map.svelte';
|
||||||
|
|
||||||
export let waypointPopup: MapPopup | null = null;
|
export let waypointPopup: MapPopup | null = null;
|
||||||
export let trackpointPopup: MapPopup | null = null;
|
export let trackpointPopup: MapPopup | null = null;
|
@@ -1,13 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { TrackPoint } from 'gpx';
|
import type { TrackPoint } from 'gpx';
|
||||||
import type { PopupItem } from '$lib/components/MapPopup';
|
import type { PopupItem } from '$lib/components/map/map.svelte';
|
||||||
import CopyCoordinates from '$lib/components/gpx-layer/CopyCoordinates.svelte';
|
import CopyCoordinates from '$lib/components/map/gpx-layer/CopyCoordinates.svelte';
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
import WithUnits from '$lib/components/WithUnits.svelte';
|
||||||
import { Compass, Mountain, Timer } from 'lucide-svelte';
|
import { Compass, Mountain, Timer } from '@lucide/svelte';
|
||||||
import { _, df } from '$lib/i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
|
||||||
export let trackpoint: PopupItem<TrackPoint>;
|
let { trackpoint }: { trackpoint: PopupItem<TrackPoint> } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card.Root class="border-none shadow-md text-base p-2">
|
<Card.Root class="border-none shadow-md text-base p-2">
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
{#if trackpoint.item.time}
|
{#if trackpoint.item.time}
|
||||||
<div class="flex flex-row items-center gap-1">
|
<div class="flex flex-row items-center gap-1">
|
||||||
<Timer size="14" />
|
<Timer size="14" />
|
||||||
{$df.format(trackpoint.item.time)}
|
{i18n.df.format(trackpoint.item.time)}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<CopyCoordinates
|
<CopyCoordinates
|
@@ -2,16 +2,16 @@
|
|||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||||
import CopyCoordinates from '$lib/components/gpx-layer/CopyCoordinates.svelte';
|
import CopyCoordinates from '$lib/components/map/gpx-layer/CopyCoordinates.svelte';
|
||||||
import { deleteWaypoint } from './GPXLayerPopup';
|
import { deleteWaypoint } from './GPXLayerPopup';
|
||||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
import WithUnits from '$lib/components/WithUnits.svelte';
|
||||||
import { Dot, ExternalLink, Trash2 } from 'lucide-svelte';
|
import { Dot, ExternalLink, Trash2 } from '@lucide/svelte';
|
||||||
import { Tool, currentTool } from '$lib/stores';
|
import { tool, Tool } from '$lib/components/toolbar/utils.svelte';
|
||||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||||
import { _ } from '$lib/i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import sanitizeHtml from 'sanitize-html';
|
import sanitizeHtml from 'sanitize-html';
|
||||||
import type { Waypoint } from 'gpx';
|
import type { Waypoint } from 'gpx';
|
||||||
import type { PopupItem } from '$lib/components/MapPopup';
|
import type { PopupItem } from '$lib/components/map/map.svelte';
|
||||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||||
|
|
||||||
export let waypoint: PopupItem<Waypoint>;
|
export let waypoint: PopupItem<Waypoint>;
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
<ExternalLink size="12" class="inline-block mb-1.5" />
|
<ExternalLink size="12" class="inline-block mb-1.5" />
|
||||||
</a>
|
</a>
|
||||||
{:else}
|
{:else}
|
||||||
{waypoint.item.name ?? $_('gpx.waypoint')}
|
{waypoint.item.name ?? i18n._('gpx.waypoint')}
|
||||||
{/if}
|
{/if}
|
||||||
</Card.Title>
|
</Card.Title>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<span class="w-4 inline-block"></span>
|
<span class="w-4 inline-block"></span>
|
||||||
{/if}
|
{/if}
|
||||||
{$_(`gpx.symbol.${symbolKey}`)}
|
{i18n._(`gpx.symbol.${symbolKey}`)}
|
||||||
</span>
|
</span>
|
||||||
<Dot size="16" />
|
<Dot size="16" />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
<WithUnits value={waypoint.item.ele} type="elevation" />
|
<WithUnits value={waypoint.item.ele} type="elevation" />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea class="flex flex-col" viewportClasses="max-h-[30dvh]">
|
<ScrollArea class="flex flex-col max-h-[30dvh]">
|
||||||
{#if waypoint.item.desc}
|
{#if waypoint.item.desc}
|
||||||
<span class="whitespace-pre-wrap">{@html sanitize(waypoint.item.desc)}</span>
|
<span class="whitespace-pre-wrap">{@html sanitize(waypoint.item.desc)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -80,14 +80,14 @@
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
<div class="mt-2 flex flex-col gap-1">
|
<div class="mt-2 flex flex-col gap-1">
|
||||||
<CopyCoordinates coordinates={waypoint.item.attributes} />
|
<CopyCoordinates coordinates={waypoint.item.attributes} />
|
||||||
{#if $currentTool === Tool.WAYPOINT}
|
{#if tool.current === Tool.WAYPOINT}
|
||||||
<Button
|
<Button
|
||||||
class="w-full px-2 py-1 h-8 justify-start"
|
class="w-full px-2 py-1 h-8 justify-start"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
on:click={() => deleteWaypoint(waypoint.fileId, waypoint.item._data.index)}
|
onclick={() => deleteWaypoint(waypoint.fileId, waypoint.item._data.index)}
|
||||||
>
|
>
|
||||||
<Trash2 size="16" class="mr-1" />
|
<Trash2 size="16" class="mr-1" />
|
||||||
{$_('menu.delete')}
|
{i18n._('menu.delete')}
|
||||||
<Shortcut shift={true} click={true} />
|
<Shortcut shift={true} click={true} />
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -96,6 +96,8 @@
|
|||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
|
@reference "../../../../app.css";
|
||||||
|
|
||||||
div :global(a) {
|
div :global(a) {
|
||||||
@apply text-link;
|
@apply text-link;
|
||||||
@apply hover:underline;
|
@apply hover:underline;
|
@@ -16,14 +16,14 @@
|
|||||||
Move,
|
Move,
|
||||||
Map,
|
Map,
|
||||||
Layers2,
|
Layers2,
|
||||||
} from 'lucide-svelte';
|
} from '@lucide/svelte';
|
||||||
import { _ } from '$lib/i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { settings } from '$lib/db';
|
import { settings } from '$lib/db';
|
||||||
import { defaultBasemap, type CustomLayer } from '$lib/assets/layers';
|
import { defaultBasemap, type CustomLayer } from '$lib/assets/layers';
|
||||||
import { map } from '$lib/stores';
|
import { map } from '$lib/stores';
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import Sortable from 'sortablejs/Sortable';
|
import Sortable from 'sortablejs/Sortable';
|
||||||
import { customBasemapUpdate } from './utils';
|
import { customBasemapUpdate } from './utils.svelte';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
customLayers,
|
customLayers,
|
||||||
@@ -298,7 +298,7 @@
|
|||||||
{#if $customBasemapOrder.length > 0}
|
{#if $customBasemapOrder.length > 0}
|
||||||
<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" />
|
||||||
{$_('layers.label.basemaps')}
|
{i18n._('layers.label.basemaps')}
|
||||||
<div class="grow">
|
<div class="grow">
|
||||||
<Separator />
|
<Separator />
|
||||||
</div>
|
</div>
|
||||||
@@ -324,7 +324,7 @@
|
|||||||
{#if $customOverlayOrder.length > 0}
|
{#if $customOverlayOrder.length > 0}
|
||||||
<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" />
|
||||||
{$_('layers.label.overlays')}
|
{i18n._('layers.label.overlays')}
|
||||||
<div class="grow">
|
<div class="grow">
|
||||||
<Separator />
|
<Separator />
|
||||||
</div>
|
</div>
|
||||||
@@ -352,24 +352,24 @@
|
|||||||
<Card.Header class="p-3">
|
<Card.Header class="p-3">
|
||||||
<Card.Title class="text-base">
|
<Card.Title class="text-base">
|
||||||
{#if selectedLayerId}
|
{#if selectedLayerId}
|
||||||
{$_('layers.custom_layers.edit')}
|
{i18n._('layers.custom_layers.edit')}
|
||||||
{:else}
|
{:else}
|
||||||
{$_('layers.custom_layers.new')}
|
{i18n._('layers.custom_layers.new')}
|
||||||
{/if}
|
{/if}
|
||||||
</Card.Title>
|
</Card.Title>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content class="p-3 pt-0">
|
<Card.Content class="p-3 pt-0">
|
||||||
<fieldset class="flex flex-col gap-2">
|
<fieldset class="flex flex-col gap-2">
|
||||||
<Label for="name">{$_('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" />
|
||||||
<Label for="url">{$_('layers.custom_layers.urls')}</Label>
|
<Label for="url">{i18n._('layers.custom_layers.urls')}</Label>
|
||||||
{#each tileUrls as url, i}
|
{#each tileUrls as url, i}
|
||||||
<div class="flex flex-row gap-2">
|
<div class="flex flex-row gap-2">
|
||||||
<Input
|
<Input
|
||||||
bind:value={tileUrls[i]}
|
bind:value={tileUrls[i]}
|
||||||
id="url"
|
id="url"
|
||||||
class="h-8"
|
class="h-8"
|
||||||
placeholder={$_('layers.custom_layers.url_placeholder')}
|
placeholder={i18n._('layers.custom_layers.url_placeholder')}
|
||||||
/>
|
/>
|
||||||
{#if tileUrls.length > 1}
|
{#if tileUrls.length > 1}
|
||||||
<Button
|
<Button
|
||||||
@@ -393,7 +393,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{#if resourceType === 'raster'}
|
{#if resourceType === 'raster'}
|
||||||
<Label for="maxZoom">{$_('layers.custom_layers.max_zoom')}</Label>
|
<Label for="maxZoom">{i18n._('layers.custom_layers.max_zoom')}</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
bind:value={maxZoom}
|
bind:value={maxZoom}
|
||||||
@@ -403,22 +403,22 @@
|
|||||||
class="h-8"
|
class="h-8"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<Label>{$_('layers.custom_layers.layer_type')}</Label>
|
<Label>{i18n._('layers.custom_layers.layer_type')}</Label>
|
||||||
<RadioGroup.Root bind:value={layerType} class="flex flex-row">
|
<RadioGroup.Root bind:value={layerType} class="flex flex-row">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<RadioGroup.Item value="basemap" id="basemap" />
|
<RadioGroup.Item value="basemap" id="basemap" />
|
||||||
<Label for="basemap">{$_('layers.custom_layers.basemap')}</Label>
|
<Label for="basemap">{i18n._('layers.custom_layers.basemap')}</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<RadioGroup.Item value="overlay" id="overlay" />
|
<RadioGroup.Item value="overlay" id="overlay" />
|
||||||
<Label for="overlay">{$_('layers.custom_layers.overlay')}</Label>
|
<Label for="overlay">{i18n._('layers.custom_layers.overlay')}</Label>
|
||||||
</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-2">
|
||||||
<Button variant="outline" on:click={createLayer} class="grow">
|
<Button variant="outline" on:click={createLayer} class="grow">
|
||||||
<Save size="16" class="mr-1" />
|
<Save size="16" class="mr-1" />
|
||||||
{$_('layers.custom_layers.update')}
|
{i18n._('layers.custom_layers.update')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" on:click={() => (selectedLayerId = undefined)}>
|
<Button variant="outline" on:click={() => (selectedLayerId = undefined)}>
|
||||||
<CircleX size="16" />
|
<CircleX size="16" />
|
||||||
@@ -427,7 +427,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<Button variant="outline" class="mt-2" on:click={createLayer}>
|
<Button variant="outline" class="mt-2" on:click={createLayer}>
|
||||||
<CirclePlus size="16" class="mr-1" />
|
<CirclePlus size="16" class="mr-1" />
|
||||||
{$_('layers.custom_layers.create')}
|
{i18n._('layers.custom_layers.create')}
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</fieldset>
|
</fieldset>
|
231
website/src/lib/components/map/layer-control/LayerControl.svelte
Normal file
231
website/src/lib/components/map/layer-control/LayerControl.svelte
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import CustomControl from '$lib/components/map/custom-control/CustomControl.svelte';
|
||||||
|
import LayerTree from './LayerTree.svelte';
|
||||||
|
// import { OverpassLayer } from './OverpassLayer';
|
||||||
|
import { Separator } from '$lib/components/ui/separator';
|
||||||
|
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||||
|
import { Layers } from '@lucide/svelte';
|
||||||
|
import { basemaps, defaultBasemap, overlays } from '$lib/assets/layers';
|
||||||
|
import { settings } from '$lib/logic/settings.svelte';
|
||||||
|
import { map } from '$lib/components/map/utils.svelte';
|
||||||
|
import { customBasemapUpdate, getLayers } from './utils.svelte';
|
||||||
|
import type { ImportSpecification, StyleSpecification } from 'mapbox-gl';
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
// let overpassLayer: OverpassLayer;
|
||||||
|
|
||||||
|
const {
|
||||||
|
currentBasemap,
|
||||||
|
previousBasemap,
|
||||||
|
currentOverlays,
|
||||||
|
currentOverpassQueries,
|
||||||
|
selectedBasemapTree,
|
||||||
|
selectedOverlayTree,
|
||||||
|
selectedOverpassTree,
|
||||||
|
customLayers,
|
||||||
|
opacities,
|
||||||
|
} = settings;
|
||||||
|
|
||||||
|
function setStyle() {
|
||||||
|
if (!map.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let basemap = basemaps.hasOwnProperty(currentBasemap.value)
|
||||||
|
? basemaps[currentBasemap.value]
|
||||||
|
: (customLayers.value[currentBasemap.value]?.value ?? basemaps[defaultBasemap]);
|
||||||
|
map.value.removeImport('basemap');
|
||||||
|
if (typeof basemap === 'string') {
|
||||||
|
map.value.addImport({ id: 'basemap', url: basemap }, 'overlays');
|
||||||
|
} else {
|
||||||
|
map.value.addImport(
|
||||||
|
{
|
||||||
|
id: 'basemap',
|
||||||
|
url: '',
|
||||||
|
data: basemap as StyleSpecification,
|
||||||
|
},
|
||||||
|
'overlays'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (map.value && (currentBasemap.value || customBasemapUpdate.value)) {
|
||||||
|
setStyle();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function addOverlay(id: string) {
|
||||||
|
if (!map.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
let overlay = customLayers.value.hasOwnProperty(id)
|
||||||
|
? customLayers.value[id].value
|
||||||
|
: overlays[id];
|
||||||
|
if (typeof overlay === 'string') {
|
||||||
|
map.value.addImport({ id, url: overlay });
|
||||||
|
} else {
|
||||||
|
if (opacities.value.hasOwnProperty(id)) {
|
||||||
|
overlay = {
|
||||||
|
...overlay,
|
||||||
|
layers: (overlay as StyleSpecification).layers.map((layer) => {
|
||||||
|
if (layer.type === 'raster') {
|
||||||
|
if (!layer.paint) {
|
||||||
|
layer.paint = {};
|
||||||
|
}
|
||||||
|
layer.paint['raster-opacity'] = opacities.value[id];
|
||||||
|
}
|
||||||
|
return layer;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
map.value.addImport({
|
||||||
|
id,
|
||||||
|
url: '',
|
||||||
|
data: overlay as StyleSpecification,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// No reliable way to check if the map is ready to add sources and layers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateOverlays() {
|
||||||
|
if (map.value && currentOverlays.value && opacities.value) {
|
||||||
|
let overlayLayers = getLayers(currentOverlays.value);
|
||||||
|
try {
|
||||||
|
let activeOverlays =
|
||||||
|
map.value
|
||||||
|
.getStyle()
|
||||||
|
?.imports?.reduce(
|
||||||
|
(
|
||||||
|
acc: Record<string, ImportSpecification>,
|
||||||
|
imprt: ImportSpecification
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
!['basemap', 'overlays', 'glyphs-and-sprite'].includes(imprt.id)
|
||||||
|
) {
|
||||||
|
acc[imprt.id] = imprt;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
) || {};
|
||||||
|
let toRemove = Object.keys(activeOverlays).filter((id) => !overlayLayers[id]);
|
||||||
|
toRemove.forEach((id) => {
|
||||||
|
map.value?.removeImport(id);
|
||||||
|
});
|
||||||
|
let toAdd = Object.entries(overlayLayers)
|
||||||
|
.filter(([id, selected]) => selected && !activeOverlays.hasOwnProperty(id))
|
||||||
|
.map(([id]) => id);
|
||||||
|
toAdd.forEach((id) => {
|
||||||
|
addOverlay(id);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// No reliable way to check if the map is ready to add sources and layers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (map.value && currentOverlays.value && opacities.value) {
|
||||||
|
updateOverlays();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// map.onLoad((map: mapboxgl.Map) => {
|
||||||
|
// if (overpassLayer) {
|
||||||
|
// overpassLayer.remove();
|
||||||
|
// }
|
||||||
|
// overpassLayer = new OverpassLayer(map);
|
||||||
|
// overpassLayer.add();
|
||||||
|
// map.on('style.import.load', updateOverlays);
|
||||||
|
// });
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
function openLayerControl() {
|
||||||
|
open = true;
|
||||||
|
}
|
||||||
|
function closeLayerControl() {
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
let cancelEvents = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CustomControl class="group min-w-[29px] min-h-[29px] overflow-hidden">
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
bind:this={container}
|
||||||
|
class="size-full"
|
||||||
|
onmouseenter={openLayerControl}
|
||||||
|
onmouseleave={closeLayerControl}
|
||||||
|
onpointerenter={() => {
|
||||||
|
if (!open) {
|
||||||
|
cancelEvents = true;
|
||||||
|
openLayerControl();
|
||||||
|
setTimeout(() => {
|
||||||
|
cancelEvents = false;
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex flex-row justify-center items-center delay-100 transition-[opacity] duration-0 {open
|
||||||
|
? 'opacity-0 size-0 delay-0'
|
||||||
|
: 'w-[29px] h-[29px]'}"
|
||||||
|
>
|
||||||
|
<Layers size="20" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="transition-[grid-template-rows grid-template-cols] grid grid-rows-[0fr] grid-cols-[0fr] duration-150 h-full {open
|
||||||
|
? 'grid-rows-[1fr] grid-cols-[1fr]'
|
||||||
|
: ''} {cancelEvents ? 'pointer-events-none' : ''}"
|
||||||
|
>
|
||||||
|
<ScrollArea>
|
||||||
|
<div class="h-fit">
|
||||||
|
<div class="p-2">
|
||||||
|
<LayerTree
|
||||||
|
layerTree={selectedBasemapTree.value}
|
||||||
|
name="basemaps"
|
||||||
|
selected={currentBasemap.value}
|
||||||
|
onselect={(value) => {
|
||||||
|
previousBasemap.value = currentBasemap.value;
|
||||||
|
currentBasemap.value = value;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Separator class="w-full" />
|
||||||
|
<div class="p-2">
|
||||||
|
{#if currentOverlays.value}
|
||||||
|
<LayerTree
|
||||||
|
layerTree={selectedOverlayTree.value}
|
||||||
|
name="overlays"
|
||||||
|
multiple={true}
|
||||||
|
bind:checked={currentOverlays.value}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<Separator class="w-full" />
|
||||||
|
<div class="p-2">
|
||||||
|
{#if currentOverpassQueries.value}
|
||||||
|
<LayerTree
|
||||||
|
layerTree={selectedOverpassTree.value}
|
||||||
|
name="overpass"
|
||||||
|
multiple={true}
|
||||||
|
bind:checked={currentOverpassQueries.value}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CustomControl>
|
||||||
|
|
||||||
|
<svelte:window
|
||||||
|
on:click={(e) => {
|
||||||
|
if (open && !cancelEvents && !container.contains(e.target)) {
|
||||||
|
closeLayerControl();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import LayerTree from './LayerTree.svelte';
|
import LayerTree from './LayerTree.svelte';
|
||||||
|
|
||||||
import { Separator } from '$lib/components/ui/separator';
|
import { Separator } from '$lib/components/ui/separator';
|
||||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||||
import * as Sheet from '$lib/components/ui/sheet';
|
import * as Sheet from '$lib/components/ui/sheet';
|
||||||
@@ -8,7 +7,6 @@
|
|||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import * as Select from '$lib/components/ui/select';
|
import * as Select from '$lib/components/ui/select';
|
||||||
import { Slider } from '$lib/components/ui/slider';
|
import { Slider } from '$lib/components/ui/slider';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
basemapTree,
|
basemapTree,
|
||||||
defaultBasemap,
|
defaultBasemap,
|
||||||
@@ -16,12 +14,10 @@
|
|||||||
overlayTree,
|
overlayTree,
|
||||||
overpassTree,
|
overpassTree,
|
||||||
} from '$lib/assets/layers';
|
} from '$lib/assets/layers';
|
||||||
import { getLayers, isSelected, toggle } from '$lib/components/layer-control/utils';
|
import { getLayers, isSelected, toggle } from '$lib/components/map/layer-control/utils.svelte';
|
||||||
import { settings } from '$lib/db';
|
import { settings } from '$lib/db';
|
||||||
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { _ } from '$lib/i18n';
|
import { map } from '$lib/components/map/map.svelte';
|
||||||
import { writable } from 'svelte/store';
|
|
||||||
import { map } from '$lib/stores';
|
|
||||||
import CustomLayers from './CustomLayers.svelte';
|
import CustomLayers from './CustomLayers.svelte';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -34,22 +30,21 @@
|
|||||||
opacities,
|
opacities,
|
||||||
} = settings;
|
} = settings;
|
||||||
|
|
||||||
export let open: boolean;
|
let { open = $bindable() }: { open: boolean } = $props();
|
||||||
let accordionValue: string | string[] | undefined = undefined;
|
|
||||||
|
|
||||||
let selectedOverlay = writable(undefined);
|
let accordionValue: string | undefined = $state(undefined);
|
||||||
let overlayOpacity = writable([1]);
|
let selectedOverlay = $state(undefined);
|
||||||
|
let overlayOpacity = $state(1);
|
||||||
|
|
||||||
function setOpacityFromSelection() {
|
function setOpacityFromSelection() {
|
||||||
if ($selectedOverlay) {
|
if (selectedOverlay) {
|
||||||
let overlayId = $selectedOverlay.value;
|
if ($opacities.hasOwnProperty(selectedOverlay)) {
|
||||||
if ($opacities.hasOwnProperty(overlayId)) {
|
overlayOpacity = $opacities[selectedOverlay];
|
||||||
$overlayOpacity = [$opacities[overlayId]];
|
|
||||||
} else {
|
} else {
|
||||||
$overlayOpacity = [1];
|
overlayOpacity = 1;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$overlayOpacity = [1];
|
overlayOpacity = 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,24 +71,20 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$: if ($selectedOverlay) {
|
|
||||||
setOpacityFromSelection();
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Sheet.Root bind:open>
|
<Sheet.Root bind:open>
|
||||||
<Sheet.Trigger class="hidden" />
|
<Sheet.Trigger class="hidden" />
|
||||||
<Sheet.Content>
|
<Sheet.Content>
|
||||||
<Sheet.Header class="h-full">
|
<Sheet.Header class="h-full">
|
||||||
<Sheet.Title>{$_('layers.settings')}</Sheet.Title>
|
<Sheet.Title>{i18n._('layers.settings')}</Sheet.Title>
|
||||||
<ScrollArea class="w-[105%] min-h-full pr-4">
|
<ScrollArea class="w-[105%] min-h-full pr-4">
|
||||||
<Sheet.Description>
|
<Sheet.Description>
|
||||||
{$_('layers.settings_help')}
|
{i18n._('layers.settings_help')}
|
||||||
</Sheet.Description>
|
</Sheet.Description>
|
||||||
<Accordion.Root class="flex flex-col" bind:value={accordionValue}>
|
<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>{$_('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">
|
||||||
<div class="py-2 pl-1 pr-2">
|
<div class="py-2 pl-1 pr-2">
|
||||||
<LayerTree
|
<LayerTree
|
||||||
@@ -124,21 +115,31 @@
|
|||||||
</Accordion.Content>
|
</Accordion.Content>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
<Accordion.Item value="overlay-opacity">
|
<Accordion.Item value="overlay-opacity">
|
||||||
<Accordion.Trigger>{$_('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">
|
||||||
<div class="flex flex-row gap-6 items-center">
|
<div class="flex flex-row gap-6 items-center">
|
||||||
<Label>
|
<Label>
|
||||||
{$_('layers.custom_layers.overlay')}
|
{i18n._('layers.custom_layers.overlay')}
|
||||||
</Label>
|
</Label>
|
||||||
<Select.Root bind:selected={$selectedOverlay}>
|
<Select.Root
|
||||||
|
bind:value={selectedOverlay}
|
||||||
|
type="single"
|
||||||
|
onValueChange={setOpacityFromSelection}
|
||||||
|
>
|
||||||
<Select.Trigger class="h-8 mr-1">
|
<Select.Trigger class="h-8 mr-1">
|
||||||
<Select.Value />
|
{#if selectedOverlay}
|
||||||
|
{#if isSelected($selectedOverlayTree, selectedOverlay)}
|
||||||
|
{i18n._(`layers.label.${selectedOverlay}`)}
|
||||||
|
{:else if $customLayers.hasOwnProperty(selectedOverlay)}
|
||||||
|
{$customLayers[selectedOverlay].name}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
<Select.Content class="h-fit max-h-[40dvh] overflow-y-auto">
|
<Select.Content class="h-fit max-h-[40dvh] overflow-y-auto">
|
||||||
{#each Object.keys(overlays) as id}
|
{#each Object.keys(overlays) as id}
|
||||||
{#if isSelected($selectedOverlayTree, id)}
|
{#if isSelected($selectedOverlayTree, id)}
|
||||||
<Select.Item value={id}
|
<Select.Item value={id}
|
||||||
>{$_(`layers.label.${id}`)}</Select.Item
|
>{i18n._(`layers.label.${id}`)}</Select.Item
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
@@ -151,30 +152,28 @@
|
|||||||
</Select.Root>
|
</Select.Root>
|
||||||
</div>
|
</div>
|
||||||
<Label class="flex flex-row gap-6 items-center">
|
<Label class="flex flex-row gap-6 items-center">
|
||||||
{$_('menu.style.opacity')}
|
{i18n._('menu.style.opacity')}
|
||||||
<div class="p-2 pr-3 grow">
|
<div class="p-2 pr-3 grow">
|
||||||
<Slider
|
<Slider
|
||||||
bind:value={$overlayOpacity}
|
bind:value={overlayOpacity}
|
||||||
|
type="single"
|
||||||
min={0.1}
|
min={0.1}
|
||||||
max={1}
|
max={1}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
disabled={$selectedOverlay === undefined}
|
disabled={selectedOverlay === undefined}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
if ($selectedOverlay) {
|
if (selectedOverlay) {
|
||||||
if (
|
if (
|
||||||
$map &&
|
map.current &&
|
||||||
isSelected(
|
isSelected($currentOverlays, selectedOverlay)
|
||||||
$currentOverlays,
|
|
||||||
$selectedOverlay.value
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
$map.removeImport($selectedOverlay.value);
|
map.current.removeImport(selectedOverlay);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// No reliable way to check if the map is ready to remove sources and layers
|
// No reliable way to check if the map is ready to remove sources and layers
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$opacities[$selectedOverlay.value] = value[0];
|
$opacities[selectedOverlay] = value;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -183,7 +182,8 @@
|
|||||||
</Accordion.Content>
|
</Accordion.Content>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
<Accordion.Item value="custom-layers">
|
<Accordion.Item value="custom-layers">
|
||||||
<Accordion.Trigger>{$_('layers.custom_layers.title')}</Accordion.Trigger>
|
<Accordion.Trigger>{i18n._('layers.custom_layers.title')}</Accordion.Trigger
|
||||||
|
>
|
||||||
<Accordion.Content>
|
<Accordion.Content>
|
||||||
<ScrollArea>
|
<ScrollArea>
|
||||||
<CustomLayers />
|
<CustomLayers />
|
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import LayerTreeNode from './LayerTreeNode.svelte';
|
||||||
|
import { type LayerTreeType } from '$lib/assets/layers';
|
||||||
|
import CollapsibleTree from '$lib/components/collapsible-tree/CollapsibleTree.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
layerTree,
|
||||||
|
name,
|
||||||
|
selected = '',
|
||||||
|
onselect = () => {},
|
||||||
|
multiple = false,
|
||||||
|
checked = $bindable({}),
|
||||||
|
}: {
|
||||||
|
layerTree: LayerTreeType;
|
||||||
|
name: string;
|
||||||
|
selected?: string;
|
||||||
|
onselect?: (value: string) => void;
|
||||||
|
multiple?: boolean;
|
||||||
|
checked?: LayerTreeType;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form>
|
||||||
|
<fieldset class="min-w-64 mb-1">
|
||||||
|
<CollapsibleTree nohover={true}>
|
||||||
|
<LayerTreeNode {name} node={layerTree} {selected} {onselect} {multiple} bind:checked />
|
||||||
|
</CollapsibleTree>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
@@ -1,25 +1,32 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Self from '$lib/components/map/layer-control/LayerTreeNode.svelte';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
import CollapsibleTreeNode from '../collapsible-tree/CollapsibleTreeNode.svelte';
|
import CollapsibleTreeNode from '$lib/components/collapsible-tree/CollapsibleTreeNode.svelte';
|
||||||
|
|
||||||
import { type LayerTreeType } from '$lib/assets/layers';
|
import { type LayerTreeType } from '$lib/assets/layers';
|
||||||
import { anySelectedLayer } from './utils';
|
import { anySelectedLayer } from './utils.svelte';
|
||||||
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
import { settings } from '$lib/logic/settings.svelte';
|
||||||
|
|
||||||
import { _ } from '$lib/i18n';
|
let {
|
||||||
import { settings } from '$lib/db';
|
name,
|
||||||
import { beforeUpdate } from 'svelte';
|
node,
|
||||||
|
selected = '',
|
||||||
export let name: string;
|
onselect = () => {},
|
||||||
export let node: LayerTreeType;
|
multiple = false,
|
||||||
export let selected: string | undefined = undefined;
|
checked = $bindable({}),
|
||||||
export let multiple: boolean = false;
|
}: {
|
||||||
|
name: string;
|
||||||
export let checked: LayerTreeType;
|
node: LayerTreeType;
|
||||||
|
selected?: string;
|
||||||
|
onselect?: (value: string) => void;
|
||||||
|
multiple: boolean;
|
||||||
|
checked: LayerTreeType;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
const { customLayers } = settings;
|
const { customLayers } = settings;
|
||||||
|
|
||||||
beforeUpdate(() => {
|
$effect.pre(() => {
|
||||||
if (checked !== undefined) {
|
if (checked !== undefined) {
|
||||||
Object.keys(node).forEach((id) => {
|
Object.keys(node).forEach((id) => {
|
||||||
if (!checked.hasOwnProperty(id)) {
|
if (!checked.hasOwnProperty(id)) {
|
||||||
@@ -46,7 +53,7 @@
|
|||||||
value={id}
|
value={id}
|
||||||
bind:checked={checked[id]}
|
bind:checked={checked[id]}
|
||||||
class="scale-90"
|
class="scale-90"
|
||||||
aria-label={$_(`layers.label.${id}`)}
|
aria-label={i18n._(`layers.label.${id}`)}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<input
|
<input
|
||||||
@@ -54,36 +61,48 @@
|
|||||||
type="radio"
|
type="radio"
|
||||||
{name}
|
{name}
|
||||||
value={id}
|
value={id}
|
||||||
bind:group={selected}
|
checked={selected === id}
|
||||||
|
oninput={(e) => {
|
||||||
|
if ((e.target as HTMLInputElement)?.checked) {
|
||||||
|
onselect(id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<Label for="{name}-{id}" class="flex flex-row items-center gap-1">
|
<Label for="{name}-{id}" class="flex flex-row items-center gap-1">
|
||||||
{#if $customLayers.hasOwnProperty(id)}
|
{#if customLayers.value.hasOwnProperty(id)}
|
||||||
{$customLayers[id].name}
|
{customLayers.value[id].name}
|
||||||
{:else}
|
{:else}
|
||||||
{$_(`layers.label.${id}`)}
|
{i18n._(`layers.label.${id}`)}
|
||||||
{/if}
|
{/if}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if anySelectedLayer(node[id])}
|
{:else if anySelectedLayer(node[id])}
|
||||||
<CollapsibleTreeNode {id}>
|
<CollapsibleTreeNode {id}>
|
||||||
<span slot="trigger">{$_(`layers.label.${id}`)}</span>
|
{#snippet trigger()}
|
||||||
<div slot="content">
|
<span>{i18n._(`layers.label.${id}`)}</span>
|
||||||
<svelte:self
|
{/snippet}
|
||||||
node={node[id]}
|
{#snippet content()}
|
||||||
{name}
|
<div class="ml-2">
|
||||||
bind:selected
|
<Self
|
||||||
{multiple}
|
node={node[id]}
|
||||||
bind:checked={checked[id]}
|
{name}
|
||||||
/>
|
{selected}
|
||||||
</div>
|
{onselect}
|
||||||
|
{multiple}
|
||||||
|
bind:checked={checked[id]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
</CollapsibleTreeNode>
|
</CollapsibleTreeNode>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
|
@reference "../../../../app.css";
|
||||||
|
|
||||||
div :global(input[type='radio']) {
|
div :global(input[type='radio']) {
|
||||||
@apply appearance-none;
|
@apply appearance-none;
|
||||||
@apply w-4 h-4;
|
@apply w-4 h-4;
|
@@ -1,12 +1,12 @@
|
|||||||
import { SphericalMercator } from '@mapbox/sphericalmercator';
|
import { SphericalMercator } from '@mapbox/sphericalmercator';
|
||||||
import { getLayers } from './utils';
|
import { getLayers } from './utils.svelte';
|
||||||
import { get, writable } from 'svelte/store';
|
import { get, writable } from 'svelte/store';
|
||||||
import { liveQuery } from 'dexie';
|
import { liveQuery } from 'dexie';
|
||||||
import { db, settings } from '$lib/db';
|
import { db, settings } from '$lib/db';
|
||||||
import { overpassQueryData } from '$lib/assets/layers';
|
import { overpassQueryData } from '$lib/assets/layers';
|
||||||
import { MapPopup } from '$lib/components/MapPopup';
|
import { MapPopup } from '$lib/components/map/map.svelte';
|
||||||
|
|
||||||
const { currentOverpassQueries } = settings;
|
// const { currentOverpassQueries } = settings;
|
||||||
|
|
||||||
const mercator = new SphericalMercator({
|
const mercator = new SphericalMercator({
|
||||||
size: 256,
|
size: 256,
|
@@ -2,8 +2,8 @@
|
|||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { selection } from '$lib/components/file-list/Selection';
|
import { selection } from '$lib/components/file-list/Selection';
|
||||||
import { PencilLine, MapPin } from 'lucide-svelte';
|
import { PencilLine, MapPin } from '@lucide/svelte';
|
||||||
import { _ } from '$lib/i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { dbUtils } from '$lib/db';
|
import { dbUtils } from '$lib/db';
|
||||||
import type { PopupItem } from '$lib/components/MapPopup';
|
import type { PopupItem } from '$lib/components/MapPopup';
|
||||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
if (tags.name !== undefined && tags.name !== '') {
|
if (tags.name !== undefined && tags.name !== '') {
|
||||||
name = tags.name;
|
name = tags.name;
|
||||||
} else {
|
} else {
|
||||||
name = $_(`layers.label.${poi.item.query}`);
|
name = i18n._(`layers.label.${poi.item.query}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
on:click={addToFile}
|
on:click={addToFile}
|
||||||
>
|
>
|
||||||
<MapPin size="16" class="mr-1" />
|
<MapPin size="16" class="mr-1" />
|
||||||
{$_('toolbar.waypoint.add')}
|
{i18n._('toolbar.waypoint.add')}
|
||||||
</Button>
|
</Button>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
@@ -55,4 +55,6 @@ export function toggle(node: LayerTreeType, id: string) {
|
|||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const customBasemapUpdate = writable(0);
|
export const customBasemapUpdate = $state({
|
||||||
|
value: 0,
|
||||||
|
});
|
@@ -2,7 +2,6 @@ import mapboxgl, { type LayerSpecification, type VectorSourceSpecification } fro
|
|||||||
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 { resetCursor, setPointerCursor } from '$lib/utils';
|
import { resetCursor, setPointerCursor } from '$lib/utils';
|
||||||
import type { Writable } from 'svelte/store';
|
|
||||||
|
|
||||||
const mapillarySource: VectorSourceSpecification = {
|
const mapillarySource: VectorSourceSpecification = {
|
||||||
type: 'vector',
|
type: 'vector',
|
||||||
@@ -47,13 +46,13 @@ export class MapillaryLayer {
|
|||||||
viewer: Viewer;
|
viewer: Viewer;
|
||||||
|
|
||||||
active = false;
|
active = false;
|
||||||
popupOpen: Writable<boolean>;
|
popupOpen: { value: boolean };
|
||||||
|
|
||||||
addBinded = this.add.bind(this);
|
addBinded = this.add.bind(this);
|
||||||
onMouseEnterBinded = this.onMouseEnter.bind(this);
|
onMouseEnterBinded = this.onMouseEnter.bind(this);
|
||||||
onMouseLeaveBinded = this.onMouseLeave.bind(this);
|
onMouseLeaveBinded = this.onMouseLeave.bind(this);
|
||||||
|
|
||||||
constructor(map: mapboxgl.Map, container: HTMLElement, popupOpen: Writable<boolean>) {
|
constructor(map: mapboxgl.Map, container: HTMLElement, popupOpen: { value: boolean }) {
|
||||||
this.map = map;
|
this.map = map;
|
||||||
|
|
||||||
this.viewer = new Viewer({
|
this.viewer = new Viewer({
|
||||||
@@ -77,7 +76,7 @@ export class MapillaryLayer {
|
|||||||
|
|
||||||
this.viewer.on('position', async () => {
|
this.viewer.on('position', async () => {
|
||||||
if (this.active) {
|
if (this.active) {
|
||||||
popupOpen.set(true);
|
popupOpen.value = true;
|
||||||
let latLng = await this.viewer.getPosition();
|
let latLng = await this.viewer.getPosition();
|
||||||
this.marker.setLngLat(latLng).addTo(this.map);
|
this.marker.setLngLat(latLng).addTo(this.map);
|
||||||
if (!this.map.getBounds()?.contains(latLng)) {
|
if (!this.map.getBounds()?.contains(latLng)) {
|
||||||
@@ -126,13 +125,13 @@ export class MapillaryLayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.marker.remove();
|
this.marker.remove();
|
||||||
this.popupOpen.set(false);
|
this.popupOpen.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
closePopup() {
|
closePopup() {
|
||||||
this.active = false;
|
this.active = false;
|
||||||
this.marker.remove();
|
this.marker.remove();
|
||||||
this.popupOpen.set(false);
|
this.popupOpen.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
onMouseEnter(e: mapboxgl.MapMouseEvent) {
|
onMouseEnter(e: mapboxgl.MapMouseEvent) {
|
@@ -0,0 +1,79 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { streetViewEnabled } from '$lib/components/map/street-view-control/utils.svelte';
|
||||||
|
import { map } from '$lib/components/map/utils.svelte';
|
||||||
|
import CustomControl from '$lib/components/map/custom-control/CustomControl.svelte';
|
||||||
|
import Tooltip from '$lib/components/Tooltip.svelte';
|
||||||
|
import { Toggle } from '$lib/components/ui/toggle';
|
||||||
|
import { PersonStanding, X } from '@lucide/svelte';
|
||||||
|
import { MapillaryLayer } from './Mapillary';
|
||||||
|
import { GoogleRedirect } from './Google';
|
||||||
|
import { settings } from '$lib/logic/settings.svelte';
|
||||||
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
const { streetViewSource } = settings;
|
||||||
|
|
||||||
|
let googleRedirect: GoogleRedirect | null = $state(null);
|
||||||
|
let mapillaryLayer: MapillaryLayer | null = $state(null);
|
||||||
|
let mapillaryOpen = $state({
|
||||||
|
value: false,
|
||||||
|
});
|
||||||
|
let container: HTMLElement;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
map.onLoad((map: mapboxgl.Map) => {
|
||||||
|
googleRedirect = new GoogleRedirect(map);
|
||||||
|
mapillaryLayer = new MapillaryLayer(map, container, mapillaryOpen);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (streetViewSource.value === 'mapillary') {
|
||||||
|
googleRedirect?.remove();
|
||||||
|
if (streetViewEnabled.current) {
|
||||||
|
mapillaryLayer?.add();
|
||||||
|
} else {
|
||||||
|
mapillaryLayer?.remove();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mapillaryLayer?.remove();
|
||||||
|
if (streetViewEnabled.current) {
|
||||||
|
googleRedirect?.add();
|
||||||
|
} else {
|
||||||
|
googleRedirect?.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CustomControl class="w-[29px] h-[29px] shrink-0">
|
||||||
|
<Tooltip class="w-full h-full" side="left" label={i18n._('menu.toggle_street_view')}>
|
||||||
|
<Toggle
|
||||||
|
bind:pressed={streetViewEnabled.current}
|
||||||
|
class="w-full h-full rounded p-0"
|
||||||
|
aria-label={i18n._('menu.toggle_street_view')}
|
||||||
|
>
|
||||||
|
<PersonStanding size="22" />
|
||||||
|
</Toggle>
|
||||||
|
</Tooltip>
|
||||||
|
</CustomControl>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={container}
|
||||||
|
class="{mapillaryOpen.value
|
||||||
|
? ''
|
||||||
|
: 'hidden'} !absolute bottom-[44px] right-2.5 z-10 w-[40%] h-[40%] bg-background rounded-md overflow-hidden border-background border-2"
|
||||||
|
>
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="absolute top-0 right-0 z-10 bg-background p-1 rounded-bl-md cursor-pointer"
|
||||||
|
onclick={() => {
|
||||||
|
if (mapillaryLayer) {
|
||||||
|
mapillaryLayer.closePopup();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size="16" />
|
||||||
|
</div>
|
||||||
|
</div>
|
@@ -0,0 +1,3 @@
|
|||||||
|
export const streetViewEnabled = $state({
|
||||||
|
current: false,
|
||||||
|
});
|
384
website/src/lib/components/map/utils.svelte.ts
Normal file
384
website/src/lib/components/map/utils.svelte.ts
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
import { TrackPoint, Waypoint, type Coordinates } from 'gpx';
|
||||||
|
import mapboxgl from 'mapbox-gl';
|
||||||
|
import { tick, mount } from 'svelte';
|
||||||
|
// import MapPopupComponent from '$lib/components/map/MapPopup.svelte';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
// import { fileObservers } from '$lib/db';
|
||||||
|
import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder';
|
||||||
|
|
||||||
|
let fitBoundsOptions: mapboxgl.MapOptions['fitBoundsOptions'] = {
|
||||||
|
maxZoom: 15,
|
||||||
|
linear: true,
|
||||||
|
easing: () => 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class MapboxGLMap {
|
||||||
|
private _map: mapboxgl.Map | null = $state(null);
|
||||||
|
private _onLoadCallbacks: ((map: mapboxgl.Map) => void)[] = [];
|
||||||
|
|
||||||
|
init(
|
||||||
|
accessToken: string,
|
||||||
|
language: string,
|
||||||
|
distanceUnits: 'metric' | 'imperial' | 'nautical',
|
||||||
|
hash: boolean,
|
||||||
|
geocoder: boolean,
|
||||||
|
geolocate: boolean
|
||||||
|
) {
|
||||||
|
const map = new mapboxgl.Map({
|
||||||
|
container: 'map',
|
||||||
|
style: {
|
||||||
|
version: 8,
|
||||||
|
sources: {},
|
||||||
|
layers: [],
|
||||||
|
imports: [
|
||||||
|
{
|
||||||
|
id: 'glyphs-and-sprite', // make Mapbox glyphs and sprite available to other styles
|
||||||
|
url: '',
|
||||||
|
data: {
|
||||||
|
version: 8,
|
||||||
|
sources: {},
|
||||||
|
layers: [],
|
||||||
|
glyphs: 'mapbox://fonts/mapbox/{fontstack}/{range}.pbf',
|
||||||
|
sprite: `https://api.mapbox.com/styles/v1/mapbox/outdoors-v12/sprite?access_token=${accessToken}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'basemap',
|
||||||
|
url: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'overlays',
|
||||||
|
url: '',
|
||||||
|
data: {
|
||||||
|
version: 8,
|
||||||
|
sources: {},
|
||||||
|
layers: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
projection: 'globe',
|
||||||
|
zoom: 0,
|
||||||
|
hash: hash,
|
||||||
|
language,
|
||||||
|
attributionControl: false,
|
||||||
|
logoPosition: 'bottom-right',
|
||||||
|
boxZoom: false,
|
||||||
|
});
|
||||||
|
map.addControl(
|
||||||
|
new mapboxgl.AttributionControl({
|
||||||
|
compact: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
map.addControl(
|
||||||
|
new mapboxgl.NavigationControl({
|
||||||
|
visualizePitch: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
if (geocoder) {
|
||||||
|
let geocoder = new MapboxGeocoder({
|
||||||
|
mapboxgl: mapboxgl,
|
||||||
|
enableEventLogging: false,
|
||||||
|
collapsed: true,
|
||||||
|
flyTo: fitBoundsOptions,
|
||||||
|
language,
|
||||||
|
localGeocoder: () => [],
|
||||||
|
localGeocoderOnly: true,
|
||||||
|
externalGeocoder: (query: string) =>
|
||||||
|
fetch(
|
||||||
|
`https://nominatim.openstreetmap.org/search?format=json&q=${query}&limit=5&accept-language=${language}`
|
||||||
|
)
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => {
|
||||||
|
return data.map((result: any) => {
|
||||||
|
return {
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: {
|
||||||
|
type: 'Point',
|
||||||
|
coordinates: [result.lon, result.lat],
|
||||||
|
},
|
||||||
|
place_name: result.display_name,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
let onKeyDown = geocoder._onKeyDown;
|
||||||
|
geocoder._onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// Trigger search on Enter key only
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
onKeyDown.apply(geocoder, [{ target: geocoder._inputEl }]);
|
||||||
|
} else if (geocoder._typeahead.data.length > 0) {
|
||||||
|
geocoder._typeahead.clear();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
map.addControl(geocoder);
|
||||||
|
}
|
||||||
|
if (geolocate) {
|
||||||
|
map.addControl(
|
||||||
|
new mapboxgl.GeolocateControl({
|
||||||
|
positionOptions: {
|
||||||
|
enableHighAccuracy: true,
|
||||||
|
},
|
||||||
|
fitBoundsOptions,
|
||||||
|
trackUserLocation: true,
|
||||||
|
showUserHeading: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const scaleControl = new mapboxgl.ScaleControl({
|
||||||
|
unit: distanceUnits,
|
||||||
|
});
|
||||||
|
map.addControl(scaleControl);
|
||||||
|
map.on('style.load', () => {
|
||||||
|
map.addSource('mapbox-dem', {
|
||||||
|
type: 'raster-dem',
|
||||||
|
url: 'mapbox://mapbox.mapbox-terrain-dem-v1',
|
||||||
|
tileSize: 512,
|
||||||
|
maxzoom: 14,
|
||||||
|
});
|
||||||
|
if (map.getPitch() > 0) {
|
||||||
|
map.setTerrain({
|
||||||
|
source: 'mapbox-dem',
|
||||||
|
exaggeration: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
map.setFog({
|
||||||
|
color: 'rgb(186, 210, 235)',
|
||||||
|
'high-color': 'rgb(36, 92, 223)',
|
||||||
|
'horizon-blend': 0.1,
|
||||||
|
'space-color': 'rgb(156, 240, 255)',
|
||||||
|
});
|
||||||
|
map.on('pitch', () => {
|
||||||
|
if (map.getPitch() > 0) {
|
||||||
|
map.setTerrain({
|
||||||
|
source: 'mapbox-dem',
|
||||||
|
exaggeration: 1,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
map.setTerrain(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
map.on('load', () => {
|
||||||
|
this._map = map; // only set the store after the map has loaded
|
||||||
|
window._map = map; // entry point for extensions
|
||||||
|
scaleControl.setUnit(distanceUnits);
|
||||||
|
|
||||||
|
this._onLoadCallbacks.forEach((callback) => callback(map));
|
||||||
|
this._onLoadCallbacks = [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad(callback: (map: mapboxgl.Map) => void) {
|
||||||
|
if (this._map) {
|
||||||
|
callback(this._map);
|
||||||
|
} else {
|
||||||
|
this._onLoadCallbacks.push(callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (this._map) {
|
||||||
|
this._map.remove();
|
||||||
|
this._map = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get value(): mapboxgl.Map | null {
|
||||||
|
return this._map;
|
||||||
|
}
|
||||||
|
|
||||||
|
resize() {
|
||||||
|
if (this._map) {
|
||||||
|
this._map.resize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle3D() {
|
||||||
|
if (this._map) {
|
||||||
|
if (this._map.getPitch() === 0) {
|
||||||
|
this._map.easeTo({ pitch: 70 });
|
||||||
|
} else {
|
||||||
|
this._map.easeTo({ pitch: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const map = new MapboxGLMap();
|
||||||
|
|
||||||
|
const targetMapBounds: {
|
||||||
|
bounds: mapboxgl.LngLatBounds;
|
||||||
|
ids: string[];
|
||||||
|
total: number;
|
||||||
|
} = $state({
|
||||||
|
bounds: new mapboxgl.LngLatBounds([180, 90, -180, -90]),
|
||||||
|
ids: [],
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// $effect(() => {
|
||||||
|
// if (
|
||||||
|
// map.current === null ||
|
||||||
|
// targetMapBounds.ids.length > 0 ||
|
||||||
|
// (targetMapBounds.bounds.getSouth() === 90 &&
|
||||||
|
// targetMapBounds.bounds.getWest() === 180 &&
|
||||||
|
// targetMapBounds.bounds.getNorth() === -90 &&
|
||||||
|
// targetMapBounds.bounds.getEast() === -180)
|
||||||
|
// ) {
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// let currentZoom = map.current.getZoom();
|
||||||
|
// let currentBounds = map.current.getBounds();
|
||||||
|
// if (
|
||||||
|
// targetMapBounds.total !== get(fileObservers).size &&
|
||||||
|
// currentBounds &&
|
||||||
|
// currentZoom > 2 // Extend current bounds only if the map is zoomed in
|
||||||
|
// ) {
|
||||||
|
// // There are other files on the map
|
||||||
|
// if (
|
||||||
|
// currentBounds.contains(targetMapBounds.bounds.getSouthEast()) &&
|
||||||
|
// currentBounds.contains(targetMapBounds.bounds.getNorthWest())
|
||||||
|
// ) {
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// targetMapBounds.bounds.extend(currentBounds.getSouthWest());
|
||||||
|
// targetMapBounds.bounds.extend(currentBounds.getNorthEast());
|
||||||
|
// }
|
||||||
|
|
||||||
|
// map.current.fitBounds(targetMapBounds.bounds, { padding: 80, linear: true, easing: () => 1 });
|
||||||
|
// });
|
||||||
|
|
||||||
|
export function initTargetMapBounds(ids: string[]) {
|
||||||
|
targetMapBounds.bounds = new mapboxgl.LngLatBounds([180, 90, -180, -90]);
|
||||||
|
targetMapBounds.ids = ids;
|
||||||
|
targetMapBounds.total = ids.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTargetMapBounds(
|
||||||
|
id: string,
|
||||||
|
bounds: { southWest: Coordinates; northEast: Coordinates }
|
||||||
|
) {
|
||||||
|
if (targetMapBounds.ids.indexOf(id) === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
bounds.southWest.lat !== 90 ||
|
||||||
|
bounds.southWest.lon !== 180 ||
|
||||||
|
bounds.northEast.lat !== -90 ||
|
||||||
|
bounds.northEast.lon !== -180
|
||||||
|
) {
|
||||||
|
// Avoid update for empty (new) files
|
||||||
|
targetMapBounds.ids = targetMapBounds.ids.filter((x) => x !== id);
|
||||||
|
targetMapBounds.bounds.extend(bounds.southWest);
|
||||||
|
targetMapBounds.bounds.extend(bounds.northEast);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// export function centerMapOnSelection() {
|
||||||
|
// let selected = get(selection).getSelected();
|
||||||
|
// let bounds = new mapboxgl.LngLatBounds();
|
||||||
|
|
||||||
|
// if (selected.find((item) => item instanceof ListWaypointItem)) {
|
||||||
|
// applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
|
||||||
|
// let file = getFile(fileId);
|
||||||
|
// if (file) {
|
||||||
|
// items.forEach((item) => {
|
||||||
|
// if (item instanceof ListWaypointItem) {
|
||||||
|
// let waypoint = file.wpt[item.getWaypointIndex()];
|
||||||
|
// if (waypoint) {
|
||||||
|
// bounds.extend([waypoint.getLongitude(), waypoint.getLatitude()]);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
// } else {
|
||||||
|
// let selectionBounds = get(gpxStatistics).global.bounds;
|
||||||
|
// bounds.setNorthEast(selectionBounds.northEast);
|
||||||
|
// bounds.setSouthWest(selectionBounds.southWest);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// get(map)?.fitBounds(bounds, {
|
||||||
|
// padding: 80,
|
||||||
|
// easing: () => 1,
|
||||||
|
// maxZoom: 15,
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
export type PopupItem<T = Waypoint | TrackPoint | any> = {
|
||||||
|
item: T;
|
||||||
|
fileId?: string;
|
||||||
|
hide?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// export class MapPopup {
|
||||||
|
// map: mapboxgl.Map;
|
||||||
|
// popup: mapboxgl.Popup;
|
||||||
|
// item: PopupItem | null = $state(null);
|
||||||
|
// maybeHideBinded = this.maybeHide.bind(this);
|
||||||
|
|
||||||
|
// constructor(map: mapboxgl.Map, options?: mapboxgl.PopupOptions) {
|
||||||
|
// this.map = map;
|
||||||
|
// this.popup = new mapboxgl.Popup(options);
|
||||||
|
|
||||||
|
// let component = mount(MapPopupComponent, {
|
||||||
|
// target: document.body,
|
||||||
|
// props: {
|
||||||
|
// item: this.item,
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// tick().then(() => this.popup.setDOMContent(component.container));
|
||||||
|
// }
|
||||||
|
|
||||||
|
// setItem(item: PopupItem | null) {
|
||||||
|
// if (item) item.hide = () => this.hide();
|
||||||
|
// this.item = item;
|
||||||
|
// if (item === null) {
|
||||||
|
// this.hide();
|
||||||
|
// } else {
|
||||||
|
// tick().then(() => this.show());
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// show() {
|
||||||
|
// if (this.item === null) {
|
||||||
|
// this.hide();
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
// this.popup.setLngLat(this.getCoordinates()).addTo(this.map);
|
||||||
|
// this.map.on('mousemove', this.maybeHideBinded);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// maybeHide(e: mapboxgl.MapMouseEvent) {
|
||||||
|
// if (this.item === null) {
|
||||||
|
// this.hide();
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
// if (this.map.project(this.getCoordinates()).dist(this.map.project(e.lngLat)) > 60) {
|
||||||
|
// this.hide();
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// hide() {
|
||||||
|
// this.popup.remove();
|
||||||
|
// this.map.off('mousemove', this.maybeHideBinded);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// remove() {
|
||||||
|
// this.popup.remove();
|
||||||
|
// }
|
||||||
|
|
||||||
|
// getCoordinates() {
|
||||||
|
// if (this.item === null) {
|
||||||
|
// return new mapboxgl.LngLat(0, 0);
|
||||||
|
// }
|
||||||
|
// return this.item.item instanceof Waypoint || this.item.item instanceof TrackPoint
|
||||||
|
// ? this.item.item.getCoordinates()
|
||||||
|
// : new mapboxgl.LngLat(this.item.item.lon, this.item.item.lat);
|
||||||
|
// }
|
||||||
|
// }
|
@@ -1,74 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import CustomControl from '$lib/components/custom-control/CustomControl.svelte';
|
|
||||||
import Tooltip from '$lib/components/Tooltip.svelte';
|
|
||||||
import { Toggle } from '$lib/components/ui/toggle';
|
|
||||||
import { PersonStanding, X } from 'lucide-svelte';
|
|
||||||
import { MapillaryLayer } from './Mapillary';
|
|
||||||
import { GoogleRedirect } from './Google';
|
|
||||||
import { map, streetViewEnabled } from '$lib/stores';
|
|
||||||
import { settings } from '$lib/db';
|
|
||||||
import { _ } from '$lib/i18n';
|
|
||||||
import { writable } from 'svelte/store';
|
|
||||||
|
|
||||||
const { streetViewSource } = settings;
|
|
||||||
|
|
||||||
let googleRedirect: GoogleRedirect;
|
|
||||||
let mapillaryLayer: MapillaryLayer;
|
|
||||||
let mapillaryOpen = writable(false);
|
|
||||||
let container: HTMLElement;
|
|
||||||
|
|
||||||
$: if ($map) {
|
|
||||||
googleRedirect = new GoogleRedirect($map);
|
|
||||||
mapillaryLayer = new MapillaryLayer($map, container, mapillaryOpen);
|
|
||||||
}
|
|
||||||
|
|
||||||
$: if (mapillaryLayer) {
|
|
||||||
if ($streetViewSource === 'mapillary') {
|
|
||||||
googleRedirect.remove();
|
|
||||||
if ($streetViewEnabled) {
|
|
||||||
mapillaryLayer.add();
|
|
||||||
} else {
|
|
||||||
mapillaryLayer.remove();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
mapillaryLayer.remove();
|
|
||||||
if ($streetViewEnabled) {
|
|
||||||
googleRedirect.add();
|
|
||||||
} else {
|
|
||||||
googleRedirect.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<CustomControl class="w-[29px] h-[29px] shrink-0">
|
|
||||||
<Tooltip class="w-full h-full" side="left" label={$_('menu.toggle_street_view')}>
|
|
||||||
<Toggle
|
|
||||||
bind:pressed={$streetViewEnabled}
|
|
||||||
class="w-full h-full rounded p-0"
|
|
||||||
aria-label={$_('menu.toggle_street_view')}
|
|
||||||
>
|
|
||||||
<PersonStanding size="22" />
|
|
||||||
</Toggle>
|
|
||||||
</Tooltip>
|
|
||||||
</CustomControl>
|
|
||||||
|
|
||||||
<div
|
|
||||||
bind:this={container}
|
|
||||||
class="{$mapillaryOpen
|
|
||||||
? ''
|
|
||||||
: 'hidden'} !absolute bottom-[44px] right-2.5 z-10 w-[40%] h-[40%] bg-background rounded-md overflow-hidden border-background border-2"
|
|
||||||
>
|
|
||||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
|
||||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
|
||||||
<div
|
|
||||||
class="absolute top-0 right-0 z-10 bg-background p-1 rounded-bl-md cursor-pointer"
|
|
||||||
on:click={() => {
|
|
||||||
if (mapillaryLayer) {
|
|
||||||
mapillaryLayer.closePopup();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X size="16" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Tool } from '$lib/stores';
|
import ToolbarItem from '$lib/components/toolbar/ToolbarItem.svelte';
|
||||||
import ToolbarItem from './ToolbarItem.svelte';
|
import ToolbarItemMenu from '$lib/components/toolbar/ToolbarItemMenu.svelte';
|
||||||
|
import { Tool } from '$lib/components/toolbar/utils.svelte';
|
||||||
import {
|
import {
|
||||||
Group,
|
Group,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
@@ -8,47 +9,49 @@
|
|||||||
SquareDashedMousePointer,
|
SquareDashedMousePointer,
|
||||||
Ungroup,
|
Ungroup,
|
||||||
MapPin,
|
MapPin,
|
||||||
Filter,
|
Funnel,
|
||||||
Scissors,
|
Scissors,
|
||||||
MountainSnow,
|
MountainSnow,
|
||||||
} from 'lucide-svelte';
|
} from '@lucide/svelte';
|
||||||
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
|
||||||
import { _ } from '$lib/i18n';
|
let props: {
|
||||||
import ToolbarItemMenu from './ToolbarItemMenu.svelte';
|
class?: string;
|
||||||
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-row w-full items-center pr-12">
|
<div class="flex flex-row w-full items-center pr-12">
|
||||||
<div
|
<div
|
||||||
class="h-fit flex flex-col p-1 gap-1.5 bg-background rounded-r-md pointer-events-auto shadow-md {$$props.class ??
|
class="h-fit flex flex-col p-1 gap-1.5 bg-background rounded-r-md pointer-events-auto shadow-md {props.class ??
|
||||||
''}"
|
''}"
|
||||||
>
|
>
|
||||||
<ToolbarItem tool={Tool.ROUTING} label={$_('toolbar.routing.tooltip')}>
|
<ToolbarItem itemTool={Tool.ROUTING} label={i18n._('toolbar.routing.tooltip')}>
|
||||||
<Pencil slot="icon" size="18" />
|
<Pencil size="18" />
|
||||||
</ToolbarItem>
|
</ToolbarItem>
|
||||||
<ToolbarItem tool={Tool.WAYPOINT} label={$_('toolbar.waypoint.tooltip')}>
|
<ToolbarItem itemTool={Tool.WAYPOINT} label={i18n._('toolbar.waypoint.tooltip')}>
|
||||||
<MapPin slot="icon" size="18" />
|
<MapPin size="18" />
|
||||||
</ToolbarItem>
|
</ToolbarItem>
|
||||||
<ToolbarItem tool={Tool.SCISSORS} label={$_('toolbar.scissors.tooltip')}>
|
<ToolbarItem itemTool={Tool.SCISSORS} label={i18n._('toolbar.scissors.tooltip')}>
|
||||||
<Scissors slot="icon" size="18" />
|
<Scissors size="18" />
|
||||||
</ToolbarItem>
|
</ToolbarItem>
|
||||||
<ToolbarItem tool={Tool.TIME} label={$_('toolbar.time.tooltip')}>
|
<ToolbarItem itemTool={Tool.TIME} label={i18n._('toolbar.time.tooltip')}>
|
||||||
<CalendarClock slot="icon" size="18" />
|
<CalendarClock size="18" />
|
||||||
</ToolbarItem>
|
</ToolbarItem>
|
||||||
<ToolbarItem tool={Tool.MERGE} label={$_('toolbar.merge.tooltip')}>
|
<ToolbarItem itemTool={Tool.MERGE} label={i18n._('toolbar.merge.tooltip')}>
|
||||||
<Group slot="icon" size="18" />
|
<Group size="18" />
|
||||||
</ToolbarItem>
|
</ToolbarItem>
|
||||||
<ToolbarItem tool={Tool.EXTRACT} label={$_('toolbar.extract.tooltip')}>
|
<ToolbarItem itemTool={Tool.EXTRACT} label={i18n._('toolbar.extract.tooltip')}>
|
||||||
<Ungroup slot="icon" size="18" />
|
<Ungroup size="18" />
|
||||||
</ToolbarItem>
|
</ToolbarItem>
|
||||||
<ToolbarItem tool={Tool.ELEVATION} label={$_('toolbar.elevation.button')}>
|
<ToolbarItem itemTool={Tool.ELEVATION} label={i18n._('toolbar.elevation.button')}>
|
||||||
<MountainSnow slot="icon" size="18" />
|
<MountainSnow size="18" />
|
||||||
</ToolbarItem>
|
</ToolbarItem>
|
||||||
<ToolbarItem tool={Tool.REDUCE} label={$_('toolbar.reduce.tooltip')}>
|
<ToolbarItem itemTool={Tool.REDUCE} label={i18n._('toolbar.reduce.tooltip')}>
|
||||||
<Filter slot="icon" size="18" />
|
<Funnel size="18" />
|
||||||
</ToolbarItem>
|
</ToolbarItem>
|
||||||
<ToolbarItem tool={Tool.CLEAN} label={$_('toolbar.clean.tooltip')}>
|
<ToolbarItem itemTool={Tool.CLEAN} label={i18n._('toolbar.clean.tooltip')}>
|
||||||
<SquareDashedMousePointer slot="icon" size="18" />
|
<SquareDashedMousePointer size="18" />
|
||||||
</ToolbarItem>
|
</ToolbarItem>
|
||||||
</div>
|
</div>
|
||||||
<ToolbarItemMenu class={$$props.class ?? ''} />
|
<ToolbarItemMenu class={props.class ?? ''} />
|
||||||
</div>
|
</div>
|
||||||
|
@@ -1,29 +1,45 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||||
import { currentTool, type Tool } from '$lib/stores';
|
import { tool, Tool } from '$lib/components/toolbar/utils.svelte';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
export let tool: Tool;
|
let {
|
||||||
export let label: string;
|
itemTool,
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
itemTool: Tool;
|
||||||
|
label: string;
|
||||||
|
children: Snippet;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
function toggleTool() {
|
function toggleTool() {
|
||||||
currentTool.update((current) => (current === tool ? null : tool));
|
if (tool.current === itemTool) {
|
||||||
|
tool.current = null;
|
||||||
|
} else {
|
||||||
|
tool.current = itemTool;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Tooltip.Root openDelay={300}>
|
<Tooltip.Provider>
|
||||||
<Tooltip.Trigger asChild let:builder>
|
<Tooltip.Root delayDuration={300}>
|
||||||
<Button
|
<Tooltip.Trigger>
|
||||||
builders={[builder]}
|
{#snippet child({ props })}
|
||||||
variant="ghost"
|
<Button
|
||||||
class="h-[26px] px-1 py-1.5 {$currentTool === tool ? 'bg-accent' : ''}"
|
{...props}
|
||||||
on:click={toggleTool}
|
variant="ghost"
|
||||||
aria-label={label}
|
class="h-[26px] px-1 py-1.5 {tool.current === itemTool ? 'bg-accent' : ''}"
|
||||||
>
|
onclick={toggleTool}
|
||||||
<slot name="icon" />
|
aria-label={label}
|
||||||
</Button>
|
>
|
||||||
</Tooltip.Trigger>
|
{@render children()}
|
||||||
<Tooltip.Content side="right">
|
</Button>
|
||||||
<span>{label}</span>
|
{/snippet}
|
||||||
</Tooltip.Content>
|
</Tooltip.Trigger>
|
||||||
</Tooltip.Root>
|
<Tooltip.Content side="right">
|
||||||
|
<span>{label}</span>
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Root>
|
||||||
|
</Tooltip.Provider>
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Tool, currentTool } from '$lib/stores';
|
import { Tool, tool } from '$lib/components/toolbar/utils.svelte';
|
||||||
import { settings } from '$lib/db';
|
import { settings } from '$lib/db';
|
||||||
import { flyAndScale } from '$lib/utils';
|
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
import Routing from '$lib/components/toolbar/tools/routing/Routing.svelte';
|
import Routing from '$lib/components/toolbar/tools/routing/Routing.svelte';
|
||||||
import Scissors from '$lib/components/toolbar/tools/scissors/Scissors.svelte';
|
import Scissors from '$lib/components/toolbar/tools/scissors/Scissors.svelte';
|
||||||
@@ -31,31 +30,28 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $currentTool !== null}
|
{#if tool.current !== null}
|
||||||
<div
|
<div class="translate-x-1 h-full animate-in animate-out {$$props.class ?? ''}">
|
||||||
in:flyAndScale={{ x: -2, y: 0, duration: 100 }}
|
|
||||||
class="translate-x-1 h-full {$$props.class ?? ''}"
|
|
||||||
>
|
|
||||||
<div class="rounded-md shadow-md pointer-events-auto">
|
<div class="rounded-md shadow-md pointer-events-auto">
|
||||||
<Card.Root class="rounded-md border-none">
|
<Card.Root class="rounded-md border-none">
|
||||||
<Card.Content class="p-2.5">
|
<Card.Content class="p-2.5">
|
||||||
{#if $currentTool === Tool.ROUTING}
|
{#if tool.current === Tool.ROUTING}
|
||||||
<Routing {popup} {popupElement} bind:minimized={$minimizeRoutingMenu} />
|
<Routing {popup} {popupElement} bind:minimized={$minimizeRoutingMenu} />
|
||||||
{:else if $currentTool === Tool.SCISSORS}
|
{:else if tool.current === Tool.SCISSORS}
|
||||||
<Scissors />
|
<Scissors />
|
||||||
{:else if $currentTool === Tool.WAYPOINT}
|
{:else if tool.current === Tool.WAYPOINT}
|
||||||
<Waypoint />
|
<Waypoint />
|
||||||
{:else if $currentTool === Tool.TIME}
|
{:else if tool.current === Tool.TIME}
|
||||||
<Time />
|
<Time />
|
||||||
{:else if $currentTool === Tool.MERGE}
|
{:else if tool.current === Tool.MERGE}
|
||||||
<Merge />
|
<Merge />
|
||||||
{:else if $currentTool === Tool.ELEVATION}
|
{:else if tool.current === Tool.ELEVATION}
|
||||||
<Elevation />
|
<Elevation />
|
||||||
{:else if $currentTool === Tool.EXTRACT}
|
{:else if tool.current === Tool.EXTRACT}
|
||||||
<Extract />
|
<Extract />
|
||||||
{:else if $currentTool === Tool.CLEAN}
|
{:else if tool.current === Tool.CLEAN}
|
||||||
<Clean />
|
<Clean />
|
||||||
{:else if $currentTool === Tool.REDUCE}
|
{:else if tool.current === Tool.REDUCE}
|
||||||
<Reduce />
|
<Reduce />
|
||||||
{/if}
|
{/if}
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
@@ -66,8 +62,8 @@
|
|||||||
|
|
||||||
<svelte:window
|
<svelte:window
|
||||||
on:keydown={(e) => {
|
on:keydown={(e) => {
|
||||||
if ($currentTool !== null && e.key === 'Escape') {
|
if (tool.current !== null && e.key === 'Escape') {
|
||||||
currentTool.set(null);
|
tool.current = null;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@@ -11,13 +11,14 @@
|
|||||||
import * as RadioGroup from '$lib/components/ui/radio-group';
|
import * as RadioGroup from '$lib/components/ui/radio-group';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import Help from '$lib/components/Help.svelte';
|
import Help from '$lib/components/Help.svelte';
|
||||||
import { _, locale } from '$lib/i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import { getURLForLanguage, resetCursor, setCrosshairCursor } from '$lib/utils';
|
import { getURLForLanguage, resetCursor, setCrosshairCursor } from '$lib/utils';
|
||||||
import { Trash2 } from 'lucide-svelte';
|
import { Trash2 } from '@lucide/svelte';
|
||||||
import { map } from '$lib/stores';
|
import { map } from '$lib/components/map/map.svelte';
|
||||||
import { selection } from '$lib/components/file-list/Selection';
|
import { selection } from '$lib/components/file-list/Selection';
|
||||||
import { dbUtils } from '$lib/db';
|
import { dbUtils } from '$lib/db';
|
||||||
|
import type { GeoJSONSource } from 'mapbox-gl';
|
||||||
|
|
||||||
let cleanType = CleanType.INSIDE;
|
let cleanType = CleanType.INSIDE;
|
||||||
let deleteTrackpoints = true;
|
let deleteTrackpoints = true;
|
||||||
@@ -25,13 +26,13 @@
|
|||||||
let rectangleCoordinates: mapboxgl.LngLat[] = [];
|
let rectangleCoordinates: mapboxgl.LngLat[] = [];
|
||||||
|
|
||||||
function updateRectangle() {
|
function updateRectangle() {
|
||||||
if ($map) {
|
if (map.current) {
|
||||||
if (rectangleCoordinates.length != 2) {
|
if (rectangleCoordinates.length != 2) {
|
||||||
if ($map.getLayer('rectangle')) {
|
if (map.current.getLayer('rectangle')) {
|
||||||
$map.removeLayer('rectangle');
|
map.current.removeLayer('rectangle');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let data = {
|
let data: GeoJSON.Feature = {
|
||||||
type: 'Feature',
|
type: 'Feature',
|
||||||
geometry: {
|
geometry: {
|
||||||
type: 'Polygon',
|
type: 'Polygon',
|
||||||
@@ -45,18 +46,19 @@
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
properties: {},
|
||||||
};
|
};
|
||||||
let source = $map.getSource('rectangle');
|
let source: GeoJSONSource | undefined = map.current.getSource('rectangle');
|
||||||
if (source) {
|
if (source) {
|
||||||
source.setData(data);
|
source.setData(data);
|
||||||
} else {
|
} else {
|
||||||
$map.addSource('rectangle', {
|
map.current.addSource('rectangle', {
|
||||||
type: 'geojson',
|
type: 'geojson',
|
||||||
data: data,
|
data: data,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!$map.getLayer('rectangle')) {
|
if (!map.current.getLayer('rectangle')) {
|
||||||
$map.addLayer({
|
map.current.addLayer({
|
||||||
id: 'rectangle',
|
id: 'rectangle',
|
||||||
type: 'fill',
|
type: 'fill',
|
||||||
source: 'rectangle',
|
source: 'rectangle',
|
||||||
@@ -91,35 +93,34 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
setCrosshairCursor();
|
if (map.current) {
|
||||||
|
setCrosshairCursor(map.current.getCanvas());
|
||||||
|
map.current.on('mousedown', onMouseDown);
|
||||||
|
map.current.on('mousemove', onMouseMove);
|
||||||
|
map.current.on('mouseup', onMouseUp);
|
||||||
|
map.current.on('touchstart', onMouseDown);
|
||||||
|
map.current.on('touchmove', onMouseMove);
|
||||||
|
map.current.on('touchend', onMouseUp);
|
||||||
|
map.current.dragPan.disable();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$: if ($map) {
|
|
||||||
$map.on('mousedown', onMouseDown);
|
|
||||||
$map.on('mousemove', onMouseMove);
|
|
||||||
$map.on('mouseup', onMouseUp);
|
|
||||||
$map.on('touchstart', onMouseDown);
|
|
||||||
$map.on('touchmove', onMouseMove);
|
|
||||||
$map.on('touchend', onMouseUp);
|
|
||||||
$map.dragPan.disable();
|
|
||||||
}
|
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
resetCursor();
|
if (map.current) {
|
||||||
if ($map) {
|
resetCursor(map.current.getCanvas());
|
||||||
$map.off('mousedown', onMouseDown);
|
map.current.off('mousedown', onMouseDown);
|
||||||
$map.off('mousemove', onMouseMove);
|
map.current.off('mousemove', onMouseMove);
|
||||||
$map.off('mouseup', onMouseUp);
|
map.current.off('mouseup', onMouseUp);
|
||||||
$map.off('touchstart', onMouseDown);
|
map.current.off('touchstart', onMouseDown);
|
||||||
$map.off('touchmove', onMouseMove);
|
map.current.off('touchmove', onMouseMove);
|
||||||
$map.off('touchend', onMouseUp);
|
map.current.off('touchend', onMouseUp);
|
||||||
$map.dragPan.enable();
|
map.current.dragPan.enable();
|
||||||
|
|
||||||
if ($map.getLayer('rectangle')) {
|
if (map.current.getLayer('rectangle')) {
|
||||||
$map.removeLayer('rectangle');
|
map.current.removeLayer('rectangle');
|
||||||
}
|
}
|
||||||
if ($map.getSource('rectangle')) {
|
if (map.current.getSource('rectangle')) {
|
||||||
$map.removeSource('rectangle');
|
map.current.removeSource('rectangle');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -132,23 +133,23 @@
|
|||||||
<div class="flex flex-row items-center gap-[6.4px] h-3">
|
<div class="flex flex-row items-center gap-[6.4px] h-3">
|
||||||
<Checkbox id="delete-trkpt" bind:checked={deleteTrackpoints} class="scale-90" />
|
<Checkbox id="delete-trkpt" bind:checked={deleteTrackpoints} class="scale-90" />
|
||||||
<Label for="delete-trkpt">
|
<Label for="delete-trkpt">
|
||||||
{$_('toolbar.clean.delete_trackpoints')}
|
{i18n._('toolbar.clean.delete_trackpoints')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row items-center gap-[6.4px] h-3">
|
<div class="flex flex-row items-center gap-[6.4px] h-3">
|
||||||
<Checkbox id="delete-wpt" bind:checked={deleteWaypoints} class="scale-90" />
|
<Checkbox id="delete-wpt" bind:checked={deleteWaypoints} class="scale-90" />
|
||||||
<Label for="delete-wpt">
|
<Label for="delete-wpt">
|
||||||
{$_('toolbar.clean.delete_waypoints')}
|
{i18n._('toolbar.clean.delete_waypoints')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<RadioGroup.Root bind:value={cleanType}>
|
<RadioGroup.Root bind:value={cleanType}>
|
||||||
<Label class="flex flex-row items-center gap-2">
|
<Label class="flex flex-row items-center gap-2">
|
||||||
<RadioGroup.Item value={CleanType.INSIDE} />
|
<RadioGroup.Item value={CleanType.INSIDE} />
|
||||||
{$_('toolbar.clean.delete_inside')}
|
{i18n._('toolbar.clean.delete_inside')}
|
||||||
</Label>
|
</Label>
|
||||||
<Label class="flex flex-row items-center gap-2">
|
<Label class="flex flex-row items-center gap-2">
|
||||||
<RadioGroup.Item value={CleanType.OUTSIDE} />
|
<RadioGroup.Item value={CleanType.OUTSIDE} />
|
||||||
{$_('toolbar.clean.delete_outside')}
|
{i18n._('toolbar.clean.delete_outside')}
|
||||||
</Label>
|
</Label>
|
||||||
</RadioGroup.Root>
|
</RadioGroup.Root>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@@ -156,7 +157,7 @@
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
disabled={!validSelection || rectangleCoordinates.length != 2}
|
disabled={!validSelection || rectangleCoordinates.length != 2}
|
||||||
on:click={() => {
|
onclick={() => {
|
||||||
dbUtils.cleanSelection(
|
dbUtils.cleanSelection(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -176,13 +177,13 @@
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 size="16" class="mr-1" />
|
<Trash2 size="16" class="mr-1" />
|
||||||
{$_('toolbar.clean.button')}
|
{i18n._('toolbar.clean.button')}
|
||||||
</Button>
|
</Button>
|
||||||
<Help link={getURLForLanguage($locale, '/help/toolbar/clean')}>
|
<Help link={getURLForLanguage(i18n.lang, '/help/toolbar/clean')}>
|
||||||
{#if validSelection}
|
{#if validSelection}
|
||||||
{$_('toolbar.clean.help')}
|
{i18n._('toolbar.clean.help')}
|
||||||
{:else}
|
{:else}
|
||||||
{$_('toolbar.clean.help_no_selection')}
|
{i18n._('toolbar.clean.help_no_selection')}
|
||||||
{/if}
|
{/if}
|
||||||
</Help>
|
</Help>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -2,34 +2,38 @@
|
|||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { selection } from '$lib/components/file-list/Selection';
|
import { selection } from '$lib/components/file-list/Selection';
|
||||||
import Help from '$lib/components/Help.svelte';
|
import Help from '$lib/components/Help.svelte';
|
||||||
import { MountainSnow } from 'lucide-svelte';
|
import { MountainSnow } from '@lucide/svelte';
|
||||||
import { dbUtils } from '$lib/db';
|
import { dbUtils } from '$lib/db';
|
||||||
import { map } from '$lib/stores';
|
import { map } from '$lib/components/map/map.svelte';
|
||||||
import { _, locale } from '$lib/i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
|
|
||||||
$: validSelection = $selection.size > 0;
|
let props: {
|
||||||
|
class?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let validSelection = $derived($selection.size > 0);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<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"
|
||||||
disabled={!validSelection}
|
disabled={!validSelection}
|
||||||
on:click={async () => {
|
onclick={async () => {
|
||||||
if ($map) {
|
if (map.current) {
|
||||||
dbUtils.addElevationToSelection($map);
|
dbUtils.addElevationToSelection(map.current);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MountainSnow size="16" class="mr-1 shrink-0" />
|
<MountainSnow size="16" class="mr-1 shrink-0" />
|
||||||
{$_('toolbar.elevation.button')}
|
{i18n._('toolbar.elevation.button')}
|
||||||
</Button>
|
</Button>
|
||||||
<Help link={getURLForLanguage($locale, '/help/toolbar/elevation')}>
|
<Help link={getURLForLanguage(i18n.lang, '/help/toolbar/elevation')}>
|
||||||
{#if validSelection}
|
{#if validSelection}
|
||||||
{$_('toolbar.elevation.help')}
|
{i18n._('toolbar.elevation.help')}
|
||||||
{:else}
|
{:else}
|
||||||
{$_('toolbar.elevation.help_no_selection')}
|
{i18n._('toolbar.elevation.help_no_selection')}
|
||||||
{/if}
|
{/if}
|
||||||
</Help>
|
</Help>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Ungroup } from 'lucide-svelte';
|
import { Ungroup } from '@lucide/svelte';
|
||||||
import { selection } from '$lib/components/file-list/Selection';
|
import { selection } from '$lib/components/file-list/Selection';
|
||||||
import {
|
import {
|
||||||
ListFileItem,
|
ListFileItem,
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
} from '$lib/components/file-list/FileList';
|
} from '$lib/components/file-list/FileList';
|
||||||
import Help from '$lib/components/Help.svelte';
|
import Help from '$lib/components/Help.svelte';
|
||||||
import { dbUtils, getFile } from '$lib/db';
|
import { dbUtils, getFile } from '$lib/db';
|
||||||
import { _, locale } from '$lib/i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
|
|
||||||
$: validSelection =
|
$: validSelection =
|
||||||
@@ -39,15 +39,15 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<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 variant="outline" disabled={!validSelection} on:click={dbUtils.extractSelection}>
|
<Button variant="outline" disabled={!validSelection} onclick={dbUtils.extractSelection}>
|
||||||
<Ungroup size="16" class="mr-1" />
|
<Ungroup size="16" class="mr-1" />
|
||||||
{$_('toolbar.extract.button')}
|
{i18n._('toolbar.extract.button')}
|
||||||
</Button>
|
</Button>
|
||||||
<Help link={getURLForLanguage($locale, '/help/toolbar/extract')}>
|
<Help link={getURLForLanguage(i18n.lang, '/help/toolbar/extract')}>
|
||||||
{#if validSelection}
|
{#if validSelection}
|
||||||
{$_('toolbar.extract.help')}
|
{i18n._('toolbar.extract.help')}
|
||||||
{:else}
|
{:else}
|
||||||
{$_('toolbar.extract.help_invalid_selection')}
|
{i18n._('toolbar.extract.help_invalid_selection')}
|
||||||
{/if}
|
{/if}
|
||||||
</Help>
|
</Help>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -13,9 +13,9 @@
|
|||||||
import { Label } from '$lib/components/ui/label/index.js';
|
import { Label } from '$lib/components/ui/label/index.js';
|
||||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
import * as RadioGroup from '$lib/components/ui/radio-group';
|
import * as RadioGroup from '$lib/components/ui/radio-group';
|
||||||
import { _, locale } from '$lib/i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { dbUtils, getFile } from '$lib/db';
|
import { dbUtils, getFile } from '$lib/db';
|
||||||
import { Group } from 'lucide-svelte';
|
import { Group } from '@lucide/svelte';
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||||
import { gpxStatistics } from '$lib/stores';
|
import { gpxStatistics } from '$lib/stores';
|
||||||
@@ -61,17 +61,17 @@
|
|||||||
<RadioGroup.Root bind:value={mergeType}>
|
<RadioGroup.Root bind:value={mergeType}>
|
||||||
<Label class="flex flex-row items-center gap-1.5 leading-5">
|
<Label class="flex flex-row items-center gap-1.5 leading-5">
|
||||||
<RadioGroup.Item value={MergeType.TRACES} />
|
<RadioGroup.Item value={MergeType.TRACES} />
|
||||||
{$_('toolbar.merge.merge_traces')}
|
{i18n._('toolbar.merge.merge_traces')}
|
||||||
</Label>
|
</Label>
|
||||||
<Label class="flex flex-row items-center gap-1.5 leading-5">
|
<Label class="flex flex-row items-center gap-1.5 leading-5">
|
||||||
<RadioGroup.Item value={MergeType.CONTENTS} />
|
<RadioGroup.Item value={MergeType.CONTENTS} />
|
||||||
{$_('toolbar.merge.merge_contents')}
|
{i18n._('toolbar.merge.merge_contents')}
|
||||||
</Label>
|
</Label>
|
||||||
</RadioGroup.Root>
|
</RadioGroup.Root>
|
||||||
{#if mergeType === MergeType.TRACES && $gpxStatistics.global.time.total > 0}
|
{#if mergeType === MergeType.TRACES && $gpxStatistics.global.time.total > 0}
|
||||||
<div class="flex flex-row items-center gap-1.5">
|
<div class="flex flex-row items-center gap-1.5">
|
||||||
<Checkbox id="remove-gaps" bind:checked={removeGaps} />
|
<Checkbox id="remove-gaps" bind:checked={removeGaps} />
|
||||||
<Label for="remove-gaps">{$_('toolbar.merge.remove_gaps')}</Label>
|
<Label for="remove-gaps">{i18n._('toolbar.merge.remove_gaps')}</Label>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<Button
|
<Button
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
class="whitespace-normal h-fit"
|
class="whitespace-normal h-fit"
|
||||||
disabled={(mergeType === MergeType.TRACES && !canMergeTraces) ||
|
disabled={(mergeType === MergeType.TRACES && !canMergeTraces) ||
|
||||||
(mergeType === MergeType.CONTENTS && !canMergeContents)}
|
(mergeType === MergeType.CONTENTS && !canMergeContents)}
|
||||||
on:click={() => {
|
onclick={() => {
|
||||||
dbUtils.mergeSelection(
|
dbUtils.mergeSelection(
|
||||||
mergeType === MergeType.TRACES,
|
mergeType === MergeType.TRACES,
|
||||||
mergeType === MergeType.TRACES && $gpxStatistics.global.time.total > 0 && removeGaps
|
mergeType === MergeType.TRACES && $gpxStatistics.global.time.total > 0 && removeGaps
|
||||||
@@ -87,31 +87,31 @@
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Group size="16" class="mr-1 shrink-0" />
|
<Group size="16" class="mr-1 shrink-0" />
|
||||||
{$_('toolbar.merge.merge_selection')}
|
{i18n._('toolbar.merge.merge_selection')}
|
||||||
</Button>
|
</Button>
|
||||||
<Help link={getURLForLanguage($locale, '/help/toolbar/merge')}>
|
<Help link={getURLForLanguage(i18n.lang, '/help/toolbar/merge')}>
|
||||||
{#if mergeType === MergeType.TRACES && canMergeTraces}
|
{#if mergeType === MergeType.TRACES && canMergeTraces}
|
||||||
{$_('toolbar.merge.help_merge_traces')}
|
{i18n._('toolbar.merge.help_merge_traces')}
|
||||||
{:else if mergeType === MergeType.TRACES && !canMergeTraces}
|
{:else if mergeType === MergeType.TRACES && !canMergeTraces}
|
||||||
{$_('toolbar.merge.help_cannot_merge_traces')}
|
{i18n._('toolbar.merge.help_cannot_merge_traces')}
|
||||||
{$_('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[0]}
|
{i18n._('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[0]}
|
||||||
<Shortcut
|
<Shortcut
|
||||||
ctrl={true}
|
ctrl={true}
|
||||||
click={true}
|
click={true}
|
||||||
class="inline-flex text-muted-foreground text-xs border rounded p-0.5 gap-0"
|
class="inline-flex text-muted-foreground text-xs border rounded p-0.5 gap-0"
|
||||||
/>
|
/>
|
||||||
{$_('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[1]}
|
{i18n._('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[1]}
|
||||||
{:else if mergeType === MergeType.CONTENTS && canMergeContents}
|
{:else if mergeType === MergeType.CONTENTS && canMergeContents}
|
||||||
{$_('toolbar.merge.help_merge_contents')}
|
{i18n._('toolbar.merge.help_merge_contents')}
|
||||||
{:else if mergeType === MergeType.CONTENTS && !canMergeContents}
|
{:else if mergeType === MergeType.CONTENTS && !canMergeContents}
|
||||||
{$_('toolbar.merge.help_cannot_merge_contents')}
|
{i18n._('toolbar.merge.help_cannot_merge_contents')}
|
||||||
{$_('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[0]}
|
{i18n._('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[0]}
|
||||||
<Shortcut
|
<Shortcut
|
||||||
ctrl={true}
|
ctrl={true}
|
||||||
click={true}
|
click={true}
|
||||||
class="inline-flex text-muted-foreground text-xs border rounded p-0.5 gap-0"
|
class="inline-flex text-muted-foreground text-xs border rounded p-0.5 gap-0"
|
||||||
/>
|
/>
|
||||||
{$_('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[1]}
|
{i18n._('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[1]}
|
||||||
{/if}
|
{/if}
|
||||||
</Help>
|
</Help>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -9,26 +9,30 @@
|
|||||||
ListTrackSegmentItem,
|
ListTrackSegmentItem,
|
||||||
} from '$lib/components/file-list/FileList';
|
} from '$lib/components/file-list/FileList';
|
||||||
import Help from '$lib/components/Help.svelte';
|
import Help from '$lib/components/Help.svelte';
|
||||||
import { Filter } from 'lucide-svelte';
|
import { Funnel } from '@lucide/svelte';
|
||||||
import { _, locale } from '$lib/i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
import WithUnits from '$lib/components/WithUnits.svelte';
|
||||||
import { dbUtils, fileObservers } from '$lib/db';
|
import { dbUtils, fileObservers } from '$lib/db';
|
||||||
import { map } from '$lib/stores';
|
import { map } from '$lib/components/map/map.svelte';
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
import { ramerDouglasPeucker, TrackPoint, type SimplifiedTrackPoint } from 'gpx';
|
import { ramerDouglasPeucker, TrackPoint, type SimplifiedTrackPoint } from 'gpx';
|
||||||
import { derived } from 'svelte/store';
|
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
|
import type { GeoJSONSource } from 'mapbox-gl';
|
||||||
|
|
||||||
let sliderValue = [50];
|
let props: { class?: string } = $props();
|
||||||
let maxPoints = 0;
|
|
||||||
let currentPoints = 0;
|
let sliderValue = $state([50]);
|
||||||
|
let maxPoints = $state(0);
|
||||||
|
let currentPoints = $state(0);
|
||||||
const minTolerance = 0.1;
|
const minTolerance = 0.1;
|
||||||
const maxTolerance = 10000;
|
const maxTolerance = 10000;
|
||||||
|
|
||||||
$: validSelection = $selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']);
|
let validSelection = $derived(
|
||||||
|
$selection.hasAnyChildren(new ListRootItem(), true, ['waypoints'])
|
||||||
$: tolerance =
|
);
|
||||||
minTolerance * 2 ** (sliderValue[0] / (100 / Math.log2(maxTolerance / minTolerance)));
|
let tolerance = $derived(
|
||||||
|
minTolerance * 2 ** (sliderValue[0] / (100 / Math.log2(maxTolerance / minTolerance)))
|
||||||
|
);
|
||||||
|
|
||||||
let simplified = new Map<string, [ListItem, number, SimplifiedTrackPoint[]]>();
|
let simplified = new Map<string, [ListItem, number, SimplifiedTrackPoint[]]>();
|
||||||
let unsubscribes = new Map<string, () => void>();
|
let unsubscribes = new Map<string, () => void>();
|
||||||
@@ -63,18 +67,18 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if ($map) {
|
if (map.current) {
|
||||||
let source = $map.getSource('simplified');
|
let source: GeoJSONSource | undefined = map.current.getSource('simplified');
|
||||||
if (source) {
|
if (source) {
|
||||||
source.setData(data);
|
source.setData(data);
|
||||||
} else {
|
} else {
|
||||||
$map.addSource('simplified', {
|
map.current.addSource('simplified', {
|
||||||
type: 'geojson',
|
type: 'geojson',
|
||||||
data: data,
|
data: data,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!$map.getLayer('simplified')) {
|
if (!map.current.getLayer('simplified')) {
|
||||||
$map.addLayer({
|
map.current.addLayer({
|
||||||
id: 'simplified',
|
id: 'simplified',
|
||||||
type: 'line',
|
type: 'line',
|
||||||
source: 'simplified',
|
source: 'simplified',
|
||||||
@@ -84,62 +88,66 @@
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
$map.moveLayer('simplified');
|
map.current.moveLayer('simplified');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$: if ($fileObservers) {
|
$effect(() => {
|
||||||
unsubscribes.forEach((unsubscribe, fileId) => {
|
if ($fileObservers) {
|
||||||
if (!$fileObservers.has(fileId)) {
|
unsubscribes.forEach((unsubscribe, fileId) => {
|
||||||
unsubscribe();
|
if (!$fileObservers.has(fileId)) {
|
||||||
unsubscribes.delete(fileId);
|
unsubscribe();
|
||||||
}
|
unsubscribes.delete(fileId);
|
||||||
});
|
}
|
||||||
$fileObservers.forEach((fileStore, fileId) => {
|
});
|
||||||
if (!unsubscribes.has(fileId)) {
|
$fileObservers.forEach((fileStore, fileId) => {
|
||||||
let unsubscribe = derived([fileStore, selection], ([fs, sel]) => [
|
if (!unsubscribes.has(fileId)) {
|
||||||
fs,
|
let unsubscribe = derived([fileStore, selection], ([fs, sel]) => [
|
||||||
sel,
|
fs,
|
||||||
]).subscribe(([fs, sel]) => {
|
sel,
|
||||||
if (fs) {
|
]).subscribe(([fs, sel]) => {
|
||||||
fs.file.forEachSegment((segment, trackIndex, segmentIndex) => {
|
if (fs) {
|
||||||
let segmentItem = new ListTrackSegmentItem(
|
fs.file.forEachSegment((segment, trackIndex, segmentIndex) => {
|
||||||
fileId,
|
let segmentItem = new ListTrackSegmentItem(
|
||||||
trackIndex,
|
fileId,
|
||||||
segmentIndex
|
trackIndex,
|
||||||
);
|
segmentIndex
|
||||||
if (sel.hasAnyParent(segmentItem)) {
|
);
|
||||||
let statistics = fs.statistics.getStatisticsFor(segmentItem);
|
if (sel.hasAnyParent(segmentItem)) {
|
||||||
simplified.set(segmentItem.getFullId(), [
|
let statistics = fs.statistics.getStatisticsFor(segmentItem);
|
||||||
segmentItem,
|
simplified.set(segmentItem.getFullId(), [
|
||||||
statistics.local.points.length,
|
segmentItem,
|
||||||
ramerDouglasPeucker(statistics.local.points, minTolerance),
|
statistics.local.points.length,
|
||||||
]);
|
ramerDouglasPeucker(statistics.local.points, minTolerance),
|
||||||
update();
|
]);
|
||||||
} else if (simplified.has(segmentItem.getFullId())) {
|
update();
|
||||||
simplified.delete(segmentItem.getFullId());
|
} else if (simplified.has(segmentItem.getFullId())) {
|
||||||
update();
|
simplified.delete(segmentItem.getFullId());
|
||||||
}
|
update();
|
||||||
});
|
}
|
||||||
}
|
});
|
||||||
});
|
}
|
||||||
unsubscribes.set(fileId, unsubscribe);
|
});
|
||||||
}
|
unsubscribes.set(fileId, unsubscribe);
|
||||||
});
|
}
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
$: if (tolerance) {
|
$effect(() => {
|
||||||
update();
|
if (tolerance) {
|
||||||
}
|
update();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
if ($map) {
|
if (map.current) {
|
||||||
if ($map.getLayer('simplified')) {
|
if (map.current.getLayer('simplified')) {
|
||||||
$map.removeLayer('simplified');
|
map.current.removeLayer('simplified');
|
||||||
}
|
}
|
||||||
if ($map.getSource('simplified')) {
|
if (map.current.getSource('simplified')) {
|
||||||
$map.removeSource('simplified');
|
map.current.removeSource('simplified');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
unsubscribes.forEach((unsubscribe) => unsubscribe());
|
unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||||
@@ -160,28 +168,28 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<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} />
|
<Slider bind:value={sliderValue} min={0} max={100} step={1} type="multiple" />
|
||||||
</div>
|
</div>
|
||||||
<Label class="flex flex-row justify-between">
|
<Label class="flex flex-row justify-between">
|
||||||
<span>{$_('toolbar.reduce.tolerance')}</span>
|
<span>{i18n._('toolbar.reduce.tolerance')}</span>
|
||||||
<WithUnits value={tolerance / 1000} type="distance" decimals={4} class="font-normal" />
|
<WithUnits value={tolerance / 1000} type="distance" decimals={4} class="font-normal" />
|
||||||
</Label>
|
</Label>
|
||||||
<Label class="flex flex-row justify-between">
|
<Label class="flex flex-row justify-between">
|
||||||
<span>{$_('toolbar.reduce.number_of_points')}</span>
|
<span>{i18n._('toolbar.reduce.number_of_points')}</span>
|
||||||
<span class="font-normal">{currentPoints}/{maxPoints}</span>
|
<span class="font-normal">{currentPoints}/{maxPoints}</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Button variant="outline" disabled={!validSelection} on:click={reduce}>
|
<Button variant="outline" disabled={!validSelection} onclick={reduce}>
|
||||||
<Filter size="16" class="mr-1" />
|
<Funnel size="16" class="mr-1" />
|
||||||
{$_('toolbar.reduce.button')}
|
{i18n._('toolbar.reduce.button')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Help link={getURLForLanguage($locale, '/help/toolbar/minify')}>
|
<Help link={getURLForLanguage(i18n.lang, '/help/toolbar/minify')}>
|
||||||
{#if validSelection}
|
{#if validSelection}
|
||||||
{$_('toolbar.reduce.help')}
|
{i18n._('toolbar.reduce.help')}
|
||||||
{:else}
|
{:else}
|
||||||
{$_('toolbar.reduce.help_no_selection')}
|
{i18n._('toolbar.reduce.help_no_selection')}
|
||||||
{/if}
|
{/if}
|
||||||
</Help>
|
</Help>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -14,10 +14,9 @@
|
|||||||
nauticalMilesToKilometers,
|
nauticalMilesToKilometers,
|
||||||
} from '$lib/units';
|
} from '$lib/units';
|
||||||
import { CalendarDate, type DateValue } from '@internationalized/date';
|
import { CalendarDate, type DateValue } from '@internationalized/date';
|
||||||
import { CalendarClock, CirclePlay, CircleStop, CircleX, Timer, Zap } from 'lucide-svelte';
|
import { CalendarClock, CirclePlay, CircleStop, CircleX, Timer, Zap } from '@lucide/svelte';
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
import { _, locale } from '$lib/i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { get } from 'svelte/store';
|
|
||||||
import { selection } from '$lib/components/file-list/Selection';
|
import { selection } from '$lib/components/file-list/Selection';
|
||||||
import {
|
import {
|
||||||
ListFileItem,
|
ListFileItem,
|
||||||
@@ -183,9 +182,9 @@
|
|||||||
<Label for="speed" class="flex flex-row">
|
<Label for="speed" class="flex flex-row">
|
||||||
<Zap size="16" class="mr-1" />
|
<Zap size="16" class="mr-1" />
|
||||||
{#if $velocityUnits === 'speed'}
|
{#if $velocityUnits === 'speed'}
|
||||||
{$_('quantities.speed')}
|
{i18n._('quantities.speed')}
|
||||||
{:else}
|
{:else}
|
||||||
{$_('quantities.pace')}
|
{i18n._('quantities.pace')}
|
||||||
{/if}
|
{/if}
|
||||||
</Label>
|
</Label>
|
||||||
<div class="flex flex-row gap-1 items-center">
|
<div class="flex flex-row gap-1 items-center">
|
||||||
@@ -197,15 +196,15 @@
|
|||||||
min={0.01}
|
min={0.01}
|
||||||
disabled={!canUpdate}
|
disabled={!canUpdate}
|
||||||
bind:value={speed}
|
bind:value={speed}
|
||||||
on:change={updateDataFromSpeed}
|
onchange={updateDataFromSpeed}
|
||||||
/>
|
/>
|
||||||
<span class="text-sm shrink-0">
|
<span class="text-sm shrink-0">
|
||||||
{#if $distanceUnits === 'imperial'}
|
{#if $distanceUnits === 'imperial'}
|
||||||
{$_('units.miles_per_hour')}
|
{i18n._('units.miles_per_hour')}
|
||||||
{:else if $distanceUnits === 'metric'}
|
{:else if $distanceUnits === 'metric'}
|
||||||
{$_('units.kilometers_per_hour')}
|
{i18n._('units.kilometers_per_hour')}
|
||||||
{:else if $distanceUnits === 'nautical'}
|
{:else if $distanceUnits === 'nautical'}
|
||||||
{$_('units.knots')}
|
{i18n._('units.knots')}
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -217,11 +216,11 @@
|
|||||||
/>
|
/>
|
||||||
<span class="text-sm shrink-0">
|
<span class="text-sm shrink-0">
|
||||||
{#if $distanceUnits === 'imperial'}
|
{#if $distanceUnits === 'imperial'}
|
||||||
{$_('units.minutes_per_mile')}
|
{i18n._('units.minutes_per_mile')}
|
||||||
{:else if $distanceUnits === 'metric'}
|
{:else if $distanceUnits === 'metric'}
|
||||||
{$_('units.minutes_per_kilometer')}
|
{i18n._('units.minutes_per_kilometer')}
|
||||||
{:else if $distanceUnits === 'nautical'}
|
{:else if $distanceUnits === 'nautical'}
|
||||||
{$_('units.minutes_per_nautical_mile')}
|
{i18n._('units.minutes_per_nautical_mile')}
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -230,7 +229,7 @@
|
|||||||
<div class="flex flex-col gap-2 grow">
|
<div class="flex flex-col gap-2 grow">
|
||||||
<Label for="duration" class="flex flex-row">
|
<Label for="duration" class="flex flex-row">
|
||||||
<Timer size="16" class="mr-1" />
|
<Timer size="16" class="mr-1" />
|
||||||
{$_('toolbar.time.total_time')}
|
{i18n._('toolbar.time.total_time')}
|
||||||
</Label>
|
</Label>
|
||||||
<TimePicker
|
<TimePicker
|
||||||
bind:value={movingTime}
|
bind:value={movingTime}
|
||||||
@@ -241,14 +240,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<Label class="flex flex-row">
|
<Label class="flex flex-row">
|
||||||
<CirclePlay size="16" class="mr-1" />
|
<CirclePlay size="16" class="mr-1" />
|
||||||
{$_('toolbar.time.start')}
|
{i18n._('toolbar.time.start')}
|
||||||
</Label>
|
</Label>
|
||||||
<div class="flex flex-row gap-2">
|
<div class="flex flex-row gap-2">
|
||||||
<DatePicker
|
<DatePicker
|
||||||
bind:value={startDate}
|
bind:value={startDate}
|
||||||
disabled={!canUpdate}
|
disabled={!canUpdate}
|
||||||
locale={get(locale) ?? 'en'}
|
locale={i18n.lang}
|
||||||
placeholder={$_('toolbar.time.pick_date')}
|
placeholder={i18n._('toolbar.time.pick_date')}
|
||||||
class="w-fit grow"
|
class="w-fit grow"
|
||||||
onValueChange={async () => {
|
onValueChange={async () => {
|
||||||
await tick();
|
await tick();
|
||||||
@@ -266,14 +265,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<Label class="flex flex-row">
|
<Label class="flex flex-row">
|
||||||
<CircleStop size="16" class="mr-1" />
|
<CircleStop size="16" class="mr-1" />
|
||||||
{$_('toolbar.time.end')}
|
{i18n._('toolbar.time.end')}
|
||||||
</Label>
|
</Label>
|
||||||
<div class="flex flex-row gap-2">
|
<div class="flex flex-row gap-2">
|
||||||
<DatePicker
|
<DatePicker
|
||||||
bind:value={endDate}
|
bind:value={endDate}
|
||||||
disabled={!canUpdate}
|
disabled={!canUpdate}
|
||||||
locale={get(locale) ?? 'en'}
|
locale={i18n.lang}
|
||||||
placeholder={$_('toolbar.time.pick_date')}
|
placeholder={i18n._('toolbar.time.pick_date')}
|
||||||
class="w-fit grow"
|
class="w-fit grow"
|
||||||
onValueChange={async () => {
|
onValueChange={async () => {
|
||||||
await tick();
|
await tick();
|
||||||
@@ -293,7 +292,7 @@
|
|||||||
<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} />
|
||||||
<Label for="artificial-time">
|
<Label for="artificial-time">
|
||||||
{$_('toolbar.time.artificial')}
|
{i18n._('toolbar.time.artificial')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -303,7 +302,7 @@
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={!canUpdate}
|
disabled={!canUpdate}
|
||||||
class="grow whitespace-normal h-fit"
|
class="grow whitespace-normal h-fit"
|
||||||
on:click={() => {
|
onclick={() => {
|
||||||
let effectiveSpeed = getSpeed();
|
let effectiveSpeed = getSpeed();
|
||||||
if (
|
if (
|
||||||
startDate === undefined ||
|
startDate === undefined ||
|
||||||
@@ -378,22 +377,24 @@
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CalendarClock size="16" class="mr-1 shrink-0" />
|
<CalendarClock size="16" class="mr-1 shrink-0" />
|
||||||
{$_('toolbar.time.update')}
|
{i18n._('toolbar.time.update')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" on:click={setGPXData}>
|
<Button variant="outline" onclick={setGPXData}>
|
||||||
<CircleX size="16" />
|
<CircleX size="16" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Help link={getURLForLanguage($locale, '/help/toolbar/time')}>
|
<Help link={getURLForLanguage(i18n.lang, '/help/toolbar/time')}>
|
||||||
{#if canUpdate}
|
{#if canUpdate}
|
||||||
{$_('toolbar.time.help')}
|
{i18n._('toolbar.time.help')}
|
||||||
{:else}
|
{:else}
|
||||||
{$_('toolbar.time.help_invalid_selection')}
|
{i18n._('toolbar.time.help_invalid_selection')}
|
||||||
{/if}
|
{/if}
|
||||||
</Help>
|
</Help>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
|
@reference "../../../../app.css";
|
||||||
|
|
||||||
div :global(input[type='time']) {
|
div :global(input[type='time']) {
|
||||||
/*
|
/*
|
||||||
Style copy-pasted from shadcn-svelte Input.
|
Style copy-pasted from shadcn-svelte Input.
|
||||||
|
@@ -12,7 +12,7 @@
|
|||||||
import * as Select from '$lib/components/ui/select';
|
import * as Select from '$lib/components/ui/select';
|
||||||
import { selection } from '$lib/components/file-list/Selection';
|
import { selection } from '$lib/components/file-list/Selection';
|
||||||
import { Waypoint } from 'gpx';
|
import { Waypoint } from 'gpx';
|
||||||
import { _, locale } from '$lib/i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { ListWaypointItem } from '$lib/components/file-list/FileList';
|
import { ListWaypointItem } from '$lib/components/file-list/FileList';
|
||||||
import { dbUtils, fileObservers, getFile, settings, type GPXFileWithStatistics } from '$lib/db';
|
import { dbUtils, fileObservers, getFile, settings, type GPXFileWithStatistics } from '$lib/db';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import { map } from '$lib/stores';
|
import { map } from '$lib/stores';
|
||||||
import { getURLForLanguage, resetCursor, setCrosshairCursor } from '$lib/utils';
|
import { getURLForLanguage, resetCursor, setCrosshairCursor } from '$lib/utils';
|
||||||
import { MapPin, CircleX, Save } from 'lucide-svelte';
|
import { MapPin, CircleX, Save } from '@lucide/svelte';
|
||||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||||
|
|
||||||
let name: string;
|
let name: string;
|
||||||
@@ -28,11 +28,7 @@
|
|||||||
let link: string;
|
let link: string;
|
||||||
let longitude: number;
|
let longitude: number;
|
||||||
let latitude: number;
|
let latitude: number;
|
||||||
|
let symbolKey: string;
|
||||||
let selectedSymbol = {
|
|
||||||
value: '',
|
|
||||||
label: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
const { treeFileView } = settings;
|
const { treeFileView } = settings;
|
||||||
|
|
||||||
@@ -70,18 +66,7 @@
|
|||||||
}
|
}
|
||||||
link = $selectedWaypoint[0].link?.attributes?.href ?? '';
|
link = $selectedWaypoint[0].link?.attributes?.href ?? '';
|
||||||
let symbol = $selectedWaypoint[0].sym ?? '';
|
let symbol = $selectedWaypoint[0].sym ?? '';
|
||||||
let symbolKey = getSymbolKey(symbol);
|
symbolKey = getSymbolKey(symbol) ?? symbol ?? '';
|
||||||
if (symbolKey) {
|
|
||||||
selectedSymbol = {
|
|
||||||
value: symbol,
|
|
||||||
label: $_(`gpx.symbol.${symbolKey}`),
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
selectedSymbol = {
|
|
||||||
value: symbol,
|
|
||||||
label: '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
longitude = parseFloat($selectedWaypoint[0].getLongitude().toFixed(6));
|
longitude = parseFloat($selectedWaypoint[0].getLongitude().toFixed(6));
|
||||||
latitude = parseFloat($selectedWaypoint[0].getLatitude().toFixed(6));
|
latitude = parseFloat($selectedWaypoint[0].getLatitude().toFixed(6));
|
||||||
} else {
|
} else {
|
||||||
@@ -97,10 +82,7 @@
|
|||||||
name = '';
|
name = '';
|
||||||
description = '';
|
description = '';
|
||||||
link = '';
|
link = '';
|
||||||
selectedSymbol = {
|
symbolKey = '';
|
||||||
value: '',
|
|
||||||
label: '',
|
|
||||||
};
|
|
||||||
longitude = 0;
|
longitude = 0;
|
||||||
latitude = 0;
|
latitude = 0;
|
||||||
}
|
}
|
||||||
@@ -140,7 +122,7 @@
|
|||||||
desc: description.length > 0 ? description : undefined,
|
desc: description.length > 0 ? description : undefined,
|
||||||
cmt: description.length > 0 ? description : undefined,
|
cmt: description.length > 0 ? description : undefined,
|
||||||
link: link.length > 0 ? { attributes: { href: link } } : undefined,
|
link: link.length > 0 ? { attributes: { href: link } } : undefined,
|
||||||
sym: selectedSymbol.value.length > 0 ? selectedSymbol.value : undefined,
|
sym: symbols[symbolKey]?.value ?? '',
|
||||||
},
|
},
|
||||||
$selectedWaypoint
|
$selectedWaypoint
|
||||||
? new ListWaypointItem($selectedWaypoint[1], $selectedWaypoint[0]._data.index)
|
? new ListWaypointItem($selectedWaypoint[1], $selectedWaypoint[0]._data.index)
|
||||||
@@ -157,7 +139,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
$: sortedSymbols = Object.entries(symbols).sort((a, b) => {
|
$: sortedSymbols = Object.entries(symbols).sort((a, b) => {
|
||||||
return $_(`gpx.symbol.${a[0]}`).localeCompare($_(`gpx.symbol.${b[0]}`), $locale ?? 'en');
|
return i18n._(`gpx.symbol.${a[0]}`).localeCompare(i18n._(`gpx.symbol.${b[0]}`), i18n.lang);
|
||||||
});
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -180,27 +162,31 @@
|
|||||||
|
|
||||||
<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-2">
|
||||||
<Label for="name">{$_('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 h-8"
|
||||||
disabled={!canCreate && !$selectedWaypoint}
|
disabled={!canCreate && !$selectedWaypoint}
|
||||||
/>
|
/>
|
||||||
<Label for="description">{$_('menu.metadata.description')}</Label>
|
<Label for="description">{i18n._('menu.metadata.description')}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
bind:value={description}
|
bind:value={description}
|
||||||
id="description"
|
id="description"
|
||||||
disabled={!canCreate && !$selectedWaypoint}
|
disabled={!canCreate && !$selectedWaypoint}
|
||||||
/>
|
/>
|
||||||
<Label for="symbol">{$_('toolbar.waypoint.icon')}</Label>
|
<Label for="symbol">{i18n._('toolbar.waypoint.icon')}</Label>
|
||||||
<Select.Root bind:selected={selectedSymbol}>
|
<Select.Root bind:value={symbolKey} type="single">
|
||||||
<Select.Trigger
|
<Select.Trigger
|
||||||
id="symbol"
|
id="symbol"
|
||||||
class="w-full h-8"
|
class="w-full h-8"
|
||||||
disabled={!canCreate && !$selectedWaypoint}
|
disabled={!canCreate && !$selectedWaypoint}
|
||||||
>
|
>
|
||||||
<Select.Value />
|
{#if symbolKey in symbols}
|
||||||
|
{i18n._(`gpx.symbol.${symbolKey}`)}
|
||||||
|
{:else}
|
||||||
|
{symbolKey}
|
||||||
|
{/if}
|
||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
<Select.Content class="max-h-60 overflow-y-scroll">
|
<Select.Content class="max-h-60 overflow-y-scroll">
|
||||||
{#each sortedSymbols as [key, symbol]}
|
{#each sortedSymbols as [key, symbol]}
|
||||||
@@ -215,13 +201,13 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<span class="w-4 inline-block"></span>
|
<span class="w-4 inline-block"></span>
|
||||||
{/if}
|
{/if}
|
||||||
{$_(`gpx.symbol.${key}`)}
|
{i18n._(`gpx.symbol.${key}`)}
|
||||||
</span>
|
</span>
|
||||||
</Select.Item>
|
</Select.Item>
|
||||||
{/each}
|
{/each}
|
||||||
</Select.Content>
|
</Select.Content>
|
||||||
</Select.Root>
|
</Select.Root>
|
||||||
<Label for="link">{$_('toolbar.waypoint.link')}</Label>
|
<Label for="link">{i18n._('toolbar.waypoint.link')}</Label>
|
||||||
<Input
|
<Input
|
||||||
bind:value={link}
|
bind:value={link}
|
||||||
id="link"
|
id="link"
|
||||||
@@ -230,7 +216,7 @@
|
|||||||
/>
|
/>
|
||||||
<div class="flex flex-row gap-2">
|
<div class="flex flex-row gap-2">
|
||||||
<div class="grow">
|
<div class="grow">
|
||||||
<Label for="latitude">{$_('toolbar.waypoint.latitude')}</Label>
|
<Label for="latitude">{i18n._('toolbar.waypoint.latitude')}</Label>
|
||||||
<Input
|
<Input
|
||||||
bind:value={latitude}
|
bind:value={latitude}
|
||||||
type="number"
|
type="number"
|
||||||
@@ -243,7 +229,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="grow">
|
<div class="grow">
|
||||||
<Label for="longitude">{$_('toolbar.waypoint.longitude')}</Label>
|
<Label for="longitude">{i18n._('toolbar.waypoint.longitude')}</Label>
|
||||||
<Input
|
<Input
|
||||||
bind:value={longitude}
|
bind:value={longitude}
|
||||||
type="number"
|
type="number"
|
||||||
@@ -262,19 +248,19 @@
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={!canCreate && !$selectedWaypoint}
|
disabled={!canCreate && !$selectedWaypoint}
|
||||||
class="grow whitespace-normal h-fit"
|
class="grow whitespace-normal h-fit"
|
||||||
on:click={createOrUpdateWaypoint}
|
onclick={createOrUpdateWaypoint}
|
||||||
>
|
>
|
||||||
{#if $selectedWaypoint}
|
{#if $selectedWaypoint}
|
||||||
<Save size="16" class="mr-1 shrink-0" />
|
<Save size="16" class="mr-1 shrink-0" />
|
||||||
{$_('menu.metadata.save')}
|
{i18n._('menu.metadata.save')}
|
||||||
{:else}
|
{:else}
|
||||||
<MapPin size="16" class="mr-1 shrink-0" />
|
<MapPin size="16" class="mr-1 shrink-0" />
|
||||||
{$_('toolbar.waypoint.create')}
|
{i18n._('toolbar.waypoint.create')}
|
||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
on:click={() => {
|
onclick={() => {
|
||||||
selectedWaypoint.set(undefined);
|
selectedWaypoint.set(undefined);
|
||||||
resetWaypointData();
|
resetWaypointData();
|
||||||
}}
|
}}
|
||||||
@@ -282,11 +268,11 @@
|
|||||||
<CircleX size="16" />
|
<CircleX size="16" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Help link={getURLForLanguage($locale, '/help/toolbar/poi')}>
|
<Help link={getURLForLanguage(i18n.lang, '/help/toolbar/poi')}>
|
||||||
{#if $selectedWaypoint || canCreate}
|
{#if $selectedWaypoint || canCreate}
|
||||||
{$_('toolbar.waypoint.help')}
|
{i18n._('toolbar.waypoint.help')}
|
||||||
{:else}
|
{:else}
|
||||||
{$_('toolbar.waypoint.help_no_selection')}
|
{i18n._('toolbar.waypoint.help_no_selection')}
|
||||||
{/if}
|
{/if}
|
||||||
</Help>
|
</Help>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -20,13 +20,13 @@
|
|||||||
Repeat,
|
Repeat,
|
||||||
SquareArrowUpLeft,
|
SquareArrowUpLeft,
|
||||||
SquareArrowOutDownRight,
|
SquareArrowOutDownRight,
|
||||||
} from 'lucide-svelte';
|
} from '@lucide/svelte';
|
||||||
|
|
||||||
import { map, newGPXFile, routingControls, selectFileWhenLoaded } from '$lib/stores';
|
import { map, newGPXFile, routingControls, selectFileWhenLoaded } from '$lib/stores';
|
||||||
import { dbUtils, getFile, getFileIds, settings } from '$lib/db';
|
import { dbUtils, getFile, getFileIds, settings } from '$lib/db';
|
||||||
import { brouterProfiles, routingProfileSelectItem } from './Routing';
|
import { brouterProfiles } from '$lib/components/toolbar/tools/routing/routing.svelte';
|
||||||
|
|
||||||
import { _, locale } from '$lib/i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { RoutingControls } from './RoutingControls';
|
import { RoutingControls } from './RoutingControls';
|
||||||
import mapboxgl from 'mapbox-gl';
|
import mapboxgl from 'mapbox-gl';
|
||||||
import { fileObservers } from '$lib/db';
|
import { fileObservers } from '$lib/db';
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
ListTrackSegmentItem,
|
ListTrackSegmentItem,
|
||||||
type ListItem,
|
type ListItem,
|
||||||
} from '$lib/components/file-list/FileList';
|
} from '$lib/components/file-list/FileList';
|
||||||
import { flyAndScale, getURLForLanguage, resetCursor, setCrosshairCursor } from '$lib/utils';
|
import { getURLForLanguage, resetCursor, setCrosshairCursor } from '$lib/utils';
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import { TrackPoint } from 'gpx';
|
import { TrackPoint } from 'gpx';
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
export let popupElement: HTMLElement | undefined = undefined;
|
export let popupElement: HTMLElement | undefined = undefined;
|
||||||
let selectedItem: ListItem | null = null;
|
let selectedItem: ListItem | null = null;
|
||||||
|
|
||||||
const { privateRoads, routing } = settings;
|
const { privateRoads, routing, routingProfile } = settings;
|
||||||
|
|
||||||
$: if ($map && popup && popupElement) {
|
$: if ($map && popup && popupElement) {
|
||||||
// remove controls for deleted files
|
// remove controls for deleted files
|
||||||
@@ -111,15 +111,12 @@
|
|||||||
|
|
||||||
{#if minimizable && minimized}
|
{#if minimizable && minimized}
|
||||||
<div class="-m-1.5 -mb-2">
|
<div class="-m-1.5 -mb-2">
|
||||||
<Button variant="ghost" class="px-1 h-[26px]" on:click={() => (minimized = false)}>
|
<Button variant="ghost" class="px-1 h-[26px]" onclick={() => (minimized = false)}>
|
||||||
<SquareArrowOutDownRight size="18" />
|
<SquareArrowOutDownRight size="18" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div class="flex flex-col gap-3 w-full max-w-80 animate-in animate-out {$$props.class ?? ''}">
|
||||||
class="flex flex-col gap-3 w-full max-w-80 {$$props.class ?? ''}"
|
|
||||||
in:flyAndScale={{ x: -2, y: 0, duration: 50 }}
|
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<Label class="flex flex-row justify-between items-center gap-2">
|
<Label class="flex flex-row justify-between items-center gap-2">
|
||||||
<span class="flex flex-row items-center gap-1">
|
<span class="flex flex-row items-center gap-1">
|
||||||
@@ -128,9 +125,9 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<RouteOff size="16" />
|
<RouteOff size="16" />
|
||||||
{/if}
|
{/if}
|
||||||
{$_('toolbar.routing.use_routing')}
|
{i18n._('toolbar.routing.use_routing')}
|
||||||
</span>
|
</span>
|
||||||
<Tooltip label={$_('toolbar.routing.use_routing_tooltip')}>
|
<Tooltip label={i18n._('toolbar.routing.use_routing_tooltip')}>
|
||||||
<Switch class="scale-90" bind:checked={$routing} />
|
<Switch class="scale-90" bind:checked={$routing} />
|
||||||
<Shortcut slot="extra" key="F5" />
|
<Shortcut slot="extra" key="F5" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -139,25 +136,27 @@
|
|||||||
<div class="flex flex-col gap-3" in:slide>
|
<div class="flex flex-col gap-3" in:slide>
|
||||||
<Label class="flex flex-row justify-between items-center gap-2">
|
<Label class="flex flex-row justify-between items-center gap-2">
|
||||||
<span class="shrink-0 flex flex-row items-center gap-1">
|
<span class="shrink-0 flex flex-row items-center gap-1">
|
||||||
{#if $routingProfileSelectItem.value.includes('bike') || $routingProfileSelectItem.value.includes('motorcycle')}
|
{#if $routingProfile.includes('bike') || $routingProfile.includes('motorcycle')}
|
||||||
<Bike size="16" />
|
<Bike size="16" />
|
||||||
{:else if $routingProfileSelectItem.value.includes('foot')}
|
{:else if $routingProfile.includes('foot')}
|
||||||
<Footprints size="16" />
|
<Footprints size="16" />
|
||||||
{:else if $routingProfileSelectItem.value.includes('water')}
|
{:else if $routingProfile.includes('water')}
|
||||||
<Waves size="16" />
|
<Waves size="16" />
|
||||||
{:else if $routingProfileSelectItem.value.includes('railway')}
|
{:else if $routingProfile.includes('railway')}
|
||||||
<TrainFront size="16" />
|
<TrainFront size="16" />
|
||||||
{/if}
|
{/if}
|
||||||
{$_('toolbar.routing.activity')}
|
{i18n._('toolbar.routing.activity')}
|
||||||
</span>
|
</span>
|
||||||
<Select.Root bind:selected={$routingProfileSelectItem}>
|
<Select.Root type="single" bind:value={$routingProfile}>
|
||||||
<Select.Trigger class="h-8 grow">
|
<Select.Trigger class="h-8 grow">
|
||||||
<Select.Value />
|
{i18n._(`toolbar.routing.activities.${$routingProfile}`)}
|
||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
<Select.Content>
|
<Select.Content>
|
||||||
{#each Object.keys(brouterProfiles) as profile}
|
{#each Object.keys(brouterProfiles) as profile}
|
||||||
<Select.Item value={profile}
|
<Select.Item value={profile}
|
||||||
>{$_(`toolbar.routing.activities.${profile}`)}</Select.Item
|
>{i18n._(
|
||||||
|
`toolbar.routing.activities.${profile}`
|
||||||
|
)}</Select.Item
|
||||||
>
|
>
|
||||||
{/each}
|
{/each}
|
||||||
</Select.Content>
|
</Select.Content>
|
||||||
@@ -166,7 +165,7 @@
|
|||||||
<Label class="flex flex-row justify-between items-center gap-2">
|
<Label class="flex flex-row justify-between items-center gap-2">
|
||||||
<span class="flex flex-row gap-1">
|
<span class="flex flex-row gap-1">
|
||||||
<TriangleAlert size="16" />
|
<TriangleAlert size="16" />
|
||||||
{$_('toolbar.routing.allow_private')}
|
{i18n._('toolbar.routing.allow_private')}
|
||||||
</span>
|
</span>
|
||||||
<Switch class="scale-90" bind:checked={$privateRoads} />
|
<Switch class="scale-90" bind:checked={$privateRoads} />
|
||||||
</Label>
|
</Label>
|
||||||
@@ -175,20 +174,20 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row flex-wrap justify-center gap-1">
|
<div class="flex flex-row flex-wrap justify-center gap-1">
|
||||||
<ButtonWithTooltip
|
<ButtonWithTooltip
|
||||||
label={$_('toolbar.routing.reverse.tooltip')}
|
label={i18n._('toolbar.routing.reverse.tooltip')}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="flex flex-row gap-1 text-xs px-2"
|
class="flex flex-row gap-1 text-xs px-2"
|
||||||
disabled={!validSelection}
|
disabled={!validSelection}
|
||||||
on:click={dbUtils.reverseSelection}
|
onclick={dbUtils.reverseSelection}
|
||||||
>
|
>
|
||||||
<ArrowRightLeft size="12" />{$_('toolbar.routing.reverse.button')}
|
<ArrowRightLeft size="12" />{i18n._('toolbar.routing.reverse.button')}
|
||||||
</ButtonWithTooltip>
|
</ButtonWithTooltip>
|
||||||
<ButtonWithTooltip
|
<ButtonWithTooltip
|
||||||
label={$_('toolbar.routing.route_back_to_start.tooltip')}
|
label={i18n._('toolbar.routing.route_back_to_start.tooltip')}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="flex flex-row gap-1 text-xs px-2"
|
class="flex flex-row gap-1 text-xs px-2"
|
||||||
disabled={!validSelection}
|
disabled={!validSelection}
|
||||||
on:click={() => {
|
onclick={() => {
|
||||||
const selected = getOrderedSelection();
|
const selected = getOrderedSelection();
|
||||||
if (selected.length > 0) {
|
if (selected.length > 0) {
|
||||||
const firstFileId = selected[0].getFileId();
|
const firstFileId = selected[0].getFileId();
|
||||||
@@ -217,30 +216,30 @@
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Home size="12" />{$_('toolbar.routing.route_back_to_start.button')}
|
<Home size="12" />{i18n._('toolbar.routing.route_back_to_start.button')}
|
||||||
</ButtonWithTooltip>
|
</ButtonWithTooltip>
|
||||||
<ButtonWithTooltip
|
<ButtonWithTooltip
|
||||||
label={$_('toolbar.routing.round_trip.tooltip')}
|
label={i18n._('toolbar.routing.round_trip.tooltip')}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="flex flex-row gap-1 text-xs px-2"
|
class="flex flex-row gap-1 text-xs px-2"
|
||||||
disabled={!validSelection}
|
disabled={!validSelection}
|
||||||
on:click={dbUtils.createRoundTripForSelection}
|
onclick={dbUtils.createRoundTripForSelection}
|
||||||
>
|
>
|
||||||
<Repeat size="12" />{$_('toolbar.routing.round_trip.button')}
|
<Repeat size="12" />{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-2 items-end justify-between">
|
||||||
<Help link={getURLForLanguage($locale, '/help/toolbar/routing')}>
|
<Help link={getURLForLanguage(i18n.lang, '/help/toolbar/routing')}>
|
||||||
{#if !validSelection}
|
{#if !validSelection}
|
||||||
{$_('toolbar.routing.help_no_file')}
|
{i18n._('toolbar.routing.help_no_file')}
|
||||||
{:else}
|
{:else}
|
||||||
{$_('toolbar.routing.help')}
|
{i18n._('toolbar.routing.help')}
|
||||||
{/if}
|
{/if}
|
||||||
</Help>
|
</Help>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="px-1 h-6"
|
class="px-1 h-6"
|
||||||
on:click={() => {
|
onclick={() => {
|
||||||
if (minimizable) {
|
if (minimizable) {
|
||||||
minimized = true;
|
minimized = true;
|
||||||
}
|
}
|
||||||
|
@@ -3,9 +3,9 @@
|
|||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||||
import { canChangeStart } from './RoutingControls';
|
import { canChangeStart } from './RoutingControls';
|
||||||
import { CirclePlay, Trash2 } from 'lucide-svelte';
|
import { CirclePlay, Trash2 } from '@lucide/svelte';
|
||||||
|
|
||||||
import { _ } from '$lib/i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
|
||||||
export let element: HTMLElement;
|
export let element: HTMLElement;
|
||||||
</script>
|
</script>
|
||||||
@@ -17,19 +17,19 @@
|
|||||||
<Button
|
<Button
|
||||||
class="w-full px-2 py-1 h-6 justify-start"
|
class="w-full px-2 py-1 h-6 justify-start"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
on:click={() => element.dispatchEvent(new CustomEvent('change-start'))}
|
onclick={() => element.dispatchEvent(new CustomEvent('change-start'))}
|
||||||
>
|
>
|
||||||
<CirclePlay size="16" class="mr-1" />
|
<CirclePlay size="16" class="mr-1" />
|
||||||
{$_('toolbar.routing.start_loop_here')}
|
{i18n._('toolbar.routing.start_loop_here')}
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
<Button
|
<Button
|
||||||
class="w-full px-2 py-1 h-6 justify-start"
|
class="w-full px-2 py-1 h-6 justify-start"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
on:click={() => element.dispatchEvent(new CustomEvent('delete'))}
|
onclick={() => element.dispatchEvent(new CustomEvent('delete'))}
|
||||||
>
|
>
|
||||||
<Trash2 size="16" class="mr-1" />
|
<Trash2 size="16" class="mr-1" />
|
||||||
{$_('menu.delete')}
|
{i18n._('menu.delete')}
|
||||||
<Shortcut shift={true} click={true} />
|
<Shortcut shift={true} click={true} />
|
||||||
</Button>
|
</Button>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
|
@@ -1,9 +1,9 @@
|
|||||||
import { distance, type Coordinates, TrackPoint, TrackSegment, Track, projectedPoint } from 'gpx';
|
import { distance, type Coordinates, TrackPoint, TrackSegment, Track, projectedPoint } from 'gpx';
|
||||||
import { get, writable, type Readable } from 'svelte/store';
|
import { get, writable, type Readable } from 'svelte/store';
|
||||||
import mapboxgl from 'mapbox-gl';
|
import mapboxgl from 'mapbox-gl';
|
||||||
import { route } from './Routing';
|
import { route } from './routing.svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { _ } from '$lib/i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { dbUtils, settings, type GPXFileWithStatistics } from '$lib/db';
|
import { dbUtils, settings, type GPXFileWithStatistics } from '$lib/db';
|
||||||
import { getOrderedSelection, selection } from '$lib/components/file-list/Selection';
|
import { getOrderedSelection, selection } from '$lib/components/file-list/Selection';
|
||||||
import {
|
import {
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
import { currentTool, streetViewEnabled, Tool } from '$lib/stores';
|
import { currentTool, streetViewEnabled, Tool } from '$lib/stores';
|
||||||
import { getClosestLinePoint, resetCursor, setGrabbingCursor } from '$lib/utils';
|
import { getClosestLinePoint, resetCursor, setGrabbingCursor } from '$lib/utils';
|
||||||
|
|
||||||
const { streetViewSource } = settings;
|
// const { streetViewSource } = settings;
|
||||||
export const canChangeStart = writable(false);
|
export const canChangeStart = writable(false);
|
||||||
|
|
||||||
function stopPropagation(e: any) {
|
function stopPropagation(e: any) {
|
||||||
|
@@ -1,8 +1,6 @@
|
|||||||
import type { Coordinates } from 'gpx';
|
import type { Coordinates } from 'gpx';
|
||||||
import { TrackPoint, distance } from 'gpx';
|
import { TrackPoint, distance } from 'gpx';
|
||||||
import { derived, get, writable } from 'svelte/store';
|
import { settings } from '$lib/logic/settings.svelte';
|
||||||
import { settings } from '$lib/db';
|
|
||||||
import { _, locale, isLoadingLocale } from '$lib/i18n';
|
|
||||||
import { getElevation } from '$lib/utils';
|
import { getElevation } from '$lib/utils';
|
||||||
|
|
||||||
const { routing, routingProfile, privateRoads } = settings;
|
const { routing, routingProfile, privateRoads } = settings;
|
||||||
@@ -17,38 +15,10 @@ export const brouterProfiles: { [key: string]: string } = {
|
|||||||
water: 'river',
|
water: 'river',
|
||||||
railway: 'rail',
|
railway: 'rail',
|
||||||
};
|
};
|
||||||
export const routingProfileSelectItem = writable({
|
|
||||||
value: '',
|
|
||||||
label: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
derived([routingProfile, locale, isLoadingLocale], ([profile, l, i]) => [profile, l, i]).subscribe(
|
|
||||||
([profile, l, i]) => {
|
|
||||||
if (
|
|
||||||
!i &&
|
|
||||||
profile !== '' &&
|
|
||||||
(profile !== get(routingProfileSelectItem).value ||
|
|
||||||
get(_)(`toolbar.routing.activities.${profile}`) !==
|
|
||||||
get(routingProfileSelectItem).label) &&
|
|
||||||
l !== null
|
|
||||||
) {
|
|
||||||
routingProfileSelectItem.update((item) => {
|
|
||||||
item.value = profile;
|
|
||||||
item.label = get(_)(`toolbar.routing.activities.${profile}`);
|
|
||||||
return item;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
routingProfileSelectItem.subscribe((item) => {
|
|
||||||
if (item.value !== '' && item.value !== get(routingProfile)) {
|
|
||||||
routingProfile.set(item.value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export function route(points: Coordinates[]): Promise<TrackPoint[]> {
|
export function route(points: Coordinates[]): Promise<TrackPoint[]> {
|
||||||
if (get(routing)) {
|
if (routing.value) {
|
||||||
return getRoute(points, brouterProfiles[get(routingProfile)], get(privateRoads));
|
return getRoute(points, brouterProfiles[routingProfile.value], privateRoads.value);
|
||||||
} else {
|
} else {
|
||||||
return getIntermediatePoints(points);
|
return getIntermediatePoints(points);
|
||||||
}
|
}
|
@@ -1,12 +1,5 @@
|
|||||||
<script lang="ts" context="module">
|
|
||||||
export enum SplitType {
|
|
||||||
FILES = 'files',
|
|
||||||
TRACKS = 'tracks',
|
|
||||||
SEGMENTS = 'segments',
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { splitAs, SplitType } from '$lib/components/toolbar/tools/scissors/utils.svelte';
|
||||||
import Help from '$lib/components/Help.svelte';
|
import Help from '$lib/components/Help.svelte';
|
||||||
import { ListRootItem } from '$lib/components/file-list/FileList';
|
import { ListRootItem } from '$lib/components/file-list/FileList';
|
||||||
import { selection } from '$lib/components/file-list/Selection';
|
import { selection } from '$lib/components/file-list/Selection';
|
||||||
@@ -15,31 +8,39 @@
|
|||||||
import { Slider } from '$lib/components/ui/slider';
|
import { Slider } from '$lib/components/ui/slider';
|
||||||
import * as Select from '$lib/components/ui/select';
|
import * as Select from '$lib/components/ui/select';
|
||||||
import { Separator } from '$lib/components/ui/separator';
|
import { Separator } from '$lib/components/ui/separator';
|
||||||
import { gpxStatistics, map, slicedGPXStatistics, splitAs } from '$lib/stores';
|
import { gpxStatistics, slicedGPXStatistics } from '$lib/stores';
|
||||||
|
import { map } from '$lib/components/map/map.svelte';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import { _, locale } from '$lib/i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { onDestroy, tick } from 'svelte';
|
import { onDestroy, tick } from 'svelte';
|
||||||
import { Crop } from 'lucide-svelte';
|
import { Crop } from '@lucide/svelte';
|
||||||
import { dbUtils } from '$lib/db';
|
import { dbUtils } from '$lib/db';
|
||||||
import { SplitControls } from './SplitControls';
|
import { SplitControls } from './SplitControls.svelte';
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
|
|
||||||
|
let props: {
|
||||||
|
class?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let splitControls: SplitControls | undefined = undefined;
|
let splitControls: SplitControls | undefined = undefined;
|
||||||
let canCrop = false;
|
let canCrop = $state(false);
|
||||||
|
|
||||||
$: if ($map) {
|
$effect(() => {
|
||||||
if (splitControls) {
|
if (map.current) {
|
||||||
splitControls.destroy();
|
if (splitControls) {
|
||||||
|
splitControls.destroy();
|
||||||
|
}
|
||||||
|
splitControls = new SplitControls(map.current);
|
||||||
}
|
}
|
||||||
splitControls = new SplitControls($map);
|
});
|
||||||
}
|
|
||||||
|
|
||||||
$: validSelection =
|
let validSelection = $derived(
|
||||||
$selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']) &&
|
$selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']) &&
|
||||||
$gpxStatistics.local.points.length > 0;
|
$gpxStatistics.local.points.length > 0
|
||||||
|
);
|
||||||
|
|
||||||
let maxSliderValue = 1;
|
let maxSliderValue = $state(1);
|
||||||
let sliderValues = [0, 1];
|
let sliderValues = $state([0, 1]);
|
||||||
|
|
||||||
function updateCanCrop() {
|
function updateCanCrop() {
|
||||||
canCrop = sliderValues[0] != 0 || sliderValues[1] != maxSliderValue;
|
canCrop = sliderValues[0] != 0 || sliderValues[1] != maxSliderValue;
|
||||||
@@ -73,32 +74,29 @@
|
|||||||
sliderValues = [0, maxSliderValue];
|
sliderValues = [0, maxSliderValue];
|
||||||
}
|
}
|
||||||
|
|
||||||
$: if ($gpxStatistics.local.points.length - 1 != maxSliderValue) {
|
$effect(() => {
|
||||||
updateSliderLimits();
|
if ($gpxStatistics.local.points.length - 1 != maxSliderValue) {
|
||||||
}
|
updateSliderLimits();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
$: if (sliderValues) {
|
$effect(() => {
|
||||||
updateCanCrop();
|
if (sliderValues) {
|
||||||
updateSlicedGPXStatistics();
|
updateCanCrop();
|
||||||
}
|
updateSlicedGPXStatistics();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
$: if (
|
$effect(() => {
|
||||||
$slicedGPXStatistics !== undefined &&
|
if (
|
||||||
($slicedGPXStatistics[1] !== sliderValues[0] || $slicedGPXStatistics[2] !== sliderValues[1])
|
$slicedGPXStatistics !== undefined &&
|
||||||
) {
|
($slicedGPXStatistics[1] !== sliderValues[0] ||
|
||||||
updateSliderValues();
|
$slicedGPXStatistics[2] !== sliderValues[1])
|
||||||
updateCanCrop();
|
) {
|
||||||
}
|
updateSliderValues();
|
||||||
|
updateCanCrop();
|
||||||
const splitTypes = [
|
}
|
||||||
{ value: SplitType.FILES, label: $_('gpx.files') },
|
});
|
||||||
{ value: SplitType.TRACKS, label: $_('gpx.tracks') },
|
|
||||||
{ value: SplitType.SEGMENTS, label: $_('gpx.segments') },
|
|
||||||
];
|
|
||||||
|
|
||||||
let splitType = splitTypes.find((type) => type.value === $splitAs) ?? splitTypes[0];
|
|
||||||
|
|
||||||
$: splitAs.set(splitType.value);
|
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
$slicedGPXStatistics = undefined;
|
$slicedGPXStatistics = undefined;
|
||||||
@@ -109,43 +107,44 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<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
|
<Slider
|
||||||
bind:value={sliderValues}
|
bind:value={sliderValues}
|
||||||
max={maxSliderValue}
|
max={maxSliderValue}
|
||||||
step={1}
|
step={1}
|
||||||
|
type="multiple"
|
||||||
disabled={!validSelection}
|
disabled={!validSelection}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={!validSelection || !canCrop}
|
disabled={!validSelection || !canCrop}
|
||||||
on:click={() => dbUtils.cropSelection(sliderValues[0], sliderValues[1])}
|
onclick={() => dbUtils.cropSelection(sliderValues[0], sliderValues[1])}
|
||||||
>
|
>
|
||||||
<Crop size="16" class="mr-1" />{$_('toolbar.scissors.crop')}
|
<Crop size="16" class="mr-1" />{i18n._('toolbar.scissors.crop')}
|
||||||
</Button>
|
</Button>
|
||||||
<Separator />
|
<Separator />
|
||||||
<Label class="flex flex-row flex-wrap gap-3 items-center">
|
<Label class="flex flex-row flex-wrap gap-3 items-center">
|
||||||
<span class="shrink-0">
|
<span class="shrink-0">
|
||||||
{$_('toolbar.scissors.split_as')}
|
{i18n._('toolbar.scissors.split_as')}
|
||||||
</span>
|
</span>
|
||||||
<Select.Root bind:selected={splitType}>
|
<Select.Root bind:value={splitAs.current} type="single">
|
||||||
<Select.Trigger class="h-8 w-fit grow">
|
<Select.Trigger class="h-8 w-fit grow">
|
||||||
<Select.Value />
|
{i18n._('gpx.' + splitAs)}
|
||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
<Select.Content>
|
<Select.Content>
|
||||||
{#each splitTypes as { value, label }}
|
{#each Object.values(SplitType) as splitType}
|
||||||
<Select.Item {value}>{label}</Select.Item>
|
<Select.Item value={splitType}>{i18n._('gpx.' + splitType)}</Select.Item>
|
||||||
{/each}
|
{/each}
|
||||||
</Select.Content>
|
</Select.Content>
|
||||||
</Select.Root>
|
</Select.Root>
|
||||||
</Label>
|
</Label>
|
||||||
<Help link={getURLForLanguage($locale, '/help/toolbar/scissors')}>
|
<Help link={getURLForLanguage(i18n.lang, '/help/toolbar/scissors')}>
|
||||||
{#if validSelection}
|
{#if validSelection}
|
||||||
{$_('toolbar.scissors.help')}
|
{i18n._('toolbar.scissors.help')}
|
||||||
{:else}
|
{:else}
|
||||||
{$_('toolbar.scissors.help_invalid_selection')}
|
{i18n._('toolbar.scissors.help_invalid_selection')}
|
||||||
{/if}
|
{/if}
|
||||||
</Help>
|
</Help>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -7,8 +7,9 @@ import {
|
|||||||
selection,
|
selection,
|
||||||
} from '$lib/components/file-list/Selection';
|
} from '$lib/components/file-list/Selection';
|
||||||
import { ListTrackSegmentItem } from '$lib/components/file-list/FileList';
|
import { ListTrackSegmentItem } from '$lib/components/file-list/FileList';
|
||||||
import { currentTool, gpxStatistics, Tool } from '$lib/stores';
|
import { gpxStatistics } from '$lib/stores';
|
||||||
import { _ } from '$lib/i18n';
|
import { tool, Tool } from '$lib/components/toolbar/utils.svelte';
|
||||||
|
import { splitAs } from '$lib/components/toolbar/tools/scissors/utils.svelte';
|
||||||
import { Scissors } from 'lucide-static';
|
import { Scissors } from 'lucide-static';
|
||||||
|
|
||||||
export class SplitControls {
|
export class SplitControls {
|
||||||
@@ -26,11 +27,13 @@ export class SplitControls {
|
|||||||
|
|
||||||
this.unsubscribes.push(selection.subscribe(this.addIfNeeded.bind(this)));
|
this.unsubscribes.push(selection.subscribe(this.addIfNeeded.bind(this)));
|
||||||
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)));
|
$effect(() => {
|
||||||
|
tool.current, this.addIfNeeded.bind(this);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
addIfNeeded() {
|
addIfNeeded() {
|
||||||
let scissors = get(currentTool) === Tool.SCISSORS;
|
let scissors = tool.current === Tool.SCISSORS;
|
||||||
if (!scissors) {
|
if (!scissors) {
|
||||||
if (this.active) {
|
if (this.active) {
|
||||||
this.remove();
|
this.remove();
|
||||||
@@ -166,6 +169,7 @@ export class SplitControls {
|
|||||||
marker.getElement().addEventListener('click', (e) => {
|
marker.getElement().addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
dbUtils.split(
|
dbUtils.split(
|
||||||
|
splitAs.current,
|
||||||
control.fileId,
|
control.fileId,
|
||||||
control.trackIndex,
|
control.trackIndex,
|
||||||
control.segmentIndex,
|
control.segmentIndex,
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user