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:
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user