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
+7 -1
View File
@@ -361,6 +361,9 @@ export async function renderStaticMap(opts: RenderStaticMapOpts): Promise<boolea
export interface RenderOverviewPolyline {
points: Array<[number, number]>;
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<bo
// zoomed-out, so even ≤150-point preview polylines stay compact.
const paths = drawable
.map((line) => {
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 (
`<path d="${parts.join(' ')}" fill="none" stroke="${line.color}" ` +