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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user