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

2
web/package-lock.json generated
View File

@@ -26,6 +26,7 @@
"@turf/destination": "^7.3.3",
"@turf/distance": "^7.3.3",
"@types/chart.js": "^4.0.1",
"@types/supercluster": "^7.1.3",
"@types/three": "^0.183.1",
"@xmldom/xmldom": "^0.8.12",
"activitypub-types": "^1.1.0",
@@ -53,6 +54,7 @@
"photoswipe": "^5.4.3",
"pocketbase": "^0.26.8",
"qrcode": "^1.4.4",
"supercluster": "^8.0.1",
"svelte-i18n": "^4.0.0",
"tailwindcss": "^4.2.4",
"three": "^0.183.1",

View File

@@ -51,6 +51,7 @@
"@turf/destination": "^7.3.3",
"@turf/distance": "^7.3.3",
"@types/chart.js": "^4.0.1",
"@types/supercluster": "^7.1.3",
"@types/three": "^0.183.1",
"@xmldom/xmldom": "^0.8.12",
"activitypub-types": "^1.1.0",
@@ -78,6 +79,7 @@
"photoswipe": "^5.4.3",
"pocketbase": "^0.26.8",
"qrcode": "^1.4.4",
"supercluster": "^8.0.1",
"svelte-i18n": "^4.0.0",
"tailwindcss": "^4.2.4",
"three": "^0.183.1",

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) {

View File

@@ -0,0 +1,3 @@
import { env } from "$env/dynamic/public";
export const MAP_MAX_POLYLINES = Number(env.PUBLIC_MAP_MAX_POLYLINES || 100);

View File

@@ -264,6 +264,8 @@
"make-one": "Vytvořte si vlastní!",
"make-thumbnail": "Vytvořit náhled",
"map": "Mapa",
"map-trail-preview-zoom-level": "Zobrazit náhledy tras od úrovně přiblížení",
"show-trail-start-marker": "Zobrazit značku na začátku trasy",
"map-style": "Styl mapy",
"mark-trail-as-completed": "",
"mark-trail-as-completed-modal-text": "",

View File

@@ -286,6 +286,8 @@
"make-one": "Neues erstellen!",
"make-thumbnail": "Thumbnail festlegen",
"map": "Karte",
"map-trail-preview-zoom-level": "Routenlinien anzeigen ab Zoomstufe",
"show-trail-start-marker": "Marker am Start der Route anzeigen",
"map-style": "Kartenstil",
"mark-trail-as-completed": "Route als abgeschlossen markieren",
"mark-trail-as-completed-modal-text": "Möchtest du diese Route als abgeschlossen markieren? Du kannst diesen Status jederzeit wieder ändern.",

View File

@@ -286,6 +286,8 @@
"make-one": "Make one!",
"make-thumbnail": "Make thumbnail",
"map": "Map",
"map-trail-preview-zoom-level": "Show trail previews from zoom level",
"show-trail-start-marker": "Show marker at start of trail",
"map-style": "Map style",
"mark-trail-as-completed": "Mark trail as completed",
"mark-trail-as-completed-modal-text": "Would you like to mark this trail as completed? You can change this status again at any time.",

View File

@@ -264,6 +264,8 @@
"make-one": "¡Crea uno!",
"make-thumbnail": "Generar miniaturas",
"map": "Mapa",
"map-trail-preview-zoom-level": "Mostrar vistas previas de rutas a partir del nivel de zoom",
"show-trail-start-marker": "Mostrar marcador al inicio de la ruta",
"map-style": "Estilo de mapa",
"mark-trail-as-completed": "",
"mark-trail-as-completed-modal-text": "",

View File

@@ -264,6 +264,8 @@
"make-one": "Egin bat!",
"make-thumbnail": "Egin iruditxoa",
"map": "Mapa",
"map-trail-preview-zoom-level": "Erakutsi ibilbideen aurrebistak zoom-mailatik aurrera",
"show-trail-start-marker": "Erakutsi markatzailea ibilbidearen hasieran",
"map-style": "Maparen estiloa",
"mark-trail-as-completed": "",
"mark-trail-as-completed-modal-text": "",

View File

@@ -264,6 +264,8 @@
"make-one": "Faites-en un !",
"make-thumbnail": "Créer une miniature",
"map": "Carte",
"map-trail-preview-zoom-level": "Afficher les aperçus d'itinéraires à partir du niveau de zoom",
"show-trail-start-marker": "Afficher un marqueur au début de l'itinéraire",
"map-style": "Style de carte",
"mark-trail-as-completed": "",
"mark-trail-as-completed-modal-text": "",

View File

@@ -264,6 +264,8 @@
"make-one": "Készítsen egyet!",
"make-thumbnail": "Készítsen miniatűrképet",
"map": "Térkép",
"map-trail-preview-zoom-level": "Útvonal-előnézetek megjelenítése ettől a nagyítási szinttől",
"show-trail-start-marker": "Jelölő megjelenítése az útvonal elején",
"map-style": "Map style",
"mark-trail-as-completed": "",
"mark-trail-as-completed-modal-text": "",

View File

@@ -264,6 +264,8 @@
"make-one": "Creane uno!",
"make-thumbnail": "Imposta miniatura",
"map": "Mappa",
"map-trail-preview-zoom-level": "Mostra le anteprime dei percorsi dal livello di zoom",
"show-trail-start-marker": "Mostra un indicatore all'inizio del percorso",
"map-style": "Map style",
"mark-trail-as-completed": "",
"mark-trail-as-completed-modal-text": "",

View File

@@ -264,6 +264,8 @@
"make-one": "Maak er een aan!",
"make-thumbnail": "Miniatuur maken",
"map": "Kaart",
"map-trail-preview-zoom-level": "Routevoorbeelden tonen vanaf zoomniveau",
"show-trail-start-marker": "Markering aan het begin van de route tonen",
"map-style": "Kaartstijl",
"mark-trail-as-completed": "",
"mark-trail-as-completed-modal-text": "",

View File

@@ -264,6 +264,8 @@
"make-one": "Lag en!",
"make-thumbnail": "Lag miniatyrbilde",
"map": "Kart",
"map-trail-preview-zoom-level": "Vis ruteforhåndsvisninger fra zoomnivå",
"show-trail-start-marker": "Vis markør ved starten av stien",
"map-style": "Kartstil",
"mark-trail-as-completed": "",
"mark-trail-as-completed-modal-text": "",

View File

@@ -264,6 +264,8 @@
"make-one": "Stwórz ją!",
"make-thumbnail": "Zrób miniaturkę",
"map": "Mapa",
"map-trail-preview-zoom-level": "Pokazuj podglądy tras od poziomu powiększenia",
"show-trail-start-marker": "Pokaż znacznik na początku trasy",
"map-style": "Map style",
"mark-trail-as-completed": "",
"mark-trail-as-completed-modal-text": "",

View File

@@ -264,6 +264,8 @@
"make-one": "Faz um!",
"make-thumbnail": "Faça miniatura",
"map": "Mapa",
"map-trail-preview-zoom-level": "Mostrar pré-visualizações de rotas a partir do nível de zoom",
"show-trail-start-marker": "Mostrar marcador no início da rota",
"map-style": "Map style",
"mark-trail-as-completed": "",
"mark-trail-as-completed-modal-text": "",

View File

@@ -264,6 +264,8 @@
"make-one": "Создайте!",
"make-thumbnail": "Сделать миниатюру",
"map": "Карта",
"map-trail-preview-zoom-level": "Показывать предпросмотр маршрутов с уровня масштабирования",
"show-trail-start-marker": "Показывать маркер в начале маршрута",
"map-style": "Стиль карты",
"mark-trail-as-completed": "",
"mark-trail-as-completed-modal-text": "",

View File

@@ -264,6 +264,8 @@
"make-one": "立刻注册!",
"make-thumbnail": "生成缩略图",
"map": "地图",
"map-trail-preview-zoom-level": "从该缩放级别开始显示路线预览",
"show-trail-start-marker": "在路线起点显示标记",
"map-style": "地图样式",
"mark-trail-as-completed": "",
"mark-trail-as-completed-modal-text": "",

View File

@@ -22,8 +22,11 @@ const SettingsCreateSchema = z.object({
lists: z.enum(["public", "private"])
}).optional().nullable(),
notifications: z.record(z.enum(Object.values(NotificationType) as [string, ...string[]]), z.object({ web: z.boolean(), email: z.boolean() })).optional().nullable(),
behavior: z.object({ allowAutoGeolocate: z.boolean() }).optional().nullable(),
behavior: z.object({
allowAutoGeolocate: z.boolean(),
mapClusteringMaxZoom: z.number().optional(),
showTrailStartMarker: z.boolean().optional()
}).optional().nullable(),
}) satisfies ZodType<Settings>
ZodType<Partial<Comment>>
export { SettingsCreateSchema };

