fix(hikes): repair Swisstopo elevation API (LV95 + POST), add busy chip

Two upstream constraints were causing the densified route-builder
tracks to ship without `<ele>` on most points:

1. api3.geo.admin.ch's elevation services reject `sr=4326` outright
   (HTTP 400: "Please provide a valid number for the spatial
   reference system model: 21781, 2056"). Add an in-process
   WGS84→LV95 converter using Swisstopo's published approximation
   (≈1 m positional accuracy, well below the DTM grid resolution)
   and switch both `height` and `profile.json` to sr=2056.

2. profile.json GET silently 414s once the URL crosses ~8 KB. At
   our densified-track sizes a 200-coord chunk hit ~9.6 KB and got
   dropped — that's why only a handful of segments came back with
   elevations. Switch to POST + form-encoded body; chunk size can
   safely go to 500 coords.

UX: extend the busy state to cover the densify+elevate path
(previously only set during snap-to-route) and add a mode-agnostic
status chip in the header that pulses amber while the elevation
request is in flight. GPX download button is now disabled while
busy so the file can't be exported half-finished.
This commit is contained in:
2026-05-19 11:01:23 +02:00
parent e3ccd96c7b
commit 7b7fbed472
3 changed files with 139 additions and 15 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.76.0",
"version": "1.76.1",
"private": true,
"type": "module",
"scripts": {
+64 -10
View File
@@ -229,6 +229,41 @@ const ELEV_CACHE_DIR = path.resolve(process.cwd(), 'scripts', '.cache', 'swissto
type SwisstopoProfile = Array<{ alts: { COMB?: number; DTM2?: number; DTM25?: number } }>;
/**
* WGS84 (lng, lat in degrees) → CH1903+ / LV95 (easting, northing in
* metres). Swisstopo's elevation services only speak the native Swiss
* projections (sr 21781 / 2056); they reject `sr=4326` outright. Their
* official "approximation" formulas yield ±1 m positional accuracy
* within the Swiss bbox — well below the elevation grid resolution
* (DTM25 = 25 m, DTM2 = 2 m), so doing the conversion in-process is
* cheap, dependency-free, and avoids a per-coord round-trip to the
* reframe service.
*
* Source: https://www.swisstopo.admin.ch/en/transformation-calculation-services
*/
function wgs84ToLv95(lng: number, lat: number): { easting: number; northing: number } {
const phi = (lat * 3600 - 169028.66) / 10000;
const lam = (lng * 3600 - 26782.5) / 10000;
const phi2 = phi * phi;
const phi3 = phi2 * phi;
const lam2 = lam * lam;
const lam3 = lam2 * lam;
const easting =
2600072.37 +
211455.93 * lam -
10938.51 * lam * phi -
0.36 * lam * phi2 -
44.54 * lam3;
const northing =
1200147.07 +
308807.95 * phi +
3745.25 * lam2 +
76.63 * phi2 -
194.56 * lam2 * phi +
119.79 * phi3;
return { easting, northing };
}
async function heightAt(lng: number, lat: number): Promise<number | null> {
const key = hashKey({ kind: 'height', lng, lat });
try {
@@ -237,9 +272,10 @@ async function heightAt(lng: number, lat: number): Promise<number | null> {
if (cached) return JSON.parse(cached) as number | null;
} catch { /* ignored */ }
const { easting, northing } = wgs84ToLv95(lng, lat);
const url =
`https://api3.geo.admin.ch/rest/services/height` +
`?easting=${lng}&northing=${lat}&sr=4326`;
`?easting=${easting}&northing=${northing}&sr=2056`;
let elev: number | null = null;
try {
const res = await fetch(url, {
@@ -309,19 +345,37 @@ export async function enrichElevations(
if (!uniqueElev) {
uniqueElev = new Array<number | null>(uniqueCoords.length).fill(null);
// Swisstopo caps offsets/payload sizes; chunk if needed.
const CHUNK = 200;
// profile.json must be POSTed: even ~100-point GETs return HTTP 400,
// and at our densified-track sizes the URL hits the upstream's
// ~8 KB length cap (silent HTTP 414). POST + form-encoded body has
// no such limit and accepts at least 500 coords per call, so the
// chunking is just for politeness against the public API.
const CHUNK = 500;
let cursor = 0;
while (cursor < uniqueCoords.length) {
const slice = uniqueCoords.slice(cursor, Math.min(uniqueCoords.length, cursor + CHUNK));
const slicedGeom = { type: 'LineString', coordinates: slice };
const url =
`https://api3.geo.admin.ch/rest/services/profile.json` +
`?geom=${encodeURIComponent(JSON.stringify(slicedGeom))}` +
`&nb_points=${slice.length}&offset=0&sr=4326`;
// Convert each WGS84 coord to LV95 — the elevation services only
// accept the native Swiss projections (`sr=2056`); `sr=4326` is
// rejected with HTTP 400.
const lv95: number[][] = slice.map(([lng, lat]) => {
const p = wgs84ToLv95(lng, lat);
return [p.easting, p.northing];
});
const slicedGeom = { type: 'LineString', coordinates: lv95 };
const body = new URLSearchParams({
geom: JSON.stringify(slicedGeom),
nb_points: String(slice.length),
offset: '0',
sr: '2056'
});
try {
const res = await fetch(url, {
headers: { 'User-Agent': 'bocken-homepage route-builder' }
const res = await fetch('https://api3.geo.admin.ch/rest/services/profile.json', {
method: 'POST',
headers: {
'User-Agent': 'bocken-homepage route-builder',
'Content-Type': 'application/x-www-form-urlencoded'
},
body
});
if (res.ok) {
const json = (await res.json()) as SwisstopoProfile;
+74 -4
View File
@@ -117,8 +117,15 @@
// overwrite what we're about to densify.
const reqId = ++routeRequestId;
snapDebounce = setTimeout(async () => {
densifyLinearSegments(25);
await enrichMissingElevations(reqId);
busy = true;
try {
densifyLinearSegments(25);
await enrichMissingElevations(reqId);
} catch (err) {
if (reqId === routeRequestId) error = (err as Error).message;
} finally {
if (reqId === routeRequestId) busy = false;
}
}, 250);
}
});
@@ -230,9 +237,22 @@
</select>
<label class="snap-toggle" class:active={builder.autoSnap}>
<input type="checkbox" bind:checked={builder.autoSnap} />
<span>Auf Wege snappen{busy ? ' …' : ''}</span>
<span>Auf Wege snappen</span>
</label>
<button type="button" class="primary" onclick={downloadGpx}>
<!-- Mode-agnostic busy chip — fires for both the snap-to-route
path and the densify+elevate path so the user always knows
when the GPX is still incomplete. -->
<span class="status" class:busy aria-live="polite" aria-atomic="true">
<span class="status-dot" aria-hidden="true"></span>
{busy ? 'Berechne Route + Höhenprofil…' : 'Bereit'}
</span>
<button
type="button"
class="primary"
onclick={downloadGpx}
disabled={busy}
title={busy ? 'Warten bis Route + Höhenprofil berechnet sind' : ''}
>
GPX herunterladen
</button>
<button type="button" class="link" onclick={clearDraft}>Zurücksetzen</button>
@@ -359,6 +379,56 @@
height: 1rem;
}
/* Live busy chip — sits between the snap toggle and the download
* button so the user can't miss it when GPX export would land
* without elevations yet. Quiet green dot when idle, pulsing
* amber dot when fetching. */
.status {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.8rem;
color: var(--color-text-secondary);
font-variant-numeric: tabular-nums;
min-width: 0;
}
.status-dot {
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
background: var(--green);
flex: 0 0 auto;
box-shadow: 0 0 0 0 color-mix(in oklab, var(--green) 40%, transparent);
}
.status.busy {
color: var(--orange);
}
.status.busy .status-dot {
background: var(--orange);
animation: status-pulse 1.1s ease-out infinite;
}
@keyframes status-pulse {
0% {
box-shadow: 0 0 0 0 color-mix(in oklab, var(--orange) 70%, transparent);
}
70% {
box-shadow: 0 0 0 0.55rem color-mix(in oklab, var(--orange) 0%, transparent);
}
100% {
box-shadow: 0 0 0 0 color-mix(in oklab, var(--orange) 0%, transparent);
}
}
@media (prefers-reduced-motion: reduce) {
.status.busy .status-dot {
animation: none;
}
}
.grid {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(280px, 1fr);