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

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