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:
2
web/package-lock.json
generated
2
web/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
3
web/src/lib/config/map.ts
Normal file
3
web/src/lib/config/map.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { env } from "$env/dynamic/public";
|
||||
|
||||
export const MAP_MAX_POLYLINES = Number(env.PUBLIC_MAP_MAX_POLYLINES || 100);
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -57,6 +57,8 @@ class Settings {
|
||||
|
||||
export type Behavior = {
|
||||
allowAutoGeolocate: boolean;
|
||||
mapClusteringMaxZoom?: number;
|
||||
showTrailStartMarker?: boolean;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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",]
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ?? {} }
|
||||
}
|
||||
}
|
||||
128
web/src/routes/api/v1/search/trails/cluster/+server.ts
Normal file
128
web/src/routes/api/v1/search/trails/cluster/+server.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user