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);