feat(route-builder): densify + elevate off-trail segments
With "snap to route" off, every waypoint pair shipped as a 2-point linear segment with no `<ele>` 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 `<ele>` 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.
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homepage",
|
"name": "homepage",
|
||||||
"version": "1.75.5",
|
"version": "1.76.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -231,6 +231,66 @@ export function reconcileSegments(): void {
|
|||||||
builder.segmentSources.splice(0, builder.segmentSources.length, ...newSources);
|
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 {
|
export function setElevations(elevations: (number | null)[]): void {
|
||||||
// elevations are aligned with the flattened routedSegments points; fold them
|
// elevations are aligned with the flattened routedSegments points; fold them
|
||||||
// back into the per-segment arrays.
|
// back into the per-segment arrays.
|
||||||
|
|||||||
@@ -10,13 +10,39 @@
|
|||||||
setRoutedSegments,
|
setRoutedSegments,
|
||||||
setElevations,
|
setElevations,
|
||||||
clearDraft,
|
clearDraft,
|
||||||
reconcileSegments
|
reconcileSegments,
|
||||||
|
densifyLinearSegments
|
||||||
} from '$lib/components/hikes/route-builder/builderStore.svelte';
|
} from '$lib/components/hikes/route-builder/builderStore.svelte';
|
||||||
|
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let routeRequestId = 0;
|
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<void> {
|
||||||
|
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() {
|
async function snapToRoute() {
|
||||||
const placed = builder.waypoints.filter((w) => !w.unplaced);
|
const placed = builder.waypoints.filter((w) => !w.unplaced);
|
||||||
if (placed.length < 2) {
|
if (placed.length < 2) {
|
||||||
@@ -46,22 +72,9 @@
|
|||||||
};
|
};
|
||||||
if (reqId !== routeRequestId) return;
|
if (reqId !== routeRequestId) return;
|
||||||
setRoutedSegments(data.segments);
|
setRoutedSegments(data.segments);
|
||||||
|
// BRouter usually embeds elevations inline; OSRM / linear
|
||||||
// If routing didn't return elevations, enrich via Swisstopo.
|
// fallbacks don't. Single helper handles both cases.
|
||||||
const flat = data.segments.flat();
|
await enrichMissingElevations(reqId);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (reqId !== routeRequestId) return;
|
if (reqId !== routeRequestId) return;
|
||||||
error = (err as Error).message;
|
error = (err as Error).message;
|
||||||
@@ -95,10 +108,18 @@
|
|||||||
if (builder.autoSnap) {
|
if (builder.autoSnap) {
|
||||||
snapDebounce = setTimeout(() => snapToRoute(), 250);
|
snapDebounce = setTimeout(() => snapToRoute(), 250);
|
||||||
} else {
|
} else {
|
||||||
// Keep whatever was already snapped. Cancel any in-flight request so
|
// Manual / off-trail mode: keep already-snapped pairs intact
|
||||||
// a late response doesn't overwrite the linear placeholders we just
|
// (reconcileSegments preserved them); but for any fresh
|
||||||
// reconciled.
|
// two-point linear placeholder, densify to ~25 m spacing and
|
||||||
routeRequestId++;
|
// pull a Swisstopo elevation profile so the GPX carries
|
||||||
|
// per-trkpt `<ele>` 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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user