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:
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
218
db/migrations/1778583800_persist_trail_bounds.go
Normal file
218
db/migrations/1778583800_persist_trail_bounds.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 | |
|
||||
|
||||
@@ -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 <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
2
web/package-lock.json
generated
@@ -26,6 +26,7 @@
|
||||
"@turf/destination": "^7.3.3",
|
||||
"@turf/distance": "^7.3.3",
|
||||
"@types/chart.js": "^4.0.1",
|
||||
"@types/supercluster": "^7.1.3",
|
||||
"@types/three": "^0.183.1",
|
||||
"@xmldom/xmldom": "^0.8.12",
|
||||
"activitypub-types": "^1.1.0",
|
||||
@@ -53,6 +54,7 @@
|
||||
"photoswipe": "^5.4.3",
|
||||
"pocketbase": "^0.26.8",
|
||||
"qrcode": "^1.4.4",
|
||||
"supercluster": "^8.0.1",
|
||||
"svelte-i18n": "^4.0.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"three": "^0.183.1",
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"@turf/destination": "^7.3.3",
|
||||
"@turf/distance": "^7.3.3",
|
||||
"@types/chart.js": "^4.0.1",
|
||||
"@types/supercluster": "^7.1.3",
|
||||
"@types/three": "^0.183.1",
|
||||
"@xmldom/xmldom": "^0.8.12",
|
||||
"activitypub-types": "^1.1.0",
|
||||
@@ -78,6 +79,7 @@
|
||||
"photoswipe": "^5.4.3",
|
||||
"pocketbase": "^0.26.8",
|
||||
"qrcode": "^1.4.4",
|
||||
"supercluster": "^8.0.1",
|
||||
"svelte-i18n": "^4.0.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"three": "^0.183.1",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
|
||||
interface Props {
|
||||
trails?: Trail[];
|
||||
serverClusters?: GeoJSON.FeatureCollection;
|
||||
gpx?: GPX;
|
||||
waypoints?: Waypoint[];
|
||||
markers?: M.Marker[];
|
||||
@@ -75,6 +76,7 @@
|
||||
|
||||
let {
|
||||
trails = [],
|
||||
serverClusters = undefined,
|
||||
waypoints = [],
|
||||
markers = $bindable([]),
|
||||
map = $bindable(),
|
||||
@@ -117,7 +119,7 @@
|
||||
|
||||
let hoveringTrail: boolean = false;
|
||||
|
||||
let mapLoaded: boolean = false;
|
||||
let mapLoaded: boolean = $state(false);
|
||||
let terrainEnabled: boolean | null = null;
|
||||
|
||||
const trailColors = [
|
||||
@@ -135,9 +137,16 @@
|
||||
|
||||
let clusterPopup: M.Popup | null = null;
|
||||
|
||||
let [data, clusterData, previewData] = $derived(getData(trails));
|
||||
let mapData = $derived(getData(trails, serverClusters));
|
||||
let gpxDataMap = $derived(mapData[0]);
|
||||
let clusterData = $derived(mapData[1]);
|
||||
let previewData = $derived(mapData[2]);
|
||||
|
||||
$effect(() => {
|
||||
if (data && map && mapLoaded) {
|
||||
// Track dependencies for Svelte 5
|
||||
mapData;
|
||||
|
||||
if (map && mapLoaded) {
|
||||
untrack(() => initMap(map?.loaded() ?? false));
|
||||
}
|
||||
});
|
||||
@@ -189,8 +198,13 @@
|
||||
|
||||
function getData(
|
||||
trails: Trail[],
|
||||
): [FeatureCollection[], FeatureCollection, FeatureCollection] {
|
||||
let clusterData: FeatureCollection = {
|
||||
serverClusters?: GeoJSON.FeatureCollection
|
||||
): [
|
||||
Record<string, FeatureCollection>,
|
||||
FeatureCollection,
|
||||
FeatureCollection,
|
||||
] {
|
||||
let clusterData: FeatureCollection = serverClusters ?? {
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
};
|
||||
@@ -198,21 +212,36 @@
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
};
|
||||
let r: FeatureCollection[] = [];
|
||||
let gpxDataMap: Record<string, FeatureCollection> = {};
|
||||
|
||||
trails.forEach((t, i) => {
|
||||
if (t.expand?.gpx) {
|
||||
r.push(t.expand.gpx.toGeoJSON());
|
||||
} else if (t.expand?.gpx_data) {
|
||||
r.push(GPX.parse(t.expand.gpx_data).toGeoJSON());
|
||||
trails.forEach((t) => {
|
||||
if (t.id) {
|
||||
let fc: FeatureCollection | null = null;
|
||||
if (t.expand?.gpx) {
|
||||
fc = t.expand.gpx.toGeoJSON();
|
||||
} else if (t.expand?.gpx_data) {
|
||||
fc = GPX.parse(t.expand.gpx_data).toGeoJSON();
|
||||
}
|
||||
|
||||
if (fc) {
|
||||
fc.features.forEach((f) => {
|
||||
if (f.properties) {
|
||||
f.properties.bounding_box_diagonal =
|
||||
t.bounding_box_diagonal;
|
||||
}
|
||||
});
|
||||
gpxDataMap[t.id] = fc;
|
||||
}
|
||||
}
|
||||
|
||||
if (clusterTrails) {
|
||||
if (t.lat !== null && t.lon !== null) {
|
||||
if (!serverClusters && t.lat !== undefined && t.lon !== undefined) {
|
||||
clusterData.features.push({
|
||||
id: t.id,
|
||||
type: "Feature",
|
||||
properties: {
|
||||
trail: t.id,
|
||||
bounding_box_diagonal: t.bounding_box_diagonal,
|
||||
},
|
||||
geometry: {
|
||||
type: "Point",
|
||||
@@ -227,6 +256,7 @@
|
||||
type: "Feature",
|
||||
properties: {
|
||||
trail: t.id,
|
||||
bounding_box_diagonal: t.bounding_box_diagonal,
|
||||
color: trailColors[
|
||||
hashStringToIndex(
|
||||
t.id ?? "",
|
||||
@@ -243,7 +273,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
return [r, clusterData, previewData];
|
||||
return [gpxDataMap, clusterData, previewData];
|
||||
}
|
||||
|
||||
function initMap(mapLoaded: boolean) {
|
||||
@@ -252,20 +282,20 @@
|
||||
}
|
||||
|
||||
refreshElevationProfile();
|
||||
if (showElevation && data.length && activeTrail !== null) {
|
||||
if (
|
||||
showElevation &&
|
||||
Object.keys(gpxDataMap).length &&
|
||||
activeTrail !== null
|
||||
) {
|
||||
epc?.showProfile();
|
||||
} else {
|
||||
epc?.hideProfile();
|
||||
}
|
||||
|
||||
trails.forEach((t, i) => {
|
||||
trails.forEach((t) => {
|
||||
const layerId = t.id!;
|
||||
addTrailLayer(t, layerId, i, data[i]);
|
||||
addTrailLayer(t, layerId, 0, gpxDataMap[layerId]);
|
||||
});
|
||||
if (clusterTrails) {
|
||||
addClusterLayer(clusterData);
|
||||
addPreviewLayer(previewData);
|
||||
}
|
||||
|
||||
Object.entries(layerManager.layers).forEach(([id, layer]) => {
|
||||
if (!(layer instanceof TrailLayer)) {
|
||||
@@ -278,18 +308,31 @@
|
||||
}
|
||||
});
|
||||
|
||||
if (
|
||||
!drawing &&
|
||||
fitBounds !== "off" &&
|
||||
data.some((d) => d.bbox !== undefined)
|
||||
) {
|
||||
if (activeTrail !== null && trails[activeTrail] && mapLoaded) {
|
||||
if (clusterTrails) {
|
||||
addPreviewLayer(previewData);
|
||||
addClusterLayer(clusterData);
|
||||
}
|
||||
|
||||
if (!drawing && fitBounds !== "off") {
|
||||
const currentBboxes = Object.values(gpxDataMap)
|
||||
.map((d) => d.bbox)
|
||||
.filter((b) => b !== undefined);
|
||||
|
||||
if (
|
||||
activeTrail !== null &&
|
||||
trails[activeTrail] &&
|
||||
mapLoaded &&
|
||||
gpxDataMap[trails[activeTrail].id!]
|
||||
) {
|
||||
focusTrail(trails[activeTrail]);
|
||||
} else {
|
||||
} else if (currentBboxes.length > 0) {
|
||||
flyToBounds();
|
||||
}
|
||||
} else if (drawing && activeTrail !== null && mapLoaded) {
|
||||
addCaretLayer(data[activeTrail]);
|
||||
const activeId = trails[activeTrail]?.id;
|
||||
if (activeId && gpxDataMap[activeId]) {
|
||||
addCaretLayer(gpxDataMap[activeId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,8 +356,9 @@
|
||||
}
|
||||
|
||||
export function refreshElevationProfile() {
|
||||
if (activeTrail !== null && data[activeTrail]) {
|
||||
epc?.setData(data[activeTrail]!, waypoints);
|
||||
const activeId = activeTrail !== null ? trails[activeTrail]?.id : null;
|
||||
if (activeId && gpxDataMap[activeId]) {
|
||||
epc?.setData(gpxDataMap[activeId]!, waypoints);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,7 +368,7 @@
|
||||
maxX = -Infinity,
|
||||
maxY = -Infinity;
|
||||
|
||||
for (const [xMin, yMin, xMax, yMax] of data
|
||||
for (const [xMin, yMin, xMax, yMax] of Object.values(gpxDataMap)
|
||||
.filter((d) => d.bbox !== undefined)
|
||||
.map((d) => d.bbox!)) {
|
||||
minX = Math.min(minX, xMin);
|
||||
@@ -346,9 +390,10 @@
|
||||
}
|
||||
|
||||
function flyToBounds() {
|
||||
const activeId = activeTrail !== null ? trails[activeTrail]?.id : null;
|
||||
const bounds =
|
||||
activeTrail !== null && data[activeTrail]
|
||||
? (data[activeTrail].bbox as M.LngLatBoundsLike)
|
||||
activeId && gpxDataMap[activeId]
|
||||
? (gpxDataMap[activeId].bbox as M.LngLatBoundsLike)
|
||||
: getBounds();
|
||||
|
||||
if (!bounds || !map) {
|
||||
@@ -389,18 +434,20 @@
|
||||
trailColors[
|
||||
clusterTrails
|
||||
? hashStringToIndex(id ?? "", trailColors.length)
|
||||
: 0
|
||||
: index % trailColors.length
|
||||
],
|
||||
{
|
||||
onEnter: (e) =>
|
||||
highlightTrail(id, trails[activeTrail ?? -1]?.id == id),
|
||||
listeners: {
|
||||
onEnter: (e) =>
|
||||
highlightTrail(id, trails[activeTrail ?? -1]?.id == id),
|
||||
|
||||
onLeave: (e) => unHighlightTrail(id),
|
||||
onMouseUp: (e) => {
|
||||
activeTrail = trails.findIndex((t) => t.id == trail.id);
|
||||
onLeave: (e) => unHighlightTrail(id),
|
||||
onMouseUp: (e) => {
|
||||
activeTrail = trails.findIndex((t) => t.id == trail.id);
|
||||
},
|
||||
onMouseMove: moveCrosshairToCursorPosition,
|
||||
onMouseDown: (e) => handleDragStart(e, id),
|
||||
},
|
||||
onMouseMove: moveCrosshairToCursorPosition,
|
||||
onMouseDown: (e) => handleDragStart(e, id),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -415,7 +462,20 @@
|
||||
if (!geojson || !map || !map.style) {
|
||||
return;
|
||||
}
|
||||
layerManager.addLayer("clusters", new ClusterLayer(map, geojson));
|
||||
layerManager.addLayer(
|
||||
"clusters",
|
||||
new ClusterLayer(map, geojson, {
|
||||
"unclustered-point": {
|
||||
onEnter: (e) => {
|
||||
if (map) map.getCanvas().style.cursor = "pointer";
|
||||
const id = (e as any).features[0].properties.id;
|
||||
const trail = trails.find((t) => t.id === id);
|
||||
if (!hasTrailDetails(trail)) return;
|
||||
highlightCluster(trail, e.lngLat);
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function addPreviewLayer(geojson: FeatureCollection) {
|
||||
@@ -425,18 +485,19 @@
|
||||
layerManager.addLayer(
|
||||
"preview",
|
||||
new PreviewLayer(map, geojson, {
|
||||
preview: {
|
||||
onEnter: (e) => {
|
||||
const trail = trails.find(
|
||||
(t) =>
|
||||
t.id ===
|
||||
(e as any).features[0].properties.trail,
|
||||
);
|
||||
if (!trail) return;
|
||||
highlightCluster(trail, e.lngLat);
|
||||
},
|
||||
onLeave: (e) => {
|
||||
// unHighlightCluster();
|
||||
showStartMarker: page.data.settings?.behavior?.showTrailStartMarker ?? false,
|
||||
listeners: {
|
||||
preview: {
|
||||
onEnter: (e) => {
|
||||
if (map) map.getCanvas().style.cursor = "pointer";
|
||||
const trail = trails.find(
|
||||
(t) =>
|
||||
t.id ===
|
||||
(e as any).features[0].properties.trail,
|
||||
);
|
||||
if (!hasTrailDetails(trail)) return;
|
||||
highlightCluster(trail, e.lngLat);
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -537,11 +598,15 @@
|
||||
// map?.setPaintProperty(id, "line-color", "#648ad5");
|
||||
}
|
||||
|
||||
function hasTrailDetails(trail: Trail | undefined): trail is Trail {
|
||||
return Boolean(trail?.name?.trim());
|
||||
}
|
||||
|
||||
export async function highlightCluster(
|
||||
trail: Trail,
|
||||
lnglat?: M.LngLatLike,
|
||||
) {
|
||||
if (!map || !map.style) {
|
||||
if (!map || !map.style || !hasTrailDetails(trail)) {
|
||||
return;
|
||||
}
|
||||
clusterPopup?.remove();
|
||||
@@ -581,7 +646,7 @@
|
||||
if (
|
||||
!drawing &&
|
||||
fitBounds !== "off" &&
|
||||
data.some((d) => d.bbox !== undefined)
|
||||
Object.values(gpxDataMap).some((d) => d.bbox !== undefined)
|
||||
) {
|
||||
untrack(() => focusTrail(trails[activeTrail]));
|
||||
}
|
||||
@@ -604,7 +669,9 @@
|
||||
epc?.showProfile();
|
||||
}
|
||||
showWaypoints();
|
||||
addCaretLayer(data[activeTrail]);
|
||||
if (trail.id && gpxDataMap[trail.id]) {
|
||||
addCaretLayer(gpxDataMap[trail.id]);
|
||||
}
|
||||
flyToBounds();
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
@@ -651,10 +718,11 @@
|
||||
map.getCanvas().style.cursor = "inherit";
|
||||
|
||||
if (activeTrail !== null && trails[activeTrail] && !clusterTrails) {
|
||||
const activeId = trails[activeTrail].id;
|
||||
addStartEndMarkers(
|
||||
trails[activeTrail],
|
||||
trails[activeTrail].id,
|
||||
data[activeTrail],
|
||||
activeId,
|
||||
activeId ? gpxDataMap[activeId] : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1006,8 +1074,9 @@
|
||||
|
||||
if (e.key == "m") {
|
||||
if (trails.length === 1) {
|
||||
addTrailLayer(trails[0], trails[0].id!, 0, data[0]);
|
||||
addCaretLayer(data[0]);
|
||||
const trailId = trails[0].id!;
|
||||
addTrailLayer(trails[0], trailId, 0, gpxDataMap[trailId]);
|
||||
addCaretLayer(gpxDataMap[trailId]);
|
||||
}
|
||||
} else if (e.key == "p") {
|
||||
if (showElevation) {
|
||||
|
||||
3
web/src/lib/config/map.ts
Normal file
3
web/src/lib/config/map.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { env } from "$env/dynamic/public";
|
||||
|
||||
export const MAP_MAX_POLYLINES = Number(env.PUBLIC_MAP_MAX_POLYLINES || 100);
|
||||
@@ -264,6 +264,8 @@
|
||||
"make-one": "Vytvořte si vlastní!",
|
||||
"make-thumbnail": "Vytvořit náhled",
|
||||
"map": "Mapa",
|
||||
"map-trail-preview-zoom-level": "Zobrazit náhledy tras od úrovně přiblížení",
|
||||
"show-trail-start-marker": "Zobrazit značku na začátku trasy",
|
||||
"map-style": "Styl mapy",
|
||||
"mark-trail-as-completed": "",
|
||||
"mark-trail-as-completed-modal-text": "",
|
||||
|
||||
@@ -286,6 +286,8 @@
|
||||
"make-one": "Neues erstellen!",
|
||||
"make-thumbnail": "Thumbnail festlegen",
|
||||
"map": "Karte",
|
||||
"map-trail-preview-zoom-level": "Routenlinien anzeigen ab Zoomstufe",
|
||||
"show-trail-start-marker": "Marker am Start der Route anzeigen",
|
||||
"map-style": "Kartenstil",
|
||||
"mark-trail-as-completed": "Route als abgeschlossen markieren",
|
||||
"mark-trail-as-completed-modal-text": "Möchtest du diese Route als abgeschlossen markieren? Du kannst diesen Status jederzeit wieder ändern.",
|
||||
|
||||
@@ -286,6 +286,8 @@
|
||||
"make-one": "Make one!",
|
||||
"make-thumbnail": "Make thumbnail",
|
||||
"map": "Map",
|
||||
"map-trail-preview-zoom-level": "Show trail previews from zoom level",
|
||||
"show-trail-start-marker": "Show marker at start of trail",
|
||||
"map-style": "Map style",
|
||||
"mark-trail-as-completed": "Mark trail as completed",
|
||||
"mark-trail-as-completed-modal-text": "Would you like to mark this trail as completed? You can change this status again at any time.",
|
||||
|
||||
@@ -264,6 +264,8 @@
|
||||
"make-one": "¡Crea uno!",
|
||||
"make-thumbnail": "Generar miniaturas",
|
||||
"map": "Mapa",
|
||||
"map-trail-preview-zoom-level": "Mostrar vistas previas de rutas a partir del nivel de zoom",
|
||||
"show-trail-start-marker": "Mostrar marcador al inicio de la ruta",
|
||||
"map-style": "Estilo de mapa",
|
||||
"mark-trail-as-completed": "",
|
||||
"mark-trail-as-completed-modal-text": "",
|
||||
|
||||
@@ -264,6 +264,8 @@
|
||||
"make-one": "Egin bat!",
|
||||
"make-thumbnail": "Egin iruditxoa",
|
||||
"map": "Mapa",
|
||||
"map-trail-preview-zoom-level": "Erakutsi ibilbideen aurrebistak zoom-mailatik aurrera",
|
||||
"show-trail-start-marker": "Erakutsi markatzailea ibilbidearen hasieran",
|
||||
"map-style": "Maparen estiloa",
|
||||
"mark-trail-as-completed": "",
|
||||
"mark-trail-as-completed-modal-text": "",
|
||||
|
||||
@@ -264,6 +264,8 @@
|
||||
"make-one": "Faites-en un !",
|
||||
"make-thumbnail": "Créer une miniature",
|
||||
"map": "Carte",
|
||||
"map-trail-preview-zoom-level": "Afficher les aperçus d'itinéraires à partir du niveau de zoom",
|
||||
"show-trail-start-marker": "Afficher un marqueur au début de l'itinéraire",
|
||||
"map-style": "Style de carte",
|
||||
"mark-trail-as-completed": "",
|
||||
"mark-trail-as-completed-modal-text": "",
|
||||
|
||||
@@ -264,6 +264,8 @@
|
||||
"make-one": "Készítsen egyet!",
|
||||
"make-thumbnail": "Készítsen miniatűrképet",
|
||||
"map": "Térkép",
|
||||
"map-trail-preview-zoom-level": "Útvonal-előnézetek megjelenítése ettől a nagyítási szinttől",
|
||||
"show-trail-start-marker": "Jelölő megjelenítése az útvonal elején",
|
||||
"map-style": "Map style",
|
||||
"mark-trail-as-completed": "",
|
||||
"mark-trail-as-completed-modal-text": "",
|
||||
|
||||
@@ -264,6 +264,8 @@
|
||||
"make-one": "Creane uno!",
|
||||
"make-thumbnail": "Imposta miniatura",
|
||||
"map": "Mappa",
|
||||
"map-trail-preview-zoom-level": "Mostra le anteprime dei percorsi dal livello di zoom",
|
||||
"show-trail-start-marker": "Mostra un indicatore all'inizio del percorso",
|
||||
"map-style": "Map style",
|
||||
"mark-trail-as-completed": "",
|
||||
"mark-trail-as-completed-modal-text": "",
|
||||
|
||||
@@ -264,6 +264,8 @@
|
||||
"make-one": "Maak er een aan!",
|
||||
"make-thumbnail": "Miniatuur maken",
|
||||
"map": "Kaart",
|
||||
"map-trail-preview-zoom-level": "Routevoorbeelden tonen vanaf zoomniveau",
|
||||
"show-trail-start-marker": "Markering aan het begin van de route tonen",
|
||||
"map-style": "Kaartstijl",
|
||||
"mark-trail-as-completed": "",
|
||||
"mark-trail-as-completed-modal-text": "",
|
||||
|
||||
@@ -264,6 +264,8 @@
|
||||
"make-one": "Lag en!",
|
||||
"make-thumbnail": "Lag miniatyrbilde",
|
||||
"map": "Kart",
|
||||
"map-trail-preview-zoom-level": "Vis ruteforhåndsvisninger fra zoomnivå",
|
||||
"show-trail-start-marker": "Vis markør ved starten av stien",
|
||||
"map-style": "Kartstil",
|
||||
"mark-trail-as-completed": "",
|
||||
"mark-trail-as-completed-modal-text": "",
|
||||
|
||||
@@ -264,6 +264,8 @@
|
||||
"make-one": "Stwórz ją!",
|
||||
"make-thumbnail": "Zrób miniaturkę",
|
||||
"map": "Mapa",
|
||||
"map-trail-preview-zoom-level": "Pokazuj podglądy tras od poziomu powiększenia",
|
||||
"show-trail-start-marker": "Pokaż znacznik na początku trasy",
|
||||
"map-style": "Map style",
|
||||
"mark-trail-as-completed": "",
|
||||
"mark-trail-as-completed-modal-text": "",
|
||||
|
||||
@@ -264,6 +264,8 @@
|
||||
"make-one": "Faz um!",
|
||||
"make-thumbnail": "Faça miniatura",
|
||||
"map": "Mapa",
|
||||
"map-trail-preview-zoom-level": "Mostrar pré-visualizações de rotas a partir do nível de zoom",
|
||||
"show-trail-start-marker": "Mostrar marcador no início da rota",
|
||||
"map-style": "Map style",
|
||||
"mark-trail-as-completed": "",
|
||||
"mark-trail-as-completed-modal-text": "",
|
||||
|
||||
@@ -264,6 +264,8 @@
|
||||
"make-one": "Создайте!",
|
||||
"make-thumbnail": "Сделать миниатюру",
|
||||
"map": "Карта",
|
||||
"map-trail-preview-zoom-level": "Показывать предпросмотр маршрутов с уровня масштабирования",
|
||||
"show-trail-start-marker": "Показывать маркер в начале маршрута",
|
||||
"map-style": "Стиль карты",
|
||||
"mark-trail-as-completed": "",
|
||||
"mark-trail-as-completed-modal-text": "",
|
||||
|
||||
@@ -264,6 +264,8 @@
|
||||
"make-one": "立刻注册!",
|
||||
"make-thumbnail": "生成缩略图",
|
||||
"map": "地图",
|
||||
"map-trail-preview-zoom-level": "从该缩放级别开始显示路线预览",
|
||||
"show-trail-start-marker": "在路线起点显示标记",
|
||||
"map-style": "地图样式",
|
||||
"mark-trail-as-completed": "",
|
||||
"mark-trail-as-completed-modal-text": "",
|
||||
|
||||
@@ -22,8 +22,11 @@ const SettingsCreateSchema = z.object({
|
||||
lists: z.enum(["public", "private"])
|
||||
}).optional().nullable(),
|
||||
notifications: z.record(z.enum(Object.values(NotificationType) as [string, ...string[]]), z.object({ web: z.boolean(), email: z.boolean() })).optional().nullable(),
|
||||
behavior: z.object({ allowAutoGeolocate: z.boolean() }).optional().nullable(),
|
||||
behavior: z.object({
|
||||
allowAutoGeolocate: z.boolean(),
|
||||
mapClusteringMaxZoom: z.number().optional(),
|
||||
showTrailStartMarker: z.boolean().optional()
|
||||
}).optional().nullable(),
|
||||
}) satisfies ZodType<Settings>
|
||||
ZodType<Partial<Comment>>
|
||||
|
||||
export { SettingsCreateSchema };
|
||||
|
||||
@@ -57,6 +57,8 @@ class Settings {
|
||||
|
||||
export type Behavior = {
|
||||
allowAutoGeolocate: boolean;
|
||||
mapClusteringMaxZoom?: number;
|
||||
showTrailStartMarker?: boolean;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ class Trail {
|
||||
domain?: string;
|
||||
iri?: string;
|
||||
like_count: number;
|
||||
bounding_box_diagonal?: number;
|
||||
expand?: {
|
||||
tags?: Tag[]
|
||||
category?: Category;
|
||||
@@ -74,8 +75,9 @@ class Trail {
|
||||
comments?: Comment[],
|
||||
shares?: TrailShare[],
|
||||
tags?: Tag[],
|
||||
description?: string
|
||||
created?: string
|
||||
description?: string,
|
||||
created?: string,
|
||||
bounding_box_diagonal?: number
|
||||
}
|
||||
|
||||
) {
|
||||
@@ -96,6 +98,7 @@ class Trail {
|
||||
this.photos = params?.photos ?? [];
|
||||
this.tags = [];
|
||||
this.gpx = params?.gpx;
|
||||
this.bounding_box_diagonal = params?.bounding_box_diagonal ?? 0;
|
||||
this.like_count = 0
|
||||
this.expand = {
|
||||
category: params?.category,
|
||||
@@ -214,6 +217,7 @@ interface TrailSearchResult {
|
||||
domain?: string;
|
||||
iri?: string;
|
||||
gpx: string;
|
||||
bounding_box_diagonal: number;
|
||||
_geo: {
|
||||
lat: number,
|
||||
lng: number
|
||||
@@ -245,6 +249,7 @@ export const defaultTrailSearchAttributes = [
|
||||
"like_count",
|
||||
"shares",
|
||||
"iri",
|
||||
"bounding_box_diagonal",
|
||||
"_geo",]
|
||||
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ export async function searchTrails(q: string, options: SearchParams): Promise<Hi
|
||||
|
||||
const response: SearchResponse<TrailSearchResult> = await r.json();
|
||||
|
||||
return response.hits
|
||||
return response.hits || []
|
||||
}
|
||||
|
||||
export async function searchLocations(q: string, limit?: number, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch): Promise<Hits<LocationSearchResult>> {
|
||||
@@ -245,6 +245,10 @@ export async function searchMulti(options: MultiSearchParams): Promise<MultiSear
|
||||
|
||||
const response: MultiSearchResponse<any> = await r.json();
|
||||
|
||||
if (!response.results) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
if (locationQuery && locationQuery.q !== undefined && locationQuery.q !== null) {
|
||||
const locationsResults = await searchLocations(locationQuery.q, locationQuery.limit)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { SummitLog } from "$lib/models/summit_log";
|
||||
import type { Tag } from "$lib/models/tag";
|
||||
import { MAP_MAX_POLYLINES } from "$lib/config/map";
|
||||
import { defaultTrailSearchAttributes, Trail, type TrailFilter, type TrailFilterValues, type TrailSearchResult } from "$lib/models/trail";
|
||||
import type { Waypoint } from "$lib/models/waypoint";
|
||||
import { APIError } from "$lib/util/api_util";
|
||||
@@ -14,11 +15,6 @@ import { tags_create } from "./tag_store";
|
||||
import { currentUser } from "./user_store";
|
||||
import { waypoints_create, waypoints_delete, waypoints_update } from "./waypoint_store";
|
||||
|
||||
let trails: Trail[] = []
|
||||
export const trail: Writable<Trail> = writable(new Trail(""));
|
||||
|
||||
export const editTrail: Writable<Trail> = writable(new Trail(""));
|
||||
|
||||
export async function trails_index(perPage: number = 21, random: boolean = false, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||
const r = await f('/api/v1/trail?' + new URLSearchParams({
|
||||
"perPage": perPage.toString(),
|
||||
@@ -93,7 +89,50 @@ export async function trails_search_filter(filter: TrailFilter, page: number = 1
|
||||
|
||||
}
|
||||
|
||||
export async function trails_search_bounding_box(northEast: M.LngLat, southWest: M.LngLat, filter: TrailFilter, page: number = 1, includePolyline: boolean = true) {
|
||||
const DETAILED_CACHE_MAX_SIZE = Math.max(200, MAP_MAX_POLYLINES * 10);
|
||||
|
||||
let trails: Trail[] = []
|
||||
const detailedCache = new Map<string, Trail>();
|
||||
let detailedCacheKey = "";
|
||||
|
||||
function getDetailedCache(id: string): Trail | undefined {
|
||||
const cached = detailedCache.get(id);
|
||||
if (!cached) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
detailedCache.delete(id);
|
||||
// Reinsert the entry so Map iteration order tracks recent usage for LRU eviction.
|
||||
detailedCache.set(id, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
function setDetailedCache(id: string, trail: Trail) {
|
||||
detailedCache.delete(id);
|
||||
detailedCache.set(id, trail);
|
||||
|
||||
while (detailedCache.size > DETAILED_CACHE_MAX_SIZE) {
|
||||
const oldestKey = detailedCache.keys().next().value;
|
||||
if (!oldestKey) {
|
||||
break;
|
||||
}
|
||||
detailedCache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
export const trail: Writable<Trail> = writable(new Trail(""));
|
||||
|
||||
export const editTrail: Writable<Trail> = writable(new Trail(""));
|
||||
|
||||
export async function trails_search_bounding_box(
|
||||
northEast: M.LngLat,
|
||||
southWest: M.LngLat,
|
||||
filter: TrailFilter,
|
||||
page: number = 1,
|
||||
zoom: number = 11,
|
||||
perPage: number = 50,
|
||||
loadMapData: boolean = true
|
||||
) {
|
||||
const user = get(currentUser)
|
||||
|
||||
let filterText: string = "";
|
||||
@@ -102,36 +141,182 @@ export async function trails_search_bounding_box(northEast: M.LngLat, southWest:
|
||||
filterText = buildFilterText(user, filter, false);
|
||||
}
|
||||
|
||||
let r = await fetch("/api/v1/search/trails", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
q: "",
|
||||
options: {
|
||||
filter: [
|
||||
`_geoBoundingBox([${northEast.lat}, ${northEast.lng}], [${southWest.lat}, ${southWest.lng}])`,
|
||||
filterText
|
||||
],
|
||||
sort: [`${filter.sort}:${filter.sortOrder == "+" ? "asc" : "desc"}`,],
|
||||
attributesToRetrieve: [...defaultTrailSearchAttributes, ...(includePolyline ? ["polyline"] : [])],
|
||||
hitsPerPage: 500,
|
||||
page: page
|
||||
}
|
||||
}),
|
||||
});
|
||||
const result: { page: number, totalPages: number, hits: Hits<TrailSearchResult> } = await r.json();
|
||||
|
||||
if (result.hits.length == 0) {
|
||||
trails = [];
|
||||
return { trails: [], ...result }
|
||||
let lonFilter = `max_lon >= ${southWest.lng} AND min_lon <= ${northEast.lng}`;
|
||||
if (southWest.lng > northEast.lng) {
|
||||
lonFilter = `(max_lon >= ${southWest.lng} OR min_lon <= ${northEast.lng})`;
|
||||
}
|
||||
|
||||
const resultTrails: Trail[] = await searchResultToTrailList(result.hits)
|
||||
const geoFilter = `max_lat >= ${southWest.lat} AND min_lat <= ${northEast.lat} AND ${lonFilter}`;
|
||||
const listFilter = [filterText, geoFilter].filter(Boolean).join(" AND ");
|
||||
const cacheKey = JSON.stringify({
|
||||
q: filter.q,
|
||||
filterText,
|
||||
sort: filter.sort,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
if (cacheKey !== detailedCacheKey) {
|
||||
detailedCache.clear();
|
||||
detailedCacheKey = cacheKey;
|
||||
}
|
||||
|
||||
trails = page > 1 ? trails.concat(resultTrails) : resultTrails
|
||||
// Step 1: Fetch paginated trails for the side list.
|
||||
const listResponse = await fetch("/api/v1/search/trails", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
q: filter.q,
|
||||
options: {
|
||||
filter: listFilter,
|
||||
attributesToRetrieve: defaultTrailSearchAttributes,
|
||||
sort: [`${filter.sort}:${filter.sortOrder == "+" ? "asc" : "desc"}`],
|
||||
hitsPerPage: perPage,
|
||||
page,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return { trails, ...result };
|
||||
if (!listResponse.ok) {
|
||||
const response = await listResponse.json();
|
||||
throw new APIError(listResponse.status, response.message, response.detail)
|
||||
}
|
||||
|
||||
const listResult: { page: number, totalPages: number, totalHits?: number, estimatedTotalHits?: number, hits: Hits<TrailSearchResult> } = await listResponse.json();
|
||||
const listTrails = listResult.hits.length > 0
|
||||
? await searchResultToTrailList(listResult.hits)
|
||||
: [];
|
||||
|
||||
trails = page > 1 ? trails.concat(listTrails) : listTrails;
|
||||
|
||||
if (!loadMapData) {
|
||||
return {
|
||||
trails,
|
||||
mapTrails: [],
|
||||
clusters: undefined,
|
||||
estimatedTotalHits: listResult.estimatedTotalHits,
|
||||
totalHits: listResult.totalHits ?? listResult.estimatedTotalHits,
|
||||
totalPages: listResult.totalPages
|
||||
};
|
||||
}
|
||||
|
||||
// Step 2: Fetch server-side clusters and unclustered points for the map.
|
||||
let cr = await fetch("/api/v1/search/trails/cluster", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
southWest: { lat: southWest.lat, lng: southWest.lng },
|
||||
northEast: { lat: northEast.lat, lng: northEast.lng },
|
||||
zoom,
|
||||
q: filter.q,
|
||||
filterText
|
||||
})
|
||||
});
|
||||
|
||||
if (!cr.ok) {
|
||||
const response = await cr.json();
|
||||
throw new APIError(cr.status, response.message, response.detail)
|
||||
}
|
||||
|
||||
const clusterResult = await cr.json();
|
||||
const clusterFeatureCollection = clusterResult;
|
||||
|
||||
const unclusteredFeatures = clusterFeatureCollection.features
|
||||
.filter((f: any) => !f.properties.cluster);
|
||||
|
||||
// Extract IDs of visible unclustered points that are large enough to show details for
|
||||
const unclusteredIds = unclusteredFeatures
|
||||
.filter((f: any) => f.properties.is_large)
|
||||
.map((f: any) => f.properties.id);
|
||||
|
||||
// Step 3: Identify which visible trails are MISSING from the local cache
|
||||
const missingIds = unclusteredIds.filter((id: string) => !detailedCache.has(id));
|
||||
|
||||
// Step 4: Only fetch details for missing trails
|
||||
if (missingIds.length > 0) {
|
||||
const batchSize = 100; // Meilisearch filter length safety
|
||||
for (let i = 0; i < missingIds.length; i += batchSize) {
|
||||
const batch = missingIds.slice(i, i + batchSize);
|
||||
const detailBatchQuery = {
|
||||
indexUid: "trails",
|
||||
q: "",
|
||||
filter: [`id IN [${batch.map((id: string) => `'${id}'`).join(",")}]`],
|
||||
attributesToRetrieve: [...defaultTrailSearchAttributes, "polyline"],
|
||||
hitsPerPage: batchSize,
|
||||
};
|
||||
|
||||
const dr = await fetch("/api/v1/search/multi", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ queries: [detailBatchQuery] }),
|
||||
});
|
||||
|
||||
if (dr.ok) {
|
||||
const detailResult = await dr.json();
|
||||
const newTrails = await searchResultToTrailList(detailResult.results[0].hits);
|
||||
// Populate cache
|
||||
newTrails.forEach(t => {
|
||||
if (t.id) setDetailedCache(t.id, t)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Convert unclustered hits to lightweight Trail objects for map popups/previews.
|
||||
const mapTrails: Trail[] = unclusteredFeatures
|
||||
.map((f: any) => {
|
||||
const s = f.properties;
|
||||
const lat = f.geometry.coordinates[1];
|
||||
const lng = f.geometry.coordinates[0];
|
||||
|
||||
const cached = getDetailedCache(s.id);
|
||||
if (cached) {
|
||||
return {
|
||||
...cached,
|
||||
lat,
|
||||
lon: lng,
|
||||
bounding_box_diagonal: s.bounding_box_diagonal ?? cached.bounding_box_diagonal,
|
||||
// Strip polyline for small trails so they don't linger as lines when zoomed out
|
||||
polyline: s.is_large ? cached.polyline : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Lightweight fallback for map markers
|
||||
const t: Trail & RecordModel = {
|
||||
id: s.id,
|
||||
lat: lat,
|
||||
lon: lng,
|
||||
name: "",
|
||||
author: "",
|
||||
photos: [],
|
||||
public: true,
|
||||
completed: false,
|
||||
summit_logs: [],
|
||||
waypoints: [],
|
||||
tags: [],
|
||||
category: "",
|
||||
created: new Date(0).toISOString(),
|
||||
date: new Date(0).toISOString(),
|
||||
updated: new Date(0).toISOString(),
|
||||
description: "",
|
||||
difficulty: "easy",
|
||||
distance: 0,
|
||||
duration: 0,
|
||||
elevation_gain: 0,
|
||||
elevation_loss: 0,
|
||||
location: "",
|
||||
bounding_box_diagonal: s.bounding_box_diagonal ?? 0,
|
||||
like_count: 0,
|
||||
collectionId: "trails",
|
||||
collectionName: "trails",
|
||||
expand: { author: {} as any }
|
||||
};
|
||||
return t;
|
||||
});
|
||||
|
||||
return {
|
||||
trails,
|
||||
mapTrails,
|
||||
clusters: clusterFeatureCollection,
|
||||
estimatedTotalHits: listResult.estimatedTotalHits ?? clusterResult.totalHits,
|
||||
totalHits: listResult.totalHits ?? listResult.estimatedTotalHits ?? clusterResult.totalHits,
|
||||
totalPages: listResult.totalPages
|
||||
};
|
||||
}
|
||||
|
||||
export async function trails_show(id: string, handle?: string, share?: string, loadGPX?: boolean, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||
@@ -503,10 +688,12 @@ export async function fetchGPX(trail: { gpx?: string } & Record<string, any>, f:
|
||||
export async function searchResultToTrailList(hits: Hits<TrailSearchResult>): Promise<Trail[]> {
|
||||
const trails: Trail[] = []
|
||||
for (const h of hits) {
|
||||
const created = Number(h.created || 0);
|
||||
const date = Number(h.date || 0);
|
||||
const t: Trail & RecordModel = {
|
||||
collectionId: "trails",
|
||||
collectionName: "trails",
|
||||
updated: new Date(h.created * 1000).toISOString(),
|
||||
updated: new Date(created * 1000).toISOString(),
|
||||
author: h.author_name,
|
||||
name: h.name,
|
||||
photos: h.thumbnail ? [h.thumbnail] : [],
|
||||
@@ -516,8 +703,8 @@ export async function searchResultToTrailList(hits: Hits<TrailSearchResult>): Pr
|
||||
waypoints: [],
|
||||
tags: h.tags ?? [],
|
||||
category: h.category,
|
||||
created: new Date(h.created * 1000).toISOString(),
|
||||
date: new Date(h.date * 1000).toISOString(),
|
||||
created: new Date(created * 1000).toISOString(),
|
||||
date: new Date(date * 1000).toISOString(),
|
||||
description: h.description,
|
||||
difficulty: h.difficulty == 0 ? "easy" : h.difficulty == 1 ? "moderate" : "difficult",
|
||||
distance: h.distance,
|
||||
@@ -530,6 +717,7 @@ export async function searchResultToTrailList(hits: Hits<TrailSearchResult>): Pr
|
||||
location: h.location,
|
||||
gpx: h.gpx,
|
||||
polyline: h.polyline,
|
||||
bounding_box_diagonal: h.bounding_box_diagonal ?? 0,
|
||||
domain: h.domain,
|
||||
iri: h.iri,
|
||||
thumbnail: 0,
|
||||
|
||||
@@ -28,6 +28,7 @@ export class ClusterLayer implements BaseLayer {
|
||||
"clusters": { ...this.listeners["clusters"], ...listeners?.["clusters"] },
|
||||
"unclustered-point": { ...this.listeners["unclustered-point"], ...listeners?.["unclustered-point"] }
|
||||
}
|
||||
|
||||
this.spec = {
|
||||
version: 8,
|
||||
name: "clusters",
|
||||
@@ -36,8 +37,6 @@ export class ClusterLayer implements BaseLayer {
|
||||
"cluster-trails": {
|
||||
type: "geojson",
|
||||
data: geojson,
|
||||
cluster: true,
|
||||
clusterRadius: 50,
|
||||
}
|
||||
},
|
||||
layers: [
|
||||
@@ -45,26 +44,37 @@ export class ClusterLayer implements BaseLayer {
|
||||
id: "clusters",
|
||||
type: "circle",
|
||||
source: "cluster-trails",
|
||||
filter: ["has", "point_count"],
|
||||
maxzoom: 10,
|
||||
filter: ["all", ["!=", ["get", "is_large"], true], [">", ["get", "point_count"], 1]],
|
||||
paint: {
|
||||
"circle-color": "#242734",
|
||||
"circle-radius": [
|
||||
"step",
|
||||
["get", "point_count"],
|
||||
10,
|
||||
5,
|
||||
12,
|
||||
10,
|
||||
15,
|
||||
20,
|
||||
20,
|
||||
50,
|
||||
25,
|
||||
18,
|
||||
100,
|
||||
30,
|
||||
200,
|
||||
35,
|
||||
22,
|
||||
500,
|
||||
25,
|
||||
],
|
||||
"circle-stroke-width": 3,
|
||||
"circle-stroke-width": 2,
|
||||
"circle-stroke-color": "#fff",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "unclustered-point",
|
||||
type: "circle",
|
||||
source: "cluster-trails",
|
||||
filter: ["all", ["!=", ["get", "is_large"], true], ["==", ["get", "point_count"], 1]],
|
||||
paint: {
|
||||
"circle-color": "#242734",
|
||||
"circle-radius": 5,
|
||||
"circle-stroke-width": 2,
|
||||
"circle-stroke-color": "#fff",
|
||||
},
|
||||
},
|
||||
@@ -72,29 +82,17 @@ export class ClusterLayer implements BaseLayer {
|
||||
id: "cluster-count",
|
||||
type: "symbol",
|
||||
source: "cluster-trails",
|
||||
filter: ["has", "point_count"],
|
||||
maxzoom: 10,
|
||||
filter: ["all", ["!=", ["get", "is_large"], true], [">", ["get", "point_count"], 1]],
|
||||
layout: {
|
||||
"text-field": ["get", "point_count_abbreviated"],
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": 11,
|
||||
"text-allow-overlap": true,
|
||||
"text-ignore-placement": true,
|
||||
},
|
||||
paint: {
|
||||
"text-color": "#fff",
|
||||
},
|
||||
layout: {
|
||||
"text-field": "{point_count_abbreviated}",
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": 12,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "unclustered-point",
|
||||
type: "circle",
|
||||
source: "cluster-trails",
|
||||
maxzoom: 10,
|
||||
filter: ["!", ["has", "point_count"]],
|
||||
paint: {
|
||||
"circle-color": "#242734",
|
||||
"circle-radius": 7,
|
||||
"circle-stroke-width": 2,
|
||||
"circle-stroke-color": "#fff",
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
@@ -105,19 +103,26 @@ export class ClusterLayer implements BaseLayer {
|
||||
const features = this.map.queryRenderedFeatures(e.point, {
|
||||
layers: ["clusters"],
|
||||
});
|
||||
const clusterId = features[0].properties.cluster_id;
|
||||
const zoom = await (
|
||||
this.map.getSource("cluster-trails") as M.GeoJSONSource
|
||||
).getClusterExpansionZoom(clusterId);
|
||||
const feature = features[0];
|
||||
if (!feature) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentZoom = this.map.getZoom();
|
||||
this.map.flyTo({
|
||||
center: (features[0].geometry as any).coordinates,
|
||||
zoom,
|
||||
center: (feature.geometry as any).coordinates,
|
||||
zoom: currentZoom + 2,
|
||||
maxDuration: 3000
|
||||
});
|
||||
}
|
||||
|
||||
private zoomOnUnclusteredPoint(e: MapMouseEvent) {
|
||||
const coordinates = (e as any).features[0].geometry.coordinates.slice();
|
||||
const feature = (e as any).features?.[0];
|
||||
if (!feature) {
|
||||
return;
|
||||
}
|
||||
|
||||
const coordinates = feature.geometry.coordinates.slice();
|
||||
|
||||
this.map.flyTo({
|
||||
center: coordinates,
|
||||
@@ -125,4 +130,4 @@ export class ClusterLayer implements BaseLayer {
|
||||
maxDuration: 3000
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { baseMapStyles, defaultMapState, type BaseLayer, type MapState } from ".
|
||||
import { OverlayLayer } from "./overlay-layer";
|
||||
import { OverpassLayer, type OverpassPopupActionFactory } from "./overpass-layer";
|
||||
|
||||
const DEFAULT_GLYPHS = "https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf";
|
||||
|
||||
|
||||
export class LayerManager {
|
||||
@@ -120,6 +121,14 @@ export class LayerManager {
|
||||
}
|
||||
}
|
||||
|
||||
const style = this.map.getStyle();
|
||||
if (
|
||||
!style.glyphs &&
|
||||
layer.spec.layers.some((l) => l.type === "symbol" && l.layout && "text-field" in l.layout)
|
||||
) {
|
||||
this.map.setGlyphs(layer.spec.glyphs ?? DEFAULT_GLYPHS);
|
||||
}
|
||||
|
||||
for (const l of layer.spec.layers) {
|
||||
if (!this.map.getLayer(l.id)) {
|
||||
this.map.addLayer(l)
|
||||
@@ -210,4 +219,4 @@ export class LayerManager {
|
||||
this.addLayer(id, layer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,9 +18,10 @@ export class PreviewLayer implements BaseLayer {
|
||||
}
|
||||
};
|
||||
|
||||
constructor(map: M.Map, geojson: GeoJSON.FeatureCollection, listeners?: Record<string, { onMouseUp?: (e: MapMouseEvent) => void; onMouseDown?: (e: MapMouseEvent) => void; onEnter?: (e: MapMouseEvent) => void; onLeave?: (e: MapMouseEvent) => void; onMouseMove?: (e: MapMouseEvent) => void; }>) {
|
||||
constructor(map: M.Map, geojson: GeoJSON.FeatureCollection, options?: { showStartMarker?: boolean, listeners?: Record<string, { onMouseUp?: (e: MapMouseEvent) => void; onMouseDown?: (e: MapMouseEvent) => void; onEnter?: (e: MapMouseEvent) => void; onLeave?: (e: MapMouseEvent) => void; onMouseMove?: (e: MapMouseEvent) => void; }> }) {
|
||||
|
||||
this.map = map;
|
||||
const listeners = options?.listeners;
|
||||
this.listeners = {
|
||||
"preview": { ...this.listeners["preview"], ...listeners?.["preview"] },
|
||||
"preview-start-points": { ...this.listeners["preview-start-points"], ...listeners?.["preview-start-points"] }
|
||||
@@ -40,9 +41,11 @@ export class PreviewLayer implements BaseLayer {
|
||||
}
|
||||
}))
|
||||
};
|
||||
|
||||
this.spec = {
|
||||
version: 8,
|
||||
name: "preview",
|
||||
glyphs: "https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf",
|
||||
sources: {
|
||||
"preview": {
|
||||
type: "geojson",
|
||||
@@ -58,7 +61,6 @@ export class PreviewLayer implements BaseLayer {
|
||||
id: "preview",
|
||||
type: "line",
|
||||
source: "preview",
|
||||
minzoom: 10,
|
||||
paint: {
|
||||
"line-color": ["get", "color"],
|
||||
"line-width": 5,
|
||||
@@ -68,10 +70,10 @@ export class PreviewLayer implements BaseLayer {
|
||||
id: "preview-start-points",
|
||||
type: "circle",
|
||||
source: "preview-start-points",
|
||||
minzoom: 10,
|
||||
filter: ["literal", options?.showStartMarker ?? false],
|
||||
paint: {
|
||||
"circle-color": "#242734",
|
||||
"circle-radius": 6,
|
||||
"circle-radius": 5,
|
||||
"circle-stroke-width": 2,
|
||||
"circle-stroke-color": "#fff",
|
||||
},
|
||||
@@ -80,7 +82,6 @@ export class PreviewLayer implements BaseLayer {
|
||||
id: "preview-direction-carets",
|
||||
type: "symbol",
|
||||
source: "preview",
|
||||
minzoom: 10,
|
||||
layout: {
|
||||
"symbol-placement": "line",
|
||||
"symbol-spacing": [
|
||||
@@ -108,4 +109,4 @@ export class PreviewLayer implements BaseLayer {
|
||||
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { MapMouseEvent, Marker, StyleSpecification } from "maplibre-gl";
|
||||
import type { FilterSpecification, MapMouseEvent, Marker, StyleSpecification } from "maplibre-gl";
|
||||
import * as M from "maplibre-gl";
|
||||
import type { BaseLayer } from "./layers";
|
||||
|
||||
export class TrailLayer implements BaseLayer {
|
||||
@@ -7,7 +8,19 @@ export class TrailLayer implements BaseLayer {
|
||||
listeners: Record<string, { onMouseUp?: (e: MapMouseEvent) => void; onMouseDown?: (e: MapMouseEvent) => void; onEnter?: (e: MapMouseEvent) => void; onLeave?: (e: MapMouseEvent) => void; onMouseMove?: (e: MapMouseEvent) => void; }>
|
||||
markers: Record<string, Marker> = {};
|
||||
|
||||
constructor(id: string, geojson: GeoJSON.FeatureCollection, color: string, listerners?: { onMouseUp?: (e: MapMouseEvent) => void; onMouseDown?: (e: MapMouseEvent) => void; onEnter?: (e: MapMouseEvent) => void; onLeave?: (e: MapMouseEvent) => void; onMouseMove?: (e: MapMouseEvent) => void; }) {
|
||||
constructor(id: string, geojson: GeoJSON.FeatureCollection, color: string, options?: {
|
||||
listeners?: { onMouseUp?: (e: MapMouseEvent) => void; onMouseDown?: (e: MapMouseEvent) => void; onEnter?: (e: MapMouseEvent) => void; onLeave?: (e: MapMouseEvent) => void; onMouseMove?: (e: MapMouseEvent) => void; }
|
||||
}) {
|
||||
const layer: M.LineLayerSpecification = {
|
||||
id: id,
|
||||
type: "line",
|
||||
source: id,
|
||||
paint: {
|
||||
"line-color": color,
|
||||
"line-width": 5,
|
||||
},
|
||||
};
|
||||
|
||||
this.spec = {
|
||||
version: 8,
|
||||
name: id,
|
||||
@@ -17,18 +30,10 @@ export class TrailLayer implements BaseLayer {
|
||||
data: geojson,
|
||||
}
|
||||
},
|
||||
layers: [{
|
||||
id: id,
|
||||
type: "line",
|
||||
source: id,
|
||||
paint: {
|
||||
"line-color": color,
|
||||
"line-width": 5,
|
||||
},
|
||||
}]
|
||||
layers: [layer]
|
||||
|
||||
};
|
||||
|
||||
this.listeners = { [id]: listerners ?? {} }
|
||||
this.listeners = { [id]: options?.listeners ?? {} }
|
||||
}
|
||||
}
|
||||
128
web/src/routes/api/v1/search/trails/cluster/+server.ts
Normal file
128
web/src/routes/api/v1/search/trails/cluster/+server.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { error, json, type RequestEvent } from "@sveltejs/kit";
|
||||
import Supercluster from "supercluster";
|
||||
import { MAP_MAX_POLYLINES } from "$lib/config/map";
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function isValidLngLat(value: any): value is { lat: number; lng: number } {
|
||||
return isFiniteNumber(value?.lat) && isFiniteNumber(value?.lng);
|
||||
}
|
||||
|
||||
export async function POST(event: RequestEvent) {
|
||||
const data = await event.request.json()
|
||||
const { southWest, northEast, zoom, filterText, q = "" } = data;
|
||||
|
||||
if (!southWest || !northEast || zoom === undefined) {
|
||||
throw error(400, "Missing required parameters: southWest, northEast, zoom");
|
||||
}
|
||||
|
||||
if (!isValidLngLat(southWest) || !isValidLngLat(northEast) || !isFiniteNumber(zoom)) {
|
||||
throw error(400, "Invalid cluster bounds or zoom");
|
||||
}
|
||||
|
||||
try {
|
||||
let lonFilter = `max_lon >= ${southWest.lng} AND min_lon <= ${northEast.lng}`;
|
||||
if (southWest.lng > northEast.lng) {
|
||||
lonFilter = `(max_lon >= ${southWest.lng} OR min_lon <= ${northEast.lng})`;
|
||||
}
|
||||
|
||||
const geoFilter = `max_lat >= ${southWest.lat} AND min_lat <= ${northEast.lat} AND ${lonFilter}`;
|
||||
|
||||
const summaryQuery = {
|
||||
indexUid: "trails",
|
||||
q,
|
||||
filter: [geoFilter, filterText].filter(f => f && f !== ""),
|
||||
attributesToRetrieve: ["id", "_geo", "bounding_box_diagonal"],
|
||||
limit: 10000,
|
||||
};
|
||||
|
||||
const r = await event.locals.ms.multiSearch({
|
||||
queries: [summaryQuery]
|
||||
});
|
||||
|
||||
const hits = r.results[0].hits;
|
||||
|
||||
const clusteringMaxZoom = event.locals.settings?.behavior?.mapClusteringMaxZoom ?? 11;
|
||||
const forceClustering = zoom < clusteringMaxZoom;
|
||||
|
||||
// Dynamic Threshold: Sort by diagonal and pick top N for polylines
|
||||
const sortedHits = [...hits].sort((a: any, b: any) => (b.bounding_box_diagonal ?? 0) - (a.bounding_box_diagonal ?? 0));
|
||||
|
||||
const largeHits = forceClustering ? [] : sortedHits.slice(0, MAP_MAX_POLYLINES);
|
||||
const smallHits = forceClustering ? sortedHits : sortedHits.slice(MAP_MAX_POLYLINES);
|
||||
|
||||
const smallFeatures: GeoJSON.Feature<GeoJSON.Point, any>[] = smallHits.map((h: any) => ({
|
||||
type: "Feature",
|
||||
properties: {
|
||||
id: h.id,
|
||||
bounding_box_diagonal: h.bounding_box_diagonal ?? 0
|
||||
},
|
||||
geometry: {
|
||||
type: "Point",
|
||||
coordinates: [h._geo.lng, h._geo.lat]
|
||||
}
|
||||
}));
|
||||
|
||||
const index = new Supercluster({
|
||||
radius: 40, // Less aggressive clustering
|
||||
maxZoom: 16,
|
||||
});
|
||||
|
||||
index.load(smallFeatures);
|
||||
|
||||
const bbox: [number, number, number, number] = southWest.lng > northEast.lng
|
||||
? [-180, southWest.lat, 180, northEast.lat]
|
||||
: [southWest.lng, southWest.lat, northEast.lng, northEast.lat];
|
||||
|
||||
const clusters = index.getClusters(
|
||||
bbox,
|
||||
Math.floor(zoom)
|
||||
);
|
||||
|
||||
function abbreviateCount(count: number): string {
|
||||
if (count >= 1000) {
|
||||
return (count / 1000).toFixed(1) + "k";
|
||||
}
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
const normalizedSmallFeatures = clusters.map((f: any) => {
|
||||
if (f.properties.cluster) {
|
||||
f.properties.point_count_abbreviated = abbreviateCount(f.properties.point_count);
|
||||
} else {
|
||||
f.properties.point_count = 1;
|
||||
f.properties.point_count_abbreviated = "1";
|
||||
f.properties.is_large = false;
|
||||
}
|
||||
return f;
|
||||
});
|
||||
|
||||
// Step 3: Individual markers for large trails (NOT clustered)
|
||||
const largeFeatures: GeoJSON.Feature<GeoJSON.Point, any>[] = largeHits.map((h: any) => ({
|
||||
type: "Feature",
|
||||
properties: {
|
||||
id: h.id,
|
||||
cluster: false,
|
||||
is_large: true,
|
||||
point_count: 1,
|
||||
point_count_abbreviated: "1",
|
||||
bounding_box_diagonal: h.bounding_box_diagonal ?? 0
|
||||
},
|
||||
geometry: {
|
||||
type: "Point",
|
||||
coordinates: [h._geo.lng, h._geo.lat] // Back to stable anchor point
|
||||
}
|
||||
}));
|
||||
|
||||
return json({
|
||||
type: "FeatureCollection",
|
||||
features: [...normalizedSmallFeatures, ...largeFeatures],
|
||||
totalHits: r.results[0].estimatedTotalHits ?? r.results[0].totalHits
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error("Clustering error:", e);
|
||||
throw error(e.httpStatus || 500, e.message ?? "Unable to cluster trails");
|
||||
}
|
||||
}
|
||||
@@ -29,11 +29,14 @@
|
||||
import { trails_search_bounding_box } from "$lib/stores/trail_store";
|
||||
import { getIconForLocation } from "$lib/util/icon_util";
|
||||
import type { Snapshot } from "@sveltejs/kit";
|
||||
import type { FeatureCollection } from "geojson";
|
||||
import * as M from "maplibre-gl";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { slide } from "svelte/transition";
|
||||
|
||||
let trails: Trail[] = $state([]);
|
||||
let mapTrails: Trail[] = $state([]);
|
||||
let clusters: FeatureCollection | undefined = $state();
|
||||
|
||||
let map: M.Map | undefined = $state();
|
||||
let mapWithElevation: MapWithElevationMaplibre | undefined = $state();
|
||||
@@ -46,8 +49,6 @@
|
||||
const maxBoundingBox: TrailBoundingBox = page.data.boundingBox;
|
||||
const settings: Settings = page.data.settings;
|
||||
|
||||
const MIN_ZOOM = 10;
|
||||
|
||||
let loading: boolean = $state(true);
|
||||
let loadingNextPage: boolean = false;
|
||||
|
||||
@@ -55,6 +56,7 @@
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
};
|
||||
let searchRequestId = 0;
|
||||
|
||||
const sortOptions: SelectItem[] = [
|
||||
{ text: $_("name"), value: "name" },
|
||||
@@ -98,19 +100,19 @@
|
||||
],
|
||||
});
|
||||
|
||||
const trailItems = r[0].hits.map((t: TrailSearchResult) => ({
|
||||
const trailItems = (r[0]?.hits || []).map((t: TrailSearchResult) => ({
|
||||
text: t.name,
|
||||
description: `Trail ${t.location.length ? ", " + t.location : ""}`,
|
||||
value: `@${t.author_name}${t.domain ? `@${t.domain}` : ""}/${t.id}`,
|
||||
icon: "route",
|
||||
}));
|
||||
const listItems = r[1].hits.map((t: ListSearchResult) => ({
|
||||
const listItems = (r[1]?.hits || []).map((t: ListSearchResult) => ({
|
||||
text: t.name,
|
||||
description: `List, ${t.trails} ${$_("trail", { values: { n: t.trails } })}`,
|
||||
value: t.id,
|
||||
icon: "layer-group",
|
||||
}));
|
||||
const cityItems = r[2].hits.map((c: LocationSearchResult) => ({
|
||||
const cityItems = (r[2]?.hits || []).map((c: LocationSearchResult) => ({
|
||||
text: c.name,
|
||||
description: c.description,
|
||||
value: c,
|
||||
@@ -135,7 +137,11 @@
|
||||
northEast: M.LngLat,
|
||||
southWest: M.LngLat,
|
||||
reset: boolean = true,
|
||||
loadMapData: boolean = true,
|
||||
) {
|
||||
const requestId =
|
||||
reset || loadMapData ? ++searchRequestId : searchRequestId;
|
||||
|
||||
if (reset) {
|
||||
pagination.page = 1;
|
||||
loading = true;
|
||||
@@ -146,11 +152,23 @@
|
||||
southWest,
|
||||
filter,
|
||||
pagination.page,
|
||||
(map?.getZoom() ?? 0) > MIN_ZOOM,
|
||||
map?.getZoom(),
|
||||
50,
|
||||
loadMapData,
|
||||
);
|
||||
|
||||
if (requestId !== searchRequestId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
pagination.totalPages = trailsInBox.totalPages;
|
||||
trails = trailsInBox.trails;
|
||||
if (loadMapData) {
|
||||
mapTrails = trailsInBox.mapTrails;
|
||||
clusters = trailsInBox.clusters;
|
||||
}
|
||||
loading = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleTrailCardMouseEnter(trail: Trail) {
|
||||
@@ -190,33 +208,57 @@
|
||||
await searchTrails(bounds.getNorthEast(), bounds.getSouthWest());
|
||||
}
|
||||
|
||||
let moveTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
async function handleMapMove() {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
const bounds = map.getBounds();
|
||||
|
||||
const normalizedBounds = {
|
||||
southWest: new M.LngLat(
|
||||
((((bounds.getSouthWest().lng + 180) % 360) + 360) % 360) - 180,
|
||||
bounds.getSouthWest().lat,
|
||||
),
|
||||
northEast: new M.LngLat(
|
||||
((((bounds.getNorthEast().lng + 180) % 360) + 360) % 360) - 180,
|
||||
bounds.getNorthEast().lat,
|
||||
),
|
||||
};
|
||||
await searchTrails(
|
||||
normalizedBounds.northEast,
|
||||
normalizedBounds.southWest,
|
||||
);
|
||||
if (moveTimeout) {
|
||||
clearTimeout(moveTimeout);
|
||||
}
|
||||
moveTimeout = setTimeout(async () => {
|
||||
const bounds = map!.getBounds();
|
||||
const west = bounds.getWest();
|
||||
const east = bounds.getEast();
|
||||
const north = bounds.getNorth();
|
||||
const south = bounds.getSouth();
|
||||
|
||||
page.url.searchParams.set("tl_lat", bounds.getNorth().toString());
|
||||
page.url.searchParams.set("tl_lon", bounds.getEast().toString());
|
||||
page.url.searchParams.set("br_lat", bounds.getSouth().toString());
|
||||
page.url.searchParams.set("br_lon", bounds.getWest().toString());
|
||||
let normalizedSW: M.LngLat;
|
||||
let normalizedNE: M.LngLat;
|
||||
|
||||
goto(`?${page.url.searchParams.toString()}`);
|
||||
if (east - west >= 360) {
|
||||
// Global view
|
||||
normalizedSW = new M.LngLat(-180, south);
|
||||
normalizedNE = new M.LngLat(180, north);
|
||||
} else {
|
||||
// Handle wrap-around
|
||||
normalizedSW = new M.LngLat(
|
||||
((((west + 180) % 360) + 360) % 360) - 180,
|
||||
south,
|
||||
);
|
||||
normalizedNE = new M.LngLat(
|
||||
((((east + 180) % 360) + 360) % 360) - 180,
|
||||
north,
|
||||
);
|
||||
}
|
||||
|
||||
const applied = await searchTrails(normalizedNE, normalizedSW);
|
||||
if (!applied) {
|
||||
return;
|
||||
}
|
||||
|
||||
page.url.searchParams.set("tl_lat", north.toString());
|
||||
page.url.searchParams.set("tl_lon", east.toString());
|
||||
page.url.searchParams.set("br_lat", south.toString());
|
||||
page.url.searchParams.set("br_lon", west.toString());
|
||||
|
||||
goto(`?${page.url.searchParams.toString()}`, {
|
||||
replaceState: true,
|
||||
noScroll: true,
|
||||
keepFocus: true,
|
||||
});
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function handleMapInit() {
|
||||
@@ -314,7 +356,7 @@
|
||||
}
|
||||
pagination.page += 1;
|
||||
const bounds = map.getBounds();
|
||||
await searchTrails(bounds.getNorthEast(), bounds.getSouthWest(), false);
|
||||
await searchTrails(bounds.getNorthEast(), bounds.getSouthWest(), false, false);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -391,7 +433,7 @@
|
||||
{#if trails.length == 0}
|
||||
<EmptyStateSearch></EmptyStateSearch>
|
||||
{/if}
|
||||
{#each trails as trail, i}
|
||||
{#each trails.filter(t => t.name !== "") as trail, i}
|
||||
<a
|
||||
href="/map/trail/@{trail.author}{trail.domain
|
||||
? `@${trail.domain}`
|
||||
@@ -419,7 +461,8 @@
|
||||
<MapWithElevationMaplibre
|
||||
onmoveend={handleMapMove}
|
||||
oninit={handleMapInit}
|
||||
{trails}
|
||||
trails={mapTrails}
|
||||
serverClusters={clusters}
|
||||
showElevation={false}
|
||||
showTerrain={true}
|
||||
showInfoPopup={true}
|
||||
@@ -427,6 +470,7 @@
|
||||
fitBounds="off"
|
||||
clusterTrails={true}
|
||||
bind:map
|
||||
|
||||
bind:this={mapWithElevation}
|
||||
></MapWithElevationMaplibre>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { categories_index } from "$lib/stores/category_store";
|
||||
import { trails_get_bounding_box, trails_get_filter_values } from "$lib/stores/trail_store";
|
||||
import type { ServerLoad } from "@sveltejs/kit";
|
||||
|
||||
export const load: ServerLoad = async ({ params, locals, fetch }) => {
|
||||
export const load: ServerLoad = async ({ fetch }) => {
|
||||
const boundingBox = await trails_get_bounding_box(fetch);
|
||||
const filterValues = await trails_get_filter_values(fetch);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
type SelectItem,
|
||||
} from "$lib/components/base/select.svelte";
|
||||
|
||||
import Slider from "$lib/components/base/slider.svelte";
|
||||
import TextField from "$lib/components/base/text_field.svelte";
|
||||
import {
|
||||
searchLocations,
|
||||
@@ -15,7 +16,7 @@
|
||||
import { settings_update } from "$lib/stores/settings_store";
|
||||
import { currentUser } from "$lib/stores/user_store";
|
||||
import { getIconForLocation } from "$lib/util/icon_util";
|
||||
import { onMount } from "svelte";
|
||||
import { onMount, untrack } from "svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import Toggle from "$lib/components/base/toggle.svelte";
|
||||
import { show_toast } from "$lib/stores/toast_store.svelte.js";
|
||||
@@ -25,20 +26,40 @@
|
||||
let allowAutoGeolocate = $state(
|
||||
page.data.settings.behavior?.allowAutoGeolocate ?? false,
|
||||
);
|
||||
let mapClusteringMaxZoom = $state(
|
||||
page.data.settings.behavior?.mapClusteringMaxZoom ?? 11,
|
||||
);
|
||||
let showTrailStartMarker = $state(
|
||||
page.data.settings.behavior?.showTrailStartMarker ?? false,
|
||||
);
|
||||
|
||||
async function handleAllowAutoGeolocateChange() {
|
||||
$effect(() => {
|
||||
const b = page.data.settings?.behavior;
|
||||
if (b) {
|
||||
untrack(() => {
|
||||
allowAutoGeolocate = b.allowAutoGeolocate ?? false;
|
||||
mapClusteringMaxZoom = b.mapClusteringMaxZoom ?? 11;
|
||||
showTrailStartMarker = b.showTrailStartMarker ?? false;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function handleBehaviorChange() {
|
||||
if (!settings) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!settings.behavior) {
|
||||
settings.behavior = { allowAutoGeolocate: allowAutoGeolocate };
|
||||
} else {
|
||||
settings.behavior.allowAutoGeolocate = allowAutoGeolocate;
|
||||
}
|
||||
const updatedSettings = {
|
||||
...settings,
|
||||
behavior: {
|
||||
allowAutoGeolocate: allowAutoGeolocate,
|
||||
mapClusteringMaxZoom: Number(mapClusteringMaxZoom),
|
||||
showTrailStartMarker: showTrailStartMarker,
|
||||
},
|
||||
};
|
||||
|
||||
await settings_update(settings);
|
||||
await settings_update(updatedSettings);
|
||||
} catch (e) {
|
||||
show_toast({
|
||||
type: "error",
|
||||
@@ -166,16 +187,50 @@
|
||||
></Search>
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="mt-4 grid gap-4"
|
||||
style="grid-template-columns: 1fr min-content ;"
|
||||
>
|
||||
<p>{$_("allow-auto-geolocate")}</p>
|
||||
<div>
|
||||
<Toggle
|
||||
bind:value={allowAutoGeolocate}
|
||||
onchange={handleAllowAutoGeolocateChange}
|
||||
></Toggle>
|
||||
<div class="mt-8 space-y-4">
|
||||
<div
|
||||
class="grid gap-4 items-center"
|
||||
style="grid-template-columns: 1fr min-content ;"
|
||||
>
|
||||
<p>{$_("allow-auto-geolocate")}</p>
|
||||
<div>
|
||||
<Toggle
|
||||
bind:value={allowAutoGeolocate}
|
||||
onchange={handleBehaviorChange}
|
||||
></Toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid gap-4 items-center"
|
||||
style="grid-template-columns: 1fr min-content ;"
|
||||
>
|
||||
<p>{$_("map-trail-preview-zoom-level")}</p>
|
||||
<div class="flex items-center gap-4 w-56">
|
||||
<span class="w-8 text-right tabular-nums">
|
||||
{Math.round(Number(mapClusteringMaxZoom))}
|
||||
</span>
|
||||
<div class="w-44">
|
||||
<Slider
|
||||
minValue={0}
|
||||
maxValue={22}
|
||||
step={1}
|
||||
bind:currentValue={mapClusteringMaxZoom}
|
||||
onset={handleBehaviorChange}
|
||||
></Slider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid gap-4 items-center"
|
||||
style="grid-template-columns: 1fr min-content ;"
|
||||
>
|
||||
<p>{$_("show-trail-start-marker")}</p>
|
||||
<div>
|
||||
<Toggle
|
||||
bind:value={showTrailStartMarker}
|
||||
onchange={handleBehaviorChange}
|
||||
></Toggle>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user