diff --git a/CHANGELOG.md b/CHANGELOG.md index d03bd95b..1ff2e6aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/db/main.go b/db/main.go index 0e201851..11f5b152 100644 --- a/db/main.go +++ b/db/main.go @@ -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", diff --git a/db/migrations/1778583800_persist_trail_bounds.go b/db/migrations/1778583800_persist_trail_bounds.go new file mode 100644 index 00000000..19e8f4b0 --- /dev/null +++ b/db/migrations/1778583800_persist_trail_bounds.go @@ -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) +} diff --git a/db/util/meilisearch.go b/db/util/meilisearch.go index 5ae1f916..6c4c4b58 100644 --- a/db/util/meilisearch.go +++ b/db/util/meilisearch.go @@ -39,35 +39,47 @@ 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, - "author_name": author.GetString("preferred_username"), - "author_avatar": author.GetString("icon"), - "name": r.GetString("name"), - "description": r.GetString("description"), - "location": r.GetString("location"), - "distance": r.GetFloat("distance"), - "elevation_gain": r.GetFloat("elevation_gain"), - "elevation_loss": r.GetFloat("elevation_loss"), - "duration": r.GetFloat("duration"), - "difficulty": difficultyToNumber(r.GetString("difficulty")), - "category": category, - "completed": r.GetBool("completed"), - "date": r.GetDateTime("date").Time().Unix(), - "created": r.GetDateTime("created").Time().Unix(), - "public": r.GetBool("public"), - "thumbnail": thumbnail, - "gpx": r.GetString("gpx"), - "tags": tags, - "polyline": r.GetString("polyline"), - "domain": domain, - "iri": r.GetString("iri"), + "id": r.Id, + "author": author.Id, + "author_name": author.GetString("preferred_username"), + "author_avatar": author.GetString("icon"), + "name": r.GetString("name"), + "description": r.GetString("description"), + "location": r.GetString("location"), + "distance": r.GetFloat("distance"), + "elevation_gain": r.GetFloat("elevation_gain"), + "elevation_loss": r.GetFloat("elevation_loss"), + "duration": r.GetFloat("duration"), + "difficulty": difficultyToNumber(r.GetString("difficulty")), + "category": category, + "completed": r.GetBool("completed"), + "date": r.GetDateTime("date").Time().Unix(), + "created": r.GetDateTime("created").Time().Unix(), + "public": r.GetBool("public"), + "thumbnail": thumbnail, + "gpx": r.GetString("gpx"), + "tags": tags, + "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 diff --git a/db/util/polyline.go b/db/util/polyline.go index 291a740e..69dfd5ba 100644 --- a/db/util/polyline.go +++ b/db/util/polyline.go @@ -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 } diff --git a/docker-compose.yml b/docker-compose.yml index 9dda7d8a..4df742ad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 5b821175..67b9ff9a 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -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 diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index fc398ca4..43768373 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -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 diff --git a/docs/src/content/docs/run/environment-configuration.md b/docs/src/content/docs/run/environment-configuration.md index ee0e10b0..a912cbdc 100644 --- a/docs/src/content/docs/run/environment-configuration.md +++ b/docs/src/content/docs/run/environment-configuration.md @@ -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 wanderer auto-uploads trails | /app/uploads | | UPLOAD_USER | Username for the account with which wanderer auto-uploads trails | | | UPLOAD_PASSWORD | Password for the account with which wanderer auto-uploads trails | | diff --git a/docs/src/content/docs/use/customize-map.md b/docs/src/content/docs/use/customize-map.md index 84fc5474..d239a918 100644 --- a/docs/src/content/docs/use/customize-map.md +++ b/docs/src/content/docs/use/customize-map.md @@ -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 map’s 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 wanderer'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. diff --git a/web/package-lock.json b/web/package-lock.json index 0ac5403c..27579560 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index c49f8417..79f5d6ed 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/lib/components/trail/map_with_elevation_maplibre.svelte b/web/src/lib/components/trail/map_with_elevation_maplibre.svelte index 57072669..4da60518 100644 --- a/web/src/lib/components/trail/map_with_elevation_maplibre.svelte +++ b/web/src/lib/components/trail/map_with_elevation_maplibre.svelte @@ -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, + FeatureCollection, + FeatureCollection, + ] { + let clusterData: FeatureCollection = serverClusters ?? { type: "FeatureCollection", features: [], }; @@ -198,21 +212,36 @@ type: "FeatureCollection", features: [], }; - let r: FeatureCollection[] = []; + let gpxDataMap: Record = {}; - 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) { diff --git a/web/src/lib/config/map.ts b/web/src/lib/config/map.ts new file mode 100644 index 00000000..e8246779 --- /dev/null +++ b/web/src/lib/config/map.ts @@ -0,0 +1,3 @@ +import { env } from "$env/dynamic/public"; + +export const MAP_MAX_POLYLINES = Number(env.PUBLIC_MAP_MAX_POLYLINES || 100); diff --git a/web/src/lib/i18n/locales/cs.json b/web/src/lib/i18n/locales/cs.json index 7cf90ee5..1db9629b 100644 --- a/web/src/lib/i18n/locales/cs.json +++ b/web/src/lib/i18n/locales/cs.json @@ -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": "", diff --git a/web/src/lib/i18n/locales/de.json b/web/src/lib/i18n/locales/de.json index 3a312a83..be42eaee 100644 --- a/web/src/lib/i18n/locales/de.json +++ b/web/src/lib/i18n/locales/de.json @@ -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.", diff --git a/web/src/lib/i18n/locales/en.json b/web/src/lib/i18n/locales/en.json index 52d26b58..cf5bde1d 100644 --- a/web/src/lib/i18n/locales/en.json +++ b/web/src/lib/i18n/locales/en.json @@ -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.", diff --git a/web/src/lib/i18n/locales/es.json b/web/src/lib/i18n/locales/es.json index fb3675bd..36f1b825 100644 --- a/web/src/lib/i18n/locales/es.json +++ b/web/src/lib/i18n/locales/es.json @@ -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": "", diff --git a/web/src/lib/i18n/locales/eu.json b/web/src/lib/i18n/locales/eu.json index 065448aa..0611b5b8 100644 --- a/web/src/lib/i18n/locales/eu.json +++ b/web/src/lib/i18n/locales/eu.json @@ -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": "", diff --git a/web/src/lib/i18n/locales/fr.json b/web/src/lib/i18n/locales/fr.json index 1449efa3..d4e3b623 100644 --- a/web/src/lib/i18n/locales/fr.json +++ b/web/src/lib/i18n/locales/fr.json @@ -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": "", diff --git a/web/src/lib/i18n/locales/hu.json b/web/src/lib/i18n/locales/hu.json index 24f37bb0..0a40e85a 100644 --- a/web/src/lib/i18n/locales/hu.json +++ b/web/src/lib/i18n/locales/hu.json @@ -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": "", diff --git a/web/src/lib/i18n/locales/it.json b/web/src/lib/i18n/locales/it.json index 544140cb..57d4d55a 100644 --- a/web/src/lib/i18n/locales/it.json +++ b/web/src/lib/i18n/locales/it.json @@ -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": "", diff --git a/web/src/lib/i18n/locales/nl.json b/web/src/lib/i18n/locales/nl.json index 05bd37f0..7ce6cec0 100644 --- a/web/src/lib/i18n/locales/nl.json +++ b/web/src/lib/i18n/locales/nl.json @@ -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": "", diff --git a/web/src/lib/i18n/locales/no.json b/web/src/lib/i18n/locales/no.json index 73c498a3..ab9360e3 100644 --- a/web/src/lib/i18n/locales/no.json +++ b/web/src/lib/i18n/locales/no.json @@ -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": "", diff --git a/web/src/lib/i18n/locales/pl.json b/web/src/lib/i18n/locales/pl.json index 151143f5..8f8dfd9c 100644 --- a/web/src/lib/i18n/locales/pl.json +++ b/web/src/lib/i18n/locales/pl.json @@ -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": "", diff --git a/web/src/lib/i18n/locales/pt.json b/web/src/lib/i18n/locales/pt.json index 7cfb3723..36f7af45 100644 --- a/web/src/lib/i18n/locales/pt.json +++ b/web/src/lib/i18n/locales/pt.json @@ -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": "", diff --git a/web/src/lib/i18n/locales/ru.json b/web/src/lib/i18n/locales/ru.json index a8e37d1f..371fe72a 100644 --- a/web/src/lib/i18n/locales/ru.json +++ b/web/src/lib/i18n/locales/ru.json @@ -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": "", diff --git a/web/src/lib/i18n/locales/zh.json b/web/src/lib/i18n/locales/zh.json index 81e6f181..e6181333 100644 --- a/web/src/lib/i18n/locales/zh.json +++ b/web/src/lib/i18n/locales/zh.json @@ -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": "", diff --git a/web/src/lib/models/api/settings_schema.ts b/web/src/lib/models/api/settings_schema.ts index a426adb2..a89e22e7 100644 --- a/web/src/lib/models/api/settings_schema.ts +++ b/web/src/lib/models/api/settings_schema.ts @@ -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 -ZodType> export { SettingsCreateSchema }; diff --git a/web/src/lib/models/settings.ts b/web/src/lib/models/settings.ts index f895288f..529c2a46 100644 --- a/web/src/lib/models/settings.ts +++ b/web/src/lib/models/settings.ts @@ -57,6 +57,8 @@ class Settings { export type Behavior = { allowAutoGeolocate: boolean; + mapClusteringMaxZoom?: number; + showTrailStartMarker?: boolean; } diff --git a/web/src/lib/models/trail.ts b/web/src/lib/models/trail.ts index 4a405aeb..e1811d69 100644 --- a/web/src/lib/models/trail.ts +++ b/web/src/lib/models/trail.ts @@ -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",] diff --git a/web/src/lib/stores/search_store.ts b/web/src/lib/stores/search_store.ts index c583422b..1ad9ba35 100644 --- a/web/src/lib/stores/search_store.ts +++ b/web/src/lib/stores/search_store.ts @@ -99,7 +99,7 @@ export async function searchTrails(q: string, options: SearchParams): Promise = await r.json(); - return response.hits + return response.hits || [] } export async function searchLocations(q: string, limit?: number, f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch): Promise> { @@ -245,6 +245,10 @@ export async function searchMulti(options: MultiSearchParams): Promise = await r.json(); + if (!response.results) { + return []; + } + if (locationQuery && locationQuery.q !== undefined && locationQuery.q !== null) { const locationsResults = await searchLocations(locationQuery.q, locationQuery.limit) diff --git a/web/src/lib/stores/trail_store.ts b/web/src/lib/stores/trail_store.ts index 628236db..6acd627e 100644 --- a/web/src/lib/stores/trail_store.ts +++ b/web/src/lib/stores/trail_store.ts @@ -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 = writable(new Trail("")); - -export const editTrail: Writable = writable(new Trail("")); - export async function trails_index(perPage: number = 21, random: boolean = false, f: (url: RequestInfo | URL, config?: RequestInit) => Promise = 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(); +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 = writable(new Trail("")); + +export const editTrail: Writable = 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 } = 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 } = 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 = fetch) { @@ -503,10 +688,12 @@ export async function fetchGPX(trail: { gpx?: string } & Record, f: export async function searchResultToTrailList(hits: Hits): Promise { 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): 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): 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, diff --git a/web/src/lib/vendor/maplibre-layer-manager/cluster-layer.ts b/web/src/lib/vendor/maplibre-layer-manager/cluster-layer.ts index ef5885bb..1bf6a261 100644 --- a/web/src/lib/vendor/maplibre-layer-manager/cluster-layer.ts +++ b/web/src/lib/vendor/maplibre-layer-manager/cluster-layer.ts @@ -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 }); } -} \ No newline at end of file +} diff --git a/web/src/lib/vendor/maplibre-layer-manager/maplibre-layer-manager.ts b/web/src/lib/vendor/maplibre-layer-manager/maplibre-layer-manager.ts index 11f8b8a8..44391e89 100644 --- a/web/src/lib/vendor/maplibre-layer-manager/maplibre-layer-manager.ts +++ b/web/src/lib/vendor/maplibre-layer-manager/maplibre-layer-manager.ts @@ -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) } } -} \ No newline at end of file +} diff --git a/web/src/lib/vendor/maplibre-layer-manager/preview-layer.ts b/web/src/lib/vendor/maplibre-layer-manager/preview-layer.ts index 0ca39a34..40d0c76d 100644 --- a/web/src/lib/vendor/maplibre-layer-manager/preview-layer.ts +++ b/web/src/lib/vendor/maplibre-layer-manager/preview-layer.ts @@ -18,9 +18,10 @@ export class PreviewLayer implements BaseLayer { } }; - constructor(map: M.Map, geojson: GeoJSON.FeatureCollection, listeners?: Record 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 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 { }; } -} \ No newline at end of file +} diff --git a/web/src/lib/vendor/maplibre-layer-manager/trail-layer.ts b/web/src/lib/vendor/maplibre-layer-manager/trail-layer.ts index 28dc5961..b5494dae 100644 --- a/web/src/lib/vendor/maplibre-layer-manager/trail-layer.ts +++ b/web/src/lib/vendor/maplibre-layer-manager/trail-layer.ts @@ -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 void; onMouseDown?: (e: MapMouseEvent) => void; onEnter?: (e: MapMouseEvent) => void; onLeave?: (e: MapMouseEvent) => void; onMouseMove?: (e: MapMouseEvent) => void; }> markers: Record = {}; - 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 ?? {} } } } \ No newline at end of file diff --git a/web/src/routes/api/v1/search/trails/cluster/+server.ts b/web/src/routes/api/v1/search/trails/cluster/+server.ts new file mode 100644 index 00000000..e5887b78 --- /dev/null +++ b/web/src/routes/api/v1/search/trails/cluster/+server.ts @@ -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[] = 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[] = 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"); + } +} diff --git a/web/src/routes/map/+page.svelte b/web/src/routes/map/+page.svelte index de73ddc7..2c2e555e 100644 --- a/web/src/routes/map/+page.svelte +++ b/web/src/routes/map/+page.svelte @@ -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 | 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); } @@ -391,7 +433,7 @@ {#if trails.length == 0} {/if} - {#each trails as trail, i} + {#each trails.filter(t => t.name !== "") as trail, i} diff --git a/web/src/routes/map/+page.ts b/web/src/routes/map/+page.ts index aa53828a..35ffe098 100644 --- a/web/src/routes/map/+page.ts +++ b/web/src/routes/map/+page.ts @@ -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); diff --git a/web/src/routes/settings/map/+page.svelte b/web/src/routes/settings/map/+page.svelte index 79e52da2..3d9c9c14 100644 --- a/web/src/routes/settings/map/+page.svelte +++ b/web/src/routes/settings/map/+page.svelte @@ -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 @@ > {/if} - - {$_("allow-auto-geolocate")} - - + + + {$_("allow-auto-geolocate")} + + + + + + {$_("map-trail-preview-zoom-level")} + + + {Math.round(Number(mapClusteringMaxZoom))} + + + + + + + + {$_("show-trail-start-marker")} + + +
{$_("allow-auto-geolocate")}
{$_("map-trail-preview-zoom-level")}
{$_("show-trail-start-marker")}