feat(05-01): polyline6 codec (pure, zero-dependency)

- standard encoded-polyline algorithm at 1e6 precision, double math
  (no int32 shifts) so no coordinate range wraps silently
- decode THROWS on truncated varints — never partial/NaN points (T-05-02)
- round-trip law encode(decode(s)) === s proven on all vectors incl.
  the external live-OSRM geometry (RESEARCH Finding 1)
This commit is contained in:
araharah-executor
2026-10-24 05:01:13 +08:00
parent 9b66f90a4b
commit 4acb878c33
+97
View File
@@ -0,0 +1,97 @@
/*
* polyline.ts — Google encoded-polyline codec at precision 1e6 ("polyline6").
* -----------------------------------------------------------------------------
* This is the encoding the OSRM demo's `geometries=polyline6` returns and what
* the fixture corpus stores VERBATIM (RESEARCH Finding 1): the committed string
* is exactly what the server returned — no float rounding, no re-serialization.
*
* Algorithm (standard Google encoded-polyline, lat then lng per point):
* encode value = round(coordinate * 1e6); delta = value - previous value
* (previous seeded at 0); zigzag the delta (negative v -> -v*2 - 1,
* else v*2); emit 5-bit chunks little-end first with continuation
* bit 0x20 on every chunk but the last; each chunk + 63 is the
* output character.
* decode the exact inverse; the accumulated integer is divided by 1e6.
*
* Because every decoded value lives on the 1e-6 grid, the ROUND-TRIP LAW
* holds: encodePolyline6(decodePolyline6(s)) === s for every valid string —
* the fixture corpus gate (plan 05-02) leans on this as a free per-pair
* assertion.
*
* T-05-02: malformed input — a coordinate cut off mid-varint (dangling
* continuation chunk, odd trailing coordinate) — THROWS "truncated polyline6
* input". The decoder never returns partial or NaN points that could poison a
* map draw. "" decodes to [] (a legal empty geometry).
*
* The point shape is declared structurally (identical to types.ts RoutePoint)
* because this module is PURE: zero imports, zero I/O — the
* search/src/distance.ts precedent (a dependency would weight the Worker and
* browser bundles for zero gain). Arithmetic uses double math (values stay far
* below 2^53), not int32 bit shifts, so no coordinate range can silently wrap.
*/
/** Minimal structural point — structurally identical to types.ts RoutePoint. */
interface PolyPoint {
lat: number;
lng: number;
}
/** Encode one zigzagged delta as a 5-bit-chunk varint over the +63 alphabet. */
function encodeVarint(value: number): string {
let v = value < 0 ? -value * 2 - 1 : value * 2;
let out = "";
while (v >= 0x20) {
out += String.fromCharCode((v % 32) + 0x20 + 63);
v = Math.floor(v / 32);
}
return out + String.fromCharCode(v + 63);
}
/** Decode polyline6 exactly; throws on truncated input (never partial points). */
export function decodePolyline6(encoded: string): PolyPoint[] {
const points: PolyPoint[] = [];
const n = encoded.length;
let index = 0;
let lat = 0;
let lng = 0;
while (index < n) {
// lat then lng per point — a coordinate cut off mid-varint throws (T-05-02).
for (let axis = 0; axis < 2; axis++) {
let result = 0;
let factor = 1;
let continuation = true;
while (continuation) {
if (index >= n) throw new Error("truncated polyline6 input");
const code = encoded.charCodeAt(index) - 63;
index += 1;
result += (code & 0x1f) * factor;
factor *= 32;
continuation = (code & 0x20) !== 0;
}
const delta = result % 2 === 1 ? -((result + 1) / 2) : result / 2;
if (axis === 0) {
lat += delta;
} else {
lng += delta;
}
}
points.push({ lat: lat / 1e6, lng: lng / 1e6 });
}
return points;
}
/** Encode points to polyline6 (deltas accumulate left to right; 1e-6 grid). */
export function encodePolyline6(points: readonly PolyPoint[]): string {
let out = "";
let prevLat = 0;
let prevLng = 0;
for (const point of points) {
const lat = Math.round(point.lat * 1e6);
const lng = Math.round(point.lng * 1e6);
out += encodeVarint(lat - prevLat);
out += encodeVarint(lng - prevLng);
prevLat = lat;
prevLng = lng;
}
return out;
}