Compare commits
6 Commits
dev
...
1473886f54
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1473886f54 | ||
|
|
daeb3d4f57 | ||
|
|
65bad83635 | ||
|
|
c2ac4fb7d9 | ||
|
|
c52fa0001a | ||
|
|
dfad2ef3ef |
2
.github/FUNDING.yml
vendored
@@ -1 +1 @@
|
||||
open_collective: gpxstudio
|
||||
ko_fi: gpxstudio
|
||||
2
.github/workflows/deploy.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
- name: Create env file
|
||||
run: |
|
||||
touch website/.env
|
||||
echo PUBLIC_MAPTILER_KEY=${{ secrets.PUBLIC_MAPTILER_KEY }} >> website/.env
|
||||
echo PUBLIC_MAPBOX_TOKEN=${{ secrets.PUBLIC_MAPBOX_TOKEN }} >> website/.env
|
||||
cat website/.env
|
||||
|
||||
- name: Build website
|
||||
|
||||
48
README.md
@@ -5,7 +5,7 @@
|
||||
|
||||
[**gpx.studio**](https://gpx.studio) is an online tool for creating and editing GPX files.
|
||||
|
||||

|
||||

|
||||
|
||||
This repository contains the source code of the website.
|
||||
|
||||
@@ -27,8 +27,8 @@ Any help is greatly appreciated!
|
||||
|
||||
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.
|
||||
|
||||
@@ -42,11 +42,11 @@ npm run build
|
||||
|
||||
### Running the website
|
||||
|
||||
To be able to load the map, you will need to create your own <a href="https://cloud.maptiler.com/auth/widget?next=https://cloud.maptiler.com/maps/" target="_blank">MapTiler key</a> and store it in a `.env` file in the `website` directory.
|
||||
To be able to load the map, you will need to create your own <a href="https://account.mapbox.com/auth/signup" target="_blank">Mapbox access token</a> and store it in a `.env` file in the `website` directory.
|
||||
|
||||
```bash
|
||||
cd website
|
||||
echo PUBLIC_MAPTILER_KEY={YOUR_MAPTILER_KEY} >> .env
|
||||
echo PUBLIC_MAPBOX_TOKEN={YOUR_MAPBOX_TOKEN} >> .env
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
@@ -55,25 +55,25 @@ npm run dev
|
||||
|
||||
This project has been made possible thanks to the following open source projects:
|
||||
|
||||
- Development:
|
||||
- [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
|
||||
- Design:
|
||||
- [shadcn-svelte](https://github.com/huntabyte/shadcn-svelte) — beautiful components
|
||||
- [@lucide/svelte](https://github.com/lucide-icons/lucide/tree/main/packages/svelte) — beautiful icons
|
||||
- [tailwindcss](https://github.com/tailwindlabs/tailwindcss) — easy styling
|
||||
- [Chart.js](https://github.com/chartjs/Chart.js) — beautiful and fast charts
|
||||
- Logic:
|
||||
- [immer](https://github.com/immerjs/immer) — complex state management
|
||||
- [Dexie.js](https://github.com/dexie/Dexie.js) — IndexedDB wrapper
|
||||
- [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
|
||||
- Mapping:
|
||||
- [MapLibre GL JS](https://github.com/maplibre/maplibre-gl-js) — beautiful and fast interactive map rendering
|
||||
- [GraphHopper](https://github.com/graphhopper/graphhopper) — routing engine
|
||||
- [OpenStreetMap](https://www.openstreetmap.org) — map data used by most of the map layers, and by the routing engine
|
||||
- Search:
|
||||
- [DocSearch](https://github.com/algolia/docsearch) — search engine for the documentation
|
||||
- Development:
|
||||
- [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
|
||||
- Design:
|
||||
- [shadcn-svelte](https://github.com/huntabyte/shadcn-svelte) — beautiful components
|
||||
- [@lucide/svelte](https://github.com/lucide-icons/lucide/tree/main/packages/svelte) — beautiful icons
|
||||
- [tailwindcss](https://github.com/tailwindlabs/tailwindcss) — easy styling
|
||||
- [Chart.js](https://github.com/chartjs/Chart.js) — beautiful and fast charts
|
||||
- Logic:
|
||||
- [immer](https://github.com/immerjs/immer) — complex state management
|
||||
- [Dexie.js](https://github.com/dexie/Dexie.js) — IndexedDB wrapper
|
||||
- [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
|
||||
- Mapping:
|
||||
- [Mapbox GL JS](https://github.com/mapbox/mapbox-gl-js) — beautiful and fast interactive maps
|
||||
- [brouter](https://github.com/abrensch/brouter) — routing engine
|
||||
- [OpenStreetMap](https://www.openstreetmap.org) — map data used by Mapbox and brouter
|
||||
- Search:
|
||||
- [DocSearch](https://github.com/algolia/docsearch) — search engine for the documentation
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1398,7 +1398,10 @@ export class TrackPoint {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
setExtension(key: string, value: string) {
|
||||
setExtensions(extensions: Record<string, string>) {
|
||||
if (Object.keys(extensions).length === 0) {
|
||||
return;
|
||||
}
|
||||
if (!this.extensions) {
|
||||
this.extensions = {};
|
||||
}
|
||||
@@ -1408,12 +1411,8 @@ export class TrackPoint {
|
||||
if (!this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions']) {
|
||||
this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions'] = {};
|
||||
}
|
||||
this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions'][key] = value;
|
||||
}
|
||||
|
||||
setExtensions(extensions: Record<string, string>) {
|
||||
Object.entries(extensions).forEach(([key, value]) => {
|
||||
this.setExtension(key, value);
|
||||
this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions'][key] = value;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
PUBLIC_MAPTILER_KEY=YOUR_MAPTILER_KEY
|
||||
PUBLIC_MAPBOX_TOKEN=YOUR_MAPBOX_TOKEN
|
||||
1231
website/package-lock.json
generated
@@ -23,9 +23,10 @@
|
||||
"@types/eslint": "^9.6.1",
|
||||
"@types/events": "^3.0.3",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/mapbox__sphericalmercator": "^1.2.3",
|
||||
"@types/mapbox__tilebelt": "^1.0.4",
|
||||
"@types/mapbox-gl": "^3.4.1",
|
||||
"@types/node": "^22.15.30",
|
||||
"@types/png.js": "^0.2.3",
|
||||
"@types/sanitize-html": "^2.16.0",
|
||||
"@types/sortablejs": "^1.15.8",
|
||||
"@typescript-eslint/eslint-plugin": "^8.33.1",
|
||||
@@ -61,9 +62,10 @@
|
||||
"dependencies": {
|
||||
"@docsearch/js": "^3.9.0",
|
||||
"@internationalized/date": "^3.8.2",
|
||||
"@mapbox/mapbox-gl-geocoder": "^5.0.3",
|
||||
"@mapbox/sphericalmercator": "^2.0.1",
|
||||
"@mapbox/tilebelt": "^2.0.2",
|
||||
"@maplibre/maplibre-gl-geocoder": "^1.9.4",
|
||||
"@types/mapbox__sphericalmercator": "^1.2.3",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-plugin-zoom": "^2.2.0",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -72,8 +74,9 @@
|
||||
"gpx": "file:../gpx",
|
||||
"immer": "^10.1.1",
|
||||
"jszip": "^3.10.1",
|
||||
"mapbox-gl": "^3.17.0",
|
||||
"mapillary-js": "^4.1.2",
|
||||
"maplibre-gl": "^5.21.1",
|
||||
"png.js": "^0.2.1",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"sortablejs": "^1.15.6",
|
||||
"tailwind-merge": "^3.3.0"
|
||||
|
||||
BIN
website/src/lib/assets/img/docs/getting-started/interface.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 339 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 768 KiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 596 KiB |
BIN
website/src/lib/assets/img/home/cyclosm.png
Normal file
|
After Width: | Height: | Size: 4.3 MiB |
BIN
website/src/lib/assets/img/home/ign.png
Normal file
|
After Width: | Height: | Size: 5.4 MiB |
|
Before Width: | Height: | Size: 2.5 MiB |
BIN
website/src/lib/assets/img/home/map.png
Normal file
|
After Width: | Height: | Size: 2.9 MiB |
BIN
website/src/lib/assets/img/home/mapbox-outdoors.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
website/src/lib/assets/img/home/mapbox-satellite.png
Normal file
|
After Width: | Height: | Size: 3.6 MiB |
|
Before Width: | Height: | Size: 2.8 MiB |
BIN
website/src/lib/assets/img/home/routing.png
Normal file
|
After Width: | Height: | Size: 6.9 MiB |
|
Before Width: | Height: | Size: 348 KiB After Width: | Height: | Size: 448 KiB |
@@ -22,19 +22,15 @@ import {
|
||||
Binoculars,
|
||||
Toilet,
|
||||
} from 'lucide-static';
|
||||
import { type RasterDEMSourceSpecification, type StyleSpecification } from 'maplibre-gl';
|
||||
import { type RasterDEMSourceSpecification, type StyleSpecification } from 'mapbox-gl';
|
||||
import ignFrTopo from './custom/ign-fr-topo.json';
|
||||
import ignFrPlan from './custom/ign-fr-plan.json';
|
||||
import ignFrSatellite from './custom/ign-fr-satellite.json';
|
||||
import bikerouterGravel from './custom/bikerouter-gravel.json';
|
||||
|
||||
export const maptilerKeyPlaceHolder = 'MAPTILER_KEY';
|
||||
|
||||
export const basemaps: { [key: string]: string | StyleSpecification } = {
|
||||
maptilerStreets: `https://api.maptiler.com/maps/streets-v4/style.json?key=${maptilerKeyPlaceHolder}`,
|
||||
maptilerTopo: `https://api.maptiler.com/maps/topo-v4/style.json?key=${maptilerKeyPlaceHolder}`,
|
||||
maptilerOutdoors: `https://api.maptiler.com/maps/outdoor-v4/style.json?key=${maptilerKeyPlaceHolder}`,
|
||||
maptilerSatellite: `https://api.maptiler.com/maps/hybrid-v4/style.json?key=${maptilerKeyPlaceHolder}`,
|
||||
mapboxOutdoors: 'mapbox://styles/mapbox/outdoors-v12',
|
||||
mapboxSatellite: 'mapbox://styles/mapbox/satellite-streets-v12',
|
||||
openStreetMap: {
|
||||
version: 8,
|
||||
sources: {
|
||||
@@ -777,10 +773,8 @@ export type LayerTreeType = { [key: string]: LayerTreeType | boolean };
|
||||
export const basemapTree: LayerTreeType = {
|
||||
basemaps: {
|
||||
world: {
|
||||
maptilerStreets: true,
|
||||
maptilerTopo: true,
|
||||
maptilerOutdoors: true,
|
||||
maptilerSatellite: true,
|
||||
mapboxOutdoors: true,
|
||||
mapboxSatellite: true,
|
||||
openStreetMap: true,
|
||||
openTopoMap: true,
|
||||
openHikingMap: true,
|
||||
@@ -913,7 +907,7 @@ export const overpassTree: LayerTreeType = {
|
||||
};
|
||||
|
||||
// Default basemap used
|
||||
export const defaultBasemap = 'maptilerStreets';
|
||||
export const defaultBasemap = 'mapboxOutdoors';
|
||||
|
||||
// Default overlays used (none)
|
||||
export const defaultOverlays: LayerTreeType = {
|
||||
@@ -1002,10 +996,8 @@ export const defaultOverpassQueries: LayerTreeType = {
|
||||
export const defaultBasemapTree: LayerTreeType = {
|
||||
basemaps: {
|
||||
world: {
|
||||
maptilerStreets: true,
|
||||
maptilerTopo: true,
|
||||
maptilerOutdoors: true,
|
||||
maptilerSatellite: true,
|
||||
mapboxOutdoors: true,
|
||||
mapboxSatellite: true,
|
||||
openStreetMap: true,
|
||||
openTopoMap: true,
|
||||
openHikingMap: true,
|
||||
@@ -1144,7 +1136,7 @@ export type CustomLayer = {
|
||||
maxZoom: number;
|
||||
layerType: 'basemap' | 'overlay';
|
||||
resourceType: 'raster' | 'vector';
|
||||
value: string | maplibregl.StyleSpecification;
|
||||
value: string | {};
|
||||
};
|
||||
|
||||
type OverpassQueryData = {
|
||||
@@ -1463,9 +1455,11 @@ export const overpassQueryData: Record<string, OverpassQueryData> = {
|
||||
};
|
||||
|
||||
export const terrainSources: { [key: string]: RasterDEMSourceSpecification } = {
|
||||
'maptiler-dem': {
|
||||
'mapbox-dem': {
|
||||
type: 'raster-dem',
|
||||
url: `https://api.maptiler.com/tiles/terrain-rgb-v2/tiles.json?key=${maptilerKeyPlaceHolder}`,
|
||||
url: 'mapbox://mapbox.mapbox-terrain-dem-v1',
|
||||
tileSize: 512,
|
||||
maxzoom: 14,
|
||||
},
|
||||
mapterhorn: {
|
||||
type: 'raster-dem',
|
||||
@@ -1473,4 +1467,4 @@ export const terrainSources: { [key: string]: RasterDEMSourceSpecification } = {
|
||||
},
|
||||
};
|
||||
|
||||
export const defaultTerrainSource = 'maptiler-dem';
|
||||
export const defaultTerrainSource = 'mapbox-dem';
|
||||
|
||||
@@ -64,9 +64,3 @@
|
||||
</svelte:head>
|
||||
|
||||
<div id="docsearch" class={props.class ?? ''}></div>
|
||||
|
||||
<style>
|
||||
#docsearch :global(button) {
|
||||
margin-left: 0px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} {variant} class="bg-inherit {className}" {onclick}>
|
||||
<Button {...props} {variant} class={className} {onclick}>
|
||||
{@render children()}
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
@@ -1,118 +1,116 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import LanguageSelect from '$lib/components/LanguageSelect.svelte';
|
||||
import ModeSwitch from '$lib/components/ModeSwitch.svelte';
|
||||
import Logo from '$lib/components/Logo.svelte';
|
||||
import { AtSign, BookOpenText, Heart, House, Map } from '@lucide/svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
</script>
|
||||
|
||||
<footer class="w-full px-12 py-10 border-t flex flex-col items-center">
|
||||
<div class="w-full max-w-5xl flex flex-row flex-wrap justify-between gap-x-10 gap-y-6">
|
||||
<div class="grow flex flex-col items-start">
|
||||
<Logo class="h-8" width="153" />
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||
href="https://github.com/gpxstudio/gpx.studio/blob/main/LICENSE"
|
||||
target="_blank"
|
||||
>
|
||||
MIT © 2026 gpx.studio
|
||||
</Button>
|
||||
<div class="mt-3 flex flex-row gap-1.5">
|
||||
<LanguageSelect />
|
||||
<ModeSwitch />
|
||||
<footer class="w-full">
|
||||
<div class="mx-6 border-t">
|
||||
<div class="mx-12 py-10 flex flex-row flex-wrap justify-between gap-x-10 gap-y-6">
|
||||
<div class="grow flex flex-col items-start">
|
||||
<Logo class="h-8" width="153" />
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||
href="https://github.com/gpxstudio/gpx.studio/blob/main/LICENSE"
|
||||
target="_blank"
|
||||
>
|
||||
MIT © 2026 gpx.studio
|
||||
</Button>
|
||||
<LanguageSelect class="w-40 mt-3" />
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<span class="font-semibold">{i18n._('homepage.website')}</span>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||
href={getURLForLanguage(i18n.lang, '/')}
|
||||
>
|
||||
<House size="16" />
|
||||
{i18n._('homepage.home')}
|
||||
</Button>
|
||||
<Button
|
||||
data-sveltekit-reload
|
||||
variant="link"
|
||||
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||
href={getURLForLanguage(i18n.lang, '/app')}
|
||||
>
|
||||
<Map size="16" />
|
||||
{i18n._('homepage.app')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||
href={getURLForLanguage(i18n.lang, '/help')}
|
||||
>
|
||||
<BookOpenText size="16" />
|
||||
{i18n._('menu.help')}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-col items-start gap-1" id="contact">
|
||||
<span class="font-semibold">{i18n._('homepage.contact')}</span>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||
href="https://www.reddit.com/r/gpxstudio/"
|
||||
target="_blank"
|
||||
>
|
||||
<Logo company="reddit" class="h-4 fill-muted-foreground" />
|
||||
{i18n._('homepage.reddit')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||
href="https://facebook.com/gpx.studio"
|
||||
target="_blank"
|
||||
>
|
||||
<Logo company="facebook" class="h-4 fill-muted-foreground" />
|
||||
{i18n._('homepage.facebook')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||
href="mailto:hello@gpx.studio"
|
||||
target="_blank"
|
||||
>
|
||||
<AtSign size="16" />
|
||||
{i18n._('homepage.email')}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-col items-start gap-1">
|
||||
<span class="font-semibold">{i18n._('homepage.contribute')}</span>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||
href="https://opencollective.com/gpxstudio"
|
||||
target="_blank"
|
||||
>
|
||||
<Heart size="16" />
|
||||
{i18n._('menu.donate')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||
href="https://crowdin.com/project/gpxstudio"
|
||||
target="_blank"
|
||||
>
|
||||
<Logo company="crowdin" class="h-4 fill-muted-foreground" />
|
||||
{i18n._('homepage.crowdin')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||
href="https://github.com/gpxstudio/gpx.studio"
|
||||
target="_blank"
|
||||
>
|
||||
<Logo company="github" class="h-4 fill-muted-foreground" />
|
||||
{i18n._('homepage.github')}
|
||||
</Button>
|
||||
<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">
|
||||
<span class="font-semibold">{i18n._('homepage.website')}</span>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||
href={getURLForLanguage(i18n.lang, '/')}
|
||||
>
|
||||
<House size="16" />
|
||||
{i18n._('homepage.home')}
|
||||
</Button>
|
||||
<Button
|
||||
data-sveltekit-reload
|
||||
variant="link"
|
||||
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||
href={getURLForLanguage(i18n.lang, '/app')}
|
||||
>
|
||||
<Map size="16" />
|
||||
{i18n._('homepage.app')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||
href={getURLForLanguage(i18n.lang, '/help')}
|
||||
>
|
||||
<BookOpenText size="16" />
|
||||
{i18n._('menu.help')}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-col items-start gap-1" id="contact">
|
||||
<span class="font-semibold">{i18n._('homepage.contact')}</span>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||
href="https://www.reddit.com/r/gpxstudio/"
|
||||
target="_blank"
|
||||
>
|
||||
<Logo company="reddit" class="h-4 fill-muted-foreground" />
|
||||
{i18n._('homepage.reddit')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||
href="https://facebook.com/gpx.studio"
|
||||
target="_blank"
|
||||
>
|
||||
<Logo company="facebook" class="h-4 fill-muted-foreground" />
|
||||
{i18n._('homepage.facebook')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||
href="mailto:hello@gpx.studio"
|
||||
target="_blank"
|
||||
>
|
||||
<AtSign size="16" />
|
||||
{i18n._('homepage.email')}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-col items-start gap-1">
|
||||
<span class="font-semibold">{i18n._('homepage.contribute')}</span>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||
href="https://ko-fi.com/gpxstudio"
|
||||
target="_blank"
|
||||
>
|
||||
<Heart size="16" />
|
||||
{i18n._('menu.donate')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||
href="https://crowdin.com/project/gpxstudio"
|
||||
target="_blank"
|
||||
>
|
||||
<Logo company="crowdin" class="h-4 fill-muted-foreground" />
|
||||
{i18n._('homepage.crowdin')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||
href="https://github.com/gpxstudio/gpx.studio"
|
||||
target="_blank"
|
||||
>
|
||||
<Logo company="github" class="h-4 fill-muted-foreground" />
|
||||
{i18n._('homepage.github')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,17 +12,16 @@
|
||||
|
||||
const { velocityUnits } = settings;
|
||||
|
||||
let panelHeight: number = $state(0);
|
||||
let panelWidth: number = $state(0);
|
||||
|
||||
let {
|
||||
gpxStatistics,
|
||||
slicedGPXStatistics,
|
||||
orientation,
|
||||
panelSize,
|
||||
}: {
|
||||
gpxStatistics: Readable<GPXStatisticsGroup>;
|
||||
slicedGPXStatistics: Readable<[GPXGlobalStatistics, number, number] | undefined>;
|
||||
orientation: 'horizontal' | 'vertical';
|
||||
panelSize: number;
|
||||
} = $props();
|
||||
|
||||
let statistics = $derived(
|
||||
@@ -32,61 +31,59 @@
|
||||
|
||||
<Card.Root
|
||||
class="h-full {orientation === 'vertical'
|
||||
? 'min-w-40 sm:min-w-44'
|
||||
: 'w-full h-fit my-1'} border-none shadow-none p-0 text-sm sm:text-base bg-transparent"
|
||||
? 'min-w-40 sm:min-w-44 text-sm sm:text-base'
|
||||
: 'w-full'} border-none shadow-none p-0"
|
||||
>
|
||||
<Card.Content class="h-full p-0">
|
||||
<div
|
||||
bind:clientHeight={panelHeight}
|
||||
bind:clientWidth={panelWidth}
|
||||
class="flex {orientation === 'vertical'
|
||||
? 'flex-col h-full justify-center'
|
||||
: 'flex-row w-full justify-evenly'} gap-4"
|
||||
>
|
||||
<Tooltip label={i18n._('quantities.distance')}>
|
||||
<Card.Content
|
||||
class="h-full flex {orientation === 'vertical'
|
||||
? 'flex-col justify-center'
|
||||
: 'flex-row w-full justify-between'} gap-4 p-0"
|
||||
>
|
||||
<Tooltip label={i18n._('quantities.distance')}>
|
||||
<span class="flex flex-row items-center">
|
||||
<Ruler size="16" class="mr-1" />
|
||||
<WithUnits value={statistics.distance.total} type="distance" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip label={i18n._('quantities.elevation_gain_loss')}>
|
||||
<span class="flex flex-row items-center">
|
||||
<MoveUpRight size="16" class="mr-1" />
|
||||
<WithUnits value={statistics.elevation.gain} type="elevation" />
|
||||
<MoveDownRight size="16" class="mx-1" />
|
||||
<WithUnits value={statistics.elevation.loss} type="elevation" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
{#if panelSize > 120 || orientation === 'horizontal'}
|
||||
<Tooltip
|
||||
class={orientation === 'horizontal' ? 'hidden xs:block' : ''}
|
||||
label="{$velocityUnits === 'speed'
|
||||
? i18n._('quantities.speed')
|
||||
: i18n._('quantities.pace')} ({i18n._('quantities.moving')} / {i18n._(
|
||||
'quantities.total'
|
||||
)})"
|
||||
>
|
||||
<span class="flex flex-row items-center">
|
||||
<Ruler size="16" class="mr-1" />
|
||||
<WithUnits value={statistics.distance.total} type="distance" />
|
||||
<Zap size="16" class="mr-1" />
|
||||
<WithUnits value={statistics.speed.moving} type="speed" showUnits={false} />
|
||||
<span class="mx-1">/</span>
|
||||
<WithUnits value={statistics.speed.total} type="speed" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip label={i18n._('quantities.elevation_gain_loss')}>
|
||||
{/if}
|
||||
{#if panelSize > 160 || orientation === 'horizontal'}
|
||||
<Tooltip
|
||||
class={orientation === 'horizontal' ? 'hidden md:block' : ''}
|
||||
label="{i18n._('quantities.time')} ({i18n._('quantities.moving')} / {i18n._(
|
||||
'quantities.total'
|
||||
)})"
|
||||
>
|
||||
<span class="flex flex-row items-center">
|
||||
<MoveUpRight size="16" class="mr-1" />
|
||||
<WithUnits value={statistics.elevation.gain} type="elevation" />
|
||||
<MoveDownRight size="16" class="mx-1" />
|
||||
<WithUnits value={statistics.elevation.loss} type="elevation" />
|
||||
<Timer size="16" class="mr-1" />
|
||||
<WithUnits value={statistics.time.moving} type="time" />
|
||||
<span class="mx-1">/</span>
|
||||
<WithUnits value={statistics.time.total} type="time" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
{#if panelHeight > 120 || (orientation === 'horizontal' && panelWidth > 450)}
|
||||
<Tooltip
|
||||
label="{$velocityUnits === 'speed'
|
||||
? i18n._('quantities.speed')
|
||||
: i18n._('quantities.pace')} ({i18n._('quantities.moving')} / {i18n._(
|
||||
'quantities.total'
|
||||
)})"
|
||||
>
|
||||
<span class="flex flex-row items-center">
|
||||
<Zap size="16" class="mr-1" />
|
||||
<WithUnits value={statistics.speed.moving} type="speed" showUnits={false} />
|
||||
<span class="mx-1">/</span>
|
||||
<WithUnits value={statistics.speed.total} type="speed" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
{#if panelHeight > 150 || (orientation === 'horizontal' && panelWidth > 620)}
|
||||
<Tooltip
|
||||
label="{i18n._('quantities.time')} ({i18n._('quantities.moving')} / {i18n._(
|
||||
'quantities.total'
|
||||
)})"
|
||||
>
|
||||
<span class="flex flex-row items-center">
|
||||
<Timer size="16" class="mr-1" />
|
||||
<WithUnits value={statistics.time.moving} type="time" />
|
||||
<span class="mx-1">/</span>
|
||||
<WithUnits value={statistics.time.total} type="time" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
@@ -5,10 +5,16 @@
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import { Languages } from '@lucide/svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
}: {
|
||||
class?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Select.Root type="single" value={i18n.lang}>
|
||||
<Select.Trigger class="w-[180px] px-2" aria-label={i18n._('menu.language')}>
|
||||
<Select.Trigger class="min-w-[180px] {className}" aria-label={i18n._('menu.language')}>
|
||||
<Languages size="16" />
|
||||
<span class="mr-auto">
|
||||
{languages[i18n.lang]}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
...others
|
||||
}: {
|
||||
iconOnly?: boolean;
|
||||
company?: 'gpx.studio' | 'maptiler' | 'github' | 'crowdin' | 'facebook' | 'reddit';
|
||||
company?: 'gpx.studio' | 'mapbox' | 'github' | 'crowdin' | 'facebook' | 'reddit';
|
||||
[key: string]: any;
|
||||
} = $props();
|
||||
</script>
|
||||
@@ -19,10 +19,10 @@
|
||||
alt="Logo of gpx.studio."
|
||||
{...others}
|
||||
/>
|
||||
{:else if company === 'maptiler'}
|
||||
{:else if company === 'mapbox'}
|
||||
<img
|
||||
src="{base}/maptiler-logo{mode.current === 'dark' ? '-dark' : ''}.svg"
|
||||
alt="Logo of Maptiler."
|
||||
src="{base}/mapbox-logo-{mode.current === 'dark' ? 'white' : 'black'}.svg"
|
||||
alt="Logo of Mapbox."
|
||||
{...others}
|
||||
/>
|
||||
{:else if company === 'github'}
|
||||
|
||||
@@ -375,7 +375,7 @@
|
||||
<Menubar.Item inset onclick={() => map.toggle3D()}>
|
||||
<Box size="16" />
|
||||
{i18n._('menu.toggle_3d')}
|
||||
<Shortcut key={i18n._('menu.right_click_drag')} />
|
||||
<Shortcut key="{i18n._('menu.ctrl')} {i18n._('menu.drag')}" />
|
||||
</Menubar.Item>
|
||||
</Menubar.Content>
|
||||
</Menubar.Menu>
|
||||
@@ -515,7 +515,7 @@
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
href="https://opencollective.com/gpxstudio"
|
||||
href="https://ko-fi.com/gpxstudio"
|
||||
target="_blank"
|
||||
class="cursor-default h-fit rounded-sm font-bold text-support hover:text-support px-3 py-0.5"
|
||||
aria-label={i18n._('menu.donate')}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</script>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class={className}
|
||||
onclick={() => {
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
<script lang="ts">
|
||||
import Logo from '$lib/components/Logo.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import AlgoliaDocSearch from '$lib/components/AlgoliaDocSearch.svelte';
|
||||
import ModeSwitch from '$lib/components/ModeSwitch.svelte';
|
||||
import { BookOpenText, House, Map } from '@lucide/svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
</script>
|
||||
|
||||
<nav class="sticky top-0 w-full px-12 py-2 bg-background z-50 flex flex-col items-center border-b">
|
||||
<div class="w-full max-w-5xl flex flex-row items-center gap-4 sm:gap-8">
|
||||
<a
|
||||
href={getURLForLanguage(i18n.lang, '/')}
|
||||
class="shrink-0 translate-y-0.25 justify-self-start"
|
||||
>
|
||||
<Logo class="h-8 xs:hidden" iconOnly={true} width="26" />
|
||||
<Logo class="h-8 hidden xs:block" width="153" />
|
||||
<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">
|
||||
<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 hidden sm:block" width="153" />
|
||||
</a>
|
||||
<Button
|
||||
variant="link"
|
||||
class="text-base px-0 has-[>svg]:px-0 ml-auto"
|
||||
class="text-base px-0 has-[>svg]:px-0"
|
||||
href={getURLForLanguage(i18n.lang, '/')}
|
||||
>
|
||||
<House size="18" />
|
||||
@@ -40,5 +39,7 @@
|
||||
<BookOpenText size="18" />
|
||||
{i18n._('menu.help')}
|
||||
</Button>
|
||||
<AlgoliaDocSearch class="ml-auto" />
|
||||
<ModeSwitch class="hidden xs:inline-flex" />
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
@apply text-foreground;
|
||||
@apply text-3xl;
|
||||
@apply font-semibold;
|
||||
@apply mb-3;
|
||||
@apply mb-3 pt-6;
|
||||
}
|
||||
|
||||
:global(.markdown h2) {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<div class="rounded-md overflow-hidden overflow-clip shadow-xl mx-auto">
|
||||
{#if src === 'getting-started/interface'}
|
||||
<enhanced:img
|
||||
src="/src/lib/assets/img/docs/getting-started/interface.webp"
|
||||
src="/src/lib/assets/img/docs/getting-started/interface.png"
|
||||
{alt}
|
||||
class="w-full max-w-3xl"
|
||||
/>
|
||||
@@ -20,13 +20,13 @@
|
||||
<enhanced:img
|
||||
src="/src/lib/assets/img/docs/tools/routing.png"
|
||||
{alt}
|
||||
class="w-full max-w-lg"
|
||||
class="w-full max-w-3xl"
|
||||
/>
|
||||
{:else if src === 'tools/split'}
|
||||
<enhanced:img
|
||||
src="/src/lib/assets/img/docs/tools/split.png"
|
||||
{alt}
|
||||
class="w-full max-w-lg"
|
||||
class="w-full max-w-3xl"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import maptilerTopoMap from '$lib/assets/img/home/maptiler-topo.png?enhanced';
|
||||
import mapboxOutdoorsMap from '$lib/assets/img/home/mapbox-outdoors.png?enhanced';
|
||||
import waymarkedMap from '$lib/assets/img/home/waymarked.png?enhanced';
|
||||
</script>
|
||||
|
||||
<div class="relative h-80 aspect-square rounded-2xl shadow-xl overflow-clip">
|
||||
<enhanced:img src={maptilerTopoMap} alt="MapTiler Topo map screenshot." class="absolute" />
|
||||
<enhanced:img src={mapboxOutdoorsMap} alt="Mapbox Outdoors map screenshot." class="absolute" />
|
||||
<enhanced:img
|
||||
src={waymarkedMap}
|
||||
alt="Waymarked Trails map screenshot."
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
Construction,
|
||||
} from '@lucide/svelte';
|
||||
import type { Readable, Writable } from 'svelte/store';
|
||||
import type { Coordinates, GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
|
||||
import type { GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { ElevationProfile } from '$lib/components/elevation-profile/elevation-profile';
|
||||
@@ -28,14 +28,12 @@
|
||||
let {
|
||||
gpxStatistics,
|
||||
slicedGPXStatistics,
|
||||
hoveredPoint,
|
||||
additionalDatasets,
|
||||
elevationFill,
|
||||
showControls = true,
|
||||
}: {
|
||||
gpxStatistics: Readable<GPXStatisticsGroup>;
|
||||
slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>;
|
||||
hoveredPoint: Writable<Coordinates | null>;
|
||||
additionalDatasets: Writable<string[]>;
|
||||
elevationFill: Writable<'slope' | 'surface' | 'highway' | undefined>;
|
||||
showControls?: boolean;
|
||||
@@ -49,7 +47,6 @@
|
||||
elevationProfile = new ElevationProfile(
|
||||
gpxStatistics,
|
||||
slicedGPXStatistics,
|
||||
hoveredPoint,
|
||||
additionalDatasets,
|
||||
elevationFill,
|
||||
canvas,
|
||||
@@ -64,18 +61,18 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="h-full grow min-w-0 min-h-0 relative">
|
||||
<div class="h-full grow min-w-0 relative py-2">
|
||||
<canvas bind:this={overlay} class="w-full h-full absolute pointer-events-none"></canvas>
|
||||
<canvas bind:this={canvas} class="w-full h-full absolute"></canvas>
|
||||
{#if showControls}
|
||||
<div class="absolute bottom-9 right-2.5">
|
||||
<div class="absolute bottom-10 right-1.5">
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<ButtonWithTooltip
|
||||
label={i18n._('chart.settings')}
|
||||
variant="outline"
|
||||
side="left"
|
||||
class="w-7 h-7 p-0 flex justify-center opacity-70 hover:opacity-100 transition-opacity duration-300 bg-background"
|
||||
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>
|
||||
|
||||
@@ -20,8 +20,10 @@ import Chart, {
|
||||
type ScriptableLineSegmentContext,
|
||||
type TooltipItem,
|
||||
} from 'chart.js/auto';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { get, type Readable, type Writable } from 'svelte/store';
|
||||
import type { Coordinates, GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import type { GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
|
||||
import { mode } from 'mode-watcher';
|
||||
import { getHighwayColor, getSlopeColor, getSurfaceColor } from '$lib/assets/colors';
|
||||
|
||||
@@ -40,7 +42,7 @@ interface ElevationProfilePoint {
|
||||
length: number;
|
||||
};
|
||||
extensions: Record<string, any>;
|
||||
coordinates: Coordinates;
|
||||
coordinates: [number, number];
|
||||
index: number;
|
||||
}
|
||||
|
||||
@@ -48,19 +50,18 @@ export class ElevationProfile {
|
||||
private _chart: Chart | null = null;
|
||||
private _canvas: HTMLCanvasElement;
|
||||
private _overlay: HTMLCanvasElement;
|
||||
private _marker: mapboxgl.Marker | null = null;
|
||||
private _dragging = false;
|
||||
private _panning = false;
|
||||
|
||||
private _gpxStatistics: Readable<GPXStatisticsGroup>;
|
||||
private _slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>;
|
||||
private _hoveredPoint: Writable<Coordinates | null>;
|
||||
private _additionalDatasets: Readable<string[]>;
|
||||
private _elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>;
|
||||
|
||||
constructor(
|
||||
gpxStatistics: Readable<GPXStatisticsGroup>,
|
||||
slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>,
|
||||
hoveredPoint: Writable<Coordinates | null>,
|
||||
additionalDatasets: Readable<string[]>,
|
||||
elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>,
|
||||
canvas: HTMLCanvasElement,
|
||||
@@ -68,12 +69,17 @@ export class ElevationProfile {
|
||||
) {
|
||||
this._gpxStatistics = gpxStatistics;
|
||||
this._slicedGPXStatistics = slicedGPXStatistics;
|
||||
this._hoveredPoint = hoveredPoint;
|
||||
this._additionalDatasets = additionalDatasets;
|
||||
this._elevationFill = elevationFill;
|
||||
this._canvas = canvas;
|
||||
this._overlay = overlay;
|
||||
|
||||
let element = document.createElement('div');
|
||||
element.className = 'h-4 w-4 rounded-full bg-cyan-500 border-2 border-white';
|
||||
this._marker = new mapboxgl.Marker({
|
||||
element,
|
||||
});
|
||||
|
||||
import('chartjs-plugin-zoom').then((module) => {
|
||||
Chart.register(module.default);
|
||||
this.initialize();
|
||||
@@ -156,10 +162,14 @@ export class ElevationProfile {
|
||||
label: (context: TooltipItem<'line'>) => {
|
||||
let point = context.raw as ElevationProfilePoint;
|
||||
if (context.datasetIndex === 0) {
|
||||
if (this._dragging) {
|
||||
this._hoveredPoint.set(null);
|
||||
} else {
|
||||
this._hoveredPoint.set(point.coordinates);
|
||||
const map_ = get(map);
|
||||
if (map_ && this._marker) {
|
||||
if (this._dragging) {
|
||||
this._marker.remove();
|
||||
} else {
|
||||
this._marker.setLngLat(point.coordinates);
|
||||
this._marker.addTo(map_);
|
||||
}
|
||||
}
|
||||
return `${i18n._('quantities.elevation')}: ${getElevationWithUnits(point.y, false)}`;
|
||||
} else if (context.datasetIndex === 1) {
|
||||
@@ -302,7 +312,10 @@ export class ElevationProfile {
|
||||
events: ['mouseout'],
|
||||
afterEvent: (chart: Chart, args: { event: ChartEvent }) => {
|
||||
if (args.event.type === 'mouseout') {
|
||||
this._hoveredPoint.set(null);
|
||||
const map_ = get(map);
|
||||
if (map_ && this._marker) {
|
||||
this._marker.remove();
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -624,5 +637,8 @@ export class ElevationProfile {
|
||||
this._chart.destroy();
|
||||
this._chart = null;
|
||||
}
|
||||
if (this._marker) {
|
||||
this._marker.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
import { setMode } from 'mode-watcher';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { fileStateCollection } from '$lib/logic/file-state';
|
||||
import { gpxStatistics, hoveredPoint, slicedGPXStatistics } from '$lib/logic/statistics';
|
||||
import { gpxStatistics, slicedGPXStatistics } from '$lib/logic/statistics';
|
||||
import { loadFile } from '$lib/logic/file-actions';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
import { untrack } from 'svelte';
|
||||
@@ -102,7 +102,7 @@
|
||||
<div class="grow relative">
|
||||
<Map
|
||||
class="h-full {$fileStateCollection.size > 1 ? 'horizontal' : ''}"
|
||||
maptilerKey={options.key}
|
||||
accessToken={options.token}
|
||||
geocoder={false}
|
||||
geolocate={true}
|
||||
hash={useHash}
|
||||
@@ -117,19 +117,19 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="{options.elevation.show ? '' : 'h-10'} flex flex-row gap-2 p-2 sm:px-4"
|
||||
class="{options.elevation.show ? '' : 'h-10'} flex flex-row gap-2 px-2 sm:px-4"
|
||||
style={options.elevation.show ? `height: ${options.elevation.height}px` : ''}
|
||||
>
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
panelSize={options.elevation.height}
|
||||
orientation={options.elevation.show ? 'vertical' : 'horizontal'}
|
||||
/>
|
||||
{#if options.elevation.show}
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
showControls={options.elevation.controls}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
getCleanedEmbeddingOptions,
|
||||
getMergedEmbeddingOptions,
|
||||
} from './embedding';
|
||||
import { PUBLIC_MAPTILER_KEY } from '$env/static/public';
|
||||
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
|
||||
import Embedding from './Embedding.svelte';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { base } from '$app/paths';
|
||||
@@ -32,7 +32,7 @@
|
||||
let options = $state(
|
||||
getMergedEmbeddingOptions(
|
||||
{
|
||||
key: 'YOUR_MAPTILER_KEY',
|
||||
token: 'YOUR_MAPBOX_TOKEN',
|
||||
theme: mode.current,
|
||||
},
|
||||
defaultEmbeddingOptions
|
||||
@@ -46,10 +46,10 @@
|
||||
let iframeOptions = $derived(
|
||||
getMergedEmbeddingOptions(
|
||||
{
|
||||
key:
|
||||
options.key.length === 0 || options.key === 'YOUR_MAPTILER_KEY'
|
||||
? PUBLIC_MAPTILER_KEY
|
||||
: options.key,
|
||||
token:
|
||||
options.token.length === 0 || options.token === 'YOUR_MAPBOX_TOKEN'
|
||||
? PUBLIC_MAPBOX_TOKEN
|
||||
: options.token,
|
||||
files: files.split(',').filter((url) => url.length > 0),
|
||||
ids: driveIds.split(',').filter((id) => id.length > 0),
|
||||
elevation: {
|
||||
@@ -102,8 +102,8 @@
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<fieldset class="flex flex-col gap-3">
|
||||
<Label for="key">{i18n._('embedding.maptiler_key')}</Label>
|
||||
<Input id="key" type="text" class="h-8" bind:value={options.key} />
|
||||
<Label for="token">{i18n._('embedding.mapbox_token')}</Label>
|
||||
<Input id="token" type="text" class="h-8" bind:value={options.token} />
|
||||
<Label for="file_urls">{i18n._('embedding.file_urls')}</Label>
|
||||
<Input id="file_urls" type="text" class="h-8" bind:value={files} />
|
||||
<Label for="drive_ids">{i18n._('embedding.drive_ids')}</Label>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { PUBLIC_MAPTILER_KEY } from '$env/static/public';
|
||||
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
|
||||
import { basemaps } from '$lib/assets/layers';
|
||||
|
||||
export type EmbeddingOptions = {
|
||||
key: string;
|
||||
token: string;
|
||||
files: string[];
|
||||
ids: string[];
|
||||
basemap: string;
|
||||
@@ -26,10 +26,10 @@ export type EmbeddingOptions = {
|
||||
};
|
||||
|
||||
export const defaultEmbeddingOptions = {
|
||||
key: '',
|
||||
token: '',
|
||||
files: [],
|
||||
ids: [],
|
||||
basemap: 'maptilerStreets',
|
||||
basemap: 'mapboxOutdoors',
|
||||
elevation: {
|
||||
show: true,
|
||||
height: 170,
|
||||
@@ -90,9 +90,6 @@ export function getCleanedEmbeddingOptions(
|
||||
delete cleanedOptions[key];
|
||||
}
|
||||
}
|
||||
if (cleanedOptions['key'] && cleanedOptions['key'] === PUBLIC_MAPTILER_KEY) {
|
||||
delete cleanedOptions['key'];
|
||||
}
|
||||
return cleanedOptions;
|
||||
}
|
||||
|
||||
@@ -110,7 +107,7 @@ export function getURLForGoogleDriveFile(fileId: string): string {
|
||||
|
||||
export function convertOldEmbeddingOptions(options: URLSearchParams): any {
|
||||
let newOptions: any = {
|
||||
key: PUBLIC_MAPTILER_KEY,
|
||||
token: PUBLIC_MAPBOX_TOKEN,
|
||||
files: [],
|
||||
ids: [],
|
||||
};
|
||||
@@ -126,7 +123,7 @@ export function convertOldEmbeddingOptions(options: URLSearchParams): any {
|
||||
if (options.has('source')) {
|
||||
let basemap = options.get('source')!;
|
||||
if (basemap === 'satellite') {
|
||||
newOptions.basemap = 'maptilerSatellite';
|
||||
newOptions.basemap = 'mapboxSatellite';
|
||||
} else if (basemap === 'otm') {
|
||||
newOptions.basemap = 'openTopoMap';
|
||||
} else if (basemap === 'ohm') {
|
||||
|
||||
@@ -100,11 +100,7 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full flex flex-row flex-wrap gap-2">
|
||||
<Button
|
||||
class="bg-support grow"
|
||||
href="https://opencollective.com/gpxstudio"
|
||||
target="_blank"
|
||||
>
|
||||
<Button class="bg-support grow" href="https://ko-fi.com/gpxstudio" target="_blank">
|
||||
{i18n._('menu.support_button')}
|
||||
<span>🙏</span>
|
||||
</Button>
|
||||
|
||||
@@ -5,16 +5,6 @@
|
||||
|
||||
map.onLoad((map_) => {
|
||||
map_.on('contextmenu', (e) => {
|
||||
if (
|
||||
map_.queryRenderedFeatures(e.point, {
|
||||
layers: map_
|
||||
.getLayersOrder()
|
||||
.filter((layerId) => layerId.startsWith('routing-controls')),
|
||||
}).length
|
||||
) {
|
||||
// Clicked on routing control, ignoring
|
||||
return;
|
||||
}
|
||||
trackpointPopup?.setItem({
|
||||
item: new TrackPoint({
|
||||
attributes: {
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
<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 { i18n } from '$lib/i18n.svelte';
|
||||
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
|
||||
import { page } from '$app/state';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { PUBLIC_MAPTILER_KEY } from '$env/static/public';
|
||||
|
||||
let {
|
||||
maptilerKey = PUBLIC_MAPTILER_KEY,
|
||||
accessToken = PUBLIC_MAPBOX_TOKEN,
|
||||
geolocate = true,
|
||||
geocoder = true,
|
||||
hash = true,
|
||||
class: className = '',
|
||||
}: {
|
||||
maptilerKey?: string;
|
||||
accessToken?: string;
|
||||
geolocate?: boolean;
|
||||
geocoder?: boolean;
|
||||
hash?: boolean;
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
mapboxgl.accessToken = accessToken;
|
||||
|
||||
let webgl2Supported = $state(true);
|
||||
let embeddedApp = $state(false);
|
||||
|
||||
@@ -43,7 +48,7 @@
|
||||
language = 'en';
|
||||
}
|
||||
|
||||
map.init(maptilerKey, language, hash, geocoder, geolocate);
|
||||
map.init(language, hash, geocoder, geolocate);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
@@ -76,21 +81,21 @@
|
||||
<style lang="postcss">
|
||||
@reference "../../../app.css";
|
||||
|
||||
div :global(.maplibregl-map) {
|
||||
div :global(.mapboxgl-map) {
|
||||
@apply font-sans;
|
||||
}
|
||||
|
||||
div :global(.maplibregl-ctrl-top-right > .maplibregl-ctrl) {
|
||||
div :global(.mapboxgl-ctrl-top-right > .mapboxgl-ctrl) {
|
||||
@apply shadow-md;
|
||||
@apply bg-background;
|
||||
@apply text-foreground;
|
||||
}
|
||||
|
||||
div :global(.maplibregl-ctrl-icon) {
|
||||
div :global(.mapboxgl-ctrl-icon) {
|
||||
@apply dark:brightness-[4.7];
|
||||
}
|
||||
|
||||
div :global(.maplibregl-ctrl-geocoder) {
|
||||
div :global(.mapboxgl-ctrl-geocoder) {
|
||||
@apply flex;
|
||||
@apply flex-row;
|
||||
@apply w-fit;
|
||||
@@ -105,45 +110,36 @@
|
||||
@apply text-foreground;
|
||||
}
|
||||
|
||||
div :global(.maplibregl-ctrl-geocoder .suggestions > li > a) {
|
||||
div :global(.mapboxgl-ctrl-geocoder .suggestions > li > a) {
|
||||
@apply text-foreground;
|
||||
@apply hover:text-accent-foreground;
|
||||
@apply hover:bg-accent;
|
||||
}
|
||||
|
||||
div :global(.maplibregl-ctrl-geocoder .suggestions > .active > a) {
|
||||
div :global(.mapboxgl-ctrl-geocoder .suggestions > .active > a) {
|
||||
@apply bg-background;
|
||||
}
|
||||
|
||||
div :global(.maplibregl-ctrl-geocoder--button) {
|
||||
div :global(.mapboxgl-ctrl-geocoder--button) {
|
||||
@apply bg-transparent;
|
||||
@apply hover:bg-transparent;
|
||||
}
|
||||
|
||||
div :global(.maplibregl-ctrl-geocoder--icon) {
|
||||
div :global(.mapboxgl-ctrl-geocoder--icon) {
|
||||
@apply fill-foreground;
|
||||
@apply hover:fill-accent-foreground;
|
||||
}
|
||||
|
||||
div :global(.maplibregl-ctrl-geocoder--icon-search) {
|
||||
div :global(.mapboxgl-ctrl-geocoder--icon-search) {
|
||||
@apply relative;
|
||||
@apply top-0;
|
||||
@apply left-0;
|
||||
@apply my-2;
|
||||
@apply w-[29px];
|
||||
}
|
||||
|
||||
div :global(.maplibregl-ctrl-geocoder--icon-loading) {
|
||||
@apply -mt-1;
|
||||
@apply mb-0;
|
||||
}
|
||||
|
||||
div :global(.maplibregl-ctrl-geocoder--icon-close) {
|
||||
@apply my-0;
|
||||
}
|
||||
|
||||
div :global(.maplibregl-ctrl-geocoder--input) {
|
||||
div :global(.mapboxgl-ctrl-geocoder--input) {
|
||||
@apply relative;
|
||||
@apply h-8;
|
||||
@apply w-64;
|
||||
@apply py-0;
|
||||
@apply pl-2;
|
||||
@@ -153,12 +149,12 @@
|
||||
@apply text-foreground;
|
||||
}
|
||||
|
||||
div :global(.maplibregl-ctrl-geocoder--collapsed .maplibregl-ctrl-geocoder--input) {
|
||||
div :global(.mapboxgl-ctrl-geocoder--collapsed .mapboxgl-ctrl-geocoder--input) {
|
||||
@apply w-0;
|
||||
@apply p-0;
|
||||
}
|
||||
|
||||
div :global(.maplibregl-ctrl-top-right) {
|
||||
div :global(.mapboxgl-ctrl-top-right) {
|
||||
@apply z-40;
|
||||
@apply flex;
|
||||
@apply flex-col;
|
||||
@@ -167,76 +163,77 @@
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
|
||||
.horizontal :global(.maplibregl-ctrl-bottom-left) {
|
||||
.horizontal :global(.mapboxgl-ctrl-bottom-left) {
|
||||
@apply bottom-[42px];
|
||||
}
|
||||
|
||||
.horizontal :global(.maplibregl-ctrl-bottom-right) {
|
||||
.horizontal :global(.mapboxgl-ctrl-bottom-right) {
|
||||
@apply bottom-[42px];
|
||||
}
|
||||
|
||||
div :global(.maplibregl-ctrl-attrib) {
|
||||
div :global(.mapboxgl-ctrl-attrib) {
|
||||
@apply dark:bg-transparent;
|
||||
}
|
||||
|
||||
div :global(.maplibregl-compact-show.maplibregl-ctrl-attrib) {
|
||||
div :global(.mapboxgl-compact-show.mapboxgl-ctrl-attrib) {
|
||||
@apply dark:bg-background;
|
||||
}
|
||||
|
||||
div :global(.maplibregl-ctrl-attrib-button) {
|
||||
div :global(.mapboxgl-ctrl-attrib-button) {
|
||||
@apply dark:bg-foreground;
|
||||
}
|
||||
|
||||
div :global(.maplibregl-compact-show .maplibregl-ctrl-attrib-button) {
|
||||
div :global(.mapboxgl-compact-show .mapboxgl-ctrl-attrib-button) {
|
||||
@apply dark:bg-foreground;
|
||||
}
|
||||
|
||||
div :global(.maplibregl-ctrl-attrib a) {
|
||||
div :global(.mapboxgl-ctrl-attrib a) {
|
||||
@apply text-foreground;
|
||||
}
|
||||
|
||||
div :global(.maplibregl-popup) {
|
||||
div :global(.mapboxgl-popup) {
|
||||
@apply w-fit;
|
||||
@apply z-50;
|
||||
}
|
||||
|
||||
div :global(.maplibregl-popup-content) {
|
||||
div :global(.mapboxgl-popup-content) {
|
||||
@apply p-0;
|
||||
@apply bg-transparent;
|
||||
@apply shadow-none;
|
||||
}
|
||||
|
||||
div :global(.maplibregl-popup-anchor-top .maplibregl-popup-tip) {
|
||||
div :global(.mapboxgl-popup-anchor-top .mapboxgl-popup-tip) {
|
||||
@apply border-b-background;
|
||||
}
|
||||
|
||||
div :global(.maplibregl-popup-anchor-top-left .maplibregl-popup-tip) {
|
||||
div :global(.mapboxgl-popup-anchor-top-left .mapboxgl-popup-tip) {
|
||||
@apply border-b-background;
|
||||
}
|
||||
|
||||
div :global(.maplibregl-popup-anchor-top-right .maplibregl-popup-tip) {
|
||||
div :global(.mapboxgl-popup-anchor-top-right .mapboxgl-popup-tip) {
|
||||
@apply border-b-background;
|
||||
}
|
||||
|
||||
div :global(.maplibregl-popup-anchor-bottom .maplibregl-popup-tip) {
|
||||
div :global(.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip) {
|
||||
@apply border-t-background;
|
||||
@apply drop-shadow-md;
|
||||
}
|
||||
|
||||
div :global(.maplibregl-popup-anchor-bottom-left .maplibregl-popup-tip) {
|
||||
div :global(.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-tip) {
|
||||
@apply border-t-background;
|
||||
@apply drop-shadow-md;
|
||||
}
|
||||
|
||||
div :global(.maplibregl-popup-anchor-bottom-right .maplibregl-popup-tip) {
|
||||
div :global(.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-tip) {
|
||||
@apply border-t-background;
|
||||
@apply drop-shadow-md;
|
||||
}
|
||||
|
||||
div :global(.maplibregl-popup-anchor-left .maplibregl-popup-tip) {
|
||||
div :global(.mapboxgl-popup-anchor-left .mapboxgl-popup-tip) {
|
||||
@apply border-r-background;
|
||||
}
|
||||
|
||||
div :global(.maplibregl-popup-anchor-right .maplibregl-popup-tip) {
|
||||
div :global(.mapboxgl-popup-anchor-right .mapboxgl-popup-tip) {
|
||||
@apply border-l-background;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
let control: CustomControl | null = null;
|
||||
|
||||
onMount(() => {
|
||||
map.onLoad((map: maplibregl.Map) => {
|
||||
map.onLoad((map: mapboxgl.Map) => {
|
||||
if (position.includes('right')) container.classList.add('float-right');
|
||||
else container.classList.add('float-left');
|
||||
container.classList.remove('hidden');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type Map, type IControl } from 'maplibre-gl';
|
||||
import { type Map, type IControl } from 'mapbox-gl';
|
||||
|
||||
export default class CustomControl implements IControl {
|
||||
_map: Map | undefined;
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { gpxStatistics } from '$lib/logic/statistics';
|
||||
import { getConvertedDistanceToKilometers } from '$lib/units';
|
||||
import type { GeoJSONSource } from 'mapbox-gl';
|
||||
import { get } from 'svelte/store';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { ANCHOR_LAYER_KEY, map } from '$lib/components/map/map';
|
||||
import { allHidden } from '$lib/logic/hidden';
|
||||
import type { GeoJSONSource } from 'maplibre-gl';
|
||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||
|
||||
const { distanceMarkers, distanceUnits } = settings;
|
||||
|
||||
@@ -23,7 +22,7 @@ export class DistanceMarkers {
|
||||
this.unsubscribes.push(
|
||||
map.subscribe((map_) => {
|
||||
if (map_) {
|
||||
map_.on('style.load', this.updateBinded);
|
||||
map_.on('style.import.load', this.updateBinded);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -3,14 +3,13 @@ import { MapPopup } from '$lib/components/map/map-popup';
|
||||
export let waypointPopup: MapPopup | null = null;
|
||||
export let trackpointPopup: MapPopup | null = null;
|
||||
|
||||
export function createPopups(map: maplibregl.Map) {
|
||||
export function createPopups(map: mapboxgl.Map) {
|
||||
removePopups();
|
||||
waypointPopup = new MapPopup(map, {
|
||||
closeButton: false,
|
||||
focusAfterOpen: false,
|
||||
maxWidth: undefined,
|
||||
offset: {
|
||||
center: [0, 0],
|
||||
top: [0, 0],
|
||||
'top-left': [0, 0],
|
||||
'top-right': [0, 0],
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { get, type Readable } from 'svelte/store';
|
||||
import maplibregl, {
|
||||
type GeoJSONSource,
|
||||
type FilterSpecification,
|
||||
type MapLayerMouseEvent,
|
||||
type MapLayerTouchEvent,
|
||||
} from 'maplibre-gl';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import mapboxgl, { type FilterSpecification } from 'mapbox-gl';
|
||||
import { ANCHOR_LAYER_KEY, map } from '$lib/components/map/map';
|
||||
import { waypointPopup, trackpointPopup } from './gpx-layer-popup';
|
||||
import {
|
||||
ListTrackSegmentItem,
|
||||
@@ -15,7 +10,7 @@ import {
|
||||
ListFileItem,
|
||||
ListRootItem,
|
||||
} from '$lib/components/file-list/file-list';
|
||||
import { getClosestLinePoint, getElevation, loadSVGIcon } from '$lib/utils';
|
||||
import { getClosestLinePoint, getElevation } from '$lib/utils';
|
||||
import { selectedWaypoint } from '$lib/components/toolbar/tools/waypoint/waypoint';
|
||||
import { MapPin, Square } from 'lucide-static';
|
||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||
@@ -27,7 +22,6 @@ import { fileActionManager } from '$lib/logic/file-action-manager';
|
||||
import { fileActions } from '$lib/logic/file-actions';
|
||||
import { splitAs } from '$lib/components/toolbar/tools/scissors/scissors';
|
||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||
import { gpxColors } from '$lib/components/map/gpx-layer/gpx-layers';
|
||||
|
||||
const colors = [
|
||||
@@ -120,28 +114,28 @@ export class GPXLayer {
|
||||
selected: boolean = false;
|
||||
currentWaypointData: GeoJSON.FeatureCollection | null = null;
|
||||
draggedWaypointIndex: number | null = null;
|
||||
draggingStartingPosition: maplibregl.Point = new maplibregl.Point(0, 0);
|
||||
draggingStartingPosition: mapboxgl.Point = new mapboxgl.Point(0, 0);
|
||||
unsubscribe: Function[] = [];
|
||||
|
||||
updateBinded: () => void = this.update.bind(this);
|
||||
layerOnMouseEnterBinded: (e: any) => void = this.layerOnMouseEnter.bind(this);
|
||||
layerOnMouseLeaveBinded: () => void = this.layerOnMouseLeave.bind(this);
|
||||
layerOnMouseMoveBinded: (e: any) => void = this.layerOnMouseMove.bind(this);
|
||||
layerOnClickBinded: (e: MapLayerMouseEvent) => void = this.layerOnClick.bind(this);
|
||||
layerOnContextMenuBinded: (e: MapLayerMouseEvent) => void = this.layerOnContextMenu.bind(this);
|
||||
waypointLayerOnMouseEnterBinded: (e: MapLayerMouseEvent) => void =
|
||||
layerOnClickBinded: (e: any) => void = this.layerOnClick.bind(this);
|
||||
layerOnContextMenuBinded: (e: any) => void = this.layerOnContextMenu.bind(this);
|
||||
waypointLayerOnMouseEnterBinded: (e: mapboxgl.MapMouseEvent) => void =
|
||||
this.waypointLayerOnMouseEnter.bind(this);
|
||||
waypointLayerOnMouseLeaveBinded: (e: MapLayerMouseEvent) => void =
|
||||
waypointLayerOnMouseLeaveBinded: (e: mapboxgl.MapMouseEvent) => void =
|
||||
this.waypointLayerOnMouseLeave.bind(this);
|
||||
waypointLayerOnClickBinded: (e: MapLayerMouseEvent) => void =
|
||||
waypointLayerOnClickBinded: (e: mapboxgl.MapMouseEvent) => void =
|
||||
this.waypointLayerOnClick.bind(this);
|
||||
waypointLayerOnMouseDownBinded: (e: MapLayerMouseEvent) => void =
|
||||
waypointLayerOnMouseDownBinded: (e: mapboxgl.MapMouseEvent) => void =
|
||||
this.waypointLayerOnMouseDown.bind(this);
|
||||
waypointLayerOnTouchStartBinded: (e: MapLayerTouchEvent) => void =
|
||||
waypointLayerOnTouchStartBinded: (e: mapboxgl.MapTouchEvent) => void =
|
||||
this.waypointLayerOnTouchStart.bind(this);
|
||||
waypointLayerOnMouseMoveBinded: (e: MapLayerMouseEvent | MapLayerTouchEvent) => void =
|
||||
waypointLayerOnMouseMoveBinded: (e: mapboxgl.MapMouseEvent | mapboxgl.MapTouchEvent) => void =
|
||||
this.waypointLayerOnMouseMove.bind(this);
|
||||
waypointLayerOnMouseUpBinded: (e: MapLayerMouseEvent | MapLayerTouchEvent) => void =
|
||||
waypointLayerOnMouseUpBinded: (e: mapboxgl.MapMouseEvent | mapboxgl.MapTouchEvent) => void =
|
||||
this.waypointLayerOnMouseUp.bind(this);
|
||||
|
||||
constructor(fileId: string, file: Readable<GPXFileWithStatistics | undefined>) {
|
||||
@@ -151,7 +145,7 @@ export class GPXLayer {
|
||||
this.unsubscribe.push(
|
||||
map.subscribe(($map) => {
|
||||
if ($map) {
|
||||
$map.on('style.load', this.updateBinded);
|
||||
$map.on('style.import.load', this.updateBinded);
|
||||
this.update();
|
||||
}
|
||||
})
|
||||
@@ -174,9 +168,8 @@ export class GPXLayer {
|
||||
|
||||
update() {
|
||||
const _map = get(map);
|
||||
const layerEventManager = map.layerEventManager;
|
||||
let file = get(this.file)?.file;
|
||||
if (!_map || !layerEventManager || !file) {
|
||||
if (!_map || !file) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -192,7 +185,7 @@ export class GPXLayer {
|
||||
this.loadIcons();
|
||||
|
||||
try {
|
||||
let source = _map.getSource(this.fileId) as GeoJSONSource | undefined;
|
||||
let source = _map.getSource(this.fileId) as mapboxgl.GeoJSONSource | undefined;
|
||||
if (source) {
|
||||
source.setData(this.getGeoJSON());
|
||||
} else {
|
||||
@@ -221,63 +214,15 @@ export class GPXLayer {
|
||||
ANCHOR_LAYER_KEY.tracks
|
||||
);
|
||||
|
||||
layerEventManager.on('click', this.fileId, this.layerOnClickBinded);
|
||||
layerEventManager.on('contextmenu', this.fileId, this.layerOnContextMenuBinded);
|
||||
layerEventManager.on('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
|
||||
layerEventManager.on('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
|
||||
layerEventManager.on('mousemove', this.fileId, this.layerOnMouseMoveBinded);
|
||||
}
|
||||
|
||||
let visibleTrackSegmentIds: string[] = [];
|
||||
file.forEachSegment((segment, trackIndex, segmentIndex) => {
|
||||
if (!segment._data.hidden) {
|
||||
visibleTrackSegmentIds.push(`${trackIndex}-${segmentIndex}`);
|
||||
}
|
||||
});
|
||||
const segmentFilter: FilterSpecification = [
|
||||
'in',
|
||||
['get', 'trackSegmentId'],
|
||||
['literal', visibleTrackSegmentIds],
|
||||
];
|
||||
|
||||
_map.setFilter(this.fileId, segmentFilter, { validate: false });
|
||||
|
||||
if (get(directionMarkers)) {
|
||||
if (!_map.getLayer(this.fileId + '-direction')) {
|
||||
_map.addLayer(
|
||||
{
|
||||
id: this.fileId + '-direction',
|
||||
type: 'symbol',
|
||||
source: this.fileId,
|
||||
layout: {
|
||||
'text-field': '»',
|
||||
'text-offset': [0, -0.06],
|
||||
'text-keep-upright': false,
|
||||
'text-max-angle': 361,
|
||||
'text-allow-overlap': true,
|
||||
'text-font': ['Open Sans Bold'],
|
||||
'symbol-placement': 'line',
|
||||
'symbol-spacing': 20,
|
||||
},
|
||||
paint: {
|
||||
'text-color': 'white',
|
||||
'text-halo-width': 0.2,
|
||||
'text-halo-color': 'white',
|
||||
},
|
||||
},
|
||||
ANCHOR_LAYER_KEY.directionMarkers
|
||||
);
|
||||
}
|
||||
|
||||
_map.setFilter(this.fileId + '-direction', segmentFilter, { validate: false });
|
||||
} else {
|
||||
if (_map.getLayer(this.fileId + '-direction')) {
|
||||
_map.removeLayer(this.fileId + '-direction');
|
||||
}
|
||||
_map.on('click', this.fileId, this.layerOnClickBinded);
|
||||
_map.on('contextmenu', this.fileId, this.layerOnContextMenuBinded);
|
||||
_map.on('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
|
||||
_map.on('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
|
||||
_map.on('mousemove', this.fileId, this.layerOnMouseMoveBinded);
|
||||
}
|
||||
|
||||
let waypointSource = _map.getSource(this.fileId + '-waypoints') as
|
||||
| GeoJSONSource
|
||||
| mapboxgl.GeoJSONSource
|
||||
| undefined;
|
||||
this.currentWaypointData = this.getWaypointsGeoJSON();
|
||||
if (waypointSource) {
|
||||
@@ -286,7 +231,6 @@ export class GPXLayer {
|
||||
_map.addSource(this.fileId + '-waypoints', {
|
||||
type: 'geojson',
|
||||
data: this.currentWaypointData,
|
||||
promoteId: 'waypointIndex',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -307,33 +251,80 @@ export class GPXLayer {
|
||||
ANCHOR_LAYER_KEY.waypoints
|
||||
);
|
||||
|
||||
layerEventManager.on(
|
||||
_map.on(
|
||||
'mouseenter',
|
||||
this.fileId + '-waypoints',
|
||||
this.waypointLayerOnMouseEnterBinded
|
||||
);
|
||||
layerEventManager.on(
|
||||
_map.on(
|
||||
'mouseleave',
|
||||
this.fileId + '-waypoints',
|
||||
this.waypointLayerOnMouseLeaveBinded
|
||||
);
|
||||
layerEventManager.on(
|
||||
'click',
|
||||
this.fileId + '-waypoints',
|
||||
this.waypointLayerOnClickBinded
|
||||
);
|
||||
layerEventManager.on(
|
||||
_map.on('click', this.fileId + '-waypoints', this.waypointLayerOnClickBinded);
|
||||
_map.on(
|
||||
'mousedown',
|
||||
this.fileId + '-waypoints',
|
||||
this.waypointLayerOnMouseDownBinded
|
||||
);
|
||||
layerEventManager.on(
|
||||
_map.on(
|
||||
'touchstart',
|
||||
this.fileId + '-waypoints',
|
||||
this.waypointLayerOnTouchStartBinded
|
||||
);
|
||||
}
|
||||
|
||||
if (get(directionMarkers)) {
|
||||
if (!_map.getLayer(this.fileId + '-direction')) {
|
||||
_map.addLayer(
|
||||
{
|
||||
id: this.fileId + '-direction',
|
||||
type: 'symbol',
|
||||
source: this.fileId,
|
||||
layout: {
|
||||
'text-field': '»',
|
||||
'text-offset': [0, -0.1],
|
||||
'text-keep-upright': false,
|
||||
'text-max-angle': 361,
|
||||
'text-allow-overlap': true,
|
||||
'text-font': ['Open Sans Bold'],
|
||||
'symbol-placement': 'line',
|
||||
'symbol-spacing': 20,
|
||||
},
|
||||
paint: {
|
||||
'text-color': 'white',
|
||||
'text-opacity': 0.7,
|
||||
'text-halo-width': 0.2,
|
||||
'text-halo-color': 'white',
|
||||
},
|
||||
},
|
||||
ANCHOR_LAYER_KEY.directionMarkers
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (_map.getLayer(this.fileId + '-direction')) {
|
||||
_map.removeLayer(this.fileId + '-direction');
|
||||
}
|
||||
}
|
||||
|
||||
let visibleTrackSegmentIds: string[] = [];
|
||||
file.forEachSegment((segment, trackIndex, segmentIndex) => {
|
||||
if (!segment._data.hidden) {
|
||||
visibleTrackSegmentIds.push(`${trackIndex}-${segmentIndex}`);
|
||||
}
|
||||
});
|
||||
const segmentFilter: FilterSpecification = [
|
||||
'in',
|
||||
['get', 'trackSegmentId'],
|
||||
['literal', visibleTrackSegmentIds],
|
||||
];
|
||||
|
||||
_map.setFilter(this.fileId, segmentFilter, { validate: false });
|
||||
|
||||
if (_map.getLayer(this.fileId + '-direction')) {
|
||||
_map.setFilter(this.fileId + '-direction', segmentFilter, { validate: false });
|
||||
}
|
||||
|
||||
let visibleWaypoints: number[] = [];
|
||||
file.wpt.forEach((waypoint, waypointIndex) => {
|
||||
if (!waypoint._data.hidden) {
|
||||
@@ -354,47 +345,32 @@ export class GPXLayer {
|
||||
|
||||
remove() {
|
||||
const _map = get(map);
|
||||
|
||||
if (_map) {
|
||||
_map.off('style.load', this.updateBinded);
|
||||
}
|
||||
_map.off('click', this.fileId, this.layerOnClickBinded);
|
||||
_map.off('contextmenu', this.fileId, this.layerOnContextMenuBinded);
|
||||
_map.off('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
|
||||
_map.off('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
|
||||
_map.off('mousemove', this.fileId, this.layerOnMouseMoveBinded);
|
||||
_map.off('style.import.load', this.updateBinded);
|
||||
|
||||
const layerEventManager = map.layerEventManager;
|
||||
if (layerEventManager) {
|
||||
layerEventManager.off('click', this.fileId, this.layerOnClickBinded);
|
||||
layerEventManager.off('contextmenu', this.fileId, this.layerOnContextMenuBinded);
|
||||
layerEventManager.off('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
|
||||
layerEventManager.off('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
|
||||
layerEventManager.off('mousemove', this.fileId, this.layerOnMouseMoveBinded);
|
||||
|
||||
layerEventManager.off(
|
||||
_map.off(
|
||||
'mouseenter',
|
||||
this.fileId + '-waypoints',
|
||||
this.waypointLayerOnMouseEnterBinded
|
||||
);
|
||||
layerEventManager.off(
|
||||
_map.off(
|
||||
'mouseleave',
|
||||
this.fileId + '-waypoints',
|
||||
this.waypointLayerOnMouseLeaveBinded
|
||||
);
|
||||
layerEventManager.off(
|
||||
'click',
|
||||
this.fileId + '-waypoints',
|
||||
this.waypointLayerOnClickBinded
|
||||
);
|
||||
layerEventManager.off(
|
||||
'mousedown',
|
||||
this.fileId + '-waypoints',
|
||||
this.waypointLayerOnMouseDownBinded
|
||||
);
|
||||
layerEventManager.off(
|
||||
_map.off('click', this.fileId + '-waypoints', this.waypointLayerOnClickBinded);
|
||||
_map.off('mousedown', this.fileId + '-waypoints', this.waypointLayerOnMouseDownBinded);
|
||||
_map.off(
|
||||
'touchstart',
|
||||
this.fileId + '-waypoints',
|
||||
this.waypointLayerOnTouchStartBinded
|
||||
);
|
||||
}
|
||||
|
||||
if (_map) {
|
||||
if (_map.getLayer(this.fileId + '-direction')) {
|
||||
_map.removeLayer(this.fileId + '-direction');
|
||||
}
|
||||
@@ -470,7 +446,7 @@ export class GPXLayer {
|
||||
}
|
||||
}
|
||||
|
||||
layerOnClick(e: MapLayerMouseEvent) {
|
||||
layerOnClick(e: mapboxgl.MapMouseEvent) {
|
||||
if (
|
||||
get(currentTool) === Tool.ROUTING &&
|
||||
get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints'])
|
||||
@@ -528,7 +504,7 @@ export class GPXLayer {
|
||||
}
|
||||
}
|
||||
|
||||
waypointLayerOnMouseEnter(e: MapLayerMouseEvent) {
|
||||
waypointLayerOnMouseEnter(e: mapboxgl.MapMouseEvent) {
|
||||
if (this.draggedWaypointIndex !== null) {
|
||||
return;
|
||||
}
|
||||
@@ -548,7 +524,7 @@ export class GPXLayer {
|
||||
mapCursor.notify(MapCursorState.WAYPOINT_HOVER, false);
|
||||
}
|
||||
|
||||
waypointLayerOnClick(e: MapLayerMouseEvent) {
|
||||
waypointLayerOnClick(e: mapboxgl.MapMouseEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
let waypointIndex = e.features![0].properties!.waypointIndex;
|
||||
@@ -590,7 +566,7 @@ export class GPXLayer {
|
||||
}
|
||||
}
|
||||
|
||||
waypointLayerOnMouseDown(e: MapLayerMouseEvent) {
|
||||
waypointLayerOnMouseDown(e: mapboxgl.MapMouseEvent) {
|
||||
if (get(currentTool) !== Tool.WAYPOINT || !this.selected) {
|
||||
return;
|
||||
}
|
||||
@@ -600,7 +576,6 @@ export class GPXLayer {
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
_map.dragPan.disable();
|
||||
|
||||
this.draggedWaypointIndex = e.features![0].properties!.waypointIndex;
|
||||
this.draggingStartingPosition = e.point;
|
||||
@@ -610,7 +585,7 @@ export class GPXLayer {
|
||||
_map.once('mouseup', this.waypointLayerOnMouseUpBinded);
|
||||
}
|
||||
|
||||
waypointLayerOnTouchStart(e: MapLayerTouchEvent) {
|
||||
waypointLayerOnTouchStart(e: mapboxgl.MapTouchEvent) {
|
||||
if (e.points.length !== 1 || get(currentTool) !== Tool.WAYPOINT || !this.selected) {
|
||||
return;
|
||||
}
|
||||
@@ -624,13 +599,12 @@ export class GPXLayer {
|
||||
waypointPopup?.hide();
|
||||
|
||||
e.preventDefault();
|
||||
_map.dragPan.disable();
|
||||
|
||||
_map.on('touchmove', this.waypointLayerOnMouseMoveBinded);
|
||||
_map.once('touchend', this.waypointLayerOnMouseUpBinded);
|
||||
}
|
||||
|
||||
waypointLayerOnMouseMove(e: MapLayerMouseEvent | MapLayerTouchEvent) {
|
||||
waypointLayerOnMouseMove(e: mapboxgl.MapMouseEvent | mapboxgl.MapTouchEvent) {
|
||||
if (this.draggedWaypointIndex === null || e.point.equals(this.draggingStartingPosition)) {
|
||||
return;
|
||||
}
|
||||
@@ -642,35 +616,18 @@ export class GPXLayer {
|
||||
).coordinates = [e.lngLat.lng, e.lngLat.lat];
|
||||
|
||||
let waypointSource = get(map)?.getSource(this.fileId + '-waypoints') as
|
||||
| GeoJSONSource
|
||||
| mapboxgl.GeoJSONSource
|
||||
| undefined;
|
||||
if (waypointSource) {
|
||||
waypointSource.updateData({
|
||||
update: [
|
||||
{
|
||||
id: this.draggedWaypointIndex,
|
||||
newGeometry: {
|
||||
type: 'Point',
|
||||
coordinates: [e.lngLat.lng, e.lngLat.lat],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
waypointSource.setData(this.currentWaypointData!);
|
||||
}
|
||||
}
|
||||
|
||||
waypointLayerOnMouseUp(e: MapLayerMouseEvent | MapLayerTouchEvent) {
|
||||
waypointLayerOnMouseUp(e: mapboxgl.MapMouseEvent | mapboxgl.MapTouchEvent) {
|
||||
mapCursor.notify(MapCursorState.WAYPOINT_DRAGGING, false);
|
||||
|
||||
const _map = get(map);
|
||||
if (!_map) {
|
||||
return;
|
||||
}
|
||||
|
||||
_map.dragPan.enable();
|
||||
|
||||
_map.off('mousemove', this.waypointLayerOnMouseMoveBinded);
|
||||
_map.off('touchmove', this.waypointLayerOnMouseMoveBinded);
|
||||
get(map)?.off('mousemove', this.waypointLayerOnMouseMoveBinded);
|
||||
get(map)?.off('touchmove', this.waypointLayerOnMouseMoveBinded);
|
||||
|
||||
if (this.draggedWaypointIndex === null) {
|
||||
return;
|
||||
@@ -793,7 +750,20 @@ export class GPXLayer {
|
||||
|
||||
symbols.forEach((symbol) => {
|
||||
const iconId = `waypoint-${symbol ?? 'default'}-${this.layerColor}`;
|
||||
loadSVGIcon(_map, iconId, getSvgForSymbol(symbol, this.layerColor));
|
||||
if (!_map.hasImage(iconId)) {
|
||||
let icon = new Image(100, 100);
|
||||
icon.onload = () => {
|
||||
if (!_map.hasImage(iconId)) {
|
||||
_map.addImage(iconId, icon);
|
||||
}
|
||||
};
|
||||
|
||||
// Lucide icons are SVG files with a 24x24 viewBox
|
||||
// Create a new SVG with a 32x32 viewBox and center the icon in a circle
|
||||
icon.src =
|
||||
'data:image/svg+xml,' +
|
||||
encodeURIComponent(getSvgForSymbol(symbol, this.layerColor));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +1,30 @@
|
||||
import { currentTool, Tool } from '$lib/components/toolbar/tools';
|
||||
import { gpxStatistics, hoveredPoint, slicedGPXStatistics } from '$lib/logic/statistics';
|
||||
import type { GeoJSONSource } from 'maplibre-gl';
|
||||
import { gpxStatistics, slicedGPXStatistics } from '$lib/logic/statistics';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { get } from 'svelte/store';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { allHidden } from '$lib/logic/hidden';
|
||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||
import { loadSVGIcon } from '$lib/utils';
|
||||
|
||||
const startMarkerSVG = `<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="8" cy="8" r="6" fill="#22c55e" stroke="white" stroke-width="1.5"/>
|
||||
</svg>`;
|
||||
|
||||
const endMarkerSVG = `<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<pattern id="checkerboard" x="0" y="0" width="5" height="5" patternUnits="userSpaceOnUse">
|
||||
<rect x="0" y="0" width="2.5" height="2.5" fill="white"/>
|
||||
<rect x="2.5" y="2.5" width="2.5" height="2.5" fill="white"/>
|
||||
<rect x="2.5" y="0" width="2.5" height="2.5" fill="black"/>
|
||||
<rect x="0" y="2.5" width="2.5" height="2.5" fill="black"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<circle cx="8" cy="8" r="6" fill="url(#checkerboard)" stroke="white" stroke-width="1.5"/>
|
||||
</svg>`;
|
||||
const hoverMarkerSVG = `<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="8" cy="8" r="6" fill="#00b8db" stroke="white" stroke-width="1.5"/>
|
||||
</svg>`;
|
||||
|
||||
export class StartEndMarkers {
|
||||
start: mapboxgl.Marker;
|
||||
end: mapboxgl.Marker;
|
||||
updateBinded: () => void = this.update.bind(this);
|
||||
unsubscribes: (() => void)[] = [];
|
||||
|
||||
constructor() {
|
||||
map.onLoad((map_) => map_.on('style.load', this.updateBinded));
|
||||
let startElement = document.createElement('div');
|
||||
let endElement = document.createElement('div');
|
||||
startElement.className = `h-4 w-4 rounded-full bg-green-500 border-2 border-white`;
|
||||
endElement.className = `h-4 w-4 rounded-full border-2 border-white`;
|
||||
endElement.style.background =
|
||||
'repeating-conic-gradient(#fff 0 90deg, #000 0 180deg) 0 0/8px 8px round';
|
||||
|
||||
this.start = new mapboxgl.Marker({ element: startElement });
|
||||
this.end = new mapboxgl.Marker({ element: endElement });
|
||||
|
||||
map.onLoad(() => this.update());
|
||||
this.unsubscribes.push(gpxStatistics.subscribe(this.updateBinded));
|
||||
this.unsubscribes.push(slicedGPXStatistics.subscribe(this.updateBinded));
|
||||
this.unsubscribes.push(hoveredPoint.subscribe(this.updateBinded));
|
||||
this.unsubscribes.push(currentTool.subscribe(this.updateBinded));
|
||||
this.unsubscribes.push(allHidden.subscribe(this.updateBinded));
|
||||
}
|
||||
@@ -43,115 +33,33 @@ export class StartEndMarkers {
|
||||
const map_ = get(map);
|
||||
if (!map_) return;
|
||||
|
||||
this.loadIcons();
|
||||
|
||||
const tool = get(currentTool);
|
||||
const statistics = get(gpxStatistics);
|
||||
const slicedStatistics = get(slicedGPXStatistics);
|
||||
const hovered = get(hoveredPoint);
|
||||
const hidden = get(allHidden);
|
||||
if (!hidden) {
|
||||
const data: GeoJSON.FeatureCollection = {
|
||||
type: 'FeatureCollection',
|
||||
features: [],
|
||||
};
|
||||
|
||||
if (statistics.global.length > 0 && tool !== Tool.ROUTING) {
|
||||
const start = statistics
|
||||
.getTrackPoint(slicedStatistics?.[1] ?? 0)!
|
||||
.trkpt.getCoordinates();
|
||||
const end = statistics
|
||||
.getTrackPoint(slicedStatistics?.[2] ?? statistics.global.length - 1)!
|
||||
.trkpt.getCoordinates();
|
||||
data.features.push({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [start.lon, start.lat],
|
||||
},
|
||||
properties: {
|
||||
icon: 'start-marker',
|
||||
},
|
||||
});
|
||||
data.features.push({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [end.lon, end.lat],
|
||||
},
|
||||
properties: {
|
||||
icon: 'end-marker',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (hovered) {
|
||||
data.features.push({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [hovered.lon, hovered.lat],
|
||||
},
|
||||
properties: {
|
||||
icon: 'hover-marker',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let source = map_.getSource('start-end-markers') as GeoJSONSource | undefined;
|
||||
if (source) {
|
||||
source.setData(data);
|
||||
} else {
|
||||
map_.addSource('start-end-markers', {
|
||||
type: 'geojson',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
if (!map_.getLayer('start-end-markers')) {
|
||||
map_.addLayer(
|
||||
{
|
||||
id: 'start-end-markers',
|
||||
type: 'symbol',
|
||||
source: 'start-end-markers',
|
||||
layout: {
|
||||
'icon-image': ['get', 'icon'],
|
||||
'icon-size': 0.2,
|
||||
'icon-allow-overlap': true,
|
||||
},
|
||||
},
|
||||
ANCHOR_LAYER_KEY.startEndMarkers
|
||||
);
|
||||
}
|
||||
if (statistics.global.length > 0 && tool !== Tool.ROUTING && !hidden) {
|
||||
this.start
|
||||
.setLngLat(
|
||||
statistics.getTrackPoint(slicedStatistics?.[1] ?? 0)!.trkpt.getCoordinates()
|
||||
)
|
||||
.addTo(map_);
|
||||
this.end
|
||||
.setLngLat(
|
||||
statistics
|
||||
.getTrackPoint(slicedStatistics?.[2] ?? statistics.global.length - 1)!
|
||||
.trkpt.getCoordinates()
|
||||
)
|
||||
.addTo(map_);
|
||||
} else {
|
||||
if (map_.getLayer('start-end-markers')) {
|
||||
map_.removeLayer('start-end-markers');
|
||||
}
|
||||
if (map_.getSource('start-end-markers')) {
|
||||
map_.removeSource('start-end-markers');
|
||||
}
|
||||
this.start.remove();
|
||||
this.end.remove();
|
||||
}
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
|
||||
const map_ = get(map);
|
||||
if (!map_) return;
|
||||
|
||||
if (map_.getLayer('start-end-markers')) {
|
||||
map_.removeLayer('start-end-markers');
|
||||
}
|
||||
if (map_.getSource('start-end-markers')) {
|
||||
map_.removeSource('start-end-markers');
|
||||
}
|
||||
}
|
||||
|
||||
loadIcons() {
|
||||
const map_ = get(map);
|
||||
if (!map_) return;
|
||||
loadSVGIcon(map_, 'start-marker', startMarkerSVG);
|
||||
loadSVGIcon(map_, 'end-marker', endMarkerSVG);
|
||||
loadSVGIcon(map_, 'hover-marker', hoverMarkerSVG);
|
||||
this.start.remove();
|
||||
this.end.remove();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,9 @@
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { defaultBasemap, type CustomLayer } from '$lib/assets/layers';
|
||||
import { onMount } from 'svelte';
|
||||
import { remove } from './utils';
|
||||
import { customBasemapUpdate, isSelected, remove } from './utils';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { dndzone } from 'svelte-dnd-action';
|
||||
|
||||
const {
|
||||
@@ -41,8 +42,13 @@
|
||||
let maxZoom: number = $state(20);
|
||||
let layerType: 'basemap' | 'overlay' = $state('basemap');
|
||||
let resourceType: 'raster' | 'vector' = $derived.by(() => {
|
||||
if (tileUrls[0].length > 0 && tileUrls[0].includes('.json')) {
|
||||
return 'vector';
|
||||
if (tileUrls[0].length > 0) {
|
||||
if (
|
||||
tileUrls[0].includes('.json') ||
|
||||
(tileUrls[0].includes('api.mapbox.com/styles') && !tileUrls[0].includes('tiles'))
|
||||
) {
|
||||
return 'vector';
|
||||
}
|
||||
}
|
||||
return 'raster';
|
||||
});
|
||||
@@ -128,8 +134,8 @@
|
||||
],
|
||||
};
|
||||
}
|
||||
addLayer(layerId);
|
||||
$customLayers[layerId] = layer;
|
||||
addLayer(layerId);
|
||||
selectedLayerId = undefined;
|
||||
setDataFromSelectedLayer();
|
||||
}
|
||||
@@ -152,7 +158,9 @@
|
||||
return $tree;
|
||||
});
|
||||
|
||||
if ($currentBasemap !== layerId) {
|
||||
if ($currentBasemap === layerId) {
|
||||
$customBasemapUpdate++;
|
||||
} else {
|
||||
$currentBasemap = layerId;
|
||||
}
|
||||
|
||||
@@ -168,6 +176,14 @@
|
||||
return $tree;
|
||||
});
|
||||
|
||||
if ($map && $currentOverlays && isSelected($currentOverlays, layerId)) {
|
||||
try {
|
||||
$map.removeImport(layerId);
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to remove sources and layers
|
||||
}
|
||||
}
|
||||
|
||||
currentOverlays.update(($overlays) => {
|
||||
if (!$overlays.overlays.hasOwnProperty('custom')) {
|
||||
$overlays.overlays['custom'] = {};
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
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';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { customBasemapUpdate, getLayers } from './utils';
|
||||
import type { ImportSpecification, StyleSpecification } from 'mapbox-gl';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let overpassLayer: OverpassLayer;
|
||||
@@ -19,14 +23,125 @@
|
||||
selectedBasemapTree,
|
||||
selectedOverlayTree,
|
||||
selectedOverpassTree,
|
||||
customLayers,
|
||||
opacities,
|
||||
} = settings;
|
||||
|
||||
map.onLoad((_map: maplibregl.Map) => {
|
||||
function setStyle() {
|
||||
if (!$map) {
|
||||
return;
|
||||
}
|
||||
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',
|
||||
url: '',
|
||||
data: basemap as StyleSpecification,
|
||||
},
|
||||
'overlays'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if ($map && ($currentBasemap || $customBasemapUpdate)) {
|
||||
untrack(() => setStyle());
|
||||
}
|
||||
});
|
||||
|
||||
function addOverlay(id: string) {
|
||||
if (!$map) {
|
||||
return;
|
||||
}
|
||||
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 as StyleSpecification).layers.map((layer) => {
|
||||
if (layer.type === 'raster') {
|
||||
if (!layer.paint) {
|
||||
layer.paint = {};
|
||||
}
|
||||
layer.paint['raster-opacity'] = $opacities[id];
|
||||
}
|
||||
return layer;
|
||||
}),
|
||||
};
|
||||
}
|
||||
$map.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 && $currentOverlays && $opacities) {
|
||||
let overlayLayers = getLayers($currentOverlays);
|
||||
try {
|
||||
let activeOverlays =
|
||||
$map
|
||||
.getStyle()
|
||||
.imports?.reduce(
|
||||
(
|
||||
acc: Record<string, ImportSpecification>,
|
||||
imprt: ImportSpecification
|
||||
) => {
|
||||
if (!['basemap', 'overlays'].includes(imprt.id)) {
|
||||
acc[imprt.id] = imprt;
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if ($map && $currentOverlays && $opacities) {
|
||||
untrack(() => updateOverlays());
|
||||
}
|
||||
});
|
||||
|
||||
map.onLoad((_map: mapboxgl.Map) => {
|
||||
if (overpassLayer) {
|
||||
overpassLayer.remove();
|
||||
}
|
||||
overpassLayer = new OverpassLayer(_map, map.layerEventManager!);
|
||||
overpassLayer = new OverpassLayer(_map);
|
||||
overpassLayer.add();
|
||||
let first = true;
|
||||
_map.on('style.import.load', () => {
|
||||
if (!first) return;
|
||||
first = false;
|
||||
updateOverlays();
|
||||
});
|
||||
});
|
||||
|
||||
let open = $state(false);
|
||||
|
||||
@@ -167,11 +167,11 @@
|
||||
{#if isSelected($selectedOverlayTree, selectedOverlay)}
|
||||
{#if $isLayerFromExtension(selectedOverlay)}
|
||||
{$getLayerName(selectedOverlay)}
|
||||
{:else if $customLayers.hasOwnProperty(selectedOverlay)}
|
||||
{$customLayers[selectedOverlay].name}
|
||||
{:else}
|
||||
{i18n._(`layers.label.${selectedOverlay}`)}
|
||||
{/if}
|
||||
{:else if $customLayers.hasOwnProperty(selectedOverlay)}
|
||||
{$customLayers[selectedOverlay].name}
|
||||
{/if}
|
||||
{/if}
|
||||
</Select.Trigger>
|
||||
@@ -213,9 +213,7 @@
|
||||
isSelected($currentOverlays, selectedOverlay)
|
||||
) {
|
||||
try {
|
||||
if ($map.getLayer(selectedOverlay)) {
|
||||
$map.removeLayer(selectedOverlay);
|
||||
}
|
||||
$map.removeImport(selectedOverlay);
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to remove sources and layers
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ export class ExtensionAPI {
|
||||
if (current && isSelected(current, overlay.id)) {
|
||||
show = true;
|
||||
try {
|
||||
get(map)?.removeLayer(overlay.id);
|
||||
get(map)?.removeImport(overlay.id);
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to remove sources and layers
|
||||
}
|
||||
|
||||
@@ -6,10 +6,7 @@ import { overpassQueryData } from '$lib/assets/layers';
|
||||
import { MapPopup } from '$lib/components/map/map-popup';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { db } from '$lib/db';
|
||||
import type { GeoJSONSource } from 'maplibre-gl';
|
||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
|
||||
import { loadSVGIcon } from '$lib/utils';
|
||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/map';
|
||||
|
||||
const { currentOverpassQueries } = settings;
|
||||
|
||||
@@ -28,8 +25,7 @@ export class OverpassLayer {
|
||||
minZoom = 12;
|
||||
queryZoom = 12;
|
||||
expirationTime = 7 * 24 * 3600 * 1000;
|
||||
map: maplibregl.Map;
|
||||
layerEventManager: MapLayerEventManager;
|
||||
map: mapboxgl.Map;
|
||||
popup: MapPopup;
|
||||
|
||||
currentQueries: Set<string> = new Set();
|
||||
@@ -40,9 +36,8 @@ export class OverpassLayer {
|
||||
updateBinded = this.update.bind(this);
|
||||
onHoverBinded = this.onHover.bind(this);
|
||||
|
||||
constructor(map: maplibregl.Map, layerEventManager: MapLayerEventManager) {
|
||||
constructor(map: mapboxgl.Map) {
|
||||
this.map = map;
|
||||
this.layerEventManager = layerEventManager;
|
||||
this.popup = new MapPopup(map, {
|
||||
closeButton: false,
|
||||
focusAfterOpen: false,
|
||||
@@ -53,7 +48,7 @@ export class OverpassLayer {
|
||||
|
||||
add() {
|
||||
this.map.on('moveend', this.queryIfNeededBinded);
|
||||
this.map.on('style.load', this.updateBinded);
|
||||
this.map.on('style.import.load', this.updateBinded);
|
||||
this.unsubscribes.push(data.subscribe(this.updateBinded));
|
||||
this.unsubscribes.push(
|
||||
currentOverpassQueries.subscribe(() => {
|
||||
@@ -77,17 +72,10 @@ export class OverpassLayer {
|
||||
update() {
|
||||
this.loadIcons();
|
||||
|
||||
const fullData = get(data);
|
||||
const queries = getCurrentQueries();
|
||||
const d: GeoJSON.FeatureCollection = {
|
||||
type: 'FeatureCollection',
|
||||
features: fullData.features.filter((feature) =>
|
||||
queries.includes(feature.properties!.query)
|
||||
),
|
||||
};
|
||||
let d = get(data);
|
||||
|
||||
try {
|
||||
let source = this.map.getSource('overpass') as GeoJSONSource | undefined;
|
||||
let source = this.map.getSource('overpass') as mapboxgl.GeoJSONSource | undefined;
|
||||
if (source) {
|
||||
source.setData(d);
|
||||
} else {
|
||||
@@ -113,9 +101,13 @@ export class OverpassLayer {
|
||||
ANCHOR_LAYER_KEY.overpass
|
||||
);
|
||||
|
||||
this.layerEventManager.on('mouseenter', 'overpass', this.onHoverBinded);
|
||||
this.layerEventManager.on('click', 'overpass', this.onHoverBinded);
|
||||
this.map.on('mouseenter', 'overpass', this.onHoverBinded);
|
||||
this.map.on('click', 'overpass', this.onHoverBinded);
|
||||
}
|
||||
|
||||
this.map.setFilter('overpass', ['in', 'query', ...getCurrentQueries()], {
|
||||
validate: false,
|
||||
});
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to add sources and layers
|
||||
}
|
||||
@@ -123,9 +115,7 @@ export class OverpassLayer {
|
||||
|
||||
remove() {
|
||||
this.map.off('moveend', this.queryIfNeededBinded);
|
||||
this.map.off('style.load', this.updateBinded);
|
||||
this.layerEventManager.off('mouseenter', 'overpass', this.onHoverBinded);
|
||||
this.layerEventManager.off('click', 'overpass', this.onHoverBinded);
|
||||
this.map.off('style.import.load', this.updateBinded);
|
||||
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
|
||||
try {
|
||||
@@ -258,16 +248,27 @@ export class OverpassLayer {
|
||||
loadIcons() {
|
||||
let currentQueries = getCurrentQueries();
|
||||
currentQueries.forEach((query) => {
|
||||
loadSVGIcon(
|
||||
this.map,
|
||||
`overpass-${query}`,
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
|
||||
if (!this.map.hasImage(`overpass-${query}`)) {
|
||||
let icon = new Image(100, 100);
|
||||
icon.onload = () => {
|
||||
if (!this.map.hasImage(`overpass-${query}`)) {
|
||||
this.map.addImage(`overpass-${query}`, icon);
|
||||
}
|
||||
};
|
||||
|
||||
// Lucide icons are SVG files with a 24x24 viewBox
|
||||
// Create a new SVG with a 32x32 viewBox and center the icon in a circle
|
||||
icon.src =
|
||||
'data:image/svg+xml,' +
|
||||
encodeURIComponent(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
|
||||
<circle cx="20" cy="20" r="20" fill="${overpassQueryData[query].icon.color}" />
|
||||
<g transform="translate(8 8)">
|
||||
${overpassQueryData[query].icon.svg.replace('stroke="currentColor"', 'stroke="white"')}
|
||||
</g>
|
||||
</svg>`
|
||||
);
|
||||
</svg>
|
||||
`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { LayerTreeType } from '$lib/assets/layers';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export function anySelectedLayer(node: LayerTreeType) {
|
||||
return (
|
||||
@@ -75,3 +76,5 @@ export function removeAll(node: LayerTreeType, ids: string[]) {
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
export const customBasemapUpdate = writable(0);
|
||||
|
||||
@@ -1,281 +0,0 @@
|
||||
import { fileStateCollection } from '$lib/logic/file-state';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
|
||||
type MapLayerMouseEventListener = (e: maplibregl.MapLayerMouseEvent) => void;
|
||||
type MapLayerTouchEventListener = (e: maplibregl.MapLayerTouchEvent) => void;
|
||||
type MapLayerListener = {
|
||||
features: maplibregl.MapGeoJSONFeature[];
|
||||
mousemoves: MapLayerMouseEventListener[];
|
||||
mouseenters: MapLayerMouseEventListener[];
|
||||
mouseleaves: MapLayerMouseEventListener[];
|
||||
mousedowns: MapLayerMouseEventListener[];
|
||||
clicks: MapLayerMouseEventListener[];
|
||||
contextmenus: MapLayerMouseEventListener[];
|
||||
touchstarts: MapLayerTouchEventListener[];
|
||||
};
|
||||
|
||||
export class MapLayerEventManager {
|
||||
private _map: maplibregl.Map;
|
||||
private _listeners: Record<string, MapLayerListener> = {};
|
||||
|
||||
constructor(map: maplibregl.Map) {
|
||||
this._map = map;
|
||||
this._map.on('mousemove', this._handleMouseMove.bind(this));
|
||||
this._map.on('click', this._handleMouseClick.bind(this, 'click'));
|
||||
this._map.on('contextmenu', this._handleMouseClick.bind(this, 'contextmenu'));
|
||||
this._map.on('mousedown', this._handleMouseClick.bind(this, 'mousedown'));
|
||||
this._map.on('touchstart', this._handleTouchStart.bind(this));
|
||||
}
|
||||
|
||||
on(
|
||||
eventType:
|
||||
| 'mousemove'
|
||||
| 'mouseenter'
|
||||
| 'mouseleave'
|
||||
| 'mousedown'
|
||||
| 'click'
|
||||
| 'contextmenu'
|
||||
| 'touchstart',
|
||||
|
||||
layerId: string,
|
||||
listener: MapLayerMouseEventListener | MapLayerTouchEventListener
|
||||
) {
|
||||
if (!this._listeners[layerId]) {
|
||||
this._listeners[layerId] = {
|
||||
features: [],
|
||||
mousemoves: [],
|
||||
mouseenters: [],
|
||||
mouseleaves: [],
|
||||
mousedowns: [],
|
||||
clicks: [],
|
||||
contextmenus: [],
|
||||
touchstarts: [],
|
||||
};
|
||||
}
|
||||
switch (eventType) {
|
||||
case 'mousemove':
|
||||
this._listeners[layerId].mousemoves.push(listener as MapLayerMouseEventListener);
|
||||
break;
|
||||
case 'mouseenter':
|
||||
this._listeners[layerId].mouseenters.push(listener as MapLayerMouseEventListener);
|
||||
break;
|
||||
case 'mouseleave':
|
||||
this._listeners[layerId].mouseleaves.push(listener as MapLayerMouseEventListener);
|
||||
break;
|
||||
case 'mousedown':
|
||||
this._listeners[layerId].mousedowns.push(listener as MapLayerMouseEventListener);
|
||||
break;
|
||||
case 'click':
|
||||
this._listeners[layerId].clicks.push(listener as MapLayerMouseEventListener);
|
||||
break;
|
||||
case 'contextmenu':
|
||||
this._listeners[layerId].contextmenus.push(listener as MapLayerMouseEventListener);
|
||||
break;
|
||||
case 'touchstart':
|
||||
this._listeners[layerId].touchstarts.push(listener as MapLayerTouchEventListener);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
off(
|
||||
eventType:
|
||||
| 'mousemove'
|
||||
| 'mouseenter'
|
||||
| 'mouseleave'
|
||||
| 'mousedown'
|
||||
| 'click'
|
||||
| 'contextmenu'
|
||||
| 'touchstart',
|
||||
layerId: string,
|
||||
listener: MapLayerMouseEventListener | MapLayerTouchEventListener
|
||||
) {
|
||||
if (this._listeners[layerId]) {
|
||||
switch (eventType) {
|
||||
case 'mousemove':
|
||||
this._listeners[layerId].mousemoves = this._listeners[
|
||||
layerId
|
||||
].mousemoves.filter((l) => l !== listener);
|
||||
break;
|
||||
case 'mouseenter':
|
||||
this._listeners[layerId].mouseenters = this._listeners[
|
||||
layerId
|
||||
].mouseenters.filter((l) => l !== listener);
|
||||
break;
|
||||
case 'mouseleave':
|
||||
this._listeners[layerId].mouseleaves = this._listeners[
|
||||
layerId
|
||||
].mouseleaves.filter((l) => l !== listener);
|
||||
break;
|
||||
case 'mousedown':
|
||||
this._listeners[layerId].mousedowns = this._listeners[
|
||||
layerId
|
||||
].mousedowns.filter((l) => l !== listener);
|
||||
break;
|
||||
case 'click':
|
||||
this._listeners[layerId].clicks = this._listeners[layerId].clicks.filter(
|
||||
(l) => l !== listener
|
||||
);
|
||||
break;
|
||||
case 'contextmenu':
|
||||
this._listeners[layerId].contextmenus = this._listeners[
|
||||
layerId
|
||||
].contextmenus.filter((l) => l !== listener);
|
||||
break;
|
||||
case 'touchstart':
|
||||
this._listeners[layerId].touchstarts = this._listeners[
|
||||
layerId
|
||||
].touchstarts.filter((l) => l !== listener);
|
||||
break;
|
||||
}
|
||||
if (
|
||||
this._listeners[layerId].mousemoves.length === 0 &&
|
||||
this._listeners[layerId].mouseenters.length === 0 &&
|
||||
this._listeners[layerId].mouseleaves.length === 0 &&
|
||||
this._listeners[layerId].mousedowns.length === 0 &&
|
||||
this._listeners[layerId].clicks.length === 0 &&
|
||||
this._listeners[layerId].contextmenus.length === 0 &&
|
||||
this._listeners[layerId].touchstarts.length === 0
|
||||
) {
|
||||
delete this._listeners[layerId];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _handleMouseMove(e: maplibregl.MapMouseEvent) {
|
||||
const featuresByLayer = this._getRenderedFeaturesByLayer(e);
|
||||
Object.keys(this._listeners).forEach((layerId) => {
|
||||
const features = featuresByLayer[layerId] || [];
|
||||
const listener = this._listeners[layerId];
|
||||
if ((features.length == 0) != (listener.features.length == 0)) {
|
||||
if (features.length > 0) {
|
||||
if (listener.mouseenters.length > 0) {
|
||||
const event = new maplibregl.MapMouseEvent(
|
||||
'mouseenter',
|
||||
e.target,
|
||||
e.originalEvent,
|
||||
{
|
||||
features: featuresByLayer[layerId]!,
|
||||
}
|
||||
);
|
||||
listener.mouseenters.forEach((l) => l(event));
|
||||
}
|
||||
} else {
|
||||
if (listener.mouseleaves.length > 0) {
|
||||
const event = new maplibregl.MapMouseEvent(
|
||||
'mouseleave',
|
||||
e.target,
|
||||
e.originalEvent
|
||||
);
|
||||
listener.mouseleaves.forEach((l) => l(event));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (features.length > 0 && listener.mousemoves.length > 0) {
|
||||
const event = new maplibregl.MapMouseEvent('mousemove', e.target, e.originalEvent, {
|
||||
features: featuresByLayer[layerId]!,
|
||||
});
|
||||
listener.mousemoves.forEach((l) => l(event));
|
||||
}
|
||||
listener.features = features;
|
||||
});
|
||||
}
|
||||
|
||||
private _handleMouseClick(type: string, e: maplibregl.MapMouseEvent) {
|
||||
const featuresByLayer = this._getRenderedFeaturesByLayer(e);
|
||||
Object.keys(this._listeners).forEach((layerId) => {
|
||||
const features = featuresByLayer[layerId] || [];
|
||||
const listener = this._listeners[layerId];
|
||||
if (features.length > 0) {
|
||||
if (type === 'click' && listener.clicks.length > 0) {
|
||||
const event = new maplibregl.MapMouseEvent('click', e.target, e.originalEvent, {
|
||||
features: features,
|
||||
});
|
||||
listener.clicks.forEach((l) => l(event));
|
||||
} else if (type === 'contextmenu' && listener.contextmenus.length > 0) {
|
||||
const event = new maplibregl.MapMouseEvent(
|
||||
'contextmenu',
|
||||
e.target,
|
||||
e.originalEvent,
|
||||
{
|
||||
features: features,
|
||||
}
|
||||
);
|
||||
listener.contextmenus.forEach((l) => l(event));
|
||||
} else if (type === 'mousedown' && listener.mousedowns.length > 0) {
|
||||
const event = new maplibregl.MapMouseEvent(
|
||||
'mousedown',
|
||||
e.target,
|
||||
e.originalEvent,
|
||||
{
|
||||
features: features,
|
||||
}
|
||||
);
|
||||
listener.mousedowns.forEach((l) => l(event));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _handleTouchStart(e: maplibregl.MapTouchEvent) {
|
||||
const featuresByLayer = this._getRenderedFeaturesByLayer(e);
|
||||
Object.keys(this._listeners).forEach((layerId) => {
|
||||
const features = featuresByLayer[layerId] || [];
|
||||
const listener = this._listeners[layerId];
|
||||
if (features.length > 0) {
|
||||
const event: maplibregl.MapLayerTouchEvent = new maplibregl.MapTouchEvent(
|
||||
'touchstart',
|
||||
e.target,
|
||||
e.originalEvent
|
||||
);
|
||||
event.features = featuresByLayer[layerId]!;
|
||||
listener.touchstarts.forEach((l) => l(event));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _getBounds(point: maplibregl.Point) {
|
||||
const delta = 30;
|
||||
return new maplibregl.LngLatBounds(
|
||||
this._map.unproject([point.x - delta, point.y + delta]),
|
||||
this._map.unproject([point.x + delta, point.y - delta])
|
||||
);
|
||||
}
|
||||
|
||||
private _filterLayersIntersectingBounds(
|
||||
layerIds: string[],
|
||||
bounds: maplibregl.LngLatBounds
|
||||
): string[] {
|
||||
let result = layerIds.filter((layerId) => {
|
||||
if (!this._map.getLayer(layerId)) return false;
|
||||
const fileId = layerId.replace('-waypoints', '');
|
||||
if (fileId === layerId) {
|
||||
return fileStateCollection.getStatistics(fileId)?.intersectsBBox(bounds) ?? true;
|
||||
} else {
|
||||
return (
|
||||
fileStateCollection.getStatistics(fileId)?.intersectsWaypointBBox(bounds) ??
|
||||
true
|
||||
);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private _getRenderedFeaturesByLayer(e: maplibregl.MapMouseEvent | maplibregl.MapTouchEvent) {
|
||||
const layerIds = this._filterLayersIntersectingBounds(
|
||||
Object.keys(this._listeners),
|
||||
this._getBounds(e.point)
|
||||
);
|
||||
const features =
|
||||
layerIds.length > 0
|
||||
? this._map.queryRenderedFeatures(e.point, { layers: layerIds })
|
||||
: [];
|
||||
const featuresByLayer: Record<string, maplibregl.MapGeoJSONFeature[]> = {};
|
||||
features.forEach((f) => {
|
||||
if (!featuresByLayer[f.layer.id]) {
|
||||
featuresByLayer[f.layer.id] = [];
|
||||
}
|
||||
featuresByLayer[f.layer.id].push(f);
|
||||
});
|
||||
return featuresByLayer;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TrackPoint, Waypoint } from 'gpx';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { mount, tick, unmount } from 'svelte';
|
||||
import { get, writable, type Writable } from 'svelte/store';
|
||||
import MapPopupComponent from '$lib/components/map/MapPopup.svelte';
|
||||
@@ -11,15 +11,15 @@ export type PopupItem<T = Waypoint | TrackPoint | any> = {
|
||||
};
|
||||
|
||||
export class MapPopup {
|
||||
map: maplibregl.Map;
|
||||
popup: maplibregl.Popup;
|
||||
map: mapboxgl.Map;
|
||||
popup: mapboxgl.Popup;
|
||||
item: Writable<PopupItem | null> = writable(null);
|
||||
component: ReturnType<typeof mount>;
|
||||
maybeHideBinded = this.maybeHide.bind(this);
|
||||
|
||||
constructor(map: maplibregl.Map, options?: maplibregl.PopupOptions) {
|
||||
constructor(map: mapboxgl.Map, options?: mapboxgl.PopupOptions) {
|
||||
this.map = map;
|
||||
this.popup = new maplibregl.Popup(options);
|
||||
this.popup = new mapboxgl.Popup(options);
|
||||
this.component = mount(MapPopupComponent, {
|
||||
target: document.body,
|
||||
props: {
|
||||
@@ -51,7 +51,7 @@ export class MapPopup {
|
||||
this.map.on('mousemove', this.maybeHideBinded);
|
||||
}
|
||||
|
||||
maybeHide(e: maplibregl.MapMouseEvent) {
|
||||
maybeHide(e: mapboxgl.MapMouseEvent) {
|
||||
const item = get(this.item);
|
||||
if (item === null) {
|
||||
this.hide();
|
||||
@@ -75,10 +75,10 @@ export class MapPopup {
|
||||
getCoordinates() {
|
||||
const item = get(this.item);
|
||||
if (item === null) {
|
||||
return new maplibregl.LngLat(0, 0);
|
||||
return new mapboxgl.LngLat(0, 0);
|
||||
}
|
||||
return item.item instanceof Waypoint || item.item instanceof TrackPoint
|
||||
? item.item.getCoordinates()
|
||||
: new maplibregl.LngLat(item.item.lon, item.item.lat);
|
||||
: new mapboxgl.LngLat(item.item.lon, item.item.lat);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,80 +1,110 @@
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import MaplibreGeocoder, {
|
||||
type MaplibreGeocoderFeatureResults,
|
||||
} from '@maplibre/maplibre-gl-geocoder';
|
||||
import '@maplibre/maplibre-gl-geocoder/dist/maplibre-gl-geocoder.css';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder';
|
||||
import { get, writable, type Writable } from 'svelte/store';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { tick } from 'svelte';
|
||||
import { ANCHOR_LAYER_KEY, StyleManager } from '$lib/components/map/style';
|
||||
import { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
|
||||
import { terrainSources } from '$lib/assets/layers';
|
||||
|
||||
const { treeFileView, elevationProfile, bottomPanelSize, rightPanelSize, distanceUnits } = settings;
|
||||
const {
|
||||
treeFileView,
|
||||
elevationProfile,
|
||||
bottomPanelSize,
|
||||
rightPanelSize,
|
||||
distanceUnits,
|
||||
terrainSource,
|
||||
} = settings;
|
||||
|
||||
let fitBoundsOptions: maplibregl.MapOptions['fitBoundsOptions'] = {
|
||||
let fitBoundsOptions: mapboxgl.MapOptions['fitBoundsOptions'] = {
|
||||
maxZoom: 15,
|
||||
linear: true,
|
||||
easing: () => 1,
|
||||
};
|
||||
|
||||
export class MapLibreGLMap {
|
||||
private _maptilerKey: string = '';
|
||||
private _map: maplibregl.Map | null = null;
|
||||
private _mapStore: Writable<maplibregl.Map | null> = writable(null);
|
||||
private _styleManager: StyleManager | null = null;
|
||||
private _onLoadCallbacks: ((map: maplibregl.Map) => void)[] = [];
|
||||
private _unsubscribes: (() => void)[] = [];
|
||||
private callOnLoadBinded: () => void = this.callOnLoad.bind(this);
|
||||
public layerEventManager: MapLayerEventManager | null = null;
|
||||
const emptySource: mapboxgl.GeoJSONSourceSpecification = {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'FeatureCollection',
|
||||
features: [],
|
||||
},
|
||||
};
|
||||
export const ANCHOR_LAYER_KEY = {
|
||||
mapillary: 'mapillary-end',
|
||||
tracks: 'tracks-end',
|
||||
directionMarkers: 'direction-markers-end',
|
||||
distanceMarkers: 'distance-markers-end',
|
||||
interactions: 'interactions-end',
|
||||
overpass: 'overpass-end',
|
||||
waypoints: 'waypoints-end',
|
||||
};
|
||||
const anchorLayers: mapboxgl.LayerSpecification[] = Object.values(ANCHOR_LAYER_KEY).map((id) => ({
|
||||
id: id,
|
||||
type: 'symbol',
|
||||
source: 'empty-source',
|
||||
}));
|
||||
|
||||
subscribe(run: (value: maplibregl.Map | null) => void, invalidate?: () => void) {
|
||||
return this._mapStore.subscribe(run, invalidate);
|
||||
export class MapboxGLMap {
|
||||
private _map: Writable<mapboxgl.Map | null> = writable(null);
|
||||
private _onLoadCallbacks: ((map: mapboxgl.Map) => void)[] = [];
|
||||
private _unsubscribes: (() => void)[] = [];
|
||||
|
||||
subscribe(run: (value: mapboxgl.Map | null) => void, invalidate?: () => void) {
|
||||
return this._map.subscribe(run, invalidate);
|
||||
}
|
||||
|
||||
init(
|
||||
maptilerKey: string,
|
||||
language: string,
|
||||
hash: boolean,
|
||||
geocoder: boolean,
|
||||
geolocate: boolean
|
||||
) {
|
||||
this._maptilerKey = maptilerKey;
|
||||
this._styleManager = new StyleManager(this._mapStore, this._maptilerKey);
|
||||
const map = new maplibregl.Map({
|
||||
init(language: string, hash: boolean, geocoder: boolean, geolocate: boolean) {
|
||||
const map = new mapboxgl.Map({
|
||||
container: 'map',
|
||||
style: {
|
||||
version: 8,
|
||||
projection: {
|
||||
type: 'globe',
|
||||
sources: {
|
||||
'empty-source': emptySource,
|
||||
},
|
||||
sources: {},
|
||||
layers: [],
|
||||
layers: anchorLayers,
|
||||
imports: [
|
||||
{
|
||||
id: 'basemap',
|
||||
url: '',
|
||||
},
|
||||
{
|
||||
id: 'overlays',
|
||||
url: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
projection: 'globe',
|
||||
zoom: 0,
|
||||
hash: hash,
|
||||
language,
|
||||
attributionControl: false,
|
||||
logoPosition: 'bottom-right',
|
||||
boxZoom: false,
|
||||
maxPitch: 90,
|
||||
});
|
||||
this.layerEventManager = new MapLayerEventManager(map);
|
||||
map.addControl(
|
||||
new maplibregl.NavigationControl({
|
||||
new mapboxgl.AttributionControl({
|
||||
compact: true,
|
||||
})
|
||||
);
|
||||
map.addControl(
|
||||
new mapboxgl.NavigationControl({
|
||||
visualizePitch: true,
|
||||
})
|
||||
);
|
||||
if (geocoder) {
|
||||
let geocoder = new MaplibreGeocoder(
|
||||
{
|
||||
forwardGeocode: async (config) => {
|
||||
const results: MaplibreGeocoderFeatureResults = {
|
||||
features: [],
|
||||
type: 'FeatureCollection',
|
||||
};
|
||||
try {
|
||||
const request = `https://nominatim.openstreetmap.org/search?format=json&q=${config.query}&limit=5&accept-language=${language}`;
|
||||
const response = await fetch(request);
|
||||
const geojson = await response.json();
|
||||
results.features = geojson.map((result: any) => {
|
||||
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: {
|
||||
@@ -84,43 +114,61 @@ export class MapLibreGLMap {
|
||||
place_name: result.display_name,
|
||||
};
|
||||
});
|
||||
} catch (e) {}
|
||||
return results;
|
||||
},
|
||||
},
|
||||
{
|
||||
maplibregl: maplibregl,
|
||||
enableEventLogging: false,
|
||||
collapsed: true,
|
||||
flyTo: fitBoundsOptions,
|
||||
language,
|
||||
}),
|
||||
});
|
||||
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 maplibregl.GeolocateControl({
|
||||
new mapboxgl.GeolocateControl({
|
||||
positionOptions: {
|
||||
enableHighAccuracy: true,
|
||||
},
|
||||
fitBoundsOptions,
|
||||
trackUserLocation: true,
|
||||
showUserHeading: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
const scaleControl = new maplibregl.ScaleControl({
|
||||
const scaleControl = new mapboxgl.ScaleControl({
|
||||
unit: get(distanceUnits),
|
||||
});
|
||||
map.addControl(scaleControl);
|
||||
map.on('style.load', () => {
|
||||
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', this.setTerrain.bind(this));
|
||||
this.setTerrain();
|
||||
});
|
||||
map.on('style.import.load', () => {
|
||||
const basemap = map.getStyle().imports?.find((imprt) => imprt.id === 'basemap');
|
||||
if (basemap && basemap.data && basemap.data.glyphs) {
|
||||
map.setGlyphsUrl(basemap.data.glyphs);
|
||||
}
|
||||
});
|
||||
map.on('load', () => {
|
||||
this._map = map;
|
||||
this._mapStore.set(map); // only set the store after the map has loaded
|
||||
this._map.set(map); // only set the store after the map has loaded
|
||||
window._map = map; // entry point for extensions
|
||||
this.resize();
|
||||
this.setTerrain();
|
||||
scaleControl.setUnit(get(distanceUnits));
|
||||
|
||||
this._onLoadCallbacks.forEach((callback) => callback(map));
|
||||
this._onLoadCallbacks = [];
|
||||
});
|
||||
map.on('style.load', this.callOnLoadBinded);
|
||||
|
||||
this._unsubscribes.push(treeFileView.subscribe(() => this.resize()));
|
||||
this._unsubscribes.push(elevationProfile.subscribe(() => this.resize()));
|
||||
@@ -131,50 +179,70 @@ export class MapLibreGLMap {
|
||||
scaleControl.setUnit(units);
|
||||
})
|
||||
);
|
||||
this._unsubscribes.push(terrainSource.subscribe(() => this.setTerrain()));
|
||||
}
|
||||
|
||||
onLoad(callback: (map: mapboxgl.Map) => void) {
|
||||
const map = get(this._map);
|
||||
if (map) {
|
||||
callback(map);
|
||||
} else {
|
||||
this._onLoadCallbacks.push(callback);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._map) {
|
||||
this._map.remove();
|
||||
this._mapStore.set(null);
|
||||
const map = get(this._map);
|
||||
if (map) {
|
||||
map.remove();
|
||||
this._map.set(null);
|
||||
}
|
||||
this._unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
this._unsubscribes = [];
|
||||
}
|
||||
|
||||
resize() {
|
||||
if (this._map) {
|
||||
const map = get(this._map);
|
||||
if (map) {
|
||||
tick().then(() => {
|
||||
this._map?.resize();
|
||||
map.resize();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
toggle3D() {
|
||||
if (this._map) {
|
||||
if (this._map.getPitch() === 0) {
|
||||
this._map.easeTo({ pitch: 70 });
|
||||
const map = get(this._map);
|
||||
if (map) {
|
||||
if (map.getPitch() === 0) {
|
||||
map.easeTo({ pitch: 70 });
|
||||
} else {
|
||||
this._map.easeTo({ pitch: 0 });
|
||||
map.easeTo({ pitch: 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onLoad(callback: (map: maplibregl.Map) => void) {
|
||||
if (this._map) {
|
||||
callback(this._map);
|
||||
} else {
|
||||
this._onLoadCallbacks.push(callback);
|
||||
}
|
||||
}
|
||||
|
||||
callOnLoad() {
|
||||
if (this._map && this._map.getLayer(ANCHOR_LAYER_KEY.overlays)) {
|
||||
this._onLoadCallbacks.forEach((callback) => callback(this._map!));
|
||||
this._onLoadCallbacks = [];
|
||||
this._map.off('style.load', this.callOnLoadBinded);
|
||||
setTerrain() {
|
||||
const map = get(this._map);
|
||||
if (map) {
|
||||
const source = get(terrainSource);
|
||||
try {
|
||||
if (!map.getSource(source)) {
|
||||
map.addSource(source, terrainSources[source]);
|
||||
}
|
||||
if (map.getPitch() > 0) {
|
||||
map.setTerrain({
|
||||
source: source,
|
||||
exaggeration: 1,
|
||||
});
|
||||
} else {
|
||||
map.setTerrain(null);
|
||||
}
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to add sources and layers
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const map = new MapLibreGLMap();
|
||||
export const map = new MapboxGLMap();
|
||||
|
||||
@@ -20,14 +20,9 @@
|
||||
let container: HTMLElement;
|
||||
|
||||
onMount(() => {
|
||||
map.onLoad((map_: maplibregl.Map) => {
|
||||
googleRedirect = new GoogleRedirect(map_);
|
||||
mapillaryLayer = new MapillaryLayer(
|
||||
map_,
|
||||
map.layerEventManager!,
|
||||
container,
|
||||
mapillaryOpen
|
||||
);
|
||||
map.onLoad((map: mapboxgl.Map) => {
|
||||
googleRedirect = new GoogleRedirect(map);
|
||||
mapillaryLayer = new MapillaryLayer(map, container, mapillaryOpen);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
||||
import type mapboxgl from 'mapbox-gl';
|
||||
|
||||
export class GoogleRedirect {
|
||||
map: maplibregl.Map;
|
||||
map: mapboxgl.Map;
|
||||
enabled = false;
|
||||
|
||||
constructor(map: maplibregl.Map) {
|
||||
constructor(map: mapboxgl.Map) {
|
||||
this.map = map;
|
||||
}
|
||||
|
||||
@@ -24,7 +25,7 @@ export class GoogleRedirect {
|
||||
this.map.off('click', this.openStreetView);
|
||||
}
|
||||
|
||||
openStreetView(e: maplibregl.MapMouseEvent) {
|
||||
openStreetView(e: mapboxgl.MapMouseEvent) {
|
||||
window.open(
|
||||
`https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=${e.lngLat.lat},${e.lngLat.lng}`
|
||||
);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import maplibregl, { type LayerSpecification, type VectorSourceSpecification } from 'maplibre-gl';
|
||||
import mapboxgl, { type LayerSpecification, type VectorSourceSpecification } from 'mapbox-gl';
|
||||
import { Viewer, type ViewerBearingEvent } from 'mapillary-js/dist/mapillary.module';
|
||||
import 'mapillary-js/dist/mapillary.css';
|
||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
|
||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/map';
|
||||
|
||||
const mapillarySource: VectorSourceSpecification = {
|
||||
type: 'vector',
|
||||
@@ -43,9 +42,8 @@ const mapillaryImageLayer: LayerSpecification = {
|
||||
};
|
||||
|
||||
export class MapillaryLayer {
|
||||
map: maplibregl.Map;
|
||||
layerEventManager: MapLayerEventManager;
|
||||
marker: maplibregl.Marker;
|
||||
map: mapboxgl.Map;
|
||||
marker: mapboxgl.Marker;
|
||||
viewer: Viewer;
|
||||
|
||||
active = false;
|
||||
@@ -55,14 +53,8 @@ export class MapillaryLayer {
|
||||
onMouseEnterBinded = this.onMouseEnter.bind(this);
|
||||
onMouseLeaveBinded = this.onMouseLeave.bind(this);
|
||||
|
||||
constructor(
|
||||
map: maplibregl.Map,
|
||||
layerEventManager: MapLayerEventManager,
|
||||
container: HTMLElement,
|
||||
popupOpen: { value: boolean }
|
||||
) {
|
||||
constructor(map: mapboxgl.Map, container: HTMLElement, popupOpen: { value: boolean }) {
|
||||
this.map = map;
|
||||
this.layerEventManager = layerEventManager;
|
||||
|
||||
this.viewer = new Viewer({
|
||||
accessToken: 'MLY|4381405525255083|3204871ec181638c3c31320490f03011',
|
||||
@@ -70,12 +62,15 @@ export class MapillaryLayer {
|
||||
});
|
||||
|
||||
const element = document.createElement('div');
|
||||
element.className = 'maplibregl-user-location maplibregl-user-location-show-heading';
|
||||
element.className = 'mapboxgl-user-location mapboxgl-user-location-show-heading';
|
||||
const dot = document.createElement('div');
|
||||
dot.className = 'maplibregl-user-location-dot';
|
||||
dot.className = 'mapboxgl-user-location-dot';
|
||||
const heading = document.createElement('div');
|
||||
heading.className = 'mapboxgl-user-location-heading';
|
||||
element.appendChild(dot);
|
||||
element.appendChild(heading);
|
||||
|
||||
this.marker = new maplibregl.Marker({
|
||||
this.marker = new mapboxgl.Marker({
|
||||
rotationAlignment: 'map',
|
||||
element,
|
||||
});
|
||||
@@ -111,14 +106,14 @@ export class MapillaryLayer {
|
||||
this.map.addLayer(mapillaryImageLayer, ANCHOR_LAYER_KEY.mapillary);
|
||||
}
|
||||
this.map.on('style.load', this.addBinded);
|
||||
this.layerEventManager.on('mouseenter', 'mapillary-image', this.onMouseEnterBinded);
|
||||
this.layerEventManager.on('mouseleave', 'mapillary-image', this.onMouseLeaveBinded);
|
||||
this.map.on('mouseenter', 'mapillary-image', this.onMouseEnterBinded);
|
||||
this.map.on('mouseleave', 'mapillary-image', this.onMouseLeaveBinded);
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.map.off('style.load', this.addBinded);
|
||||
this.layerEventManager.off('mouseenter', 'mapillary-image', this.onMouseEnterBinded);
|
||||
this.layerEventManager.off('mouseleave', 'mapillary-image', this.onMouseLeaveBinded);
|
||||
this.map.off('mouseenter', 'mapillary-image', this.onMouseEnterBinded);
|
||||
this.map.off('mouseleave', 'mapillary-image', this.onMouseLeaveBinded);
|
||||
|
||||
if (this.map.getLayer('mapillary-image')) {
|
||||
this.map.removeLayer('mapillary-image');
|
||||
@@ -140,7 +135,7 @@ export class MapillaryLayer {
|
||||
this.popupOpen.value = false;
|
||||
}
|
||||
|
||||
onMouseEnter(e: maplibregl.MapLayerMouseEvent) {
|
||||
onMouseEnter(e: mapboxgl.MapMouseEvent) {
|
||||
if (
|
||||
e.features &&
|
||||
e.features.length > 0 &&
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { get, type Writable } from 'svelte/store';
|
||||
import {
|
||||
basemaps,
|
||||
defaultBasemap,
|
||||
maptilerKeyPlaceHolder,
|
||||
overlays,
|
||||
terrainSources,
|
||||
} from '$lib/assets/layers';
|
||||
import { getLayers } from '$lib/components/map/layer-control/utils';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
|
||||
const { currentBasemap, currentOverlays, customLayers, opacities, terrainSource } = settings;
|
||||
|
||||
const emptySource: maplibregl.GeoJSONSourceSpecification = {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'FeatureCollection',
|
||||
features: [],
|
||||
},
|
||||
};
|
||||
export const ANCHOR_LAYER_KEY = {
|
||||
overlays: 'overlays-end',
|
||||
mapillary: 'mapillary-end',
|
||||
tracks: 'tracks-end',
|
||||
directionMarkers: 'direction-markers-end',
|
||||
distanceMarkers: 'distance-markers-end',
|
||||
startEndMarkers: 'start-end-markers-end',
|
||||
interactions: 'interactions-end',
|
||||
overpass: 'overpass-end',
|
||||
waypoints: 'waypoints-end',
|
||||
routingControls: 'routing-controls-end',
|
||||
};
|
||||
const anchorLayers: maplibregl.LayerSpecification[] = Object.values(ANCHOR_LAYER_KEY).map((id) => ({
|
||||
id: id,
|
||||
type: 'symbol',
|
||||
source: 'empty-source',
|
||||
}));
|
||||
|
||||
export class StyleManager {
|
||||
private _map: Writable<maplibregl.Map | null>;
|
||||
private _maptilerKey: string;
|
||||
private _pastOverlays: Set<string> = new Set();
|
||||
|
||||
constructor(map: Writable<maplibregl.Map | null>, maptilerKey: string) {
|
||||
this._map = map;
|
||||
this._maptilerKey = maptilerKey;
|
||||
this._map.subscribe((map_) => {
|
||||
if (map_) {
|
||||
this.updateBasemap();
|
||||
map_.on('style.load', () => this.updateOverlays());
|
||||
map_.on('pitch', () => this.updateTerrain());
|
||||
}
|
||||
});
|
||||
currentBasemap.subscribe(() => this.updateBasemap());
|
||||
currentOverlays.subscribe(() => this.updateOverlays());
|
||||
opacities.subscribe(() => this.updateOverlays());
|
||||
terrainSource.subscribe(() => this.updateTerrain());
|
||||
customLayers.subscribe(() => this.updateBasemap());
|
||||
}
|
||||
|
||||
updateBasemap() {
|
||||
const map_ = get(this._map);
|
||||
if (!map_) return;
|
||||
this.buildStyle().then((style) => map_.setStyle(style));
|
||||
}
|
||||
|
||||
async buildStyle(): Promise<maplibregl.StyleSpecification> {
|
||||
const custom = get(customLayers);
|
||||
|
||||
const style: maplibregl.StyleSpecification = {
|
||||
version: 8,
|
||||
projection: {
|
||||
type: 'globe',
|
||||
},
|
||||
sources: {
|
||||
'empty-source': emptySource,
|
||||
},
|
||||
layers: [],
|
||||
};
|
||||
|
||||
let basemap = get(currentBasemap);
|
||||
const basemapInfo = basemaps[basemap] ?? custom[basemap]?.value ?? basemaps[defaultBasemap];
|
||||
const basemapStyle = await this.get(basemapInfo);
|
||||
|
||||
this.merge(style, basemapStyle);
|
||||
|
||||
if (this._maptilerKey !== '') {
|
||||
const terrain = this.getCurrentTerrain();
|
||||
style.sources[terrain.source] = terrainSources[terrain.source];
|
||||
style.terrain = terrain.exaggeration > 0 ? terrain : undefined;
|
||||
}
|
||||
|
||||
style.layers.push(...anchorLayers);
|
||||
|
||||
return style;
|
||||
}
|
||||
|
||||
async updateOverlays() {
|
||||
const map_ = get(this._map);
|
||||
if (!map_) return;
|
||||
if (!map_.getSource('empty-source')) return;
|
||||
|
||||
const custom = get(customLayers);
|
||||
const overlayOpacities = get(opacities);
|
||||
try {
|
||||
const layers = getLayers(get(currentOverlays) ?? {});
|
||||
for (let overlay in layers) {
|
||||
if (!layers[overlay]) {
|
||||
if (this._pastOverlays.has(overlay)) {
|
||||
const overlayInfo = custom[overlay]?.value ?? overlays[overlay];
|
||||
const overlayStyle = await this.get(overlayInfo);
|
||||
for (let layer of overlayStyle.layers ?? []) {
|
||||
if (map_.getLayer(layer.id)) {
|
||||
map_.removeLayer(layer.id);
|
||||
}
|
||||
}
|
||||
this._pastOverlays.delete(overlay);
|
||||
}
|
||||
} else {
|
||||
const overlayInfo = custom[overlay]?.value ?? overlays[overlay];
|
||||
const overlayStyle = await this.get(overlayInfo);
|
||||
const opacity = overlayOpacities[overlay];
|
||||
|
||||
for (let sourceId in overlayStyle.sources) {
|
||||
if (!map_.getSource(sourceId)) {
|
||||
map_.addSource(sourceId, overlayStyle.sources[sourceId]);
|
||||
}
|
||||
}
|
||||
|
||||
for (let layer of overlayStyle.layers ?? []) {
|
||||
if (!map_.getLayer(layer.id)) {
|
||||
if (opacity !== undefined) {
|
||||
if (layer.type === 'raster') {
|
||||
if (!layer.paint) {
|
||||
layer.paint = {};
|
||||
}
|
||||
layer.paint['raster-opacity'] = opacity;
|
||||
} else if (layer.type === 'hillshade') {
|
||||
if (!layer.paint) {
|
||||
layer.paint = {};
|
||||
}
|
||||
layer.paint['hillshade-exaggeration'] = opacity / 2;
|
||||
}
|
||||
}
|
||||
map_.addLayer(layer, ANCHOR_LAYER_KEY.overlays);
|
||||
}
|
||||
}
|
||||
|
||||
this._pastOverlays.add(overlay);
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
updateTerrain() {
|
||||
if (this._maptilerKey === '') return;
|
||||
const map_ = get(this._map);
|
||||
if (!map_) return;
|
||||
|
||||
const mapTerrain = map_.getTerrain();
|
||||
const terrain = this.getCurrentTerrain();
|
||||
if (JSON.stringify(mapTerrain) !== JSON.stringify(terrain)) {
|
||||
if (terrain.exaggeration > 0) {
|
||||
if (!map_.getSource(terrain.source)) {
|
||||
map_.addSource(terrain.source, terrainSources[terrain.source]);
|
||||
}
|
||||
map_.setTerrain(terrain);
|
||||
} else {
|
||||
map_.setTerrain(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async get(
|
||||
styleInfo: maplibregl.StyleSpecification | string
|
||||
): Promise<maplibregl.StyleSpecification> {
|
||||
if (typeof styleInfo === 'string') {
|
||||
let styleUrl = styleInfo as string;
|
||||
if (styleUrl.includes(maptilerKeyPlaceHolder)) {
|
||||
styleUrl = styleUrl.replace(maptilerKeyPlaceHolder, this._maptilerKey);
|
||||
}
|
||||
const response = await fetch(styleUrl, { cache: 'force-cache' });
|
||||
const style = await response.json();
|
||||
return style;
|
||||
} else {
|
||||
return styleInfo;
|
||||
}
|
||||
}
|
||||
|
||||
merge(style: maplibregl.StyleSpecification, other: maplibregl.StyleSpecification) {
|
||||
style.sources = { ...style.sources, ...other.sources };
|
||||
for (let layer of other.layers ?? []) {
|
||||
if (layer.type === 'symbol' && layer.layout && layer.layout['text-field']) {
|
||||
const textField = layer.layout['text-field'];
|
||||
if (
|
||||
Array.isArray(textField) &&
|
||||
textField.length >= 2 &&
|
||||
textField[0] === 'coalesce' &&
|
||||
Array.isArray(textField[1]) &&
|
||||
textField[1][0] === 'get' &&
|
||||
typeof textField[1][1] === 'string' &&
|
||||
textField[1][1].startsWith('name')
|
||||
) {
|
||||
layer.layout['text-field'] = [
|
||||
'coalesce',
|
||||
['get', `name:${i18n.lang}`],
|
||||
['get', 'name'],
|
||||
];
|
||||
}
|
||||
}
|
||||
style.layers.push(layer);
|
||||
}
|
||||
if (other.sprite && !style.sprite) {
|
||||
style.sprite = other.sprite;
|
||||
}
|
||||
if (other.glyphs && !style.glyphs) {
|
||||
style.glyphs = other.glyphs;
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentTerrain() {
|
||||
const terrain = get(terrainSource);
|
||||
const source = terrainSources[terrain];
|
||||
if (source.url && source.url.includes(maptilerKeyPlaceHolder)) {
|
||||
source.url = source.url.replace(maptilerKeyPlaceHolder, this._maptilerKey);
|
||||
}
|
||||
const map_ = get(this._map);
|
||||
return {
|
||||
source: terrain,
|
||||
exaggeration: !map_ || map_.getPitch() === 0 ? 0 : 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
import Clean from '$lib/components/toolbar/tools/Clean.svelte';
|
||||
import Reduce from '$lib/components/toolbar/tools/reduce/Reduce.svelte';
|
||||
import RoutingControlPopup from '$lib/components/toolbar/tools/routing/RoutingControlPopup.svelte';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
|
||||
let {
|
||||
@@ -23,11 +23,11 @@
|
||||
const { minimizeRoutingMenu } = settings;
|
||||
|
||||
let popupElement: HTMLDivElement | undefined = $state(undefined);
|
||||
let popup: maplibregl.Popup | undefined = $derived.by(() => {
|
||||
let popup: mapboxgl.Popup | undefined = $derived.by(() => {
|
||||
if (!popupElement) {
|
||||
return undefined;
|
||||
}
|
||||
let popup = new maplibregl.Popup({
|
||||
let popup = new mapboxgl.Popup({
|
||||
closeButton: false,
|
||||
maxWidth: undefined,
|
||||
});
|
||||
|
||||
@@ -15,12 +15,11 @@
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import { Trash2 } from '@lucide/svelte';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import type { GeoJSONSource } from 'maplibre-gl';
|
||||
import { ANCHOR_LAYER_KEY, map } from '$lib/components/map/map';
|
||||
import type { GeoJSONSource } from 'mapbox-gl';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
import { fileActions } from '$lib/logic/file-actions';
|
||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||
|
||||
let props: {
|
||||
class?: string;
|
||||
@@ -29,7 +28,7 @@
|
||||
let cleanType = $state(CleanType.INSIDE);
|
||||
let deleteTrackpoints = $state(true);
|
||||
let deleteWaypoints = $state(true);
|
||||
let rectangleCoordinates: maplibregl.LngLat[] = $state([]);
|
||||
let rectangleCoordinates: mapboxgl.LngLat[] = $state([]);
|
||||
|
||||
$effect(() => {
|
||||
if ($map) {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { ListItem, ListTrackSegmentItem } from '$lib/components/file-list/file-list';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||
import { ANCHOR_LAYER_KEY, map } from '$lib/components/map/map';
|
||||
import { fileActions } from '$lib/logic/file-actions';
|
||||
import { GPXFileStateCollectionObserver, type GPXFileState } from '$lib/logic/file-state';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
import { ramerDouglasPeucker, TrackPoint, type SimplifiedTrackPoint } from 'gpx';
|
||||
import type { GeoJSONSource } from 'maplibre-gl';
|
||||
import type { GeoJSONSource } from 'mapbox-gl';
|
||||
import { get, writable } from 'svelte/store';
|
||||
|
||||
export const minTolerance = 0.1;
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
SquareArrowUpLeft,
|
||||
SquareArrowOutDownRight,
|
||||
} from '@lucide/svelte';
|
||||
import { routingProfiles } from '$lib/components/toolbar/tools/routing/routing';
|
||||
import { brouterProfiles } from '$lib/components/toolbar/tools/routing/routing';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import {
|
||||
@@ -51,7 +51,7 @@
|
||||
}: {
|
||||
minimized?: boolean;
|
||||
minimizable?: boolean;
|
||||
popup?: maplibregl.Popup;
|
||||
popup?: mapboxgl.Popup;
|
||||
popupElement?: HTMLDivElement;
|
||||
class?: string;
|
||||
} = $props();
|
||||
@@ -167,7 +167,7 @@
|
||||
{i18n._(`toolbar.routing.activities.${$routingProfile}`)}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each Object.keys(routingProfiles) as profile}
|
||||
{#each Object.keys(brouterProfiles) as profile}
|
||||
<Select.Item value={profile}
|
||||
>{i18n._(
|
||||
`toolbar.routing.activities.${profile}`
|
||||
|
||||
@@ -6,213 +6,37 @@ import { get } from 'svelte/store';
|
||||
|
||||
const { routing, routingProfile, privateRoads } = settings;
|
||||
|
||||
export type RoutingProfile = {
|
||||
engine: 'graphhopper' | 'brouter';
|
||||
profile: string;
|
||||
};
|
||||
|
||||
export const routingProfiles: { [key: string]: RoutingProfile } = {
|
||||
bike: { engine: 'graphhopper', profile: 'bike' },
|
||||
racing_bike: { engine: 'graphhopper', profile: 'racingbike' },
|
||||
gravel_bike: { engine: 'graphhopper', profile: 'gravelbike' },
|
||||
mountain_bike: { engine: 'graphhopper', profile: 'mtb' },
|
||||
foot: { engine: 'graphhopper', profile: 'foot' },
|
||||
motorcycle: { engine: 'graphhopper', profile: 'motorbike' },
|
||||
water: { engine: 'brouter', profile: 'river' },
|
||||
railway: { engine: 'brouter', profile: 'rail' },
|
||||
export const brouterProfiles: { [key: string]: string } = {
|
||||
bike: 'Trekking-dry',
|
||||
racing_bike: 'fastbike',
|
||||
gravel_bike: 'gravel',
|
||||
mountain_bike: 'MTB',
|
||||
foot: 'Hiking-Alpine-SAC6',
|
||||
motorcycle: 'Car-FastEco',
|
||||
water: 'river',
|
||||
railway: 'rail',
|
||||
};
|
||||
|
||||
export function route(points: Coordinates[]): Promise<TrackPoint[]> {
|
||||
if (get(routing)) {
|
||||
const profile = routingProfiles[get(routingProfile)];
|
||||
if (profile.engine === 'graphhopper') {
|
||||
return getGraphHopperRoute(points, profile.profile, get(privateRoads));
|
||||
} else {
|
||||
return getBRouterRoute(points, profile.profile);
|
||||
}
|
||||
return getRoute(points, brouterProfiles[get(routingProfile)], get(privateRoads));
|
||||
} else {
|
||||
return getIntermediatePoints(points);
|
||||
}
|
||||
}
|
||||
|
||||
const graphhopperDetails = ['road_class', 'surface', 'hike_rating', 'mtb_rating'];
|
||||
const hikeRatingToSACScale: { [key: string]: string } = {
|
||||
'1': 'hiking',
|
||||
'2': 'mountain_hiking',
|
||||
'3': 'demanding_mountain_hiking',
|
||||
'4': 'alpine_hiking',
|
||||
'5': 'demanding_alpine_hiking',
|
||||
'6': 'difficult_alpine_hiking',
|
||||
};
|
||||
const mtbRatingToScale: { [key: string]: string } = {
|
||||
'1': '0',
|
||||
'2': '1',
|
||||
'3': '2',
|
||||
'4': '3',
|
||||
'5': '4',
|
||||
'6': '5',
|
||||
'7': '6',
|
||||
};
|
||||
|
||||
const graphhopperBlockPrivateCustomModels: { [key: string]: any } = {
|
||||
bike: {
|
||||
priority: [
|
||||
{
|
||||
if: 'bike_road_access == PRIVATE',
|
||||
multiply_by: '0.0',
|
||||
},
|
||||
],
|
||||
},
|
||||
racingbike: {
|
||||
priority: [
|
||||
{
|
||||
if: 'bike_road_access == PRIVATE',
|
||||
multiply_by: '0.0',
|
||||
},
|
||||
],
|
||||
},
|
||||
gravelbike: {
|
||||
priority: [
|
||||
{
|
||||
if: 'bike_road_access == PRIVATE',
|
||||
multiply_by: '0.0',
|
||||
},
|
||||
],
|
||||
},
|
||||
mtb: {
|
||||
priority: [
|
||||
{
|
||||
if: 'bike_road_access == PRIVATE',
|
||||
multiply_by: '0.0',
|
||||
},
|
||||
],
|
||||
},
|
||||
foot: {
|
||||
priority: [
|
||||
{
|
||||
if: 'foot_road_access == PRIVATE',
|
||||
multiply_by: '0.0',
|
||||
},
|
||||
],
|
||||
},
|
||||
motorcycle: {
|
||||
priority: [
|
||||
{
|
||||
if: 'road_access == PRIVATE',
|
||||
multiply_by: '0.0',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
async function getGraphHopperRoute(
|
||||
async function getRoute(
|
||||
points: Coordinates[],
|
||||
graphHopperProfile: string,
|
||||
brouterProfile: string,
|
||||
privateRoads: boolean
|
||||
): Promise<TrackPoint[]> {
|
||||
let response = await fetch('https://graphhopper.gpx.studio/route', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
points: points.map((point) => [point.lon, point.lat]),
|
||||
profile: graphHopperProfile,
|
||||
elevation: true,
|
||||
points_encoded: false,
|
||||
details: graphhopperDetails,
|
||||
custom_model: privateRoads
|
||||
? {}
|
||||
: graphhopperBlockPrivateCustomModels[graphHopperProfile] || {},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
if (error.message.includes('Cannot find point 0')) {
|
||||
throw new Error('toolbar.routing.error.from');
|
||||
} else if (error.message.includes('Cannot find point 1')) {
|
||||
if (points.length == 3) {
|
||||
throw new Error('toolbar.routing.error.via');
|
||||
} else {
|
||||
throw new Error('toolbar.routing.error.to');
|
||||
}
|
||||
} else if (error.hints[0].details.includes('PointDistanceExceededException')) {
|
||||
throw new Error('toolbar.routing.error.distance');
|
||||
} else if (error.hints[0].details.includes('ConnectionNotFoundException')) {
|
||||
throw new Error('toolbar.routing.error.connection');
|
||||
} else {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
let json = await response.json();
|
||||
|
||||
let route: TrackPoint[] = [];
|
||||
let coordinates = json.paths[0].points.coordinates;
|
||||
let details = json.paths[0].details;
|
||||
|
||||
for (let i = 0; i < coordinates.length; i++) {
|
||||
route.push(
|
||||
new TrackPoint({
|
||||
attributes: {
|
||||
lat: coordinates[i][1],
|
||||
lon: coordinates[i][0],
|
||||
},
|
||||
ele: coordinates[i][2] ?? (i > 0 ? route[i - 1].ele : 0),
|
||||
extensions: {},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
for (let key of graphhopperDetails) {
|
||||
let detail = details[key];
|
||||
for (let i = 0; i < detail.length; i++) {
|
||||
for (let j = detail[i][0]; j < detail[i][1] + (i == detail.length - 1); j++) {
|
||||
if (detail[i][2] !== undefined && detail[i][2] !== 'missing') {
|
||||
if (key === 'road_class') {
|
||||
route[j].setExtension('highway', detail[i][2]);
|
||||
} else if (key === 'hike_rating') {
|
||||
const sacScale = hikeRatingToSACScale[detail[i][2]];
|
||||
if (sacScale) {
|
||||
route[j].setExtension('sac_scale', sacScale);
|
||||
}
|
||||
} else if (key === 'mtb_rating') {
|
||||
const mtbScale = mtbRatingToScale[detail[i][2]];
|
||||
if (mtbScale) {
|
||||
route[j].setExtension('mtb_scale', mtbScale);
|
||||
}
|
||||
} else if (key === 'surface' && detail[i][2] !== 'other') {
|
||||
route[j].setExtension('surface', detail[i][2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return route;
|
||||
}
|
||||
|
||||
async function getBRouterRoute(
|
||||
points: Coordinates[],
|
||||
brouterProfile: string
|
||||
): Promise<TrackPoint[]> {
|
||||
let url = `https://brouter.de/brouter?lonlats=${points.map((point) => `${point.lon.toFixed(8)},${point.lat.toFixed(8)}`).join('|')}&profile=${brouterProfile}&format=geojson&alternativeidx=0`;
|
||||
let url = `https://brouter.gpx.studio?lonlats=${points.map((point) => `${point.lon.toFixed(8)},${point.lat.toFixed(8)}`).join('|')}&profile=${brouterProfile + (privateRoads ? '-private' : '')}&format=geojson&alternativeidx=0`;
|
||||
|
||||
let response = await fetch(url);
|
||||
|
||||
// Check if the response is ok
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
if (error.includes('from-position not mapped in existing datafile')) {
|
||||
throw new Error('toolbar.routing.error.from');
|
||||
} else if (error.includes('via1-position not mapped in existing datafile')) {
|
||||
throw new Error('toolbar.routing.error.via');
|
||||
} else if (error.includes('to-position not mapped in existing datafile')) {
|
||||
throw new Error('toolbar.routing.error.to');
|
||||
} else if (error.includes('Time-out')) {
|
||||
throw new Error('toolbar.routing.error.timeout');
|
||||
} else {
|
||||
throw new Error(error);
|
||||
}
|
||||
throw new Error(`${await response.text()}`);
|
||||
}
|
||||
|
||||
let geojson = await response.json();
|
||||
@@ -228,13 +52,14 @@ async function getBRouterRoute(
|
||||
let tags = messageIdx < messages.length ? getTags(messages[messageIdx][tagIdx]) : {};
|
||||
|
||||
for (let i = 0; i < coordinates.length; i++) {
|
||||
let coord = coordinates[i];
|
||||
route.push(
|
||||
new TrackPoint({
|
||||
attributes: {
|
||||
lat: coordinates[i][1],
|
||||
lon: coordinates[i][0],
|
||||
lat: coord[1],
|
||||
lon: coord[0],
|
||||
},
|
||||
ele: coordinates[i][2] ?? (i > 0 ? route[i - 1].ele : 0),
|
||||
ele: coord[2] ?? (i > 0 ? route[i - 1].ele : 0),
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -2,21 +2,15 @@ import { ramerDouglasPeucker, type GPXFile, type TrackSegment } from 'gpx';
|
||||
|
||||
const earthRadius = 6371008.8;
|
||||
|
||||
export const MIN_ANCHOR_ZOOM = 0;
|
||||
export const MAX_ANCHOR_ZOOM = 22;
|
||||
|
||||
export function getZoomLevelForDistance(latitude: number, distance?: number): number {
|
||||
if (distance === undefined) {
|
||||
return MIN_ANCHOR_ZOOM;
|
||||
return 0;
|
||||
}
|
||||
|
||||
const rad = Math.PI / 180;
|
||||
const lat = latitude * rad;
|
||||
|
||||
return Math.min(
|
||||
MAX_ANCHOR_ZOOM,
|
||||
Math.max(MIN_ANCHOR_ZOOM, Math.round(Math.log2((earthRadius * Math.cos(lat)) / distance)))
|
||||
);
|
||||
return Math.min(22, Math.max(0, Math.log2((earthRadius * Math.cos(lat)) / distance)));
|
||||
}
|
||||
|
||||
export function updateAnchorPoints(file: GPXFile) {
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
onMount(() => {
|
||||
if ($map) {
|
||||
splitControls = new SplitControls($map, map.layerEventManager!);
|
||||
splitControls = new SplitControls($map);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -8,33 +8,40 @@ import { get } from 'svelte/store';
|
||||
import { fileStateCollection } from '$lib/logic/file-state';
|
||||
import { fileActions } from '$lib/logic/file-actions';
|
||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
||||
import type { GeoJSONSource } from 'maplibre-gl';
|
||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
|
||||
import { loadSVGIcon } from '$lib/utils';
|
||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/map';
|
||||
|
||||
export class SplitControls {
|
||||
map: maplibregl.Map;
|
||||
layerEventManager: MapLayerEventManager;
|
||||
map: mapboxgl.Map;
|
||||
unsubscribes: Function[] = [];
|
||||
|
||||
layerOnMouseEnterBinded: (e: any) => void = this.layerOnMouseEnter.bind(this);
|
||||
layerOnMouseLeaveBinded: () => void = this.layerOnMouseLeave.bind(this);
|
||||
layerOnClickBinded: (e: any) => void = this.layerOnClick.bind(this);
|
||||
|
||||
constructor(map: maplibregl.Map, layerEventManager: MapLayerEventManager) {
|
||||
constructor(map: mapboxgl.Map) {
|
||||
this.map = map;
|
||||
this.layerEventManager = layerEventManager;
|
||||
loadSVGIcon(
|
||||
this.map,
|
||||
'split-control',
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
|
||||
<circle cx="20" cy="20" r="20" fill="white" />
|
||||
<g transform="translate(8 8)">
|
||||
${Scissors.replace('stroke="currentColor"', 'stroke="black"')}
|
||||
</g>
|
||||
</svg>`
|
||||
);
|
||||
|
||||
if (!this.map.hasImage('split-control')) {
|
||||
let icon = new Image(100, 100);
|
||||
icon.onload = () => {
|
||||
if (!this.map.hasImage('split-control')) {
|
||||
this.map.addImage('split-control', icon);
|
||||
}
|
||||
};
|
||||
|
||||
// Lucide icons are SVG files with a 24x24 viewBox
|
||||
// Create a new SVG with a 32x32 viewBox and center the icon in a circle
|
||||
icon.src =
|
||||
'data:image/svg+xml,' +
|
||||
encodeURIComponent(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
|
||||
<circle cx="20" cy="20" r="20" fill="white" />
|
||||
<g transform="translate(8 8)">
|
||||
${Scissors.replace('stroke="currentColor"', 'stroke="black"')}
|
||||
</g>
|
||||
</svg>
|
||||
`);
|
||||
}
|
||||
|
||||
this.unsubscribes.push(gpxStatistics.subscribe(this.addIfNeeded.bind(this)));
|
||||
this.unsubscribes.push(currentTool.subscribe(this.addIfNeeded.bind(this)));
|
||||
@@ -91,7 +98,7 @@ export class SplitControls {
|
||||
}, false);
|
||||
|
||||
try {
|
||||
let source = this.map.getSource('split-controls') as GeoJSONSource | undefined;
|
||||
let source = this.map.getSource('split-controls') as mapboxgl.GeoJSONSource | undefined;
|
||||
if (source) {
|
||||
source.setData(data);
|
||||
} else {
|
||||
@@ -117,17 +124,9 @@ export class SplitControls {
|
||||
ANCHOR_LAYER_KEY.interactions
|
||||
);
|
||||
|
||||
this.layerEventManager.on(
|
||||
'mouseenter',
|
||||
'split-controls',
|
||||
this.layerOnMouseEnterBinded
|
||||
);
|
||||
this.layerEventManager.on(
|
||||
'mouseleave',
|
||||
'split-controls',
|
||||
this.layerOnMouseLeaveBinded
|
||||
);
|
||||
this.layerEventManager.on('click', 'split-controls', this.layerOnClickBinded);
|
||||
this.map.on('mouseenter', 'split-controls', this.layerOnMouseEnterBinded);
|
||||
this.map.on('mouseleave', 'split-controls', this.layerOnMouseLeaveBinded);
|
||||
this.map.on('click', 'split-controls', this.layerOnClickBinded);
|
||||
}
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to add sources and layers
|
||||
@@ -135,9 +134,9 @@ export class SplitControls {
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.layerEventManager.off('mouseenter', 'split-controls', this.layerOnMouseEnterBinded);
|
||||
this.layerEventManager.off('mouseleave', 'split-controls', this.layerOnMouseLeaveBinded);
|
||||
this.layerEventManager.off('click', 'split-controls', this.layerOnClickBinded);
|
||||
this.map.off('mouseenter', 'split-controls', this.layerOnMouseEnterBinded);
|
||||
this.map.off('mouseleave', 'split-controls', this.layerOnMouseLeaveBinded);
|
||||
this.map.off('click', 'split-controls', this.layerOnClickBinded);
|
||||
|
||||
try {
|
||||
if (this.map.getLayer('split-controls')) {
|
||||
@@ -160,7 +159,7 @@ export class SplitControls {
|
||||
mapCursor.notify(MapCursorState.SPLIT_CONTROL, false);
|
||||
}
|
||||
|
||||
layerOnClick(e: maplibregl.MapLayerMouseEvent) {
|
||||
layerOnClick(e: mapboxgl.MapMouseEvent) {
|
||||
let coordinates = (e.features![0].geometry as GeoJSON.Point).coordinates;
|
||||
fileActions.split(
|
||||
get(splitAs),
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
import { fileActions } from '$lib/logic/file-actions';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { getSvgForSymbol } from '$lib/components/map/gpx-layer/gpx-layer';
|
||||
|
||||
let props: {
|
||||
@@ -41,7 +41,7 @@
|
||||
})
|
||||
);
|
||||
|
||||
let marker: maplibregl.Marker | null = null;
|
||||
let marker: mapboxgl.Marker | null = null;
|
||||
|
||||
function reset() {
|
||||
if ($selectedWaypoint) {
|
||||
@@ -125,7 +125,7 @@
|
||||
let element = document.createElement('div');
|
||||
element.classList.add('w-8', 'h-8');
|
||||
element.innerHTML = getSvgForSymbol(symbolKey);
|
||||
marker = new maplibregl.Marker({
|
||||
marker = new mapboxgl.Marker({
|
||||
element,
|
||||
anchor: 'bottom',
|
||||
})
|
||||
|
||||
@@ -12,7 +12,6 @@ title: Files and statistics
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
let hoveredPoint = writable(null);
|
||||
let additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable(undefined);
|
||||
</script>
|
||||
@@ -85,17 +84,19 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
<div class="flex flex-col items-center -mt-6">
|
||||
<div class="h-10 w-fit">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
panelSize={120}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
### Additional data
|
||||
|
||||
13
website/src/lib/docs/be/home/funding.mdx
Normal file
@@ -0,0 +1,13 @@
|
||||
<script>
|
||||
import { HeartHandshake } from '@lucide/svelte';
|
||||
</script>
|
||||
|
||||
## <HeartHandshake size="18" class="inline-block align-baseline" /> Help keep the website free (and ad-free)
|
||||
|
||||
Each time you add or move GPS points, our servers calculate the best route on the road network.
|
||||
We also use APIs from <a href="https://mapbox.com" target="_blank">Mapbox</a> to display beautiful maps, retrieve elevation data and allow you to search for places.
|
||||
|
||||
Unfortunately, this is expensive.
|
||||
If you enjoy using this tool and find it valuable, please consider making a small donation to help keep the website free and ad-free.
|
||||
|
||||
Thank you very much for your support! ❤️
|
||||
5
website/src/lib/docs/be/home/mapbox.mdx
Normal file
@@ -0,0 +1,5 @@
|
||||
Mapbox is the company that provides some of the beautiful maps on this website.
|
||||
They also develop the <a href="https://github.com/mapbox/mapbox-gl-js" target="_blank">map engine</a> which powers **gpx.studio**.
|
||||
|
||||
We are incredibly fortunate and grateful to be part of their <a href="https://mapbox.com/community" target="_blank">Community</a> program, which supports nonprofits, educational institutions, and positive impact organizations.
|
||||
This partnership allows **gpx.studio** to benefit from Mapbox tools at discounted prices, greatly contributing to the financial viability of the project and enabling us to offer the best possible user experience.
|
||||
12
website/src/lib/docs/be/home/translation.mdx
Normal file
@@ -0,0 +1,12 @@
|
||||
<script>
|
||||
import { Languages } from '@lucide/svelte';
|
||||
</script>
|
||||
|
||||
## <Languages size="18" class="inline-block align-baseline" /> Translation
|
||||
|
||||
The website is translated by volunteers using a collaborative translation platform.
|
||||
You can contribute by adding or improving translations on our <a href="https://crowdin.com/project/gpxstudio" target="_blank">Crowdin project</a>.
|
||||
|
||||
If you would like to start translating into a new language, please <a href="#contact">get in touch</a>.
|
||||
|
||||
Any help is greatly appreciated!
|
||||
@@ -13,8 +13,8 @@ title: Інтэграцыя
|
||||
|
||||
Усё, што вам трэба, гэта:
|
||||
|
||||
1. GPX files hosted on your server or on Google Drive, or accessible via a public URL;
|
||||
2. _Optional:_ a <a href="https://cloud.maptiler.com/auth/widget?next=https://cloud.maptiler.com/maps/" target="_blank">MapTiler key</a> to load MapTiler maps.
|
||||
1. <a href="https://account.mapbox.com/auth/signup" target="_blank">Ключ доступу Mapbox</a> для загрузкі карты і
|
||||
2. Файлы GPX, размешчаныя на вашым серверы або на Google Drive, або даступныя праз публічны URL.
|
||||
|
||||
Затым вы можаце пагуляць з канфігуратарам ніжэй, каб наладзіць сваю карту і стварыць адпаведны HTML-код.
|
||||
|
||||
|
||||
@@ -56,12 +56,10 @@ These controls allow you to navigate the map, zoom in and out, and switch betwee
|
||||
- **Кропкі цікавасці** можна дадаць на карту, каб паказаць розныя катэгорыі месцаў, такіх як крамы, рэстараны або жыллё.
|
||||
|
||||
<div class="flex flex-col items-center">
|
||||
<DocsLayers />
|
||||
<span class="text-sm text-center mt-2">
|
||||
|
||||
Hover over the map to show the <a href="https://hiking.waymarkedtrails.org" target="_blank">Waymarked Trails hiking</a> overlay on top of the <a href="https://www.maptiler.com/maps/outdoor-topo/" target="_blank">MapTiler Topo</a> basemap.
|
||||
|
||||
</span>
|
||||
<DocsLayers />
|
||||
<span class="text-sm text-center mt-2">
|
||||
Навядзіце курсор мышы на карту, каб паказаць накладанне <a href="https://hiking.waymarkedtrails.org" target="_blank">Пешаходных Сцежак</a> на базавай карце <a href="https://www.mapbox.com/maps/outdoors" target="_blank">Mapbox Outdoors</a>.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
Вялікая калекцыя глабальных і лакальных базавых карт і накладанняў даступная ў **gpx.studio**, а таксама выбар катэгорый кропак цікавасці.
|
||||
@@ -69,4 +67,4 @@ Hover over the map to show the <a href="https://hiking.waymarkedtrails.org" targ
|
||||
|
||||
У гэтых наладах вы таксама можаце кіраваць непразрыстасцю накладанняў.
|
||||
|
||||
For advanced users, it is possible to add custom basemaps and overlays by providing <a href="https://en.wikipedia.org/wiki/Web_Map_Tile_Service" target="_blank">WMTS</a>, <a href="https://en.wikipedia.org/wiki/Web_Map_Service" target="_blank">WMS</a>, or <a href="https://maplibre.org/maplibre-style-spec/" target="_blank">MapLibre style JSON</a> URLs.
|
||||
Для прасунутых карыстальнікаў можна дадаваць карыстальніцкія базавыя карты і накладкі, дадаўшы URL-адрасы <a href="https://en.wikipedia.org/wiki/Web_Map_Tile_Service" target="_blank">WMTS</a>, <a href="https://en.wikipedia.org/wiki/Web_Map_Service" target="_blank">WMS</a> або <a href="https://docs.mapbox.com/help/glossary/style/" target="_blank">JSON у стылі Mapbox</a>.
|
||||
|
||||
@@ -18,7 +18,7 @@ This tool allows you to add elevation data to traces and [points of interest](..
|
||||
|
||||
<DocsNote>
|
||||
|
||||
Elevation data is provided by <a href="https://maptiler.com" target="_blank">MapTiler</a>.
|
||||
You can learn more about its origin and accuracy in the <a href="https://docs.maptiler.com/guides/map-tiling-hosting/data-hosting/rgb-terrain-by-maptiler/" target="_blank">documentation</a>.
|
||||
Elevation data is provided by <a href="https://mapbox.com" target="_blank">Mapbox</a>.
|
||||
You can learn more about its origin and accuracy in the <a href="https://docs.mapbox.com/data/tilesets/reference/mapbox-terrain-dem-v1/#elevation-data" target="_blank">documentation</a>.
|
||||
|
||||
</DocsNote>
|
||||
|
||||
@@ -5,7 +5,6 @@ title: Merge
|
||||
<script>
|
||||
import { Group } from '@lucide/svelte';
|
||||
import Merge from '$lib/components/toolbar/tools/Merge.svelte';
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
</script>
|
||||
|
||||
# <Group size="24" class="inline-block" style="margin-bottom: 5px" /> { title }
|
||||
@@ -16,13 +15,6 @@ To use this tool, you need to [select](../files-and-stats) multiple files, [trac
|
||||
- The second option can be used to create or manage files with multiple [tracks or segments](../gpx).
|
||||
Merging files (or tracks) will result in a single file (or track) containing all tracks (or segments) from the selection.
|
||||
|
||||
<DocsNote>
|
||||
|
||||
Selected items are merged in the order they appear in the files list.
|
||||
Reorder items by drag-and-drop if needed.
|
||||
|
||||
</DocsNote>
|
||||
|
||||
<div class="flex flex-row justify-center">
|
||||
<Merge class="text-foreground p-3 border rounded-md shadow-lg" />
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,6 @@ title: Fitxers i estadístiques
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
let hoveredPoint = writable(null);
|
||||
let additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable(undefined);
|
||||
</script>
|
||||
@@ -85,17 +84,19 @@ També pots utilitzar la rodeta del ratolí per apropar o allunyar el perfil d'e
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
<div class="flex flex-col items-center -mt-6">
|
||||
<div class="h-10 w-fit">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
panelSize={120}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
### Dades addicionals
|
||||
|
||||
13
website/src/lib/docs/ca/home/funding.mdx
Normal file
@@ -0,0 +1,13 @@
|
||||
<script>
|
||||
import { HeartHandshake } from '@lucide/svelte';
|
||||
</script>
|
||||
|
||||
## <HeartHandshake size="18" class="inline-block align-baseline" /> Ajuda a mantenir aquesta pàgina web gratuïta (i sense anuncis)
|
||||
|
||||
Cada cop que afegeixes o mous un punt GPS, els nostres servidors calculen la millor ruta possible.
|
||||
També utilitzen l'API de <a href="https://mapbox.com" target="_blank">Mapbox</a> per ensenyar mapes bonics, donar informació sobre l'altitud i permetre la cerca de llocs d'interès.
|
||||
|
||||
Desafortunadament, això és car.
|
||||
Si gaudeixes aquesta eina i la trobes valuosa, si us plau, considera fer una petita donació per ajudar a mantenir la pàgina web gratuïta i sense anuncis.
|
||||
|
||||
Moltíssimes gràcies pel teu suport! ❤️
|
||||
5
website/src/lib/docs/ca/home/mapbox.mdx
Normal file
@@ -0,0 +1,5 @@
|
||||
Mapbox és l'empresa que ofereix alguns dels mapes d'aquest lloc web.
|
||||
Ells també desenvolupen el <a href="https://github.com/mapbox/mapbox-gl-js" target="_blank">motor de mapes</a> el qual recolza **gpx.studio**.
|
||||
|
||||
Som increïblement afortunats i estem agraïts de formar part del seu programa <a href="https://mapbox.com/community" target="_blank">comunitari</a>, que dona suport a organitzacions sense ànim de lucre, institucions educatives i organitzacions d'impacte positiu.
|
||||
Aquesta associació permet a **gpx.studio** beneficiar-se de les eines de Mapbox a preus amb descompte, contribuint en gran mesura a la viabilitat financera del projecte i permetent oferir la millor experiència d'usuari possible.
|
||||
12
website/src/lib/docs/ca/home/translation.mdx
Normal file
@@ -0,0 +1,12 @@
|
||||
<script>
|
||||
import { Languages } from '@lucide/svelte';
|
||||
</script>
|
||||
|
||||
## <Languages size="18" class="inline-block align-baseline" /> Traducció
|
||||
|
||||
Aquesta pàgina web ha estat traduïda per voluntaris utilitzant una plataforma de traducció col·laborativa.
|
||||
Tu també pots contribuir-hi afegint o millorant les traduccions al nostre <a href="https://crowdin.com/project/gpxstudio" target="_blank">projecte de Crowdin</a>.
|
||||
|
||||
Si vols començar a traduir ara mateix a una nova llengua, si us plau <a href="#contact">posa't en contacte amb nosaltres</a>.
|
||||
|
||||
Qualsevol ajuda és molt apreciada!
|
||||
@@ -13,8 +13,8 @@ Pots utilitzar **gpx.studio** per crear mapes que mostrin els teus arxius GPX i
|
||||
|
||||
Tot el que necessites és:
|
||||
|
||||
1. GPX files hosted on your server or on Google Drive, or accessible via a public URL;
|
||||
2. _Optional:_ a <a href="https://cloud.maptiler.com/auth/widget?next=https://cloud.maptiler.com/maps/" target="_blank">MapTiler key</a> to load MapTiler maps.
|
||||
1. Un <a href="https://account.mapbox.com/auth/signup" target="_blank"> token d'accés a Mapbox</a> per carregar el mapa i
|
||||
2. Arxius GPX allotjats en el teu servidor, a Google Drive o accessibles a través d'una URL pública.
|
||||
|
||||
Aleshores pots jugar amb el configurador de sota per personalitzar el teu mapa i generar el corresponent codi HTML.
|
||||
|
||||
|
||||
@@ -55,13 +55,8 @@ El botó de capa de mapa permet canviar entre diferents mapes base i alternar ca
|
||||
- Les **Capes sobreposades** són capes addicionals que es poden mostrar sobre el mapa base per proporcionar informació complementària.
|
||||
- Els **Punts d'interès** es poden afegir al mapa per mostrar diferents categories de llocs, com botigues, restaurants o allotjaments.
|
||||
|
||||
<div class="flex flex-col items-center">
|
||||
<DocsLayers />
|
||||
<span class="text-sm text-center mt-2">
|
||||
|
||||
Hover over the map to show the <a href="https://hiking.waymarkedtrails.org" target="_blank">Waymarked Trails hiking</a> overlay on top of the <a href="https://www.maptiler.com/maps/outdoor-topo/" target="_blank">MapTiler Topo</a> basemap.
|
||||
|
||||
</span>
|
||||
<div class="flex flex-col items-center"><DocsLayers /><span class="text-sm text-center mt-2">Situa el cursor sobre el mapa per mostrar la capa <a href="https://hiking.waymarkedtrails.org" target="_blank">Waymarked Trails hiking</a> sobreposada sobre del <a href="https://www.mapbox.com/maps/outdoors" target="_blank">Mapbox Outdoors</a> mapa base.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
Una gran col·lecció de mapes i capes sobreposades globals i locals està disponible a **gpx.studio**, així com una selecció de categories de punts d'interès.
|
||||
@@ -69,4 +64,4 @@ Poden activar-se en el [configuració de capes del mapa](./menu/settings).
|
||||
|
||||
En aquests ajustaments pots gestionar l'opacitat de les capes sobreposades.
|
||||
|
||||
For advanced users, it is possible to add custom basemaps and overlays by providing <a href="https://en.wikipedia.org/wiki/Web_Map_Tile_Service" target="_blank">WMTS</a>, <a href="https://en.wikipedia.org/wiki/Web_Map_Service" target="_blank">WMS</a>, or <a href="https://maplibre.org/maplibre-style-spec/" target="_blank">MapLibre style JSON</a> URLs.
|
||||
Per a usuaris avançats és possible afegir mapes base i sobreposicions personalitzades proporcionant <a href="https://en.wikipedia.org/wiki/Web_Map_Tile_Service" target="_blank">WMTS</a>, <a href="https://en.wikipedia.org/wiki/Web_Map_Service" target="_blank">WMS</a>, o <a href="https://docs.mapbox.com/help/glossary/style/" target="_blank">JSON tipus Mapbox</a> URLs.
|
||||
|
||||
@@ -29,13 +29,13 @@ Pots arrossegar y deixar arxius directament des del seu sistema d'arxius cap a l
|
||||
|
||||
Crear una còpia dels arxius seleccionats.
|
||||
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Esborra
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete
|
||||
|
||||
Esborra l'arxiu seleccinat.
|
||||
Delete the currently selected files.
|
||||
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Esborra-ho tot
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete all
|
||||
|
||||
Esborra tots els fitxers.
|
||||
Delete all files.
|
||||
|
||||
### <Download size="16" class="inline-block" style="margin-bottom: 2px" /> Exportar...
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Aquesta eina permet afegir dades d'elevació a traces i [punts d'interès](../gp
|
||||
|
||||
<DocsNote>
|
||||
|
||||
Elevation data is provided by <a href="https://maptiler.com" target="_blank">MapTiler</a>.
|
||||
You can learn more about its origin and accuracy in the <a href="https://docs.maptiler.com/guides/map-tiling-hosting/data-hosting/rgb-terrain-by-maptiler/" target="_blank">documentation</a>.
|
||||
Dades d'elevació subministrades per <a href="https://mapbox.com" target="_blank">Mapbox</a>.
|
||||
Pots aprendre més sobre els seus orígens i precisió en la <a href="https://docs.mapbox.com/data/tilesets/reference/mapbox-terrain-dem-v1/#elevation-data" target="_blank">documentació</a>.
|
||||
|
||||
</DocsNote>
|
||||
|
||||
@@ -5,7 +5,6 @@ title: Fusionar
|
||||
<script>
|
||||
import { Group } from '@lucide/svelte';
|
||||
import Merge from '$lib/components/toolbar/tools/Merge.svelte';
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
</script>
|
||||
|
||||
# <Group size="24" class="inline-block" style="margin-bottom: 5px" /> { title }
|
||||
@@ -16,13 +15,6 @@ Per utilitzar aquesta eina, s'ha de [seleccionar](../files-and-stats) múltiples
|
||||
- La segona opció es pot utilitzar per a crear o gestionar arxius amb múltiples [tracs o segments](../gpx).
|
||||
Fusionar arxius (o tracs) crearà un sol arxiu (o trac) que contindrà tots els tracs (o segments) seleccionats.
|
||||
|
||||
<DocsNote>
|
||||
|
||||
Selected items are merged in the order they appear in the files list.
|
||||
Reorder items by drag-and-drop if needed.
|
||||
|
||||
</DocsNote>
|
||||
|
||||
<div class="flex flex-row justify-center">
|
||||
<Merge class="text-foreground p-3 border rounded-md shadow-lg" />
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,6 @@ title: Soubory a statistiky
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
let hoveredPoint = writable(null);
|
||||
let additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable(undefined);
|
||||
</script>
|
||||
@@ -85,17 +84,19 @@ Pomocí kolečka myši můžete také výškový profil přiblížit a oddálit
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
<div class="flex flex-col items-center -mt-6">
|
||||
<div class="h-10 w-fit">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
panelSize={120}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
### Doplňující údaje
|
||||
|
||||
13
website/src/lib/docs/cs/home/funding.mdx
Normal file
@@ -0,0 +1,13 @@
|
||||
<script>
|
||||
import { HeartHandshake } from '@lucide/svelte';
|
||||
</script>
|
||||
|
||||
## <HeartHandshake size="18" class="inline-block align-baseline" /> Pomozte udržet web zdarma (a bez reklam)
|
||||
|
||||
Vždy, když přidáte nebo přesunete GPS body, naše servery vypočítají nejlepší cestu po silniční síti.
|
||||
Používáme také API z <a href="https://mapbox.com" target="_blank">Mapboxu</a> pro zobrazení krásných map, získání dat o nadmořské výšce a vyhledávání míst.
|
||||
|
||||
Bohužel, to vše je nákladné.
|
||||
Pokud rádi používáte tento nástroj a zdá se vám hodnotný, zvažte prosím malý příspěvek k udržení webu zdarma a bez reklam.
|
||||
|
||||
Děkujeme za vaši podporu! ❤️
|
||||
5
website/src/lib/docs/cs/home/mapbox.mdx
Normal file
@@ -0,0 +1,5 @@
|
||||
Mapbox je společnost poskytující některé z krásných map na tomto webu.
|
||||
Vyvíjí také <a href="https://github.com/mapbox/mapbox-gl-js" target="_blank">mapový engine</a> na jehož základě provozujeme **gpx.studio**.
|
||||
|
||||
Jsme velmi rádi a vděční za to, že můžeme být součástí jejich <a href="https://mapbox.com/community" target="_blank">komunitního</a> programu, který podporuje neziskové a vzdělávací organizace a organizace s pozitivním dopadem.
|
||||
Toto partnerství umožňuje **gpx.studio** využívat nástroje Mapboxu se slevou a přináší projektu finanční udržitelnost. Díky tomu vám můžeme nabídnout tu nejlepší uživatelskou přívětivost.
|
||||
12
website/src/lib/docs/cs/home/translation.mdx
Normal file
@@ -0,0 +1,12 @@
|
||||
<script>
|
||||
import { Languages } from '@lucide/svelte';
|
||||
</script>
|
||||
|
||||
## <Languages size="18" class="inline-block align-baseline" /> Překlad
|
||||
|
||||
Tento web je překládán dobrovolníky prostřednictvím kolaborativní překladatelské platformy.
|
||||
Ke zlepšení překladů můžete přispět na našem <a href="https://crowdin.com/project/gpxstudio" target="_blank">Crowdin projektu</a>.
|
||||
|
||||
Pokud byste chtěli zahájit překlad do nového jazyka, <a href="#contact">ozvěte se nám</a>, prosím.
|
||||
|
||||
Jakákoliv pomoc je velmi ceněna!
|
||||
@@ -13,8 +13,8 @@ Pomocí **gpx.studio** můžete vytvářet mapy se zobrazením souborů GPX a vk
|
||||
|
||||
Vše, co potřebujete, je:
|
||||
|
||||
1. GPX files hosted on your server or on Google Drive, or accessible via a public URL;
|
||||
2. _Optional:_ a <a href="https://cloud.maptiler.com/auth/widget?next=https://cloud.maptiler.com/maps/" target="_blank">MapTiler key</a> to load MapTiler maps.
|
||||
1. <a href="https://account.mapbox.com/auth/signup" target="_blank">Přístupový token Mapboxu</a> k načtení mapy,
|
||||
2. Soubory GPX umístěné na vašem serveru nebo na Disku Google, nebo přístupné prostřednictvím veřejné adresy URL.
|
||||
|
||||
V níže zobrazeném konfigurátoru si pak můžete mapu přizpůsobit a vygenerovat odpovídající kód HTML.
|
||||
|
||||
|
||||
@@ -56,12 +56,10 @@ Tlačítko mapové vrstvy umožňuje přepínat mezi různými podkladovými map
|
||||
- **Body zájmu** lze přidat do mapy a zobrazit tak různé kategorie míst, jako jsou obchody, restaurace nebo ubytování.
|
||||
|
||||
<div class="flex flex-col items-center">
|
||||
<DocsLayers />
|
||||
<span class="text-sm text-center mt-2">
|
||||
|
||||
Hover over the map to show the <a href="https://hiking.waymarkedtrails.org" target="_blank">Waymarked Trails hiking</a> overlay on top of the <a href="https://www.maptiler.com/maps/outdoor-topo/" target="_blank">MapTiler Topo</a> basemap.
|
||||
|
||||
</span>
|
||||
<DocsLayers />
|
||||
<span class="text-sm text-center mt-2">
|
||||
Po najetí myší nad mapu se zobrazí překryv<a href="https://hiking.waymarkedtrails.org" target="_blank">značených stezek pro pěší turistiku</a> na podkladové mapě <a href="https://www.mapbox.com/maps/outdoors" target="_blank">Mapbox Outdoors</a>.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
V **gpx.studio** je k dispozici rozsáhlá sbírka globálních a místních podkladových map a překryvů a také řada kategorií bodů zájmu.
|
||||
@@ -69,4 +67,4 @@ Lze je povolit v nabídce [nastavení mapových vrstev](./menu/settings).
|
||||
|
||||
V tomto nastavení můžete také spravovat neprůhlednost překryvů.
|
||||
|
||||
For advanced users, it is possible to add custom basemaps and overlays by providing <a href="https://en.wikipedia.org/wiki/Web_Map_Tile_Service" target="_blank">WMTS</a>, <a href="https://en.wikipedia.org/wiki/Web_Map_Service" target="_blank">WMS</a>, or <a href="https://maplibre.org/maplibre-style-spec/" target="_blank">MapLibre style JSON</a> URLs.
|
||||
Pokročilí uživatelé mohou přidávat vlastní podkladové mapy a překryvy pomocí <a href="https://en.wikipedia.org/wiki/Web_Map_Tile_Service" target="_blank">WMTS</a>, <a href="https://en.wikipedia.org/wiki/Web_Map_Service" target="_blank">WMS</a> nebo URL <a href="https://docs.mapbox.com/help/glossary/style/" target="_blank">Mapbox stylu JSON</a>.
|
||||
|
||||
@@ -18,7 +18,7 @@ Tento nástroj umožňuje přidat údaje o nadmořské výšce ke stopám a [bod
|
||||
|
||||
<DocsNote>
|
||||
|
||||
Elevation data is provided by <a href="https://maptiler.com" target="_blank">MapTiler</a>.
|
||||
You can learn more about its origin and accuracy in the <a href="https://docs.maptiler.com/guides/map-tiling-hosting/data-hosting/rgb-terrain-by-maptiler/" target="_blank">documentation</a>.
|
||||
Údaje o nadmořské výšce poskytuje <a href="https://mapbox.com" target="_blank">Mapbox</a>.
|
||||
Více informací o jejich původu a přesnosti najdete v <a href="https://docs.mapbox.com/data/tilesets/reference/mapbox-terrain-dem-v1/#elevation-data" target="_blank">dokumentaci</a>.
|
||||
|
||||
</DocsNote>
|
||||
|
||||
@@ -5,7 +5,6 @@ title: Sloučit
|
||||
<script>
|
||||
import { Group } from '@lucide/svelte';
|
||||
import Merge from '$lib/components/toolbar/tools/Merge.svelte';
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
</script>
|
||||
|
||||
# <Group size="24" class="inline-block" style="margin-bottom: 5px" /> { title }
|
||||
@@ -16,13 +15,6 @@ Chcete-li použít tento nástroj, musíte [vybrat](../files-and-stats) více so
|
||||
- Druhá možnost může být použita k vytvoření nebo správě souborů s více [trasami nebo segmenty](../gpx).
|
||||
Sloučením souborů (nebo tras) vznikne jeden soubor (nebo trasa) obsahující všechny trasy (nebo úseky) z výběru.
|
||||
|
||||
<DocsNote>
|
||||
|
||||
Selected items are merged in the order they appear in the files list.
|
||||
Reorder items by drag-and-drop if needed.
|
||||
|
||||
</DocsNote>
|
||||
|
||||
<div class="flex flex-row justify-center">
|
||||
<Merge class="text-foreground p-3 border rounded-md shadow-lg" />
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,6 @@ title: Files and statistics
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
let hoveredPoint = writable(null);
|
||||
let additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable(undefined);
|
||||
</script>
|
||||
@@ -85,17 +84,19 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
<div class="flex flex-col items-center -mt-6">
|
||||
<div class="h-10 w-fit">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
panelSize={120}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
### Additional data
|
||||
|
||||
13
website/src/lib/docs/da/home/funding.mdx
Normal file
@@ -0,0 +1,13 @@
|
||||
<script>
|
||||
import { HeartHandshake } from '@lucide/svelte';
|
||||
</script>
|
||||
|
||||
## <HeartHandshake size="18" class="inline-block align-baseline" /> Help keep the website free (and ad-free)
|
||||
|
||||
Each time you add or move GPS points, our servers calculate the best route on the road network.
|
||||
We also use APIs from <a href="https://mapbox.com" target="_blank">Mapbox</a> to display beautiful maps, retrieve elevation data and allow you to search for places.
|
||||
|
||||
Unfortunately, this is expensive.
|
||||
If you enjoy using this tool and find it valuable, please consider making a small donation to help keep the website free and ad-free.
|
||||
|
||||
Mange tak for din støtte! ❤️
|
||||
5
website/src/lib/docs/da/home/mapbox.mdx
Normal file
@@ -0,0 +1,5 @@
|
||||
Mapbox is the company that provides some of the beautiful maps on this website.
|
||||
They also develop the <a href="https://github.com/mapbox/mapbox-gl-js" target="_blank">map engine</a> which powers **gpx.studio**.
|
||||
|
||||
We are incredibly fortunate and grateful to be part of their <a href="https://mapbox.com/community" target="_blank">Community</a> program, which supports nonprofits, educational institutions, and positive impact organizations.
|
||||
Dette partnerskab tillader **gpx. tudio-** at drage fordel af Mapbox værktøjer til nedsatte priser i høj grad bidrage til projektets finansielle levedygtighed og sætte os i stand til at tilbyde den bedst mulige brugeroplevelse.
|
||||
12
website/src/lib/docs/da/home/translation.mdx
Normal file
@@ -0,0 +1,12 @@
|
||||
<script>
|
||||
import { Languages } from '@lucide/svelte';
|
||||
</script>
|
||||
|
||||
## <Languages size="18" class="inline-block align-baseline" /> Translation
|
||||
|
||||
Hjemmesiden er oversat af frivillige ved hjælp af en kollaborativ oversættelsesplatform.
|
||||
You can contribute by adding or improving translations on our <a href="https://crowdin.com/project/gpxstudio" target="_blank">Crowdin project</a>.
|
||||
|
||||
If you would like to start translating into a new language, please <a href="#contact">get in touch</a>.
|
||||
|
||||
Enhver hjælp er værdsat!
|
||||
@@ -13,8 +13,8 @@ You can use **gpx.studio** to create maps showing your GPX files and embed them
|
||||
|
||||
All you need is:
|
||||
|
||||
1. GPX files hosted on your server or on Google Drive, or accessible via a public URL;
|
||||
2. _Optional:_ a <a href="https://cloud.maptiler.com/auth/widget?next=https://cloud.maptiler.com/maps/" target="_blank">MapTiler key</a> to load MapTiler maps.
|
||||
1. A <a href="https://account.mapbox.com/auth/signup" target="_blank">Mapbox access token</a> to load the map, and
|
||||
2. GPX files hosted on your server or on Google Drive, or accessible via a public URL.
|
||||
|
||||
You can then play with the configurator below to customize your map and generate the corresponding HTML code.
|
||||
|
||||
|
||||