feat(hikes): re-derive track altitudes from swisstopo + pre-commit hook
Replace noisy phone-GPS <ele> in every committed track.gpx with swisstopo swissALTI3D heights at each exact lat/lon (coordinates unchanged; phone altitude was off by up to ~430m). - scripts/fix-altitudes.ts: batched swisstopo profile.json lookup, WGS84->LV95, disk-cached, keeps original ele for any out-of-CH point. - .githooks/pre-commit: auto-corrects any added/modified track.gpx on commit and re-stages it; wired via package.json prepare -> core.hooksPath.
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Re-derive track-point altitudes from a real terrain model.
|
||||
*
|
||||
* Phone GPS altitude is noisy (often ±10-20 m), which throws off the elevation
|
||||
* profile and the ascend/descend stats. This script keeps every point's exact
|
||||
* lat/lon and only rewrites its `<ele>`, sourcing the height from swisstopo's
|
||||
* swissALTI3D / DHM25 combined model (~0.5-2 m vertical accuracy) at that exact
|
||||
* coordinate.
|
||||
*
|
||||
* 1. Collect every `<wpt>` and `<trkpt>` in each `track.gpx`.
|
||||
* 2. Convert WGS84 → LV95 (swisstopo approximate formula, ~1 m horizontal —
|
||||
* negligible for an elevation lookup).
|
||||
* 3. Ask swisstopo for the height of each distinct point (one batched
|
||||
* `profile.json` POST per ~1000 points; per-point `height` as a fallback),
|
||||
* cached on disk so re-runs and shared points are free.
|
||||
* 4. Surgically replace each point's `<ele>` value, leaving coordinates,
|
||||
* timestamps, `<bocken:image>` extensions and all formatting untouched.
|
||||
*
|
||||
* swisstopo only covers Switzerland: points outside CH keep their original
|
||||
* elevation and are reported as skipped.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm exec vite-node scripts/fix-altitudes.ts [slug...] [--dry-run]
|
||||
* (no slug → every hike under src/content/hikes/)
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const ROOT = path.resolve(process.cwd());
|
||||
const CONTENT_DIR = path.join(ROOT, 'src', 'content', 'hikes');
|
||||
const CACHE_DIR = path.join(ROOT, 'scripts', '.cache');
|
||||
const CACHE_FILE = path.join(CACHE_DIR, 'swisstopo-elevation.json');
|
||||
|
||||
const PROFILE_URL = 'https://api3.geo.admin.ch/rest/services/profile.json';
|
||||
const HEIGHT_URL = 'https://api3.geo.admin.ch/rest/services/height';
|
||||
// swisstopo's profile service handles a few thousand vertices per call; keep
|
||||
// chunks well under that so the POST body and response stay modest.
|
||||
const PROFILE_CHUNK = 1000;
|
||||
|
||||
// Matches a <wpt>/<trkpt> opening tag and its immediate <ele> child. The route
|
||||
// builder always writes `<ele>` as the first child (verified across every
|
||||
// track.gpx), so a single capture group around the value is enough to rewrite.
|
||||
const POINT_ELE_RE =
|
||||
/(<(?:wpt|trkpt)\s+lat="([^"]+)"\s+lon="([^"]+)"[^>]*>\s*<ele>)([^<]*)(<\/ele>)/g;
|
||||
|
||||
type Cache = Record<string, number>;
|
||||
|
||||
/** WGS84 (lat/lon, degrees) → CH1903+/LV95 (E, N), swisstopo approx formula. */
|
||||
function wgs84ToLV95(lat: number, lon: number): [number, number] {
|
||||
const phi = (lat * 3600 - 169028.66) / 10000;
|
||||
const lam = (lon * 3600 - 26782.5) / 10000;
|
||||
const E =
|
||||
2600072.37 +
|
||||
211455.93 * lam -
|
||||
10938.51 * lam * phi -
|
||||
0.36 * lam * phi * phi -
|
||||
44.54 * lam ** 3;
|
||||
const N =
|
||||
1200147.07 +
|
||||
308807.95 * phi +
|
||||
3745.25 * lam * lam +
|
||||
76.63 * phi * phi -
|
||||
194.56 * lam * lam * phi +
|
||||
119.79 * phi ** 3;
|
||||
return [Math.round(E * 100) / 100, Math.round(N * 100) / 100];
|
||||
}
|
||||
|
||||
const enKey = (E: number, N: number): string => `${E.toFixed(2)},${N.toFixed(2)}`;
|
||||
|
||||
async function loadCache(): Promise<Cache> {
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(CACHE_FILE, 'utf-8'));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCache(cache: Cache): Promise<void> {
|
||||
await fs.mkdir(CACHE_DIR, { recursive: true });
|
||||
await fs.writeFile(CACHE_FILE, JSON.stringify(cache));
|
||||
}
|
||||
|
||||
/** Batched height lookup. Returns a map of `enKey` → height for resolved points. */
|
||||
async function fetchProfile(coords: [number, number][]): Promise<Map<string, number>> {
|
||||
const out = new Map<string, number>();
|
||||
if (coords.length < 2) return out;
|
||||
const body = new URLSearchParams({
|
||||
geom: JSON.stringify({ type: 'LineString', coordinates: coords }),
|
||||
sr: '2056',
|
||||
distinct_points: 'true',
|
||||
nb_points: String(coords.length),
|
||||
offset: '0'
|
||||
});
|
||||
const res = await fetch(PROFILE_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body
|
||||
});
|
||||
if (!res.ok) throw new Error(`profile.json HTTP ${res.status}`);
|
||||
const rows = (await res.json()) as Array<{
|
||||
alts?: Record<string, number | null>;
|
||||
easting: number;
|
||||
northing: number;
|
||||
}>;
|
||||
for (const r of rows) {
|
||||
const h = r.alts?.COMB ?? r.alts?.DTM2 ?? r.alts?.DTM25;
|
||||
if (typeof h === 'number') out.set(enKey(r.easting, r.northing), h);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Single-point fallback (also the only option for a 1-point chunk). */
|
||||
async function fetchHeight(E: number, N: number): Promise<number | null> {
|
||||
try {
|
||||
const res = await fetch(`${HEIGHT_URL}?easting=${E}&northing=${N}&sr=2056`);
|
||||
if (!res.ok) return null;
|
||||
const j = (await res.json()) as { height?: string | number; success?: boolean };
|
||||
if (j.success === false) return null;
|
||||
const h = typeof j.height === 'string' ? parseFloat(j.height) : j.height;
|
||||
return typeof h === 'number' && Number.isFinite(h) ? h : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type PointKey = string; // `${latStr},${lonStr}` exactly as written in the file
|
||||
|
||||
async function fixTrack(slug: string, cache: Cache, dryRun: boolean): Promise<void> {
|
||||
const file = path.join(CONTENT_DIR, slug, 'track.gpx');
|
||||
let text: string;
|
||||
try {
|
||||
text = await fs.readFile(file, 'utf-8');
|
||||
} catch {
|
||||
console.warn(`[fix-altitudes] ${slug}: no track.gpx, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Distinct points, keyed by the exact lat/lon strings in the file so the
|
||||
// rewrite can match without any float round-tripping.
|
||||
const points = new Map<PointKey, { lat: number; lon: number; E: number; N: number }>();
|
||||
for (const m of text.matchAll(POINT_ELE_RE)) {
|
||||
const key = `${m[2]},${m[3]}`;
|
||||
if (!points.has(key)) {
|
||||
const lat = parseFloat(m[2]);
|
||||
const lon = parseFloat(m[3]);
|
||||
const [E, N] = wgs84ToLV95(lat, lon);
|
||||
points.set(key, { lat, lon, E, N });
|
||||
}
|
||||
}
|
||||
if (points.size === 0) {
|
||||
console.warn(`[fix-altitudes] ${slug}: no points found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve heights for any points not already cached.
|
||||
const uncached = [...points.values()].filter((p) => cache[enKey(p.E, p.N)] === undefined);
|
||||
if (uncached.length > 0) {
|
||||
for (let i = 0; i < uncached.length; i += PROFILE_CHUNK) {
|
||||
const chunk = uncached.slice(i, i + PROFILE_CHUNK);
|
||||
let resolved = new Map<string, number>();
|
||||
try {
|
||||
resolved = await fetchProfile(chunk.map((p) => [p.E, p.N] as [number, number]));
|
||||
} catch (err) {
|
||||
console.warn(`[fix-altitudes] ${slug}: profile batch failed (${String(err)}), falling back per-point`);
|
||||
}
|
||||
for (const p of chunk) {
|
||||
const k = enKey(p.E, p.N);
|
||||
let h = resolved.get(k);
|
||||
if (h === undefined) h = (await fetchHeight(p.E, p.N)) ?? undefined;
|
||||
if (h !== undefined) cache[k] = h;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rewrite each <ele> in place; tally changes and out-of-CH skips.
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
let maxDelta = 0;
|
||||
const fixed = text.replace(POINT_ELE_RE, (full, open, latStr, lonStr, oldEle, close) => {
|
||||
const p = points.get(`${latStr},${lonStr}`)!;
|
||||
const h = cache[enKey(p.E, p.N)];
|
||||
if (h === undefined) {
|
||||
skipped++;
|
||||
return full; // outside CH coverage — keep original elevation
|
||||
}
|
||||
const newEle = h.toFixed(1);
|
||||
const old = parseFloat(oldEle);
|
||||
if (Number.isFinite(old)) maxDelta = Math.max(maxDelta, Math.abs(h - old));
|
||||
if (newEle !== oldEle.trim()) updated++;
|
||||
return `${open}${newEle}${close}`;
|
||||
});
|
||||
|
||||
const summary =
|
||||
`${points.size} distinct pts · ${updated} ele rewritten · ` +
|
||||
`max Δ ${maxDelta.toFixed(1)} m` +
|
||||
(skipped > 0 ? ` · ${skipped} kept (outside CH)` : '');
|
||||
if (dryRun) {
|
||||
console.log(`[fix-altitudes] ${slug}: ${summary} (dry-run, not written)`);
|
||||
return;
|
||||
}
|
||||
if (fixed !== text) {
|
||||
await fs.writeFile(file, fixed);
|
||||
console.log(`[fix-altitudes] ${slug}: ${summary}`);
|
||||
} else {
|
||||
console.log(`[fix-altitudes] ${slug}: already up to date (${summary})`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
const dryRun = args.includes('--dry-run');
|
||||
const slugArgs = args.filter((a) => !a.startsWith('--'));
|
||||
|
||||
let slugs = slugArgs;
|
||||
if (slugs.length === 0) {
|
||||
const entries = await fs.readdir(CONTENT_DIR, { withFileTypes: true });
|
||||
slugs = entries
|
||||
.filter((e) => e.isDirectory() && !e.name.startsWith('TODO-'))
|
||||
.map((e) => e.name)
|
||||
.sort();
|
||||
}
|
||||
|
||||
const cache = await loadCache();
|
||||
for (const slug of slugs) {
|
||||
await fixTrack(slug, cache, dryRun);
|
||||
}
|
||||
await saveCache(cache);
|
||||
console.log(`[fix-altitudes] done (${slugs.length} track(s), cache: ${Object.keys(cache).length} pts)`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[fix-altitudes] Fatal:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user