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:
+117
-11
@@ -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<HikeManifes
|
||||
}
|
||||
|
||||
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) {
|
||||
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<HikeManifes
|
||||
bbox,
|
||||
centroid,
|
||||
previewPolyline,
|
||||
...(previewBreaks.length > 0 ? { previewBreaks } : {}),
|
||||
...(multiStage ? { stages: stageEntries } : {}),
|
||||
region: geo.region,
|
||||
canton: geo.canton,
|
||||
municipality: geo.municipality,
|
||||
|
||||
@@ -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}" ` +
|
||||
|
||||
Reference in New Issue
Block a user