Files
wanderer/db/main.go
Pål Håland 197cd8d04e 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>
2026-06-07 13:00:26 +02:00

409 lines
12 KiB
Go

package main
import (
"fmt"
"log"
"os"
"strings"
"github.com/meilisearch/meilisearch-go"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/plugins/migratecmd"
"github.com/pocketbase/pocketbase/tools/filesystem"
"pocketbase/commands"
"pocketbase/hooks"
"pocketbase/integrations/hammerhead"
"pocketbase/integrations/komoot"
"pocketbase/integrations/strava"
"pocketbase/routes"
_ "pocketbase/migrations"
"pocketbase/util"
)
const (
defaultPocketBaseEncryptionKey = "fde406459dc1f6ca6f348e1f44a9a2af"
defaultMeiliMasterKey = "vODkljPcfFANYNepCHyDyGjzAMPcdHnrb6X5KyXQPWo"
)
// verifySettings checks if the required environment variables are set.
// If they are not set, it logs a warning.
func verifySettings(app core.App) {
encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
if len(encryptionKey) != 32 {
// terminate if the encryption key is not set or is not exactly 32 bytes long,
// as this is a requirement for PocketBase to function properly.
log.Fatal("POCKETBASE_ENCRYPTION_KEY must be exactly 32 bytes long- See https://wanderer.to/run/installation/docker#prerequisites for more information")
}
if encryptionKey == defaultPocketBaseEncryptionKey {
app.Logger().Warn("POCKETBASE_ENCRYPTION_KEY is still set to the default value. Please change it to a secure value")
}
meiliMasterKey := os.Getenv("MEILI_MASTER_KEY")
if len(meiliMasterKey) < 32 {
app.Logger().Warn("MEILI_MASTER_KEY not set or is shorter than 32 bytes")
}
if meiliMasterKey == defaultMeiliMasterKey {
app.Logger().Warn("MEILI_MASTER_KEY is still set to the default value. Please change it to a secure value")
}
}
func main() {
app := pocketbase.New()
client := initializeMeilisearch()
verifySettings(app)
registerMigrations(app)
setupEventHandlers(app, client)
setupCommands(app)
if err := app.Start(); err != nil {
log.Fatal(err)
}
}
func initializeMeilisearch() meilisearch.ServiceManager {
return meilisearch.New(
os.Getenv("MEILI_URL"),
meilisearch.WithAPIKey(os.Getenv("MEILI_MASTER_KEY")),
)
}
func registerMigrations(app *pocketbase.PocketBase) {
migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{
Dir: "migrations",
Automigrate: true,
})
}
func setupEventHandlers(app *pocketbase.PocketBase, client meilisearch.ServiceManager) {
app.OnRecordAfterCreateSuccess("users").BindFunc(hooks.CreateUserHandler(client))
app.OnRecordAfterUpdateSuccess("users").BindFunc(hooks.UpdateUserHandler(client))
app.OnRecordAfterCreateSuccess("trails").BindFunc(hooks.CreateTrailHandler(client))
app.OnRecordAfterUpdateSuccess("trails").BindFunc(hooks.UpdateTrailHandler(client))
app.OnRecordAfterDeleteSuccess("trails").BindFunc(hooks.DeleteTrailHandler(client))
app.OnRecordCreateRequest("summit_logs").BindFunc(hooks.CreateSummitLogHandler(client))
app.OnRecordUpdateRequest("summit_logs").BindFunc(hooks.UpdateSummitLogHandler())
app.OnRecordDeleteRequest("summit_logs").BindFunc(hooks.DeleteSummitLogHandler(client))
app.OnRecordCreateRequest("waypoints").BindFunc(hooks.CreateWaypointHandler())
app.OnRecordCreateRequest("comments").BindFunc(hooks.CreateCommentHandler())
app.OnRecordUpdateRequest("comments").BindFunc(hooks.UpdateCommentHandler())
app.OnRecordDeleteRequest("comments").BindFunc(hooks.DeleteCommentHandler(client))
app.OnRecordCreateRequest("trail_share").BindFunc(hooks.CreateTrailShareHandler(client))
app.OnRecordDeleteRequest("trail_share").BindFunc(hooks.DeleteTrailShareHandler(client))
app.OnRecordAfterCreateSuccess("trail_like").BindFunc(hooks.CreateTrailLikeHandler(client))
app.OnRecordAfterDeleteSuccess("trail_like").BindFunc(hooks.DeleteTrailLikeHandler(client))
app.OnRecordAfterCreateSuccess("lists").BindFunc(hooks.CreateListHandler(client))
app.OnRecordAfterUpdateSuccess("lists").BindFunc(hooks.UpdateListHandler(client))
app.OnRecordAfterDeleteSuccess("lists").BindFunc(hooks.DeleteListHandler(client))
app.OnRecordCreateRequest("list_share").BindFunc(hooks.CreateListShareHandler(client))
app.OnRecordDeleteRequest("list_share").BindFunc(hooks.DeleteListShareHandler(client))
app.OnRecordCreateRequest("follows").BindFunc(hooks.CreateFollowHandler())
app.OnRecordDeleteRequest("follows").BindFunc(hooks.DeleteFollowHandler())
app.OnRecordsListRequest("integrations").BindFunc(hooks.ListIntegrationHandler())
app.OnRecordCreate("integrations").BindFunc(hooks.CreateIntegrationHandler())
app.OnRecordAfterCreateSuccess("integrations").BindFunc(hooks.CreateUpdateIntegrationSuccessHandler())
app.OnRecordUpdate("integrations").BindFunc(hooks.UpdateIntegrationHandler())
app.OnRecordAfterUpdateSuccess("integrations").BindFunc(hooks.CreateUpdateIntegrationSuccessHandler())
app.OnRecordsListRequest("feed", "profile_feed").BindFunc(hooks.ListFeedHandler())
app.OnRecordCreate("api_tokens").BindFunc(hooks.CreateAPITokenHandler())
app.OnRecordCreateRequest().BindFunc(util.SanitizeHTML())
app.OnRecordUpdateRequest().BindFunc(util.SanitizeHTML())
app.OnServe().BindFunc(onBeforeServeHandler(client))
app.OnBootstrap().BindFunc(hooks.OnBootstrapHandler())
}
func setupCommands(app *pocketbase.PocketBase) {
app.RootCmd.AddCommand(commands.Dedup(app))
}
func onBeforeServeHandler(client meilisearch.ServiceManager) func(se *core.ServeEvent) error {
return func(se *core.ServeEvent) error {
registerRoutes(se, client)
registerCronJobs(se.App, client)
initData(se.App, client)
return se.Next()
}
}
func registerRoutes(se *core.ServeEvent, client meilisearch.ServiceManager) {
se.Router.GET("/health", routes.Health)
se.Router.POST("/auth/token", routes.AuthToken)
se.Router.POST("/user/email", routes.UserEmailChange)
se.Router.POST("/waypoint/cluster", routes.WaypointCluster)
se.Router.POST("/trail-merge/suggest", routes.TrailMergeSuggest)
se.Router.POST("/trail-merge", routes.TrailMerge(client))
se.Router.GET("/search/token", routes.SearchToken(client))
se.Router.POST("/integration/strava/token", routes.IntegrationStravaToken)
se.Router.POST("/integration/hammerhead/upload", routes.IntegrationHammerheadUpload)
se.Router.GET("/integration/hammerhead/login", routes.IntegrationHammerheadLogin)
se.Router.GET("/integration/komoot/login", routes.IntegrationKommotLogin)
se.Router.POST("/activitypub/activity/process", routes.ActivitypubActivityProcess)
se.Router.GET("/activitypub/actor", routes.ActivitypubActor)
se.Router.GET("/activitypub/actor/{id}/{follow}", routes.ActivitypubActorFollow)
se.Router.GET("/activitypub/trail/{id}", routes.ActivitypubTrail)
se.Router.GET("/activitypub/comment/{id}", routes.ActivitypubComment)
se.Router.GET("/remote/trail/{id}", routes.RemoteTrailGet)
se.Router.GET("/remote/trail/{id}/comments", routes.RemoteTrailCommentsList)
se.Router.GET("/remote/list/{id}", routes.RemoteListGet)
se.Router.GET("/remote/profile/{handle}/follows", routes.RemoteProfileFollowsList)
}
func registerCronJobs(app core.App, client meilisearch.ServiceManager) {
schedule := os.Getenv("POCKETBASE_CRON_SYNC_SCHEDULE")
if len(schedule) == 0 {
schedule = "0 2 * * *"
}
app.Cron().MustAdd("integrations", schedule, func() {
err := strava.SyncStrava(app, client)
if err != nil {
warning := fmt.Sprintf("Error syncing with strava: %v", err)
fmt.Println(warning)
app.Logger().Error(warning)
}
err = komoot.SyncKomoot(app, client)
if err != nil {
warning := fmt.Sprintf("Error syncing with komoot: %v", err)
fmt.Println(warning)
app.Logger().Error(warning)
}
err = hammerhead.SyncHammerhead(app, client)
if err != nil {
warning := fmt.Sprintf("Error syncing with hammerhead: %v", err)
fmt.Println(warning)
app.Logger().Error(warning)
}
})
}
func initData(app core.App, client meilisearch.ServiceManager) error {
initCategories(app)
initMeilisearchConfig(client)
go func() {
backfillPolylines(app)
initMeilisearchDocuments(app, client)
}()
return nil
}
func backfillPolylines(app core.App) {
const pageSize int64 = 100
var lastID string
var processed int
var failed int
log.Printf("backfill polyline started")
defer func() {
log.Printf("backfill polyline completed: processed=%d failed=%d", processed, failed)
}()
for {
trails := []*core.Record{}
query := app.RecordQuery("trails").
AndWhere(dbx.NewExp("(polyline IS NULL OR polyline = '') AND gpx != ''")).
OrderBy("id ASC").
Limit(pageSize)
if lastID != "" {
query = query.AndWhere(dbx.NewExp("id > {:lastID}", dbx.Params{"lastID": lastID}))
}
err := query.All(&trails)
if err != nil {
log.Printf("backfill polyline query failed after trail %q: %v", lastID, err)
break
}
if len(trails) == 0 {
break
}
for _, r := range trails {
if err := util.SavePolyline(app, r); err != nil {
failed++
log.Printf("backfill polyline failed for trail %s (%q), gpx=%q: %v", r.Id, r.GetString("name"), r.GetString("gpx"), err)
} else {
processed++
}
lastID = r.Id
}
}
}
func initCategories(app core.App) error {
query := app.RecordQuery("categories")
records := []*core.Record{}
if err := query.All(&records); err != nil {
return err
}
if len(records) == 0 {
collection, _ := app.FindCollectionByNameOrId("categories")
categories := []string{"Hiking", "Walking", "Climbing", "Skiing", "Canoeing", "Biking"}
for _, element := range categories {
record := core.NewRecord(collection)
record.Set("name", element)
record.Set("settings", map[string]any{
"wp_merge_enabled": true,
"wp_merge_radius": 50,
})
f, _ := filesystem.NewFileFromPath("migrations/initial_data/" + strings.ToLower(element) + ".jpg")
record.Set("img", f)
err := app.Save(record)
if err != nil {
return err
}
}
}
return nil
}
func initMeilisearchConfig(client meilisearch.ServiceManager) {
configs := map[string]meilisearch.Settings{
"trails": {
SearchableAttributes: []string{"author_name", "name", "description", "location", "tags"},
FilterableAttributes: []string{
"id", "_geo", "author", "category", "completed", "date", "difficulty",
"distance", "elevation_gain", "elevation_loss", "likes", "public",
"shares", "tags", "min_lat", "max_lat", "min_lon", "max_lon", "bounding_box_diagonal",
},
SortableAttributes: []string{
"author", "created", "date", "difficulty", "distance",
"duration", "elevation_gain", "elevation_loss", "like_count", "name",
},
RankingRules: []string{"words", "typo", "proximity", "attribute", "sort", "exactness"},
},
"lists": {
SearchableAttributes: []string{"*"},
FilterableAttributes: []string{"author", "public", "shares"},
SortableAttributes: []string{"created", "name"},
RankingRules: []string{"words", "typo", "proximity", "attribute", "sort", "exactness"},
},
}
for indexName, settings := range configs {
_, err := client.GetIndex(indexName)
if err != nil {
log.Printf("Index [%s] not found, creating it...", indexName)
task, err := client.CreateIndex(&meilisearch.IndexConfig{
Uid: indexName,
PrimaryKey: "id",
})
if err != nil {
log.Printf("Failed to create index [%s]: %v", indexName, err)
continue
}
_, err = client.WaitForTask(task.TaskUID, 0)
if err != nil {
log.Printf("Error waiting for index creation [%s]: %v", indexName, err)
continue
}
}
_, err = client.Index(indexName).UpdateSettings(&settings)
if err != nil {
log.Printf("Failed to sync settings for index [%s]: %v", indexName, err)
} else {
log.Printf("Settings synced for index [%s]", indexName)
}
}
}
func initMeilisearchDocuments(app core.App, client meilisearch.ServiceManager) error {
// --- Trails ---
const pageSize int64 = 100
var page int64 = 0
// Clear index before re-indexing
if _, err := client.Index("trails").DeleteAllDocuments(nil); err != nil {
return err
}
for {
trails := []*core.Record{}
err := app.RecordQuery("trails").
Limit(pageSize).
Offset(page * pageSize).
All(&trails)
if err != nil {
return err
}
if len(trails) == 0 {
break
}
if err := util.IndexTrails(app, trails, client); err != nil {
app.Logger().Warn(fmt.Sprintf("Unable to index trails page %d: %v", page, err))
continue
}
page++
}
// --- Lists ---
if _, err := client.Index("lists").DeleteAllDocuments(nil); err != nil {
return err
}
page = 0
for {
lists := []*core.Record{}
err := app.RecordQuery("lists").
Limit(pageSize).
Offset(page * pageSize).
All(&lists)
if err != nil {
return err
}
if len(lists) == 0 {
break
}
if err := util.IndexLists(app, lists, client); err != nil {
app.Logger().Warn(fmt.Sprintf("Unable to index list page %d: %v", page, err))
continue
}
page++
}
return nil
}