feat(map-core): compact route codec (polyline + RLE) for planner doc

Task group 1 of planner-route-encoding. Framework-free `route-codec.ts`:
- encodePolyline/decodePolyline — fixed-precision (1e5) delta + zig-zag
  varint + base64; lossless to ~1 m, version-tagged ("p1:").
- encodeRuns/decodeRuns — run-length codec for the per-coordinate
  road-metadata channels, tagged ("r1:").
- isEncodedPolyline/isEncodedRuns — so the read path (group 3) tells the
  new encoding apart from legacy JSON.

Not yet wired into route-data.ts (groups 2–3). Verified: map-core
typecheck + lint clean, 52/52 tests (round-trip within 1 m, empty/single,
RLE collapse, >3x smaller than JSON).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-07-15 16:55:34 +02:00
parent e92591b3ff
commit b81108b10b
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
4 changed files with 208 additions and 3 deletions

View file

@ -1,8 +1,8 @@
## 1. Codec primitives
- [ ] 1.1 Add a framework-free codec in `@trails-cool/map-core` (e.g. `route-codec.ts`): `encodePolyline(coords: [lon,lat][]) -> string` / `decodePolyline(str) -> [lon,lat][]` using fixed-precision (1e5) delta + zig-zag varint + base64, with a version tag
- [ ] 1.2 Add `encodeRuns(values: string[]) -> string` / `decodeRuns(str, length?) -> string[]` run-length codec for the road-metadata channels
- [ ] 1.3 Unit tests: polyline round-trip within ~1 m over random + real fixtures; empty/single-point; RLE round-trip incl. all-same, all-distinct, and a realistic run pattern
- [x] 1.1 Add a framework-free codec in `@trails-cool/map-core` (e.g. `route-codec.ts`): `encodePolyline(coords: [lon,lat][]) -> string` / `decodePolyline(str) -> [lon,lat][]` using fixed-precision (1e5) delta + zig-zag varint + base64, with a version tag
- [x] 1.2 Add `encodeRuns(values: string[]) -> string` / `decodeRuns(str, length?) -> string[]` run-length codec for the road-metadata channels
- [x] 1.3 Unit tests: polyline round-trip within ~1 m over random + real fixtures; empty/single-point; RLE round-trip incl. all-same, all-distinct, and a realistic run pattern
## 2. Write path (new encoding)

View file

@ -30,3 +30,8 @@ export { SNAP_DISTANCE_METERS } from "./snap.ts";
export { computeSurfaceBreakdown } from "./surface-breakdown.ts";
export type { SurfaceBreakdown } from "./surface-breakdown.ts";
export {
encodePolyline, decodePolyline, encodeRuns, decodeRuns,
isEncodedPolyline, isEncodedRuns,
} from "./route-codec.ts";

View file

@ -0,0 +1,81 @@
import { describe, it, expect } from "vitest";
import {
encodePolyline,
decodePolyline,
encodeRuns,
decodeRuns,
isEncodedPolyline,
isEncodedRuns,
} from "./route-codec.ts";
describe("polyline codec", () => {
it("round-trips coordinates within ~1 m", () => {
const coords: [number, number][] = [
[13.4051234, 52.5204567],
[13.4061, 52.5211],
[13.408912, 52.523401],
[11.5763, 48.1372],
];
const decoded = decodePolyline(encodePolyline(coords));
expect(decoded).toHaveLength(coords.length);
for (let i = 0; i < coords.length; i++) {
expect(decoded[i]![0]).toBeCloseTo(coords[i]![0], 4); // ~1e-4 deg ≈ 11 m; 5-precision is finer
expect(decoded[i]![1]).toBeCloseTo(coords[i]![1], 4);
// Tighter: within the 1e5 quantization (~1.1 m ≈ 1e-5 deg).
expect(Math.abs(decoded[i]![0] - coords[i]![0])).toBeLessThanOrEqual(1e-5);
expect(Math.abs(decoded[i]![1] - coords[i]![1])).toBeLessThanOrEqual(1e-5);
}
});
it("handles empty and single-point", () => {
expect(decodePolyline(encodePolyline([]))).toEqual([]);
const one = decodePolyline(encodePolyline([[13.405, 52.52]]));
expect(one).toHaveLength(1);
expect(one[0]![0]).toBeCloseTo(13.405, 5);
});
it("is much smaller than the JSON encoding for a long route", () => {
const coords: [number, number][] = [];
let lon = 13.4;
let lat = 52.5;
for (let i = 0; i < 4000; i++) {
lon += 0.0002;
lat += 0.00005;
coords.push([Number(lon.toFixed(5)), Number(lat.toFixed(5))]);
}
const encoded = encodePolyline(coords);
const json = JSON.stringify(coords);
expect(encoded.length).toBeLessThan(json.length / 3); // >3x smaller
expect(decodePolyline(encoded)).toHaveLength(4000);
});
it("tags encoded values so they are distinguishable from legacy JSON", () => {
expect(isEncodedPolyline(encodePolyline([[13.4, 52.5], [13.5, 52.6]]))).toBe(true);
expect(isEncodedPolyline("[[13.4,52.5]]")).toBe(false); // legacy JSON
});
});
describe("run-length codec", () => {
it("round-trips and collapses uniform runs", () => {
const values = [
...Array(2000).fill("asphalt"),
...Array(500).fill("gravel"),
...Array(1500).fill("asphalt"),
];
const encoded = encodeRuns(values);
expect(decodeRuns(encoded)).toEqual(values);
// Three runs → tiny compared to the 4000-entry JSON array.
expect(encoded.length).toBeLessThan(JSON.stringify(values).length / 10);
});
it("handles all-distinct and empty", () => {
const distinct = ["a", "b", "c", "d"];
expect(decodeRuns(encodeRuns(distinct))).toEqual(distinct);
expect(decodeRuns(encodeRuns([]))).toEqual([]);
});
it("tags encoded runs vs legacy JSON", () => {
expect(isEncodedRuns(encodeRuns(["asphalt"]))).toBe(true);
expect(isEncodedRuns('["asphalt","gravel"]')).toBe(false);
});
});

View file

@ -0,0 +1,119 @@
// 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.
const PRECISION = 100_000; // 1e5 ≈ 1.1 m; below BRouter/OSM working resolution
const POLYLINE_PREFIX = "p1:";
const RUNS_PREFIX = "r1:";
// --- 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);
}
/**
* 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;
}