From 6f8c9d66db8f0d682f683df9b8e9f512b11e8529 Mon Sep 17 00:00:00 2001 From: vcoppe Date: Sun, 1 Feb 2026 17:18:17 +0100 Subject: [PATCH] use map layers for start/end/hover markers --- .../elevation-profile/ElevationProfile.svelte | 5 +- .../elevation-profile/elevation-profile.ts | 36 ++--- .../lib/components/embedding/Embedding.svelte | 5 +- .../embedding/EmbeddingPlayground.svelte | 4 +- .../lib/components/map/gpx-layer/gpx-layer.ts | 17 +- .../map/gpx-layer/start-end-markers.ts | 150 ++++++++++++++---- .../map/layer-control/overpass-layer.ts | 24 +-- website/src/lib/components/map/style.ts | 1 + .../toolbar/tools/scissors/split-controls.ts | 33 ++-- website/src/lib/docs/en/files-and-stats.mdx | 2 + website/src/lib/logic/statistics.ts | 4 +- website/src/lib/utils.ts | 12 ++ website/src/routes/[[language]]/+page.svelte | 2 + .../src/routes/[[language]]/app/+page.svelte | 3 +- 14 files changed, 181 insertions(+), 117 deletions(-) diff --git a/website/src/lib/components/elevation-profile/ElevationProfile.svelte b/website/src/lib/components/elevation-profile/ElevationProfile.svelte index df469e107..11db47106 100644 --- a/website/src/lib/components/elevation-profile/ElevationProfile.svelte +++ b/website/src/lib/components/elevation-profile/ElevationProfile.svelte @@ -18,7 +18,7 @@ Construction, } from '@lucide/svelte'; import type { Readable, Writable } from 'svelte/store'; - import type { GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx'; + import type { Coordinates, GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx'; import { settings } from '$lib/logic/settings'; import { i18n } from '$lib/i18n.svelte'; import { ElevationProfile } from '$lib/components/elevation-profile/elevation-profile'; @@ -28,12 +28,14 @@ let { gpxStatistics, slicedGPXStatistics, + hoveredPoint, additionalDatasets, elevationFill, showControls = true, }: { gpxStatistics: Readable; slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>; + hoveredPoint: Writable; additionalDatasets: Writable; elevationFill: Writable<'slope' | 'surface' | 'highway' | undefined>; showControls?: boolean; @@ -47,6 +49,7 @@ elevationProfile = new ElevationProfile( gpxStatistics, slicedGPXStatistics, + hoveredPoint, additionalDatasets, elevationFill, canvas, diff --git a/website/src/lib/components/elevation-profile/elevation-profile.ts b/website/src/lib/components/elevation-profile/elevation-profile.ts index d63ffd523..4aa8b969f 100644 --- a/website/src/lib/components/elevation-profile/elevation-profile.ts +++ b/website/src/lib/components/elevation-profile/elevation-profile.ts @@ -20,10 +20,8 @@ import Chart, { type ScriptableLineSegmentContext, type TooltipItem, } from 'chart.js/auto'; -import maplibregl from 'maplibre-gl'; import { get, type Readable, type Writable } from 'svelte/store'; -import { map } from '$lib/components/map/map'; -import type { GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx'; +import type { Coordinates, GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx'; import { mode } from 'mode-watcher'; import { getHighwayColor, getSlopeColor, getSurfaceColor } from '$lib/assets/colors'; @@ -42,7 +40,7 @@ interface ElevationProfilePoint { length: number; }; extensions: Record; - coordinates: [number, number]; + coordinates: Coordinates; index: number; } @@ -50,18 +48,19 @@ export class ElevationProfile { private _chart: Chart | null = null; private _canvas: HTMLCanvasElement; private _overlay: HTMLCanvasElement; - private _marker: maplibregl.Marker | null = null; private _dragging = false; private _panning = false; private _gpxStatistics: Readable; private _slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>; + private _hoveredPoint: Writable; private _additionalDatasets: Readable; private _elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>; constructor( gpxStatistics: Readable, slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>, + hoveredPoint: Writable, additionalDatasets: Readable, elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>, canvas: HTMLCanvasElement, @@ -69,17 +68,12 @@ 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 maplibregl.Marker({ - element, - }); - import('chartjs-plugin-zoom').then((module) => { Chart.register(module.default); this.initialize(); @@ -162,14 +156,10 @@ export class ElevationProfile { label: (context: TooltipItem<'line'>) => { let point = context.raw as ElevationProfilePoint; if (context.datasetIndex === 0) { - const map_ = get(map); - if (map_ && this._marker) { - if (this._dragging) { - this._marker.remove(); - } else { - this._marker.setLngLat(point.coordinates); - this._marker.addTo(map_); - } + if (this._dragging) { + this._hoveredPoint.set(null); + } else { + this._hoveredPoint.set(point.coordinates); } return `${i18n._('quantities.elevation')}: ${getElevationWithUnits(point.y, false)}`; } else if (context.datasetIndex === 1) { @@ -312,10 +302,7 @@ export class ElevationProfile { events: ['mouseout'], afterEvent: (chart: Chart, args: { event: ChartEvent }) => { if (args.event.type === 'mouseout') { - const map_ = get(map); - if (map_ && this._marker) { - this._marker.remove(); - } + this._hoveredPoint.set(null); } }, }, @@ -637,8 +624,5 @@ export class ElevationProfile { this._chart.destroy(); this._chart = null; } - if (this._marker) { - this._marker.remove(); - } } } diff --git a/website/src/lib/components/embedding/Embedding.svelte b/website/src/lib/components/embedding/Embedding.svelte index 48d0bd931..bba44c349 100644 --- a/website/src/lib/components/embedding/Embedding.svelte +++ b/website/src/lib/components/embedding/Embedding.svelte @@ -16,7 +16,7 @@ import { setMode } from 'mode-watcher'; import { settings } from '$lib/logic/settings'; import { fileStateCollection } from '$lib/logic/file-state'; - import { gpxStatistics, slicedGPXStatistics } from '$lib/logic/statistics'; + import { gpxStatistics, hoveredPoint, slicedGPXStatistics } from '$lib/logic/statistics'; import { loadFile } from '$lib/logic/file-actions'; import { selection } from '$lib/logic/selection'; import { untrack } from 'svelte'; @@ -102,7 +102,7 @@
{ const iconId = `waypoint-${symbol ?? 'default'}-${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)); - } + loadSVGIcon(_map, iconId, getSvgForSymbol(symbol, this.layerColor)); }); } } diff --git a/website/src/lib/components/map/gpx-layer/start-end-markers.ts b/website/src/lib/components/map/gpx-layer/start-end-markers.ts index 6f021db17..59e38a762 100644 --- a/website/src/lib/components/map/gpx-layer/start-end-markers.ts +++ b/website/src/lib/components/map/gpx-layer/start-end-markers.ts @@ -1,30 +1,40 @@ import { currentTool, Tool } from '$lib/components/toolbar/tools'; -import { gpxStatistics, slicedGPXStatistics } from '$lib/logic/statistics'; -import maplibregl from 'maplibre-gl'; +import { gpxStatistics, hoveredPoint, slicedGPXStatistics } from '$lib/logic/statistics'; +import type { GeoJSONSource } from 'maplibre-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 = ` + +`; + +const endMarkerSVG = ` + + + + + + + + + +`; +const hoverMarkerSVG = ` + +`; export class StartEndMarkers { - start: maplibregl.Marker; - end: maplibregl.Marker; updateBinded: () => void = this.update.bind(this); unsubscribes: (() => void)[] = []; constructor() { - 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 maplibregl.Marker({ element: startElement }); - this.end = new maplibregl.Marker({ element: endElement }); - map.onLoad(() => this.update()); this.unsubscribes.push(gpxStatistics.subscribe(this.updateBinded)); this.unsubscribes.push(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)); } @@ -33,33 +43,113 @@ 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 (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_); + const start = statistics + .getTrackPoint(slicedStatistics?.[1] ?? 0)! + .trkpt.getCoordinates(); + const end = statistics + .getTrackPoint(slicedStatistics?.[2] ?? statistics.global.length - 1)! + .trkpt.getCoordinates(); + const data: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [start.lon, start.lat], + }, + properties: { + icon: 'start-marker', + }, + }, + { + 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 + ); + } } else { - this.start.remove(); - this.end.remove(); + if (map_.getLayer('start-end-markers')) { + map_.removeLayer('start-end-markers'); + } + if (map_.getSource('start-end-markers')) { + map_.removeSource('start-end-markers'); + } } } remove() { this.unsubscribes.forEach((unsubscribe) => unsubscribe()); - this.start.remove(); - this.end.remove(); + 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); } } diff --git a/website/src/lib/components/map/layer-control/overpass-layer.ts b/website/src/lib/components/map/layer-control/overpass-layer.ts index b14fdf8c6..f3d92c54b 100644 --- a/website/src/lib/components/map/layer-control/overpass-layer.ts +++ b/website/src/lib/components/map/layer-control/overpass-layer.ts @@ -9,6 +9,7 @@ import { db } from '$lib/db'; import type { GeoJSONSource } from 'maplibre-gl'; import { ANCHOR_LAYER_KEY } from '../style'; import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager'; +import { loadSVGIcon } from '$lib/utils'; const { currentOverpassQueries } = settings; @@ -257,27 +258,16 @@ export class OverpassLayer { loadIcons() { let currentQueries = getCurrentQueries(); currentQueries.forEach((query) => { - 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(` - + loadSVGIcon( + this.map, + `overpass-${query}`, + ` ${overpassQueryData[query].icon.svg.replace('stroke="currentColor"', 'stroke="white"')} - - `); - } + ` + ); }); } } diff --git a/website/src/lib/components/map/style.ts b/website/src/lib/components/map/style.ts index 03a703279..274ebad6d 100644 --- a/website/src/lib/components/map/style.ts +++ b/website/src/lib/components/map/style.ts @@ -25,6 +25,7 @@ export const ANCHOR_LAYER_KEY = { 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', diff --git a/website/src/lib/components/toolbar/tools/scissors/split-controls.ts b/website/src/lib/components/toolbar/tools/scissors/split-controls.ts index 2d59a57ef..bc5bf6d95 100644 --- a/website/src/lib/components/toolbar/tools/scissors/split-controls.ts +++ b/website/src/lib/components/toolbar/tools/scissors/split-controls.ts @@ -11,6 +11,7 @@ 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'; export class SplitControls { map: maplibregl.Map; @@ -24,28 +25,16 @@ export class SplitControls { constructor(map: maplibregl.Map, layerEventManager: MapLayerEventManager) { this.map = map; this.layerEventManager = layerEventManager; - - 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(` - - - - ${Scissors.replace('stroke="currentColor"', 'stroke="black"')} - - - `); - } + loadSVGIcon( + this.map, + 'split-control', + ` + + + ${Scissors.replace('stroke="currentColor"', 'stroke="black"')} + + ` + ); this.unsubscribes.push(gpxStatistics.subscribe(this.addIfNeeded.bind(this))); this.unsubscribes.push(currentTool.subscribe(this.addIfNeeded.bind(this))); diff --git a/website/src/lib/docs/en/files-and-stats.mdx b/website/src/lib/docs/en/files-and-stats.mdx index 634f19f92..09ded2888 100644 --- a/website/src/lib/docs/en/files-and-stats.mdx +++ b/website/src/lib/docs/en/files-and-stats.mdx @@ -12,6 +12,7 @@ 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); @@ -84,6 +85,7 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an diff --git a/website/src/lib/logic/statistics.ts b/website/src/lib/logic/statistics.ts index 1193c21a0..45322ce9c 100644 --- a/website/src/lib/logic/statistics.ts +++ b/website/src/lib/logic/statistics.ts @@ -1,5 +1,5 @@ import { selection } from '$lib/logic/selection'; -import { GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx'; +import { GPXGlobalStatistics, GPXStatisticsGroup, type Coordinates } from 'gpx'; import { fileStateCollection, GPXFileState } from '$lib/logic/file-state'; import { ListFileItem, @@ -82,6 +82,8 @@ export const gpxStatistics = new SelectedGPXStatistics(); export const slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined> = writable(undefined); +export const hoveredPoint: Writable = writable(null); + gpxStatistics.subscribe(() => { slicedGPXStatistics.set(undefined); }); diff --git a/website/src/lib/utils.ts b/website/src/lib/utils.ts index 5237a6bdc..5e335ed65 100644 --- a/website/src/lib/utils.ts +++ b/website/src/lib/utils.ts @@ -197,6 +197,18 @@ export function getElevation( ); } +export function loadSVGIcon(map: maplibregl.Map, id: string, svg: string) { + if (!map.hasImage(id)) { + let icon = new Image(100, 100); + icon.onload = () => { + if (!map.hasImage(id)) { + map.addImage(id, icon); + } + }; + icon.src = 'data:image/svg+xml,' + encodeURIComponent(svg); + } +} + export function isMac() { return navigator.userAgent.toUpperCase().indexOf('MAC') >= 0; } diff --git a/website/src/routes/[[language]]/+page.svelte b/website/src/routes/[[language]]/+page.svelte index 9a0a4f7bb..3268ca8b4 100644 --- a/website/src/routes/[[language]]/+page.svelte +++ b/website/src/routes/[[language]]/+page.svelte @@ -35,6 +35,7 @@ let gpxStatistics = writable(exampleGPXFile.getStatistics()); let slicedGPXStatistics = writable(undefined); + let hoveredPoint = writable(null); let additionalDatasets = writable(['speed', 'atemp']); let elevationFill = writable(undefined); @@ -197,6 +198,7 @@ diff --git a/website/src/routes/[[language]]/app/+page.svelte b/website/src/routes/[[language]]/app/+page.svelte index 56f0098d5..6c5002e18 100644 --- a/website/src/routes/[[language]]/app/+page.svelte +++ b/website/src/routes/[[language]]/app/+page.svelte @@ -16,7 +16,7 @@ import { loadFiles } from '$lib/logic/file-actions'; import { onDestroy, onMount } from 'svelte'; import { page } from '$app/state'; - import { gpxStatistics, slicedGPXStatistics } from '$lib/logic/statistics'; + import { gpxStatistics, hoveredPoint, slicedGPXStatistics } from '$lib/logic/statistics'; import { getURLForGoogleDriveFile } from '$lib/components/embedding/embedding'; import { db } from '$lib/db'; import { fileStateCollection } from '$lib/logic/file-state'; @@ -140,6 +140,7 @@