feat(route-builder): stats bar, waypoint detail panel, elevation refactor

Work-in-progress route-builder checkpoint:

- New RouteStatsBar and WaypointDetailPanel components.
- EditMap / ImageDropzone / WaypointTable / builderStore updates.
- Hoist the elevation gain/loss/range helpers out of build-hikes.ts into
  src/lib/hikes/elevation.ts so the builder and the build share one
  implementation.

Also bundled here (same file, couldn't be split cleanly): build-hikes.ts
now detects each hike's country at build time — 'CH' when a Swiss canton
matched, otherwise an OSM/Nominatim reverse-geocode — and writes it to the
manifest, feeding the new Kanton/Land filter.
This commit is contained in:
2026-05-22 13:07:24 +02:00
parent 53695b8244
commit 603240bf93
10 changed files with 2064 additions and 473 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.80.0",
"version": "1.81.0",
"private": true,
"type": "module",
"scripts": {
+38 -63
View File
@@ -29,6 +29,7 @@ import {
import { simplifyTrack } from '../src/lib/server/simplifyTrack.js';
import { computeStaticMapPose, renderOverviewMap, renderStaticMap } from './staticHikeMap.js';
import { sacTrailColor, SAC_TRAIL_COLOR } from '../src/lib/data/sacColors.js';
import { computeElevationStats, computeElevationRange } from '../src/lib/hikes/elevation.js';
import type {
Difficulty,
HikeManifestEntry,
@@ -55,8 +56,6 @@ const CACHE_DIR = path.join(ROOT, 'scripts', '.cache');
const GEOCODE_CACHE_FILE = path.join(CACHE_DIR, 'hikes-geocode.json');
const MANIFEST_OUT = path.join(ROOT, 'src', 'lib', 'data', 'hikes.generated.ts');
const ELEV_SMOOTH_WINDOW = 5; // moving-average window for altitude denoising
const ELEV_MIN_STEP_M = 3; // discard altitude deltas below this
const PREVIEW_POLYLINE_MAX_POINTS = 150;
const IMAGE_WIDTHS = [480, 960, 1600] as const;
const IMAGE_THUMBNAIL_WIDTH = 240; // popup thumbnail for map markers
@@ -184,67 +183,9 @@ function parseSeasonRange(raw: unknown): { seasonStart: number | null; seasonEnd
}
// ---------------------------------------------------------------------------
// Elevation helpers
// Bounding box / centroid
// ---------------------------------------------------------------------------
// Returns `null` for indices where no defined altitude exists in the ±half
// window. The previous behaviour (defaulting to 0) silently turned missing
// `<ele>` tags into huge synthetic gain spikes against the next real altitude.
function smoothAltitudes(track: GpxPoint[]): (number | null)[] {
const n = track.length;
const out = new Array<number | null>(n);
const half = Math.floor(ELEV_SMOOTH_WINDOW / 2);
for (let i = 0; i < n; i++) {
let sum = 0;
let count = 0;
for (let j = Math.max(0, i - half); j <= Math.min(n - 1, i + half); j++) {
const a = track[j].altitude;
if (typeof a === 'number') {
sum += a;
count++;
}
}
out[i] = count > 0 ? sum / count : null;
}
return out;
}
function computeElevationStats(track: GpxPoint[]): { gain: number; loss: number } {
if (track.length < 2) return { gain: 0, loss: 0 };
const altitudes = smoothAltitudes(track);
let gain = 0;
let loss = 0;
let prev: number | null = null;
for (const a of altitudes) {
if (a === null) continue;
if (prev === null) {
prev = a;
continue;
}
const diff = a - prev;
if (diff >= ELEV_MIN_STEP_M) {
gain += diff;
prev = a;
} else if (diff <= -ELEV_MIN_STEP_M) {
loss += -diff;
prev = a;
}
}
return { gain: Math.round(gain), loss: Math.round(loss) };
}
function computeElevationRange(track: GpxPoint[]): { min: number | null; max: number | null } {
let min = Infinity;
let max = -Infinity;
for (const p of track) {
if (typeof p.altitude !== 'number') continue;
if (p.altitude < min) min = p.altitude;
if (p.altitude > max) max = p.altitude;
}
if (!Number.isFinite(min) || !Number.isFinite(max)) return { min: null, max: null };
return { min: Math.round(min), max: Math.round(max) };
}
function computeBboxAndCentroid(track: GpxPoint[]): {
bbox: [number, number, number, number];
centroid: [number, number];
@@ -274,6 +215,9 @@ type GeocodeResult = {
canton: string | null;
municipality: string | null;
region: string | null;
/** ISO 3166-1 alpha-2 code. 'CH' whenever a Swiss canton matched;
* otherwise resolved via an OSM/Nominatim country lookup. */
country: string | null;
};
type GeocodeCache = Record<string, GeocodeResult>;
@@ -293,6 +237,31 @@ async function saveGeocodeCache(cache: GeocodeCache): Promise<void> {
}
const SWISSTOPO_UA = 'bocken-homepage build-hikes';
const NOMINATIM_UA = 'bocken-homepage build-hikes (https://bocken.org)';
/**
* Country detection for hikes outside Switzerland. Swisstopo only covers CH,
* so when no canton matched we ask OSM/Nominatim for the country at the
* centroid. Returns an uppercase ISO 3166-1 alpha-2 code, or null on failure.
*/
async function reverseGeocodeCountry(lat: number, lng: number): Promise<string | null> {
const url =
`https://nominatim.openstreetmap.org/reverse?format=jsonv2` +
`&lat=${lat}&lon=${lng}&zoom=3&addressdetails=1`;
try {
const res = await fetch(url, { headers: { 'User-Agent': NOMINATIM_UA } });
if (!res.ok) {
console.warn(`[build-hikes] Nominatim country lookup failed (${res.status})`);
return null;
}
const json = (await res.json()) as { address?: { country_code?: string } };
const cc = json.address?.country_code;
return typeof cc === 'string' ? cc.toUpperCase() : null;
} catch (err) {
console.warn('[build-hikes] Nominatim country lookup error:', err);
return null;
}
}
async function fetchFeatureName(layerBodId: string, featureId: number | string): Promise<string | null> {
const url = `https://api3.geo.admin.ch/rest/services/api/MapServer/${layerBodId}/${featureId}/htmlPopup?lang=de`;
@@ -315,7 +284,8 @@ async function reverseGeocode(
cache: GeocodeCache
): Promise<GeocodeResult> {
const key = `${lat.toFixed(5)},${lng.toFixed(5)}`;
if (cache[key]) return cache[key];
// `country` post-dates the cache format — re-resolve entries that predate it.
if (cache[key] && cache[key].country !== undefined) return cache[key];
const layers =
'all:ch.swisstopo.swissboundaries3d-kanton-flaeche.fill,' +
@@ -332,7 +302,7 @@ async function reverseGeocode(
`&mapExtent=${lng - eps},${lat - eps},${lng + eps},${lat + eps}` +
`&tolerance=1&layers=${layers}&sr=4326`;
const result: GeocodeResult = { canton: null, municipality: null, region: null };
const result: GeocodeResult = { canton: null, municipality: null, region: null, country: null };
try {
const res = await fetch(url, { headers: { 'User-Agent': SWISSTOPO_UA } });
if (res.ok) {
@@ -359,6 +329,10 @@ async function reverseGeocode(
console.warn(`[build-hikes] Swisstopo identify error for ${key}:`, err);
}
// Country: 'CH' when a Swiss canton matched (no extra request needed),
// otherwise an OSM/Nominatim lookup for hikes abroad.
result.country = result.canton ? 'CH' : await reverseGeocodeCountry(lat, lng);
cache[key] = result;
return result;
}
@@ -1185,6 +1159,7 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise<HikeManifes
region: geo.region,
canton: geo.canton,
municipality: geo.municipality,
country: geo.country,
trackUrl: `/hikes/${slug}/track.${trackHash}.json`,
pointCount: track.length,
cover,
@@ -37,6 +37,54 @@
const DEFAULT_CENTER: [number, number] = [46.8, 8.3];
const DEFAULT_ZOOM = 8;
const TRACK_COLOR = SAC_TRAIL_COLOR.T2;
const ACCENT_COLOR = '#2965c8'; // SAC T4 blue — used for the focused-marker accent ring
function escapeAttr(s: string): string {
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
}
// Pin geometry:
// - Solo pin: 28 wide × 36 tall, head r=10 at (14,14), tip at (14,36).
// - Image pin: 44 wide × 52 tall, head r=15 (clip) inside r=18 (frame)
// at (22,22), tip at (22,52).
// Both anchor at the tip so `iconAnchor = [width/2, height]`.
function makePinIcon(num: number, opts: { active: boolean }) {
const ring = opts.active ? ACCENT_COLOR : 'white';
const ringWidth = opts.active ? 3 : 2;
const html = `
<svg viewBox="0 0 28 36" width="28" height="36" class="rb-pin solo${opts.active ? ' is-active' : ''}" aria-hidden="true">
<path d="M14 36 L5.1 18.5 A10 10 0 1 1 22.9 18.5 Z"
fill="${TRACK_COLOR}" stroke="${ring}" stroke-width="${ringWidth}" stroke-linejoin="round" />
<text x="14" y="17.6" text-anchor="middle" font-size="11" font-weight="700"
fill="white" font-family="ui-sans-serif,system-ui,Helvetica,Arial,sans-serif">${num}</text>
</svg>`;
return { html, size: [28, 36] as [number, number], anchor: [14, 36] as [number, number] };
}
function makeImagePinIcon(num: number, thumb: string, opts: { active: boolean }) {
const safeThumb = escapeAttr(thumb);
const ring = opts.active ? ACCENT_COLOR : TRACK_COLOR;
const ringWidth = opts.active ? 3 : 2.5;
const clipId = `rb-pin-head-${Math.random().toString(36).slice(2, 8)}`;
const html = `
<svg viewBox="0 0 44 52" width="44" height="52" class="rb-pin image${opts.active ? ' is-active' : ''}" aria-hidden="true">
<defs>
<clipPath id="${clipId}"><circle cx="22" cy="22" r="15" /></clipPath>
</defs>
<path d="M22 52 L7.6 32.8 A18 18 0 1 1 36.4 32.8 Z"
fill="white" stroke="${ring}" stroke-width="${ringWidth}" stroke-linejoin="round" />
<image href="${safeThumb}" x="7" y="7" width="30" height="30"
clip-path="url(#${clipId})" preserveAspectRatio="xMidYMid slice" />
<g transform="translate(34 9)">
<circle r="7.5" fill="${ring}" stroke="white" stroke-width="1.5" />
<text y="3" text-anchor="middle" font-size="9" font-weight="700" fill="white"
font-family="ui-sans-serif,system-ui,Helvetica,Arial,sans-serif">${num}</text>
</g>
</svg>`;
return { html, size: [44, 52] as [number, number], anchor: [22, 52] as [number, number] };
}
const editAttachment: Attachment<HTMLElement> = (node) => {
let cancelled = false;
let cleanup: (() => void) | undefined;
@@ -61,20 +109,20 @@
const markerLayer = L.layerGroup().addTo(map);
const lineLayer = L.layerGroup().addTo(map);
function makeNumberedIcon(num: number, thumbnail?: string) {
if (thumbnail) {
return L.divIcon({
className: 'rb-waypoint with-thumb',
html: `<span class="thumb"><img src="${thumbnail}" alt="" /></span><span class="num">${num}</span>`,
iconSize: [56, 56],
iconAnchor: [28, 28]
});
}
// Map of waypointId → marker, kept in sync by render(). Used by the
// focus effect so it can pan/zoom + style the marker for `mapView.focusId`
// without forcing a full re-render of every marker.
const markerByWp = new Map<string, ReturnType<typeof L.marker>>();
function buildIcon(num: number, wp: { thumbnail?: string }, active: boolean) {
const spec = wp.thumbnail
? makeImagePinIcon(num, wp.thumbnail, { active })
: makePinIcon(num, { active });
return L.divIcon({
className: 'rb-waypoint',
html: `<span class="num solo">${num}</span>`,
iconSize: [28, 28],
iconAnchor: [14, 28]
html: spec.html,
iconSize: spec.size,
iconAnchor: spec.anchor
});
}
@@ -104,6 +152,7 @@
function render() {
markerLayer.clearLayers();
lineLayer.clearLayers();
markerByWp.clear();
// Markers per waypoint. Skip unplaced ones — they don't have a
// usable lat/lng and live only in the waypoint table.
@@ -112,11 +161,16 @@
if (w.unplaced) return;
placedIndices.push(idx);
});
const focusId = mapView.focusId;
placedIndices.forEach((idx, displayPos) => {
const w = builder.waypoints[idx];
const seqNum = displayPos + 1;
const marker = L.marker([w.lat, w.lng], {
icon: makeNumberedIcon(displayPos + 1, w.thumbnail),
draggable: true
icon: buildIcon(seqNum, w, w.id === focusId),
draggable: true,
// Lift the focused marker above its neighbours so its accent
// ring isn't covered by an adjacent unfocused pin.
zIndexOffset: w.id === focusId ? 1000 : 0
}).addTo(markerLayer);
marker.on('dragend', () => {
const p = marker.getLatLng();
@@ -132,6 +186,11 @@
scheduleSave();
render();
});
marker.on('click', () => {
mapView.focusId = w.id;
mapView.focusTick++;
});
markerByWp.set(w.id, marker);
});
// Lines: per-pair so each can carry a segIdx for inline insertion.
@@ -139,12 +198,11 @@
// no need to call out the difference, the user picked the mode.
// SAC white-red-white red — matches /hikes overview + detail-page
// trail colour so the live preview reads as the final published track.
const trackColor = SAC_TRAIL_COLOR.T2;
if (builder.routedSegments.length > 0) {
builder.routedSegments.forEach((seg, segIdx) => {
const latLngs = seg.map((p) => [p[1], p[0]] as [number, number]);
const poly = L.polyline(latLngs, {
color: trackColor,
color: TRACK_COLOR,
weight: 4,
opacity: 0.9
}).addTo(lineLayer);
@@ -174,28 +232,51 @@
map.fitBounds(L.latLngBounds(points), { padding: [40, 40] });
}
function focusOnWaypoint(id: string | null) {
if (!id) return;
const wp = builder.waypoints.find((w) => w.id === id);
if (!wp || wp.unplaced) return;
// Zoom in but don't over-zoom — 16 reads as "this trail junction"
// without losing surrounding context. flyTo gives smooth motion.
const targetZoom = Math.max(map.getZoom(), 16);
map.flyTo([wp.lat, wp.lng], targetZoom, { duration: 0.6 });
}
// React to store changes.
const stopRoot = $effect.root(() => {
$effect(() => {
// Touch each reactive field so we re-render on any mutation.
// Touch each reactive field so we re-render on any mutation,
// including focus changes (so the active marker re-styles).
builder.waypoints.length;
for (const w of builder.waypoints) {
w.lat; w.lng; w.thumbnail;
}
builder.routedSegments.length;
mapView.focusId;
render();
});
// External fit-bounds requests (image drops, GPX imports).
// The map's own init-time auto-fit covers first-load; this
// effect handles every subsequent batch insertion.
let lastTick = mapView.fitTick;
let lastFitTick = mapView.fitTick;
$effect(() => {
const tick = mapView.fitTick;
if (tick === lastTick) return;
lastTick = tick;
if (tick === lastFitTick) return;
lastFitTick = tick;
fitToTrack();
});
// Focus requests (table row "fokussieren", prev/next nav bar).
// Tick is bumped on every request even if the id stays the same
// so repeated clicks re-center even if the user panned away.
let lastFocusTick = mapView.focusTick;
$effect(() => {
const tick = mapView.focusTick;
if (tick === lastFocusTick) return;
lastFocusTick = tick;
focusOnWaypoint(mapView.focusId);
});
});
// Click on blank map. In normal mode, append a new waypoint at the end.
@@ -263,7 +344,7 @@
.edit-map {
width: 100%;
height: 600px;
height: 640px;
border-radius: var(--radius-card);
overflow: hidden;
box-shadow: var(--shadow-md);
@@ -272,7 +353,7 @@
@media (max-width: 900px) {
.edit-map {
height: 480px;
height: 520px;
}
}
@@ -311,58 +392,40 @@
cursor: pointer;
}
/* DON'T override `position` here — Leaflet sets `.leaflet-marker-icon` to
* `position: absolute` for placement, and the inner `.num` badge relies on
* that same ancestor as its abs-positioning context. Reassigning to
* `position: relative` causes markers to fall into normal flow and stack
* vertically instead of sitting at their lat/lng. */
/* Leaflet wraps each marker in `.leaflet-marker-icon` with its own
* absolute positioning. We just neutralise its default frame/background
* so the SVG pin shows through cleanly. */
:global(.rb-waypoint) {
background: transparent !important;
border: 0 !important;
}
:global(.rb-waypoint .num) {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 0.4em;
background: var(--color-primary);
color: var(--color-text-on-primary);
font-size: 0.75rem;
font-weight: 700;
border: 2px solid var(--color-surface);
box-shadow: var(--shadow-sm);
}
:global(.rb-waypoint .num.solo) {
min-width: 24px;
height: 24px;
border-radius: 12px;
}
:global(.rb-waypoint.with-thumb .num) {
position: absolute;
top: -6px;
right: -6px;
min-width: 20px;
height: 20px;
border-radius: 10px;
}
:global(.rb-waypoint .thumb) {
:global(.rb-pin) {
display: block;
width: 56px;
height: 56px;
border-radius: var(--radius-sm);
overflow: hidden;
border: 2px solid var(--color-surface);
box-shadow: var(--shadow-sm);
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.35));
transition: filter 200ms ease, transform 200ms ease;
transform-origin: 50% 100%;
}
:global(.rb-waypoint .thumb img) {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
:global(.rb-waypoint:hover .rb-pin) {
transform: scale(1.08);
}
:global(.rb-pin.is-active) {
filter: drop-shadow(0 0 6px color-mix(in oklab, #2965c8 70%, transparent))
drop-shadow(0 2px 3px rgba(0, 0, 0, 0.4));
animation: rb-pin-bounce 0.55s ease-out;
}
@keyframes rb-pin-bounce {
0% { transform: scale(0.85) translateY(-4px); }
60% { transform: scale(1.12); }
100% { transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
:global(.rb-pin.is-active) {
animation: none;
}
}
</style>
@@ -14,6 +14,11 @@
import { generateImageHashClient } from '$lib/imageHashClient';
import { readThumbnail } from './imageThumbnail';
import { setFullImage } from './fullImageCache.svelte';
import ImagePlus from '@lucide/svelte/icons/image-plus';
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
import AlertTriangle from '@lucide/svelte/icons/triangle-alert';
import X from '@lucide/svelte/icons/x';
import '$lib/css/action_button.css';
type Status = 'pending' | 'placed' | 'unplaced' | 'matched' | 'error';
@@ -24,31 +29,71 @@
message?: string;
};
interface Props {
/** Called when the user picks/drops a `.gpx` file via the FAB.
* Owning page handles the import + draft-replacement confirm.
* When absent, GPX files are silently ignored. */
onGpxImport?: (file: File) => void;
}
const { onGpxImport }: Props = $props();
function isGpxFile(file: File): boolean {
if (file.name.toLowerCase().endsWith('.gpx')) return true;
return (
file.type === 'application/gpx+xml' ||
file.type === 'application/xml' ||
file.type === 'text/xml'
);
}
let entries = $state<Entry[]>([]);
let isDragging = $state(false);
let showFailDetails = $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
);
const pendingCount = $derived(entries.filter((e) => e.status === 'pending').length);
const failedEntries = $derived(
entries.filter((e) => e.status === 'error' || e.status === 'unplaced')
);
const failCount = $derived(failedEntries.length);
// Numeric badge on the FAB. Pending wins (in-flight work), then
// failures (need attention), then orphan hash-only waypoints from a
// GPX import (waiting for their source images).
const badge = $derived(
pendingCount > 0
? pendingCount
: failCount > 0
? failCount
: orphanImageCount > 0
? orphanImageCount
: 0
);
const badgeTone = $derived<'pending' | 'fail' | 'info'>(
pendingCount > 0 ? 'pending' : failCount > 0 ? 'fail' : 'info'
);
type Prepared =
| { ok: true; kind: 'new'; wp: Waypoint; hasGps: boolean; id: string; file: File }
| { ok: true; kind: 'matched'; id: string; file: File }
| { ok: false };
// Auto-clear successful entries after 4s so the badge counter doesn't
// pile up. Failures stay until the user dismisses them.
function scheduleAutoDismiss(id: string, ms = 4000) {
setTimeout(() => {
const e = entries.find((x) => x.id === id);
if (!e) return;
if (e.status === 'placed' || e.status === 'matched') dismiss(id);
}, ms);
}
async function handleFiles(files: File[]) {
const exifr = (await import('exifr')).default;
// Prep every file in parallel (EXIF + hash + thumbnail). The result
// is staged in `prepared` rather than pushed into `builder.waypoints`
// one at a time — that way the snap-to-route effect (which fires on
// every waypoint insertion) sees a single synchronous batch insertion
// at the end instead of N consecutive ones. The Brouter / Swisstopo
// routing API only gets hit once per bulk upload.
const prepared = await Promise.all(
files.map(async (file): Promise<Prepared> => {
const id = nextWaypointId();
@@ -64,40 +109,32 @@
} 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.
// Match path: re-attach to an existing waypoint with the
// same content hash (covers the GPX-roundtrip flow).
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;
scheduleAutoDismiss(entries[entryIdx].id);
return { ok: true, kind: 'matched', id: existing.id, file };
}
const timestamp =
exif?.DateTimeOriginal instanceof Date ? exif.DateTimeOriginal.getTime() : null;
const hasGps =
exif &&
typeof exif.latitude === 'number' &&
typeof exif.longitude === 'number';
// Note: we deliberately ignore `exif.GPSAltitude` even when
// present. Phone GPS altitude has metre-scale noise; we backfill
// the terrain-model altitude from Swisstopo after insertion.
// EXIF GPSAltitude is intentionally ignored (too noisy);
// terrain-model altitude from Swisstopo is backfilled later.
const wp: Waypoint = hasGps
? {
id,
@@ -120,6 +157,7 @@
};
entries[entryIdx].status = hasGps ? 'placed' : 'unplaced';
if (hasGps) scheduleAutoDismiss(entries[entryIdx].id);
return { ok: true, kind: 'new', wp, hasGps, id, file };
} catch (err) {
entries[entryIdx].status = 'error';
@@ -129,13 +167,6 @@
})
);
// One synchronous batch of waypoint insertions → one snap-to-route
// debounce cycle for the whole upload. No per-image altitude fetch:
// image waypoints inherit the elevation of the routed segment they
// sit on once the route is snapped — Swisstopo's profile.json (used
// by snap-to-route enrichment) is the only reliable elevation
// source against WGS-84 inputs, and its single-point variant kept
// returning 0 even with workaround attempts.
let placedAny = false;
for (const p of prepared) {
if (!p.ok) continue;
@@ -143,29 +174,36 @@
insertWaypointChronologically(p.wp);
if (p.hasGps) placedAny = true;
}
// Cache the original file so the waypoint table can show a
// full-resolution preview this session (for both new + matched
// waypoints). Persistence to localStorage keeps only the small
// thumbnail.
setFullImage(p.id, p.file);
}
// Reframe the map to the new track. Only matters when the batch
// added at least one geolocated waypoint — unplaced images don't
// affect bounds, and matched-only drops leave coords unchanged.
if (placedAny) requestFitBounds();
}
function routeFiles(files: File[]) {
const gpx = files.find(isGpxFile);
if (gpx && onGpxImport) {
// GPX import REPLACES the draft, so we hand off the first one
// and ignore everything else in the batch — combining a GPX
// import with an image batch would race the snap-to-route
// reactor against a draft reset.
onGpxImport(gpx);
return;
}
const images = files.filter((f) => f.type.startsWith('image/'));
if (images.length > 0) handleFiles(images);
}
function onDrop(e: DragEvent) {
e.preventDefault();
isDragging = false;
const files = [...(e.dataTransfer?.files ?? [])].filter((f) => f.type.startsWith('image/'));
if (files.length > 0) handleFiles(files);
const files = [...(e.dataTransfer?.files ?? [])];
if (files.length > 0) routeFiles(files);
}
function onFileInput(e: Event) {
const input = e.currentTarget as HTMLInputElement;
const files = [...(input.files ?? [])];
if (files.length > 0) handleFiles(files);
if (files.length > 0) routeFiles(files);
input.value = '';
}
@@ -173,201 +211,328 @@
const idx = entries.findIndex((e) => e.id === entryId);
if (idx >= 0) entries.splice(idx, 1);
}
function clearFailed() {
entries = entries.filter((e) => e.status !== 'error' && e.status !== 'unplaced');
showFailDetails = false;
}
let fileInput: HTMLInputElement | undefined = $state();
function openPicker() {
fileInput?.click();
}
</script>
<section
class="dropzone"
class:active={isDragging}
aria-label="Bild-Drop"
<div
class="bulk-fab-wrap"
class:dragging={isDragging}
role="region"
aria-label="Bilder-Upload"
ondragenter={(e) => {
e.preventDefault();
isDragging = true;
const types = e.dataTransfer?.types;
if (types && Array.from(types).includes('Files')) {
e.preventDefault();
isDragging = true;
}
}}
ondragover={(e) => {
e.preventDefault();
const types = e.dataTransfer?.types;
if (types && Array.from(types).includes('Files')) {
e.preventDefault();
}
}}
ondragleave={() => {
isDragging = false;
ondragleave={(e) => {
if (e.currentTarget === e.target) isDragging = false;
}}
ondrop={onDrop}
>
<header>
<h2>Bilder</h2>
<p class="hint">
Bilder mit GPS-EXIF werden chronologisch platziert. Bilder ohne GPS
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>
<input
bind:this={fileInput}
type="file"
accept="image/*,.gpx,application/gpx+xml,application/xml,text/xml"
multiple
onchange={onFileInput}
hidden
/>
<button
type="button"
class="bulk-fab action_button"
aria-label="Bilder oder GPX hinzufügen"
title="Bilder oder GPX hinzufügen"
onclick={openPicker}
>
{#if pendingCount > 0}
<LoaderCircle size={30} strokeWidth={2.2} color="white" class="bulk-fab-icon spin" />
{:else}
<ImagePlus size={30} strokeWidth={2.2} color="white" class="bulk-fab-icon" />
{/if}
</header>
</button>
{#if failCount > 0}
<button
type="button"
class="bulk-fab-badge tone-fail"
onclick={() => (showFailDetails = !showFailDetails)}
aria-label="{failCount} {failCount === 1 ? 'Hinweis' : 'Hinweise'} anzeigen"
aria-expanded={showFailDetails}
>
{badge}
</button>
{:else if badge > 0}
<span class="bulk-fab-badge tone-{badgeTone}" aria-label="{badge} aktiv">
{badge}
</span>
{/if}
</div>
<label class="file-input">
<input type="file" accept="image/*" multiple onchange={onFileInput} />
<span>Bilder auswählen oder hierher ziehen</span>
</label>
{#if entries.length > 0}
<ul class="list">
{#each entries as e (e.id)}
<li class="entry status-{e.status}">
<span class="dot"></span>
{#if showFailDetails && failedEntries.length > 0}
<aside class="bulk-fail-popover" aria-label="Bild-Hinweise">
<header>
<strong>Bild-Hinweise</strong>
<button type="button" class="link" onclick={clearFailed}>Alle ausblenden</button>
</header>
<ul>
{#each failedEntries as e (e.id)}
<li class="bulk-fail status-{e.status}">
<span class="status-icon" aria-hidden="true">
<AlertTriangle size={12} strokeWidth={2} />
</span>
<span class="name">{e.name}</span>
<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 e.status === 'unplaced'}Position fehlt — Eintrag in der Wegpunktliste auf Karte platzieren.
{:else}Fehler: {e.message ?? 'unbekannt'}
{/if}
</span>
<button type="button" class="dismiss" aria-label="Schließen" onclick={() => dismiss(e.id)}>×</button>
<button type="button" class="dismiss" aria-label="Schließen" onclick={() => dismiss(e.id)}>
<X size={13} strokeWidth={2} />
</button>
</li>
{/each}
</ul>
{/if}
</section>
</aside>
{/if}
<style>
.dropzone {
margin-top: 1rem;
padding: 1rem;
background: var(--color-surface);
border: 2px dashed var(--color-border);
border-radius: var(--radius-lg);
transition: border-color var(--transition-fast), background-color var(--transition-fast);
/* Wrapper holds the FAB + badge in a single positioning context so the
* drag-target (full wrapper bounds) is larger than the button itself —
* helps users dropping a stack of images. */
.bulk-fab-wrap {
position: fixed;
bottom: 2rem;
right: 2rem;
width: 3.75rem;
height: 3.75rem;
z-index: 100;
transition: transform var(--transition-normal);
}
.dropzone.active {
border-color: var(--color-primary);
background: var(--color-bg-elevated);
.bulk-fab-wrap.dragging {
transform: scale(1.08);
}
h2 {
margin: 0;
font-size: 1rem;
color: var(--color-text-primary);
/* FAB — mirrors the recipes-style ActionButton (same shake + shadow
* via the shared action_button.css). */
.bulk-fab {
width: 100%;
height: 100%;
padding: 0;
border-radius: var(--radius-pill);
background-color: var(--red);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: var(--transition-normal);
}
.bulk-fab :global(.bulk-fab-icon) {
pointer-events: none;
}
.bulk-fab :global(.bulk-fab-icon.spin) {
animation: bulk-fab-spin 0.85s linear infinite;
}
@keyframes bulk-fab-spin {
to { transform: rotate(360deg); }
}
.hint {
margin: 0.25rem 0 0.75rem;
font-size: 0.8rem;
color: var(--color-text-tertiary);
.bulk-fab-wrap.dragging .bulk-fab {
background-color: var(--nord0);
box-shadow: 0 0 0 5px color-mix(in oklab, var(--red) 35%, transparent),
0 0 1.6em 0.4em rgba(0, 0, 0, 0.35);
}
.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);
@media (max-width: 500px) {
.bulk-fab-wrap {
bottom: 1rem;
right: 1rem;
width: 3.25rem;
height: 3.25rem;
}
}
.import-hint strong {
color: var(--blue);
/* Numeric badge — pinned top-right of the FAB. Pending = primary blue,
* fail = orange, info (orphan hashes) = nord blue. */
.bulk-fab-badge {
position: absolute;
top: -0.25rem;
right: -0.25rem;
min-width: 1.35rem;
height: 1.35rem;
padding: 0 0.35rem;
border-radius: var(--radius-pill);
background: var(--color-primary);
color: var(--color-text-on-primary);
font-size: 0.72rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
display: inline-flex;
align-items: center;
justify-content: center;
border: 2px solid var(--color-surface);
box-shadow: var(--shadow-sm);
appearance: none;
font-family: inherit;
}
button.bulk-fab-badge {
cursor: pointer;
}
.bulk-fab-badge.tone-fail {
background: var(--orange);
}
.file-input {
display: block;
text-align: center;
padding: 0.65rem 1rem;
background: var(--color-bg-tertiary);
.bulk-fab-badge.tone-info {
background: var(--blue);
}
/* Failure popover anchored above the FAB. Only opens when the user
* clicks the fail-tinted badge, so the FAB itself stays minimal. */
.bulk-fail-popover {
position: fixed;
bottom: 6.5rem;
right: 2rem;
z-index: 101;
max-width: min(360px, calc(100vw - 3rem));
background: var(--color-surface);
border: 1px solid var(--color-border);
border-left: 3px solid var(--orange);
border-radius: var(--radius-md);
cursor: pointer;
font-size: 0.9rem;
color: var(--color-text-secondary);
box-shadow: var(--shadow-lg);
padding: 0.6rem 0.7rem;
animation: bulk-fail-in 200ms ease-out;
}
@keyframes bulk-fail-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.file-input input {
display: none;
.bulk-fail-popover header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.85rem;
color: var(--color-text-primary);
padding-bottom: 0.4rem;
border-bottom: 1px solid var(--color-border);
margin-bottom: 0.4rem;
}
.file-input:hover {
background: var(--color-bg-elevated);
.bulk-fail-popover .link {
appearance: none;
background: transparent;
border: 0;
font: inherit;
font-size: 0.75rem;
color: var(--color-text-tertiary);
cursor: pointer;
text-decoration: underline;
}
.list {
.bulk-fail-popover ul {
list-style: none;
margin: 0;
padding: 0;
margin: 0.75rem 0 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
gap: 0.3rem;
}
.entry {
.bulk-fail {
display: grid;
grid-template-columns: auto 1fr auto auto;
gap: 0.6rem;
grid-template-columns: auto 1fr auto;
gap: 0.5rem;
align-items: center;
padding: 0.35rem 0.5rem;
padding: 0.35rem 0.4rem;
background: var(--color-bg-secondary);
border-radius: var(--radius-sm);
font-size: 0.78rem;
color: var(--color-text-secondary);
}
.dot {
width: 0.55rem;
height: 0.55rem;
.bulk-fail .status-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.1rem;
height: 1.1rem;
border-radius: 50%;
background: var(--color-text-tertiary);
background: var(--orange);
color: white;
flex-shrink: 0;
}
.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 {
background: var(--color-primary);
animation: pulse 1.2s ease-in-out infinite;
.bulk-fail.status-error .status-icon {
background: var(--red);
}
@keyframes pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
.name {
.bulk-fail .name {
grid-column: 2;
grid-row: 1;
color: var(--color-text-primary);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.msg {
text-align: right;
font-size: 0.75rem;
.bulk-fail .msg {
grid-column: 2;
grid-row: 2;
font-size: 0.72rem;
color: var(--color-text-tertiary);
white-space: nowrap;
line-height: 1.35;
}
.status-error .msg { color: var(--red); }
.status-unplaced .msg { color: var(--orange); }
.status-placed .msg { color: var(--green); }
.status-matched .msg { color: var(--blue); }
.bulk-fail.status-error .msg {
color: var(--red);
}
.dismiss {
.bulk-fail .dismiss {
grid-column: 3;
grid-row: 1 / span 2;
appearance: none;
background: transparent;
border: 0;
color: var(--color-text-tertiary);
font-size: 1.1rem;
line-height: 1;
padding: 0 0.2rem;
padding: 0.2rem;
border-radius: var(--radius-sm);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.dismiss:hover {
.bulk-fail .dismiss:hover {
color: var(--color-text-primary);
background: var(--color-bg-elevated);
}
</style>
@@ -0,0 +1,186 @@
<script lang="ts">
import { builder } from './builderStore.svelte';
import { haversineKm } from '$lib/gpx';
import { computeElevationStats, computeElevationRange } from '$lib/hikes/elevation';
import Route from '@lucide/svelte/icons/route';
import TrendingUp from '@lucide/svelte/icons/trending-up';
import TrendingDown from '@lucide/svelte/icons/trending-down';
import ArrowUpToLine from '@lucide/svelte/icons/arrow-up-to-line';
import ArrowDownToLine from '@lucide/svelte/icons/arrow-down-to-line';
interface Props {
/** True while the snap-to-route / elevation-enrichment pipeline is still
* resolving. Stats are computed from whatever's already in the store so
* the user sees an evolving preview; the flag drives a subtle pulse so
* they know the numbers may still tick up. */
busy?: boolean;
}
const { busy = false }: Props = $props();
type Pt = { lat: number; lng: number; altitude?: number };
// Flatten routedSegments → trkpt-shaped array. We dedupe the seam between
// adjacent segments (each segment repeats its end as the next segment's
// start) so distance + elevation don't double-count those vertices.
const flatTrack = $derived.by<Pt[]>(() => {
const out: Pt[] = [];
let prev: Pt | null = null;
for (const seg of builder.routedSegments) {
for (const p of seg) {
const point: Pt = {
lng: p[0],
lat: p[1],
altitude: typeof p[2] === 'number' ? p[2] : undefined
};
if (
prev &&
prev.lat === point.lat &&
prev.lng === point.lng &&
prev.altitude === point.altitude
) {
continue;
}
out.push(point);
prev = point;
}
}
return out;
});
const distanceKm = $derived.by(() => {
let total = 0;
for (let i = 1; i < flatTrack.length; i++) {
total += haversineKm(
{ ...flatTrack[i - 1], timestamp: 0 },
{ ...flatTrack[i], timestamp: 0 }
);
}
return total;
});
const elevStats = $derived(computeElevationStats(flatTrack));
const elevRange = $derived(computeElevationRange(flatTrack));
const hasTrack = $derived(flatTrack.length >= 2);
function fmtNum(n: number | null | undefined, suffix = ''): string {
if (n === null || n === undefined) return '';
return `${n}${suffix}`;
}
</script>
<section class="stats-bar" class:busy class:idle={!hasTrack} aria-label="Routendaten">
<div class="metric">
<Route size={20} strokeWidth={1.75} aria-hidden="true" />
<span class="value">
{hasTrack ? distanceKm.toFixed(1) : ''}<span class="value-unit">km</span>
</span>
<span class="unit">Distanz</span>
</div>
<div class="metric">
<TrendingUp size={20} strokeWidth={1.75} aria-hidden="true" />
<span class="value">
{hasTrack ? fmtNum(elevStats.gain) : ''}<span class="value-unit">m</span>
</span>
<span class="unit">Aufstieg</span>
</div>
<div class="metric">
<TrendingDown size={20} strokeWidth={1.75} aria-hidden="true" />
<span class="value">
{hasTrack ? fmtNum(elevStats.loss) : ''}<span class="value-unit">m</span>
</span>
<span class="unit">Abstieg</span>
</div>
<div class="metric">
<ArrowUpToLine size={20} strokeWidth={1.75} aria-hidden="true" />
<span class="value">
{hasTrack ? fmtNum(elevRange.max) : ''}<span class="value-unit">m</span>
</span>
<span class="unit">höchster</span>
</div>
<div class="metric">
<ArrowDownToLine size={20} strokeWidth={1.75} aria-hidden="true" />
<span class="value">
{hasTrack ? fmtNum(elevRange.min) : ''}<span class="value-unit">m</span>
</span>
<span class="unit">tiefster</span>
</div>
</section>
<style>
.stats-bar {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.75rem 2rem;
padding: 1rem 1.25rem;
background: var(--color-surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
color: var(--color-text-secondary);
font-size: 0.9rem;
transition: opacity 200ms ease;
}
.stats-bar.idle {
color: var(--color-text-tertiary);
}
.stats-bar.busy {
animation: stats-pulse 1.6s ease-in-out infinite;
}
@keyframes stats-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.65; }
}
@media (prefers-reduced-motion: reduce) {
.stats-bar.busy {
animation: none;
}
}
.metric {
display: grid;
grid-template-columns: auto auto;
grid-template-rows: auto auto;
column-gap: 0.55rem;
row-gap: 0.05rem;
align-items: center;
}
.metric :global(svg) {
grid-row: 1 / span 2;
color: var(--color-primary);
}
.stats-bar.idle .metric :global(svg) {
color: var(--color-text-tertiary);
}
.value {
font-size: 1.25rem;
line-height: 1.1;
color: var(--color-text-primary);
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.stats-bar.idle .value {
color: var(--color-text-tertiary);
}
.value-unit {
font-size: 0.7em;
font-weight: 500;
color: var(--color-text-secondary);
margin-left: 0.15em;
}
.unit {
font-size: 0.75rem;
color: var(--color-text-tertiary);
letter-spacing: 0.02em;
}
</style>
@@ -0,0 +1,681 @@
<script lang="ts">
import {
builder,
focusWaypoint,
mapView,
placedSequence,
scheduleSave
} from './builderStore.svelte';
import { generateImageHashClient } from '$lib/imageHashClient';
import { readThumbnail } from './imageThumbnail';
import { dropFullImage, getFullImageUrl, setFullImage } from './fullImageCache.svelte';
import DateTimePicker from '$lib/components/DateTimePicker.svelte';
import MapPinned from '@lucide/svelte/icons/map-pinned';
import ImagePlus from '@lucide/svelte/icons/image-plus';
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
import Globe from '@lucide/svelte/icons/globe';
import Lock from '@lucide/svelte/icons/lock';
import X from '@lucide/svelte/icons/x';
interface Props {
onCancelPlacement?: () => void;
}
const { onCancelPlacement }: Props = $props();
const NUDGE_MINUTES = [-10, -5, 5, 10];
// Drive everything off the focus signal. The full waypoint array index
// (`idx`) is used for in-place mutation; `wp` is a reactive reference into
// the same store entry so writes propagate via Svelte 5 deep reactivity.
const wpIdx = $derived(
mapView.focusId ? builder.waypoints.findIndex((w) => w.id === mapView.focusId) : -1
);
const wp = $derived(wpIdx === -1 ? null : builder.waypoints[wpIdx]);
const seq = $derived(wp ? placedSequence(wp.id) : null);
const placed = $derived(builder.waypoints.filter((w) => !w.unplaced));
const firstPlacedIdx = $derived(builder.waypoints.findIndex((w) => !w.unplaced));
const lastPlacedIdx = $derived.by(() => {
for (let i = builder.waypoints.length - 1; i >= 0; i--) {
if (!builder.waypoints[i].unplaced) return i;
}
return -1;
});
const requiresTime = $derived(wpIdx !== -1 && (wpIdx === firstPlacedIdx || wpIdx === lastPlacedIdx));
function nearestTimestamp(idx: number): number | undefined {
const wps = builder.waypoints;
for (let dist = 1; dist < wps.length; dist++) {
const a = wps[idx - dist];
if (a && typeof a.timestamp === 'number') return a.timestamp;
const b = wps[idx + dist];
if (b && typeof b.timestamp === 'number') return b.timestamp;
}
return undefined;
}
const inheritedTs = $derived.by(() => {
if (!wp || wp.timestamp != null) return null;
return nearestTimestamp(wpIdx) ?? null;
});
function updateLat(raw: string) {
if (!wp) return;
const n = parseFloat(raw);
if (!isNaN(n)) {
wp.lat = n;
scheduleSave();
}
}
function updateLng(raw: string) {
if (!wp) return;
const n = parseFloat(raw);
if (!isNaN(n)) {
wp.lng = n;
scheduleSave();
}
}
function setVisibility(value: 'public' | 'private') {
if (!wp) return;
wp.imageVisibility = value;
scheduleSave();
}
function removeWaypoint() {
if (!wp || wpIdx === -1) return;
const id = wp.id;
dropFullImage(id);
builder.waypoints.splice(wpIdx, 1);
scheduleSave();
// Move focus to the next remaining placed waypoint, or clear it.
const next = placed.find((w) => w.id !== id);
focusWaypoint(next?.id ?? null);
}
function closePanel() {
focusWaypoint(null);
}
let attachBusy = $state(false);
let dragActive = $state(false);
async function attachImage(fileList: FileList | null) {
const file = fileList?.[0];
if (!file || !wp) return;
attachBusy = true;
try {
const exifr = (await import('exifr')).default;
const exif = await exifr.parse(file, { gps: true, exif: true }).catch(() => null);
const hash = await generateImageHashClient(file);
let thumbnail: string | undefined;
try {
thumbnail = await readThumbnail(file);
} catch { /* thumbnail is optional */ }
wp.imageHash = hash;
wp.thumbnail = thumbnail;
wp.imageVisibility = 'public';
setFullImage(wp.id, file);
if (wp.timestamp == null && exif?.DateTimeOriginal instanceof Date) {
wp.timestamp = exif.DateTimeOriginal.getTime();
}
scheduleSave();
} finally {
attachBusy = false;
}
}
function onHeroDrop(e: DragEvent) {
e.preventDefault();
dragActive = false;
const files = e.dataTransfer?.files;
if (!files || files.length === 0) return;
const imgs = [...files].filter((f) => f.type.startsWith('image/'));
if (imgs.length === 0) return;
const dt = new DataTransfer();
dt.items.add(imgs[0]);
attachImage(dt.files);
}
</script>
<aside class="detail-panel" aria-label="Wegpunkt-Details">
{#if !wp}
<div class="empty">
<MapPinned size={32} strokeWidth={1.5} />
<p class="empty-title">Kein Wegpunkt ausgewählt</p>
<p class="empty-sub">
Klicke einen Pin auf der Karte oder einen Eintrag in der Liste an, um ihn
hier zu bearbeiten. Mit ← / → kannst du die Route Wegpunkt für Wegpunkt
durchgehen.
</p>
</div>
{:else}
<header class="panel-head">
<span class="seq" class:unplaced={wp.unplaced}>{seq ?? '?'}</span>
<h3 class="title">
{#if wp.unplaced}
Bild ohne Position
{:else if wp.imageHash}
Bild {seq}
{:else}
Wegpunkt {seq}
{/if}
</h3>
<button
type="button"
class="close"
onclick={closePanel}
aria-label="Panel schließen"
title="Schließen"
>
<X size={16} strokeWidth={2} />
</button>
</header>
<div class="hero" class:empty={!wp.thumbnail && !getFullImageUrl(wp.id)} class:busy={attachBusy}>
{#if wp.thumbnail || getFullImageUrl(wp.id)}
<img src={getFullImageUrl(wp.id) ?? wp.thumbnail} alt="" />
{:else}
<!-- Same 4:3 box as the thumbnail variant so the rest of the panel
stays put when an image gets attached. The label fills the box,
acts as both click target and drop target. -->
<label
class="hero-upload"
class:drag={dragActive}
ondragenter={(e) => { e.preventDefault(); dragActive = true; }}
ondragover={(e) => { e.preventDefault(); }}
ondragleave={() => { dragActive = false; }}
ondrop={onHeroDrop}
>
<input
type="file"
accept="image/*"
disabled={attachBusy}
onchange={(e) => attachImage(e.currentTarget.files)}
/>
<span class="hero-upload-inner">
{#if attachBusy}
<LoaderCircle size={28} strokeWidth={1.75} class="spin" />
<span class="hero-upload-title">Bild wird gelesen…</span>
{:else}
<ImagePlus size={28} strokeWidth={1.75} />
<span class="hero-upload-title">Bild anhängen</span>
<span class="hero-upload-sub">
Klicken oder hierher ziehen
</span>
{/if}
</span>
</label>
{/if}
</div>
{#if wp.imageHash}
<div class="vis-block" class:is-private={wp.imageVisibility === 'private'}>
<div class="vis-head">
<span class="label">Sichtbarkeit auf der Website</span>
<span class="vis-state">
{wp.imageVisibility === 'private'
? 'Nur du siehst dieses Bild im veröffentlichten GPX.'
: 'Dieses Bild wird öffentlich auf der Wandereintragsseite angezeigt.'}
</span>
</div>
<div class="vis-segment" role="radiogroup" aria-label="Sichtbarkeit">
<button
type="button"
class="vis-opt"
class:active={wp.imageVisibility !== 'private'}
aria-pressed={wp.imageVisibility !== 'private'}
onclick={() => setVisibility('public')}
>
<Globe size={18} strokeWidth={2} />
<span>Öffentlich</span>
</button>
<button
type="button"
class="vis-opt"
class:active={wp.imageVisibility === 'private'}
aria-pressed={wp.imageVisibility === 'private'}
onclick={() => setVisibility('private')}
>
<Lock size={18} strokeWidth={2} />
<span>Privat</span>
</button>
</div>
</div>
{/if}
{#if !wp.unplaced}
<div class="field">
<span class="label">
{requiresTime ? 'Zeit (Pflicht)' : 'Zeit'}
</span>
<DateTimePicker
bind:value={builder.waypoints[wpIdx].timestamp}
mode={wp.imageHash || requiresTime || wp.timestamp != null ? 'datetime' : 'date'}
inheritedValue={inheritedTs}
nudgeMinutes={NUDGE_MINUTES}
required={requiresTime}
lang="de"
/>
</div>
{:else}
<p class="placement-hint">
Diese Position fehlt noch. Wähle den Eintrag in der Wegpunktliste unten und
klicke „Auf Karte platzieren“ oder ziehe ein Bild mit GPS-EXIF in den
Bildbereich.
</p>
<button type="button" class="ghost" onclick={() => onCancelPlacement?.()}>
Platzierung abbrechen
</button>
{/if}
{#if !wp.unplaced}
<details class="coords-details">
<summary>Koordinaten anpassen</summary>
<div class="coords-grid">
<div class="field">
<label class="label" for="dp-lat">Breitengrad</label>
<input
id="dp-lat"
type="number"
step="0.000001"
value={wp.lat}
onchange={(e) => updateLat(e.currentTarget.value)}
/>
</div>
<div class="field">
<label class="label" for="dp-lng">Längengrad</label>
<input
id="dp-lng"
type="number"
step="0.000001"
value={wp.lng}
onchange={(e) => updateLng(e.currentTarget.value)}
/>
</div>
</div>
</details>
{/if}
<button type="button" class="danger" onclick={removeWaypoint}>
Wegpunkt entfernen
</button>
{/if}
</aside>
<style>
.detail-panel {
background: var(--color-surface);
border-radius: var(--radius-lg);
padding: 1rem;
box-shadow: var(--shadow-sm);
display: flex;
flex-direction: column;
gap: 0.65rem;
min-width: 0;
/* Match the map's height so the column visually anchors next to it.
* The intrinsic content scrolls within so the panel itself stays the
* same shape regardless of waypoint state. */
max-height: 640px;
overflow-y: auto;
}
@media (max-width: 900px) {
.detail-panel {
max-height: none;
}
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 0.4rem;
padding: 1.5rem 0.75rem;
color: var(--color-text-tertiary);
}
.empty-title {
margin: 0.3rem 0 0;
font-size: 0.95rem;
color: var(--color-text-secondary);
font-weight: 600;
}
.empty-sub {
margin: 0;
font-size: 0.8rem;
line-height: 1.45;
}
.panel-head {
display: flex;
align-items: center;
gap: 0.6rem;
}
.seq {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 28px;
padding: 0 0.45em;
background: var(--color-primary);
color: var(--color-text-on-primary);
border-radius: 14px;
font-size: 0.8rem;
font-weight: 700;
}
.seq.unplaced {
background: var(--orange);
}
.title {
flex: 1 1 auto;
min-width: 0;
margin: 0;
font-size: 1rem;
color: var(--color-text-primary);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.close {
appearance: none;
background: transparent;
border: 0;
color: var(--color-text-tertiary);
padding: 0.25rem;
border-radius: var(--radius-sm);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 0;
}
.close:hover {
color: var(--color-text-primary);
background: var(--color-bg-elevated);
}
.hero {
width: 100%;
aspect-ratio: 4 / 3;
border-radius: var(--radius-md);
overflow: hidden;
background: var(--color-bg-elevated);
}
.hero img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
/* Empty-state variant occupies the SAME 4:3 box as the thumbnail
* variant — same width, same aspect-ratio, same border-radius — so
* dropping/attaching an image swaps the inner content without shifting
* any other panel section. */
.hero.empty {
background: linear-gradient(
135deg,
color-mix(in oklab, var(--color-primary) 6%, transparent),
transparent 70%
),
var(--color-bg-tertiary);
border: 1.5px dashed color-mix(in oklab, var(--color-primary) 32%, var(--color-border));
transition: border-color var(--transition-fast), background var(--transition-fast);
}
.hero.empty:hover {
border-color: var(--color-primary);
}
.hero-upload {
display: flex;
width: 100%;
height: 100%;
cursor: pointer;
}
.hero.busy .hero-upload {
cursor: wait;
}
.hero-upload input[type='file'] {
display: none;
}
.hero-upload.drag {
background: color-mix(in oklab, var(--color-primary) 14%, transparent);
}
.hero-upload-inner {
flex: 1 1 auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.75rem;
color: var(--color-text-secondary);
pointer-events: none;
}
.hero-upload-inner :global(svg) {
color: var(--color-primary);
}
.hero-upload-title {
font-size: 0.95rem;
font-weight: 600;
color: var(--color-text-primary);
}
.hero-upload-sub {
font-size: 0.78rem;
color: var(--color-text-tertiary);
}
.hero-upload :global(.spin) {
animation: panel-spin 0.85s linear infinite;
}
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
min-width: 0;
}
.label {
font-size: 0.72rem;
color: var(--color-text-tertiary);
letter-spacing: 0.04em;
text-transform: uppercase;
font-weight: 600;
}
.field input[type='number'] {
width: 100%;
padding: 0.45rem 0.6rem;
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-primary);
font: inherit;
font-size: 0.85rem;
}
/* Visibility is the highest-stakes setting in the panel — privacy choice
* for the published GPX. Treat it as a primary action: card-like block
* with a short rationale + a wide two-segment toggle, tinted green for
* public and amber for private so the current state reads at a glance. */
.vis-block {
display: flex;
flex-direction: column;
gap: 0.55rem;
padding: 0.75rem 0.85rem 0.85rem;
background: color-mix(in oklab, var(--green) 8%, var(--color-bg-secondary));
border: 1px solid color-mix(in oklab, var(--green) 30%, var(--color-border));
border-left: 3px solid var(--green);
border-radius: var(--radius-md);
transition: background var(--transition-fast), border-color var(--transition-fast);
}
.vis-block.is-private {
background: color-mix(in oklab, var(--orange) 8%, var(--color-bg-secondary));
border-color: color-mix(in oklab, var(--orange) 35%, var(--color-border));
border-left-color: var(--orange);
}
.vis-head {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.vis-state {
font-size: 0.78rem;
color: var(--color-text-secondary);
line-height: 1.35;
}
.vis-segment {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.4rem;
}
.vis-opt {
appearance: none;
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
font: inherit;
font-size: 0.9rem;
font-weight: 600;
padding: 0.6rem 0.5rem;
border-radius: var(--radius-md);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
transition: background var(--transition-fast), border-color var(--transition-fast),
color var(--transition-fast), box-shadow var(--transition-fast);
}
.vis-opt:hover:not(.active) {
background: var(--color-bg-elevated);
}
.vis-opt.active {
color: white;
}
.vis-block:not(.is-private) .vis-opt.active {
background: var(--green);
border-color: var(--green);
box-shadow: 0 0 0 2px color-mix(in oklab, var(--green) 30%, transparent);
}
.vis-block.is-private .vis-opt.active {
background: var(--orange);
border-color: var(--orange);
box-shadow: 0 0 0 2px color-mix(in oklab, var(--orange) 30%, transparent);
}
/* Coords are a power-user adjustment — keep them out of the way unless
* the user explicitly opens the disclosure. Dragging the marker on the
* map is the primary editing affordance. */
.coords-details {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-secondary);
}
.coords-details > summary {
cursor: pointer;
padding: 0.5rem 0.75rem;
font-size: 0.78rem;
color: var(--color-text-secondary);
font-weight: 600;
letter-spacing: 0.01em;
list-style: revert;
}
.coords-details[open] > summary {
border-bottom: 1px solid var(--color-border);
}
.coords-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
padding: 0.6rem 0.75rem 0.7rem;
}
@media (max-width: 360px) {
.coords-grid {
grid-template-columns: 1fr;
}
}
.placement-hint {
margin: 0;
padding: 0.5rem 0.7rem;
background: color-mix(in oklab, var(--orange) 10%, var(--color-bg-secondary));
border-left: 3px solid var(--orange);
border-radius: var(--radius-sm);
font-size: 0.8rem;
color: var(--color-text-secondary);
line-height: 1.45;
}
@keyframes panel-spin {
to { transform: rotate(360deg); }
}
.ghost {
appearance: none;
font: inherit;
font-size: 0.8rem;
padding: 0.4rem 0.9rem;
border-radius: var(--radius-pill);
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
cursor: pointer;
}
.danger {
appearance: none;
font: inherit;
font-size: 0.8rem;
padding: 0.5rem 0.9rem;
margin-top: 0.25rem;
border-radius: var(--radius-pill);
background: transparent;
border: 1px solid color-mix(in oklab, var(--red) 35%, var(--color-border));
color: var(--red);
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast);
}
.danger:hover {
background: var(--red);
color: white;
border-color: var(--red);
}
</style>
@@ -1,12 +1,26 @@
<script lang="ts">
import { flip } from 'svelte/animate';
import { builder, scheduleSave } from './builderStore.svelte';
import {
builder,
focusWaypoint,
mapView,
placedSequence,
scheduleSave
} from './builderStore.svelte';
import { generateImageHashClient } from '$lib/imageHashClient';
import { readThumbnail } from './imageThumbnail';
import { dropFullImage, getFullImageUrl, setFullImage } from './fullImageCache.svelte';
import DateTimePicker from '$lib/components/DateTimePicker.svelte';
import ImagePlus from '@lucide/svelte/icons/image-plus';
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
import ArrowUp from '@lucide/svelte/icons/arrow-up';
import ArrowDown from '@lucide/svelte/icons/arrow-down';
import X from '@lucide/svelte/icons/x';
import Crosshair from '@lucide/svelte/icons/crosshair';
import MapPin from '@lucide/svelte/icons/map-pin';
import MapPinOff from '@lucide/svelte/icons/map-pin-off';
import Globe from '@lucide/svelte/icons/globe';
import Lock from '@lucide/svelte/icons/lock';
const NUDGE_MINUTES = [-10, -5, 5, 10];
@@ -137,10 +151,12 @@
<p class="legend">* Erster und letzter platzierter Wegpunkt brauchen einen Zeitstempel.</p>
<ol>
{#each builder.waypoints as wp, idx (wp.id)}
{@const seq = placedSequence(wp.id)}
<li
class="wp"
class:unplaced={wp.unplaced}
class:active={wp.id === pendingPlacementId}
class:focused={wp.id === mapView.focusId && !wp.unplaced}
animate:flip={{ duration: 220 }}
>
{#if wp.thumbnail || getFullImageUrl(wp.id)}
@@ -151,28 +167,63 @@
loading="lazy"
/>
{#if wp.unplaced}
<span class="hero-badge">📍 noch nicht platziert</span>
<span class="hero-badge">
<MapPinOff size={12} strokeWidth={2} />
<span>noch nicht platziert</span>
</span>
{/if}
</div>
{/if}
<div class="row title-row">
<span class="idx" class:unplaced-idx={wp.unplaced}>
{wp.unplaced ? '?' : idx + 1}
{seq ?? '?'}
</span>
<span class="title">
{#if wp.unplaced}
Bild ohne Position
{:else if wp.imageHash}
Bild {idx + 1}
Bild {seq}
{:else}
Wegpunkt {idx + 1}
Wegpunkt {seq}
{/if}
</span>
<div class="row-actions">
<button type="button" onclick={() => move(idx, -1)} disabled={idx === 0} aria-label="Nach oben"></button>
<button type="button" onclick={() => move(idx, 1)} disabled={idx === builder.waypoints.length - 1} aria-label="Nach unten"></button>
<button type="button" class="del" onclick={() => remove(idx)} aria-label="Entfernen"></button>
{#if !wp.unplaced}
<button
type="button"
class="focus-btn"
onclick={() => focusWaypoint(wp.id)}
aria-label="Auf Karte fokussieren"
title="Auf Karte fokussieren"
>
<Crosshair size={14} strokeWidth={2} />
</button>
{/if}
<button
type="button"
onclick={() => move(idx, -1)}
disabled={idx === 0}
aria-label="Nach oben"
>
<ArrowUp size={14} strokeWidth={2} />
</button>
<button
type="button"
onclick={() => move(idx, 1)}
disabled={idx === builder.waypoints.length - 1}
aria-label="Nach unten"
>
<ArrowDown size={14} strokeWidth={2} />
</button>
<button
type="button"
class="del"
onclick={() => remove(idx)}
aria-label="Entfernen"
>
<X size={14} strokeWidth={2} />
</button>
</div>
</div>
@@ -183,7 +234,8 @@
<button type="button" class="ghost" onclick={() => onCancelPlacement?.()}>Abbrechen</button>
{:else}
<button type="button" class="primary" onclick={() => onRequestPlacement?.(wp.id)}>
📍 Auf Karte platzieren
<MapPin size={14} strokeWidth={2} />
<span>Auf Karte platzieren</span>
</button>
{/if}
</div>
@@ -236,13 +288,19 @@
class:active={wp.imageVisibility !== 'private'}
aria-pressed={wp.imageVisibility !== 'private'}
onclick={() => setVisibility(idx, 'public')}
>🌐 Öffentlich</button>
>
<Globe size={12} strokeWidth={2} />
<span>Öffentlich</span>
</button>
<button
type="button"
class:active={wp.imageVisibility === 'private'}
aria-pressed={wp.imageVisibility === 'private'}
onclick={() => setVisibility(idx, 'private')}
>🔒 Privat</button>
>
<Lock size={12} strokeWidth={2} />
<span>Privat</span>
</button>
</div>
</div>
{:else if !wp.unplaced}
@@ -278,8 +336,6 @@
border-radius: var(--radius-lg);
padding: 1rem;
box-shadow: var(--shadow-sm);
max-height: 600px;
overflow-y: auto;
}
header h2 {
@@ -304,9 +360,9 @@
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.6rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 0.75rem;
}
.wp {
@@ -318,6 +374,7 @@
display: flex;
flex-direction: column;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
scroll-margin-top: 1rem;
}
.wp.unplaced {
@@ -330,6 +387,12 @@
box-shadow: 0 0 0 2px color-mix(in oklab, var(--color-primary) 30%, transparent);
}
.wp.focused {
border-color: var(--blue);
box-shadow: 0 0 0 2px color-mix(in oklab, var(--blue) 30%, transparent),
var(--shadow-md);
}
.hero {
position: relative;
width: 100%;
@@ -349,7 +412,10 @@
position: absolute;
top: 0.4rem;
left: 0.4rem;
padding: 0.15rem 0.5rem;
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.2rem 0.55rem;
background: var(--orange);
color: white;
font-size: 0.7rem;
@@ -461,11 +527,13 @@
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
font: inherit;
padding: 0.2rem 0.4rem;
padding: 0.25rem;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
line-height: 1;
line-height: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.row-actions button:disabled {
@@ -477,6 +545,18 @@
color: var(--red);
}
.row-actions button.focus-btn {
color: var(--blue);
border-color: color-mix(in oklab, var(--blue) 35%, var(--color-border));
background: color-mix(in oklab, var(--blue) 8%, var(--color-bg-tertiary));
}
.row-actions button.focus-btn:hover {
background: var(--blue);
color: white;
border-color: var(--blue);
}
.placement-row .primary,
.placement-row .ghost {
appearance: none;
@@ -485,6 +565,9 @@
padding: 0.3rem 0.8rem;
border-radius: var(--radius-pill);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.placement-row .primary {
@@ -528,8 +611,11 @@
color: var(--color-text-secondary);
font: inherit;
font-size: 0.78rem;
padding: 0.2rem 0.6rem;
padding: 0.25rem 0.65rem;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.segment button + button {
@@ -108,16 +108,48 @@ function defaultState(): BuilderState {
export const builder = $state<BuilderState>(loadDraft());
/**
* UI-only signal for the edit map: bumping `fitTick` asks the map to
* re-run `fitBounds()` on the current track. Used after batch insertions
* (image drops, GPX import) where the user expects the map to reframe to
* show every newly-added waypoint. Not persisted.
* UI-only signals shared between the edit map and the side panels.
*
* - `fitTick`: bump to re-run `fitBounds()` on the current track. Used
* after batch insertions (image drops, GPX import) where the user
* expects the map to reframe to show every newly-added waypoint.
* - `focusId` + `focusTick`: bump to pan/zoom the map onto a specific
* waypoint AND mark it as the "current" one (drives prev/next nav and
* the highlight on the corresponding table row + marker).
*
* Not persisted pure session UI.
*/
export const mapView = $state({ fitTick: 0 });
export const mapView = $state<{
fitTick: number;
focusId: string | null;
focusTick: number;
}>({ fitTick: 0, focusId: null, focusTick: 0 });
export function requestFitBounds(): void {
mapView.fitTick++;
}
export function focusWaypoint(id: string | null): void {
mapView.focusId = id;
mapView.focusTick++;
}
/**
* Sequence number (1-based) of `wp` among placed waypoints only. Unplaced
* image entries return `null`. Single source of truth so the table badge,
* the map marker number, and the GPX export all agree on what "Wegpunkt 3"
* means.
*/
export function placedSequence(wpId: string): number | null {
let n = 0;
for (const w of builder.waypoints) {
if (w.unplaced) continue;
n++;
if (w.id === wpId) return n;
}
return null;
}
let saveTimer: ReturnType<typeof setTimeout> | null = null;
export function scheduleSave(): void {
if (!browser) return;
+83
View File
@@ -0,0 +1,83 @@
/**
* Shared elevation analytics for the hikes pipeline.
*
* The build script and the route-builder both need to derive
* gain / loss / range from a track's `<ele>` values. Keeping a single
* implementation here guarantees the live preview in the builder reports
* the same numbers the published detail page will show after a deploy.
*/
export interface HasAltitude {
altitude?: number;
}
/** Moving-average window for altitude denoising (in trkpts). */
export const ELEV_SMOOTH_WINDOW = 5;
/** Discard altitude deltas below this many metres eliminates GPS
* jitter from being counted as real gain/loss. */
export const ELEV_MIN_STEP_M = 3;
/**
* Returns the smoothed altitude per trkpt. Entries are `null` for indices
* where no defined altitude exists in the ±half window the previous
* behaviour (defaulting to 0) silently turned missing `<ele>` tags into
* huge synthetic gain spikes against the next real altitude.
*/
export function smoothAltitudes(track: HasAltitude[]): (number | null)[] {
const n = track.length;
const out = new Array<number | null>(n);
const half = Math.floor(ELEV_SMOOTH_WINDOW / 2);
for (let i = 0; i < n; i++) {
let sum = 0;
let count = 0;
for (let j = Math.max(0, i - half); j <= Math.min(n - 1, i + half); j++) {
const a = track[j].altitude;
if (typeof a === 'number') {
sum += a;
count++;
}
}
out[i] = count > 0 ? sum / count : null;
}
return out;
}
export function computeElevationStats(track: HasAltitude[]): { gain: number; loss: number } {
if (track.length < 2) return { gain: 0, loss: 0 };
const altitudes = smoothAltitudes(track);
let gain = 0;
let loss = 0;
let prev: number | null = null;
for (const a of altitudes) {
if (a === null) continue;
if (prev === null) {
prev = a;
continue;
}
const diff = a - prev;
if (diff >= ELEV_MIN_STEP_M) {
gain += diff;
prev = a;
} else if (diff <= -ELEV_MIN_STEP_M) {
loss += -diff;
prev = a;
}
}
return { gain: Math.round(gain), loss: Math.round(loss) };
}
export function computeElevationRange(track: HasAltitude[]): {
min: number | null;
max: number | null;
} {
let min = Infinity;
let max = -Infinity;
for (const p of track) {
if (typeof p.altitude !== 'number') continue;
if (p.altitude < min) min = p.altitude;
if (p.altitude > max) max = p.altitude;
}
if (!Number.isFinite(min) || !Number.isFinite(max)) return { min: null, max: null };
return { min: Math.round(min), max: Math.round(max) };
}
+478 -158
View File
@@ -3,10 +3,14 @@
import Seo from '$lib/components/Seo.svelte';
import EditMap from '$lib/components/hikes/route-builder/EditMap.svelte';
import WaypointTable from '$lib/components/hikes/route-builder/WaypointTable.svelte';
import WaypointDetailPanel from '$lib/components/hikes/route-builder/WaypointDetailPanel.svelte';
import ImageDropzone from '$lib/components/hikes/route-builder/ImageDropzone.svelte';
import RouteStatsBar from '$lib/components/hikes/route-builder/RouteStatsBar.svelte';
import { assembleTrackPoints, buildGpx, type GpxImageWaypoint } from '$lib/gpx';
import {
builder,
focusWaypoint,
mapView,
setRoutedSegments,
setElevations,
clearDraft,
@@ -14,6 +18,14 @@
densifyLinearSegments,
importGpx
} from '$lib/components/hikes/route-builder/builderStore.svelte';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import Crosshair from '@lucide/svelte/icons/crosshair';
import Download from '@lucide/svelte/icons/download';
import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
import CheckCircle2 from '@lucide/svelte/icons/circle-check-big';
import Toggle from '$lib/components/Toggle.svelte';
let busy = $state(false);
let error = $state<string | null>(null);
@@ -202,12 +214,10 @@
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() {
// GPX import: handed off to ImageDropzone's FAB picker, which forwards
// any `.gpx` file in the picked set here. Imported route REPLACES the
// current draft, so confirm first when there's existing work.
async function importGpxFile(file: File) {
if (
builder.waypoints.length > 0 &&
!confirm(
@@ -216,13 +226,6 @@
) {
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);
@@ -236,9 +239,6 @@
routeRequestId++;
} catch (err) {
error = `GPX-Import fehlgeschlagen: ${(err as Error).message}`;
} finally {
// Reset so the same file can be re-selected later.
input.value = '';
}
}
@@ -253,8 +253,73 @@
function cancelPlacement() {
pendingPlacementId = null;
}
// --- Prev / next waypoint navigation -------------------------------------
// Derived list of placed-only waypoints (the only ones that can sit on the
// map). The nav bar walks this list in display order so jumping with
// chevrons follows the table's numbering.
const placedWaypoints = $derived(builder.waypoints.filter((w) => !w.unplaced));
const focusedIdx = $derived.by(() => {
if (!mapView.focusId) return -1;
return placedWaypoints.findIndex((w) => w.id === mapView.focusId);
});
function focusByIdx(idx: number) {
const wp = placedWaypoints[idx];
if (!wp) return;
focusWaypoint(wp.id);
}
function focusPrev() {
if (placedWaypoints.length === 0) return;
if (focusedIdx <= 0) {
// No focus yet, or already at first → land on the last waypoint
// (typical "step backwards through the route" intent).
focusByIdx(focusedIdx === -1 ? placedWaypoints.length - 1 : 0);
return;
}
focusByIdx(focusedIdx - 1);
}
function focusNext() {
if (placedWaypoints.length === 0) return;
if (focusedIdx === -1) {
focusByIdx(0);
return;
}
if (focusedIdx >= placedWaypoints.length - 1) {
focusByIdx(placedWaypoints.length - 1);
return;
}
focusByIdx(focusedIdx + 1);
}
function refocus() {
// Re-center the map on the currently focused waypoint (handy after
// the user pans away).
if (focusedIdx === -1) focusByIdx(0);
else focusByIdx(focusedIdx);
}
// Keyboard shortcuts for power users: ← / → step through waypoints
// (when not focused on an input).
function onKey(e: KeyboardEvent) {
const target = e.target as HTMLElement | null;
if (target && /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return;
if (target?.isContentEditable) return;
if (e.key === 'ArrowLeft') {
e.preventDefault();
focusPrev();
} else if (e.key === 'ArrowRight') {
e.preventDefault();
focusNext();
}
}
</script>
<svelte:window onkeydown={onKey} />
<Seo title="Routen-Builder · Wanderungen" description="Eigene Wanderrouten erstellen, exportieren und teilen." lang="de" />
<svelte:head>
@@ -264,241 +329,496 @@
<section class="builder">
<header class="header">
<input
class="name-input"
type="text"
placeholder="Name der Tour…"
bind:value={builder.name}
/>
<div class="actions">
<select bind:value={builder.profile} disabled={!builder.autoSnap}>
<option value="hiking-mountain">Wandern (Berg)</option>
<option value="trekking">Trekking</option>
<option value="road">Strasse</option>
</select>
<label class="snap-toggle" class:active={builder.autoSnap}>
<input type="checkbox" bind:checked={builder.autoSnap} />
<span>Auf Wege snappen</span>
</label>
<!-- Mode-agnostic busy chip — fires for both the snap-to-route
path and the densify+elevate path so the user always knows
when the GPX is still incomplete. -->
<span class="status" class:busy aria-live="polite" aria-atomic="true">
<span class="status-dot" aria-hidden="true"></span>
{busy ? 'Berechne Route + Höhenprofil…' : 'Bereit'}
</span>
<div class="header-primary">
<input
class="name-input"
type="text"
placeholder="Name der Tour…"
bind:value={builder.name}
/>
<button
type="button"
class="primary"
class="download-cta"
onclick={downloadGpx}
disabled={busy}
title={busy ? 'Warten bis Route + Höhenprofil berechnet sind' : ''}
title={busy ? 'Warten bis Route + Höhenprofil berechnet sind' : 'GPX herunterladen'}
>
GPX herunterladen
<Download size={16} strokeWidth={2.2} />
<span>GPX herunterladen</span>
</button>
<button
type="button"
class="link"
onclick={openGpxPicker}
title="Eine zuvor exportierte GPX-Datei in den Editor laden"
class="reset-btn"
onclick={clearDraft}
aria-label="Entwurf zurücksetzen"
title="Entwurf zurücksetzen"
>
GPX laden
<RotateCcw size={16} strokeWidth={2} />
</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>
<div class="header-settings">
<div class="setting">
<span class="setting-label">Routing</span>
<select
class="profile-select"
bind:value={builder.profile}
disabled={!builder.autoSnap}
>
<option value="hiking-mountain">Wandern (Berg)</option>
<option value="trekking">Trekking</option>
<option value="road">Strasse</option>
</select>
</div>
<div class="setting setting-toggle">
<Toggle bind:checked={builder.autoSnap} label="Auf Wege snappen" />
</div>
<!-- Mode-agnostic snap status. Spinner while the route or
elevation profile is still resolving; subtle check when idle. -->
<span class="snap-status" class:busy aria-live="polite" aria-atomic="true">
{#if busy}
<LoaderCircle size={14} strokeWidth={2.4} class="spin" />
<span>Berechne Route + Höhenprofil…</span>
{:else}
<CheckCircle2 size={14} strokeWidth={2} />
<span>Bereit</span>
{/if}
</span>
</div>
</header>
<RouteStatsBar {busy} />
{#if error}
<p class="err">{error}</p>
{/if}
<div class="grid">
<div class="map-col">
<div class="map-row">
<div class="map-stage">
<EditMap
{pendingPlacementId}
onPlacementCancel={cancelPlacement}
onPlacementComplete={cancelPlacement}
/>
{#if placedWaypoints.length > 0}
<div class="map-nav" role="group" aria-label="Wegpunkt-Navigation">
<button
type="button"
onclick={focusPrev}
aria-label="Vorheriger Wegpunkt"
title="Vorheriger Wegpunkt (←)"
>
<ChevronLeft size={18} strokeWidth={2.2} />
</button>
<span class="map-nav-label" aria-live="polite">
{focusedIdx === -1 ? '' : focusedIdx + 1}
<span class="sep">/</span>
{placedWaypoints.length}
</span>
<button
type="button"
class="recenter"
onclick={refocus}
aria-label="Auf aktuellen Wegpunkt zentrieren"
title="Zentrieren"
>
<Crosshair size={16} strokeWidth={2.2} />
</button>
<button
type="button"
onclick={focusNext}
aria-label="Nächster Wegpunkt"
title="Nächster Wegpunkt (→)"
>
<ChevronRight size={18} strokeWidth={2.2} />
</button>
</div>
{/if}
</div>
<div class="side">
<WaypointTable
{pendingPlacementId}
onRequestPlacement={startPlacement}
onCancelPlacement={cancelPlacement}
/>
<ImageDropzone />
<div class="side-col">
{#if placedWaypoints.length > 0}
<nav class="step-nav" aria-label="Zwischen Wegpunkten wechseln">
<button
type="button"
onclick={focusPrev}
disabled={focusedIdx === 0}
aria-label="Vorheriger Wegpunkt"
>
<ChevronLeft size={16} strokeWidth={2} />
<span>Zurück</span>
</button>
<span class="step-pos">
{focusedIdx === -1 ? '' : focusedIdx + 1}
<span class="sep">/</span>
{placedWaypoints.length}
</span>
<button
type="button"
onclick={focusNext}
disabled={focusedIdx === placedWaypoints.length - 1}
aria-label="Nächster Wegpunkt"
>
<span>Weiter</span>
<ChevronRight size={16} strokeWidth={2} />
</button>
</nav>
{/if}
<WaypointDetailPanel onCancelPlacement={cancelPlacement} />
</div>
</div>
<WaypointTable
{pendingPlacementId}
onRequestPlacement={startPlacement}
onCancelPlacement={cancelPlacement}
/>
<p class="hint">
Tipp: Klicke auf die Karte, um Wegpunkte hinzuzufügen. Bilder mit GPS-EXIF werden
automatisch als Wegpunkte verwendet. Der GPX-Export bleibt lokal — eine Veröffentlichung
als Wandereintrag erfordert einen Commit der Dateien unter
automatisch als Wegpunkte verwendet. Mit den Pfeiltasten ← → kannst du die Wegpunkte
nacheinander auf der Karte anspringen. Der GPX-Export bleibt lokal — eine
Veröffentlichung als Wandereintrag erfordert einen Commit der Dateien unter
<code>src/content/hikes/&lt;slug&gt;/</code>.
</p>
</section>
<!-- FAB lives outside .builder so its fixed-positioning doesn't add a
phantom flex item / gap to the page's vertical flow. -->
<ImageDropzone onGpxImport={importGpxFile} />
<style>
.builder {
max-width: 1300px;
max-width: 1400px;
margin-inline: auto;
padding: 1rem 1rem 3rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.header {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
flex-direction: column;
gap: 0.6rem;
margin: 0;
}
/* Primary row: name input is the dominant element; download is the
* single primary CTA; reset is an icon-only escape hatch. */
.header-primary {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
gap: 0.5rem;
}
.name-input {
flex: 1 1 240px;
font-size: 1.25rem;
flex: 1 1 auto;
min-width: 0;
font-size: 1.35rem;
font-weight: 600;
padding: 0.5rem 0.75rem;
padding: 0.55rem 0.85rem;
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-primary);
}
.actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
.name-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-primary) 20%, transparent);
}
.actions select,
.actions button {
.download-cta {
appearance: none;
display: inline-flex;
align-items: center;
gap: 0.45rem;
font: inherit;
font-size: 0.9rem;
padding: 0.45rem 0.9rem;
font-weight: 600;
padding: 0.55rem 1rem;
border-radius: var(--radius-pill);
border: 1px solid var(--color-primary);
background: var(--color-primary);
color: var(--color-text-on-primary);
cursor: pointer;
transition: background var(--transition-fast), transform var(--transition-fast),
box-shadow var(--transition-fast);
flex-shrink: 0;
}
.download-cta:hover:not(:disabled) {
background: var(--color-primary-hover, var(--color-primary));
transform: translateY(-1px);
box-shadow: 0 0.4em 1em -0.4em color-mix(in oklab, var(--color-primary) 65%, transparent);
}
.download-cta:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.reset-btn {
appearance: none;
background: transparent;
border: 1px solid var(--color-border);
background: var(--color-bg-tertiary);
color: var(--color-text-tertiary);
padding: 0.5rem;
border-radius: 50%;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: color var(--transition-fast), border-color var(--transition-fast),
background var(--transition-fast);
flex-shrink: 0;
}
.reset-btn:hover {
color: var(--red);
border-color: color-mix(in oklab, var(--red) 35%, var(--color-border));
background: color-mix(in oklab, var(--red) 8%, transparent);
}
/* Secondary row: routing settings grouped on the left, live snap
* status on the right. Quieter than the primary row by design. */
.header-settings {
display: flex;
align-items: center;
gap: 1.25rem;
flex-wrap: wrap;
padding: 0.25rem 0.15rem 0;
}
.setting {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.setting-label {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-tertiary);
font-weight: 600;
}
.profile-select {
appearance: none;
font: inherit;
font-size: 0.85rem;
padding: 0.35rem 1.85rem 0.35rem 0.7rem;
background: var(--color-bg-tertiary)
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2381a1c1' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'/></svg>")
no-repeat right 0.55rem center;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-primary);
cursor: pointer;
}
.actions button.primary {
background: var(--color-primary);
color: var(--color-text-on-primary);
border-color: var(--color-primary);
}
.actions button.link {
background: transparent;
border-color: transparent;
color: var(--color-text-secondary);
}
.actions button:disabled,
.actions select:disabled {
opacity: 0.6;
.profile-select:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.snap-toggle {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font: inherit;
font-size: 0.9rem;
padding: 0.45rem 0.9rem;
border-radius: var(--radius-pill);
border: 1px solid var(--color-border);
background: var(--color-bg-tertiary);
color: var(--color-text-secondary);
cursor: pointer;
user-select: none;
.setting-toggle :global(.toggle-wrapper label) {
font-size: 0.85rem;
gap: 0.55rem;
}
.snap-toggle.active {
background: var(--color-primary);
color: var(--color-text-on-primary);
border-color: var(--color-primary);
}
.snap-toggle input {
accent-color: var(--color-primary);
width: 1rem;
height: 1rem;
}
/* Live busy chip — sits between the snap toggle and the download
* button so the user can't miss it when GPX export would land
* without elevations yet. Quiet green dot when idle, pulsing
* amber dot when fetching. */
.status {
.snap-status {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.8rem;
color: var(--color-text-secondary);
color: var(--color-text-tertiary);
font-variant-numeric: tabular-nums;
min-width: 0;
}
.status-dot {
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
background: var(--green);
flex: 0 0 auto;
box-shadow: 0 0 0 0 color-mix(in oklab, var(--green) 40%, transparent);
.snap-status :global(svg) {
color: var(--green);
flex-shrink: 0;
}
.status.busy {
color: var(--orange);
.snap-status.busy {
color: var(--color-text-secondary);
}
.status.busy .status-dot {
background: var(--orange);
animation: status-pulse 1.1s ease-out infinite;
.snap-status.busy :global(svg) {
color: var(--color-primary);
}
@keyframes status-pulse {
0% {
box-shadow: 0 0 0 0 color-mix(in oklab, var(--orange) 70%, transparent);
}
70% {
box-shadow: 0 0 0 0.55rem color-mix(in oklab, var(--orange) 0%, transparent);
}
100% {
box-shadow: 0 0 0 0 color-mix(in oklab, var(--orange) 0%, transparent);
}
.snap-status :global(.spin) {
animation: snap-spin 0.85s linear infinite;
}
@keyframes snap-spin {
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.status.busy .status-dot {
.snap-status :global(.spin) {
animation: none;
}
}
.grid {
.map-row {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(280px, 1fr);
gap: 1.5rem;
grid-template-columns: minmax(0, 2.2fr) minmax(280px, 1fr);
gap: 1rem;
align-items: start;
}
@media (max-width: 900px) {
.grid {
@media (max-width: 1024px) {
.map-row {
grid-template-columns: 1fr;
}
}
.map-stage {
position: relative;
min-width: 0;
}
.side-col {
display: flex;
flex-direction: column;
gap: 0.75rem;
min-width: 0;
}
/* Stand-alone Zurück/Weiter slider sitting above the detail-panel card,
* not inside it — gives the user the same step controls as the floating
* map-nav, but with full labels in the side column. */
.step-nav {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 0.3rem;
align-items: center;
padding: 0.3rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
box-shadow: var(--shadow-sm);
}
.step-nav button {
appearance: none;
background: transparent;
border: 0;
color: var(--color-text-primary);
font: inherit;
font-size: 0.85rem;
padding: 0.4rem 0.75rem;
border-radius: var(--radius-pill);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.3rem;
}
.step-nav button:first-child {
justify-self: start;
}
.step-nav button:last-child {
justify-self: end;
}
.step-nav button:hover:not(:disabled) {
background: var(--color-bg-elevated);
}
.step-nav button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.step-pos {
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-secondary);
font-variant-numeric: tabular-nums;
min-width: 3.2rem;
text-align: center;
}
.step-pos .sep {
color: var(--color-text-tertiary);
margin: 0 0.2rem;
font-weight: 400;
}
/* Floating prev/next navigator anchored to the top-right of the map.
* Sits above Leaflet's own zoom controls (z-index 1000); we use 600 to
* stay above markers/polylines (400) but below modal-ish UI. */
.map-nav {
position: absolute;
top: 0.75rem;
right: 0.75rem;
z-index: 600;
display: inline-flex;
align-items: center;
gap: 0.1rem;
padding: 0.25rem;
background: color-mix(in oklab, var(--color-surface) 92%, transparent);
backdrop-filter: blur(8px);
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
box-shadow: var(--shadow-md);
font: inherit;
}
.map-nav button {
appearance: none;
background: transparent;
border: 0;
color: var(--color-text-primary);
width: 2rem;
height: 2rem;
border-radius: 50%;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background var(--transition-fast), color var(--transition-fast);
}
.map-nav button:hover {
background: var(--color-bg-elevated);
}
.map-nav button:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.map-nav button.recenter {
color: var(--blue);
}
.map-nav-label {
padding: 0 0.4rem;
font-size: 0.85rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--color-text-primary);
min-width: 3.2rem;
text-align: center;
}
.map-nav-label .sep {
color: var(--color-text-tertiary);
margin: 0 0.15rem;
font-weight: 400;
}
.err {
margin: 0 0 1rem;
margin: 0;
padding: 0.5rem 0.75rem;
background: color-mix(in oklab, var(--red) 12%, transparent);
color: var(--red);
@@ -506,7 +826,7 @@
}
.hint {
margin-top: 1.5rem;
margin-top: 0.25rem;
color: var(--color-text-tertiary);
font-size: 0.85rem;
}