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:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homepage",
|
"name": "homepage",
|
||||||
"version": "1.76.0",
|
"version": "1.76.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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 } }>;
|
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> {
|
async function heightAt(lng: number, lat: number): Promise<number | null> {
|
||||||
const key = hashKey({ kind: 'height', lng, lat });
|
const key = hashKey({ kind: 'height', lng, lat });
|
||||||
try {
|
try {
|
||||||
@@ -237,9 +272,10 @@ async function heightAt(lng: number, lat: number): Promise<number | null> {
|
|||||||
if (cached) return JSON.parse(cached) as number | null;
|
if (cached) return JSON.parse(cached) as number | null;
|
||||||
} catch { /* ignored */ }
|
} catch { /* ignored */ }
|
||||||
|
|
||||||
|
const { easting, northing } = wgs84ToLv95(lng, lat);
|
||||||
const url =
|
const url =
|
||||||
`https://api3.geo.admin.ch/rest/services/height` +
|
`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;
|
let elev: number | null = null;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
@@ -309,19 +345,37 @@ export async function enrichElevations(
|
|||||||
|
|
||||||
if (!uniqueElev) {
|
if (!uniqueElev) {
|
||||||
uniqueElev = new Array<number | null>(uniqueCoords.length).fill(null);
|
uniqueElev = new Array<number | null>(uniqueCoords.length).fill(null);
|
||||||
// Swisstopo caps offsets/payload sizes; chunk if needed.
|
// profile.json must be POSTed: even ~100-point GETs return HTTP 400,
|
||||||
const CHUNK = 200;
|
// 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;
|
let cursor = 0;
|
||||||
while (cursor < uniqueCoords.length) {
|
while (cursor < uniqueCoords.length) {
|
||||||
const slice = uniqueCoords.slice(cursor, Math.min(uniqueCoords.length, cursor + CHUNK));
|
const slice = uniqueCoords.slice(cursor, Math.min(uniqueCoords.length, cursor + CHUNK));
|
||||||
const slicedGeom = { type: 'LineString', coordinates: slice };
|
// Convert each WGS84 coord to LV95 — the elevation services only
|
||||||
const url =
|
// accept the native Swiss projections (`sr=2056`); `sr=4326` is
|
||||||
`https://api3.geo.admin.ch/rest/services/profile.json` +
|
// rejected with HTTP 400.
|
||||||
`?geom=${encodeURIComponent(JSON.stringify(slicedGeom))}` +
|
const lv95: number[][] = slice.map(([lng, lat]) => {
|
||||||
`&nb_points=${slice.length}&offset=0&sr=4326`;
|
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 {
|
try {
|
||||||
const res = await fetch(url, {
|
const res = await fetch('https://api3.geo.admin.ch/rest/services/profile.json', {
|
||||||
headers: { 'User-Agent': 'bocken-homepage route-builder' }
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'bocken-homepage route-builder',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
},
|
||||||
|
body
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const json = (await res.json()) as SwisstopoProfile;
|
const json = (await res.json()) as SwisstopoProfile;
|
||||||
|
|||||||
@@ -117,8 +117,15 @@
|
|||||||
// overwrite what we're about to densify.
|
// overwrite what we're about to densify.
|
||||||
const reqId = ++routeRequestId;
|
const reqId = ++routeRequestId;
|
||||||
snapDebounce = setTimeout(async () => {
|
snapDebounce = setTimeout(async () => {
|
||||||
densifyLinearSegments(25);
|
busy = true;
|
||||||
await enrichMissingElevations(reqId);
|
try {
|
||||||
|
densifyLinearSegments(25);
|
||||||
|
await enrichMissingElevations(reqId);
|
||||||
|
} catch (err) {
|
||||||
|
if (reqId === routeRequestId) error = (err as Error).message;
|
||||||
|
} finally {
|
||||||
|
if (reqId === routeRequestId) busy = false;
|
||||||
|
}
|
||||||
}, 250);
|
}, 250);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -230,9 +237,22 @@
|
|||||||
</select>
|
</select>
|
||||||
<label class="snap-toggle" class:active={builder.autoSnap}>
|
<label class="snap-toggle" class:active={builder.autoSnap}>
|
||||||
<input type="checkbox" bind:checked={builder.autoSnap} />
|
<input type="checkbox" bind:checked={builder.autoSnap} />
|
||||||
<span>Auf Wege snappen{busy ? ' …' : ''}</span>
|
<span>Auf Wege snappen</span>
|
||||||
</label>
|
</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
|
GPX herunterladen
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="link" onclick={clearDraft}>Zurücksetzen</button>
|
<button type="button" class="link" onclick={clearDraft}>Zurücksetzen</button>
|
||||||
@@ -359,6 +379,56 @@
|
|||||||
height: 1rem;
|
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 {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1.5fr) minmax(280px, 1fr);
|
grid-template-columns: minmax(0, 1.5fr) minmax(280px, 1fr);
|
||||||
|
|||||||
Reference in New Issue
Block a user