From 6d1d1236efb15f6b6fa7964b954c33f6affacdf9 Mon Sep 17 00:00:00 2001 From: slothful-vassal <89943360+slothful-vassal@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:15:06 +0200 Subject: [PATCH] Trail Edit / Waypoints from photos: prevent creating different waypoints for near-by locations (#457) * trail edit / waypoints from photos: prevent creating different waypoints for near-by locations * remove not necessary imports * remove changes from gitignore * patch gitignore * waypoint merge radius as category property * change datamodel for merge radius (use more generic settings field for future implementations) * fix npm run check issues * several improvements/fixes * fix * move geo cluster code to db * fix compile issue * fix docker issue * merge with existing waypoints --------- Co-authored-by: Christian Beutel <> Co-authored-by: Flomp --- .gitignore | 2 +- CHANGELOG.md | 6 +- db/.dockerignore | 2 + db/main.go | 7 + .../1763300311_updated_categories.go | 60 ++++ db/util/geo.go | 14 + db/waypointcluster/waypoint_cluster.go | 197 +++++++++++ docs/src/content/docs/changelog.md | 6 +- .../custom-categories.md | 21 ++ .../waypoint/waypoint_merge_modal.svelte | 248 ++++++++++++++ .../components/waypoint/waypoint_modal.svelte | 12 +- web/src/lib/i18n/locales/cs.json | 1 + web/src/lib/i18n/locales/de.json | 12 + web/src/lib/i18n/locales/en.json | 12 + web/src/lib/i18n/locales/es.json | 1 + web/src/lib/i18n/locales/eu.json | 1 + web/src/lib/i18n/locales/fr.json | 1 + web/src/lib/i18n/locales/hu.json | 1 + web/src/lib/i18n/locales/it.json | 1 + web/src/lib/i18n/locales/nl.json | 1 + web/src/lib/i18n/locales/no.json | 1 + web/src/lib/i18n/locales/pl.json | 1 + web/src/lib/i18n/locales/pt.json | 1 + web/src/lib/i18n/locales/ru.json | 1 + web/src/lib/i18n/locales/zh.json | 1 + web/src/lib/models/category.ts | 9 +- web/src/lib/stores/trail_store.ts | 26 +- web/src/routes/trail/edit/[id]/+page.svelte | 311 +++++++++++++++++- 28 files changed, 929 insertions(+), 28 deletions(-) create mode 100644 db/migrations/1763300311_updated_categories.go create mode 100644 db/util/geo.go create mode 100644 db/waypointcluster/waypoint_cluster.go create mode 100644 web/src/lib/components/waypoint/waypoint_merge_modal.svelte diff --git a/.gitignore b/.gitignore index 5c5302b5..2f8389e2 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,4 @@ run.sh build*.sh start*.* -data*/ +data*/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index a7080e47..b48cf9ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# Unreleased +## Features +- Geotagged waypoint photos are now grouped into one waypoint when they are within the category's waypoint merge radius. Existing categories are initialized with waypoint merging enabled and a 50m merge radius; set `settings.wp_merge_enabled` to `false` on a category to keep creating one waypoint per photo. + # v0.18.5 ## Security - Fixes CVE-2022-39299 via xmldom upgrade (PR #820) @@ -682,4 +686,4 @@ As the number of contributors to this project continues to grow (which I’m ver - updated the docs to include BODY_SIZE_LIMIT # v0.1.0 -- Initial release \ No newline at end of file +- Initial release diff --git a/db/.dockerignore b/db/.dockerignore index 357e70b1..27d890ca 100644 --- a/db/.dockerignore +++ b/db/.dockerignore @@ -6,4 +6,6 @@ !main.go !migrations !templates +!waypointcluster +!waypointcluster/** !util diff --git a/db/main.go b/db/main.go index 52dfb71b..3c0bf627 100644 --- a/db/main.go +++ b/db/main.go @@ -31,6 +31,7 @@ import ( _ "pocketbase/migrations" "pocketbase/util" + "pocketbase/waypointcluster" pub "github.com/go-ap/activitypub" "github.com/microcosm-cc/bluemonday" @@ -1072,6 +1073,8 @@ func registerRoutes(se *core.ServeEvent, client meilisearch.ServiceManager) { return e.JSON(http.StatusOK, map[string]string{"status": "ok"}) }) + se.Router.POST("/waypoint/cluster", waypointcluster.Handler) + se.Router.POST("/auth/token", func(e *core.RequestEvent) error { var data struct { APIToken string `json:"api_token"` @@ -1488,6 +1491,10 @@ func bootstrapCategories(app core.App) error { 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) diff --git a/db/migrations/1763300311_updated_categories.go b/db/migrations/1763300311_updated_categories.go new file mode 100644 index 00000000..938b55ca --- /dev/null +++ b/db/migrations/1763300311_updated_categories.go @@ -0,0 +1,60 @@ +package migrations + +import ( + "github.com/pocketbase/pocketbase/core" + m "github.com/pocketbase/pocketbase/migrations" +) + +func init() { + m.Register(func(app core.App) error { + collection, err := app.FindCollectionByNameOrId("kjxvi8asj2igqwf") + if err != nil { + return err + } + + // add field + if err := collection.Fields.AddMarshaledJSONAt(6, []byte(`{ + "hidden": false, + "id": "json3846545605", + "maxSize": 0, + "name": "settings", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }`)); err != nil { + return err + } + + if err := app.Save(collection); err != nil { + return err + } + + records, err := app.FindAllRecords("categories") + if err != nil { + return err + } + + for _, record := range records { + record.Set("settings", map[string]any{ + "wp_merge_enabled": true, + "wp_merge_radius": 50, + }) + if err := app.Save(record); err != nil { + return err + } + } + + return nil + }, func(app core.App) error { + collection, err := app.FindCollectionByNameOrId("kjxvi8asj2igqwf") + if err != nil { + return err + } + + // remove field + collection.Fields.RemoveById("json3846545605") + + return app.Save(collection) + }) +} diff --git a/db/util/geo.go b/db/util/geo.go new file mode 100644 index 00000000..1eb38d0b --- /dev/null +++ b/db/util/geo.go @@ -0,0 +1,14 @@ +package util + +import "math" + +func HaversineDistance(lat1 float64, lon1 float64, lat2 float64, lon2 float64) float64 { + const earthRadiusKm = 6371 + dLat := (lat2 - lat1) * (math.Pi / 180) + dLon := (lon2 - lon1) * (math.Pi / 180) + a := math.Sin(dLat/2)*math.Sin(dLat/2) + + math.Cos(lat1*(math.Pi/180))*math.Cos(lat2*(math.Pi/180))* + math.Sin(dLon/2)*math.Sin(dLon/2) + c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) + return earthRadiusKm * c * 1000 +} diff --git a/db/waypointcluster/waypoint_cluster.go b/db/waypointcluster/waypoint_cluster.go new file mode 100644 index 00000000..3513f613 --- /dev/null +++ b/db/waypointcluster/waypoint_cluster.go @@ -0,0 +1,197 @@ +package waypointcluster + +import ( + "net/http" + + "github.com/pocketbase/pocketbase/apis" + "github.com/pocketbase/pocketbase/core" + + "pocketbase/util" +) + +const defaultWaypointMergeRadius = 50 + +type waypointMergeSettings struct { + Enabled bool + Radius float64 +} + +type waypointClusterRequest struct { + Category string `json:"category"` + Photos []waypointClusterPhoto `json:"photos"` + Waypoints []waypointClusterWaypoint `json:"waypoints"` +} + +type waypointClusterPhoto struct { + ID string `json:"id"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` +} + +type waypointClusterWaypoint struct { + ID string `json:"id"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` +} + +type waypointPhotoCluster struct { + Waypoint string `json:"waypoint,omitempty"` + Photos []string `json:"photos"` + SumLat float64 `json:"-"` + SumLon float64 `json:"-"` + Count int `json:"-"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` +} + +type categorySettings struct { + WaypointMergeEnabled *bool `json:"wp_merge_enabled"` + WaypointMergeRadius *float64 `json:"wp_merge_radius"` +} + +func Handler(e *core.RequestEvent) error { + if e.Auth == nil { + return apis.NewUnauthorizedError("authentication required", nil) + } + + var data waypointClusterRequest + if err := e.BindBody(&data); err != nil { + return apis.NewBadRequestError("Failed to read request data", err) + } + + if data.Category != "" && len(data.Category) != 15 { + return apis.NewBadRequestError("Invalid category", nil) + } + + for _, photo := range data.Photos { + if photo.ID == "" { + return apis.NewBadRequestError("Invalid photo id", nil) + } + if photo.Lat < -90 || photo.Lat > 90 { + return apis.NewBadRequestError("Invalid photo latitude", nil) + } + if photo.Lon < -180 || photo.Lon > 180 { + return apis.NewBadRequestError("Invalid photo longitude", nil) + } + } + + for _, waypoint := range data.Waypoints { + if waypoint.ID == "" { + return apis.NewBadRequestError("Invalid waypoint id", nil) + } + if waypoint.Lat < -90 || waypoint.Lat > 90 { + return apis.NewBadRequestError("Invalid waypoint latitude", nil) + } + if waypoint.Lon < -180 || waypoint.Lon > 180 { + return apis.NewBadRequestError("Invalid waypoint longitude", nil) + } + } + + mergeSettings, err := getWaypointMergeSettings(e.App, data.Category) + if err != nil { + return err + } + + return e.JSON(http.StatusOK, map[string]any{ + "mergeEnabled": mergeSettings.Enabled, + "mergeRadius": mergeSettings.Radius, + "clusters": clusterWaypointPhotos(data.Photos, data.Waypoints, mergeSettings), + }) +} + +func getWaypointMergeSettings(app core.App, categoryId string) (waypointMergeSettings, error) { + defaultSettings := waypointMergeSettings{ + Enabled: true, + Radius: defaultWaypointMergeRadius, + } + + if categoryId == "" { + return defaultSettings, nil + } + + category, err := app.FindRecordById("categories", categoryId) + if err != nil { + return waypointMergeSettings{}, err + } + + var settings categorySettings + if err := category.UnmarshalJSONField("settings", &settings); err != nil { + return defaultSettings, nil + } + + if settings.WaypointMergeEnabled != nil { + defaultSettings.Enabled = *settings.WaypointMergeEnabled + } + + if settings.WaypointMergeRadius != nil && *settings.WaypointMergeRadius >= 0 { + defaultSettings.Radius = *settings.WaypointMergeRadius + } + + return defaultSettings, nil +} + +func clusterWaypointPhotos(photos []waypointClusterPhoto, waypoints []waypointClusterWaypoint, mergeSettings waypointMergeSettings) []waypointPhotoCluster { + clusters := []waypointPhotoCluster{} + + if mergeSettings.Enabled { + for _, waypoint := range waypoints { + clusters = append(clusters, newWaypointCluster(waypoint)) + } + } + + for _, photo := range photos { + if !mergeSettings.Enabled { + clusters = append(clusters, newWaypointPhotoCluster(photo)) + continue + } + + matchingClusterIndex := -1 + for i, cluster := range clusters { + distanceToCenter := util.HaversineDistance(cluster.Lat, cluster.Lon, photo.Lat, photo.Lon) + if distanceToCenter <= mergeSettings.Radius { + matchingClusterIndex = i + break + } + } + + if matchingClusterIndex >= 0 { + addPhotoToWaypointCluster(&clusters[matchingClusterIndex], photo) + } else { + clusters = append(clusters, newWaypointPhotoCluster(photo)) + } + } + + return clusters +} + +func newWaypointPhotoCluster(photo waypointClusterPhoto) waypointPhotoCluster { + return waypointPhotoCluster{ + Photos: []string{photo.ID}, + SumLat: photo.Lat, + SumLon: photo.Lon, + Count: 1, + Lat: photo.Lat, + Lon: photo.Lon, + } +} + +func newWaypointCluster(waypoint waypointClusterWaypoint) waypointPhotoCluster { + return waypointPhotoCluster{ + Waypoint: waypoint.ID, + Photos: []string{}, + SumLat: waypoint.Lat, + SumLon: waypoint.Lon, + Count: 1, + Lat: waypoint.Lat, + Lon: waypoint.Lon, + } +} + +func addPhotoToWaypointCluster(cluster *waypointPhotoCluster, photo waypointClusterPhoto) { + cluster.Photos = append(cluster.Photos, photo.ID) + cluster.SumLat += photo.Lat + cluster.SumLon += photo.Lon + cluster.Count++ + cluster.Lat = cluster.SumLat / float64(cluster.Count) + cluster.Lon = cluster.SumLon / float64(cluster.Count) +} diff --git a/docs/src/content/docs/changelog.md b/docs/src/content/docs/changelog.md index fa9074b1..b0d8d651 100644 --- a/docs/src/content/docs/changelog.md +++ b/docs/src/content/docs/changelog.md @@ -2,6 +2,10 @@ title: Changelog description: What changed in the last patch? --- +## Unreleased +### Features +- Geotagged waypoint photos are now grouped into one waypoint when they are within the category's waypoint merge radius. Existing categories are initialized with waypoint merging enabled and a 50m merge radius; set `settings.wp_merge_enabled` to `false` on a category to keep creating one waypoint per photo. + ## v0.18.5 ### Security - Fixes CVE-2022-39299 via xmldom upgrade @@ -682,4 +686,4 @@ This version updates the index pattern of the meilisearch index. Please delete o - updated the docs to include BODY_SIZE_LIMIT ## v0.1.0 -- Initial release \ No newline at end of file +- Initial release diff --git a/docs/src/content/docs/run/backend-configuration/custom-categories.md b/docs/src/content/docs/run/backend-configuration/custom-categories.md index f4da95fb..6de63a74 100644 --- a/docs/src/content/docs/run/backend-configuration/custom-categories.md +++ b/docs/src/content/docs/run/backend-configuration/custom-categories.md @@ -16,3 +16,24 @@ All existing categories will be listed here. To edit one simply click on the row, edit the data you want to change, and click "Save". To delete a category check the box at the beginning of the row and click "Delete selected". To create a new category click the "New record" button in the top right corner, give your new category a name and a background image, and click "Save". + +## Category settings + +Categories can optionally define additional settings in the `settings` JSON field. +This field may be left empty. +When no settings are configured, wanderer uses the built-in defaults. + +Currently, the following setting is supported: + +```json +{ + "wp_merge_enabled": true, + "wp_merge_radius": 50 +} +``` + +`wp_merge_enabled` controls whether geotagged photos are grouped into waypoint clusters. +Set it to `false` to create one waypoint per photo. + +`wp_merge_radius` controls how close geotagged photos have to be to each other, in meters, before they are grouped into the same waypoint when adding waypoint photos to a trail. +Set it to `0` to only merge photos with the exact same coordinates, or increase the value to merge photos across a wider area. diff --git a/web/src/lib/components/waypoint/waypoint_merge_modal.svelte b/web/src/lib/components/waypoint/waypoint_merge_modal.svelte new file mode 100644 index 00000000..cc906c09 --- /dev/null +++ b/web/src/lib/components/waypoint/waypoint_merge_modal.svelte @@ -0,0 +1,248 @@ + + + + + + {#snippet content()} + {#if merge} +
+
+
+ +
+
+

+ {merge.existing.name || + $_("waypoints", { values: { n: 1 } })} +

+

+ {merge.existing.lat.toFixed(5)}, + {merge.existing.lon.toFixed(5)} +

+
+
+

+ {$_("nearby-waypoint-found-text", { + values: { + name: + merge.existing.name || + $_("waypoints", { values: { n: 1 } }), + }, + })} +

+ { + waypointMergeAction = item.value as "merge" | "create"; + setWaypointMergePreference( + "action", + waypointMergeAction === "merge", + ); + }} + > + {#if waypointMergeAction === "merge"} +
+ {#if merge.incoming._photos?.length} + + {/if} + {#if merge.incoming.name?.trim()} + + {/if} + {#if merge.incoming.description?.trim()} + + {/if} + {#if merge.incoming.icon && + merge.incoming.icon !== merge.existing.icon} + + {/if} +
+ {/if} +
+ {/if} + {/snippet} + {#snippet footer()} +
+ + +
+ {/snippet} +
diff --git a/web/src/lib/components/waypoint/waypoint_modal.svelte b/web/src/lib/components/waypoint/waypoint_modal.svelte index 8182e113..8e5b1353 100644 --- a/web/src/lib/components/waypoint/waypoint_modal.svelte +++ b/web/src/lib/components/waypoint/waypoint_modal.svelte @@ -21,7 +21,7 @@ interface Props { children?: Snippet<[any]>; - onsave?: (waypoint: Waypoint) => void + onsave?: (waypoint: Waypoint) => boolean | Promise | void } let { children, onsave }: Props = $props(); @@ -32,6 +32,10 @@ modal.openModal(); } + export function closeModal() { + modal.closeModal(); + } + const ClientWaypointCreateSchema = WaypointCreateSchema.extend({ _photos: z.array(z.instanceof(File)).optional(), }); @@ -42,9 +46,11 @@ initialValues: $waypoint, extend: validator({ schema: ClientWaypointCreateSchema }), onSubmit: async (form) => { - onsave?.(form); + const shouldClose = await onsave?.(form); - modal.closeModal!(); + if (shouldClose !== false) { + modal.closeModal!(); + } }, transform: (values: unknown) => { const v = values as any; diff --git a/web/src/lib/i18n/locales/cs.json b/web/src/lib/i18n/locales/cs.json index 0ab73098..6ddfbd92 100644 --- a/web/src/lib/i18n/locales/cs.json +++ b/web/src/lib/i18n/locales/cs.json @@ -301,6 +301,7 @@ "no-data": "Žádná data", "no-description-for-now": "Zatím bez popisu", "no-gps-data-in-image": "Obrázek neobsahuje GPS data", + "waypoint-cluster-error": "Could not create waypoint clusters", "no-grid": "Bez mřížky", "no-notifications": "Žádná upozornění", "no-photos-here": "Zde nejsou žádné fotky ani videa", diff --git a/web/src/lib/i18n/locales/de.json b/web/src/lib/i18n/locales/de.json index 39cc5605..e210d153 100644 --- a/web/src/lib/i18n/locales/de.json +++ b/web/src/lib/i18n/locales/de.json @@ -11,6 +11,7 @@ "activity": "{n, plural, =1 {Aktivität} other {Aktivitäten}}", "add-bio": "Bio hinzufügen", "add-entry": "Eintrag hinzufügen", + "add-to-existing-waypoint": "Zu bestehendem Wegpunkt hinzufügen", "add-to-list": "Listen verwalten", "add-waypoint": "Wegpunkt hinzufügen", "added-trail-to": "Route hinzugefügt zu", @@ -26,6 +27,9 @@ "api-documentation": "API Dokumentation", "api-tokens": "", "api-tokens-hint": "", + "append-waypoint-description": "Kommentar anhängen", + "append-waypoint-photos": "Fotos hinzufügen", + "append-waypoint-title": "Titel anhängen", "apply-user-settings": "", "attraction": "Sehenswürdigkeit", "author": "Autor", @@ -84,10 +88,12 @@ "confirm-publish": "Veröffentlichung bestätigen", "confirm-share": "Teilen bestätigen", "connect": "Verbinden", + "continue": "Fortfahren", "contribute": "Mitwirken", "copy-link": "Link kopieren", "create-new-list": "Neue Liste erstellen", "create-waypoint": "Wegpunkt erstellen", + "create-waypoint-anyway": "Trotzdem erstellen", "creation-date": "Erstellungsdatum", "crop": "Zuschneiden", "cross": "Querfeldein", @@ -286,6 +292,8 @@ "n-years-ago": "vor {n} Jahren", "name": "Name", "near": "Nahe", + "nearby-waypoint-found": "Wegpunkt in der Nähe gefunden", + "nearby-waypoint-found-text": "„{name}“ liegt nah genug, um mit diesem Wegpunkt zusammengeführt zu werden. Was möchtest du tun?", "never": "", "new-list": "Neue Liste", "new-password": "Neues Passwort", @@ -301,6 +309,7 @@ "no-data": "Keine Daten", "no-description-for-now": "Noch keine Beschreibung", "no-gps-data-in-image": "Keine GPS-Daten im Bild", + "waypoint-cluster-error": "Wegpunkt-Gruppen konnten nicht erstellt werden", "no-grid": "Kein Gitter", "no-notifications": "Keine Benachrichtigungen", "no-photos-here": "Hier sind noch keine Fotos", @@ -457,6 +466,9 @@ "upload-new-file": "Neue Datei hochladen", "uploaded": "hochgeladen", "uploaded-trail-to-hammerhead": "Route erfolgreich zu Hammerhead hochgeladen", + "use-new-waypoint-icon": "Icon ersetzen", + "use-waypoint-description": "Kommentar übernehmen", + "use-waypoint-title": "Titel übernehmen", "use-hills": "Hügel einbeziehen", "use-roads": "Nutze Straßen", "username": "Nutzername", diff --git a/web/src/lib/i18n/locales/en.json b/web/src/lib/i18n/locales/en.json index d7f0accf..6ec9bd97 100644 --- a/web/src/lib/i18n/locales/en.json +++ b/web/src/lib/i18n/locales/en.json @@ -11,6 +11,7 @@ "activity": "{n, plural, =1 {Activity} other {Activities}}", "add-bio": "Add Bio", "add-entry": "Add Entry", + "add-to-existing-waypoint": "Add to existing waypoint", "add-to-list": "Manage lists", "add-waypoint": "Add Waypoint", "added-trail-to": "Added trail to", @@ -26,6 +27,9 @@ "api-documentation": "API Documentation", "api-tokens": "API Tokens", "api-tokens-hint": "API Tokens can be used to grant 3rd party applications access to your wanderer account.", + "append-waypoint-description": "Append description", + "append-waypoint-photos": "Add photos", + "append-waypoint-title": "Append title", "apply-user-settings": "Apply user settings", "attraction": "Attraction", "author": "Author", @@ -84,10 +88,12 @@ "confirm-publish": "Confirm publishing", "confirm-share": "Confirm share", "connect": "Connect", + "continue": "Continue", "contribute": "Contribute", "copy-link": "Copy Link", "create-new-list": "Create new list", "create-waypoint": "Create waypoint", + "create-waypoint-anyway": "Create anyway", "creation-date": "Creation date", "crop": "Crop", "cross": "Cross", @@ -286,6 +292,8 @@ "n-years-ago": "{n} years ago", "name": "Name", "near": "Near", + "nearby-waypoint-found": "Nearby waypoint found", + "nearby-waypoint-found-text": "\"{name}\" is close enough to merge with this waypoint. What would you like to do?", "never": "Never", "new-list": "New List", "new-password": "New password", @@ -301,6 +309,7 @@ "no-data": "No data", "no-description-for-now": "No description for now", "no-gps-data-in-image": "No GPS data in image", + "waypoint-cluster-error": "Could not create waypoint clusters", "no-grid": "No Grid", "no-notifications": "No notifications", "no-photos-here": "No photos or videos here", @@ -457,6 +466,9 @@ "upload-new-file": "Upload new file", "uploaded": "uploaded", "uploaded-trail-to-hammerhead": "Successfully uploaded trail to Hammerhead", + "use-new-waypoint-icon": "Replace icon", + "use-waypoint-description": "Use description", + "use-waypoint-title": "Use title", "use-hills": "Use hills", "use-roads": "Use Roads", "username": "Username", diff --git a/web/src/lib/i18n/locales/es.json b/web/src/lib/i18n/locales/es.json index 343f5147..17401acf 100644 --- a/web/src/lib/i18n/locales/es.json +++ b/web/src/lib/i18n/locales/es.json @@ -301,6 +301,7 @@ "no-data": "No datos", "no-description-for-now": "Ninguna descripción de momento", "no-gps-data-in-image": "Sin datos GPS en la imagen", + "waypoint-cluster-error": "Could not create waypoint clusters", "no-grid": "Ninguna cuadrícula", "no-notifications": "No notificaciones", "no-photos-here": "No fotos aquí", diff --git a/web/src/lib/i18n/locales/eu.json b/web/src/lib/i18n/locales/eu.json index af020b06..e95dcd0e 100644 --- a/web/src/lib/i18n/locales/eu.json +++ b/web/src/lib/i18n/locales/eu.json @@ -301,6 +301,7 @@ "no-data": "Ez dago daturik", "no-description-for-now": "Ez dago deskribapenik", "no-gps-data-in-image": "Ez dago GPS daturik irudian", + "waypoint-cluster-error": "Could not create waypoint clusters", "no-grid": "Ez dago saretarik", "no-notifications": "Ez dago jakinarazpenik", "no-photos-here": "Ez dago argazki edo bideorik", diff --git a/web/src/lib/i18n/locales/fr.json b/web/src/lib/i18n/locales/fr.json index 1a2ba6b7..4bcb3ff1 100644 --- a/web/src/lib/i18n/locales/fr.json +++ b/web/src/lib/i18n/locales/fr.json @@ -301,6 +301,7 @@ "no-data": "Pas de données", "no-description-for-now": "Pas de description pour le moment", "no-gps-data-in-image": "Aucune donnée GPS dans l'image", + "waypoint-cluster-error": "Could not create waypoint clusters", "no-grid": "Aucune grille", "no-notifications": "Pas de notifications", "no-photos-here": "Aucune photo ici", diff --git a/web/src/lib/i18n/locales/hu.json b/web/src/lib/i18n/locales/hu.json index e08a7aac..9c98d71b 100644 --- a/web/src/lib/i18n/locales/hu.json +++ b/web/src/lib/i18n/locales/hu.json @@ -301,6 +301,7 @@ "no-data": "No data", "no-description-for-now": "No description for now", "no-gps-data-in-image": "No GPS data in image", + "waypoint-cluster-error": "Could not create waypoint clusters", "no-grid": "No Grid", "no-notifications": "No notifications", "no-photos-here": "No photos here", diff --git a/web/src/lib/i18n/locales/it.json b/web/src/lib/i18n/locales/it.json index 7e67dc17..8593d836 100644 --- a/web/src/lib/i18n/locales/it.json +++ b/web/src/lib/i18n/locales/it.json @@ -301,6 +301,7 @@ "no-data": "Nessun dato", "no-description-for-now": "Nessuna descrizione per il momento", "no-gps-data-in-image": "No GPS data in image", + "waypoint-cluster-error": "Could not create waypoint clusters", "no-grid": "Nessuna griglia", "no-notifications": "Nessuna notifica", "no-photos-here": "Nessuna foto qui", diff --git a/web/src/lib/i18n/locales/nl.json b/web/src/lib/i18n/locales/nl.json index 85bee31b..463e5d22 100644 --- a/web/src/lib/i18n/locales/nl.json +++ b/web/src/lib/i18n/locales/nl.json @@ -301,6 +301,7 @@ "no-data": "Geen data", "no-description-for-now": "Voorlopig geen beschrijving", "no-gps-data-in-image": "Geen GPS data in afbeelding", + "waypoint-cluster-error": "Could not create waypoint clusters", "no-grid": "Geen raster", "no-notifications": "Geen meldingen", "no-photos-here": "No photos here", diff --git a/web/src/lib/i18n/locales/no.json b/web/src/lib/i18n/locales/no.json index 2dda1775..3423732a 100644 --- a/web/src/lib/i18n/locales/no.json +++ b/web/src/lib/i18n/locales/no.json @@ -301,6 +301,7 @@ "no-data": "Ingen data", "no-description-for-now": "Ingen beskrivelse ennå", "no-gps-data-in-image": "Ingen GPS-data i bildet", + "waypoint-cluster-error": "Could not create waypoint clusters", "no-grid": "Ingen rutenett", "no-notifications": "Ingen varsler", "no-photos-here": "Ingen bilder eller videoer her", diff --git a/web/src/lib/i18n/locales/pl.json b/web/src/lib/i18n/locales/pl.json index 8fa0084b..28193240 100644 --- a/web/src/lib/i18n/locales/pl.json +++ b/web/src/lib/i18n/locales/pl.json @@ -301,6 +301,7 @@ "no-data": "Brak danych", "no-description-for-now": "Nie ma jeszcze opisu", "no-gps-data-in-image": "No GPS data in image", + "waypoint-cluster-error": "Could not create waypoint clusters", "no-grid": "Brak Siatki", "no-notifications": "Brak powiadomień", "no-photos-here": "Nie ma tu zdjęć", diff --git a/web/src/lib/i18n/locales/pt.json b/web/src/lib/i18n/locales/pt.json index ace21a5c..76817687 100644 --- a/web/src/lib/i18n/locales/pt.json +++ b/web/src/lib/i18n/locales/pt.json @@ -301,6 +301,7 @@ "no-data": "Sem dados", "no-description-for-now": "No description for now", "no-gps-data-in-image": "No GPS data in image", + "waypoint-cluster-error": "Could not create waypoint clusters", "no-grid": "No Grid", "no-notifications": "No notifications", "no-photos-here": "No photos here", diff --git a/web/src/lib/i18n/locales/ru.json b/web/src/lib/i18n/locales/ru.json index 2c419cc9..f1621fa4 100644 --- a/web/src/lib/i18n/locales/ru.json +++ b/web/src/lib/i18n/locales/ru.json @@ -301,6 +301,7 @@ "no-data": "Нет данных", "no-description-for-now": "Пока нет описания", "no-gps-data-in-image": "No GPS data in image", + "waypoint-cluster-error": "Could not create waypoint clusters", "no-grid": "Без сетки", "no-notifications": "Нет уведомлений", "no-photos-here": "Здесь нет фото/видео", diff --git a/web/src/lib/i18n/locales/zh.json b/web/src/lib/i18n/locales/zh.json index 016fdaca..4d589fee 100644 --- a/web/src/lib/i18n/locales/zh.json +++ b/web/src/lib/i18n/locales/zh.json @@ -301,6 +301,7 @@ "no-data": "无数据", "no-description-for-now": "暂无描述", "no-gps-data-in-image": "图像中没有GPS数据", + "waypoint-cluster-error": "Could not create waypoint clusters", "no-grid": "无网格", "no-notifications": "没有通知", "no-photos-here": "No photos here", diff --git a/web/src/lib/models/category.ts b/web/src/lib/models/category.ts index 18d05d7e..cd39a7ea 100644 --- a/web/src/lib/models/category.ts +++ b/web/src/lib/models/category.ts @@ -2,6 +2,13 @@ interface Category { id: string; name: string; img: string; + settings?: Settings | null; } -export type {Category} \ No newline at end of file +interface Settings { + wp_merge_enabled?: boolean; + wp_merge_radius?: number; +} + +export type {Category} +export type {Settings} diff --git a/web/src/lib/stores/trail_store.ts b/web/src/lib/stores/trail_store.ts index 6d04bad6..3f0e1dfe 100644 --- a/web/src/lib/stores/trail_store.ts +++ b/web/src/lib/stores/trail_store.ts @@ -221,17 +221,37 @@ export async function trails_create(trail: Trail, photos: File[], gpx: File | Bl let model: Trail = await r.json(); + const createdSummitLogs: SummitLog[] = []; for (const summitLog of trail.expand?.summit_logs_via_trail ?? []) { summitLog.trail = model.id!; - await summit_logs_create(summitLog, f); + createdSummitLogs.push(await summit_logs_create(summitLog, f)); } + const createdWaypoints: Waypoint[] = []; for (const wp of trail.expand?.waypoints_via_trail ?? []) { wp.trail = model.id!; - await waypoints_create({ + createdWaypoints.push(await waypoints_create({ ...wp, marker: undefined, - }, f, user); + }, f, user)); + } + + if (!model.expand) { + model.expand = {}; + } + + if (createdSummitLogs.length) { + model.expand.summit_logs_via_trail = [ + ...(model.expand.summit_logs_via_trail ?? []), + ...createdSummitLogs, + ]; + } + + if (createdWaypoints.length) { + model.expand.waypoints_via_trail = [ + ...(model.expand.waypoints_via_trail ?? []), + ...createdWaypoints, + ]; } return model; diff --git a/web/src/routes/trail/edit/[id]/+page.svelte b/web/src/routes/trail/edit/[id]/+page.svelte index fc5977f2..8e36a65c 100644 --- a/web/src/routes/trail/edit/[id]/+page.svelte +++ b/web/src/routes/trail/edit/[id]/+page.svelte @@ -10,6 +10,9 @@ import MapWithElevationMaplibre from "$lib/components/trail/map_with_elevation_maplibre.svelte"; import PhotoPicker from "$lib/components/trail/photo_picker.svelte"; import WaypointCard from "$lib/components/waypoint/waypoint_card.svelte"; + import WaypointMergeModal, { + type WaypointMergeOptions, + } from "$lib/components/waypoint/waypoint_merge_modal.svelte"; import WaypointModal from "$lib/components/waypoint/waypoint_modal.svelte"; import { SummitLogCreateSchema } from "$lib/models/api/summit_log_schema.js"; import { TrailCreateSchema } from "$lib/models/api/trail_schema.js"; @@ -74,6 +77,7 @@ import RouteEditor from "$lib/components/trail/route_editor.svelte"; import { TagCreateSchema } from "$lib/models/api/tag_schema.js"; import { convertDMSToDD } from "$lib/models/gpx/utils.js"; + import { getPb } from "$lib/pocketbase"; import { Tag } from "$lib/models/tag.js"; import { searchLocationReverse, @@ -93,10 +97,10 @@ import cryptoRandomString from "crypto-random-string"; import { createForm } from "felte"; import * as M from "maplibre-gl"; - import { onMount, tick, untrack } from "svelte"; + import { onMount, untrack } from "svelte"; import { _ } from "svelte-i18n"; import { backInOut } from "svelte/easing"; - import { fly, slide } from "svelte/transition"; + import { fly } from "svelte/transition"; import { z } from "zod"; import Track from "$lib/models/gpx/track.js"; import TrackSegment from "$lib/models/gpx/track-segment.js"; @@ -110,6 +114,7 @@ let lists = $state(untrack(() => data.lists)); let waypointModal: WaypointModal; + let waypointMergeModal: WaypointMergeModal; let summitLogModal: SummitLogModal; let listSelectModal: ListSearchModal; let markTrailAsCompletedModal: ConfirmModal; @@ -132,6 +137,10 @@ } let overwriteGPX = false; let draggingMarker = false; + + let pendingWaypointMerge: + | { incoming: Waypoint; existing: Waypoint } + | undefined = $state(); let searchDropdownItems: SearchItem[] = $state([]); @@ -466,7 +475,7 @@ // updateTrailOnMap(); } - function saveWaypoint(savedWaypoint: Waypoint) { + function commitWaypoint(savedWaypoint: Waypoint) { let editedWaypointIndex = $formData.expand!.waypoints_via_trail?.findIndex( (s) => s.id == savedWaypoint.id, @@ -486,6 +495,171 @@ } } + function getExistingWaypointClusterInputs() { + return ( + $formData.expand?.waypoints_via_trail + ?.filter((wp) => wp.id) + .map((wp) => ({ + id: wp.id!, + lat: wp.lat, + lon: wp.lon, + })) ?? [] + ); + } + + async function saveWaypoint(savedWaypoint: Waypoint) { + const editedWaypointIndex = + $formData.expand!.waypoints_via_trail?.findIndex( + (s) => s.id == savedWaypoint.id, + ) ?? -1; + + if (editedWaypointIndex >= 0) { + commitWaypoint(savedWaypoint); + return true; + } + + const matchingWaypoint = await findMergeableWaypoint(savedWaypoint); + if (matchingWaypoint) { + pendingWaypointMerge = { + incoming: savedWaypoint, + existing: matchingWaypoint, + }; + waypointModal.closeModal(); + waypointMergeModal.openModal(); + return false; + } + + commitWaypoint(savedWaypoint); + return true; + } + + async function findMergeableWaypoint(savedWaypoint: Waypoint) { + const existingWaypoints = getExistingWaypointClusterInputs(); + + if (!existingWaypoints.length) { + return; + } + + try { + const clusterResponse: WaypointPhotoClusterResponse = + await getPb().send("/waypoint/cluster", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + category: $formData.category, + photos: [ + { + id: waypointMergeCheckPhotoId, + lat: savedWaypoint.lat, + lon: savedWaypoint.lon, + }, + ], + waypoints: existingWaypoints, + }), + }); + + const matchingCluster = clusterResponse.clusters.find( + (cluster) => + cluster.waypoint && + cluster.photos.includes(waypointMergeCheckPhotoId), + ); + + if (!matchingCluster?.waypoint) { + return; + } + + return $formData.expand?.waypoints_via_trail?.find( + (wp) => wp.id === matchingCluster.waypoint, + ); + } catch (e) { + show_toast( + { + type: "error", + icon: "warning", + text: $_("waypoint-cluster-error"), + }, + 10000, + ); + } + } + + function createPendingWaypointAnyway() { + if (!pendingWaypointMerge) { + return; + } + + commitWaypoint(pendingWaypointMerge.incoming); + closeWaypointMergeModal(); + } + + function addPendingWaypointToExisting(options: WaypointMergeOptions) { + if (!pendingWaypointMerge) { + return; + } + + const { incoming, existing } = pendingWaypointMerge; + const mergedWaypoint = { + ...existing, + icon: options.icon ? incoming.icon : existing.icon, + name: options.title + ? appendDistinctText(existing.name, incoming.name, " / ") + : existing.name, + description: options.description + ? appendDistinctText( + existing.description, + incoming.description, + "\n\n", + ) + : existing.description, + photos: existing.photos ?? [], + _photos: options.photos + ? [ + ...((existing as Waypoint)._photos ?? []), + ...(incoming._photos ?? []), + ] + : (existing as Waypoint)._photos, + } as Waypoint; + + closeWaypointMergeModal(); + waypoint.set(mergedWaypoint); + waypointModal.openModal(); + } + + function appendDistinctText( + existing: string | undefined, + incoming: string | undefined, + separator: string, + ) { + const existingText = existing?.trim() ?? ""; + const incomingText = incoming?.trim() ?? ""; + + if (!incomingText || existingText === incomingText) { + return existing ?? ""; + } + + if (!existingText) { + return incomingText; + } + + return `${existingText}${separator}${incomingText}`; + } + + function closeWaypointMergeModal() { + pendingWaypointMerge = undefined; + waypointMergeModal.closeModal(); + } + + function cancelPendingWaypointMerge() { + if (pendingWaypointMerge) { + waypoint.set(pendingWaypointMerge.incoming); + } + + closeWaypointMergeModal(); + waypointModal.openModal(); + } + function moveMarker(marker: M.Marker, wpId?: string) { const position = marker.getLngLat(); const editableWaypointIndex = @@ -563,7 +737,7 @@ } else { list = await lists_add_trail(list, $formData as Trail); } - const index = lists.items.findIndex((l) => l.id == list.id); + const index = lists.items.findIndex((l: List) => l.id == list.id); if (index >= 0) { lists.items[index] = list; } @@ -1129,6 +1303,28 @@ document.getElementById("waypoint-photo-input")!.click(); } + interface GPXCoord { + id: string; + longitude: number; + latitude: number; + file: File; + } + + interface WaypointPhotoCluster { + lat: number; + lon: number; + waypoint?: string; + photos: string[]; + } + + interface WaypointPhotoClusterResponse { + mergeEnabled: boolean; + mergeRadius: number; + clusters: WaypointPhotoCluster[]; + } + + const waypointMergeCheckPhotoId = "__waypoint_merge_check__"; + async function handleWaypointPhotoSelection() { const files = ( document.getElementById("waypoint-photo-input") as HTMLInputElement @@ -1138,8 +1334,10 @@ return; } - for (const file of files) { - const coords = await new Promise((resolve) => { + const photoCoords: GPXCoord[] = []; + + for (const [index, file] of Array.from(files).entries()) { + const coords = await new Promise((resolve) => { EXIF.getData(file, function (p) { const lat = EXIF.getTag(p, "GPSLatitude"); const latDir = EXIF.getTag(p, "GPSLatitudeRef"); @@ -1147,22 +1345,19 @@ const lonDir = EXIF.getTag(p, "GPSLongitudeRef"); if (lat && lon) { - resolve([ - convertDMSToDD(lat, latDir), - convertDMSToDD(lon, lonDir), - ]); + resolve({ + id: index.toString(), + latitude: convertDMSToDD(lat, latDir), + longitude: convertDMSToDD(lon, lonDir), + file, + }); } else { - resolve([]); + resolve(undefined); } }); }); - if (coords.length) { - const wp: Waypoint = new Waypoint(coords[0], coords[1], { - icon: "image", - }); - wp._photos = [file]; - saveWaypoint(wp); - } else { + + if (!coords) { show_toast( { type: "warning", @@ -1171,7 +1366,80 @@ }, 10000, ); + continue; } + + photoCoords.push(coords); + } + + let clusterResponse: WaypointPhotoClusterResponse; + try { + clusterResponse = await getPb().send("/waypoint/cluster", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + category: $formData.category, + photos: photoCoords.map((coords) => ({ + id: coords.id, + lat: coords.latitude, + lon: coords.longitude, + })), + waypoints: getExistingWaypointClusterInputs(), + }), + }); + } catch (e) { + show_toast( + { + type: "error", + icon: "warning", + text: $_("waypoint-cluster-error"), + }, + 10000, + ); + return; + } + + const fileMap = new Map(photoCoords.map((coords) => [coords.id, coords.file])); + + for (const cluster of clusterResponse.clusters) { + const photos = cluster.photos + .map((id) => fileMap.get(id)) + .filter((file): file is File => file != null); + + if (!photos.length) { + continue; + } + + if (cluster.waypoint) { + const existingWaypoint = + $formData.expand?.waypoints_via_trail?.find( + (wp) => wp.id === cluster.waypoint, + ); + + if (existingWaypoint) { + const existingWaypointPhotos = + (existingWaypoint as Waypoint)._photos ?? []; + + commitWaypoint({ + ...existingWaypoint, + photos: existingWaypoint.photos ?? [], + _photos: [...existingWaypointPhotos, ...photos], + } as Waypoint); + continue; + } + } + + const wp: Waypoint = new Waypoint( + cluster.lat, + cluster.lon, + { + icon: photos.length > 1 ? "images" : "image", + }, + ); + wp._photos = photos; + commitWaypoint(wp); } } @@ -1546,6 +1814,13 @@ + saveSummitLog(log)} >