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

@ -1,4 +1,10 @@
import * as Y from "yjs";
import {
encodePolyline, decodePolyline,
encodeElevations, decodeElevations,
encodeRuns, decodeRuns,
isEncodedPolyline, isEncodedElevations, isEncodedRuns,
} from "@trails-cool/map-core";
import type { EnrichedRoute } from "./route-merge.ts";
// The schema of the shared `routeData` Y.Map (and the `noGoAreas` Y.Array).
@ -70,25 +76,29 @@ function parseJsonArrayOrUndefined<T>(json: string | undefined): T[] | undefined
// --- Computed route (written by the routing host, read by everyone) ---
export function getGeojson(routeData: Y.Map<unknown>): string | undefined {
return routeData.get("geojson") as string | undefined;
}
/**
* Route coordinates as [lon, lat, ele] triples. Reads the "coordinates"
* key; falls back to extracting them from "geojson" for documents written
* before the coordinates key existed.
* key, transparently handling three formats (spec: planner-route-encoding):
* - new: compact encoded polyline ([lon,lat]) + "elevations" channel,
* - legacy: JSON [lon,lat,ele][],
* - oldest: extracted from a stored "geojson" LineString.
*/
export function getCoordinates(routeData: Y.Map<unknown>): [number, number, number][] | null {
const coordsJson = routeData.get("coordinates") as string | undefined;
if (coordsJson) {
const coordsVal = routeData.get("coordinates") as string | undefined;
if (coordsVal) {
if (isEncodedPolyline(coordsVal)) {
const lonlat = decodePolyline(coordsVal);
const elevVal = routeData.get("elevations") as string | undefined;
const eles = elevVal && isEncodedElevations(elevVal) ? decodeElevations(elevVal) : [];
return lonlat.map(([lon, lat], i) => [lon, lat, eles[i] ?? 0] as [number, number, number]);
}
try {
return JSON.parse(coordsJson);
return JSON.parse(coordsVal); // legacy JSON
} catch {
/* fall through to geojson */
}
}
const geojson = getGeojson(routeData);
const geojson = routeData.get("geojson") as string | undefined;
if (geojson) {
try {
const parsed = JSON.parse(geojson);
@ -103,10 +113,33 @@ export function getCoordinates(routeData: Y.Map<unknown>): [number, number, numb
return null;
}
/**
* GeoJSON FeatureCollection string for the route. Returns a stored
* "geojson" for legacy documents, else assembles one from the decoded
* coordinates (new documents don't persist geojson — it's derivable).
*/
export function readGeojson(routeData: Y.Map<unknown>): string | undefined {
const stored = routeData.get("geojson") as string | undefined;
if (stored) return stored;
const coordinates = getCoordinates(routeData);
if (!coordinates) return undefined;
return JSON.stringify({
type: "FeatureCollection",
features: [{ type: "Feature", properties: {}, geometry: { type: "LineString", coordinates } }],
});
}
/** Decode one road-metadata channel — new run-length encoding or legacy JSON array. */
function decodeMetaChannel(value: string | undefined): string[] {
if (!value) return [];
if (isEncodedRuns(value)) return decodeRuns(value);
return parseJsonArray<string>(value);
}
export function readRoadMetadata(routeData: Y.Map<unknown>): RoadMetadata {
const metadata = {} as RoadMetadata;
for (const key of ROAD_METADATA_KEYS) {
metadata[key] = parseJsonArray<string>(routeData.get(key) as string | undefined);
metadata[key] = decodeMetaChannel(routeData.get(key) as string | undefined);
}
return metadata;
}
@ -133,12 +166,18 @@ export function writeComputedRoute(
enriched: EnrichedRoute,
): void {
doc.transact(() => {
routeData.set("geojson", JSON.stringify(enriched.geojson));
routeData.set("coordinates", JSON.stringify(enriched.coordinates));
// Compact encoding (spec: planner-route-encoding): geometry stored once
// as an encoded [lon,lat] polyline + a separate elevation channel; no
// redundant "geojson" copy (derived on read); road metadata run-length
// encoded. Drop any legacy "geojson" so re-encoding a legacy doc shrinks it.
const coords = enriched.coordinates;
routeData.set("coordinates", encodePolyline(coords.map((c) => [c[0], c[1]] as [number, number])));
routeData.set("elevations", encodeElevations(coords.map((c) => c[2])));
routeData.delete("geojson");
routeData.set("segmentBoundaries", JSON.stringify(enriched.segmentBoundaries));
for (const key of ROAD_METADATA_KEYS) {
if (enriched[key]?.length) {
routeData.set(key, JSON.stringify(enriched[key]));
routeData.set(key, encodeRuns(enriched[key]));
}
}
});
@ -154,6 +193,7 @@ export function clearRouteData(doc: Y.Doc, routeData: Y.Map<unknown>): void {
doc.transact(() => {
routeData.delete("geojson");
routeData.delete("coordinates");
routeData.delete("elevations");
routeData.delete("segmentBoundaries");
for (const key of ROAD_METADATA_KEYS) {
routeData.delete(key);