diff --git a/openspec/changes/planner-route-encoding/tasks.md b/openspec/changes/planner-route-encoding/tasks.md index 69d28ca..e5955e1 100644 --- a/openspec/changes/planner-route-encoding/tasks.md +++ b/openspec/changes/planner-route-encoding/tasks.md @@ -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) diff --git a/packages/map-core/src/index.ts b/packages/map-core/src/index.ts index 1495eb0..2dc72a0 100644 --- a/packages/map-core/src/index.ts +++ b/packages/map-core/src/index.ts @@ -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"; diff --git a/packages/map-core/src/route-codec.test.ts b/packages/map-core/src/route-codec.test.ts new file mode 100644 index 0000000..0f97369 --- /dev/null +++ b/packages/map-core/src/route-codec.test.ts @@ -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); + }); +}); diff --git a/packages/map-core/src/route-codec.ts b/packages/map-core/src/route-codec.ts new file mode 100644 index 0000000..aa5a706 --- /dev/null +++ b/packages/map-core/src/route-codec.ts @@ -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): 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; +}