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

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 # v0.18.5
## Security ## Security
- Fixes CVE-2022-39299 via xmldom upgrade (PR #820) - Fixes CVE-2022-39299 via xmldom upgrade (PR #820)

View File

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

View File

@@ -31,6 +31,7 @@ import (
_ "pocketbase/migrations" _ "pocketbase/migrations"
"pocketbase/util" "pocketbase/util"
"pocketbase/waypointcluster"
pub "github.com/go-ap/activitypub" pub "github.com/go-ap/activitypub"
"github.com/microcosm-cc/bluemonday" "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"}) 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 { se.Router.POST("/auth/token", func(e *core.RequestEvent) error {
var data struct { var data struct {
APIToken string `json:"api_token"` APIToken string `json:"api_token"`
@@ -1488,6 +1491,10 @@ func bootstrapCategories(app core.App) error {
for _, element := range categories { for _, element := range categories {
record := core.NewRecord(collection) record := core.NewRecord(collection)
record.Set("name", element) 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") f, _ := filesystem.NewFileFromPath("migrations/initial_data/" + strings.ToLower(element) + ".jpg")
record.Set("img", f) record.Set("img", f)
err := app.Save(record) 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 title: Changelog
description: What changed in the last patch? 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 ## v0.18.5
### Security ### Security
- Fixes CVE-2022-39299 via xmldom upgrade - Fixes CVE-2022-39299 via xmldom upgrade

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 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 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". 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 { interface Props {
children?: Snippet<[any]>; children?: Snippet<[any]>;
onsave?: (waypoint: Waypoint) => void onsave?: (waypoint: Waypoint) => boolean | Promise<boolean> | void
} }
let { children, onsave }: Props = $props(); let { children, onsave }: Props = $props();
@@ -32,6 +32,10 @@
modal.openModal(); modal.openModal();
} }
export function closeModal() {
modal.closeModal();
}
const ClientWaypointCreateSchema = WaypointCreateSchema.extend({ const ClientWaypointCreateSchema = WaypointCreateSchema.extend({
_photos: z.array(z.instanceof(File)).optional(), _photos: z.array(z.instanceof(File)).optional(),
}); });
@@ -42,9 +46,11 @@
initialValues: $waypoint, initialValues: $waypoint,
extend: validator({ schema: ClientWaypointCreateSchema }), extend: validator({ schema: ClientWaypointCreateSchema }),
onSubmit: async (form) => { onSubmit: async (form) => {
onsave?.(form); const shouldClose = await onsave?.(form);
modal.closeModal!(); if (shouldClose !== false) {
modal.closeModal!();
}
}, },
transform: (values: unknown) => { transform: (values: unknown) => {
const v = values as any; const v = values as any;

View File

@@ -301,6 +301,7 @@
"no-data": "Žádná data", "no-data": "Žádná data",
"no-description-for-now": "Zatím bez popisu", "no-description-for-now": "Zatím bez popisu",
"no-gps-data-in-image": "Obrázek neobsahuje GPS data", "no-gps-data-in-image": "Obrázek neobsahuje GPS data",
"waypoint-cluster-error": "Could not create waypoint clusters",
"no-grid": "Bez mřížky", "no-grid": "Bez mřížky",
"no-notifications": "Žádná upozornění", "no-notifications": "Žádná upozornění",
"no-photos-here": "Zde nejsou žádné fotky ani videa", "no-photos-here": "Zde nejsou žádné fotky ani videa",

View File

@@ -11,6 +11,7 @@
"activity": "{n, plural, =1 {Aktivität} other {Aktivitäten}}", "activity": "{n, plural, =1 {Aktivität} other {Aktivitäten}}",
"add-bio": "Bio hinzufügen", "add-bio": "Bio hinzufügen",
"add-entry": "Eintrag hinzufügen", "add-entry": "Eintrag hinzufügen",
"add-to-existing-waypoint": "Zu bestehendem Wegpunkt hinzufügen",
"add-to-list": "Listen verwalten", "add-to-list": "Listen verwalten",
"add-waypoint": "Wegpunkt hinzufügen", "add-waypoint": "Wegpunkt hinzufügen",
"added-trail-to": "Route hinzugefügt zu", "added-trail-to": "Route hinzugefügt zu",
@@ -26,6 +27,9 @@
"api-documentation": "API Dokumentation", "api-documentation": "API Dokumentation",
"api-tokens": "", "api-tokens": "",
"api-tokens-hint": "", "api-tokens-hint": "",
"append-waypoint-description": "Kommentar anhängen",
"append-waypoint-photos": "Fotos hinzufügen",
"append-waypoint-title": "Titel anhängen",
"apply-user-settings": "", "apply-user-settings": "",
"attraction": "Sehenswürdigkeit", "attraction": "Sehenswürdigkeit",
"author": "Autor", "author": "Autor",
@@ -84,10 +88,12 @@
"confirm-publish": "Veröffentlichung bestätigen", "confirm-publish": "Veröffentlichung bestätigen",
"confirm-share": "Teilen bestätigen", "confirm-share": "Teilen bestätigen",
"connect": "Verbinden", "connect": "Verbinden",
"continue": "Fortfahren",
"contribute": "Mitwirken", "contribute": "Mitwirken",
"copy-link": "Link kopieren", "copy-link": "Link kopieren",
"create-new-list": "Neue Liste erstellen", "create-new-list": "Neue Liste erstellen",
"create-waypoint": "Wegpunkt erstellen", "create-waypoint": "Wegpunkt erstellen",
"create-waypoint-anyway": "Trotzdem erstellen",
"creation-date": "Erstellungsdatum", "creation-date": "Erstellungsdatum",
"crop": "Zuschneiden", "crop": "Zuschneiden",
"cross": "Querfeldein", "cross": "Querfeldein",
@@ -286,6 +292,8 @@
"n-years-ago": "vor {n} Jahren", "n-years-ago": "vor {n} Jahren",
"name": "Name", "name": "Name",
"near": "Nahe", "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": "", "never": "",
"new-list": "Neue Liste", "new-list": "Neue Liste",
"new-password": "Neues Passwort", "new-password": "Neues Passwort",
@@ -301,6 +309,7 @@
"no-data": "Keine Daten", "no-data": "Keine Daten",
"no-description-for-now": "Noch keine Beschreibung", "no-description-for-now": "Noch keine Beschreibung",
"no-gps-data-in-image": "Keine GPS-Daten im Bild", "no-gps-data-in-image": "Keine GPS-Daten im Bild",
"waypoint-cluster-error": "Wegpunkt-Gruppen konnten nicht erstellt werden",
"no-grid": "Kein Gitter", "no-grid": "Kein Gitter",
"no-notifications": "Keine Benachrichtigungen", "no-notifications": "Keine Benachrichtigungen",
"no-photos-here": "Hier sind noch keine Fotos", "no-photos-here": "Hier sind noch keine Fotos",
@@ -457,6 +466,9 @@
"upload-new-file": "Neue Datei hochladen", "upload-new-file": "Neue Datei hochladen",
"uploaded": "hochgeladen", "uploaded": "hochgeladen",
"uploaded-trail-to-hammerhead": "Route erfolgreich zu Hammerhead 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-hills": "Hügel einbeziehen",
"use-roads": "Nutze Straßen", "use-roads": "Nutze Straßen",
"username": "Nutzername", "username": "Nutzername",

View File

@@ -11,6 +11,7 @@
"activity": "{n, plural, =1 {Activity} other {Activities}}", "activity": "{n, plural, =1 {Activity} other {Activities}}",
"add-bio": "Add Bio", "add-bio": "Add Bio",
"add-entry": "Add Entry", "add-entry": "Add Entry",
"add-to-existing-waypoint": "Add to existing waypoint",
"add-to-list": "Manage lists", "add-to-list": "Manage lists",
"add-waypoint": "Add Waypoint", "add-waypoint": "Add Waypoint",
"added-trail-to": "Added trail to", "added-trail-to": "Added trail to",
@@ -26,6 +27,9 @@
"api-documentation": "API Documentation", "api-documentation": "API Documentation",
"api-tokens": "API Tokens", "api-tokens": "API Tokens",
"api-tokens-hint": "API Tokens can be used to grant 3rd party applications access to your wanderer account.", "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", "apply-user-settings": "Apply user settings",
"attraction": "Attraction", "attraction": "Attraction",
"author": "Author", "author": "Author",
@@ -84,10 +88,12 @@
"confirm-publish": "Confirm publishing", "confirm-publish": "Confirm publishing",
"confirm-share": "Confirm share", "confirm-share": "Confirm share",
"connect": "Connect", "connect": "Connect",
"continue": "Continue",
"contribute": "Contribute", "contribute": "Contribute",
"copy-link": "Copy Link", "copy-link": "Copy Link",
"create-new-list": "Create new list", "create-new-list": "Create new list",
"create-waypoint": "Create waypoint", "create-waypoint": "Create waypoint",
"create-waypoint-anyway": "Create anyway",
"creation-date": "Creation date", "creation-date": "Creation date",
"crop": "Crop", "crop": "Crop",
"cross": "Cross", "cross": "Cross",
@@ -286,6 +292,8 @@
"n-years-ago": "{n} years ago", "n-years-ago": "{n} years ago",
"name": "Name", "name": "Name",
"near": "Near", "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", "never": "Never",
"new-list": "New List", "new-list": "New List",
"new-password": "New password", "new-password": "New password",
@@ -301,6 +309,7 @@
"no-data": "No data", "no-data": "No data",
"no-description-for-now": "No description for now", "no-description-for-now": "No description for now",
"no-gps-data-in-image": "No GPS data in image", "no-gps-data-in-image": "No GPS data in image",
"waypoint-cluster-error": "Could not create waypoint clusters",
"no-grid": "No Grid", "no-grid": "No Grid",
"no-notifications": "No notifications", "no-notifications": "No notifications",
"no-photos-here": "No photos or videos here", "no-photos-here": "No photos or videos here",
@@ -457,6 +466,9 @@
"upload-new-file": "Upload new file", "upload-new-file": "Upload new file",
"uploaded": "uploaded", "uploaded": "uploaded",
"uploaded-trail-to-hammerhead": "Successfully uploaded trail to Hammerhead", "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-hills": "Use hills",
"use-roads": "Use Roads", "use-roads": "Use Roads",
"username": "Username", "username": "Username",

View File

@@ -301,6 +301,7 @@
"no-data": "No datos", "no-data": "No datos",
"no-description-for-now": "Ninguna descripción de momento", "no-description-for-now": "Ninguna descripción de momento",
"no-gps-data-in-image": "Sin datos GPS en la imagen", "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-grid": "Ninguna cuadrícula",
"no-notifications": "No notificaciones", "no-notifications": "No notificaciones",
"no-photos-here": "No fotos aquí", "no-photos-here": "No fotos aquí",

View File

@@ -301,6 +301,7 @@
"no-data": "Ez dago daturik", "no-data": "Ez dago daturik",
"no-description-for-now": "Ez dago deskribapenik", "no-description-for-now": "Ez dago deskribapenik",
"no-gps-data-in-image": "Ez dago GPS daturik irudian", "no-gps-data-in-image": "Ez dago GPS daturik irudian",
"waypoint-cluster-error": "Could not create waypoint clusters",
"no-grid": "Ez dago saretarik", "no-grid": "Ez dago saretarik",
"no-notifications": "Ez dago jakinarazpenik", "no-notifications": "Ez dago jakinarazpenik",
"no-photos-here": "Ez dago argazki edo bideorik", "no-photos-here": "Ez dago argazki edo bideorik",

View File

@@ -301,6 +301,7 @@
"no-data": "Pas de données", "no-data": "Pas de données",
"no-description-for-now": "Pas de description pour le moment", "no-description-for-now": "Pas de description pour le moment",
"no-gps-data-in-image": "Aucune donnée GPS dans l'image", "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-grid": "Aucune grille",
"no-notifications": "Pas de notifications", "no-notifications": "Pas de notifications",
"no-photos-here": "Aucune photo ici", "no-photos-here": "Aucune photo ici",

View File

@@ -301,6 +301,7 @@
"no-data": "No data", "no-data": "No data",
"no-description-for-now": "No description for now", "no-description-for-now": "No description for now",
"no-gps-data-in-image": "No GPS data in image", "no-gps-data-in-image": "No GPS data in image",
"waypoint-cluster-error": "Could not create waypoint clusters",
"no-grid": "No Grid", "no-grid": "No Grid",
"no-notifications": "No notifications", "no-notifications": "No notifications",
"no-photos-here": "No photos here", "no-photos-here": "No photos here",

View File

@@ -301,6 +301,7 @@
"no-data": "Nessun dato", "no-data": "Nessun dato",
"no-description-for-now": "Nessuna descrizione per il momento", "no-description-for-now": "Nessuna descrizione per il momento",
"no-gps-data-in-image": "No GPS data in image", "no-gps-data-in-image": "No GPS data in image",
"waypoint-cluster-error": "Could not create waypoint clusters",
"no-grid": "Nessuna griglia", "no-grid": "Nessuna griglia",
"no-notifications": "Nessuna notifica", "no-notifications": "Nessuna notifica",
"no-photos-here": "Nessuna foto qui", "no-photos-here": "Nessuna foto qui",

View File

@@ -301,6 +301,7 @@
"no-data": "Geen data", "no-data": "Geen data",
"no-description-for-now": "Voorlopig geen beschrijving", "no-description-for-now": "Voorlopig geen beschrijving",
"no-gps-data-in-image": "Geen GPS data in afbeelding", "no-gps-data-in-image": "Geen GPS data in afbeelding",
"waypoint-cluster-error": "Could not create waypoint clusters",
"no-grid": "Geen raster", "no-grid": "Geen raster",
"no-notifications": "Geen meldingen", "no-notifications": "Geen meldingen",
"no-photos-here": "No photos here", "no-photos-here": "No photos here",

View File

@@ -301,6 +301,7 @@
"no-data": "Ingen data", "no-data": "Ingen data",
"no-description-for-now": "Ingen beskrivelse ennå", "no-description-for-now": "Ingen beskrivelse ennå",
"no-gps-data-in-image": "Ingen GPS-data i bildet", "no-gps-data-in-image": "Ingen GPS-data i bildet",
"waypoint-cluster-error": "Could not create waypoint clusters",
"no-grid": "Ingen rutenett", "no-grid": "Ingen rutenett",
"no-notifications": "Ingen varsler", "no-notifications": "Ingen varsler",
"no-photos-here": "Ingen bilder eller videoer her", "no-photos-here": "Ingen bilder eller videoer her",

View File

@@ -301,6 +301,7 @@
"no-data": "Brak danych", "no-data": "Brak danych",
"no-description-for-now": "Nie ma jeszcze opisu", "no-description-for-now": "Nie ma jeszcze opisu",
"no-gps-data-in-image": "No GPS data in image", "no-gps-data-in-image": "No GPS data in image",
"waypoint-cluster-error": "Could not create waypoint clusters",
"no-grid": "Brak Siatki", "no-grid": "Brak Siatki",
"no-notifications": "Brak powiadomień", "no-notifications": "Brak powiadomień",
"no-photos-here": "Nie ma tu zdjęć", "no-photos-here": "Nie ma tu zdjęć",

View File

@@ -301,6 +301,7 @@
"no-data": "Sem dados", "no-data": "Sem dados",
"no-description-for-now": "No description for now", "no-description-for-now": "No description for now",
"no-gps-data-in-image": "No GPS data in image", "no-gps-data-in-image": "No GPS data in image",
"waypoint-cluster-error": "Could not create waypoint clusters",
"no-grid": "No Grid", "no-grid": "No Grid",
"no-notifications": "No notifications", "no-notifications": "No notifications",
"no-photos-here": "No photos here", "no-photos-here": "No photos here",

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", "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-data": "无数据",
"no-description-for-now": "暂无描述", "no-description-for-now": "暂无描述",
"no-gps-data-in-image": "图像中没有GPS数据", "no-gps-data-in-image": "图像中没有GPS数据",
"waypoint-cluster-error": "Could not create waypoint clusters",
"no-grid": "无网格", "no-grid": "无网格",
"no-notifications": "没有通知", "no-notifications": "没有通知",
"no-photos-here": "No photos here", "no-photos-here": "No photos here",

View File

@@ -2,6 +2,13 @@ interface Category {
id: string; id: string;
name: string; name: string;
img: string; img: string;
settings?: Settings | null;
}
interface Settings {
wp_merge_enabled?: boolean;
wp_merge_radius?: number;
} }
export type {Category} 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(); let model: Trail = await r.json();
const createdSummitLogs: SummitLog[] = [];
for (const summitLog of trail.expand?.summit_logs_via_trail ?? []) { for (const summitLog of trail.expand?.summit_logs_via_trail ?? []) {
summitLog.trail = model.id!; 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 ?? []) { for (const wp of trail.expand?.waypoints_via_trail ?? []) {
wp.trail = model.id!; wp.trail = model.id!;
await waypoints_create({ createdWaypoints.push(await waypoints_create({
...wp, ...wp,
marker: undefined, 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; return model;

View File

@@ -10,6 +10,9 @@
import MapWithElevationMaplibre from "$lib/components/trail/map_with_elevation_maplibre.svelte"; import MapWithElevationMaplibre from "$lib/components/trail/map_with_elevation_maplibre.svelte";
import PhotoPicker from "$lib/components/trail/photo_picker.svelte"; import PhotoPicker from "$lib/components/trail/photo_picker.svelte";
import WaypointCard from "$lib/components/waypoint/waypoint_card.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 WaypointModal from "$lib/components/waypoint/waypoint_modal.svelte";
import { SummitLogCreateSchema } from "$lib/models/api/summit_log_schema.js"; import { SummitLogCreateSchema } from "$lib/models/api/summit_log_schema.js";
import { TrailCreateSchema } from "$lib/models/api/trail_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 RouteEditor from "$lib/components/trail/route_editor.svelte";
import { TagCreateSchema } from "$lib/models/api/tag_schema.js"; import { TagCreateSchema } from "$lib/models/api/tag_schema.js";
import { convertDMSToDD } from "$lib/models/gpx/utils.js"; import { convertDMSToDD } from "$lib/models/gpx/utils.js";
import { getPb } from "$lib/pocketbase";
import { Tag } from "$lib/models/tag.js"; import { Tag } from "$lib/models/tag.js";
import { import {
searchLocationReverse, searchLocationReverse,
@@ -93,10 +97,10 @@
import cryptoRandomString from "crypto-random-string"; import cryptoRandomString from "crypto-random-string";
import { createForm } from "felte"; import { createForm } from "felte";
import * as M from "maplibre-gl"; import * as M from "maplibre-gl";
import { onMount, tick, untrack } from "svelte"; import { onMount, untrack } from "svelte";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { backInOut } from "svelte/easing"; import { backInOut } from "svelte/easing";
import { fly, slide } from "svelte/transition"; import { fly } from "svelte/transition";
import { z } from "zod"; import { z } from "zod";
import Track from "$lib/models/gpx/track.js"; import Track from "$lib/models/gpx/track.js";
import TrackSegment from "$lib/models/gpx/track-segment.js"; import TrackSegment from "$lib/models/gpx/track-segment.js";
@@ -110,6 +114,7 @@
let lists = $state(untrack(() => data.lists)); let lists = $state(untrack(() => data.lists));
let waypointModal: WaypointModal; let waypointModal: WaypointModal;
let waypointMergeModal: WaypointMergeModal;
let summitLogModal: SummitLogModal; let summitLogModal: SummitLogModal;
let listSelectModal: ListSearchModal; let listSelectModal: ListSearchModal;
let markTrailAsCompletedModal: ConfirmModal; let markTrailAsCompletedModal: ConfirmModal;
@@ -133,6 +138,10 @@
let overwriteGPX = false; let overwriteGPX = false;
let draggingMarker = false; let draggingMarker = false;
let pendingWaypointMerge:
| { incoming: Waypoint; existing: Waypoint }
| undefined = $state();
let searchDropdownItems: SearchItem[] = $state([]); let searchDropdownItems: SearchItem[] = $state([]);
let cropStartMarker: FontawesomeMarker; let cropStartMarker: FontawesomeMarker;
@@ -466,7 +475,7 @@
// updateTrailOnMap(); // updateTrailOnMap();
} }
function saveWaypoint(savedWaypoint: Waypoint) { function commitWaypoint(savedWaypoint: Waypoint) {
let editedWaypointIndex = let editedWaypointIndex =
$formData.expand!.waypoints_via_trail?.findIndex( $formData.expand!.waypoints_via_trail?.findIndex(
(s) => s.id == savedWaypoint.id, (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) { function moveMarker(marker: M.Marker, wpId?: string) {
const position = marker.getLngLat(); const position = marker.getLngLat();
const editableWaypointIndex = const editableWaypointIndex =
@@ -563,7 +737,7 @@
} else { } else {
list = await lists_add_trail(list, $formData as Trail); 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) { if (index >= 0) {
lists.items[index] = list; lists.items[index] = list;
} }
@@ -1129,6 +1303,28 @@
document.getElementById("waypoint-photo-input")!.click(); 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() { async function handleWaypointPhotoSelection() {
const files = ( const files = (
document.getElementById("waypoint-photo-input") as HTMLInputElement document.getElementById("waypoint-photo-input") as HTMLInputElement
@@ -1138,8 +1334,10 @@
return; return;
} }
for (const file of files) { const photoCoords: GPXCoord[] = [];
const coords = await new Promise<number[]>((resolve) => {
for (const [index, file] of Array.from(files).entries()) {
const coords = await new Promise<GPXCoord | undefined>((resolve) => {
EXIF.getData(file, function (p) { EXIF.getData(file, function (p) {
const lat = EXIF.getTag(p, "GPSLatitude"); const lat = EXIF.getTag(p, "GPSLatitude");
const latDir = EXIF.getTag(p, "GPSLatitudeRef"); const latDir = EXIF.getTag(p, "GPSLatitudeRef");
@@ -1147,22 +1345,19 @@
const lonDir = EXIF.getTag(p, "GPSLongitudeRef"); const lonDir = EXIF.getTag(p, "GPSLongitudeRef");
if (lat && lon) { if (lat && lon) {
resolve([ resolve({
convertDMSToDD(lat, latDir), id: index.toString(),
convertDMSToDD(lon, lonDir), latitude: convertDMSToDD(lat, latDir),
]); longitude: convertDMSToDD(lon, lonDir),
file,
});
} else { } else {
resolve([]); resolve(undefined);
} }
}); });
}); });
if (coords.length) {
const wp: Waypoint = new Waypoint(coords[0], coords[1], { if (!coords) {
icon: "image",
});
wp._photos = [file];
saveWaypoint(wp);
} else {
show_toast( show_toast(
{ {
type: "warning", type: "warning",
@@ -1171,7 +1366,80 @@
}, },
10000, 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> </div>
</main> </main>
<WaypointModal bind:this={waypointModal} onsave={saveWaypoint}></WaypointModal> <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 bind:this={summitLogModal} onsave={(log) => saveSummitLog(log)}
></SummitLogModal> ></SummitLogModal>
<ListSearchModal <ListSearchModal