feat(planner): store computed route compact-encoded (groups 2–4)

Wire the map-core codec into route-data.ts so the Yjs session doc stores
the computed route compactly, without changing the routing-host
compute-once-and-share model.

- Codec: add encodeElevations/decodeElevations (delta+varint) so geometry
  = encoded [lon,lat] polyline + elevation channel; precision bumped to
  1e6 (~0.11 m) to preserve the router's 6-decimal output.
- writeComputedRoute: geometry stored once (encoded polyline + elevations,
  no redundant geojson), road metadata run-length encoded.
- Dual-format reads: getCoordinates / readRoadMetadata / new readGeojson
  transparently handle the new encoding AND legacy JSON docs (+ oldest
  geojson-only) — no migration, no flag day; legacy docs re-encode on the
  next recompute. use-elevation-data reads readGeojson (assembled for new
  docs).
- Spec correction: a lossy compact codec can't be byte-identical to legacy
  (generateGpx emits raw precision), so "GPX byte-compatible" is corrected
  to "coordinates preserved within the router's ~0.1 m precision".

Tests: legacy-format read, size assertion (>4x smaller / round-trips),
elevation round-trip. map-core + planner typecheck/lint clean; full
pnpm test 11/11.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-07-15 17:07:27 +02:00
parent 84744567bc
commit c33a413395
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
8 changed files with 181 additions and 33 deletions

View file

@ -10,7 +10,7 @@ import {
getBaseLayer,
getColorMode,
getCoordinates,
getGeojson,
readGeojson,
getOverlays,
getPoiCategories,
getProfile,
@ -94,7 +94,12 @@ describe("computed route round-trip", () => {
for (const key of ROAD_METADATA_KEYS) {
expect(computed[key]).toEqual(enriched[key]);
}
expect(getGeojson(routeData)).toBe(JSON.stringify(enriched.geojson));
// Geometry is stored compact-encoded, not as a redundant geojson copy.
expect(routeData.get("geojson")).toBeUndefined();
expect((routeData.get("coordinates") as string).startsWith("p1:")).toBe(true);
// readGeojson assembles an equivalent GeoJSON from the encoded coords.
const gj = JSON.parse(readGeojson(routeData)!);
expect(gj.features[0].geometry.coordinates).toEqual(enriched.coordinates);
});
it("keeps previous metadata when a recompute yields empty arrays", () => {
@ -160,6 +165,47 @@ describe("getCoordinates", () => {
});
});
describe("backward compatibility (legacy JSON format)", () => {
it("reads a route persisted in the legacy JSON encoding", () => {
const { routeData } = createDoc();
const enriched = enrichedFixture();
// Simulate a document written before this change: raw JSON keys.
routeData.set("geojson", JSON.stringify(enriched.geojson));
routeData.set("coordinates", JSON.stringify(enriched.coordinates));
routeData.set("segmentBoundaries", JSON.stringify(enriched.segmentBoundaries));
for (const key of ROAD_METADATA_KEYS) routeData.set(key, JSON.stringify(enriched[key]));
const computed = readComputedRoute(routeData);
expect(computed.coordinates).toEqual(enriched.coordinates);
for (const key of ROAD_METADATA_KEYS) expect(computed[key]).toEqual(enriched[key]);
// readGeojson returns the stored legacy geojson verbatim.
expect(readGeojson(routeData)).toBe(JSON.stringify(enriched.geojson));
});
});
describe("encoding size", () => {
it("stores a long route far smaller than the legacy JSON encoding", () => {
const { doc, routeData } = createDoc();
const coordinates: [number, number, number][] = [];
const surfaces: string[] = [];
for (let i = 0; i < 4000; i++) {
coordinates.push([Number((13.4 + i * 0.0002).toFixed(5)), Number((52.5 + i * 0.00005).toFixed(5)), 100 + (i % 50)]);
surfaces.push(i < 3000 ? "asphalt" : "gravel");
}
const enriched = enrichedFixture({ coordinates, surfaces, geojson: { type: "FeatureCollection", features: [{ type: "Feature", properties: {}, geometry: { type: "LineString", coordinates } }] } });
writeComputedRoute(doc, routeData, enriched);
const encodedBytes = Y.encodeStateAsUpdate(doc).byteLength;
// The legacy encoding stored geojson + coordinates (both full) + JSON metadata.
const legacyBytes = JSON.stringify(enriched.geojson).length + JSON.stringify(coordinates).length + JSON.stringify(surfaces).length;
expect(encodedBytes).toBeLessThan(legacyBytes / 4); // >4x smaller
// And it round-trips.
expect(getCoordinates(routeData)).toHaveLength(4000);
expect(readRoadMetadata(routeData).surfaces).toHaveLength(4000);
});
});
describe("profile and view preferences", () => {
it("round-trips the profile", () => {
const { routeData } = createDoc();
@ -212,7 +258,7 @@ describe("clearRouteData", () => {
clearRouteData(doc, routeData);
expect(getCoordinates(routeData)).toBeNull();
expect(getGeojson(routeData)).toBeUndefined();
expect(readGeojson(routeData)).toBeUndefined();
expect(getProfile(routeData)).toBeUndefined();
expect(readRoadMetadata(routeData).surfaces).toEqual([]);
expect(getColorMode(routeData)).toBe("surface");