diff --git a/website/src/lib/assets/layers.ts b/website/src/lib/assets/layers.ts index 513f9047..8aaed0ed 100644 --- a/website/src/lib/assets/layers.ts +++ b/website/src/lib/assets/layers.ts @@ -1,8 +1,10 @@ import { type AnySourceData, type Style } from 'mapbox-gl'; +export const mapboxAccessToken = 'pk.eyJ1IjoiZ3B4c3R1ZGlvIiwiYSI6ImNrdHVoM2pjNTBodmUycG1yZTNwcnJ3MzkifQ.YZnNs9s9oCQPzoXAWs_SLg'; + export const basemaps: { [key: string]: string | Style; } = { mapboxOutdoors: 'mapbox://styles/mapbox/outdoors-v12', - mapboxSatellite: 'mapbox://styles/mapbox/satellite-v9', + mapboxSatellite: 'mapbox://styles/mapbox/satellite-streets-v12', openStreetMap: { version: 8, sources: { @@ -281,6 +283,7 @@ export const basemaps: { [key: string]: string | Style; } = { Object.values(basemaps).forEach((basemap) => { if (typeof basemap === 'object') { basemap["glyphs"] = "mapbox://fonts/mapbox/{fontstack}/{range}.pbf"; + basemap["sprite"] = `https://api.mapbox.com/styles/v1/mapbox/outdoors-v12/sprite?access_token=${mapboxAccessToken}`; } }); diff --git a/website/src/lib/components/FileListItem.svelte b/website/src/lib/components/FileListItem.svelte index 944118e1..e9fe7ab0 100644 --- a/website/src/lib/components/FileListItem.svelte +++ b/website/src/lib/components/FileListItem.svelte @@ -6,20 +6,19 @@ import { get, type Readable } from 'svelte/store'; import { selectedFiles, selectFiles } from '$lib/stores'; - import { dbUtils } from '$lib/db'; + import { dbUtils, type GPXFileWithStatistics } from '$lib/db'; import { _ } from 'svelte-i18n'; - import type { GPXFile } from 'gpx'; - export let file: Readable; + export let file: Readable; {#if $file}
{ - if (!get(selectedFiles).has($file._data.id)) { - get(selectFiles).select($file._data.id); + if (!get(selectedFiles).has($file.file._data.id)) { + get(selectFiles).select($file.file._data.id); } }} > @@ -33,13 +32,13 @@ class="w-full h-full px-1.5 py-2" on:contextmenu={(e) => { if (e.ctrlKey) { - get(selectFiles).addSelect($file._data.id); + get(selectFiles).addSelect($file.file._data.id); e.stopPropagation(); e.preventDefault(); } }} > - {$file.metadata.name} + {$file.file.metadata.name} diff --git a/website/src/lib/components/Map.svelte b/website/src/lib/components/Map.svelte index e5589262..4220b6f4 100644 --- a/website/src/lib/components/Map.svelte +++ b/website/src/lib/components/Map.svelte @@ -11,9 +11,9 @@ import { settings } from '$lib/db'; import { locale } from 'svelte-i18n'; import { get } from 'svelte/store'; + import { mapboxAccessToken } from '$lib/assets/layers'; - mapboxgl.accessToken = - 'pk.eyJ1IjoiZ3B4c3R1ZGlvIiwiYSI6ImNrdHVoM2pjNTBodmUycG1yZTNwcnJ3MzkifQ.YZnNs9s9oCQPzoXAWs_SLg'; + mapboxgl.accessToken = mapboxAccessToken; let fitBoundsOptions: mapboxgl.FitBoundsOptions = { maxZoom: 15, diff --git a/website/src/lib/components/gpx-layer/GPXLayer.ts b/website/src/lib/components/gpx-layer/GPXLayer.ts index 768bc87a..12c57e52 100644 --- a/website/src/lib/components/gpx-layer/GPXLayer.ts +++ b/website/src/lib/components/gpx-layer/GPXLayer.ts @@ -1,6 +1,5 @@ -import type { GPXFile } from "gpx"; import { map, selectFiles, currentTool, Tool } from "$lib/stores"; -import { settings } from "$lib/db"; +import { settings, type GPXFileWithStatistics } from "$lib/db"; import { get, type Readable } from "svelte/store"; import mapboxgl from "mapbox-gl"; @@ -37,12 +36,12 @@ function decrementColor(color: string) { colorCount[color]--; } -const { directionMarkers } = settings; +const { directionMarkers, distanceMarkers, distanceUnits } = settings; export class GPXLayer { map: mapboxgl.Map; fileId: string; - file: Readable; + file: Readable; layerColor: string; popup: mapboxgl.Popup; popupElement: HTMLElement; @@ -52,35 +51,41 @@ export class GPXLayer { updateBinded: () => void = this.update.bind(this); selectOnClickBinded: (e: any) => void = this.selectOnClick.bind(this); - constructor(map: mapboxgl.Map, fileId: string, file: Readable, popup: mapboxgl.Popup, popupElement: HTMLElement) { + constructor(map: mapboxgl.Map, fileId: string, file: Readable, popup: mapboxgl.Popup, popupElement: HTMLElement) { this.map = map; this.fileId = fileId; - this.file = file + this.file = file; this.layerColor = getColor(); this.popup = popup; this.popupElement = popupElement; this.unsubscribe.push(file.subscribe(this.updateBinded)); this.unsubscribe.push(directionMarkers.subscribe(this.updateBinded)); + this.unsubscribe.push(distanceMarkers.subscribe(this.updateBinded)); + this.unsubscribe.push(distanceUnits.subscribe(() => { + if (get(distanceMarkers)) { + this.update(); + } + })); this.map.on('style.load', this.updateBinded); } update() { - let file = get(this.file); + let file = get(this.file)?.file; if (!file) { return; } - let addedSource = false; try { - if (!this.map.getSource(this.fileId)) { - let data = this.getGeoJSON(); + let source = this.map.getSource(this.fileId); + if (source) { + source.setData(this.getGeoJSON()); + } else { this.map.addSource(this.fileId, { type: 'geojson', - data + data: this.getGeoJSON() }); - addedSource = true; } if (!this.map.getLayer(this.fileId)) { @@ -104,7 +109,6 @@ export class GPXLayer { this.map.on('mouseleave', this.fileId, toDefaultCursor); } - if (get(directionMarkers)) { if (!this.map.getLayer(this.fileId + '-direction')) { this.map.addLayer({ @@ -115,6 +119,7 @@ export class GPXLayer { 'text-field': '>', 'text-keep-upright': false, 'text-max-angle': 361, + 'text-allow-overlap': true, 'symbol-placement': 'line', 'symbol-spacing': 25, }, @@ -130,17 +135,47 @@ export class GPXLayer { this.map.removeLayer(this.fileId + '-direction'); } } + + if (get(distanceMarkers)) { + let distanceSource = this.map.getSource(this.fileId + '-distance'); + if (distanceSource) { + distanceSource.setData(this.getDistanceMarkersGeoJSON()); + } else { + this.map.addSource(this.fileId + '-distance', { + type: 'geojson', + data: this.getDistanceMarkersGeoJSON() + }); + } + if (!this.map.getLayer(this.fileId + '-distance')) { + this.map.addLayer({ + id: this.fileId + '-distance', + type: 'symbol', + source: this.fileId + '-distance', + layout: { + 'text-field': ['get', 'distance'], + 'text-size': 12, + 'text-font': ['Open Sans Regular'], + 'icon-image': ['get', 'icon'], + 'icon-padding': 50, + 'icon-allow-overlap': true, + }, + paint: { + 'text-halo-width': 0.1, + 'text-halo-color': 'black' + } + }); + } else { + this.map.moveLayer(this.fileId + '-distance'); + } + } else { + if (this.map.getLayer(this.fileId + '-distance')) { + this.map.removeLayer(this.fileId + '-distance'); + } + } } catch (e) { // No reliable way to check if the map is ready to add sources and layers return; } - if (!addedSource) { - let source = this.map.getSource(this.fileId); - if (source) { - source.setData(this.getGeoJSON()); - } - } - let markerIndex = 0; file.wpt.forEach((waypoint) => { // Update markers if (markerIndex < this.markers.length) { @@ -176,6 +211,9 @@ export class GPXLayer { if (this.map.getLayer(this.fileId + '-direction')) { this.map.removeLayer(this.fileId + '-direction'); } + if (this.map.getLayer(this.fileId + '-distance')) { + this.map.removeLayer(this.fileId + '-distance'); + } if (this.map.getLayer(this.fileId)) { this.map.removeLayer(this.fileId); } @@ -199,6 +237,9 @@ export class GPXLayer { if (this.map.getLayer(this.fileId + '-direction')) { this.map.moveLayer(this.fileId + '-direction'); } + if (this.map.getLayer(this.fileId + '-distance')) { + this.map.moveLayer(this.fileId + '-distance'); + } } selectOnClick(e: any) { @@ -213,7 +254,7 @@ export class GPXLayer { } getGeoJSON(): GeoJSON.FeatureCollection { - let file = get(this.file); + let file = get(this.file)?.file; if (!file) { return { type: 'FeatureCollection', @@ -238,6 +279,41 @@ export class GPXLayer { } return data; } + + getDistanceMarkersGeoJSON(): GeoJSON.FeatureCollection { + let statistics = get(this.file)?.statistics; + if (!statistics) { + return { + type: 'FeatureCollection', + features: [] + }; + } + + let features = []; + let currentTargetDistance = 1; + for (let i = 0; i < statistics.local.distance.length; i++) { + if (statistics.local.distance[i] >= currentTargetDistance * (get(distanceUnits) === 'metric' ? 1 : 1.60934)) { + let distance = currentTargetDistance.toFixed(0); + features.push({ + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [statistics.local.points[i].getLongitude(), statistics.local.points[i].getLatitude()] + }, + properties: { + distance, + icon: distance.length < 3 ? 'circle-white-2' : 'circle-white-3' + } + } as GeoJSON.Feature); + currentTargetDistance += 1; + } + } + + return { + type: 'FeatureCollection', + features + }; + } } function toPointerCursor() { diff --git a/website/src/lib/components/toolbar/tools/routing/RoutingControls.ts b/website/src/lib/components/toolbar/tools/routing/RoutingControls.ts index d5905494..e822136f 100644 --- a/website/src/lib/components/toolbar/tools/routing/RoutingControls.ts +++ b/website/src/lib/components/toolbar/tools/routing/RoutingControls.ts @@ -1,5 +1,5 @@ -import { distance, type Coordinates, type GPXFile, TrackPoint, TrackSegment } from "gpx"; -import { get, type Readable, type Writable } from "svelte/store"; +import { distance, type Coordinates, TrackPoint, TrackSegment } from "gpx"; +import { get, type Readable } from "svelte/store"; import { computeAnchorPoints } from "./Simplify"; import mapboxgl from "mapbox-gl"; import { route } from "./Routing"; @@ -7,12 +7,12 @@ import { route } from "./Routing"; import { toast } from "svelte-sonner"; import { _ } from "svelte-i18n"; -import { dbUtils } from "$lib/db"; +import { dbUtils, type GPXFileWithStatistics } from "$lib/db"; export class RoutingControls { map: mapboxgl.Map; fileId: string = ''; - file: Readable; + file: Readable; anchors: AnchorWithMarker[] = []; shownAnchors: AnchorWithMarker[] = []; popup: mapboxgl.Popup; @@ -25,7 +25,7 @@ export class RoutingControls { updateTemporaryAnchorBinded: (e: any) => void = this.updateTemporaryAnchor.bind(this); appendAnchorBinded: (e: mapboxgl.MapMouseEvent) => void = this.appendAnchor.bind(this); - constructor(map: mapboxgl.Map, fileId: string, file: Readable, popup: mapboxgl.Popup, popupElement: HTMLElement) { + constructor(map: mapboxgl.Map, fileId: string, file: Readable, popup: mapboxgl.Popup, popupElement: HTMLElement) { this.map = map; this.fileId = fileId; this.file = file; @@ -54,7 +54,7 @@ export class RoutingControls { } updateControls() { // Update the markers when the file changes - let file = get(this.file); + let file = get(this.file)?.file; if (!file) { return; } @@ -263,8 +263,10 @@ export class RoutingControls { } getPermanentAnchor(): Anchor { + let file = get(this.file)?.file; + // Find the closest point closest to the temporary anchor - let segments = get(this.file).getSegments(); + let segments = file.getSegments(); let minDistance = Number.MAX_VALUE; let minAnchor = this.temporaryAnchor as Anchor; for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex++) { @@ -350,7 +352,12 @@ export class RoutingControls { } routeToStart() { - let segments = get(this.file).getSegments(); + let file = get(this.file)?.file; + if (!file) { + return; + } + + let segments = file.getSegments(); if (segments.length === 0) { return; } @@ -366,7 +373,12 @@ export class RoutingControls { } createRoundTrip() { - let segments = get(this.file).getSegments(); + let file = get(this.file)?.file; + if (!file) { + return; + } + + let segments = file.getSegments(); if (segments.length === 0) { return; } diff --git a/website/src/lib/db.ts b/website/src/lib/db.ts index ac756bd8..5fe268a6 100644 --- a/website/src/lib/db.ts +++ b/website/src/lib/db.ts @@ -1,8 +1,8 @@ import Dexie, { liveQuery } from 'dexie'; -import { GPXFile } from 'gpx'; +import { GPXFile, GPXStatistics } from 'gpx'; import { enableMapSet, enablePatches, produceWithPatches, applyPatches, type Patch } from 'immer'; import { writable, get, derived, type Readable, type Writable } from 'svelte/store'; -import { fileOrder, selectedFiles } from './stores'; +import { fileOrder, initTargetMapBounds, selectedFiles, updateTargetMapBounds } from './stores'; import { mode } from 'mode-watcher'; import { defaultBasemap, defaultBasemapTree, defaultOverlayTree, defaultOverlays } from './assets/layers'; @@ -107,14 +107,23 @@ function dexieStore(querier: () => T | Promise, initial?: T): Readable }; } +export type GPXFileWithStatistics = { file: GPXFile, statistics: GPXStatistics }; + // Wrap Dexie live queries in a Svelte store to avoid triggering the query for every subscriber, also takes care of the conversion to a GPXFile object -function dexieGPXFileStore(querier: () => GPXFile | undefined | Promise): Readable { - let store = writable(undefined); +function dexieGPXFileStore(querier: () => GPXFile | undefined | Promise): Readable { + let store = writable(undefined); liveQuery(querier).subscribe(value => { if (value !== undefined) { let gpx = new GPXFile(value); + let statistics = gpx.getStatistics(); + if (!fileState.has(gpx._data.id)) { // Update the map bounds for new files + updateTargetMapBounds(statistics.global.bounds); + } fileState.set(gpx._data.id, gpx); - store.set(gpx); + store.set({ + file: gpx, + statistics + }); } }); return { @@ -155,7 +164,7 @@ function commitFileStateChange(newFileState: ReadonlyMap, patch } } -export const fileObservers: Writable>> = writable(new Map()); +export const fileObservers: Writable>> = writable(new Map()); const fileState: Map = new Map(); // Used to generate patches // Observe the file ids in the database, and maintain a map of file observers for the corresponding files @@ -164,6 +173,11 @@ liveQuery(() => db.fileids.toArray()).subscribe(dbFileIds => { let newFiles = dbFileIds.filter(id => !get(fileObservers).has(id)).sort((a, b) => parseInt(a.split('-')[1]) - parseInt(b.split('-')[1])); // Find deleted files to stop observing let deletedFiles = Array.from(get(fileObservers).keys()).filter(id => !dbFileIds.find(fileId => fileId === id)); + + if (newFiles.length > 0) { // Reset the target map bounds when new files are added + initTargetMapBounds(fileState.size === 0); + } + // Update the store if (newFiles.length > 0 || deletedFiles.length > 0) { fileObservers.update($files => { diff --git a/website/src/lib/stores.ts b/website/src/lib/stores.ts index c647b0cd..9e3ad277 100644 --- a/website/src/lib/stores.ts +++ b/website/src/lib/stores.ts @@ -1,7 +1,7 @@ import { writable, get, type Writable } from 'svelte/store'; import mapboxgl from 'mapbox-gl'; -import { GPXFile, buildGPX, parseGPX, GPXStatistics } from 'gpx'; +import { GPXFile, buildGPX, parseGPX, GPXStatistics, type Coordinates } from 'gpx'; import { tick } from 'svelte'; import { _ } from 'svelte-i18n'; import type { GPXLayer } from '$lib/components/gpx-layer/GPXLayer'; @@ -28,86 +28,31 @@ fileObservers.subscribe((files) => { // Update selectedFiles automatically when } }); -const targetMapBounds = writable({ - bounds: new mapboxgl.LngLatBounds([180, 90, -180, -90]), - initial: true -}); -const fileStatistics: Map> = new Map(); -const fileUnsubscribe: Map = new Map(); export const gpxStatistics: Writable = writable(new GPXStatistics()); function updateGPXData() { - let fileIds: string[] = get(fileOrder).filter((f) => fileStatistics.has(f) && get(selectedFiles).has(f)); + let fileIds: string[] = get(fileOrder).filter((f) => get(selectedFiles).has(f)); gpxStatistics.set(fileIds.reduce((stats: GPXStatistics, fileId: string) => { - let statisticsStore = fileStatistics.get(fileId); - if (statisticsStore) { - stats.mergeWith(get(statisticsStore)); + let fileStore = get(fileObservers).get(fileId); + if (fileStore) { + let statistics = get(fileStore)?.statistics; + if (statistics) { + stats.mergeWith(statistics); + } } return stats; }, new GPXStatistics())); } -fileObservers.subscribe((files) => { // Maintain up-to-date statistics - if (files.size > fileStatistics.size) { // Files are added - let bounds = new mapboxgl.LngLatBounds([180, 90, -180, -90]); - let mapBounds = new mapboxgl.LngLatBounds([180, 90, -180, -90]); - if (fileStatistics.size > 0) { // Some files are already loaded - mapBounds = get(map)?.getBounds() ?? mapBounds; - bounds.extend(mapBounds); - } - targetMapBounds.set({ - bounds: bounds, - initial: true - }); - } - - fileStatistics.forEach((stats, fileId) => { - if (!files.has(fileId)) { - fileStatistics.delete(fileId); - let unsubscribe = fileUnsubscribe.get(fileId); - if (unsubscribe) unsubscribe(); - fileUnsubscribe.delete(fileId); - } - }); - files.forEach((fileObserver, fileId) => { - if (!fileStatistics.has(fileId)) { - let statisticsStore = writable(new GPXStatistics()); - fileStatistics.set(fileId, statisticsStore); - let unsubscribe = fileObserver.subscribe((file) => { - if (file) { - statisticsStore.set(file.getStatistics()); - if (get(selectedFiles).has(fileId)) { - updateGPXData(); - } - } - }); - fileUnsubscribe.set(fileId, unsubscribe); - - let boundsUnsubscribe = statisticsStore.subscribe((stats) => { - let fileBounds = stats.global.bounds; - if (fileBounds.southWest.lat == 90 && fileBounds.southWest.lon == 180 && fileBounds.northEast.lat == -90 && fileBounds.northEast.lon == -180) { // Stats are not yet calculated - return; - } - if (fileBounds.southWest.lat != fileBounds.northEast.lat || fileBounds.southWest.lon != fileBounds.northEast.lon) { // Avoid update for new files - targetMapBounds.update((target) => { - target.bounds.extend(fileBounds.southWest); - target.bounds.extend(fileBounds.northEast); - target.bounds.extend([fileBounds.southWest.lon, fileBounds.northEast.lat]); - target.bounds.extend([fileBounds.northEast.lon, fileBounds.southWest.lat]); - target.initial = false; - return target; - }); - } - boundsUnsubscribe(); - }) - } - }); -}); - selectedFiles.subscribe((selectedFiles) => { // Maintain up-to-date statistics for the current selection updateGPXData(); }); +const targetMapBounds = writable({ + bounds: new mapboxgl.LngLatBounds([180, 90, -180, -90]), + initial: true +}); + targetMapBounds.subscribe((bounds) => { if (bounds.initial) { return; @@ -120,6 +65,36 @@ targetMapBounds.subscribe((bounds) => { }); }); + +export function initTargetMapBounds(first: boolean) { + let bounds = new mapboxgl.LngLatBounds([180, 90, -180, -90]); + let mapBounds = new mapboxgl.LngLatBounds([180, 90, -180, -90]); + if (!first) { // Some files are already loaded + mapBounds = get(map)?.getBounds() ?? mapBounds; + bounds.extend(mapBounds); + } + targetMapBounds.set({ + bounds: bounds, + initial: true + }); +} + +export function updateTargetMapBounds(bounds: { + southWest: Coordinates, + northEast: Coordinates +}) { + if (bounds.southWest.lat == 90 && bounds.southWest.lon == 180 && bounds.northEast.lat == -90 && bounds.northEast.lon == -180) { // Avoid update for empty (new) files + return; + } + + targetMapBounds.update((target) => { + target.bounds.extend(bounds.southWest); + target.bounds.extend(bounds.northEast); + target.initial = false; + return target; + }); +} + export const gpxLayers: Writable> = writable(new Map()); export enum Tool {