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 <Flomp@users.noreply.github.com>
This commit is contained in:
slothful-vassal
2026-04-29 11:15:06 +02:00
committed by GitHub
parent 85e5fb6df7
commit 6d1d1236ef
28 changed files with 929 additions and 28 deletions

2
.gitignore vendored
View File

@@ -11,4 +11,4 @@ run.sh
build*.sh
start*.*
data*/
data*/

View File

@@ -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 Im ver
- updated the docs to include BODY_SIZE_LIMIT
# v0.1.0
- Initial release
- Initial release

View File

@@ -6,4 +6,6 @@
!main.go
!migrations
!templates
!waypointcluster
!waypointcluster/**
!util

View File

@@ -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)

View File

@@ -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)
})
}

14
db/util/geo.go Normal file
View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
- Initial release

View File

@@ -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, <span class="-tracking-[0.075em]">wanderer</span> 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.

View File

@@ -0,0 +1,248 @@
<script module lang="ts">
import type { Waypoint } from "$lib/models/waypoint";
export type WaypointMerge = {
incoming: Waypoint;
existing: Waypoint;
};
export type WaypointMergeOptions = {
photos: boolean;
title: boolean;
description: boolean;
icon: boolean;
};
</script>
<script lang="ts">
import { browser } from "$app/environment";
import { _ } from "svelte-i18n";
import Modal from "../base/modal.svelte";
import RadioGroup, { type RadioItem } from "../base/radio_group.svelte";
interface Props {
merge?: WaypointMerge;
oncreate?: () => void;
onmerge?: (options: WaypointMergeOptions) => void;
oncancel?: () => void;
}
let { merge, oncreate, onmerge, oncancel }: Props = $props();
const waypointMergeActionItems: RadioItem[] = [
{
text: $_("create-waypoint-anyway"),
value: "create",
},
{
text: $_("add-to-existing-waypoint"),
value: "merge",
},
];
let modal: Modal;
let waypointMergeAction: "merge" | "create" = $state("create");
let waypointMergeActionIndex = $derived(
waypointMergeActionItems.findIndex(
(item) => item.value === waypointMergeAction,
),
);
let appendWaypointPhotos = $state(true);
let appendWaypointTitle = $state(true);
let appendWaypointDescription = $state(true);
let appendWaypointIcon = $state(false);
export function openModal() {
loadWaypointMergePreferences();
modal.openModal();
}
export function closeModal() {
modal.closeModal();
}
function saveDecision() {
if (waypointMergeAction === "create") {
oncreate?.();
return;
}
onmerge?.({
photos: appendWaypointPhotos,
title: appendWaypointTitle,
description: appendWaypointDescription,
icon: appendWaypointIcon,
});
}
function loadWaypointMergePreferences() {
waypointMergeAction = getWaypointMergePreference("action", false)
? "merge"
: "create";
appendWaypointPhotos = getWaypointMergePreference("photos", true);
appendWaypointTitle = getWaypointMergePreference("title", true);
appendWaypointDescription = getWaypointMergePreference(
"description",
true,
);
appendWaypointIcon = getWaypointMergePreference("icon", false);
}
function getWaypointMergePreference(key: string, fallback: boolean) {
if (!browser) {
return fallback;
}
const value = localStorage.getItem(`waypoint-merge-${key}`);
if (value == null) {
return fallback;
}
return value === "true";
}
function setWaypointMergePreference(key: string, value: boolean) {
if (browser) {
localStorage.setItem(`waypoint-merge-${key}`, value.toString());
}
}
</script>
<Modal
id="waypoint-merge-modal"
title={$_("nearby-waypoint-found")}
bind:this={modal}
>
{#snippet content()}
{#if merge}
<div class="space-y-4">
<div
class="flex items-center gap-3 rounded-md border border-input-border p-3"
>
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-surface"
>
<i class="fa fa-{merge.existing.icon ?? 'circle'}"></i>
</div>
<div>
<p class="font-medium">
{merge.existing.name ||
$_("waypoints", { values: { n: 1 } })}
</p>
<p class="text-sm text-gray-500">
{merge.existing.lat.toFixed(5)},
{merge.existing.lon.toFixed(5)}
</p>
</div>
</div>
<p>
{$_("nearby-waypoint-found-text", {
values: {
name:
merge.existing.name ||
$_("waypoints", { values: { n: 1 } }),
},
})}
</p>
<RadioGroup
name="waypoint-merge-action"
items={waypointMergeActionItems}
selected={waypointMergeActionIndex}
onchange={(item) => {
waypointMergeAction = item.value as "merge" | "create";
setWaypointMergePreference(
"action",
waypointMergeAction === "merge",
);
}}
></RadioGroup>
{#if waypointMergeAction === "merge"}
<div class="space-y-2 pl-6">
{#if merge.incoming._photos?.length}
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={appendWaypointPhotos}
onchange={() =>
setWaypointMergePreference(
"photos",
appendWaypointPhotos,
)}
/>
<span>{$_("append-waypoint-photos")}</span>
</label>
{/if}
{#if merge.incoming.name?.trim()}
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={appendWaypointTitle}
onchange={() =>
setWaypointMergePreference(
"title",
appendWaypointTitle,
)}
/>
<span>
{merge.existing.name?.trim()
? $_("append-waypoint-title")
: $_("use-waypoint-title")}
</span>
</label>
{/if}
{#if merge.incoming.description?.trim()}
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={appendWaypointDescription}
onchange={() =>
setWaypointMergePreference(
"description",
appendWaypointDescription,
)}
/>
<span>
{merge.existing.description?.trim()
? $_("append-waypoint-description")
: $_("use-waypoint-description")}
</span>
</label>
{/if}
{#if merge.incoming.icon &&
merge.incoming.icon !== merge.existing.icon}
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={appendWaypointIcon}
onchange={() =>
setWaypointMergePreference(
"icon",
appendWaypointIcon,
)}
/>
<span>
{$_("use-new-waypoint-icon")}
<i
class="fa fa-{merge.incoming.icon} ml-1"
></i>
</span>
</label>
{/if}
</div>
{/if}
</div>
{/if}
{/snippet}
{#snippet footer()}
<div class="flex flex-wrap items-center gap-4">
<button class="btn-secondary" type="button" onclick={oncancel}
>{$_("cancel")}</button
>
<button class="btn-primary" type="button" onclick={saveDecision}
>{waypointMergeAction === "create"
? $_("save")
: $_("continue")}</button
>
</div>
{/snippet}
</Modal>

View File

@@ -21,7 +21,7 @@
interface Props {
children?: Snippet<[any]>;
onsave?: (waypoint: Waypoint) => void
onsave?: (waypoint: Waypoint) => boolean | Promise<boolean> | 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;

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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í",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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ęć",

View File

@@ -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",

View File

@@ -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": "Здесь нет фото/видео",

View File

@@ -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",

View File

@@ -2,6 +2,13 @@ interface Category {
id: string;
name: string;
img: string;
settings?: Settings | null;
}
export type {Category}
interface Settings {
wp_merge_enabled?: boolean;
wp_merge_radius?: number;
}
export type {Category}
export type {Settings}

View File

@@ -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;

View File

@@ -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<number[]>((resolve) => {
const photoCoords: GPXCoord[] = [];
for (const [index, file] of Array.from(files).entries()) {
const coords = await new Promise<GPXCoord | undefined>((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 @@
</div>
</main>
<WaypointModal bind:this={waypointModal} onsave={saveWaypoint}></WaypointModal>
<WaypointMergeModal
merge={pendingWaypointMerge}
bind:this={waypointMergeModal}
oncreate={createPendingWaypointAnyway}
onmerge={addPendingWaypointToExisting}
oncancel={cancelPendingWaypointMerge}
></WaypointMergeModal>
<SummitLogModal bind:this={summitLogModal} onsave={(log) => saveSummitLog(log)}
></SummitLogModal>
<ListSearchModal