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

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