From 7b7fbed4722150f675c3e576e742583235db7dd8 Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Tue, 19 May 2026 11:01:23 +0200 Subject: [PATCH] fix(hikes): repair Swisstopo elevation API (LV95 + POST), add busy chip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two upstream constraints were causing the densified route-builder tracks to ship without `` 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. --- package.json | 2 +- src/lib/server/hikesRouting.ts | 74 ++++++++++++++++--- src/routes/hikes/route-builder/+page.svelte | 78 +++++++++++++++++++-- 3 files changed, 139 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index 2792d943..aff445f8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.76.0", + "version": "1.76.1", "private": true, "type": "module", "scripts": { diff --git a/src/lib/server/hikesRouting.ts b/src/lib/server/hikesRouting.ts index d5a3f38a..0efb63be 100644 --- a/src/lib/server/hikesRouting.ts +++ b/src/lib/server/hikesRouting.ts @@ -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 { const key = hashKey({ kind: 'height', lng, lat }); try { @@ -237,9 +272,10 @@ async function heightAt(lng: number, lat: number): Promise { 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(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; diff --git a/src/routes/hikes/route-builder/+page.svelte b/src/routes/hikes/route-builder/+page.svelte index 84696ec7..528e8b4b 100644 --- a/src/routes/hikes/route-builder/+page.svelte +++ b/src/routes/hikes/route-builder/+page.svelte @@ -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 @@ - @@ -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);