View File

@@ -57,6 +57,8 @@ class Settings {
export type Behavior = {
allowAutoGeolocate: boolean;
mapClusteringMaxZoom?: number;
showTrailStartMarker?: boolean;
}

View File

@@ -34,6 +34,7 @@ class Trail {
domain?: string;
iri?: string;
like_count: number;
bounding_box_diagonal?: number;
expand?: {
tags?: Tag[]
category?: Category;
@@ -74,8 +75,9 @@ class Trail {
comments?: Comment[],
shares?: TrailShare[],
tags?: Tag[],
description?: string
created?: string
description?: string,
created?: string,
bounding_box_diagonal?: number
}
) {
@@ -96,6 +98,7 @@ class Trail {
this.photos = params?.photos ?? [];
this.tags = [];
this.gpx = params?.gpx;
this.bounding_box_diagonal = params?.bounding_box_diagonal ?? 0;
this.like_count = 0
this.expand = {
category: params?.category,
@@ -214,6 +217,7 @@ interface TrailSearchResult {
domain?: string;
iri?: string;
gpx: string;
bounding_box_diagonal: number;
_geo: {
lat: number,
lng: number
@@ -245,6 +249,7 @@ export const defaultTrailSearchAttributes = [
"like_count",
"shares",
"iri",
"bounding_box_diagonal",
"_geo",]

View File

@@ -99,7 +99,7 @@ export async function searchTrails(q: string, options: SearchParams): Promise<Hi
const response: SearchResponse<TrailSearchResult> = await r.json();
return response.hits
return response.hits || []
}
export async function searchLocations(q: string, limit?: number, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch): Promise<Hits<LocationSearchResult>> {
@@ -245,6 +245,10 @@ export async function searchMulti(options: MultiSearchParams): Promise<MultiSear
const response: MultiSearchResponse<any> = await r.json();
if (!response.results) {
return [];
}
if (locationQuery && locationQuery.q !== undefined && locationQuery.q !== null) {
const locationsResults = await searchLocations(locationQuery.q, locationQuery.limit)

View File

@@ -1,5 +1,6 @@
import type { SummitLog } from "$lib/models/summit_log";
import type { Tag } from "$lib/models/tag";
import { MAP_MAX_POLYLINES } from "$lib/config/map";
import { defaultTrailSearchAttributes, Trail, type TrailFilter, type TrailFilterValues, type TrailSearchResult } from "$lib/models/trail";
import type { Waypoint } from "$lib/models/waypoint";
import { APIError } from "$lib/util/api_util";
@@ -14,11 +15,6 @@ import { tags_create } from "./tag_store";
import { currentUser } from "./user_store";
import { waypoints_create, waypoints_delete, waypoints_update } from "./waypoint_store";
let trails: Trail[] = []
export const trail: Writable<Trail> = writable(new Trail(""));
export const editTrail: Writable<Trail> = writable(new Trail(""));
export async function trails_index(perPage: number = 21, random: boolean = false, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
const r = await f('/api/v1/trail?' + new URLSearchParams({
"perPage": perPage.toString(),
@@ -93,7 +89,50 @@ export async function trails_search_filter(filter: TrailFilter, page: number = 1
}
export async function trails_search_bounding_box(northEast: M.LngLat, southWest: M.LngLat, filter: TrailFilter, page: number = 1, includePolyline: boolean = true) {
const DETAILED_CACHE_MAX_SIZE = Math.max(200, MAP_MAX_POLYLINES * 10);
let trails: Trail[] = []
const detailedCache = new Map<string, Trail>();
let detailedCacheKey = "";
function getDetailedCache(id: string): Trail | undefined {
const cached = detailedCache.get(id);
if (!cached) {
return undefined;
}
detailedCache.delete(id);
// Reinsert the entry so Map iteration order tracks recent usage for LRU eviction.
detailedCache.set(id, cached);
return cached;
}
function setDetailedCache(id: string, trail: Trail) {
detailedCache.delete(id);
detailedCache.set(id, trail);
while (detailedCache.size > DETAILED_CACHE_MAX_SIZE) {
const oldestKey = detailedCache.keys().next().value;
if (!oldestKey) {
break;
}
detailedCache.delete(oldestKey);
}
}
export const trail: Writable<Trail> = writable(new Trail(""));
export const editTrail: Writable<Trail> = writable(new Trail(""));
export async function trails_search_bounding_box(
northEast: M.LngLat,
southWest: M.LngLat,
filter: TrailFilter,
page: number = 1,
zoom: number = 11,
perPage: number = 50,
loadMapData: boolean = true
) {
const user = get(currentUser)
let filterText: string = "";
@@ -102,36 +141,182 @@ export async function trails_search_bounding_box(northEast: M.LngLat, southWest:
filterText = buildFilterText(user, filter, false);
}
let r = await fetch("/api/v1/search/trails", {
method: "POST",
body: JSON.stringify({
q: "",
options: {
filter: [
`_geoBoundingBox([${northEast.lat}, ${northEast.lng}], [${southWest.lat}, ${southWest.lng}])`,
filterText
],
sort: [`${filter.sort}:${filter.sortOrder == "+" ? "asc" : "desc"}`,],
attributesToRetrieve: [...defaultTrailSearchAttributes, ...(includePolyline ? ["polyline"] : [])],
hitsPerPage: 500,
page: page
}
}),
});
const result: { page: number, totalPages: number, hits: Hits<TrailSearchResult> } = await r.json();
if (result.hits.length == 0) {
trails = [];
return { trails: [], ...result }
let lonFilter = `max_lon >= ${southWest.lng} AND min_lon <= ${northEast.lng}`;
if (southWest.lng > northEast.lng) {
lonFilter = `(max_lon >= ${southWest.lng} OR min_lon <= ${northEast.lng})`;
}
const resultTrails: Trail[] = await searchResultToTrailList(result.hits)
const geoFilter = `max_lat >= ${southWest.lat} AND min_lat <= ${northEast.lat} AND ${lonFilter}`;
const listFilter = [filterText, geoFilter].filter(Boolean).join(" AND ");
const cacheKey = JSON.stringify({
q: filter.q,
filterText,
sort: filter.sort,
sortOrder: filter.sortOrder,
});
if (cacheKey !== detailedCacheKey) {
detailedCache.clear();
detailedCacheKey = cacheKey;
}
trails = page > 1 ? trails.concat(resultTrails) : resultTrails
// Step 1: Fetch paginated trails for the side list.
const listResponse = await fetch("/api/v1/search/trails", {
method: "POST",
body: JSON.stringify({
q: filter.q,
options: {
filter: listFilter,
attributesToRetrieve: defaultTrailSearchAttributes,
sort: [`${filter.sort}:${filter.sortOrder == "+" ? "asc" : "desc"}`],
hitsPerPage: perPage,
page,
},
}),
});
return { trails, ...result };
if (!listResponse.ok) {
const response = await listResponse.json();
throw new APIError(listResponse.status, response.message, response.detail)
}
const listResult: { page: number, totalPages: number, totalHits?: number, estimatedTotalHits?: number, hits: Hits<TrailSearchResult> } = await listResponse.json();
const listTrails = listResult.hits.length > 0
? await searchResultToTrailList(listResult.hits)
: [];
trails = page > 1 ? trails.concat(listTrails) : listTrails;
if (!loadMapData) {
return {
trails,
mapTrails: [],
clusters: undefined,
estimatedTotalHits: listResult.estimatedTotalHits,
totalHits: listResult.totalHits ?? listResult.estimatedTotalHits,
totalPages: listResult.totalPages
};
}
// Step 2: Fetch server-side clusters and unclustered points for the map.
let cr = await fetch("/api/v1/search/trails/cluster", {
method: "POST",
body: JSON.stringify({
southWest: { lat: southWest.lat, lng: southWest.lng },
northEast: { lat: northEast.lat, lng: northEast.lng },
zoom,
q: filter.q,
filterText
})
});
if (!cr.ok) {
const response = await cr.json();
throw new APIError(cr.status, response.message, response.detail)
}
const clusterResult = await cr.json();
const clusterFeatureCollection = clusterResult;
const unclusteredFeatures = clusterFeatureCollection.features
.filter((f: any) => !f.properties.cluster);
// Extract IDs of visible unclustered points that are large enough to show details for
const unclusteredIds = unclusteredFeatures
.filter((f: any) => f.properties.is_large)
.map((f: any) => f.properties.id);
// Step 3: Identify which visible trails are MISSING from the local cache
const missingIds = unclusteredIds.filter((id: string) => !detailedCache.has(id));
// Step 4: Only fetch details for missing trails
if (missingIds.length > 0) {
const batchSize = 100; // Meilisearch filter length safety
for (let i = 0; i < missingIds.length; i += batchSize) {
const batch = missingIds.slice(i, i + batchSize);
const detailBatchQuery = {
indexUid: "trails",
q: "",
filter: [`id IN [${batch.map((id: string) => `'${id}'`).join(",")}]`],
attributesToRetrieve: [...defaultTrailSearchAttributes, "polyline"],
hitsPerPage: batchSize,
};
const dr = await fetch("/api/v1/search/multi", {
method: "POST",
body: JSON.stringify({ queries: [detailBatchQuery] }),
});
if (dr.ok) {
const detailResult = await dr.json();
const newTrails = await searchResultToTrailList(detailResult.results[0].hits);
// Populate cache
newTrails.forEach(t => {
if (t.id) setDetailedCache(t.id, t)
});
}
}
}
// Step 5: Convert unclustered hits to lightweight Trail objects for map popups/previews.
const mapTrails: Trail[] = unclusteredFeatures
.map((f: any) => {
const s = f.properties;
const lat = f.geometry.coordinates[1];
const lng = f.geometry.coordinates[0];
const cached = getDetailedCache(s.id);
if (cached) {
return {
...cached,
lat,
lon: lng,
bounding_box_diagonal: s.bounding_box_diagonal ?? cached.bounding_box_diagonal,
// Strip polyline for small trails so they don't linger as lines when zoomed out
polyline: s.is_large ? cached.polyline : undefined,
};
}
// Lightweight fallback for map markers
const t: Trail & RecordModel = {
id: s.id,
lat: lat,
lon: lng,
name: "",
author: "",
photos: [],
public: true,
completed: false,
summit_logs: [],
waypoints: [],
tags: [],
category: "",
created: new Date(0).toISOString(),
date: new Date(0).toISOString(),
updated: new Date(0).toISOString(),
description: "",
difficulty: "easy",
distance: 0,
duration: 0,
elevation_gain: 0,
elevation_loss: 0,
location: "",
bounding_box_diagonal: s.bounding_box_diagonal ?? 0,
like_count: 0,
collectionId: "trails",
collectionName: "trails",
expand: { author: {} as any }
};
return t;
});
return {
trails,
mapTrails,
clusters: clusterFeatureCollection,
estimatedTotalHits: listResult.estimatedTotalHits ?? clusterResult.totalHits,
totalHits: listResult.totalHits ?? listResult.estimatedTotalHits ?? clusterResult.totalHits,
totalPages: listResult.totalPages
};
}
export async function trails_show(id: string, handle?: string, share?: string, loadGPX?: boolean, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
@@ -503,10 +688,12 @@ export async function fetchGPX(trail: { gpx?: string } & Record<string, any>, f:
export async function searchResultToTrailList(hits: Hits<TrailSearchResult>): Promise<Trail[]> {
const trails: Trail[] = []
for (const h of hits) {
const created = Number(h.created || 0);
const date = Number(h.date || 0);
const t: Trail & RecordModel = {
collectionId: "trails",
collectionName: "trails",
updated: new Date(h.created * 1000).toISOString(),
updated: new Date(created * 1000).toISOString(),
author: h.author_name,
name: h.name,
photos: h.thumbnail ? [h.thumbnail] : [],
@@ -516,8 +703,8 @@ export async function searchResultToTrailList(hits: Hits<TrailSearchResult>): Pr
waypoints: [],
tags: h.tags ?? [],
category: h.category,
created: new Date(h.created * 1000).toISOString(),
date: new Date(h.date * 1000).toISOString(),
created: new Date(created * 1000).toISOString(),
date: new Date(date * 1000).toISOString(),
description: h.description,
difficulty: h.difficulty == 0 ? "easy" : h.difficulty == 1 ? "moderate" : "difficult",
distance: h.distance,
@@ -530,6 +717,7 @@ export async function searchResultToTrailList(hits: Hits<TrailSearchResult>): Pr
location: h.location,
gpx: h.gpx,
polyline: h.polyline,
bounding_box_diagonal: h.bounding_box_diagonal ?? 0,
domain: h.domain,
iri: h.iri,
thumbnail: 0,

View File

@@ -28,6 +28,7 @@ export class ClusterLayer implements BaseLayer {
"clusters": { ...this.listeners["clusters"], ...listeners?.["clusters"] },
"unclustered-point": { ...this.listeners["unclustered-point"], ...listeners?.["unclustered-point"] }
}
this.spec = {
version: 8,
name: "clusters",
@@ -36,8 +37,6 @@ export class ClusterLayer implements BaseLayer {
"cluster-trails": {
type: "geojson",
data: geojson,
cluster: true,
clusterRadius: 50,
}
},
layers: [
@@ -45,26 +44,37 @@ export class ClusterLayer implements BaseLayer {
id: "clusters",
type: "circle",
source: "cluster-trails",
filter: ["has", "point_count"],
maxzoom: 10,
filter: ["all", ["!=", ["get", "is_large"], true], [">", ["get", "point_count"], 1]],
paint: {
"circle-color": "#242734",
"circle-radius": [
"step",
["get", "point_count"],
10,
5,
12,
10,
15,
20,
20,
50,
25,
18,
100,
30,
200,
35,
22,
500,
25,
],
"circle-stroke-width": 3,
"circle-stroke-width": 2,
"circle-stroke-color": "#fff",
},
},
{
id: "unclustered-point",
type: "circle",
source: "cluster-trails",
filter: ["all", ["!=", ["get", "is_large"], true], ["==", ["get", "point_count"], 1]],
paint: {
"circle-color": "#242734",
"circle-radius": 5,
"circle-stroke-width": 2,
"circle-stroke-color": "#fff",
},
},
@@ -72,29 +82,17 @@ export class ClusterLayer implements BaseLayer {
id: "cluster-count",
type: "symbol",
source: "cluster-trails",
filter: ["has", "point_count"],
maxzoom: 10,
filter: ["all", ["!=", ["get", "is_large"], true], [">", ["get", "point_count"], 1]],
layout: {
"text-field": ["get", "point_count_abbreviated"],
"text-font": ["Noto Sans Regular"],
"text-size": 11,
"text-allow-overlap": true,
"text-ignore-placement": true,
},
paint: {
"text-color": "#fff",
},
layout: {
"text-field": "{point_count_abbreviated}",
"text-font": ["Noto Sans Regular"],
"text-size": 12,
},
},
{
id: "unclustered-point",
type: "circle",
source: "cluster-trails",
maxzoom: 10,
filter: ["!", ["has", "point_count"]],
paint: {
"circle-color": "#242734",
"circle-radius": 7,
"circle-stroke-width": 2,
"circle-stroke-color": "#fff",
},
}
]
@@ -105,19 +103,26 @@ export class ClusterLayer implements BaseLayer {
const features = this.map.queryRenderedFeatures(e.point, {
layers: ["clusters"],
});
const clusterId = features[0].properties.cluster_id;
const zoom = await (
this.map.getSource("cluster-trails") as M.GeoJSONSource
).getClusterExpansionZoom(clusterId);
const feature = features[0];
if (!feature) {
return;
}
const currentZoom = this.map.getZoom();
this.map.flyTo({
center: (features[0].geometry as any).coordinates,
zoom,
center: (feature.geometry as any).coordinates,
zoom: currentZoom + 2,
maxDuration: 3000
});
}
private zoomOnUnclusteredPoint(e: MapMouseEvent) {
const coordinates = (e as any).features[0].geometry.coordinates.slice();
const feature = (e as any).features?.[0];
if (!feature) {
return;
}
const coordinates = feature.geometry.coordinates.slice();
this.map.flyTo({
center: coordinates,
@@ -125,4 +130,4 @@ export class ClusterLayer implements BaseLayer {
maxDuration: 3000
});
}
}
}

View File

@@ -4,6 +4,7 @@ import { baseMapStyles, defaultMapState, type BaseLayer, type MapState } from ".
import { OverlayLayer } from "./overlay-layer";
import { OverpassLayer, type OverpassPopupActionFactory } from "./overpass-layer";
const DEFAULT_GLYPHS = "https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf";
export class LayerManager {
@@ -120,6 +121,14 @@ export class LayerManager {
}
}
const style = this.map.getStyle();
if (
!style.glyphs &&
layer.spec.layers.some((l) => l.type === "symbol" && l.layout && "text-field" in l.layout)
) {
this.map.setGlyphs(layer.spec.glyphs ?? DEFAULT_GLYPHS);
}
for (const l of layer.spec.layers) {
if (!this.map.getLayer(l.id)) {
this.map.addLayer(l)
@@ -210,4 +219,4 @@ export class LayerManager {
this.addLayer(id, layer)
}
}
}
}

View File

@@ -18,9 +18,10 @@ export class PreviewLayer implements BaseLayer {
}
};
constructor(map: M.Map, geojson: GeoJSON.FeatureCollection, listeners?: Record<string, { onMouseUp?: (e: MapMouseEvent) => void; onMouseDown?: (e: MapMouseEvent) => void; onEnter?: (e: MapMouseEvent) => void; onLeave?: (e: MapMouseEvent) => void; onMouseMove?: (e: MapMouseEvent) => void; }>) {
constructor(map: M.Map, geojson: GeoJSON.FeatureCollection, options?: { showStartMarker?: boolean, listeners?: Record<string, { onMouseUp?: (e: MapMouseEvent) => void; onMouseDown?: (e: MapMouseEvent) => void; onEnter?: (e: MapMouseEvent) => void; onLeave?: (e: MapMouseEvent) => void; onMouseMove?: (e: MapMouseEvent) => void; }> }) {
this.map = map;
const listeners = options?.listeners;
this.listeners = {
"preview": { ...this.listeners["preview"], ...listeners?.["preview"] },
"preview-start-points": { ...this.listeners["preview-start-points"], ...listeners?.["preview-start-points"] }
@@ -40,9 +41,11 @@ export class PreviewLayer implements BaseLayer {
}
}))
};
this.spec = {
version: 8,
name: "preview",
glyphs: "https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf",
sources: {
"preview": {
type: "geojson",
@@ -58,7 +61,6 @@ export class PreviewLayer implements BaseLayer {
id: "preview",
type: "line",
source: "preview",
minzoom: 10,
paint: {
"line-color": ["get", "color"],
"line-width": 5,
@@ -68,10 +70,10 @@ export class PreviewLayer implements BaseLayer {
id: "preview-start-points",
type: "circle",
source: "preview-start-points",
minzoom: 10,
filter: ["literal", options?.showStartMarker ?? false],
paint: {
"circle-color": "#242734",
"circle-radius": 6,
"circle-radius": 5,
"circle-stroke-width": 2,
"circle-stroke-color": "#fff",
},
@@ -80,7 +82,6 @@ export class PreviewLayer implements BaseLayer {
id: "preview-direction-carets",
type: "symbol",
source: "preview",
minzoom: 10,
layout: {
"symbol-placement": "line",
"symbol-spacing": [
@@ -108,4 +109,4 @@ export class PreviewLayer implements BaseLayer {
};
}
}
}

View File

@@ -1,4 +1,5 @@
import type { MapMouseEvent, Marker, StyleSpecification } from "maplibre-gl";
import type { FilterSpecification, MapMouseEvent, Marker, StyleSpecification } from "maplibre-gl";
import * as M from "maplibre-gl";
import type { BaseLayer } from "./layers";
export class TrailLayer implements BaseLayer {
@@ -7,7 +8,19 @@ export class TrailLayer implements BaseLayer {
listeners: Record<string, { onMouseUp?: (e: MapMouseEvent) => void; onMouseDown?: (e: MapMouseEvent) => void; onEnter?: (e: MapMouseEvent) => void; onLeave?: (e: MapMouseEvent) => void; onMouseMove?: (e: MapMouseEvent) => void; }>
markers: Record<string, Marker> = {};
constructor(id: string, geojson: GeoJSON.FeatureCollection, color: string, listerners?: { onMouseUp?: (e: MapMouseEvent) => void; onMouseDown?: (e: MapMouseEvent) => void; onEnter?: (e: MapMouseEvent) => void; onLeave?: (e: MapMouseEvent) => void; onMouseMove?: (e: MapMouseEvent) => void; }) {
constructor(id: string, geojson: GeoJSON.FeatureCollection, color: string, options?: {
listeners?: { onMouseUp?: (e: MapMouseEvent) => void; onMouseDown?: (e: MapMouseEvent) => void; onEnter?: (e: MapMouseEvent) => void; onLeave?: (e: MapMouseEvent) => void; onMouseMove?: (e: MapMouseEvent) => void; }
}) {
const layer: M.LineLayerSpecification = {
id: id,
type: "line",
source: id,
paint: {
"line-color": color,
"line-width": 5,
},
};
this.spec = {
version: 8,
name: id,
@@ -17,18 +30,10 @@ export class TrailLayer implements BaseLayer {
data: geojson,
}
},
layers: [{
id: id,
type: "line",
source: id,
paint: {
"line-color": color,
"line-width": 5,
},
}]
layers: [layer]
};
this.listeners = { [id]: listerners ?? {} }
this.listeners = { [id]: options?.listeners ?? {} }
}
}

View File

@@ -0,0 +1,128 @@
import { error, json, type RequestEvent } from "@sveltejs/kit";
import Supercluster from "supercluster";
import { MAP_MAX_POLYLINES } from "$lib/config/map";
function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function isValidLngLat(value: any): value is { lat: number; lng: number } {
return isFiniteNumber(value?.lat) && isFiniteNumber(value?.lng);
}
export async function POST(event: RequestEvent) {
const data = await event.request.json()
const { southWest, northEast, zoom, filterText, q = "" } = data;
if (!southWest || !northEast || zoom === undefined) {
throw error(400, "Missing required parameters: southWest, northEast, zoom");
}
if (!isValidLngLat(southWest) || !isValidLngLat(northEast) || !isFiniteNumber(zoom)) {
throw error(400, "Invalid cluster bounds or zoom");
}
try {
let lonFilter = `max_lon >= ${southWest.lng} AND min_lon <= ${northEast.lng}`;
if (southWest.lng > northEast.lng) {
lonFilter = `(max_lon >= ${southWest.lng} OR min_lon <= ${northEast.lng})`;
}
const geoFilter = `max_lat >= ${southWest.lat} AND min_lat <= ${northEast.lat} AND ${lonFilter}`;
const summaryQuery = {
indexUid: "trails",
q,
filter: [geoFilter, filterText].filter(f => f && f !== ""),
attributesToRetrieve: ["id", "_geo", "bounding_box_diagonal"],
limit: 10000,
};
const r = await event.locals.ms.multiSearch({
queries: [summaryQuery]
});
const hits = r.results[0].hits;
const clusteringMaxZoom = event.locals.settings?.behavior?.mapClusteringMaxZoom ?? 11;
const forceClustering = zoom < clusteringMaxZoom;
// Dynamic Threshold: Sort by diagonal and pick top N for polylines
const sortedHits = [...hits].sort((a: any, b: any) => (b.bounding_box_diagonal ?? 0) - (a.bounding_box_diagonal ?? 0));
const largeHits = forceClustering ? [] : sortedHits.slice(0, MAP_MAX_POLYLINES);
const smallHits = forceClustering ? sortedHits : sortedHits.slice(MAP_MAX_POLYLINES);
const smallFeatures: GeoJSON.Feature<GeoJSON.Point, any>[] = smallHits.map((h: any) => ({
type: "Feature",
properties: {
id: h.id,
bounding_box_diagonal: h.bounding_box_diagonal ?? 0
},
geometry: {
type: "Point",
coordinates: [h._geo.lng, h._geo.lat]
}
}));
const index = new Supercluster({
radius: 40, // Less aggressive clustering
maxZoom: 16,
});
index.load(smallFeatures);
const bbox: [number, number, number, number] = southWest.lng > northEast.lng
? [-180, southWest.lat, 180, northEast.lat]
: [southWest.lng, southWest.lat, northEast.lng, northEast.lat];
const clusters = index.getClusters(
bbox,
Math.floor(zoom)
);
function abbreviateCount(count: number): string {
if (count >= 1000) {
return (count / 1000).toFixed(1) + "k";
}
return count.toString();
}
const normalizedSmallFeatures = clusters.map((f: any) => {
if (f.properties.cluster) {
f.properties.point_count_abbreviated = abbreviateCount(f.properties.point_count);
} else {
f.properties.point_count = 1;
f.properties.point_count_abbreviated = "1";
f.properties.is_large = false;
}
return f;
});
// Step 3: Individual markers for large trails (NOT clustered)
const largeFeatures: GeoJSON.Feature<GeoJSON.Point, any>[] = largeHits.map((h: any) => ({
type: "Feature",
properties: {
id: h.id,
cluster: false,
is_large: true,
point_count: 1,
point_count_abbreviated: "1",
bounding_box_diagonal: h.bounding_box_diagonal ?? 0
},
geometry: {
type: "Point",
coordinates: [h._geo.lng, h._geo.lat] // Back to stable anchor point
}
}));
return json({
type: "FeatureCollection",
features: [...normalizedSmallFeatures, ...largeFeatures],
totalHits: r.results[0].estimatedTotalHits ?? r.results[0].totalHits
});
} catch (e: any) {
console.error("Clustering error:", e);
throw error(e.httpStatus || 500, e.message ?? "Unable to cluster trails");
}
}

View File

@@ -29,11 +29,14 @@
import { trails_search_bounding_box } from "$lib/stores/trail_store";
import { getIconForLocation } from "$lib/util/icon_util";
import type { Snapshot } from "@sveltejs/kit";
import type { FeatureCollection } from "geojson";
import * as M from "maplibre-gl";
import { _ } from "svelte-i18n";
import { slide } from "svelte/transition";
let trails: Trail[] = $state([]);
let mapTrails: Trail[] = $state([]);
let clusters: FeatureCollection | undefined = $state();
let map: M.Map | undefined = $state();
let mapWithElevation: MapWithElevationMaplibre | undefined = $state();
@@ -46,8 +49,6 @@
const maxBoundingBox: TrailBoundingBox = page.data.boundingBox;
const settings: Settings = page.data.settings;
const MIN_ZOOM = 10;
let loading: boolean = $state(true);
let loadingNextPage: boolean = false;
@@ -55,6 +56,7 @@
page: 1,
totalPages: 1,
};
let searchRequestId = 0;
const sortOptions: SelectItem[] = [
{ text: $_("name"), value: "name" },
@@ -98,19 +100,19 @@
],
});
const trailItems = r[0].hits.map((t: TrailSearchResult) => ({
const trailItems = (r[0]?.hits || []).map((t: TrailSearchResult) => ({
text: t.name,
description: `Trail ${t.location.length ? ", " + t.location : ""}`,
value: `@${t.author_name}${t.domain ? `@${t.domain}` : ""}/${t.id}`,
icon: "route",
}));
const listItems = r[1].hits.map((t: ListSearchResult) => ({
const listItems = (r[1]?.hits || []).map((t: ListSearchResult) => ({
text: t.name,
description: `List, ${t.trails} ${$_("trail", { values: { n: t.trails } })}`,
value: t.id,
icon: "layer-group",
}));
const cityItems = r[2].hits.map((c: LocationSearchResult) => ({
const cityItems = (r[2]?.hits || []).map((c: LocationSearchResult) => ({
text: c.name,
description: c.description,
value: c,
@@ -135,7 +137,11 @@
northEast: M.LngLat,
southWest: M.LngLat,
reset: boolean = true,
loadMapData: boolean = true,
) {
const requestId =
reset || loadMapData ? ++searchRequestId : searchRequestId;
if (reset) {
pagination.page = 1;
loading = true;
@@ -146,11 +152,23 @@
southWest,
filter,
pagination.page,
(map?.getZoom() ?? 0) > MIN_ZOOM,
map?.getZoom(),
50,
loadMapData,
);
if (requestId !== searchRequestId) {
return false;
}
pagination.totalPages = trailsInBox.totalPages;
trails = trailsInBox.trails;
if (loadMapData) {
mapTrails = trailsInBox.mapTrails;
clusters = trailsInBox.clusters;
}
loading = false;
return true;
}
function handleTrailCardMouseEnter(trail: Trail) {
@@ -190,33 +208,57 @@
await searchTrails(bounds.getNorthEast(), bounds.getSouthWest());
}
let moveTimeout: ReturnType<typeof setTimeout> | undefined;
async function handleMapMove() {
if (!map) {
return;
}
const bounds = map.getBounds();
const normalizedBounds = {
southWest: new M.LngLat(
((((bounds.getSouthWest().lng + 180) % 360) + 360) % 360) - 180,
bounds.getSouthWest().lat,
),
northEast: new M.LngLat(
((((bounds.getNorthEast().lng + 180) % 360) + 360) % 360) - 180,
bounds.getNorthEast().lat,
),
};
await searchTrails(
normalizedBounds.northEast,
normalizedBounds.southWest,
);
if (moveTimeout) {
clearTimeout(moveTimeout);
}
moveTimeout = setTimeout(async () => {
const bounds = map!.getBounds();
const west = bounds.getWest();
const east = bounds.getEast();
const north = bounds.getNorth();
const south = bounds.getSouth();
page.url.searchParams.set("tl_lat", bounds.getNorth().toString());
page.url.searchParams.set("tl_lon", bounds.getEast().toString());
page.url.searchParams.set("br_lat", bounds.getSouth().toString());
page.url.searchParams.set("br_lon", bounds.getWest().toString());
let normalizedSW: M.LngLat;
let normalizedNE: M.LngLat;
goto(`?${page.url.searchParams.toString()}`);
if (east - west >= 360) {
// Global view
normalizedSW = new M.LngLat(-180, south);
normalizedNE = new M.LngLat(180, north);
} else {
// Handle wrap-around
normalizedSW = new M.LngLat(
((((west + 180) % 360) + 360) % 360) - 180,
south,
);
normalizedNE = new M.LngLat(
((((east + 180) % 360) + 360) % 360) - 180,
north,
);
}
const applied = await searchTrails(normalizedNE, normalizedSW);
if (!applied) {
return;
}
page.url.searchParams.set("tl_lat", north.toString());
page.url.searchParams.set("tl_lon", east.toString());
page.url.searchParams.set("br_lat", south.toString());
page.url.searchParams.set("br_lon", west.toString());
goto(`?${page.url.searchParams.toString()}`, {
replaceState: true,
noScroll: true,
keepFocus: true,
});
}, 200);
}
function handleMapInit() {
@@ -314,7 +356,7 @@
}
pagination.page += 1;
const bounds = map.getBounds();
await searchTrails(bounds.getNorthEast(), bounds.getSouthWest(), false);
await searchTrails(bounds.getNorthEast(), bounds.getSouthWest(), false, false);
}
</script>
@@ -391,7 +433,7 @@
{#if trails.length == 0}
<EmptyStateSearch></EmptyStateSearch>
{/if}
{#each trails as trail, i}
{#each trails.filter(t => t.name !== "") as trail, i}
<a
href="/map/trail/@{trail.author}{trail.domain
? `@${trail.domain}`
@@ -419,7 +461,8 @@
<MapWithElevationMaplibre
onmoveend={handleMapMove}
oninit={handleMapInit}
{trails}
trails={mapTrails}
serverClusters={clusters}
showElevation={false}
showTerrain={true}
showInfoPopup={true}
@@ -427,6 +470,7 @@
fitBounds="off"
clusterTrails={true}
bind:map
bind:this={mapWithElevation}
></MapWithElevationMaplibre>
</div>

View File

@@ -3,7 +3,7 @@ import { categories_index } from "$lib/stores/category_store";
import { trails_get_bounding_box, trails_get_filter_values } from "$lib/stores/trail_store";
import type { ServerLoad } from "@sveltejs/kit";
export const load: ServerLoad = async ({ params, locals, fetch }) => {
export const load: ServerLoad = async ({ fetch }) => {
const boundingBox = await trails_get_bounding_box(fetch);
const filterValues = await trails_get_filter_values(fetch);

View File

@@ -7,6 +7,7 @@
type SelectItem,
} from "$lib/components/base/select.svelte";
import Slider from "$lib/components/base/slider.svelte";
import TextField from "$lib/components/base/text_field.svelte";
import {
searchLocations,
@@ -15,7 +16,7 @@
import { settings_update } from "$lib/stores/settings_store";
import { currentUser } from "$lib/stores/user_store";
import { getIconForLocation } from "$lib/util/icon_util";
import { onMount } from "svelte";
import { onMount, untrack } from "svelte";
import { _ } from "svelte-i18n";
import Toggle from "$lib/components/base/toggle.svelte";
import { show_toast } from "$lib/stores/toast_store.svelte.js";
@@ -25,20 +26,40 @@
let allowAutoGeolocate = $state(
page.data.settings.behavior?.allowAutoGeolocate ?? false,
);
let mapClusteringMaxZoom = $state(
page.data.settings.behavior?.mapClusteringMaxZoom ?? 11,
);
let showTrailStartMarker = $state(
page.data.settings.behavior?.showTrailStartMarker ?? false,
);
async function handleAllowAutoGeolocateChange() {
$effect(() => {
const b = page.data.settings?.behavior;
if (b) {
untrack(() => {
allowAutoGeolocate = b.allowAutoGeolocate ?? false;
mapClusteringMaxZoom = b.mapClusteringMaxZoom ?? 11;
showTrailStartMarker = b.showTrailStartMarker ?? false;
});
}
});
async function handleBehaviorChange() {
if (!settings) {
return;
}
try {
if (!settings.behavior) {
settings.behavior = { allowAutoGeolocate: allowAutoGeolocate };
} else {
settings.behavior.allowAutoGeolocate = allowAutoGeolocate;
}
const updatedSettings = {
...settings,
behavior: {
allowAutoGeolocate: allowAutoGeolocate,
mapClusteringMaxZoom: Number(mapClusteringMaxZoom),
showTrailStartMarker: showTrailStartMarker,
},
};
await settings_update(settings);
await settings_update(updatedSettings);
} catch (e) {
show_toast({
type: "error",
@@ -166,16 +187,50 @@
></Search>
</div>
{/if}
<div
class="mt-4 grid gap-4"
style="grid-template-columns: 1fr min-content ;"
>
<p>{$_("allow-auto-geolocate")}</p>
<div>
<Toggle
bind:value={allowAutoGeolocate}
onchange={handleAllowAutoGeolocateChange}
></Toggle>
<div class="mt-8 space-y-4">
<div
class="grid gap-4 items-center"
style="grid-template-columns: 1fr min-content ;"
>
<p>{$_("allow-auto-geolocate")}</p>
<div>
<Toggle
bind:value={allowAutoGeolocate}
onchange={handleBehaviorChange}
></Toggle>
</div>
</div>
<div
class="grid gap-4 items-center"
style="grid-template-columns: 1fr min-content ;"
>
<p>{$_("map-trail-preview-zoom-level")}</p>
<div class="flex items-center gap-4 w-56">
<span class="w-8 text-right tabular-nums">
{Math.round(Number(mapClusteringMaxZoom))}
</span>
<div class="w-44">
<Slider
minValue={0}
maxValue={22}
step={1}
bind:currentValue={mapClusteringMaxZoom}
onset={handleBehaviorChange}
></Slider>
</div>
</div>
</div>
<div
class="grid gap-4 items-center"
style="grid-template-columns: 1fr min-content ;"
>
<p>{$_("show-trail-start-marker")}</p>
<div>
<Toggle
bind:value={showTrailStartMarker}
onchange={handleBehaviorChange}
></Toggle>
</div>
</div>
</div>
</div>