feat(route-builder): import existing GPX (round-trip editing)

Lets the user re-load a previously-exported GPX and keep iterating
on the same route — add a waypoint, fix a turn, retag an image —
without rebuilding from scratch.

The exported GPX interleaves user-anchor waypoints with densified /
snapped intermediates in a single `<trkseg>`. The importer doesn't
try to perfectly round-trip "manual waypoint vs intermediate";
instead it recovers the *image* anchors by matching `<wpt>`
coordinates against the trkpt sequence (1e-5° tolerance, ≈1 m),
plus the start + end trkpts, and reconstructs routedSegments from
the trkpts between adjacent anchors. The intermediate geometry is
preserved verbatim — no re-routing, no second elevation pass.

Image waypoints carry their `imageHash` + `imageVisibility` across
the round-trip so the build script can still re-attach the source
JPEGs on the next publish. Visual previews from those hashes are
deferred to a follow-up — for now an image anchor renders as a
hash-only badge in the waypoint table.

Auto-snap is forced off after import so the freshly-loaded geometry
isn't immediately overwritten by a routing API call. UI: a "GPX
laden" link-style button next to the existing Reset, confirms
before replacing a non-empty draft.

The pure parsers (`parseGpx`, `parseGpxImageRefs`) move from
`$lib/server/gpx` to `$lib/gpx` so the browser-side importer can
use them; the server module re-exports for back-compat.
This commit is contained in:
2026-05-19 17:29:34 +02:00
parent 7b7fbed472
commit 59f40b9f05
5 changed files with 337 additions and 118 deletions
+57 -1
View File
@@ -11,7 +11,8 @@
setElevations,
clearDraft,
reconcileSegments,
densifyLinearSegments
densifyLinearSegments,
importGpx
} from '$lib/components/hikes/route-builder/builderStore.svelte';
let busy = $state(false);
@@ -201,6 +202,46 @@
URL.revokeObjectURL(url);
}
// GPX import: file input is hidden; the visible "GPX laden" button
// proxies its click. Imported route REPLACES the current draft, so
// confirm first when there's existing work to avoid silent data loss.
let gpxFileInput: HTMLInputElement | undefined = $state();
function openGpxPicker() {
if (
builder.waypoints.length > 0 &&
!confirm(
'Bestehenden Entwurf durch importierte GPX ersetzen? Aktuelle Wegpunkte gehen verloren.'
)
) {
return;
}
gpxFileInput?.click();
}
async function onGpxSelected(e: Event) {
const input = e.currentTarget as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
try {
const xml = await file.text();
const result = importGpx(xml);
if (!result.ok) {
error = result.error;
return;
}
error = null;
// Cancel any in-flight enrichment so it doesn't overwrite the
// freshly-imported geometry.
routeRequestId++;
} catch (err) {
error = `GPX-Import fehlgeschlagen: ${(err as Error).message}`;
} finally {
// Reset so the same file can be re-selected later.
input.value = '';
}
}
// Placement coordination: which unplaced waypoint is currently waiting for
// a click on the map?
let pendingPlacementId = $state<string | null>(null);
@@ -255,6 +296,21 @@
>
GPX herunterladen
</button>
<button
type="button"
class="link"
onclick={openGpxPicker}
title="Eine zuvor exportierte GPX-Datei in den Editor laden"
>
GPX laden
</button>
<input
bind:this={gpxFileInput}
type="file"
accept=".gpx,application/gpx+xml,application/xml,text/xml"
onchange={onGpxSelected}
hidden
/>
<button type="button" class="link" onclick={clearDraft}>Zurücksetzen</button>
</div>
</header>