feat(planner): store computed route compact-encoded (groups 2–4)

Wire the map-core codec into route-data.ts so the Yjs session doc stores
the computed route compactly, without changing the routing-host
compute-once-and-share model.

- Codec: add encodeElevations/decodeElevations (delta+varint) so geometry
  = encoded [lon,lat] polyline + elevation channel; precision bumped to
  1e6 (~0.11 m) to preserve the router's 6-decimal output.
- writeComputedRoute: geometry stored once (encoded polyline + elevations,
  no redundant geojson), road metadata run-length encoded.
- Dual-format reads: getCoordinates / readRoadMetadata / new readGeojson
  transparently handle the new encoding AND legacy JSON docs (+ oldest
  geojson-only) — no migration, no flag day; legacy docs re-encode on the
  next recompute. use-elevation-data reads readGeojson (assembled for new
  docs).
- Spec correction: a lossy compact codec can't be byte-identical to legacy
  (generateGpx emits raw precision), so "GPX byte-compatible" is corrected
  to "coordinates preserved within the router's ~0.1 m precision".

Tests: legacy-format read, size assertion (>4x smaller / round-trips),
elevation round-trip. map-core + planner typecheck/lint clean; full
pnpm test 11/11.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-07-15 17:07:27 +02:00
parent 84744567bc
commit c33a413395
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
8 changed files with 181 additions and 33 deletions

View file

@ -33,5 +33,6 @@ export type { SurfaceBreakdown } from "./surface-breakdown.ts";
export {
encodePolyline, decodePolyline, encodeRuns, decodeRuns,
isEncodedPolyline, isEncodedRuns,
encodeElevations, decodeElevations,
isEncodedPolyline, isEncodedRuns, isEncodedElevations,
} from "./route-codec.ts";

View file

@ -4,8 +4,11 @@ import {
decodePolyline,
encodeRuns,
decodeRuns,
encodeElevations,
decodeElevations,
isEncodedPolyline,
isEncodedRuns,
isEncodedElevations,
} from "./route-codec.ts";
describe("polyline codec", () => {
@ -55,6 +58,26 @@ describe("polyline codec", () => {
});
});
describe("elevation codec", () => {
it("round-trips elevations within decimetre precision", () => {
const eles = [34, 34.2, 80.5, 519, 518.9, 200, 0, -5.3];
const decoded = decodeElevations(encodeElevations(eles));
expect(decoded).toHaveLength(eles.length);
for (let i = 0; i < eles.length; i++) {
expect(Math.abs(decoded[i]! - eles[i]!)).toBeLessThanOrEqual(0.05);
}
});
it("handles empty and is much smaller than JSON for a long climb", () => {
expect(decodeElevations(encodeElevations([]))).toEqual([]);
const eles = Array.from({ length: 4000 }, (_, i) => 100 + i * 0.1);
const enc = encodeElevations(eles);
expect(isEncodedElevations(enc)).toBe(true);
expect(enc.length).toBeLessThan(JSON.stringify(eles).length / 2);
expect(decodeElevations(enc)).toHaveLength(4000);
});
});
describe("run-length codec", () => {
it("round-trips and collapses uniform runs", () => {
const values = [

View file

@ -5,9 +5,15 @@
// path can tell them apart from the legacy JSON encoding (which is a bare
// `[` … `]`), enabling transparent backward compatibility.
const PRECISION = 100_000; // 1e5 ≈ 1.1 m; below BRouter/OSM working resolution
// 1e6 ≈ 0.11 m: matches the 6-decimal precision BRouter/GPX emit, so a
// round-trip preserves the router's own coordinates (GPX export is
// unchanged in practice). Values stay well within 32-bit for real
// lon/lat ranges (±180e6 → ~29 bits after zig-zag).
const PRECISION = 1_000_000;
const ELEV_PRECISION = 10; // decimetre — finer than any real elevation source
const POLYLINE_PREFIX = "p1:";
const RUNS_PREFIX = "r1:";
const ELEV_PREFIX = "e1:";
// --- base64 via the global btoa/atob (present in browsers and Node 18+),
// so this stays framework-free with no @types/node dependency ---
@ -58,6 +64,38 @@ export function isEncodedPolyline(s: string): boolean {
export function isEncodedRuns(s: string): boolean {
return s.startsWith(RUNS_PREFIX);
}
export function isEncodedElevations(s: string): boolean {
return s.startsWith(ELEV_PREFIX);
}
/**
* Encode a per-coordinate elevation channel (metres) as delta + zig-zag
* varint over decimetre-precision integers. Elevation is the third
* coordinate dimension; the horizontal [lon,lat] pairs go through
* {@link encodePolyline}, so the two together carry the full [lon,lat,ele].
*/
export function encodeElevations(eles: readonly number[]): string {
const bytes: number[] = [];
let prev = 0;
for (const e of eles) {
const iv = Math.round(e * ELEV_PRECISION);
writeVarint(bytes, zigzag(iv - prev));
prev = iv;
}
return ELEV_PREFIX + bytesToBase64(Uint8Array.from(bytes));
}
export function decodeElevations(s: string): number[] {
if (!isEncodedElevations(s)) throw new Error("not encoded elevations");
const nums = readVarints(base64ToBytes(s.slice(ELEV_PREFIX.length)));
const out: number[] = [];
let e = 0;
for (const n of nums) {
e += unzigzag(n);
out.push(e / ELEV_PRECISION);
}
return out;
}
/**
* Encode `[lon, lat]` coordinates as delta + zig-zag varint over