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",
|
"name": "homepage",
|
||||||
"version": "1.77.0",
|
"version": "1.77.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -6,11 +6,15 @@
|
|||||||
scheduleSave,
|
scheduleSave,
|
||||||
type Waypoint
|
type Waypoint
|
||||||
} from './builderStore.svelte';
|
} 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 { generateImageHashClient } from '$lib/imageHashClient';
|
||||||
import { readThumbnail } from './imageThumbnail';
|
import { readThumbnail } from './imageThumbnail';
|
||||||
import { setFullImage } from './fullImageCache.svelte';
|
import { setFullImage } from './fullImageCache.svelte';
|
||||||
|
|
||||||
type Status = 'pending' | 'placed' | 'unplaced' | 'error';
|
type Status = 'pending' | 'placed' | 'unplaced' | 'matched' | 'error';
|
||||||
|
|
||||||
type Entry = {
|
type Entry = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -22,8 +26,17 @@
|
|||||||
let entries = $state<Entry[]>([]);
|
let entries = $state<Entry[]>([]);
|
||||||
let isDragging = $state(false);
|
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 =
|
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 };
|
| { ok: false };
|
||||||
|
|
||||||
async function handleFiles(files: File[]) {
|
async function handleFiles(files: File[]) {
|
||||||
@@ -49,6 +62,30 @@
|
|||||||
thumbnail = await readThumbnail(file);
|
thumbnail = await readThumbnail(file);
|
||||||
} catch { /* preview is optional */ }
|
} catch { /* preview is optional */ }
|
||||||
const imageHash = await generateImageHashClient(file);
|
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 =
|
const timestamp =
|
||||||
exif?.DateTimeOriginal instanceof Date ? exif.DateTimeOriginal.getTime() : null;
|
exif?.DateTimeOriginal instanceof Date ? exif.DateTimeOriginal.getTime() : null;
|
||||||
|
|
||||||
@@ -82,7 +119,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
entries[entryIdx].status = hasGps ? 'placed' : 'unplaced';
|
entries[entryIdx].status = hasGps ? 'placed' : 'unplaced';
|
||||||
return { ok: true, wp, hasGps, id, file };
|
return { ok: true, kind: 'new', wp, hasGps, id, file };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
entries[entryIdx].status = 'error';
|
entries[entryIdx].status = 'error';
|
||||||
entries[entryIdx].message = (err as Error).message;
|
entries[entryIdx].message = (err as Error).message;
|
||||||
@@ -100,10 +137,11 @@
|
|||||||
// returning 0 even with workaround attempts.
|
// returning 0 even with workaround attempts.
|
||||||
for (const p of prepared) {
|
for (const p of prepared) {
|
||||||
if (!p.ok) continue;
|
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
|
// Cache the original file so the waypoint table can show a
|
||||||
// full-resolution preview this session. Persistence to
|
// full-resolution preview this session (for both new + matched
|
||||||
// localStorage keeps only the small thumbnail.
|
// waypoints). Persistence to localStorage keeps only the small
|
||||||
|
// thumbnail.
|
||||||
setFullImage(p.id, p.file);
|
setFullImage(p.id, p.file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,6 +189,14 @@
|
|||||||
erscheinen in der Wegpunkt-Liste und können dort auf der Karte platziert
|
erscheinen in der Wegpunkt-Liste und können dort auf der Karte platziert
|
||||||
werden. Die Bilder verlassen dein Gerät nicht.
|
werden. Die Bilder verlassen dein Gerät nicht.
|
||||||
</p>
|
</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>
|
</header>
|
||||||
|
|
||||||
<label class="file-input">
|
<label class="file-input">
|
||||||
@@ -167,6 +213,7 @@
|
|||||||
<span class="msg">
|
<span class="msg">
|
||||||
{#if e.status === 'pending'}wird gelesen…
|
{#if e.status === 'pending'}wird gelesen…
|
||||||
{:else if e.status === 'placed'}✓ chronologisch platziert
|
{: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 === 'unplaced'}⚠ Position fehlt — in Liste platzieren
|
||||||
{:else if e.status === 'error'}Fehler: {e.message ?? 'unbekannt'}
|
{:else if e.status === 'error'}Fehler: {e.message ?? 'unbekannt'}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -205,6 +252,20 @@
|
|||||||
color: var(--color-text-tertiary);
|
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 {
|
.file-input {
|
||||||
display: block;
|
display: block;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -254,6 +315,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status-placed .dot { background: var(--green); }
|
.status-placed .dot { background: var(--green); }
|
||||||
|
.status-matched .dot { background: var(--blue); }
|
||||||
.status-unplaced .dot { background: var(--orange); }
|
.status-unplaced .dot { background: var(--orange); }
|
||||||
.status-error .dot { background: var(--red); }
|
.status-error .dot { background: var(--red); }
|
||||||
.status-pending .dot {
|
.status-pending .dot {
|
||||||
@@ -283,6 +345,7 @@
|
|||||||
.status-error .msg { color: var(--red); }
|
.status-error .msg { color: var(--red); }
|
||||||
.status-unplaced .msg { color: var(--orange); }
|
.status-unplaced .msg { color: var(--orange); }
|
||||||
.status-placed .msg { color: var(--green); }
|
.status-placed .msg { color: var(--green); }
|
||||||
|
.status-matched .msg { color: var(--blue); }
|
||||||
|
|
||||||
.dismiss {
|
.dismiss {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
|
|||||||
Reference in New Issue
Block a user