feat: implement server-side map clustering (#991)

* feat: meilisearch bounding box intersection and map filtering

* feat: tiered clustering and polyline filtering based on bounding box diagonal

Addressing review comments with optimized performance and data accuracy:
- Implemented a two-tiered search strategy (Summary vs. Detailed) to provide 100% accurate cluster counts while minimizing metadata traffic.
- Added a client-side ID-based cache to eliminate redundant network requests for trails already in memory.
- Introduced tiered 'detail shedding' that dynamically hides small trail lines when zooming out to maintain smooth panning performance.
- Fixed Svelte 5 reactivity issues to ensure clusters reappear instantly when trail detail is shed.
- Consolidated zoom thresholds into centralized constants for system-wide consistency.
- Updated Meilisearch configuration to support efficient ID-based filtering.

* feat: implement server-side map clustering using Meilisearch and Supercluster

- Implemented SvelteKit server-side clustering route at `/api/v1/search/trails/cluster` using the `supercluster` library.
- Integrated backend search results with the new clustering endpoint to improve performance for large trail datasets.
- Fixed Svelte 5 reactivity in `MapWithElevationMaplibre.svelte` by making `mapLoaded` reactive and fixing destructuring of `` map data.
- Removed obsolete client-side zoom constraints (`minzoom`/`maxzoom`) from `TrailLayer`, `PreviewLayer`, and `ClusterLayer` to allow dynamic visibility controlled by backend attributes.
- Fixed a bug where unauthenticated users generated invalid Meilisearch filters.
- Optimized map page performance by caching bounding box and filter values in the route loader.
- Updated `ClusterLayer` font to `Noto Sans Regular` to match available tileserver resources.

* feat: implement dynamic polyline visibility based on result density

- Replaced static zoom-based diagonal thresholds with a dynamic "Top N" approach for polyline visibility.
- Updated `/api/v1/search/trails/cluster` to mark only the top `MAP_MAX_POLYLINES` (default 100) trails by bounding box diagonal as "large".
- Modified `trails_search_bounding_box` store function to always fetch polylines for trails marked as large, regardless of zoom level.
- Removed obsolete `MAP_*_ZOOM_DIAGONAL_LIMIT` constants and simplified map layer constructors by removing tier-based filtering.
- Updated environment configurations and documentation to use the new `PUBLIC_MAP_MAX_POLYLINES` variable.

* refactor: remove obsolete zoom-based map clustering thresholds

Following the transition to dynamic server-side clustering based on result density (Top N polylines), this commit removes all remaining zoom-based thresholds and logic.

- Removed PUBLIC_MAP_LOW_ZOOM_THRESHOLD, PUBLIC_MAP_MEDIUM_ZOOM_THRESHOLD, and PUBLIC_MAP_HIGH_ZOOM_THRESHOLD environment variables.
- Removed the 'mapClusterMinZoom' user setting from the schema, models, and settings UI.
- Simplified the map component and MapLibre layer managers by removing unused 'clusterMinZoom', 'minZoom', and 'maxZoom' parameters.
- Cleaned up obsolete 'map-cluster-zoom-level' translation keys across all locales.
- Updated documentation to reflect the removal of these variables.

* chore: remove map clustering debug logs

* docs: update changelog and fix global zoom feature disappearance

* fix: address PR reviews and refine map cluster/preview visuals

* feat: implement configurable map cluster zoom and trail start marker settings

* feat: render single unclustered shedded trails as start markers instead of cluster circles

* minor fixes

* minor ui changes

* fix missing entries in map list

* remove redundant map clustering zoom clamp

* fix merge issues

* fix merge issues

* fixes

* hide popup for trails without details

* several improvements

* prevent import of client trail_store in server cluster code

* update changelog

---------

Co-authored-by: slothful-vassal <89943360+slothful-vassal@users.noreply.github.com>
This commit is contained in:
Pål Håland
2026-06-07 13:00:26 +02:00
committed by GitHub
parent fc735130cc
commit 197cd8d04e
41 changed files with 1133 additions and 245 deletions

View File

@@ -31,6 +31,7 @@
interface Props {
trails?: Trail[];
serverClusters?: GeoJSON.FeatureCollection;
gpx?: GPX;
waypoints?: Waypoint[];
markers?: M.Marker[];
@@ -75,6 +76,7 @@
let {
trails = [],
serverClusters = undefined,
waypoints = [],
markers = $bindable([]),
map = $bindable(),
@@ -117,7 +119,7 @@
let hoveringTrail: boolean = false;
let mapLoaded: boolean = false;
let mapLoaded: boolean = $state(false);
let terrainEnabled: boolean | null = null;
const trailColors = [
@@ -135,9 +137,16 @@
let clusterPopup: M.Popup | null = null;
let [data, clusterData, previewData] = $derived(getData(trails));
let mapData = $derived(getData(trails, serverClusters));
let gpxDataMap = $derived(mapData[0]);
let clusterData = $derived(mapData[1]);
let previewData = $derived(mapData[2]);
$effect(() => {
if (data && map && mapLoaded) {
// Track dependencies for Svelte 5
mapData;
if (map && mapLoaded) {
untrack(() => initMap(map?.loaded() ?? false));
}
});
@@ -189,8 +198,13 @@
function getData(
trails: Trail[],
): [FeatureCollection[], FeatureCollection, FeatureCollection] {
let clusterData: FeatureCollection = {
serverClusters?: GeoJSON.FeatureCollection
): [
Record<string, FeatureCollection>,
FeatureCollection,
FeatureCollection,
] {
let clusterData: FeatureCollection = serverClusters ?? {
type: "FeatureCollection",
features: [],
};
@@ -198,21 +212,36 @@
type: "FeatureCollection",
features: [],
};
let r: FeatureCollection[] = [];
let gpxDataMap: Record<string, FeatureCollection> = {};
trails.forEach((t, i) => {
if (t.expand?.gpx) {
r.push(t.expand.gpx.toGeoJSON());
} else if (t.expand?.gpx_data) {
r.push(GPX.parse(t.expand.gpx_data).toGeoJSON());
trails.forEach((t) => {
if (t.id) {
let fc: FeatureCollection | null = null;
if (t.expand?.gpx) {
fc = t.expand.gpx.toGeoJSON();
} else if (t.expand?.gpx_data) {
fc = GPX.parse(t.expand.gpx_data).toGeoJSON();
}
if (fc) {
fc.features.forEach((f) => {
if (f.properties) {
f.properties.bounding_box_diagonal =
t.bounding_box_diagonal;
}
});
gpxDataMap[t.id] = fc;
}
}
if (clusterTrails) {
if (t.lat !== null && t.lon !== null) {
if (!serverClusters && t.lat !== undefined && t.lon !== undefined) {
clusterData.features.push({
id: t.id,
type: "Feature",
properties: {
trail: t.id,
bounding_box_diagonal: t.bounding_box_diagonal,
},
geometry: {
type: "Point",
@@ -227,6 +256,7 @@
type: "Feature",
properties: {
trail: t.id,
bounding_box_diagonal: t.bounding_box_diagonal,
color: trailColors[
hashStringToIndex(
t.id ?? "",
@@ -243,7 +273,7 @@
}
});
return [r, clusterData, previewData];
return [gpxDataMap, clusterData, previewData];
}
function initMap(mapLoaded: boolean) {
@@ -252,20 +282,20 @@
}
refreshElevationProfile();
if (showElevation && data.length && activeTrail !== null) {
if (
showElevation &&
Object.keys(gpxDataMap).length &&
activeTrail !== null
) {
epc?.showProfile();
} else {
epc?.hideProfile();
}
trails.forEach((t, i) => {
trails.forEach((t) => {
const layerId = t.id!;
addTrailLayer(t, layerId, i, data[i]);
addTrailLayer(t, layerId, 0, gpxDataMap[layerId]);
});
if (clusterTrails) {
addClusterLayer(clusterData);
addPreviewLayer(previewData);
}
Object.entries(layerManager.layers).forEach(([id, layer]) => {
if (!(layer instanceof TrailLayer)) {
@@ -278,18 +308,31 @@
}
});
if (
!drawing &&
fitBounds !== "off" &&
data.some((d) => d.bbox !== undefined)
) {
if (activeTrail !== null && trails[activeTrail] && mapLoaded) {
if (clusterTrails) {
addPreviewLayer(previewData);
addClusterLayer(clusterData);
}
if (!drawing && fitBounds !== "off") {
const currentBboxes = Object.values(gpxDataMap)
.map((d) => d.bbox)
.filter((b) => b !== undefined);
if (
activeTrail !== null &&
trails[activeTrail] &&
mapLoaded &&
gpxDataMap[trails[activeTrail].id!]
) {
focusTrail(trails[activeTrail]);
} else {
} else if (currentBboxes.length > 0) {
flyToBounds();
}
} else if (drawing && activeTrail !== null && mapLoaded) {
addCaretLayer(data[activeTrail]);
const activeId = trails[activeTrail]?.id;
if (activeId && gpxDataMap[activeId]) {
addCaretLayer(gpxDataMap[activeId]);
}
}
}
@@ -313,8 +356,9 @@
}
export function refreshElevationProfile() {
if (activeTrail !== null && data[activeTrail]) {
epc?.setData(data[activeTrail]!, waypoints);
const activeId = activeTrail !== null ? trails[activeTrail]?.id : null;
if (activeId && gpxDataMap[activeId]) {
epc?.setData(gpxDataMap[activeId]!, waypoints);
}
}
@@ -324,7 +368,7 @@
maxX = -Infinity,
maxY = -Infinity;
for (const [xMin, yMin, xMax, yMax] of data
for (const [xMin, yMin, xMax, yMax] of Object.values(gpxDataMap)
.filter((d) => d.bbox !== undefined)
.map((d) => d.bbox!)) {
minX = Math.min(minX, xMin);
@@ -346,9 +390,10 @@
}
function flyToBounds() {
const activeId = activeTrail !== null ? trails[activeTrail]?.id : null;
const bounds =
activeTrail !== null && data[activeTrail]
? (data[activeTrail].bbox as M.LngLatBoundsLike)
activeId && gpxDataMap[activeId]
? (gpxDataMap[activeId].bbox as M.LngLatBoundsLike)
: getBounds();
if (!bounds || !map) {
@@ -389,18 +434,20 @@
trailColors[
clusterTrails
? hashStringToIndex(id ?? "", trailColors.length)
: 0
: index % trailColors.length
],
{
onEnter: (e) =>
highlightTrail(id, trails[activeTrail ?? -1]?.id == id),
listeners: {
onEnter: (e) =>
highlightTrail(id, trails[activeTrail ?? -1]?.id == id),
onLeave: (e) => unHighlightTrail(id),
onMouseUp: (e) => {
activeTrail = trails.findIndex((t) => t.id == trail.id);
onLeave: (e) => unHighlightTrail(id),
onMouseUp: (e) => {
activeTrail = trails.findIndex((t) => t.id == trail.id);
},
onMouseMove: moveCrosshairToCursorPosition,
onMouseDown: (e) => handleDragStart(e, id),
},
onMouseMove: moveCrosshairToCursorPosition,
onMouseDown: (e) => handleDragStart(e, id),
},
);
@@ -415,7 +462,20 @@
if (!geojson || !map || !map.style) {
return;
}
layerManager.addLayer("clusters", new ClusterLayer(map, geojson));
layerManager.addLayer(
"clusters",
new ClusterLayer(map, geojson, {
"unclustered-point": {
onEnter: (e) => {
if (map) map.getCanvas().style.cursor = "pointer";
const id = (e as any).features[0].properties.id;
const trail = trails.find((t) => t.id === id);
if (!hasTrailDetails(trail)) return;
highlightCluster(trail, e.lngLat);
},
},
}),
);
}
function addPreviewLayer(geojson: FeatureCollection) {
@@ -425,18 +485,19 @@
layerManager.addLayer(
"preview",
new PreviewLayer(map, geojson, {
preview: {
onEnter: (e) => {
const trail = trails.find(
(t) =>
t.id ===
(e as any).features[0].properties.trail,
);
if (!trail) return;
highlightCluster(trail, e.lngLat);
},
onLeave: (e) => {
// unHighlightCluster();
showStartMarker: page.data.settings?.behavior?.showTrailStartMarker ?? false,
listeners: {
preview: {
onEnter: (e) => {
if (map) map.getCanvas().style.cursor = "pointer";
const trail = trails.find(
(t) =>
t.id ===
(e as any).features[0].properties.trail,
);
if (!hasTrailDetails(trail)) return;
highlightCluster(trail, e.lngLat);
},
},
},
}),
@@ -537,11 +598,15 @@
// map?.setPaintProperty(id, "line-color", "#648ad5");
}
function hasTrailDetails(trail: Trail | undefined): trail is Trail {
return Boolean(trail?.name?.trim());
}
export async function highlightCluster(
trail: Trail,
lnglat?: M.LngLatLike,
) {
if (!map || !map.style) {
if (!map || !map.style || !hasTrailDetails(trail)) {
return;
}
clusterPopup?.remove();
@@ -581,7 +646,7 @@
if (
!drawing &&
fitBounds !== "off" &&
data.some((d) => d.bbox !== undefined)
Object.values(gpxDataMap).some((d) => d.bbox !== undefined)
) {
untrack(() => focusTrail(trails[activeTrail]));
}
@@ -604,7 +669,9 @@
epc?.showProfile();
}
showWaypoints();
addCaretLayer(data[activeTrail]);
if (trail.id && gpxDataMap[trail.id]) {
addCaretLayer(gpxDataMap[trail.id]);
}
flyToBounds();
} catch (e) {
console.warn(e);
@@ -651,10 +718,11 @@
map.getCanvas().style.cursor = "inherit";
if (activeTrail !== null && trails[activeTrail] && !clusterTrails) {
const activeId = trails[activeTrail].id;
addStartEndMarkers(
trails[activeTrail],
trails[activeTrail].id,
data[activeTrail],
activeId,
activeId ? gpxDataMap[activeId] : null,
);
}
}
@@ -1006,8 +1074,9 @@
if (e.key == "m") {
if (trails.length === 1) {
addTrailLayer(trails[0], trails[0].id!, 0, data[0]);
addCaretLayer(data[0]);
const trailId = trails[0].id!;
addTrailLayer(trails[0], trailId, 0, gpxDataMap[trailId]);
addCaretLayer(gpxDataMap[trailId]);
}
} else if (e.key == "p") {
if (showElevation) {