feat(route-builder): match dropped images to imported hash-only waypoints

After a GPX import the image-anchor waypoints carry just their
content hash (no thumbnail). Drop the source JPEGs into the same
image dropzone and they're now hashed and matched against existing
waypoints first — on a hit, the thumbnail + full-resolution cache
are patched onto the matched row instead of creating a duplicate
waypoint marker on the map.

A blue 'Bildvorschau ergänzt' status row + contextual hint at the
top of the dropzone ('N Bild-Wegpunkte warten auf eine Vorschau —
Originale hier ablegen') makes the round-trip workflow discoverable.

Existing fresh-upload behaviour (new image → new chronologically-
inserted waypoint with EXIF GPS) is unchanged for any image whose
hash isn't already known to the builder.
This commit is contained in:
2026-05-19 17:36:38 +02:00
parent 59f40b9f05
commit 3b524e9c70
2 changed files with 70 additions and 7 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.77.0",
"version": "1.77.1",
"private": true,
"type": "module",
"scripts": {
@@ -6,11 +6,15 @@
scheduleSave,
type Waypoint
} from './builderStore.svelte';
// `untrack` keeps the in-loop `builder.waypoints.find(...)` from
// registering as a dep on a non-reactive call site, avoiding effect
// loops when we patch the matched waypoint's `thumbnail`.
import { untrack } from 'svelte';
import { generateImageHashClient } from '$lib/imageHashClient';
import { readThumbnail } from './imageThumbnail';
import { setFullImage } from './fullImageCache.svelte';
type Status = 'pending' | 'placed' | 'unplaced' | 'error';
type Status = 'pending' | 'placed' | 'unplaced' | 'matched' | 'error';
type Entry = {
id: string;
@@ -22,8 +26,17 @@
let entries = $state<Entry[]>([]);
let isDragging = $state(false);
// Counts hash-only image waypoints (typically restored from a GPX
// import) that don't yet have a thumbnail — surfaces a contextual
// hint in the dropzone header so the user knows that dropping the
// source JPEGs here will attach previews to those rows in the table.
const orphanImageCount = $derived(
builder.waypoints.filter((w) => w.imageHash && !w.thumbnail).length
);
type Prepared =
| { ok: true; wp: Waypoint; hasGps: boolean; id: string; file: File }
| { ok: true; kind: 'new'; wp: Waypoint; hasGps: boolean; id: string; file: File }
| { ok: true; kind: 'matched'; id: string; file: File }
| { ok: false };
async function handleFiles(files: File[]) {
@@ -49,6 +62,30 @@
thumbnail = await readThumbnail(file);
} catch { /* preview is optional */ }
const imageHash = await generateImageHashClient(file);
// Match path: if a previously-imported (or earlier-dropped)
// waypoint already carries this content hash, attach the
// thumbnail to it instead of creating a duplicate marker.
// Covers the GPX-roundtrip flow where the user loads an
// existing GPX (image hashes restored as bare waypoints)
// and then drops the source images to give them previews.
const existing = untrack(() =>
builder.waypoints.find((w) => w.imageHash === imageHash)
);
if (existing) {
if (thumbnail && !existing.thumbnail) existing.thumbnail = thumbnail;
// Trust the imported visibility if the existing waypoint
// already has one set — re-dropping shouldn't silently
// flip a private photo to public.
if (!existing.imageVisibility) existing.imageVisibility = 'public';
scheduleSave();
entries[entryIdx].status = 'matched';
entries[entryIdx].message = existing.unplaced
? 'noch nicht auf der Karte platziert'
: undefined;
return { ok: true, kind: 'matched', id: existing.id, file };
}
const timestamp =
exif?.DateTimeOriginal instanceof Date ? exif.DateTimeOriginal.getTime() : null;
@@ -82,7 +119,7 @@
};
entries[entryIdx].status = hasGps ? 'placed' : 'unplaced';
return { ok: true, wp, hasGps, id, file };
return { ok: true, kind: 'new', wp, hasGps, id, file };
} catch (err) {
entries[entryIdx].status = 'error';
entries[entryIdx].message = (err as Error).message;
@@ -100,10 +137,11 @@
// returning 0 even with workaround attempts.
for (const p of prepared) {
if (!p.ok) continue;
insertWaypointChronologically(p.wp);
if (p.kind === 'new') insertWaypointChronologically(p.wp);
// Cache the original file so the waypoint table can show a
// full-resolution preview this session. Persistence to
// localStorage keeps only the small thumbnail.
// full-resolution preview this session (for both new + matched
// waypoints). Persistence to localStorage keeps only the small
// thumbnail.
setFullImage(p.id, p.file);
}
}
@@ -151,6 +189,14 @@
erscheinen in der Wegpunkt-Liste und können dort auf der Karte platziert
werden. Die Bilder verlassen dein Gerät nicht.
</p>
{#if orphanImageCount > 0}
<p class="hint import-hint">
<strong>{orphanImageCount}</strong>
{orphanImageCount === 1 ? 'Bild-Wegpunkt' : 'Bild-Wegpunkte'} aus der
geladenen GPX warten auf eine Vorschau — die Original-Bilder hier ablegen,
um sie über den Inhalts-Hash automatisch zuzuordnen.
</p>
{/if}
</header>
<label class="file-input">
@@ -167,6 +213,7 @@
<span class="msg">
{#if e.status === 'pending'}wird gelesen…
{:else if e.status === 'placed'}✓ chronologisch platziert
{:else if e.status === 'matched'}✓ Bildvorschau ergänzt{e.message ? ` (${e.message})` : ''}
{:else if e.status === 'unplaced'}⚠ Position fehlt — in Liste platzieren
{:else if e.status === 'error'}Fehler: {e.message ?? 'unbekannt'}
{/if}
@@ -205,6 +252,20 @@
color: var(--color-text-tertiary);
}
.import-hint {
margin: 0 0 0.75rem;
padding: 0.5rem 0.75rem;
background: color-mix(in oklab, var(--blue) 12%, var(--color-surface));
border-left: 3px solid var(--blue);
border-radius: var(--radius-sm);
color: var(--color-text-secondary);
}
.import-hint strong {
color: var(--blue);
font-variant-numeric: tabular-nums;
}
.file-input {
display: block;
text-align: center;
@@ -254,6 +315,7 @@
}
.status-placed .dot { background: var(--green); }
.status-matched .dot { background: var(--blue); }
.status-unplaced .dot { background: var(--orange); }
.status-error .dot { background: var(--red); }
.status-pending .dot {
@@ -283,6 +345,7 @@
.status-error .msg { color: var(--red); }
.status-unplaced .msg { color: var(--orange); }
.status-placed .msg { color: var(--green); }
.status-matched .msg { color: var(--blue); }
.dismiss {
appearance: none;