From e3ccd96c7b29c6a61da737cd7de14af1f51ad33c Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Tue, 19 May 2026 10:40:24 +0200 Subject: [PATCH] feat(route-builder): densify + elevate off-trail segments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With "snap to route" off, every waypoint pair shipped as a 2-point linear segment with no `` anywhere — the page only enriched elevations inside the snap path. The resulting GPX had a flat altitude profile and the build script's gain/loss/min/max metrics came out all zero. Two changes: * New `densifyLinearSegments` (default 25 m, matches Swisstopo's coarsest DTM) walks every 2-point segment and seeds intermediate vertices along the great circle. Snapped segments (already many points from BRouter) are left alone. * Page reactor now runs the same Swisstopo elevation enrichment in the autoSnap-off path, so the GPX carries per-trkpt `` even for fully manual / cross-country routes. Elevation source unchanged: Swisstopo profile.json (COMB → DTM2 → DTM25 fallback) is already the highest-resolution provider for our Swiss-coverage hikes; no point swapping. Also unifies the snap-path's inline enrichment call into the same helper so there's one elevation code path instead of two. --- package.json | 2 +- .../route-builder/builderStore.svelte.ts | 60 ++++++++++++++++++ src/routes/hikes/route-builder/+page.svelte | 63 ++++++++++++------- 3 files changed, 103 insertions(+), 22 deletions(-) diff --git a/package.json b/package.json index d973455a..2792d943 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.75.5", + "version": "1.76.0", "private": true, "type": "module", "scripts": { diff --git a/src/lib/components/hikes/route-builder/builderStore.svelte.ts b/src/lib/components/hikes/route-builder/builderStore.svelte.ts index 1f94575e..15883eb0 100644 --- a/src/lib/components/hikes/route-builder/builderStore.svelte.ts +++ b/src/lib/components/hikes/route-builder/builderStore.svelte.ts @@ -231,6 +231,66 @@ export function reconcileSegments(): void { builder.segmentSources.splice(0, builder.segmentSources.length, ...newSources); } +/** Haversine distance in metres between two `[lng, lat]` points. + * Inline so this module can stay client-only (the server helpers live in + * `$lib/server/hikesRouting.ts` and aren't importable here). */ +function haversineMeters(lng1: number, lat1: number, lng2: number, lat2: number): number { + const R = 6_371_000; + const dLat = ((lat2 - lat1) * Math.PI) / 180; + const dLng = ((lng2 - lng1) * Math.PI) / 180; + const sinLat = Math.sin(dLat / 2); + const sinLng = Math.sin(dLng / 2); + const h = + sinLat * sinLat + + Math.cos((lat1 * Math.PI) / 180) * + Math.cos((lat2 * Math.PI) / 180) * + sinLng * sinLng; + return 2 * R * Math.asin(Math.sqrt(h)); +} + +/** + * Expand every 2-point linear segment into evenly-spaced intermediate + * points so an elevation enrichment pass can capture the terrain profile + * between the two waypoints. Snapped segments (already many points from + * BRouter/OSRM) are left alone. + * + * `spacingM` defaults to 25 m — matches the coarsest Swisstopo DTM that + * we sample against; finer spacing would just sample the same elevation + * value twice. Very short segments (< 30 m) skip densification: the two + * endpoints already capture every meaningful elevation step within + * Swisstopo's DTM resolution at that distance. + * + * Returns `true` when at least one segment was densified (caller can use + * this to decide whether to fire a fresh elevation request). + */ +export function densifyLinearSegments(spacingM = 25): boolean { + let densifiedAny = false; + for (let i = 0; i < builder.routedSegments.length; i++) { + const seg = builder.routedSegments[i]; + if (seg.length !== 2) continue; // already snapped or already densified + const [lngA, latA, altA] = seg[0]; + const [lngB, latB, altB] = seg[1]; + const dist = haversineMeters(lngA, latA, lngB, latB); + if (dist < 30) continue; + // At least 4 sub-segments so even a 30-m linear sample gets a usable + // elevation profile; longer segments scale up to keep ~25 m spacing. + const n = Math.max(4, Math.ceil(dist / spacingM)); + const out: Array<[number, number, number?]> = new Array(n + 1); + for (let j = 0; j <= n; j++) { + const f = j / n; + // Endpoints keep whatever altitude the caller supplied (typically + // `undefined` here — enrichment fills both ends + everything between); + // intermediates are seeded as `undefined` so the enrichment step + // knows to fill them. + const alt = j === 0 ? altA : j === n ? altB : undefined; + out[j] = [lngA + (lngB - lngA) * f, latA + (latB - latA) * f, alt]; + } + builder.routedSegments[i] = out; + densifiedAny = true; + } + return densifiedAny; +} + export function setElevations(elevations: (number | null)[]): void { // elevations are aligned with the flattened routedSegments points; fold them // back into the per-segment arrays. diff --git a/src/routes/hikes/route-builder/+page.svelte b/src/routes/hikes/route-builder/+page.svelte index fd0f43b2..84696ec7 100644 --- a/src/routes/hikes/route-builder/+page.svelte +++ b/src/routes/hikes/route-builder/+page.svelte @@ -10,13 +10,39 @@ setRoutedSegments, setElevations, clearDraft, - reconcileSegments + reconcileSegments, + densifyLinearSegments } from '$lib/components/hikes/route-builder/builderStore.svelte'; let busy = $state(false); let error = $state(null); let routeRequestId = 0; + /** + * Pull elevations from Swisstopo for every point of the current + * `routedSegments` that lacks one, then fold the values back into the + * segment arrays. Shared by the snap path (where BRouter sometimes + * doesn't return elevations) and the manual / off-trail path (where + * we densify a straight line then need its profile). + * + * Returns silently if every point already has an altitude — handy when + * BRouter snapped the route and embedded elevations inline. + */ + async function enrichMissingElevations(reqId: number): Promise { + const flat = builder.routedSegments.flat(); + if (flat.length === 0) return; + if (!flat.some((p) => typeof p[2] !== 'number')) return; + const elevRes = await fetch('/api/hikes/route-builder/elevation', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ coordinates: flat.map((p) => [p[0], p[1]]) }) + }); + if (reqId !== routeRequestId) return; + if (!elevRes.ok) return; + const { elevations } = (await elevRes.json()) as { elevations: (number | null)[] }; + setElevations(elevations); + } + async function snapToRoute() { const placed = builder.waypoints.filter((w) => !w.unplaced); if (placed.length < 2) { @@ -46,22 +72,9 @@ }; if (reqId !== routeRequestId) return; setRoutedSegments(data.segments); - - // If routing didn't return elevations, enrich via Swisstopo. - const flat = data.segments.flat(); - const needsElevation = flat.some((p) => typeof p[2] !== 'number'); - if (needsElevation) { - const elevRes = await fetch('/api/hikes/route-builder/elevation', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ coordinates: flat.map((p) => [p[0], p[1]]) }) - }); - if (reqId !== routeRequestId) return; - if (elevRes.ok) { - const elevData = (await elevRes.json()) as { elevations: (number | null)[] }; - setElevations(elevData.elevations); - } - } + // BRouter usually embeds elevations inline; OSRM / linear + // fallbacks don't. Single helper handles both cases. + await enrichMissingElevations(reqId); } catch (err) { if (reqId !== routeRequestId) return; error = (err as Error).message; @@ -95,10 +108,18 @@ if (builder.autoSnap) { snapDebounce = setTimeout(() => snapToRoute(), 250); } else { - // Keep whatever was already snapped. Cancel any in-flight request so - // a late response doesn't overwrite the linear placeholders we just - // reconciled. - routeRequestId++; + // Manual / off-trail mode: keep already-snapped pairs intact + // (reconcileSegments preserved them); but for any fresh + // two-point linear placeholder, densify to ~25 m spacing and + // pull a Swisstopo elevation profile so the GPX carries + // per-trkpt `` even when the user chose not to snap. + // Cancel any in-flight snap request so a late response can't + // overwrite what we're about to densify. + const reqId = ++routeRequestId; + snapDebounce = setTimeout(async () => { + densifyLinearSegments(25); + await enrichMissingElevations(reqId); + }, 250); } }); });