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