Merge pull request #590 from trails-cool/feat/planner-route-encoding-codec
feat(map-core): compact route codec (polyline + RLE)
This commit is contained in:
commit
84744567bc
4 changed files with 208 additions and 3 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
81
packages/map-core/src/route-codec.test.ts
Normal file
81
packages/map-core/src/route-codec.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
119
packages/map-core/src/route-codec.ts
Normal file
119
packages/map-core/src/route-codec.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue