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

* feat: meilisearch bounding box intersection and map filtering

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

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

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

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

* feat: implement dynamic polyline visibility based on result density

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

* refactor: remove obsolete zoom-based map clustering thresholds

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

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

* chore: remove map clustering debug logs

* docs: update changelog and fix global zoom feature disappearance

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

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

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

* minor fixes

* minor ui changes

* fix missing entries in map list

* remove redundant map clustering zoom clamp

* fix merge issues

* fix merge issues

* fixes

* hide popup for trails without details

* several improvements

* prevent import of client trail_store in server cluster code

* update changelog

---------

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

View File

@@ -1,3 +1,8 @@
# [Unreleased]
## Features
- Server-side map clustering and zoom-aware polyline filtering: The world map now performs trail clustering on the server to improve performance. At lower zoom levels, smaller trails are clustered, while at higher zoom levels the largest routes in the current view are shown as detailed polylines. The maximum number of simultaneously visible polylines can be configured via the PUBLIC_MAP_MAX_POLYLINES environment variable.
# v0.19.2
## Documentation
- Add CONTRIBUTING guidelines
@@ -75,7 +80,6 @@
## Maintenance
- Meilisearch, PocketBase, Go, web/docs dependencies, CI actions, and Docker build setup updated.
# v0.18.5
## Security
- Fixes CVE-2022-39299 via xmldom upgrade (PR #820)

View File

@@ -299,9 +299,9 @@ func initMeilisearchConfig(client meilisearch.ServiceManager) {
"trails": {
SearchableAttributes: []string{"author_name", "name", "description", "location", "tags"},
FilterableAttributes: []string{
"_geo", "author", "category", "completed", "date", "difficulty",
"id", "_geo", "author", "category", "completed", "date", "difficulty",
"distance", "elevation_gain", "elevation_loss", "likes", "public",
"shares", "tags",
"shares", "tags", "min_lat", "max_lat", "min_lon", "max_lon", "bounding_box_diagonal",
},
SortableAttributes: []string{
"author", "created", "date", "difficulty", "distance",

View File

@@ -0,0 +1,218 @@
package migrations
import (
"encoding/json"
"pocketbase/util"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
const trailBoundsViewQuery = `SELECT
a.id, a.user,
COALESCE(MAX(t.max_lat), 0) AS max_lat,
COALESCE(MAX(t.max_lon), 0) AS max_lon,
COALESCE(MIN(t.min_lat), 0) AS min_lat,
COALESCE(MIN(t.min_lon), 0) AS min_lon
FROM activitypub_actors a
LEFT JOIN (
SELECT author AS actor_id,
MAX(max_lat) AS max_lat,
MAX(max_lon) AS max_lon,
MIN(min_lat) AS min_lat,
MIN(min_lon) AS min_lon
FROM trails
GROUP BY author
UNION ALL
SELECT ts.actor AS actor_id,
MAX(t.max_lat) AS max_lat,
MAX(t.max_lon) AS max_lon,
MIN(t.min_lat) AS min_lat,
MIN(t.min_lon) AS min_lon
FROM trail_share ts
JOIN trails t ON t.id = ts.trail
GROUP BY ts.actor
UNION ALL
SELECT a2.id AS actor_id,
p.max_lat, p.max_lon, p.min_lat, p.min_lon
FROM activitypub_actors a2
CROSS JOIN (
SELECT
MAX(max_lat) AS max_lat,
MAX(max_lon) AS max_lon,
MIN(min_lat) AS min_lat,
MIN(min_lon) AS min_lon
FROM trails
WHERE public = TRUE
) p
) t ON t.actor_id = a.id
WHERE a.user != ""
GROUP BY a.id;`
const trailStartPointBoundsViewQuery = `SELECT
a.id, a.user,
COALESCE(MAX(t.max_lat), 0) AS max_lat,
COALESCE(MAX(t.max_lon), 0) AS max_lon,
COALESCE(MIN(t.min_lat), 0) AS min_lat,
COALESCE(MIN(t.min_lon), 0) AS min_lon
FROM activitypub_actors a
LEFT JOIN (
SELECT author AS actor_id,
MAX(lat) AS max_lat,
MAX(lon) AS max_lon,
MIN(lat) AS min_lat,
MIN(lon) AS min_lon
FROM trails
GROUP BY author
UNION ALL
SELECT ts.actor AS actor_id,
MAX(t.lat) AS max_lat,
MAX(t.lon) AS max_lon,
MIN(t.lat) AS min_lat,
MIN(t.lon) AS min_lon
FROM trail_share ts
JOIN trails t ON t.id = ts.trail
GROUP BY ts.actor
UNION ALL
SELECT a2.id AS actor_id,
p.max_lat, p.max_lon, p.min_lat, p.min_lon
FROM activitypub_actors a2
CROSS JOIN (
SELECT
MAX(lat) AS max_lat,
MAX(lon) AS max_lon,
MIN(lat) AS min_lat,
MIN(lon) AS min_lon
FROM trails
WHERE public = TRUE
) p
) t ON t.actor_id = a.id
WHERE a.user != ""
GROUP BY a.id;`
func init() {
m.Register(func(app core.App) error {
trailsCollection, err := app.FindCollectionByNameOrId("trails")
if err != nil {
return err
}
addTrailBoundsField(trailsCollection, "min_lat")
addTrailBoundsField(trailsCollection, "max_lat")
addTrailBoundsField(trailsCollection, "min_lon")
addTrailBoundsField(trailsCollection, "max_lon")
addTrailBoundsField(trailsCollection, "bounding_box_diagonal")
if err := app.Save(trailsCollection); err != nil {
return err
}
if err := backfillTrailBounds(app); err != nil {
return err
}
boundingBoxCollection, err := app.FindCollectionByNameOrId("trails_bounding_box")
if err != nil {
return err
}
if err := json.Unmarshal([]byte(`{"viewQuery":`+strconvQuote(trailBoundsViewQuery)+`}`), &boundingBoxCollection); err != nil {
return err
}
if err := app.Save(boundingBoxCollection); err != nil {
return err
}
return nil
}, func(app core.App) error {
boundingBoxCollection, err := app.FindCollectionByNameOrId("trails_bounding_box")
if err != nil {
return err
}
if err := json.Unmarshal([]byte(`{"viewQuery":`+strconvQuote(trailStartPointBoundsViewQuery)+`}`), &boundingBoxCollection); err != nil {
return err
}
if err := app.Save(boundingBoxCollection); err != nil {
return err
}
trailsCollection, err := app.FindCollectionByNameOrId("trails")
if err != nil {
return err
}
trailsCollection.Fields.RemoveByName("min_lat")
trailsCollection.Fields.RemoveByName("max_lat")
trailsCollection.Fields.RemoveByName("min_lon")
trailsCollection.Fields.RemoveByName("max_lon")
trailsCollection.Fields.RemoveByName("bounding_box_diagonal")
if err := app.Save(trailsCollection); err != nil {
return err
}
return nil
})
}
func addTrailBoundsField(collection *core.Collection, name string) {
if collection.Fields.GetByName(name) != nil {
return
}
collection.Fields.Add(&core.NumberField{
Name: name,
})
}
func backfillTrailBounds(app core.App) error {
const pageSize int64 = 50
lastID := ""
for {
trails := []*core.Record{}
query := app.RecordQuery("trails").
OrderBy("id ASC").
Limit(pageSize)
if lastID != "" {
query = query.AndWhere(dbx.NewExp("id > {:lastID}", dbx.Params{"lastID": lastID}))
}
if err := query.All(&trails); err != nil {
return err
}
if len(trails) == 0 {
return nil
}
for _, trail := range trails {
if err := util.SavePolyline(app, trail); err != nil {
if err := saveDefaultTrailBounds(app, trail); err != nil {
return err
}
}
lastID = trail.Id
}
}
}
func saveDefaultTrailBounds(app core.App, trail *core.Record) error {
lat := trail.GetFloat("lat")
lon := trail.GetFloat("lon")
trail.Set("min_lat", lat)
trail.Set("max_lat", lat)
trail.Set("min_lon", lon)
trail.Set("max_lon", lon)
trail.Set("bounding_box_diagonal", 0)
return app.UnsafeWithoutHooks().Save(trail)
}
func strconvQuote(value string) string {
raw, _ := json.Marshal(value)
return string(raw)
}

View File

@@ -39,11 +39,18 @@ func documentFromTrailRecord(app core.App, r *core.Record, author *core.Record,
category = trailCategory.GetString("name")
}
bounds := getStoredBounds(r)
domain := ""
if !author.GetBool("isLocal") {
domain = author.GetString("domain")
}
diagonal := r.GetFloat("bounding_box_diagonal")
if diagonal == 0 && (bounds[0] != bounds[1] || bounds[2] != bounds[3]) {
diagonal = HaversineDistance(bounds[0], bounds[2], bounds[1], bounds[3])
}
document := map[string]any{
"id": r.Id,
"author": author.Id,
@@ -68,6 +75,11 @@ func documentFromTrailRecord(app core.App, r *core.Record, author *core.Record,
"polyline": r.GetString("polyline"),
"domain": domain,
"iri": r.GetString("iri"),
"min_lat": bounds[0],
"max_lat": bounds[1],
"min_lon": bounds[2],
"max_lon": bounds[3],
"bounding_box_diagonal": diagonal,
"_geo": map[string]float64{
"lat": r.GetFloat("lat"),
"lng": r.GetFloat("lon"),
@@ -121,6 +133,22 @@ func difficultyToNumber(difficulty string) int32 {
return 0
}
func getStoredBounds(r *core.Record) [4]float64 {
lat := r.GetFloat("lat")
lon := r.GetFloat("lon")
defaultBounds := [4]float64{lat, lat, lon, lon}
minLat := r.GetFloat("min_lat")
maxLat := r.GetFloat("max_lat")
minLon := r.GetFloat("min_lon")
maxLon := r.GetFloat("max_lon")
if minLat == 0 && maxLat == 0 && minLon == 0 && maxLon == 0 && (lat != 0 || lon != 0) {
return defaultBounds
}
return [4]float64{minLat, maxLat, minLon, maxLon}
}
func documentFromListRecord(r *core.Record, author *core.Record, includeShares bool) (map[string]any, error) {
totalElevationGain := 0.0

View File

@@ -12,33 +12,82 @@ import (
const PolylineMaxLength = 5 * 1024 * 1024
func ComputePolyline(app core.App, r *core.Record) (string, error) {
type TrailGeometry struct {
Polyline string
MinLat float64
MaxLat float64
MinLon float64
MaxLon float64
BoundingBoxDiagonal float64
}
func ComputeTrailGeometry(app core.App, r *core.Record) (*TrailGeometry, error) {
geometry := &TrailGeometry{
MinLat: r.GetFloat("lat"),
MaxLat: r.GetFloat("lat"),
MinLon: r.GetFloat("lon"),
MaxLon: r.GetFloat("lon"),
}
gpxPath := r.GetString("gpx")
if len(gpxPath) == 0 {
return "", nil
return geometry, nil
}
fsys, err := app.NewFilesystem()
if err != nil {
return "", fmt.Errorf("open filesystem: %w", err)
return nil, fmt.Errorf("open filesystem: %w", err)
}
defer fsys.Close()
gpxFilePath := r.BaseFilesPath() + "/" + gpxPath
gpxFile, err := fsys.GetReader(gpxFilePath)
if err != nil {
return "", fmt.Errorf("open gpx file %q: %w", gpxFilePath, err)
return nil, fmt.Errorf("open gpx file %q: %w", gpxFilePath, err)
}
defer gpxFile.Close()
content := new(bytes.Buffer)
if _, err = io.Copy(content, gpxFile); err != nil {
return "", fmt.Errorf("read gpx file %q: %w", gpxFilePath, err)
return nil, fmt.Errorf("read gpx file %q: %w", gpxFilePath, err)
}
gpxData, err := gpx.Parse(content)
if err != nil {
return "", fmt.Errorf("parse gpx file %q: %w", gpxFilePath, err)
return nil, fmt.Errorf("parse gpx file %q: %w", gpxFilePath, err)
}
minLat, maxLat, minLon, maxLon := 90.0, -90.0, 180.0, -180.0
hasPoints := false
addPoint := func(lat, lon float64) {
if lat < minLat {
minLat = lat
}
if lat > maxLat {
maxLat = lat
}
if lon < minLon {
minLon = lon
}
if lon > maxLon {
maxLon = lon
}
hasPoints = true
}
for _, trk := range gpxData.Tracks {
for _, seg := range trk.Segments {
for _, pt := range seg.Points {
addPoint(pt.Latitude, pt.Longitude)
}
}
}
for _, rte := range gpxData.Routes {
for _, pt := range rte.Points {
addPoint(pt.Latitude, pt.Longitude)
}
}
gpxData.SimplifyTracks(50)
@@ -50,21 +99,44 @@ func ComputePolyline(app core.App, r *core.Record) (string, error) {
}
}
}
return string(polyline.EncodeCoords(coordinates)), nil
geometry.Polyline = string(polyline.EncodeCoords(coordinates))
if hasPoints {
geometry.MinLat = minLat
geometry.MaxLat = maxLat
geometry.MinLon = minLon
geometry.MaxLon = maxLon
geometry.BoundingBoxDiagonal = HaversineDistance(minLat, minLon, maxLat, maxLon)
}
return geometry, nil
}
func ComputePolyline(app core.App, r *core.Record) (string, error) {
geometry, err := ComputeTrailGeometry(app, r)
if err != nil {
return "", err
}
return geometry.Polyline, nil
}
func SavePolyline(app core.App, r *core.Record) error {
encoded, err := ComputePolyline(app, r)
geometry, err := ComputeTrailGeometry(app, r)
if err != nil {
return err
}
// Encoded polylines are ASCII-only, so byte length matches character length.
if len(encoded) > PolylineMaxLength {
if len(geometry.Polyline) > PolylineMaxLength {
return fmt.Errorf("polyline exceeds maximum length of %d characters", PolylineMaxLength)
}
r.Set("polyline", encoded)
r.Set("polyline", geometry.Polyline)
r.Set("min_lat", geometry.MinLat)
r.Set("max_lat", geometry.MaxLat)
r.Set("min_lon", geometry.MinLon)
r.Set("max_lon", geometry.MaxLon)
r.Set("bounding_box_diagonal", geometry.BoundingBoxDiagonal)
if err := app.UnsafeWithoutHooks().Save(r); err != nil {
return fmt.Errorf("save trail polyline: %w", err)
return fmt.Errorf("save trail geometry: %w", err)
}
return nil
}

View File

@@ -67,6 +67,7 @@ services:
OVERPASS_API_URL: https://overpass-api.de
VALHALLA_URL: https://valhalla1.openstreetmap.de
NOMINATIM_URL: https://nominatim.openstreetmap.org
PUBLIC_MAP_MAX_POLYLINES: 100
volumes:
- ./data/uploads:/app/uploads
# - ./data/about.md:/app/build/client/md/about.md

View File

@@ -55,6 +55,7 @@ services:
OVERPASS_API_URL: https://overpass-api.de
VALHALLA_URL: https://valhalla1.openstreetmap.de
NOMINATIM_URL: https://nominatim.openstreetmap.org
PUBLIC_MAP_MAX_POLYLINES: 100
volumes:
- uploads:/app/uploads
# - ./data/about.md:/app/build/client/md/about.md

View File

@@ -55,6 +55,7 @@ services:
OVERPASS_API_URL: https://overpass-api.de
VALHALLA_URL: https://valhalla1.openstreetmap.de
NOMINATIM_URL: https://nominatim.openstreetmap.org
PUBLIC_MAP_MAX_POLYLINES: 100
volumes:
- uploads:/app/uploads
# - ./data/about.md:/app/build/client/md/about.md

View File

@@ -43,6 +43,7 @@ Since we use an unmodified installation of meilisearch you can use all variables
| PUBLIC_POCKETBASE_URL | IP or hostname (including the port) of your pocketbase instance | http://db:8090 |
| PUBLIC_DISABLE_SIGNUP | Disables signup option for new users | false |
| PUBLIC_PRIVATE_INSTANCE | Setting this to true will block visitors from viewing content without an account | false |
| PUBLIC_MAP_MAX_POLYLINES | Maximum number of polylines (route previews) to show simultaneously on the map, based on result density | 100 |
| UPLOAD_FOLDER | Folder from which <span class="-tracking-[0.075em]">wanderer</span> auto-uploads trails | /app/uploads |
| UPLOAD_USER | Username for the account with which <span class="-tracking-[0.075em]">wanderer</span> auto-uploads trails | |
| UPLOAD_PASSWORD | Password for the account with which <span class="-tracking-[0.075em]">wanderer</span> auto-uploads trails | |

View File

@@ -17,7 +17,7 @@ You can switch between these styles by opening the style switcher menu with the
To further personalize your map, you can add custom map styles by providing a URL to a `style.json` file. This allows you to fully control the maps appearance using your own vector tile styles. Follow these steps to add and use your custom styles:
1. Navigate to `Settings -> Display`.
1. Navigate to `Settings -> Map`.
2. Under the `Tilesets` section, you can add your custom map styles:
- Enter an arbitrary name for your style (this is how it will appear in the style switcher menu).
- Paste the URL pointing to your `style.json` file. This file should define the vector tile style you want to use.
@@ -32,7 +32,7 @@ Once added, your custom style will be available in the style switcher menu, allo
To enhance <span class="-tracking-[0.075em]">wanderer</span>'s map visualization, you can add two types of data sources to display 3D Terrain and Hillshading. This is achieved by providing URLs pointing to the required `tiles.json` files. Both the terrain and hillshading data must be in Mapbox TileJSON format and accessible through the provided URLs.
To add the respective URLs navigate to `Settings -> Display` and add them in the `Terrain` section. After adding the terrain & hillshading source, you can explore the 3D map view by interacting with the compass control on the map.
To add the respective URLs navigate to `Settings -> Map` and add them in the `Terrain` section. After adding the terrain & hillshading source, you can explore the 3D map view by interacting with the compass control on the map.
1. Enable 3D terrain with the control on the bottom-right.
2. Locate the compass control in the top-right corner of the map.
@@ -41,9 +41,18 @@ To add the respective URLs navigate to `Settings -> Display` and add them in the
## Route drawing behavior
You can configure how new route drawing starts in `Settings -> Display`.
You can configure how new route drawing starts in `Settings -> Map`.
- Enable `Begin drawing a new trail from your current location` to automatically center route drawing on your current GPS location.
- Disable it to start drawing at the current map view instead.
This option only affects creating a **new** trail in the route editor.
## Trail previews on the map
You can configure how trail previews are displayed on the main map in `Settings -> Map`.
- `Show trail previews from zoom level` controls from which zoom level individual trail lines are shown instead of clustered points.
- `Show marker at start of trail` adds a small marker to the beginning of visible trail previews.
The number of trail preview lines shown at the same time can also be limited by the `PUBLIC_MAP_MAX_POLYLINES` environment variable.

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) => {
trails.forEach((t) => {
if (t.id) {
let fc: FeatureCollection | null = null;
if (t.expand?.gpx) {
r.push(t.expand.gpx.toGeoJSON());
fc = t.expand.gpx.toGeoJSON();
} else if (t.expand?.gpx_data) {
r.push(GPX.parse(t.expand.gpx_data).toGeoJSON());
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 (clusterTrails) {
addPreviewLayer(previewData);
addClusterLayer(clusterData);
}
if (!drawing && fitBounds !== "off") {
const currentBboxes = Object.values(gpxDataMap)
.map((d) => d.bbox)
.filter((b) => b !== undefined);
if (
!drawing &&
fitBounds !== "off" &&
data.some((d) => d.bbox !== undefined)
activeTrail !== null &&
trails[activeTrail] &&
mapLoaded &&
gpxDataMap[trails[activeTrail].id!]
) {
if (activeTrail !== null && trails[activeTrail] && mapLoaded) {
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,9 +434,10 @@
trailColors[
clusterTrails
? hashStringToIndex(id ?? "", trailColors.length)
: 0
: index % trailColors.length
],
{
listeners: {
onEnter: (e) =>
highlightTrail(id, trails[activeTrail ?? -1]?.id == id),
@@ -402,6 +448,7 @@
onMouseMove: moveCrosshairToCursorPosition,
onMouseDown: (e) => handleDragStart(e, id),
},
},
);
layerManager.addLayer(id, trailLayer);
@@ -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, {
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 (!trail) return;
if (!hasTrailDetails(trail)) return;
highlightCluster(trail, e.lngLat);
},
onLeave: (e) => {
// unHighlightCluster();
},
},
}),
@@ -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", {
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 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;
}
// Step 1: Fetch paginated trails for the side list.
const listResponse = await fetch("/api/v1/search/trails", {
method: "POST",
body: JSON.stringify({
q: "",
q: filter.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
}
filter: listFilter,
attributesToRetrieve: defaultTrailSearchAttributes,
sort: [`${filter.sort}:${filter.sortOrder == "+" ? "asc" : "desc"}`],
hitsPerPage: perPage,
page,
},
}),
});
const result: { page: number, totalPages: number, hits: Hits<TrailSearchResult> } = await r.json();
if (result.hits.length == 0) {
trails = [];
return { trails: [], ...result }
if (!listResponse.ok) {
const response = await listResponse.json();
throw new APIError(listResponse.status, response.message, response.detail)
}
const resultTrails: Trail[] = await searchResultToTrailList(result.hits)
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(resultTrails) : resultTrails
trails = page > 1 ? trails.concat(listTrails) : listTrails;
return { trails, ...result };
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,

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)

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": [

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();
let normalizedSW: M.LngLat;
let normalizedNE: M.LngLat;
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,
);
}
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());
const applied = await searchTrails(normalizedNE, normalizedSW);
if (!applied) {
return;
}
goto(`?${page.url.searchParams.toString()}`);
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,18 +187,52 @@
></Search>
</div>
{/if}
<div class="mt-8 space-y-4">
<div
class="mt-4 grid gap-4"
class="grid gap-4 items-center"
style="grid-template-columns: 1fr min-content ;"
>
<p>{$_("allow-auto-geolocate")}</p>
<div>
<Toggle
bind:value={allowAutoGeolocate}
onchange={handleAllowAutoGeolocateChange}
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>
<div>
<h4 class="text-xl font-medium mb-2">{$_("tilesets")}</h4>