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:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.76.1",
|
||||
"version": "1.77.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { parseGpx, parseGpxImageRefs } from '$lib/gpx';
|
||||
|
||||
export type RoutingProfile = 'hiking-mountain' | 'trekking' | 'road';
|
||||
|
||||
@@ -304,3 +305,145 @@ export function setElevations(elevations: (number | null)[]): void {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GPX import — restores the builder state from a previously-exported GPX so
|
||||
// the user can iterate on an existing route (add a waypoint, retag an
|
||||
// image, fix a turn) without losing the densified track or photo anchors.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ImportGpxResult =
|
||||
| { ok: true; trackName: string | null; waypointCount: number; imageCount: number }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/** Coordinate equality with a small tolerance — float round-trips through
|
||||
* the GPX writer can shift the 7th decimal. 1e-5° ≈ 1 m, well below the
|
||||
* spacing of any meaningful pair of anchors on a hike. */
|
||||
function coordsClose(aLat: number, aLng: number, bLat: number, bLng: number): boolean {
|
||||
return Math.abs(aLat - bLat) < 1e-5 && Math.abs(aLng - bLng) < 1e-5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct the builder state from a GPX XML string.
|
||||
*
|
||||
* Strategy: the exported GPX interleaves user-anchor waypoints with
|
||||
* densified/snapped intermediate trkpts in a single `<trkseg>`. We don't
|
||||
* try to round-trip "manual waypoints" vs "intermediates" perfectly —
|
||||
* instead we recover the *image* anchors (matched against `<wpt>` entries
|
||||
* by coordinate), plus the very first and last trkpts (start + end), and
|
||||
* rebuild routedSegments from the trkpts that fall between each adjacent
|
||||
* anchor pair. Result is an editable route where every photo waypoint is
|
||||
* a draggable handle and the geometry between handles is preserved
|
||||
* verbatim — no re-routing required.
|
||||
*
|
||||
* Replaces the existing draft. Caller should confirm with the user if the
|
||||
* builder is non-empty.
|
||||
*/
|
||||
export function importGpx(xml: string): ImportGpxResult {
|
||||
const trk = parseGpx(xml);
|
||||
if (trk.length < 2) {
|
||||
return { ok: false, error: 'GPX enthält keinen verwertbaren Track (mind. zwei trkpt nötig).' };
|
||||
}
|
||||
const imageRefs = parseGpxImageRefs(xml);
|
||||
const imageList = Object.values(imageRefs);
|
||||
|
||||
// Optional <name> on the track or top-level metadata.
|
||||
const nameMatch =
|
||||
xml.match(/<trk>[\s\S]*?<name>([^<]+)<\/name>[\s\S]*?<\/trk>/i) ??
|
||||
xml.match(/<metadata>[\s\S]*?<name>([^<]+)<\/name>[\s\S]*?<\/metadata>/i);
|
||||
const trackName = nameMatch ? nameMatch[1].trim() : null;
|
||||
|
||||
// Map each image waypoint to its first matching trkpt index. Order the
|
||||
// image anchors by that index so they slot into the builder in
|
||||
// traversal order, not GPX-declaration order.
|
||||
type ImageAnchor = {
|
||||
trkIdx: number;
|
||||
hash: string;
|
||||
visibility: 'public' | 'private';
|
||||
lat: number;
|
||||
lng: number;
|
||||
altitude?: number;
|
||||
timestamp?: number;
|
||||
};
|
||||
const imageAnchors: ImageAnchor[] = [];
|
||||
for (const ref of imageList) {
|
||||
let bestIdx = -1;
|
||||
for (let i = 0; i < trk.length; i++) {
|
||||
if (coordsClose(trk[i].lat, trk[i].lng, ref.lat, ref.lng)) {
|
||||
bestIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (bestIdx < 0) continue; // wpt position doesn't match any trkpt — skip
|
||||
imageAnchors.push({
|
||||
trkIdx: bestIdx,
|
||||
hash: ref.hash,
|
||||
visibility: ref.visibility === 'private' ? 'private' : 'public',
|
||||
lat: ref.lat,
|
||||
lng: ref.lng,
|
||||
altitude: ref.altitude,
|
||||
timestamp: ref.timestamp
|
||||
});
|
||||
}
|
||||
imageAnchors.sort((a, b) => a.trkIdx - b.trkIdx);
|
||||
|
||||
// Build the set of anchor trkpt indices: first, last, all image anchors.
|
||||
const anchorIndices = new Set<number>([0, trk.length - 1]);
|
||||
for (const ia of imageAnchors) anchorIndices.add(ia.trkIdx);
|
||||
const sortedAnchorIdx = [...anchorIndices].sort((a, b) => a - b);
|
||||
|
||||
// Assemble waypoints in traversal order.
|
||||
const newWaypoints: Waypoint[] = sortedAnchorIdx.map((i) => {
|
||||
const t = trk[i];
|
||||
const ia = imageAnchors.find((a) => a.trkIdx === i);
|
||||
const wp: Waypoint = {
|
||||
id: nextWaypointId(),
|
||||
lat: t.lat,
|
||||
lng: t.lng,
|
||||
altitude: typeof t.altitude === 'number' ? t.altitude : ia?.altitude,
|
||||
timestamp: t.timestamp ?? ia?.timestamp ?? null
|
||||
};
|
||||
if (ia) {
|
||||
wp.imageHash = ia.hash;
|
||||
wp.imageVisibility = ia.visibility;
|
||||
}
|
||||
return wp;
|
||||
});
|
||||
|
||||
// Reconstruct routedSegments from the trkpts between consecutive anchors.
|
||||
// Each segment is `[lng, lat, ele?][]` and spans anchor[i] .. anchor[i+1]
|
||||
// inclusive — the GPX writer's reverse operation.
|
||||
const newSegments: Array<Array<[number, number, number?]>> = [];
|
||||
for (let i = 0; i < sortedAnchorIdx.length - 1; i++) {
|
||||
const start = sortedAnchorIdx[i];
|
||||
const end = sortedAnchorIdx[i + 1];
|
||||
const seg: Array<[number, number, number?]> = [];
|
||||
for (let j = start; j <= end; j++) {
|
||||
const t = trk[j];
|
||||
seg.push([t.lng, t.lat, typeof t.altitude === 'number' ? t.altitude : undefined]);
|
||||
}
|
||||
newSegments.push(seg);
|
||||
}
|
||||
|
||||
const newSources: SegmentSource[] = [];
|
||||
for (let i = 0; i < newWaypoints.length - 1; i++) {
|
||||
newSources.push(makeSource(newWaypoints[i], newWaypoints[i + 1]));
|
||||
}
|
||||
|
||||
// Atomic swap.
|
||||
builder.name = trackName ?? builder.name ?? '';
|
||||
// Disable auto-snap so the imported densified/snapped geometry isn't
|
||||
// immediately overwritten by a routing API call.
|
||||
builder.autoSnap = false;
|
||||
builder.waypoints.splice(0, builder.waypoints.length, ...newWaypoints);
|
||||
builder.routedSegments.splice(0, builder.routedSegments.length, ...newSegments);
|
||||
builder.segmentSources.splice(0, builder.segmentSources.length, ...newSources);
|
||||
scheduleSave();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
trackName,
|
||||
waypointCount: newWaypoints.length,
|
||||
imageCount: imageAnchors.length
|
||||
};
|
||||
}
|
||||
|
||||
+123
@@ -4,6 +4,129 @@
|
||||
* GPX export without dragging in Node-only helpers.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GPX parsing (pure, regex-based — no DOM / no XML library, so usable in
|
||||
// build scripts, server endpoints, and the browser alike).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface GpxPoint {
|
||||
lat: number;
|
||||
lng: number;
|
||||
altitude?: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/** Haversine distance in km between two GpxPoints. */
|
||||
export function haversineKm(a: GpxPoint, b: GpxPoint): number {
|
||||
const R = 6371;
|
||||
const dLat = ((b.lat - a.lat) * Math.PI) / 180;
|
||||
const dLng = ((b.lng - a.lng) * Math.PI) / 180;
|
||||
const sinLat = Math.sin(dLat / 2);
|
||||
const sinLng = Math.sin(dLng / 2);
|
||||
const h =
|
||||
sinLat * sinLat +
|
||||
Math.cos((a.lat * Math.PI) / 180) *
|
||||
Math.cos((b.lat * Math.PI) / 180) *
|
||||
sinLng * sinLng;
|
||||
return 2 * R * Math.asin(Math.sqrt(h));
|
||||
}
|
||||
|
||||
/** Sum of consecutive haversine distances in km. */
|
||||
export function trackDistance(track: GpxPoint[]): number {
|
||||
let total = 0;
|
||||
for (let i = 1; i < track.length; i++) {
|
||||
total += haversineKm(track[i - 1], track[i]);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a GPX XML string into an array of GpxPoints.
|
||||
* Extracts `<trkpt>`/`<rtept>` with optional `<ele>` and `<time>`.
|
||||
* Falls back to `Date.now()` when no timestamp is present so downstream
|
||||
* consumers always have a numeric `timestamp` field.
|
||||
*/
|
||||
export function parseGpx(xml: string): GpxPoint[] {
|
||||
const points: GpxPoint[] = [];
|
||||
const trkptRegex = /<(?:trkpt|rtept)\s+lat="([^"]+)"\s+lon="([^"]+)"[^>]*>([\s\S]*?)<\/(?:trkpt|rtept)>/gi;
|
||||
let match;
|
||||
while ((match = trkptRegex.exec(xml)) !== null) {
|
||||
const lat = parseFloat(match[1]);
|
||||
const lng = parseFloat(match[2]);
|
||||
const body = match[3];
|
||||
|
||||
let altitude: number | undefined;
|
||||
const eleMatch = body.match(/<ele>([^<]+)<\/ele>/);
|
||||
if (eleMatch) altitude = parseFloat(eleMatch[1]);
|
||||
|
||||
let timestamp = Date.now();
|
||||
const timeMatch = body.match(/<time>([^<]+)<\/time>/);
|
||||
if (timeMatch) timestamp = new Date(timeMatch[1]).getTime();
|
||||
|
||||
if (!isNaN(lat) && !isNaN(lng)) {
|
||||
points.push({ lat, lng, altitude, timestamp });
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
export interface GpxImageRef {
|
||||
hash: string;
|
||||
name?: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
altitude?: number;
|
||||
timestamp?: number;
|
||||
visibility?: 'public' | 'private';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse standalone `<wpt>` waypoints that carry a `<bocken:image hash="…"/>`
|
||||
* extension. Returned as a hash → ref map so the build script can look up an
|
||||
* image's corrected position by content hash. Waypoints without the image
|
||||
* extension are ignored.
|
||||
*/
|
||||
export function parseGpxImageRefs(xml: string): Record<string, GpxImageRef> {
|
||||
const out: Record<string, GpxImageRef> = {};
|
||||
const wptRegex = /<wpt\s+lat="([^"]+)"\s+lon="([^"]+)"[^>]*>([\s\S]*?)<\/wpt>/gi;
|
||||
let match;
|
||||
while ((match = wptRegex.exec(xml)) !== null) {
|
||||
const lat = parseFloat(match[1]);
|
||||
const lng = parseFloat(match[2]);
|
||||
const body = match[3];
|
||||
// Accept either namespaced (`bocken:image`) or bare (`image`) tags so
|
||||
// the parser tolerates GPX files produced by other tooling that may
|
||||
// drop our custom namespace prefix.
|
||||
const imageMatch = body.match(/<(?:[A-Za-z]+:)?image\s+([^/>]*?)\/?>/i);
|
||||
if (!imageMatch) continue;
|
||||
const attrs = imageMatch[1];
|
||||
const hashAttr = attrs.match(/\bhash="([^"]+)"/i);
|
||||
if (!hashAttr) continue;
|
||||
const hash = hashAttr[1];
|
||||
const visibilityAttr = attrs.match(/\bvisibility="([^"]+)"/i);
|
||||
const visibility: 'public' | 'private' =
|
||||
visibilityAttr && visibilityAttr[1].toLowerCase() === 'private' ? 'private' : 'public';
|
||||
const nameMatch = body.match(/<name>([^<]+)<\/name>/);
|
||||
const eleMatch = body.match(/<ele>([^<]+)<\/ele>/);
|
||||
const timeMatch = body.match(/<time>([^<]+)<\/time>/);
|
||||
if (isNaN(lat) || isNaN(lng)) continue;
|
||||
out[hash] = {
|
||||
hash,
|
||||
name: nameMatch ? nameMatch[1].trim() : undefined,
|
||||
lat,
|
||||
lng,
|
||||
altitude: eleMatch ? parseFloat(eleMatch[1]) : undefined,
|
||||
timestamp: timeMatch ? new Date(timeMatch[1]).getTime() : undefined,
|
||||
visibility
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GPX writing (used by the route-builder export + fitness GPX export).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface GpxWritePoint {
|
||||
lat: number;
|
||||
lng: number;
|
||||
|
||||
+13
-116
@@ -1,119 +1,16 @@
|
||||
/**
|
||||
* Shared GPX helpers used by the fitness API and the hikes build pipeline.
|
||||
* Kept dependency-free so the same module is callable from server routes,
|
||||
* vite-node build scripts, and (where useful) the browser.
|
||||
* Server-side GPX entry point. The parsers themselves are dependency-free
|
||||
* and live in `$lib/gpx` (also imported by the browser-side route-builder
|
||||
* for the import feature). This shim re-exports them under the historical
|
||||
* server-module path so existing callers (build script, fitness API)
|
||||
* don't need to change their imports.
|
||||
*/
|
||||
|
||||
export interface GpxPoint {
|
||||
lat: number;
|
||||
lng: number;
|
||||
altitude?: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/** Haversine distance in km between two points. */
|
||||
export function haversine(a: GpxPoint, b: GpxPoint): number {
|
||||
const R = 6371;
|
||||
const dLat = ((b.lat - a.lat) * Math.PI) / 180;
|
||||
const dLng = ((b.lng - a.lng) * Math.PI) / 180;
|
||||
const sinLat = Math.sin(dLat / 2);
|
||||
const sinLng = Math.sin(dLng / 2);
|
||||
const h =
|
||||
sinLat * sinLat +
|
||||
Math.cos((a.lat * Math.PI) / 180) *
|
||||
Math.cos((b.lat * Math.PI) / 180) *
|
||||
sinLng * sinLng;
|
||||
return 2 * R * Math.asin(Math.sqrt(h));
|
||||
}
|
||||
|
||||
/** Sum of consecutive haversine distances in km. */
|
||||
export function trackDistance(track: GpxPoint[]): number {
|
||||
let total = 0;
|
||||
for (let i = 1; i < track.length; i++) {
|
||||
total += haversine(track[i - 1], track[i]);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a GPX XML string into an array of GpxPoints.
|
||||
* Extracts `<trkpt>`/`<rtept>` with optional `<ele>` and `<time>`.
|
||||
* Falls back to `Date.now()` when no timestamp is present so downstream
|
||||
* consumers always have a numeric `timestamp` field.
|
||||
*/
|
||||
export function parseGpx(xml: string): GpxPoint[] {
|
||||
const points: GpxPoint[] = [];
|
||||
const trkptRegex = /<(?:trkpt|rtept)\s+lat="([^"]+)"\s+lon="([^"]+)"[^>]*>([\s\S]*?)<\/(?:trkpt|rtept)>/gi;
|
||||
let match;
|
||||
while ((match = trkptRegex.exec(xml)) !== null) {
|
||||
const lat = parseFloat(match[1]);
|
||||
const lng = parseFloat(match[2]);
|
||||
const body = match[3];
|
||||
|
||||
let altitude: number | undefined;
|
||||
const eleMatch = body.match(/<ele>([^<]+)<\/ele>/);
|
||||
if (eleMatch) altitude = parseFloat(eleMatch[1]);
|
||||
|
||||
let timestamp = Date.now();
|
||||
const timeMatch = body.match(/<time>([^<]+)<\/time>/);
|
||||
if (timeMatch) timestamp = new Date(timeMatch[1]).getTime();
|
||||
|
||||
if (!isNaN(lat) && !isNaN(lng)) {
|
||||
points.push({ lat, lng, altitude, timestamp });
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
export interface GpxImageRef {
|
||||
hash: string;
|
||||
name?: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
altitude?: number;
|
||||
timestamp?: number;
|
||||
visibility?: 'public' | 'private';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse standalone `<wpt>` waypoints that carry a `<bocken:image hash="…"/>`
|
||||
* extension. Returned as a hash → ref map so the build script can look up an
|
||||
* image's corrected position by content hash. Waypoints without the image
|
||||
* extension are ignored.
|
||||
*/
|
||||
export function parseGpxImageRefs(xml: string): Record<string, GpxImageRef> {
|
||||
const out: Record<string, GpxImageRef> = {};
|
||||
const wptRegex = /<wpt\s+lat="([^"]+)"\s+lon="([^"]+)"[^>]*>([\s\S]*?)<\/wpt>/gi;
|
||||
let match;
|
||||
while ((match = wptRegex.exec(xml)) !== null) {
|
||||
const lat = parseFloat(match[1]);
|
||||
const lng = parseFloat(match[2]);
|
||||
const body = match[3];
|
||||
// Accept either namespaced (`bocken:image`) or bare (`image`) tags so
|
||||
// the parser tolerates GPX files produced by other tooling that may
|
||||
// drop our custom namespace prefix.
|
||||
const imageMatch = body.match(/<(?:[A-Za-z]+:)?image\s+([^/>]*?)\/?>/i);
|
||||
if (!imageMatch) continue;
|
||||
const attrs = imageMatch[1];
|
||||
const hashAttr = attrs.match(/\bhash="([^"]+)"/i);
|
||||
if (!hashAttr) continue;
|
||||
const hash = hashAttr[1];
|
||||
const visibilityAttr = attrs.match(/\bvisibility="([^"]+)"/i);
|
||||
const visibility: 'public' | 'private' =
|
||||
visibilityAttr && visibilityAttr[1].toLowerCase() === 'private' ? 'private' : 'public';
|
||||
const nameMatch = body.match(/<name>([^<]+)<\/name>/);
|
||||
const eleMatch = body.match(/<ele>([^<]+)<\/ele>/);
|
||||
const timeMatch = body.match(/<time>([^<]+)<\/time>/);
|
||||
if (isNaN(lat) || isNaN(lng)) continue;
|
||||
out[hash] = {
|
||||
hash,
|
||||
name: nameMatch ? nameMatch[1].trim() : undefined,
|
||||
lat,
|
||||
lng,
|
||||
altitude: eleMatch ? parseFloat(eleMatch[1]) : undefined,
|
||||
timestamp: timeMatch ? new Date(timeMatch[1]).getTime() : undefined,
|
||||
visibility
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
export {
|
||||
parseGpx,
|
||||
parseGpxImageRefs,
|
||||
trackDistance,
|
||||
haversineKm as haversine,
|
||||
type GpxPoint,
|
||||
type GpxImageRef
|
||||
} from '$lib/gpx';
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user