trails/packages/map-core/src/route-codec.ts
Ullrich Schäfer c33a413395
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>
2026-07-15 17:07:28 +02:00

157 lines
5.2 KiB
TypeScript

// Compact, lossless-at-working-precision codecs for the planner's computed
// route as stored in the Yjs session doc (spec: planner-route-encoding).
// Framework-free so it can be unit-tested and run on both the client and
// the server. All encoded values carry a short version prefix so the read
// path can tell them apart from the legacy JSON encoding (which is a bare
// `[` … `]`), enabling transparent backward compatibility.
// 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 ---
function bytesToBase64(bytes: Uint8Array): string {
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin);
}
function base64ToBytes(b64: string): Uint8Array {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
// --- unsigned/zig-zag varint over a byte array ---
function writeVarint(out: number[], value: number): void {
let v = value >>> 0;
while (v > 0x7f) {
out.push((v & 0x7f) | 0x80);
v = v >>> 7;
}
out.push(v);
}
function readVarints(bytes: Uint8Array): number[] {
const nums: number[] = [];
let v = 0;
let shift = 0;
for (const byte of bytes) {
v |= (byte & 0x7f) << shift;
if (byte & 0x80) {
shift += 7;
} else {
nums.push(v >>> 0);
v = 0;
shift = 0;
}
}
return nums;
}
const zigzag = (n: number): number => (n << 1) ^ (n >> 31);
const unzigzag = (n: number): number => (n >>> 1) ^ -(n & 1);
/** True if `s` is a compact-encoded value (vs a legacy JSON string). */
export function isEncodedPolyline(s: string): boolean {
return s.startsWith(POLYLINE_PREFIX);
}
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
* fixed-precision integers, base64'd. Lossless to ~1 m.
*/
export function encodePolyline(coords: ReadonlyArray<readonly [number, number]>): string {
const ints: number[] = [];
let prevLon = 0;
let prevLat = 0;
for (const [lon, lat] of coords) {
const iLon = Math.round(lon * PRECISION);
const iLat = Math.round(lat * PRECISION);
ints.push(zigzag(iLon - prevLon), zigzag(iLat - prevLat));
prevLon = iLon;
prevLat = iLat;
}
const bytes: number[] = [];
for (const n of ints) writeVarint(bytes, n);
return POLYLINE_PREFIX + bytesToBase64(Uint8Array.from(bytes));
}
export function decodePolyline(s: string): [number, number][] {
if (!isEncodedPolyline(s)) throw new Error("not an encoded polyline");
const nums = readVarints(base64ToBytes(s.slice(POLYLINE_PREFIX.length)));
const coords: [number, number][] = [];
let lon = 0;
let lat = 0;
for (let i = 0; i + 1 < nums.length; i += 2) {
lon += unzigzag(nums[i]!);
lat += unzigzag(nums[i + 1]!);
coords.push([lon / PRECISION, lat / PRECISION]);
}
return coords;
}
/**
* Run-length-encode a per-coordinate metadata channel. Road attributes run
* in long identical stretches, so this collapses thousands of entries to a
* handful of `[value, count]` pairs.
*/
export function encodeRuns(values: readonly string[]): string {
const runs: [string, number][] = [];
for (const v of values) {
const last = runs[runs.length - 1];
if (last && last[0] === v) last[1]++;
else runs.push([v, 1]);
}
return RUNS_PREFIX + JSON.stringify(runs);
}
export function decodeRuns(s: string): string[] {
if (!isEncodedRuns(s)) throw new Error("not encoded runs");
const runs = JSON.parse(s.slice(RUNS_PREFIX.length)) as [string, number][];
const out: string[] = [];
for (const [value, count] of runs) {
for (let i = 0; i < count; i++) out.push(value);
}
return out;
}