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:
parent
84744567bc
commit
c33a413395
8 changed files with 181 additions and 33 deletions
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import * as Y from "yjs";
|
|||
import type { ElevationPoint } from "~/lib/elevation-chart-draw";
|
||||
import {
|
||||
getColorMode,
|
||||
getGeojson,
|
||||
readGeojson,
|
||||
readRoadMetadata,
|
||||
type ColorMode,
|
||||
type RoadMetadata,
|
||||
|
|
@ -70,7 +70,7 @@ export function useElevationData(routeData: Y.Map<unknown>): ElevationData {
|
|||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const geojson = getGeojson(routeData);
|
||||
const geojson = readGeojson(routeData);
|
||||
setData({
|
||||
points: geojson ? extractElevation(geojson) : [],
|
||||
colorMode: getColorMode(routeData),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue