From 6483c55fce26256bd3b1db928ed491481c244d13 Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Fri, 22 May 2026 14:14:57 +0200 Subject: [PATCH] feat(hikes): multi-day stages (separate GPX tracks, stage nav, builder) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Represent a multi-day hike as separate named GPX elements, one per stage, while still treating the whole thing as one route on the overview. GPX & build: - gpx.ts: parseGpxStages (one stage per ) + 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 ; import re-marks stage boundaries from a multi-track GPX. --- package.json | 2 +- scripts/build-hikes.ts | 128 +++++++++++- scripts/staticHikeMap.ts | 8 +- .../components/hikes/ElevationProfile.svelte | 35 +++- src/lib/components/hikes/HikeMap.svelte | 71 ++++++- .../components/hikes/HikePhotoStrip.svelte | 75 +++++-- src/lib/components/hikes/HikeStageNav.svelte | 191 ++++++++++++++++++ .../components/hikes/HikesOverviewMap.svelte | 17 +- .../route-builder/WaypointDetailPanel.svelte | 134 +++++++++++- .../hikes/route-builder/WaypointTable.svelte | 137 ++++++++++++- .../route-builder/builderStore.svelte.ts | 82 +++++++- src/lib/components/hikes/stageStore.svelte.ts | 19 ++ src/lib/gpx.ts | 75 +++++-- src/lib/server/gpx.ts | 2 + src/routes/hikes/[slug]/+page.svelte | 48 +++-- src/routes/hikes/route-builder/+page.svelte | 39 ++-- src/types/hikes.ts | 26 +++ 17 files changed, 1012 insertions(+), 77 deletions(-) create mode 100644 src/lib/components/hikes/HikeStageNav.svelte create mode 100644 src/lib/components/hikes/stageStore.svelte.ts diff --git a/package.json b/package.json index 4b1be69a..9fc24fa9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.81.0", + "version": "1.82.0", "private": true, "type": "module", "scripts": { diff --git a/scripts/build-hikes.ts b/scripts/build-hikes.ts index 76fb611b..0ec9c8c1 100644 --- a/scripts/build-hikes.ts +++ b/scripts/build-hikes.ts @@ -20,11 +20,13 @@ import crypto from 'node:crypto'; import os from 'node:os'; import sharp from 'sharp'; import { - parseGpx, + parseGpxStages, parseGpxImageRefs, trackDistance, + haversine, type GpxImageRef, - type GpxPoint + type GpxPoint, + type GpxStage } from '../src/lib/server/gpx.js'; import { simplifyTrack } from '../src/lib/server/simplifyTrack.js'; import { computeStaticMapPose, renderOverviewMap, renderStaticMap } from './staticHikeMap.js'; @@ -33,6 +35,7 @@ import { computeElevationStats, computeElevationRange } from '../src/lib/hikes/e import type { Difficulty, HikeManifestEntry, + HikeStage, HikesOverview, ImagePoint, 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 // --------------------------------------------------------------------------- @@ -646,7 +696,8 @@ async function processOverview( .filter((h) => h.previewPolyline && h.previewPolyline.length >= 2) .map((h) => ({ 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; @@ -945,22 +996,75 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise. 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) { console.warn(`[build-hikes] Skipping ${slug}: empty GPX`); return null; } const gpxImageRefs = parseGpxImageRefs(gpxSource); 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 previewPolyline = simplifyTrack(track, PREVIEW_POLYLINE_MAX_POINTS) as [number, number][]; - const dtMs = track[track.length - 1].timestamp - track[0].timestamp; - const durationMin = dtMs > 0 ? Math.round(dtMs / 60000) : null; + const { previewPolyline, previewBreaks } = buildPreview(gpxStages); console.log(`[build-hikes:${slug}] metrics: ${distanceKm.toFixed(2)} km · ↑${gain}m / ↓${loss}m · ${elevationMinM ?? '?'}–${elevationMaxM ?? '?'}m · ${durationMin ?? '?'} min`); const geoT0 = Date.now(); @@ -1156,6 +1260,8 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise 0 ? { previewBreaks } : {}), + ...(multiStage ? { stages: stageEntries } : {}), region: geo.region, canton: geo.canton, municipality: geo.municipality, diff --git a/scripts/staticHikeMap.ts b/scripts/staticHikeMap.ts index bf8962f5..8e36cbf2 100644 --- a/scripts/staticHikeMap.ts +++ b/scripts/staticHikeMap.ts @@ -361,6 +361,9 @@ export async function renderStaticMap(opts: RenderStaticMapOpts): Promise; 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 { @@ -388,13 +391,16 @@ export async function renderOverviewMap(opts: RenderOverviewMapOpts): Promise { + const breakSet = new Set(line.breaks ?? []); const parts: string[] = []; for (let i = 0; i < line.points.length; i++) { const [lat, lng] = line.points[i]; const p = lngLatToPx(lng, lat, zoom); const px = p.x - originX; 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 ( `(undefined); let chart: ChartType | null = null; @@ -137,9 +149,10 @@ type: 'linear', // 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 - // ends up with empty space out to 14 km. - min: 0, - max: cumKm[cumKm.length - 1] ?? 0, + // ends up with empty space out to 14 km. When a stage is + // selected, the window narrows to that stage. + min: xBounds().min, + max: xBounds().max, bounds: 'data', title: { display: true, text: 'Distanz (km)', color: textColor }, ticks: { color: textColor }, @@ -220,6 +233,20 @@ 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 // disappears too. function onCanvasMouseLeave() { diff --git a/src/lib/components/hikes/HikeMap.svelte b/src/lib/components/hikes/HikeMap.svelte index 6d956ffc..afcbc551 100644 --- a/src/lib/components/hikes/HikeMap.svelte +++ b/src/lib/components/hikes/HikeMap.svelte @@ -1,7 +1,8 @@ + + + + diff --git a/src/lib/components/hikes/HikesOverviewMap.svelte b/src/lib/components/hikes/HikesOverviewMap.svelte index c316831c..0dac974d 100644 --- a/src/lib/components/hikes/HikesOverviewMap.svelte +++ b/src/lib/components/hikes/HikesOverviewMap.svelte @@ -195,7 +195,22 @@ if (!hike.previewPolyline || hike.previewPolyline.length < 2) continue; const latLngs = hike.previewPolyline.map(([lat, lng]) => [lat, lng] as [number, number]); 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, weight: 4, opacity: 0.9, diff --git a/src/lib/components/hikes/route-builder/WaypointDetailPanel.svelte b/src/lib/components/hikes/route-builder/WaypointDetailPanel.svelte index 0527a6eb..b12acbb5 100644 --- a/src/lib/components/hikes/route-builder/WaypointDetailPanel.svelte +++ b/src/lib/components/hikes/route-builder/WaypointDetailPanel.svelte @@ -4,7 +4,9 @@ focusWaypoint, mapView, placedSequence, - scheduleSave + scheduleSave, + toggleStageBreak, + renameStage } from './builderStore.svelte'; import { generateImageHashClient } from '$lib/imageHashClient'; import { readThumbnail } from './imageThumbnail'; @@ -16,6 +18,7 @@ import Globe from '@lucide/svelte/icons/globe'; import Lock from '@lucide/svelte/icons/lock'; import X from '@lucide/svelte/icons/x'; + import Flag from '@lucide/svelte/icons/flag'; interface Props { onCancelPlacement?: () => void; @@ -45,6 +48,27 @@ }); 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 { const wps = builder.waypoints; for (let dist = 1; dist < wps.length; dist++) { @@ -272,6 +296,31 @@ {/if} + {#if !wp.unplaced} + {#if stageInfo?.isStart && stageCount > 1} +
+ Etappe {stageInfo.num} + renameStage(wp.id, e.currentTarget.value)} + aria-label={`Name Etappe ${stageInfo.num}`} + /> + {#if !stageInfo.isFirst} + + {/if} +
+ {:else if !stageInfo?.isFirst} + + {/if} + {/if} + {#if !wp.unplaced}
Koordinaten anpassen @@ -596,6 +645,89 @@ 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 * the user explicitly opens the disclosure. Dragging the marker on the * map is the primary editing affordance. */ diff --git a/src/lib/components/hikes/route-builder/WaypointTable.svelte b/src/lib/components/hikes/route-builder/WaypointTable.svelte index 2fc5eb6c..fc907033 100644 --- a/src/lib/components/hikes/route-builder/WaypointTable.svelte +++ b/src/lib/components/hikes/route-builder/WaypointTable.svelte @@ -5,7 +5,9 @@ focusWaypoint, mapView, placedSequence, - scheduleSave + scheduleSave, + toggleStageBreak, + renameStage } from './builderStore.svelte'; import { generateImageHashClient } from '$lib/imageHashClient'; import { readThumbnail } from './imageThumbnail'; @@ -21,6 +23,7 @@ import MapPinOff from '@lucide/svelte/icons/map-pin-off'; import Globe from '@lucide/svelte/icons/globe'; import Lock from '@lucide/svelte/icons/lock'; + import Flag from '@lucide/svelte/icons/flag'; const NUDGE_MINUTES = [-10, -5, 5, 10]; @@ -46,6 +49,30 @@ 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(); + 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. * Used as the `inheritedValue` for click waypoints — searching by sequence * position (rather than geography) mirrors how authors typically insert @@ -152,13 +179,39 @@
    {#each builder.waypoints as wp, idx (wp.id)} {@const seq = placedSequence(wp.id)} + {@const sm = stageMeta.get(wp.id)}
  1. 1 && sm?.isStart} class:unplaced={wp.unplaced} class:active={wp.id === pendingPlacementId} class:focused={wp.id === mapView.focusId && !wp.unplaced} animate:flip={{ duration: 220 }} > + {#if stageCount > 1 && sm?.isStart} +
    + Etappe {sm.num} + renameStage(wp.id, e.currentTarget.value)} + aria-label={`Name Etappe ${sm.num}`} + /> + {#if !sm.first} + + {/if} +
    + {/if} + {#if wp.thumbnail || getFullImageUrl(wp.id)}
    + {#if !wp.unplaced && !sm?.first} + + {/if} {#if !wp.unplaced}