From e1bc3be69859afd5d235d8fb0a4b602c5bd5bd5a Mon Sep 17 00:00:00 2001 From: Christian Beutel <> Date: Thu, 24 Apr 2025 19:22:32 +0200 Subject: [PATCH] map performance improvements --- .../trail/map_with_elevation_maplibre.svelte | 219 ++++++++++++------ web/src/lib/stores/search_store.ts | 1 + web/src/lib/util/geojson_util.ts | 2 +- web/src/routes/map/+page.svelte | 17 +- 4 files changed, 164 insertions(+), 75 deletions(-) diff --git a/web/src/lib/components/trail/map_with_elevation_maplibre.svelte b/web/src/lib/components/trail/map_with_elevation_maplibre.svelte index 79a1d06f..1c5271ed 100644 --- a/web/src/lib/components/trail/map_with_elevation_maplibre.svelte +++ b/web/src/lib/components/trail/map_with_elevation_maplibre.svelte @@ -5,6 +5,7 @@ import type { Trail } from "$lib/models/trail"; import type { Waypoint } from "$lib/models/waypoint"; import { theme } from "$lib/stores/theme_store"; + import { fetchGPX } from "$lib/stores/trail_store"; import { findStartAndEndPoints } from "$lib/util/geojson_util"; import { toGeoJson } from "$lib/util/gpx_util"; import { @@ -17,12 +18,8 @@ import { FullscreenControl } from "$lib/vendor/maplibre-fullscreen/fullscreen-control"; import MaplibreGraticule from "$lib/vendor/maplibre-graticule/maplibre-graticule"; import { StyleSwitcherControl } from "$lib/vendor/maplibre-style-switcher/style-switcher-control"; - import type { - Feature, - FeatureCollection, - GeoJSON, - Geometry, - } from "geojson"; + import { T } from "@threlte/core"; + import type { Feature, FeatureCollection, GeoJSON } from "geojson"; import * as M from "maplibre-gl"; import "maplibre-gl/dist/maplibre-gl.css"; import { onDestroy, onMount, untrack } from "svelte"; @@ -46,7 +43,7 @@ elevationProfileContainer?: string | HTMLDivElement | undefined; mapOptions?: Partial | undefined; activeTrail?: number | null; - minZoom?: number; + clusterTrails?: boolean; onsegmentdragend?: (data: { segment: number; event: M.MapMouseEvent; @@ -57,6 +54,10 @@ onmoveend?: (map: M.Map) => void; onzoom?: (map: M.Map) => void; onclick?: (event: M.MapMouseEvent & Object) => void; + onUnclusteredClick?: ( + event: M.MapMouseEvent & Object, + trail: Trail, + ) => void; oninit?: (map: M.Map) => void; } @@ -76,7 +77,7 @@ elevationProfileContainer = undefined, mapOptions = undefined, activeTrail = $bindable(0), - minZoom = 0, + clusterTrails = false, onmarkerdragend, onsegmentdragend, onselect, @@ -85,6 +86,7 @@ onmoveend, onzoom, onclick, + onUnclusteredClick, oninit, }: Props = $props(); @@ -99,6 +101,7 @@ endMarker: M.Marker | null; source: M.GeoJSONSource | null; layer: M.LineLayerSpecification | null; + highlighted: boolean; listener: { onEnter: ((e: M.MapMouseEvent) => void) | null; onLeave: ((e: M.MapMouseEvent) => void) | null; @@ -130,7 +133,9 @@ "#DE7DC5", ]; - let data = $derived(getData(trails)); + let clusterPopup: M.Popup | null = null; + + let [data, clusterData] = $derived(getData(trails)); $effect(() => { if (data && map) { untrack(() => initMap(map?.loaded() ?? false)); @@ -182,27 +187,21 @@ }); }); - function getData(trails: Trail[]) { - if (!trails.length) { - return []; - } + function getData(trails: Trail[]): [GeoJSON[], FeatureCollection] { + let cD: FeatureCollection = { type: "FeatureCollection", features: [] }; + let r: GeoJSON[] = []; - let r: GeoJSON[] = - (map?.getZoom() ?? 0) > minZoom - ? [] - : [{ type: "FeatureCollection", features: [] }]; trails.forEach((t) => { - if ((map?.getZoom() ?? 0) > minZoom) { - if (t.polyline) { - r.push(polylineToGeoJSON(t.polyline, 5)); - } else if (t.expand?.gpx_data) { - r.push(toGeoJson(t.expand.gpx_data) as GeoJSON); - } - } else if (t.lat !== null && t.lon !== null) { - (r[0] as FeatureCollection).features.push({ + if (t.expand?.gpx_data) { + r.push(toGeoJson(t.expand.gpx_data) as GeoJSON); + } + if (clusterTrails && t.lat !== null && t.lon !== null) { + cD.features.push({ id: t.id, type: "Feature", - properties: {}, + properties: { + trail: t.id, + }, geometry: { type: "Point", coordinates: [t.lon ?? 0, t.lat ?? 0], @@ -211,7 +210,7 @@ } }); - return r; + return [r, cD]; } function initMap(mapLoaded: boolean) { @@ -226,13 +225,12 @@ epc?.hideProfile(); } - if ((map?.getZoom() ?? 0) > minZoom) { - trails.forEach((t, i) => { - const layerId = t.id!; - addTrailLayer(t, layerId, i, data[i]); - }); - } else { - addClusterLayer(data[0] as FeatureCollection); + trails.forEach((t, i) => { + const layerId = t.id!; + addTrailLayer(t, layerId, i, data[i]); + }); + if (clusterTrails) { + addClusterLayer(clusterData); } Object.keys(layers).forEach((layerId) => { @@ -279,7 +277,12 @@ maxY = Math.max(maxY, yMax); } - if(minX < Infinity && minY < Infinity && maxX > -Infinity && maxY > -Infinity) { + if ( + minX < Infinity && + minY < Infinity && + maxX > -Infinity && + maxY > -Infinity + ) { return new M.LngLatBounds([minX, minY, maxX, maxY]); } else { return new M.LngLatBounds([0, 0, 0, 0]); @@ -342,6 +345,7 @@ endMarker: null, source: null, layer: null, + highlighted: false, listener: { onMouseUp: null, onMouseDown: null, @@ -384,7 +388,6 @@ id: id, type: "line", source: id, - minzoom: minZoom, paint: { "line-color": trailColors[index % trailColors.length], "line-width": 5, @@ -407,7 +410,7 @@ map.on("mousedown", id, layers[id].listener.onMouseDown); } - if (!drawing) { + if (!drawing && !clusterTrails) { addStartEndMarkers(trail, id, geojson); } } @@ -421,7 +424,6 @@ type: "geojson", data: geojson, cluster: true, - clusterMaxZoom: minZoom - 1, clusterRadius: 50, }); } else { @@ -438,11 +440,17 @@ "circle-radius": [ "step", ["get", "point_count"], + 10, + 10, + 15, 20, + 20, + 50, + 25, 100, 30, - 750, - 40, + 200, + 35, ], "circle-stroke-width": 3, "circle-stroke-color": "#fff", @@ -461,7 +469,6 @@ "text-size": 12, }, }); - map.addLayer({ id: "unclustered-point", type: "circle", @@ -477,6 +484,34 @@ } } + function addClusterHighlightLayer(geojson: GeoJSON) { + if (!geojson || !map || !map.style) { + return; + } + if (!map.getSource("cluster-highlight")) { + map.addSource("cluster-highlight", { + type: "geojson", + data: geojson, + }); + } else { + (map.getSource("cluster-highlight") as M.GeoJSONSource).setData( + geojson, + ); + } + + if (!map.getLayer("cluster-highlight")) { + map.addLayer({ + id: "cluster-highlight", + type: "line", + source: "cluster-highlight", + paint: { + "line-color": trailColors[0], + "line-width": 5, + }, + }); + } + } + function moveCrosshairToCursorPosition(e: M.MapMouseEvent) { epc?.moveCrosshair(e.lngLat.lat, e.lngLat.lng); moveElevationMarkerToCursorPosition(e); @@ -534,7 +569,7 @@ "interpolate", ["exponential", 1.5], ["zoom"], - minZoom, + 0, 80, 18, 200, @@ -544,7 +579,7 @@ "interpolate", ["exponential", 1.5], ["zoom"], - minZoom, + 0, 0.5, 18, 0.8, @@ -590,6 +625,34 @@ // map?.setPaintProperty(id, "line-color", "#648ad5"); } + export async function highlightCluster(trail: Trail) { + if (!map || !map.style) { + return; + } + clusterPopup = createPopupFromTrail(trail); + clusterPopup.setLngLat([trail.lon!, trail.lat!]).addTo(map); + + const geojson = await fetchGPX(trail); + + addClusterHighlightLayer(toGeoJson(geojson)); + + clusterPopup.on("close", () => { + unHighlightCluster(false); + }); + } + + export async function unHighlightCluster(closePopup: boolean = true) { + if (!map || !map.style) { + return; + } + if (map?.getLayer("cluster-highlight")) { + map?.removeLayer("cluster-highlight"); + } + if (closePopup) { + clusterPopup?.remove(); + } + } + function adjustTrailFocus(activeTrail: number | null) { if (activeTrail !== null && trails[activeTrail] !== undefined) { if ( @@ -658,7 +721,7 @@ } map.getCanvas().style.cursor = "inherit"; - if (activeTrail !== null && trails[activeTrail]) { + if (activeTrail !== null && trails[activeTrail] && !clusterTrails) { addStartEndMarkers( trails[activeTrail], trails[activeTrail].id, @@ -710,7 +773,7 @@ layers[id].endMarker.setLngLat( startEndPoint[startEndPoint.length - 1] as M.LngLatLike, ); - if (map.getZoom() > minZoom) { + if (!clusterTrails) { layers[id].endMarker.addTo(map); } @@ -727,16 +790,6 @@ layers[id].endMarker?.remove(); } - export function togglePopup(id: string, currentState?: boolean) { - if ( - currentState !== undefined && - layers[id]?.startMarker?.getPopup().isOpen() != currentState - ) { - return; - } - layers[id]?.startMarker?.togglePopup(); - } - function showWaypoints() { if (!map) { return; @@ -1014,17 +1067,6 @@ }); map.on("zoom", (e) => { - const zoom = e.target.getZoom(); - Object.values(layers).forEach((l) => { - if (zoom > minZoom && map && !drawing) { - l.startMarker?.addTo(map); - l.endMarker?.addTo(map); - } else { - l.startMarker?.remove(); - l.endMarker?.remove(); - } - }); - onzoom?.(e.target); }); @@ -1035,6 +1077,51 @@ onclick?.(e); }); + map.on("click", "clusters", async (e) => { + if (!map) { + return; + } + const features = map.queryRenderedFeatures(e.point, { + layers: ["clusters"], + }); + const clusterId = features[0].properties.cluster_id; + const zoom = await ( + map.getSource("trails") as M.GeoJSONSource + ).getClusterExpansionZoom(clusterId); + map.flyTo({ + center: (features[0].geometry as any).coordinates, + zoom, + }); + }); + + map.on( + "click", + "unclustered-point", + async (e: M.MapMouseEvent & Object) => { + const trail = trails.find( + (t) => t.id == (e as any).features[0].properties.trail, + ); + if (!trail || !map) { + return; + } + highlightCluster(trail); + }, + ); + + map.on("mouseenter", "clusters", () => { + map!.getCanvas().style.cursor = "pointer"; + }); + map.on("mouseleave", "clusters", () => { + map!.getCanvas().style.cursor = ""; + }); + + map.on("mouseenter", "unclustered-point", () => { + map!.getCanvas().style.cursor = "pointer"; + }); + map.on("mouseleave", "unclustered-point", () => { + map!.getCanvas().style.cursor = ""; + }); + map.on("load", () => { initMap(true); oninit?.(map!); diff --git a/web/src/lib/stores/search_store.ts b/web/src/lib/stores/search_store.ts index 307e8302..129e5d9a 100644 --- a/web/src/lib/stores/search_store.ts +++ b/web/src/lib/stores/search_store.ts @@ -32,6 +32,7 @@ export type TrailSearchResult = { location: string; name: string; public: boolean; + polyline?: string; } export type ListSearchResult = { diff --git a/web/src/lib/util/geojson_util.ts b/web/src/lib/util/geojson_util.ts index 60215c9d..8ac23872 100644 --- a/web/src/lib/util/geojson_util.ts +++ b/web/src/lib/util/geojson_util.ts @@ -66,7 +66,7 @@ export function splitMultiLineStringToLineStrings(geojson: GeoJsonObject): Featu ...feature.properties, coordinateProperties: { ...feature.properties.coordinateProperties, - times: feature.properties.coordinateProperties.times[lineIndex] + times: feature.properties.coordinateProperties?.times?.[lineIndex] }, featureId: features.length, segmentId: lineIndex, diff --git a/web/src/routes/map/+page.svelte b/web/src/routes/map/+page.svelte index b8b176a9..02207632 100644 --- a/web/src/routes/map/+page.svelte +++ b/web/src/routes/map/+page.svelte @@ -12,7 +12,7 @@ import TrailFilterPanel from "$lib/components/trail/trail_filter_panel.svelte"; import type { Settings } from "$lib/models/settings"; import { - defaultTrailSearchAttributes, + defaultTrailSearchAttributes, type Trail, type TrailBoundingBox, type TrailFilter, @@ -24,11 +24,12 @@ type LocationSearchResult, type TrailSearchResult, } from "$lib/stores/search_store"; - import { trails_search_bounding_box } from "$lib/stores/trail_store"; + import { + trails_search_bounding_box + } from "$lib/stores/trail_store"; import { getIconForLocation } from "$lib/util/icon_util"; import type { Snapshot } from "@sveltejs/kit"; import * as M from "maplibre-gl"; - import { onMount } from "svelte"; import { _ } from "svelte-i18n"; import { slide } from "svelte/transition"; @@ -45,7 +46,7 @@ const maxBoundingBox: TrailBoundingBox = page.data.boundingBox; const settings: Settings = page.data.settings; - const MIN_ZOOM = 6; + const MIN_ZOOM = 100; let loading: boolean = $state(true); let loadingNextPage: boolean = false; @@ -57,7 +58,7 @@ export const snapshot: Snapshot = { capture: () => filter, - restore: (value) => { + restore: (value) => { filter = value; handleFilterUpdate(); }, @@ -140,11 +141,11 @@ } function handleTrailCardMouseEnter(trail: Trail) { - mapWithElevation?.togglePopup(trail.id!, false); + mapWithElevation?.highlightCluster(trail); } function handleTrailCardMouseLeave(trail: Trail) { - mapWithElevation?.togglePopup(trail.id!, true); + mapWithElevation?.unHighlightCluster(); } async function handleFilterUpdate() { @@ -356,7 +357,7 @@ showInfoPopup={true} activeTrail={-1} fitBounds="off" - minZoom={MIN_ZOOM} + clusterTrails={true} bind:map bind:this={mapWithElevation} >