diff --git a/routing/src/polyline.ts b/routing/src/polyline.ts new file mode 100644 index 0000000..b1cdb37 --- /dev/null +++ b/routing/src/polyline.ts @@ -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; +}