feat(hikes): multi-day stages (separate GPX tracks, stage nav, builder)

Represent a multi-day hike as separate named GPX <trk> elements, one per
stage, while still treating the whole thing as one route on the overview.

GPX & build:
- gpx.ts: parseGpxStages (one stage per <trk>) + multi-track buildGpx.
- build-hikes.ts: per-stage stats with totals summed across stages so the
  overnight gaps (distance, time) and the altitude jump between stages are
  excluded; previewBreaks recorded where stages sit >1 km apart.
- types: HikeStage, manifest `stages?` and `previewBreaks?` (both optional —
  single-stage hikes are unchanged).

Detail page:
- HikeStageNav: a light itinerary-stepper switcher (numbered nodes, active
  glows in the accent) writing a shared stageStore.
- Selecting a stage scopes the metrics, elevation profile (x-window),
  map (highlight + zoom, dim the rest) and photo strip/markers; "Alle
  Etappen" shows the whole route.

Overview: live map and the prerendered static composite both break the
preview line across >1 km inter-stage transfers (previewBreaks).

Route builder:
- Mark any placed waypoint as a stage start (named) from the waypoint list
  or the detail panel; export assembles each stage independently into its
  own <trk>; import re-marks stage boundaries from a multi-track GPX.
This commit is contained in:
2026-05-22 14:14:57 +02:00
parent 603240bf93
commit 6483c55fce
17 changed files with 1012 additions and 77 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "1.81.0", "version": "1.82.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+117 -11
View File
@@ -20,11 +20,13 @@ import crypto from 'node:crypto';
import os from 'node:os'; import os from 'node:os';
import sharp from 'sharp'; import sharp from 'sharp';
import { import {
parseGpx, parseGpxStages,
parseGpxImageRefs, parseGpxImageRefs,
trackDistance, trackDistance,
haversine,
type GpxImageRef, type GpxImageRef,
type GpxPoint type GpxPoint,
type GpxStage
} from '../src/lib/server/gpx.js'; } from '../src/lib/server/gpx.js';
import { simplifyTrack } from '../src/lib/server/simplifyTrack.js'; import { simplifyTrack } from '../src/lib/server/simplifyTrack.js';
import { computeStaticMapPose, renderOverviewMap, renderStaticMap } from './staticHikeMap.js'; import { computeStaticMapPose, renderOverviewMap, renderStaticMap } from './staticHikeMap.js';
@@ -33,6 +35,7 @@ import { computeElevationStats, computeElevationRange } from '../src/lib/hikes/e
import type { import type {
Difficulty, Difficulty,
HikeManifestEntry, HikeManifestEntry,
HikeStage,
HikesOverview, HikesOverview,
ImagePoint, ImagePoint,
ImageVariant ImageVariant
@@ -207,6 +210,53 @@ function computeBboxAndCentroid(track: GpxPoint[]): {
}; };
} }
// Overview preview polyline. Stages whose join gap exceeds this are drawn as
// separate runs (a break) so the overview doesn't connect across an overnight
// transfer; closer stages stay one continuous line.
const PREVIEW_GAP_BREAK_KM = 1;
function buildPreview(stages: GpxStage[]): {
previewPolyline: [number, number][];
previewBreaks: number[];
} {
// Group consecutive stages into runs, splitting only at a significant gap.
const runs: GpxPoint[][] = [];
let current: GpxPoint[] = [];
for (let i = 0; i < stages.length; i++) {
if (i > 0 && current.length > 0) {
const prevEnd = stages[i - 1].points[stages[i - 1].points.length - 1];
const curStart = stages[i].points[0];
if (haversine(prevEnd, curStart) > PREVIEW_GAP_BREAK_KM) {
runs.push(current);
current = [];
}
}
current.push(...stages[i].points);
}
if (current.length > 0) runs.push(current);
// One run (every single-stage hike, and multi-stage hikes with only small
// gaps): identical to the previous behaviour — one simplified line.
if (runs.length <= 1) {
return {
previewPolyline: simplifyTrack(runs[0] ?? [], PREVIEW_POLYLINE_MAX_POINTS) as [number, number][],
previewBreaks: []
};
}
// Multiple runs: simplify each within a proportional point budget so the
// total stays near PREVIEW_POLYLINE_MAX_POINTS, recording the run starts.
const total = runs.reduce((a, r) => a + r.length, 0) || 1;
const previewPolyline: [number, number][] = [];
const previewBreaks: number[] = [];
for (const run of runs) {
if (previewPolyline.length > 0) previewBreaks.push(previewPolyline.length);
const budget = Math.max(2, Math.round((PREVIEW_POLYLINE_MAX_POINTS * run.length) / total));
previewPolyline.push(...(simplifyTrack(run, budget) as [number, number][]));
}
return { previewPolyline, previewBreaks };
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Swisstopo reverse-geocode with disk cache // Swisstopo reverse-geocode with disk cache
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -646,7 +696,8 @@ async function processOverview(
.filter((h) => h.previewPolyline && h.previewPolyline.length >= 2) .filter((h) => h.previewPolyline && h.previewPolyline.length >= 2)
.map((h) => ({ .map((h) => ({
points: h.previewPolyline, points: h.previewPolyline,
color: SAC_TRAIL_COLOR[h.difficulty] ?? '#5e81ac' color: SAC_TRAIL_COLOR[h.difficulty] ?? '#5e81ac',
breaks: h.previewBreaks
})); }));
if (lines.length === 0) return undefined; if (lines.length === 0) return undefined;
@@ -945,22 +996,75 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise<HikeManifes
} }
const { data: fm } = parseFrontmatter(svxSource); const { data: fm } = parseFrontmatter(svxSource);
const track = parseGpx(gpxSource); // One stage per <trk>. The flat track is their concatenation — identical to
// the old `parseGpx` output for single-track GPX, so everything downstream
// (track JSON, hero map, images) is unchanged for normal hikes.
const gpxStages = parseGpxStages(gpxSource);
const track: GpxPoint[] = gpxStages.flatMap((s) => s.points);
if (track.length === 0) { if (track.length === 0) {
console.warn(`[build-hikes] Skipping ${slug}: empty GPX`); console.warn(`[build-hikes] Skipping ${slug}: empty GPX`);
return null; return null;
} }
const gpxImageRefs = parseGpxImageRefs(gpxSource); const gpxImageRefs = parseGpxImageRefs(gpxSource);
const gpxImageCount = Object.keys(gpxImageRefs).length; const gpxImageCount = Object.keys(gpxImageRefs).length;
console.log(`[build-hikes:${slug}] parsed GPX (${track.length} track pts, ${gpxImageCount} image refs)`); console.log(`[build-hikes:${slug}] parsed GPX (${track.length} track pts, ${gpxStages.length} stage(s), ${gpxImageCount} image refs)`);
// Per-stage stats + flat-track index ranges. Indices are contiguous and
// disjoint (endIdx + 1 === next.startIdx).
const stageEntries: HikeStage[] = [];
{
let offset = 0;
for (const s of gpxStages) {
const startIdx = offset;
const endIdx = offset + s.points.length - 1;
offset = endIdx + 1;
const range = computeElevationRange(s.points);
const { gain: sGain, loss: sLoss } = computeElevationStats(s.points);
const sDtMs = s.points[s.points.length - 1].timestamp - s.points[0].timestamp;
stageEntries.push({
name: s.name ?? `Etappe ${stageEntries.length + 1}`,
startIdx,
endIdx,
distanceKm: trackDistance(s.points),
durationMin: sDtMs > 0 ? Math.round(sDtMs / 60000) : null,
elevationGainM: sGain,
elevationLossM: sLoss,
elevationMaxM: range.max,
elevationMinM: range.min
});
}
}
const multiStage = stageEntries.length >= 2;
// Totals: summed per-stage when multi-day, so overnight horizontal gaps
// (distance) and time gaps (duration) and the altitude jump between a
// stage's end and the next stage's start (gain/loss) are all excluded.
let distanceKm: number;
let gain: number;
let loss: number;
let durationMin: number | null;
let elevationMinM: number | null;
let elevationMaxM: number | null;
if (multiStage) {
distanceKm = stageEntries.reduce((a, s) => a + s.distanceKm, 0);
gain = stageEntries.reduce((a, s) => a + s.elevationGainM, 0);
loss = stageEntries.reduce((a, s) => a + s.elevationLossM, 0);
const durs = stageEntries.map((s) => s.durationMin).filter((d): d is number => d != null);
durationMin = durs.length > 0 ? durs.reduce((a, d) => a + d, 0) : null;
const mins = stageEntries.map((s) => s.elevationMinM).filter((v): v is number => v != null);
const maxs = stageEntries.map((s) => s.elevationMaxM).filter((v): v is number => v != null);
elevationMinM = mins.length > 0 ? Math.min(...mins) : null;
elevationMaxM = maxs.length > 0 ? Math.max(...maxs) : null;
} else {
distanceKm = trackDistance(track);
({ gain, loss } = computeElevationStats(track));
({ min: elevationMinM, max: elevationMaxM } = computeElevationRange(track));
const dtMs = track[track.length - 1].timestamp - track[0].timestamp;
durationMin = dtMs > 0 ? Math.round(dtMs / 60000) : null;
}
const distanceKm = trackDistance(track);
const { gain, loss } = computeElevationStats(track);
const { min: elevationMinM, max: elevationMaxM } = computeElevationRange(track);
const { bbox, centroid } = computeBboxAndCentroid(track); const { bbox, centroid } = computeBboxAndCentroid(track);
const previewPolyline = simplifyTrack(track, PREVIEW_POLYLINE_MAX_POINTS) as [number, number][]; const { previewPolyline, previewBreaks } = buildPreview(gpxStages);
const dtMs = track[track.length - 1].timestamp - track[0].timestamp;
const durationMin = dtMs > 0 ? Math.round(dtMs / 60000) : null;
console.log(`[build-hikes:${slug}] metrics: ${distanceKm.toFixed(2)} km · ↑${gain}m / ↓${loss}m · ${elevationMinM ?? '?'}${elevationMaxM ?? '?'}m · ${durationMin ?? '?'} min`); console.log(`[build-hikes:${slug}] metrics: ${distanceKm.toFixed(2)} km · ↑${gain}m / ↓${loss}m · ${elevationMinM ?? '?'}${elevationMaxM ?? '?'}m · ${durationMin ?? '?'} min`);
const geoT0 = Date.now(); const geoT0 = Date.now();
@@ -1156,6 +1260,8 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise<HikeManifes
bbox, bbox,
centroid, centroid,
previewPolyline, previewPolyline,
...(previewBreaks.length > 0 ? { previewBreaks } : {}),
...(multiStage ? { stages: stageEntries } : {}),
region: geo.region, region: geo.region,
canton: geo.canton, canton: geo.canton,
municipality: geo.municipality, municipality: geo.municipality,
+7 -1
View File
@@ -361,6 +361,9 @@ export async function renderStaticMap(opts: RenderStaticMapOpts): Promise<boolea
export interface RenderOverviewPolyline { export interface RenderOverviewPolyline {
points: Array<[number, number]>; points: Array<[number, number]>;
color: string; color: string;
/** Indices where a new disconnected sub-path begins (multi-day stage gaps
* >1 km), so the line isn't drawn across an overnight transfer. */
breaks?: number[];
} }
export interface RenderOverviewMapOpts { export interface RenderOverviewMapOpts {
@@ -388,13 +391,16 @@ export async function renderOverviewMap(opts: RenderOverviewMapOpts): Promise<bo
// zoomed-out, so even ≤150-point preview polylines stay compact. // zoomed-out, so even ≤150-point preview polylines stay compact.
const paths = drawable const paths = drawable
.map((line) => { .map((line) => {
const breakSet = new Set(line.breaks ?? []);
const parts: string[] = []; const parts: string[] = [];
for (let i = 0; i < line.points.length; i++) { for (let i = 0; i < line.points.length; i++) {
const [lat, lng] = line.points[i]; const [lat, lng] = line.points[i];
const p = lngLatToPx(lng, lat, zoom); const p = lngLatToPx(lng, lat, zoom);
const px = p.x - originX; const px = p.x - originX;
const py = p.y - originY; const py = p.y - originY;
parts.push((i === 0 ? 'M' : 'L') + escapeSvgNumber(px) + ',' + escapeSvgNumber(py)); // Start a fresh sub-path at index 0 and at every stage break.
const cmd = i === 0 || breakSet.has(i) ? 'M' : 'L';
parts.push(cmd + escapeSvgNumber(px) + ',' + escapeSvgNumber(py));
} }
return ( return (
`<path d="${parts.join(' ')}" fill="none" stroke="${line.color}" ` + `<path d="${parts.join(' ')}" fill="none" stroke="${line.color}" ` +
@@ -6,9 +6,21 @@
interface Props { interface Props {
track: HikeTrackPoint[]; track: HikeTrackPoint[];
/** Restrict the x-axis to a stage's index range (multi-day hikes). The
* dataset stays the full track so hover indices remain global. */
viewRange?: { startIdx: number; endIdx: number } | null;
} }
const { track }: Props = $props(); const { track, viewRange = null }: Props = $props();
// x-axis window in km for the current view (whole track, or a stage slice).
function xBounds(): { min: number; max: number } {
const last = cumKm[cumKm.length - 1] ?? 0;
if (!viewRange) return { min: 0, max: last };
const lo = Math.max(0, Math.min(viewRange.startIdx, cumKm.length - 1));
const hi = Math.max(0, Math.min(viewRange.endIdx, cumKm.length - 1));
return { min: cumKm[lo] ?? 0, max: cumKm[hi] ?? last };
}
let canvas = $state<HTMLCanvasElement | undefined>(undefined); let canvas = $state<HTMLCanvasElement | undefined>(undefined);
let chart: ChartType | null = null; let chart: ChartType | null = null;
@@ -137,9 +149,10 @@
type: 'linear', type: 'linear',
// Pin the axis to the actual data range so Chart.js doesn't // Pin the axis to the actual data range so Chart.js doesn't
// round up to the next nice tick — otherwise a 12.3 km hike // round up to the next nice tick — otherwise a 12.3 km hike
// ends up with empty space out to 14 km. // ends up with empty space out to 14 km. When a stage is
min: 0, // selected, the window narrows to that stage.
max: cumKm[cumKm.length - 1] ?? 0, min: xBounds().min,
max: xBounds().max,
bounds: 'data', bounds: 'data',
title: { display: true, text: 'Distanz (km)', color: textColor }, title: { display: true, text: 'Distanz (km)', color: textColor },
ticks: { color: textColor }, ticks: { color: textColor },
@@ -220,6 +233,20 @@
chart.update('none'); chart.update('none');
}); });
// Re-window the x-axis when the active stage changes (reads `viewRange`).
$effect(() => {
const b = (() => {
void viewRange;
return xBounds();
})();
if (!chart) return;
const xScale = chart.options.scales?.x;
if (!xScale) return;
xScale.min = b.min;
xScale.max = b.max;
chart.update('none');
});
// Mouse-leave on the canvas clears the shared hover state so the map marker // Mouse-leave on the canvas clears the shared hover state so the map marker
// disappears too. // disappears too.
function onCanvasMouseLeave() { function onCanvasMouseLeave() {
+69 -2
View File
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import type { Attachment } from 'svelte/attachments'; import type { Attachment } from 'svelte/attachments';
import type { HikeTrackPoint, ImagePoint } from '$types/hikes'; import type { HikeTrackPoint, ImagePoint, HikeStage } from '$types/hikes';
import { hover, setHover, clearHover } from './hoverStore.svelte'; import { hover, setHover, clearHover } from './hoverStore.svelte';
import { stage } from './stageStore.svelte';
import { focused, setFocused, clearFocused } from './focusedImageStore.svelte'; import { focused, setFocused, clearFocused } from './focusedImageStore.svelte';
import Map from '@lucide/svelte/icons/map'; import Map from '@lucide/svelte/icons/map';
import Satellite from '@lucide/svelte/icons/satellite'; import Satellite from '@lucide/svelte/icons/satellite';
@@ -34,6 +35,10 @@
* route on the /hikes overview map (orange for T1, red for T2/T3, * route on the /hikes overview map (orange for T1, red for T2/T3,
* blue for T4T6). */ * blue for T4T6). */
trackColor?: string; trackColor?: string;
/** Stage ranges for a multi-day hike. When a stage is active (shared
* stageStore) the map highlights it, dims the rest, zooms to it, and
* scopes photo markers to that stage. */
stages?: HikeStage[] | null;
} }
const { const {
@@ -43,7 +48,8 @@
initialCenter, initialCenter,
initialZoom, initialZoom,
onReady, onReady,
trackColor trackColor,
stages = null
}: Props = $props(); }: Props = $props();
// User-location toggle moved inside the map UI. localStorage-persisted so // User-location toggle moved inside the map UI. localStorage-persisted so
@@ -236,6 +242,15 @@
interactive: false interactive: false
}).addTo(map); }).addTo(map);
// Brighter overlay drawn over the active stage (multi-day hikes); the
// base line is dimmed underneath it. Empty until a stage is selected.
const stageOverlay = L.polyline([] as [number, number][], {
color: trailColor,
weight: 6,
opacity: 1,
interactive: false
});
L.circleMarker(latLngs[0], { L.circleMarker(latLngs[0], {
radius: 6, radius: 6,
fillColor: '#a3be8c', fillColor: '#a3be8c',
@@ -313,6 +328,31 @@
'<circle cx="12" cy="13" r="3"/>' + '<circle cx="12" cy="13" r="3"/>' +
'</svg>'; '</svg>';
// Nearest track sample (by time) to a photo — used to test which
// stage a photo belongs to when scoping markers to the active stage.
function nearestTrackIdx(ts: number): number {
let best = -1;
let bestD = Infinity;
for (let i = 0; i < track.length; i++) {
const t = track[i][3];
if (typeof t !== 'number') continue;
const d = Math.abs(t - ts);
if (d < bestD) {
bestD = d;
best = i;
}
}
return best;
}
function photoInActiveStage(ip: ImagePoint): boolean {
const active = stage.active;
if (active === null || !stages || !stages[active]) return true;
if (typeof ip.timestamp !== 'number') return false;
const s = stages[active];
const idx = nearestTrackIdx(ip.timestamp);
return idx >= s.startIdx && idx <= s.endIdx;
}
function renderPhotos() { function renderPhotos() {
photoLayer.clearLayers(); photoLayer.clearLayers();
visiblePoints = []; visiblePoints = [];
@@ -322,6 +362,9 @@
if (ip.visibility === 'private' && !showPrivate) continue; if (ip.visibility === 'private' && !showPrivate) continue;
const visibleIdx = visiblePoints.length; const visibleIdx = visiblePoints.length;
visiblePoints.push(ip); visiblePoints.push(ip);
// Keep `visiblePoints` aligned with the strip's index space, but
// only draw a marker when the photo is in the active stage.
if (!photoInActiveStage(ip)) continue;
const altSafe = ip.alt.replace(/"/g, '&quot;'); const altSafe = ip.alt.replace(/"/g, '&quot;');
const isPrivate = ip.visibility === 'private'; const isPrivate = ip.visibility === 'private';
const icon = L.divIcon({ const icon = L.divIcon({
@@ -540,10 +583,34 @@
// React to user-toggle of photo markers, base-layer choice, and the // React to user-toggle of photo markers, base-layer choice, and the
// enableUserLocation prop. // enableUserLocation prop.
let stageInitialized = false;
const stopReactRoot = $effect.root(() => { const stopReactRoot = $effect.root(() => {
$effect(() => { $effect(() => {
renderPhotos(); renderPhotos();
}); });
// Active-stage highlight + zoom. The first run (active === null on
// mount) only normalises the base style — it must NOT fly, or it
// would clobber the static-hero → live handover above.
$effect(() => {
const active = stage.active;
if (active !== null && stages && stages[active]) {
const s = stages[active];
stageOverlay.setLatLngs(latLngs.slice(s.startIdx, s.endIdx + 1));
if (!map.hasLayer(stageOverlay)) stageOverlay.addTo(map);
polyline.setStyle({ opacity: 0.28 });
const b = stageOverlay.getBounds();
if (b.isValid()) {
map.flyToBounds(b, { padding: [40, 40], duration: 0.6, easeLinearity: 0.25 });
}
stageInitialized = true;
} else {
if (map.hasLayer(stageOverlay)) stageOverlay.remove();
polyline.setStyle({ opacity: 0.95 });
if (stageInitialized) {
map.flyToBounds(initialBounds, { padding: [24, 24], duration: 0.6, easeLinearity: 0.25 });
}
}
});
$effect(() => { $effect(() => {
if (baseLayer === currentBase) return; if (baseLayer === currentBase) return;
tileLayers[currentBase].remove(); tileLayers[currentBase].remove();
+61 -14
View File
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import type { HikeTrackPoint, ImagePoint } from '$types/hikes'; import type { HikeTrackPoint, ImagePoint, HikeStage } from '$types/hikes';
import { focused, setFocused } from './focusedImageStore.svelte'; import { focused, setFocused } from './focusedImageStore.svelte';
import { stage } from './stageStore.svelte';
import MapPin from '@lucide/svelte/icons/map-pin'; import MapPin from '@lucide/svelte/icons/map-pin';
import Lock from '@lucide/svelte/icons/lock'; import Lock from '@lucide/svelte/icons/lock';
import ChevronLeft from '@lucide/svelte/icons/chevron-left'; import ChevronLeft from '@lucide/svelte/icons/chevron-left';
@@ -12,9 +13,45 @@
interface Props { interface Props {
images: ImagePoint[]; images: ImagePoint[];
track: HikeTrackPoint[]; track: HikeTrackPoint[];
/** Stage ranges (multi-day hikes). When a stage is active, the strip
* shows only that stage's photos. Indices stay aligned with the full
* list so the shared focus store keeps matching the map. */
stages?: HikeStage[] | null;
} }
const { images, track }: Props = $props(); const { images, track, stages = null }: Props = $props();
// Nearest track index (by time) per image — for testing stage membership.
const imageTrackIdx = $derived(
images.map((ip) => {
if (typeof ip.timestamp !== 'number') return -1;
let best = -1;
let bestD = Infinity;
for (let i = 0; i < track.length; i++) {
const t = track[i][3];
if (typeof t !== 'number') continue;
const d = Math.abs(t - ip.timestamp);
if (d < bestD) {
bestD = d;
best = i;
}
}
return best;
})
);
const activeStageRange = $derived.by(() => {
if (stage.active === null || !stages || !stages[stage.active]) return null;
const s = stages[stage.active];
return { startIdx: s.startIdx, endIdx: s.endIdx };
});
function inActiveStage(i: number): boolean {
const r = activeStageRange;
if (!r) return true;
const idx = imageTrackIdx[i];
return idx >= r.startIdx && idx <= r.endIdx;
}
const startTimestamp = $derived.by(() => { const startTimestamp = $derived.by(() => {
for (const p of track) { for (const p of track) {
@@ -109,23 +146,31 @@
} }
} }
// Step to the next/previous photo that's in the active stage (skips photos
// hidden by stage scoping).
function advance(direction: -1 | 1): void { function advance(direction: -1 | 1): void {
if (images.length === 0) return; if (images.length === 0) return;
const current = focused.index; let i = focused.index === null ? (direction === 1 ? -1 : images.length) : focused.index;
let next: number; i += direction;
if (current === null) { while (i >= 0 && i < images.length) {
next = direction === 1 ? 0 : images.length - 1; if (inActiveStage(i)) {
} else { setFocused(i, 'strip');
next = current + direction; return;
if (next < 0 || next >= images.length) return; }
i += direction;
} }
setFocused(next, 'strip');
} }
const canPrev = $derived(focused.index !== null && focused.index > 0); const canPrev = $derived.by(() => {
const canNext = $derived( if (focused.index === null) return false;
focused.index === null ? images.length > 0 : focused.index < images.length - 1 for (let i = focused.index - 1; i >= 0; i--) if (inActiveStage(i)) return true;
); return false;
});
const canNext = $derived.by(() => {
const start = focused.index === null ? -1 : focused.index;
for (let i = start + 1; i < images.length; i++) if (inActiveStage(i)) return true;
return false;
});
function onKey(e: KeyboardEvent): void { function onKey(e: KeyboardEvent): void {
if (images.length === 0) return; if (images.length === 0) return;
@@ -174,6 +219,7 @@
? formatElapsed(ip.timestamp - startTimestamp) ? formatElapsed(ip.timestamp - startTimestamp)
: null} : null}
{@const active = focused.index === i} {@const active = focused.index === i}
{#if inActiveStage(i)}
<div class="card-wrap" class:active bind:this={cardEls[i]}> <div class="card-wrap" class:active bind:this={cardEls[i]}>
<button <button
type="button" type="button"
@@ -208,6 +254,7 @@
<Expand size={15} strokeWidth={2} aria-hidden="true" /> <Expand size={15} strokeWidth={2} aria-hidden="true" />
</button> </button>
</div> </div>
{/if}
{/each} {/each}
</div> </div>
@@ -0,0 +1,191 @@
<script lang="ts">
// Stage switcher styled as a hut-to-hut itinerary line: a leading "Alle"
// pill, then numbered nodes joined by thin connectors. The active stage's
// node glows in the accent and its name/distance shows alongside. Light and
// in-flow (no boxed/blurred bar) — writes the shared stageStore.
import type { HikeStage } from '$types/hikes';
import { stage, setActiveStage } from './stageStore.svelte';
interface Props {
stages: HikeStage[];
}
const { stages }: Props = $props();
const active = $derived(stage.active);
const totalKm = $derived(stages.reduce((a, s) => a + s.distanceKm, 0));
</script>
<nav class="stepper" aria-label="Etappen">
<button
type="button"
class="all"
class:active={active === null}
aria-pressed={active === null}
onclick={() => setActiveStage(null)}
>
Alle
</button>
<ol class="line">
{#each stages as s, i (i)}
{#if i > 0}
<li class="connector" class:lit={active === null} aria-hidden="true"></li>
{/if}
<li>
<button
type="button"
class="node"
class:active={active === i}
class:lit={active === null}
aria-pressed={active === i}
aria-label={`Etappe ${i + 1}: ${s.name}`}
title={s.name}
onclick={() => setActiveStage(i)}
>
{i + 1}
</button>
</li>
{/each}
</ol>
<p class="label" aria-live="polite">
{#if active === null}
<span class="title">Alle Etappen</span>
<span class="dist">{totalKm.toFixed(1)} km</span>
{:else}
<span class="kicker">Etappe {active + 1}</span>
<span class="title">{stages[active].name}</span>
<span class="dist">{stages[active].distanceKm.toFixed(1)} km</span>
{/if}
</p>
</nav>
<style>
.stepper {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem 1rem;
/* (a) breathing room below the full-bleed hero map; horizontal inset
* matches the other detail sections. */
margin-top: 1.75rem;
padding: 0 1rem;
}
.all {
flex: 0 0 auto;
appearance: none;
font: inherit;
font-size: 0.82rem;
font-weight: 600;
padding: 0.25rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
transition: color var(--transition-fast), background-color var(--transition-fast),
border-color var(--transition-fast);
}
.all:hover {
color: var(--color-text-primary);
border-color: var(--color-border-hover);
}
.all.active {
background: var(--color-primary);
color: var(--color-text-on-primary);
border-color: var(--color-primary);
}
.line {
display: flex;
align-items: center;
gap: 0;
list-style: none;
margin: 0;
padding: 0;
}
.connector {
width: 1.75rem;
height: 2px;
flex: 0 0 auto;
border-radius: 2px;
background: var(--color-border);
transition: background-color var(--transition-fast);
}
.connector.lit {
background: color-mix(in srgb, var(--color-primary) 45%, var(--color-border));
}
.node {
display: grid;
place-items: center;
width: 1.75rem;
height: 1.75rem;
appearance: none;
font: inherit;
font-size: 0.8rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
border: 1.5px solid var(--color-border);
border-radius: 50%;
background: var(--color-surface);
color: var(--color-text-secondary);
cursor: pointer;
transition: color var(--transition-fast), background-color var(--transition-fast),
border-color var(--transition-fast), scale var(--transition-fast),
box-shadow var(--transition-fast);
}
.node:hover {
scale: 1.1;
border-color: var(--color-primary);
color: var(--color-text-primary);
}
/* "Alle" selected: whole line subtly lit so it reads as the full route. */
.node.lit {
border-color: color-mix(in srgb, var(--color-primary) 55%, var(--color-border));
color: var(--color-text-primary);
}
.node.active {
background: var(--color-primary);
color: var(--color-text-on-primary);
border-color: var(--color-primary);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-primary) 22%, transparent);
}
.label {
display: inline-flex;
align-items: baseline;
flex-wrap: wrap;
gap: 0.1rem 0.5rem;
margin: 0;
min-width: 0;
}
.kicker {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-tertiary);
}
.title {
font-size: 0.95rem;
font-weight: 600;
color: var(--color-text-primary);
}
.dist {
font-size: 0.82rem;
color: var(--color-text-tertiary);
font-variant-numeric: tabular-nums;
}
</style>
@@ -195,7 +195,22 @@
if (!hike.previewPolyline || hike.previewPolyline.length < 2) continue; if (!hike.previewPolyline || hike.previewPolyline.length < 2) continue;
const latLngs = hike.previewPolyline.map(([lat, lng]) => [lat, lng] as [number, number]); const latLngs = hike.previewPolyline.map(([lat, lng]) => [lat, lng] as [number, number]);
const color = sacTrailColor(hike.difficulty); const color = sacTrailColor(hike.difficulty);
const poly = L.polyline(latLngs, { // Multi-day hikes with a big inter-stage gap ship `previewBreaks`
// (indices where a new run starts); split there so Leaflet draws
// disconnected segments instead of a line across the transfer.
const breaks = hike.previewBreaks;
let coords: [number, number][] | [number, number][][] = latLngs;
if (breaks && breaks.length > 0) {
const segs: [number, number][][] = [];
let start = 0;
for (const brk of breaks) {
if (brk > start) segs.push(latLngs.slice(start, brk));
start = brk;
}
segs.push(latLngs.slice(start));
coords = segs.filter((s) => s.length >= 2);
}
const poly = L.polyline(coords, {
color, color,
weight: 4, weight: 4,
opacity: 0.9, opacity: 0.9,
@@ -4,7 +4,9 @@
focusWaypoint, focusWaypoint,
mapView, mapView,
placedSequence, placedSequence,
scheduleSave scheduleSave,
toggleStageBreak,
renameStage
} from './builderStore.svelte'; } from './builderStore.svelte';
import { generateImageHashClient } from '$lib/imageHashClient'; import { generateImageHashClient } from '$lib/imageHashClient';
import { readThumbnail } from './imageThumbnail'; import { readThumbnail } from './imageThumbnail';
@@ -16,6 +18,7 @@
import Globe from '@lucide/svelte/icons/globe'; import Globe from '@lucide/svelte/icons/globe';
import Lock from '@lucide/svelte/icons/lock'; import Lock from '@lucide/svelte/icons/lock';
import X from '@lucide/svelte/icons/x'; import X from '@lucide/svelte/icons/x';
import Flag from '@lucide/svelte/icons/flag';
interface Props { interface Props {
onCancelPlacement?: () => void; onCancelPlacement?: () => void;
@@ -45,6 +48,27 @@
}); });
const requiresTime = $derived(wpIdx !== -1 && (wpIdx === firstPlacedIdx || wpIdx === lastPlacedIdx)); const requiresTime = $derived(wpIdx !== -1 && (wpIdx === firstPlacedIdx || wpIdx === lastPlacedIdx));
// Stage info for the focused waypoint (multi-day hikes). Mirrors the
// per-row control in the waypoint list.
const stageInfo = $derived.by(() => {
if (!wp) return null;
let num = 0;
let name = '';
for (let i = 0; i < placed.length; i++) {
const w = placed[i];
const isStart = i === 0 || w.stageStart !== undefined;
if (isStart) {
num++;
name = w.stageStart || `Etappe ${num}`;
}
if (w.id === wp.id) return { isStart, num, name, isFirst: i === 0 };
}
return null;
});
const stageCount = $derived(
placed.reduce((n, w, i) => n + (i === 0 || w.stageStart !== undefined ? 1 : 0), 0)
);
function nearestTimestamp(idx: number): number | undefined { function nearestTimestamp(idx: number): number | undefined {
const wps = builder.waypoints; const wps = builder.waypoints;
for (let dist = 1; dist < wps.length; dist++) { for (let dist = 1; dist < wps.length; dist++) {
@@ -272,6 +296,31 @@
</button> </button>
{/if} {/if}
{#if !wp.unplaced}
{#if stageInfo?.isStart && stageCount > 1}
<div class="stage-block">
<span class="stage-cap"><Flag size={12} strokeWidth={2.25} />Etappe {stageInfo.num}</span>
<input
class="stage-name-input"
value={wp.stageStart ?? stageInfo.name}
placeholder={`Etappe ${stageInfo.num}`}
oninput={(e) => renameStage(wp.id, e.currentTarget.value)}
aria-label={`Name Etappe ${stageInfo.num}`}
/>
{#if !stageInfo.isFirst}
<button type="button" class="stage-dissolve" onclick={() => toggleStageBreak(wp.id)}>
Etappe auflösen
</button>
{/if}
</div>
{:else if !stageInfo?.isFirst}
<button type="button" class="stage-new" onclick={() => toggleStageBreak(wp.id)}>
<Flag size={15} strokeWidth={2} />
<span>Neue Etappe ab hier</span>
</button>
{/if}
{/if}
{#if !wp.unplaced} {#if !wp.unplaced}
<details class="coords-details"> <details class="coords-details">
<summary>Koordinaten anpassen</summary> <summary>Koordinaten anpassen</summary>
@@ -596,6 +645,89 @@
box-shadow: 0 0 0 2px color-mix(in oklab, var(--orange) 30%, transparent); box-shadow: 0 0 0 2px color-mix(in oklab, var(--orange) 30%, transparent);
} }
/* Stage controls — start a new stage at this waypoint, or name/dissolve an
* existing stage start. Mirrors the waypoint-list affordance. */
.stage-new {
appearance: none;
font: inherit;
font-size: 0.85rem;
font-weight: 600;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
padding: 0.5rem 0.9rem;
border-radius: var(--radius-pill);
background: transparent;
border: 1px dashed color-mix(in oklab, var(--color-primary) 40%, var(--color-border));
color: var(--color-primary);
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast),
color var(--transition-fast);
}
.stage-new:hover {
background: var(--color-primary);
border-style: solid;
border-color: var(--color-primary);
color: var(--color-text-on-primary);
}
.stage-block {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.6rem 0.7rem;
background: color-mix(in oklab, var(--color-primary) 8%, var(--color-bg-secondary));
border: 1px solid color-mix(in oklab, var(--color-primary) 25%, var(--color-border));
border-left: 3px solid var(--color-primary);
border-radius: var(--radius-md);
}
.stage-cap {
display: inline-flex;
align-items: center;
gap: 0.3rem;
flex: 0 0 auto;
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-primary);
}
.stage-name-input {
flex: 1 1 auto;
min-width: 0;
padding: 0.35rem 0.5rem;
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;
font-weight: 600;
}
.stage-dissolve {
flex: 0 0 auto;
appearance: none;
font: inherit;
font-size: 0.72rem;
padding: 0.3rem 0.5rem;
background: transparent;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-secondary);
cursor: pointer;
white-space: nowrap;
}
.stage-dissolve:hover {
color: var(--red);
border-color: color-mix(in oklab, var(--red) 40%, var(--color-border));
}
/* Coords are a power-user adjustment — keep them out of the way unless /* Coords are a power-user adjustment — keep them out of the way unless
* the user explicitly opens the disclosure. Dragging the marker on the * the user explicitly opens the disclosure. Dragging the marker on the
* map is the primary editing affordance. */ * map is the primary editing affordance. */
@@ -5,7 +5,9 @@
focusWaypoint, focusWaypoint,
mapView, mapView,
placedSequence, placedSequence,
scheduleSave scheduleSave,
toggleStageBreak,
renameStage
} from './builderStore.svelte'; } from './builderStore.svelte';
import { generateImageHashClient } from '$lib/imageHashClient'; import { generateImageHashClient } from '$lib/imageHashClient';
import { readThumbnail } from './imageThumbnail'; import { readThumbnail } from './imageThumbnail';
@@ -21,6 +23,7 @@
import MapPinOff from '@lucide/svelte/icons/map-pin-off'; import MapPinOff from '@lucide/svelte/icons/map-pin-off';
import Globe from '@lucide/svelte/icons/globe'; import Globe from '@lucide/svelte/icons/globe';
import Lock from '@lucide/svelte/icons/lock'; import Lock from '@lucide/svelte/icons/lock';
import Flag from '@lucide/svelte/icons/flag';
const NUDGE_MINUTES = [-10, -5, 5, 10]; const NUDGE_MINUTES = [-10, -5, 5, 10];
@@ -46,6 +49,30 @@
return -1; return -1;
}); });
// Per-waypoint stage metadata (placed waypoints only): whether it begins a
// stage, the stage number/name, and whether it's the route start.
const stageMeta = $derived.by(() => {
const map = new Map<string, { isStart: boolean; num: number; name: string; first: boolean }>();
let num = 0;
let name = '';
let firstSeen = false;
for (const w of builder.waypoints) {
if (w.unplaced) continue;
const first = !firstSeen;
firstSeen = true;
const isStart = first || w.stageStart !== undefined;
if (isStart) {
num++;
name = w.stageStart || `Etappe ${num}`;
}
map.set(w.id, { isStart, num, name, first });
}
return map;
});
const stageCount = $derived(
[...stageMeta.values()].filter((m) => m.isStart).length
);
/** Find the nearest waypoint *by index* that already carries a timestamp. /** Find the nearest waypoint *by index* that already carries a timestamp.
* Used as the `inheritedValue` for click waypoints — searching by sequence * Used as the `inheritedValue` for click waypoints — searching by sequence
* position (rather than geography) mirrors how authors typically insert * position (rather than geography) mirrors how authors typically insert
@@ -152,13 +179,39 @@
<ol> <ol>
{#each builder.waypoints as wp, idx (wp.id)} {#each builder.waypoints as wp, idx (wp.id)}
{@const seq = placedSequence(wp.id)} {@const seq = placedSequence(wp.id)}
{@const sm = stageMeta.get(wp.id)}
<li <li
class="wp" class="wp"
class:stage-start={stageCount > 1 && sm?.isStart}
class:unplaced={wp.unplaced} class:unplaced={wp.unplaced}
class:active={wp.id === pendingPlacementId} class:active={wp.id === pendingPlacementId}
class:focused={wp.id === mapView.focusId && !wp.unplaced} class:focused={wp.id === mapView.focusId && !wp.unplaced}
animate:flip={{ duration: 220 }} animate:flip={{ duration: 220 }}
> >
{#if stageCount > 1 && sm?.isStart}
<div class="stage-band">
<span class="stage-badge"><Flag size={11} strokeWidth={2.25} />Etappe {sm.num}</span>
<input
class="stage-name"
value={wp.stageStart ?? sm.name}
placeholder={`Etappe ${sm.num}`}
oninput={(e) => renameStage(wp.id, e.currentTarget.value)}
aria-label={`Name Etappe ${sm.num}`}
/>
{#if !sm.first}
<button
type="button"
class="stage-merge"
onclick={() => toggleStageBreak(wp.id)}
title="Mit vorheriger Etappe zusammenführen"
aria-label="Etappe auflösen"
>
<X size={13} strokeWidth={2.25} />
</button>
{/if}
</div>
{/if}
{#if wp.thumbnail || getFullImageUrl(wp.id)} {#if wp.thumbnail || getFullImageUrl(wp.id)}
<div class="hero"> <div class="hero">
<img <img
@@ -189,6 +242,19 @@
{/if} {/if}
</span> </span>
<div class="row-actions"> <div class="row-actions">
{#if !wp.unplaced && !sm?.first}
<button
type="button"
class="stage-flag"
class:on={sm?.isStart}
onclick={() => toggleStageBreak(wp.id)}
aria-pressed={sm?.isStart}
aria-label={sm?.isStart ? 'Etappenbeginn entfernen' : 'Neue Etappe ab hier'}
title={sm?.isStart ? 'Etappenbeginn entfernen' : 'Neue Etappe ab hier'}
>
<Flag size={14} strokeWidth={2} />
</button>
{/if}
{#if !wp.unplaced} {#if !wp.unplaced}
<button <button
type="button" type="button"
@@ -365,6 +431,70 @@
gap: 0.75rem; gap: 0.75rem;
} }
/* Stage band at the top of the first card of each stage. */
.stage-band {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.45rem 0.55rem;
background: color-mix(in oklab, var(--color-primary) 8%, var(--color-bg-secondary));
border-bottom: 1px solid color-mix(in oklab, var(--color-primary) 22%, var(--color-border));
}
.stage-badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
flex: 0 0 auto;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-primary);
padding: 0.22rem 0.55rem;
background: color-mix(in oklab, var(--color-primary) 12%, transparent);
border-radius: var(--radius-pill);
}
.stage-name {
flex: 1 1 auto;
min-width: 0;
padding: 0.3rem 0.5rem;
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;
font-weight: 600;
}
.stage-merge {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
appearance: none;
padding: 0.25rem;
line-height: 0;
background: transparent;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-secondary);
cursor: pointer;
}
.stage-merge:hover {
color: var(--red);
border-color: color-mix(in oklab, var(--red) 40%, var(--color-border));
}
.row-actions button.stage-flag.on {
color: var(--color-primary);
border-color: color-mix(in oklab, var(--color-primary) 40%, var(--color-border));
background: color-mix(in oklab, var(--color-primary) 10%, var(--color-bg-tertiary));
}
.wp { .wp {
padding: 0; padding: 0;
background: var(--color-bg-secondary); background: var(--color-bg-secondary);
@@ -382,6 +512,11 @@
background: color-mix(in oklab, var(--orange) 6%, var(--color-bg-secondary)); background: color-mix(in oklab, var(--orange) 6%, var(--color-bg-secondary));
} }
/* Mark the first card of each stage with a top accent. */
.wp.stage-start {
border-top: 2px solid var(--color-primary);
}
.wp.active { .wp.active {
border-color: var(--color-primary); border-color: var(--color-primary);
box-shadow: 0 0 0 2px color-mix(in oklab, var(--color-primary) 30%, transparent); box-shadow: 0 0 0 2px color-mix(in oklab, var(--color-primary) 30%, transparent);
@@ -8,7 +8,7 @@
*/ */
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { parseGpx, parseGpxImageRefs } from '$lib/gpx'; import { parseGpx, parseGpxStages, parseGpxImageRefs } from '$lib/gpx';
export type RoutingProfile = 'hiking-mountain' | 'trekking' | 'road'; export type RoutingProfile = 'hiking-mountain' | 'trekking' | 'road';
@@ -35,6 +35,11 @@ export type Waypoint = {
* Lat/lng are placeholders (0/0) and the waypoint is hidden from the map * Lat/lng are placeholders (0/0) and the waypoint is hidden from the map
* and excluded from GPX export until placed. */ * and excluded from GPX export until placed. */
unplaced?: boolean; unplaced?: boolean;
/** When set, this (placed) waypoint begins a new stage of a multi-day
* hike, with this string as the stage name. The first placed waypoint
* always begins stage 1 implicitly; setting `stageStart` on it just names
* that stage. Exported as separate `<trk>` elements. */
stageStart?: string;
}; };
export type BuilderState = { export type BuilderState = {
@@ -150,6 +155,59 @@ export function placedSequence(wpId: string): number | null {
return null; return null;
} }
// ---------------------------------------------------------------------------
// Stages (multi-day hikes). A new stage begins at the first placed waypoint
// and at any placed waypoint carrying `stageStart`. Each stage exports as its
// own named <trk>.
// ---------------------------------------------------------------------------
/** Placed waypoints split into stages, in placed-index ranges. */
export function deriveStageGroups(): { name: string; startIdx: number; endIdx: number }[] {
const placed = builder.waypoints.filter((w) => !w.unplaced);
const groups: { name: string; startIdx: number; endIdx: number }[] = [];
for (let i = 0; i < placed.length; i++) {
if (groups.length === 0 || placed[i].stageStart !== undefined) {
groups.push({
name: placed[i].stageStart || `Etappe ${groups.length + 1}`,
startIdx: i,
endIdx: i
});
} else {
groups[groups.length - 1].endIdx = i;
}
}
return groups;
}
/** Toggle whether a placed waypoint begins a new stage. The route start can't
* be a break (it always begins stage 1). */
export function toggleStageBreak(wpId: string): void {
const placed = builder.waypoints.filter((w) => !w.unplaced);
if (placed.length === 0 || placed[0].id === wpId) return;
const wp = builder.waypoints.find((w) => w.id === wpId);
if (!wp) return;
if (wp.stageStart !== undefined) {
delete wp.stageStart;
} else {
const idxInPlaced = placed.findIndex((w) => w.id === wpId);
let n = 0;
for (let i = 0; i <= idxInPlaced; i++) {
if (i === 0 || placed[i].stageStart !== undefined || placed[i].id === wpId) n++;
}
wp.stageStart = `Etappe ${n}`;
}
scheduleSave();
}
/** Name (or rename) the stage that begins at this waypoint. Setting it on the
* first placed waypoint names stage 1 without creating an extra break. */
export function renameStage(firstWpId: string, name: string): void {
const wp = builder.waypoints.find((w) => w.id === firstWpId);
if (!wp) return;
wp.stageStart = name;
scheduleSave();
}
let saveTimer: ReturnType<typeof setTimeout> | null = null; let saveTimer: ReturnType<typeof setTimeout> | null = null;
export function scheduleSave(): void { export function scheduleSave(): void {
if (!browser) return; if (!browser) return;
@@ -387,6 +445,19 @@ export function importGpx(xml: string): ImportGpxResult {
if (trk.length < 2) { if (trk.length < 2) {
return { ok: false, error: 'GPX enthält keinen verwertbaren Track (mind. zwei trkpt nötig).' }; return { ok: false, error: 'GPX enthält keinen verwertbaren Track (mind. zwei trkpt nötig).' };
} }
// Stage boundaries: a multi-<trk> GPX is a multi-day route. Map each
// stage's first flat-track index to its name so we can re-mark the
// corresponding waypoint as a stage start.
const gpxStages = parseGpxStages(xml);
const multiStage = gpxStages.length > 1;
const stageNameAt = new Map<number, string>();
{
let off = 0;
for (let k = 0; k < gpxStages.length; k++) {
stageNameAt.set(off, gpxStages[k].name ?? `Etappe ${k + 1}`);
off += gpxStages[k].points.length;
}
}
const imageRefs = parseGpxImageRefs(xml); const imageRefs = parseGpxImageRefs(xml);
const imageList = Object.values(imageRefs); const imageList = Object.values(imageRefs);
@@ -430,9 +501,11 @@ export function importGpx(xml: string): ImportGpxResult {
} }
imageAnchors.sort((a, b) => a.trkIdx - b.trkIdx); imageAnchors.sort((a, b) => a.trkIdx - b.trkIdx);
// Build the set of anchor trkpt indices: first, last, all image anchors. // Build the set of anchor trkpt indices: first, last, all image anchors,
// plus every stage boundary so multi-day breaks survive the round-trip.
const anchorIndices = new Set<number>([0, trk.length - 1]); const anchorIndices = new Set<number>([0, trk.length - 1]);
for (const ia of imageAnchors) anchorIndices.add(ia.trkIdx); for (const ia of imageAnchors) anchorIndices.add(ia.trkIdx);
if (multiStage) for (const idx of stageNameAt.keys()) anchorIndices.add(idx);
const sortedAnchorIdx = [...anchorIndices].sort((a, b) => a - b); const sortedAnchorIdx = [...anchorIndices].sort((a, b) => a - b);
// Assemble waypoints in traversal order. // Assemble waypoints in traversal order.
@@ -450,6 +523,11 @@ export function importGpx(xml: string): ImportGpxResult {
wp.imageHash = ia.hash; wp.imageHash = ia.hash;
wp.imageVisibility = ia.visibility; wp.imageVisibility = ia.visibility;
} }
// Re-mark stage starts (skip index 0 — the route start is stage 1
// implicitly; naming it is harmless but unnecessary).
if (multiStage && i > 0 && stageNameAt.has(i)) {
wp.stageStart = stageNameAt.get(i);
}
return wp; return wp;
}); });
@@ -0,0 +1,19 @@
/**
* Active-stage selection for a multi-day hike detail page.
*
* `active` is the index into the hike's `stages[]`, or `null` for the
* "Alle Etappen" (whole route) view. The stage nav writes it; the map,
* elevation profile, metrics row and photo strip read it to scope themselves
* to one stage. A shared rune (like hoverStore / focusedImageStore) avoids
* prop-drilling through the two map instances.
*/
export const stage = $state<{ active: number | null }>({ active: null });
export function setActiveStage(index: number | null): void {
stage.active = index;
}
export function clearActiveStage(): void {
stage.active = null;
}
+63 -12
View File
@@ -70,6 +70,38 @@ export function parseGpx(xml: string): GpxPoint[] {
return points; return points;
} }
export interface GpxStage {
/** `<name>` of the `<trk>`, or null when absent. */
name: string | null;
points: GpxPoint[];
}
/**
* Parse a GPX into one stage per `<trk>` element (a multi-day route ships its
* stages as separate named tracks). Each stage keeps its own ordered points;
* concatenating them yields the same flat list `parseGpx` returns.
*
* Falls back to a single unnamed stage covering the whole document when there
* are no `<trk>` wrappers (e.g. an `<rtept>`-only route).
*/
export function parseGpxStages(xml: string): GpxStage[] {
const stages: GpxStage[] = [];
const trkRegex = /<trk>([\s\S]*?)<\/trk>/gi;
let match;
while ((match = trkRegex.exec(xml)) !== null) {
const body = match[1];
const nameMatch = body.match(/<name>([^<]*)<\/name>/i);
const name = nameMatch ? nameMatch[1].trim() : null;
const points = parseGpx(body);
if (points.length > 0) stages.push({ name: name || null, points });
}
if (stages.length === 0) {
const points = parseGpx(xml);
if (points.length > 0) stages.push({ name: null, points });
}
return stages;
}
export interface GpxImageRef { export interface GpxImageRef {
hash: string; hash: string;
name?: string; name?: string;
@@ -314,12 +346,8 @@ export interface GpxImageWaypoint {
* the image's EXIF GPS — letting a contributor correct an image's position * the image's EXIF GPS — letting a contributor correct an image's position
* by simply dragging the matching waypoint in the route-builder. * by simply dragging the matching waypoint in the route-builder.
*/ */
export function buildGpx(opts: { function serializeTrkpts(points: GpxWritePoint[]): string {
name: string; return points
trackPoints: GpxWritePoint[];
imageWaypoints?: GpxImageWaypoint[];
}): string {
const trkpts = opts.trackPoints
.map((p) => { .map((p) => {
const ele = typeof p.altitude === 'number' ? ` <ele>${p.altitude.toFixed(1)}</ele>\n` : ''; const ele = typeof p.altitude === 'number' ? ` <ele>${p.altitude.toFixed(1)}</ele>\n` : '';
const time = typeof p.timestamp === 'number' const time = typeof p.timestamp === 'number'
@@ -328,6 +356,34 @@ export function buildGpx(opts: {
return ` <trkpt lat="${p.lat}" lon="${p.lng}">\n${ele}${time} </trkpt>`; return ` <trkpt lat="${p.lat}" lon="${p.lng}">\n${ele}${time} </trkpt>`;
}) })
.join('\n'); .join('\n');
}
export interface GpxTrack {
name: string;
points: GpxWritePoint[];
}
export function buildGpx(opts: {
name: string;
/** Single-track convenience. Ignored when `tracks` is given. */
trackPoints?: GpxWritePoint[];
/** One `<trk>` per stage. Each gets its own `<name>`. */
tracks?: GpxTrack[];
imageWaypoints?: GpxImageWaypoint[];
}): string {
const tracks: GpxTrack[] =
opts.tracks && opts.tracks.length > 0
? opts.tracks
: [{ name: opts.name, points: opts.trackPoints ?? [] }];
const trksXml = tracks
.map(
(t) =>
` <trk>\n <name>${escapeXml(t.name)}</name>\n <trkseg>\n` +
`${serializeTrkpts(t.points)}\n` +
` </trkseg>\n </trk>`
)
.join('\n');
const hasImages = (opts.imageWaypoints?.length ?? 0) > 0; const hasImages = (opts.imageWaypoints?.length ?? 0) > 0;
const wpts = hasImages const wpts = hasImages
@@ -352,12 +408,7 @@ export function buildGpx(opts: {
return `<?xml version="1.0" encoding="UTF-8"?> return `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="Bocken Route Builder" xmlns="http://www.topografix.com/GPX/1/1"${ns}> <gpx version="1.1" creator="Bocken Route Builder" xmlns="http://www.topografix.com/GPX/1/1"${ns}>
${wpts} <trk> ${wpts}${trksXml}
<name>${escapeXml(opts.name)}</name>
<trkseg>
${trkpts}
</trkseg>
</trk>
</gpx> </gpx>
`; `;
} }
+2
View File
@@ -8,9 +8,11 @@
export { export {
parseGpx, parseGpx,
parseGpxStages,
parseGpxImageRefs, parseGpxImageRefs,
trackDistance, trackDistance,
haversineKm as haversine, haversineKm as haversine,
type GpxPoint, type GpxPoint,
type GpxStage,
type GpxImageRef type GpxImageRef
} from '$lib/gpx'; } from '$lib/gpx';
+35 -13
View File
@@ -1,7 +1,9 @@
<script lang="ts"> <script lang="ts">
import HikeMap from '$lib/components/hikes/HikeMap.svelte'; import HikeMap from '$lib/components/hikes/HikeMap.svelte';
import HikePhotoStrip from '$lib/components/hikes/HikePhotoStrip.svelte'; import HikePhotoStrip from '$lib/components/hikes/HikePhotoStrip.svelte';
import HikeStageNav from '$lib/components/hikes/HikeStageNav.svelte';
import ElevationProfile from '$lib/components/hikes/ElevationProfile.svelte'; import ElevationProfile from '$lib/components/hikes/ElevationProfile.svelte';
import { stage, clearActiveStage } from '$lib/components/hikes/stageStore.svelte';
import Seo from '$lib/components/Seo.svelte'; import Seo from '$lib/components/Seo.svelte';
import { setHikeContext } from '$lib/components/hikes/hikeContext.svelte'; import { setHikeContext } from '$lib/components/hikes/hikeContext.svelte';
import { listScrollAnchors } from '$lib/components/hikes/scrollAnchors'; import { listScrollAnchors } from '$lib/components/hikes/scrollAnchors';
@@ -98,9 +100,24 @@
}; };
}); });
// Active-stage scoping (multi-day hikes). When a stage is selected, the
// metrics row + elevation view switch to that stage; "Alle Etappen" (null)
// shows the whole route. Single-stage hikes never show the nav.
const stages = $derived(hike.stages ?? null);
const hasStages = $derived(!!stages && stages.length > 1);
const activeStage = $derived(hasStages && stage.active !== null ? stages![stage.active] : null);
/** Metric source: the active stage, or the whole hike on "Alle Etappen". */
const m = $derived(activeStage ?? hike);
const stageViewRange = $derived(
activeStage ? { startIdx: activeStage.startIdx, endIdx: activeStage.endIdx } : null
);
// Reset the shared selection when leaving the page.
$effect(() => () => clearActiveStage());
const durationLabel = $derived( const durationLabel = $derived(
hike.durationMin !== null && hike.durationMin > 0 m.durationMin !== null && m.durationMin > 0
? `${Math.floor(hike.durationMin / 60)}h ${hike.durationMin % 60}m` ? `${Math.floor(m.durationMin / 60)}h ${m.durationMin % 60}m`
: '—' : '—'
); );
@@ -339,6 +356,7 @@
imagePoints={visibleImagePoints} imagePoints={visibleImagePoints}
showPrivate showPrivate
{trackColor} {trackColor}
{stages}
initialCenter={heroPose?.center} initialCenter={heroPose?.center}
initialZoom={heroPose?.zoom} initialZoom={heroPose?.zoom}
onReady={() => (heroMapReady = true)} onReady={() => (heroMapReady = true)}
@@ -372,9 +390,13 @@
</div> </div>
</section> </section>
{#if hasStages && stages}
<HikeStageNav {stages} />
{/if}
{#if track && track.length > 0 && visibleImagePoints.length > 0} {#if track && track.length > 0 && visibleImagePoints.length > 0}
<section class="strip-area"> <section class="strip-area">
<HikePhotoStrip images={visibleImagePoints} {track} /> <HikePhotoStrip images={visibleImagePoints} {track} {stages} />
</section> </section>
{/if} {/if}
@@ -384,7 +406,7 @@
{/if} {/if}
<div class="metric"> <div class="metric">
<Route size={20} strokeWidth={1.75} aria-hidden="true" /> <Route size={20} strokeWidth={1.75} aria-hidden="true" />
<span class="value">{hike.distanceKm.toFixed(1)}<span class="value-unit">km</span></span> <span class="value">{m.distanceKm.toFixed(1)}<span class="value-unit">km</span></span>
<span class="unit">Distanz</span> <span class="unit">Distanz</span>
</div> </div>
<div class="metric"> <div class="metric">
@@ -394,25 +416,25 @@
</div> </div>
<div class="metric"> <div class="metric">
<TrendingUp size={20} strokeWidth={1.75} aria-hidden="true" /> <TrendingUp size={20} strokeWidth={1.75} aria-hidden="true" />
<span class="value">{hike.elevationGainM}<span class="value-unit">m</span></span> <span class="value">{m.elevationGainM}<span class="value-unit">m</span></span>
<span class="unit">Aufstieg</span> <span class="unit">Aufstieg</span>
</div> </div>
<div class="metric"> <div class="metric">
<TrendingDown size={20} strokeWidth={1.75} aria-hidden="true" /> <TrendingDown size={20} strokeWidth={1.75} aria-hidden="true" />
<span class="value">{hike.elevationLossM}<span class="value-unit">m</span></span> <span class="value">{m.elevationLossM}<span class="value-unit">m</span></span>
<span class="unit">Abstieg</span> <span class="unit">Abstieg</span>
</div> </div>
{#if hike.elevationMaxM !== null} {#if m.elevationMaxM !== null}
<div class="metric"> <div class="metric">
<ArrowUpToLine size={20} strokeWidth={1.75} aria-hidden="true" /> <ArrowUpToLine size={20} strokeWidth={1.75} aria-hidden="true" />
<span class="value">{hike.elevationMaxM}<span class="value-unit">m</span></span> <span class="value">{m.elevationMaxM}<span class="value-unit">m</span></span>
<span class="unit">höchster</span> <span class="unit">höchster</span>
</div> </div>
{/if} {/if}
{#if hike.elevationMinM !== null} {#if m.elevationMinM !== null}
<div class="metric"> <div class="metric">
<ArrowDownToLine size={20} strokeWidth={1.75} aria-hidden="true" /> <ArrowDownToLine size={20} strokeWidth={1.75} aria-hidden="true" />
<span class="value">{hike.elevationMinM}<span class="value-unit">m</span></span> <span class="value">{m.elevationMinM}<span class="value-unit">m</span></span>
<span class="unit">tiefster</span> <span class="unit">tiefster</span>
</div> </div>
{/if} {/if}
@@ -447,15 +469,15 @@
{#if track && track.length > 0} {#if track && track.length > 0}
<section class="elev-area"> <section class="elev-area">
<ElevationProfile {track} /> <ElevationProfile {track} viewRange={stageViewRange} />
</section> </section>
{/if} {/if}
<section class="scroll-area"> <section class="scroll-area">
<aside class="trail-col"> <aside class="trail-col">
{#if track && track.length > 0} {#if track && track.length > 0}
<HikeMap {track} imagePoints={visibleImagePoints} showPrivate {trackColor} /> <HikeMap {track} imagePoints={visibleImagePoints} showPrivate {trackColor} {stages} />
<ElevationProfile {track} /> <ElevationProfile {track} viewRange={stageViewRange} />
{/if} {/if}
</aside> </aside>
+25 -14
View File
@@ -6,7 +6,7 @@
import WaypointDetailPanel from '$lib/components/hikes/route-builder/WaypointDetailPanel.svelte'; import WaypointDetailPanel from '$lib/components/hikes/route-builder/WaypointDetailPanel.svelte';
import ImageDropzone from '$lib/components/hikes/route-builder/ImageDropzone.svelte'; import ImageDropzone from '$lib/components/hikes/route-builder/ImageDropzone.svelte';
import RouteStatsBar from '$lib/components/hikes/route-builder/RouteStatsBar.svelte'; import RouteStatsBar from '$lib/components/hikes/route-builder/RouteStatsBar.svelte';
import { assembleTrackPoints, buildGpx, type GpxImageWaypoint } from '$lib/gpx'; import { assembleTrackPoints, buildGpx, type GpxImageWaypoint, type GpxTrack } from '$lib/gpx';
import { import {
builder, builder,
focusWaypoint, focusWaypoint,
@@ -16,6 +16,7 @@
clearDraft, clearDraft,
reconcileSegments, reconcileSegments,
densifyLinearSegments, densifyLinearSegments,
deriveStageGroups,
importGpx importGpx
} from '$lib/components/hikes/route-builder/builderStore.svelte'; } from '$lib/components/hikes/route-builder/builderStore.svelte';
import ChevronLeft from '@lucide/svelte/icons/chevron-left'; import ChevronLeft from '@lucide/svelte/icons/chevron-left';
@@ -151,18 +152,28 @@
return; return;
} }
const placed = builder.waypoints.filter((w) => !w.unplaced); const placed = builder.waypoints.filter((w) => !w.unplaced);
const assembled = assembleTrackPoints({ // Assemble each stage independently so timestamps interpolate within a
waypoints: placed.map((w) => ({ // stage and the overnight gap between stages is never bridged. One
lat: w.lat, // <trk> per stage; a single-stage route yields one track (== before).
lng: w.lng, const groups = deriveStageGroups();
altitude: w.altitude, const tracks: GpxTrack[] = [];
timestamp: w.timestamp ?? null for (const g of groups) {
})), const assembled = assembleTrackPoints({
routedSegments: builder.routedSegments waypoints: placed.slice(g.startIdx, g.endIdx + 1).map((w) => ({
}); lat: w.lat,
if (!assembled.ok) { lng: w.lng,
error = assembled.error; altitude: w.altitude,
return; timestamp: w.timestamp ?? null
})),
// Only the segments *within* the stage (between its consecutive
// waypoints); the boundary segment to the next stage is excluded.
routedSegments: builder.routedSegments.slice(g.startIdx, g.endIdx)
});
if (!assembled.ok) {
error = groups.length > 1 ? `Etappe „${g.name}“: ${assembled.error}` : assembled.error;
return;
}
tracks.push({ name: g.name, points: assembled.points });
} }
error = null; error = null;
// Look up altitude for a placed-waypoint index from the routed segments. // Look up altitude for a placed-waypoint index from the routed segments.
@@ -199,7 +210,7 @@
})); }));
const gpx = buildGpx({ const gpx = buildGpx({
name: builder.name || 'Neue Wanderung', name: builder.name || 'Neue Wanderung',
trackPoints: assembled.points, tracks,
imageWaypoints imageWaypoints
}); });
const blob = new Blob([gpx], { type: 'application/gpx+xml' }); const blob = new Blob([gpx], { type: 'application/gpx+xml' });
+26
View File
@@ -27,6 +27,23 @@ export type ImagePoint = {
// [lng, lat, elevation?, unixMs?] // [lng, lat, elevation?, unixMs?]
export type HikeTrackPoint = [number, number, number?, number?]; export type HikeTrackPoint = [number, number, number?, number?];
/** One stage of a multi-day hike. Index ranges point into the flat track JSON
* (`trackUrl`); stats are computed per stage so totals exclude the overnight
* gaps between them. Stages are disjoint and contiguous
* (`endIdx + 1 === next.startIdx`). */
export type HikeStage = {
name: string;
/** Inclusive start/end indices into the flat track. */
startIdx: number;
endIdx: number;
distanceKm: number;
durationMin: number | null;
elevationGainM: number;
elevationLossM: number;
elevationMaxM: number | null;
elevationMinM: number | null;
};
export type HikeManifestEntry = { export type HikeManifestEntry = {
slug: string; slug: string;
title: string; title: string;
@@ -49,6 +66,15 @@ export type HikeManifestEntry = {
bbox: [number, number, number, number]; // [minLat, minLng, maxLat, maxLng] bbox: [number, number, number, number]; // [minLat, minLng, maxLat, maxLng]
centroid: [number, number]; centroid: [number, number];
previewPolyline: [number, number][]; previewPolyline: [number, number][];
/** Indices into `previewPolyline` where a new disconnected run begins
* set only where the gap between two stages exceeds ~1 km, so the overview
* doesn't draw a straight line across an overnight transfer. Absent/empty
* one continuous line. */
previewBreaks?: number[];
/** Present only for multi-day hikes (2 GPX `<trk>` elements). Single-stage
* hikes omit it and render exactly as before. */
stages?: HikeStage[];
// Reverse-geocoded from the centroid (Swisstopo): // Reverse-geocoded from the centroid (Swisstopo):
region: string | null; region: string | null;