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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user