From c0c3ce98d064c97e7e6e96eaf4c8ffc6351a1fdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 24 Apr 2026 17:57:54 +0200 Subject: [PATCH] Cache BRouter segments client-side, fetch only the diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving one waypoint previously re-fetched all N-1 segments of the route through /api/route every time, because the server-side computeRoute recomputes the whole route from scratch. Under rapid edits this piles up on BRouter's thread pool and wastes BRouter+Planner cycles on segments the caller already has. This change keeps a host-local LRU segment cache in the Planner tab, keyed by (from, to, profile, noGoHash). On each computeRoute: - build the pair list from the current waypoints - split into cached vs. missing by looking up each pair key - if any missing, POST { pairs: missing, … } to the new /api/route-segments endpoint and populate the cache with its ordered raw BRouter responses - merge cache-ordered segments into an EnrichedRoute client-side via mergeGeoJsonSegments (moved to a pure, isomorphic route-merge.ts) - write to yjs.routeData exactly as before Typical drag of one waypoint in a 10-waypoint route drops from 9 BRouter fetches to 2. A session re-entering a cached arrangement (undo/redo) short-circuits the server entirely. Profile/no-go changes invalidate all keys and trigger a full refetch, matching prior behavior. Yjs stays out of the cache: segments are bulky and CRDT sync of large blobs hurts more than it helps. The elected host owns the cache; on host handover the new host pays one full recompute. Backwards-compat: /api/route is unchanged for external callers (e2e integration tests, the Journal demo-bot's format=gpx path). Server-side it now delegates to the shared fetchSegments helper. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/planner/app/lib/brouter.ts | 262 +++--------------- apps/planner/app/lib/route-merge.test.ts | 60 ++++ apps/planner/app/lib/route-merge.ts | 224 +++++++++++++++ apps/planner/app/lib/segment-cache.test.ts | 63 +++++ apps/planner/app/lib/segment-cache.ts | 53 ++++ apps/planner/app/lib/use-routing.ts | 100 +++++-- apps/planner/app/routes.ts | 1 + apps/planner/app/routes/api.route-segments.ts | 59 ++++ 8 files changed, 574 insertions(+), 248 deletions(-) create mode 100644 apps/planner/app/lib/route-merge.test.ts create mode 100644 apps/planner/app/lib/route-merge.ts create mode 100644 apps/planner/app/lib/segment-cache.test.ts create mode 100644 apps/planner/app/lib/segment-cache.ts create mode 100644 apps/planner/app/routes/api.route-segments.ts diff --git a/apps/planner/app/lib/brouter.ts b/apps/planner/app/lib/brouter.ts index e5662d7..42f7750 100644 --- a/apps/planner/app/lib/brouter.ts +++ b/apps/planner/app/lib/brouter.ts @@ -1,3 +1,8 @@ +import { mergeGeoJsonSegments, type EnrichedRoute, type NoGoArea, type Waypoint } from "./route-merge"; + +export { mergeGeoJsonSegments }; +export type { EnrichedRoute, NoGoArea }; + const BROUTER_URL = process.env.BROUTER_URL ?? "http://localhost:17777"; // The BRouter host's Caddy sidecar requires every request to carry a @@ -18,10 +23,6 @@ function authHeaders(): HeadersInit { return token ? { "X-BRouter-Auth": token } : {}; } -export interface NoGoArea { - points: Array<{ lat: number; lon: number }>; -} - export interface RouteRequest { waypoints: Array<{ lat: number; lon: number }>; profile?: string; @@ -30,22 +31,6 @@ export interface RouteRequest { noGoAreas?: NoGoArea[]; } -export interface EnrichedRoute { - coordinates: [number, number, number][]; // [lon, lat, ele] - segmentBoundaries: number[]; // coordinate index where each waypoint segment starts - surfaces: string[]; // surface type per coordinate point (e.g. "asphalt", "gravel") - highways: string[]; // highway classification per coordinate point (e.g. "cycleway", "residential") - maxspeeds: string[]; // speed limit per coordinate point (e.g. "30", "50", "none") - smoothnesses: string[]; // smoothness per coordinate point (e.g. "good", "bad") - tracktypes: string[]; // track type per coordinate point (e.g. "grade1", "grade5") - cycleways: string[]; // cycleway type per coordinate point (e.g. "track", "lane") - bikeroutes: string[]; // bicycle route network per coordinate point (e.g. "icn", "lcn") - totalLength: number; - totalAscend: number; - totalTime: number; - geojson: GeoJsonCollection; // original merged GeoJSON for backwards compat -} - /** * Compute a route segment-by-segment between consecutive waypoints. * This matches bikerouter.de's behavior and guarantees the route @@ -56,32 +41,15 @@ export async function computeRoute(request: RouteRequest): Promise { - const next = request.waypoints[i + 1]!; - const lonlats = `${wp.lon},${wp.lat}|${next.lon},${next.lat}`; - const params = new URLSearchParams({ - lonlats, - profile, - alternativeidx: "0", - format, - tiledesc: "true", - }); - if (nogoParam) params.set("polygons", nogoParam); - return fetchSegment(`${BROUTER_URL}/brouter?${params}`); - }), - ); - - // Merge GeoJSON segments into a single FeatureCollection + const pairs = request.waypoints.slice(0, -1).map((from, i) => ({ + from, + to: request.waypoints[i + 1]!, + })); + const segments = await fetchSegments({ + pairs, + profile: request.profile, + noGoAreas: request.noGoAreas, + }); return mergeGeoJsonSegments(segments); } @@ -103,186 +71,34 @@ async function fetchSegment(url: string): Promise> { return response.json() as Promise>; } -interface GeoJsonFeature { - type: string; - properties: Record; - geometry: { type: string; coordinates: number[][] }; -} - -interface GeoJsonCollection { - type: string; - features: GeoJsonFeature[]; -} - -export function mergeGeoJsonSegments(segments: Record[]): EnrichedRoute { - const allCoords: [number, number, number][] = []; - const allSurfaces: string[] = []; - const allHighways: string[] = []; - const allMaxspeeds: string[] = []; - const allSmoothnesses: string[] = []; - const allTracktypes: string[] = []; - const allCycleways: string[] = []; - const allBikeroutes: string[] = []; - const segmentBoundaries: number[] = []; - let totalLength = 0; - let totalAscend = 0; - let totalTime = 0; - - for (let i = 0; i < segments.length; i++) { - const segment = segments[i] as unknown as GeoJsonCollection; - const feature = segment.features?.[0]; - if (!feature) continue; - - // Record where this segment starts in the merged coordinate array - segmentBoundaries.push(allCoords.length); - - const coords = feature.geometry.coordinates; - // Extract surface and highway data from BRouter messages (tiledesc=true) - const wayTagData = extractWayTagData(feature.properties); - - // Skip first point of subsequent segments to avoid duplicates - const startIdx = i === 0 ? 0 : 1; - for (let j = startIdx; j < coords.length; j++) { - const c = coords[j]!; - allCoords.push([c[0]!, c[1]!, c[2] ?? 0]); - allSurfaces.push(wayTagData.surfaces.get(j) ?? wayTagData.surfaces.get(j - 1) ?? "unknown"); - allHighways.push(wayTagData.highways.get(j) ?? wayTagData.highways.get(j - 1) ?? "unknown"); - allMaxspeeds.push(wayTagData.maxspeeds.get(j) ?? wayTagData.maxspeeds.get(j - 1) ?? "unknown"); - allSmoothnesses.push(wayTagData.smoothnesses.get(j) ?? wayTagData.smoothnesses.get(j - 1) ?? "unknown"); - allTracktypes.push(wayTagData.tracktypes.get(j) ?? wayTagData.tracktypes.get(j - 1) ?? "unknown"); - allCycleways.push(wayTagData.cycleways.get(j) ?? wayTagData.cycleways.get(j - 1) ?? "unknown"); - allBikeroutes.push(wayTagData.bikeroutes.get(j) ?? wayTagData.bikeroutes.get(j - 1) ?? "none"); - } - - // Accumulate stats - const props = feature.properties; - totalLength += parseInt(String(props["track-length"] ?? "0")); - totalAscend += parseInt(String(props["filtered ascend"] ?? "0")); - totalTime += parseInt(String(props["total-time"] ?? "0")); - } - - const geojson: GeoJsonCollection = { - type: "FeatureCollection", - features: [ - { - type: "Feature", - properties: { - "track-length": String(totalLength), - "filtered ascend": String(totalAscend), - "total-time": String(totalTime), - creator: "trails.cool (BRouter segments)", - }, - geometry: { - type: "LineString", - coordinates: allCoords, - }, - }, - ], - }; - - return { - coordinates: allCoords, - segmentBoundaries, - surfaces: allSurfaces, - highways: allHighways, - maxspeeds: allMaxspeeds, - smoothnesses: allSmoothnesses, - tracktypes: allTracktypes, - cycleways: allCycleways, - bikeroutes: allBikeroutes, - totalLength, - totalAscend, - totalTime, - geojson, - }; -} - -interface WayTagData { - surfaces: Map; - highways: Map; - maxspeeds: Map; - smoothnesses: Map; - tracktypes: Map; - cycleways: Map; - bikeroutes: Map; -} - /** - * Extract surface and highway type per message row from BRouter's tiledesc messages. - * Messages is an array of arrays: first row is headers, subsequent rows are data. - * We look for the "WayTags" column which contains OSM tags like "surface=asphalt" and "highway=cycleway". + * Fetch a set of waypoint-pair segments from BRouter in parallel. Returned + * segments are in the same order as the input `pairs`. Used by the + * `/api/route-segments` endpoint: the browser cache sends only the pairs + * it doesn't already have, and merges the response client-side. */ -function extractWayTagData(properties: Record): WayTagData { - const surfaces = new Map(); - const highways = new Map(); - const maxspeeds = new Map(); - const smoothnesses = new Map(); - const tracktypes = new Map(); - const cycleways = new Map(); - const bikeroutes = new Map(); - const messages = properties.messages as string[][] | undefined; - if (!messages || messages.length < 2) return { surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes }; +export async function fetchSegments(request: { + pairs: Array<{ from: Waypoint; to: Waypoint }>; + profile?: string; + noGoAreas?: NoGoArea[]; +}): Promise[]> { + const profile = request.profile ?? "trekking"; + const nogoParam = request.noGoAreas?.length ? noGoAreasToParam(request.noGoAreas) : undefined; - const headers = messages[0]!; - const wayTagsIdx = headers.indexOf("WayTags"); - if (wayTagsIdx === -1) return { surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes }; - - for (let i = 1; i < messages.length; i++) { - const row = messages[i]!; - const tags = row[wayTagsIdx] ?? ""; - const surfaceMatch = tags.match(/surface=(\S+)/); - surfaces.set(i - 1, surfaceMatch ? surfaceMatch[1]! : "unknown"); - const highwayMatch = tags.match(/highway=(\S+)/); - highways.set(i - 1, highwayMatch ? highwayMatch[1]! : "unknown"); - - // Extract maxspeed with direction handling - const reversedirection = /reversedirection=yes/.test(tags); - let speed: string | null = null; - if (!reversedirection) { - const fwd = tags.match(/maxspeed:forward=(\S+)/); - if (fwd) speed = fwd[1]!; - } else { - const bwd = tags.match(/maxspeed:backward=(\S+)/); - if (bwd) speed = bwd[1]!; - } - if (!speed) { - const plain = tags.match(/maxspeed=(\S+)/); - speed = plain ? plain[1]! : "unknown"; - } - maxspeeds.set(i - 1, speed); - - // Extract smoothness - const smoothnessMatch = tags.match(/smoothness=(\S+)/); - smoothnesses.set(i - 1, smoothnessMatch ? smoothnessMatch[1]! : "unknown"); - - // Extract tracktype - const tracktypeMatch = tags.match(/tracktype=(\S+)/); - tracktypes.set(i - 1, tracktypeMatch ? tracktypeMatch[1]! : "unknown"); - - // Extract cycleway with direction handling - let cycleway: string | null = null; - if (!reversedirection) { - const right = tags.match(/cycleway:right=(\S+)/); - if (right) cycleway = right[1]!; - } else { - const left = tags.match(/cycleway:left=(\S+)/); - if (left) cycleway = left[1]!; - } - if (!cycleway) { - const bare = tags.match(/cycleway=(\S+)/); - cycleway = bare ? bare[1]! : "unknown"; - } - cycleways.set(i - 1, cycleway); - - // Extract bicycle route network (priority: icn > ncn > rcn > lcn) - let bikeroute = "none"; - if (/route_bicycle_icn=yes/.test(tags)) bikeroute = "icn"; - else if (/route_bicycle_ncn=yes/.test(tags)) bikeroute = "ncn"; - else if (/route_bicycle_rcn=yes/.test(tags)) bikeroute = "rcn"; - else if (/route_bicycle_lcn=yes/.test(tags)) bikeroute = "lcn"; - bikeroutes.set(i - 1, bikeroute); - } - return { surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes }; + return Promise.all( + request.pairs.map(({ from, to }) => { + const lonlats = `${from.lon},${from.lat}|${to.lon},${to.lat}`; + const params = new URLSearchParams({ + lonlats, + profile, + alternativeidx: "0", + format: "geojson", + tiledesc: "true", + }); + if (nogoParam) params.set("polygons", nogoParam); + return fetchSegment(`${BROUTER_URL}/brouter?${params}`); + }), + ); } /** diff --git a/apps/planner/app/lib/route-merge.test.ts b/apps/planner/app/lib/route-merge.test.ts new file mode 100644 index 0000000..1945fe2 --- /dev/null +++ b/apps/planner/app/lib/route-merge.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { hashNoGoAreas, pairKey } from "./route-merge"; + +describe("pairKey", () => { + it("is stable for the same inputs", () => { + const a = pairKey({ lat: 1, lon: 2 }, { lat: 3, lon: 4 }, "trekking", "abc"); + const b = pairKey({ lat: 1, lon: 2 }, { lat: 3, lon: 4 }, "trekking", "abc"); + expect(a).toBe(b); + }); + + it("changes when the profile changes", () => { + const a = pairKey({ lat: 1, lon: 2 }, { lat: 3, lon: 4 }, "trekking", ""); + const b = pairKey({ lat: 1, lon: 2 }, { lat: 3, lon: 4 }, "fastbike", ""); + expect(a).not.toBe(b); + }); + + it("changes when either endpoint moves", () => { + const base = pairKey({ lat: 1, lon: 2 }, { lat: 3, lon: 4 }, "p", ""); + const moveFrom = pairKey({ lat: 1.0001, lon: 2 }, { lat: 3, lon: 4 }, "p", ""); + const moveTo = pairKey({ lat: 1, lon: 2 }, { lat: 3, lon: 4.0001 }, "p", ""); + expect(base).not.toBe(moveFrom); + expect(base).not.toBe(moveTo); + }); + + it("is direction-sensitive (A→B and B→A are distinct)", () => { + const forward = pairKey({ lat: 1, lon: 2 }, { lat: 3, lon: 4 }, "p", ""); + const reverse = pairKey({ lat: 3, lon: 4 }, { lat: 1, lon: 2 }, "p", ""); + expect(forward).not.toBe(reverse); + }); + + it("changes when the no-go hash changes", () => { + const a = pairKey({ lat: 1, lon: 2 }, { lat: 3, lon: 4 }, "p", "h1"); + const b = pairKey({ lat: 1, lon: 2 }, { lat: 3, lon: 4 }, "p", "h2"); + expect(a).not.toBe(b); + }); +}); + +describe("hashNoGoAreas", () => { + it("returns empty string for no areas", () => { + expect(hashNoGoAreas()).toBe(""); + expect(hashNoGoAreas([])).toBe(""); + }); + + it("is stable for equal inputs", () => { + const areas = [ + { points: [{ lat: 1, lon: 2 }, { lat: 3, lon: 4 }, { lat: 5, lon: 6 }] }, + ]; + expect(hashNoGoAreas(areas)).toBe(hashNoGoAreas(areas)); + }); + + it("differs when a polygon changes", () => { + const a = hashNoGoAreas([ + { points: [{ lat: 1, lon: 2 }, { lat: 3, lon: 4 }, { lat: 5, lon: 6 }] }, + ]); + const b = hashNoGoAreas([ + { points: [{ lat: 1, lon: 2 }, { lat: 3, lon: 4 }, { lat: 5, lon: 7 }] }, + ]); + expect(a).not.toBe(b); + }); +}); diff --git a/apps/planner/app/lib/route-merge.ts b/apps/planner/app/lib/route-merge.ts new file mode 100644 index 0000000..f386861 --- /dev/null +++ b/apps/planner/app/lib/route-merge.ts @@ -0,0 +1,224 @@ +// Pure, isomorphic helpers for stitching BRouter segment responses into an +// EnrichedRoute and for keying the client-side segment cache. Imported by +// both the server-side `brouter.ts` and the browser (use-routing.ts). + +export interface NoGoArea { + points: Array<{ lat: number; lon: number }>; +} + +export interface Waypoint { + lat: number; + lon: number; +} + +export interface EnrichedRoute { + coordinates: [number, number, number][]; + segmentBoundaries: number[]; + surfaces: string[]; + highways: string[]; + maxspeeds: string[]; + smoothnesses: string[]; + tracktypes: string[]; + cycleways: string[]; + bikeroutes: string[]; + totalLength: number; + totalAscend: number; + totalTime: number; + geojson: GeoJsonCollection; +} + +interface GeoJsonFeature { + type: string; + properties: Record; + geometry: { type: string; coordinates: number[][] }; +} + +interface GeoJsonCollection { + type: string; + features: GeoJsonFeature[]; +} + +export function mergeGeoJsonSegments(segments: Record[]): EnrichedRoute { + const allCoords: [number, number, number][] = []; + const allSurfaces: string[] = []; + const allHighways: string[] = []; + const allMaxspeeds: string[] = []; + const allSmoothnesses: string[] = []; + const allTracktypes: string[] = []; + const allCycleways: string[] = []; + const allBikeroutes: string[] = []; + const segmentBoundaries: number[] = []; + let totalLength = 0; + let totalAscend = 0; + let totalTime = 0; + + for (let i = 0; i < segments.length; i++) { + const segment = segments[i] as unknown as GeoJsonCollection; + const feature = segment.features?.[0]; + if (!feature) continue; + + segmentBoundaries.push(allCoords.length); + + const coords = feature.geometry.coordinates; + const wayTagData = extractWayTagData(feature.properties); + + // Skip first point of subsequent segments to avoid duplicates + const startIdx = i === 0 ? 0 : 1; + for (let j = startIdx; j < coords.length; j++) { + const c = coords[j]!; + allCoords.push([c[0]!, c[1]!, c[2] ?? 0]); + allSurfaces.push(wayTagData.surfaces.get(j) ?? wayTagData.surfaces.get(j - 1) ?? "unknown"); + allHighways.push(wayTagData.highways.get(j) ?? wayTagData.highways.get(j - 1) ?? "unknown"); + allMaxspeeds.push(wayTagData.maxspeeds.get(j) ?? wayTagData.maxspeeds.get(j - 1) ?? "unknown"); + allSmoothnesses.push(wayTagData.smoothnesses.get(j) ?? wayTagData.smoothnesses.get(j - 1) ?? "unknown"); + allTracktypes.push(wayTagData.tracktypes.get(j) ?? wayTagData.tracktypes.get(j - 1) ?? "unknown"); + allCycleways.push(wayTagData.cycleways.get(j) ?? wayTagData.cycleways.get(j - 1) ?? "unknown"); + allBikeroutes.push(wayTagData.bikeroutes.get(j) ?? wayTagData.bikeroutes.get(j - 1) ?? "none"); + } + + const props = feature.properties; + totalLength += parseInt(String(props["track-length"] ?? "0")); + totalAscend += parseInt(String(props["filtered ascend"] ?? "0")); + totalTime += parseInt(String(props["total-time"] ?? "0")); + } + + const geojson: GeoJsonCollection = { + type: "FeatureCollection", + features: [ + { + type: "Feature", + properties: { + "track-length": String(totalLength), + "filtered ascend": String(totalAscend), + "total-time": String(totalTime), + creator: "trails.cool (BRouter segments)", + }, + geometry: { + type: "LineString", + coordinates: allCoords, + }, + }, + ], + }; + + return { + coordinates: allCoords, + segmentBoundaries, + surfaces: allSurfaces, + highways: allHighways, + maxspeeds: allMaxspeeds, + smoothnesses: allSmoothnesses, + tracktypes: allTracktypes, + cycleways: allCycleways, + bikeroutes: allBikeroutes, + totalLength, + totalAscend, + totalTime, + geojson, + }; +} + +interface WayTagData { + surfaces: Map; + highways: Map; + maxspeeds: Map; + smoothnesses: Map; + tracktypes: Map; + cycleways: Map; + bikeroutes: Map; +} + +function extractWayTagData(properties: Record): WayTagData { + const surfaces = new Map(); + const highways = new Map(); + const maxspeeds = new Map(); + const smoothnesses = new Map(); + const tracktypes = new Map(); + const cycleways = new Map(); + const bikeroutes = new Map(); + const messages = properties.messages as string[][] | undefined; + if (!messages || messages.length < 2) return { surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes }; + + const headers = messages[0]!; + const wayTagsIdx = headers.indexOf("WayTags"); + if (wayTagsIdx === -1) return { surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes }; + + for (let i = 1; i < messages.length; i++) { + const row = messages[i]!; + const tags = row[wayTagsIdx] ?? ""; + const surfaceMatch = tags.match(/surface=(\S+)/); + surfaces.set(i - 1, surfaceMatch ? surfaceMatch[1]! : "unknown"); + const highwayMatch = tags.match(/highway=(\S+)/); + highways.set(i - 1, highwayMatch ? highwayMatch[1]! : "unknown"); + + const reversedirection = /reversedirection=yes/.test(tags); + let speed: string | null = null; + if (!reversedirection) { + const fwd = tags.match(/maxspeed:forward=(\S+)/); + if (fwd) speed = fwd[1]!; + } else { + const bwd = tags.match(/maxspeed:backward=(\S+)/); + if (bwd) speed = bwd[1]!; + } + if (!speed) { + const plain = tags.match(/maxspeed=(\S+)/); + speed = plain ? plain[1]! : "unknown"; + } + maxspeeds.set(i - 1, speed); + + const smoothnessMatch = tags.match(/smoothness=(\S+)/); + smoothnesses.set(i - 1, smoothnessMatch ? smoothnessMatch[1]! : "unknown"); + + const tracktypeMatch = tags.match(/tracktype=(\S+)/); + tracktypes.set(i - 1, tracktypeMatch ? tracktypeMatch[1]! : "unknown"); + + let cycleway: string | null = null; + if (!reversedirection) { + const right = tags.match(/cycleway:right=(\S+)/); + if (right) cycleway = right[1]!; + } else { + const left = tags.match(/cycleway:left=(\S+)/); + if (left) cycleway = left[1]!; + } + if (!cycleway) { + const bare = tags.match(/cycleway=(\S+)/); + cycleway = bare ? bare[1]! : "unknown"; + } + cycleways.set(i - 1, cycleway); + + let bikeroute = "none"; + if (/route_bicycle_icn=yes/.test(tags)) bikeroute = "icn"; + else if (/route_bicycle_ncn=yes/.test(tags)) bikeroute = "ncn"; + else if (/route_bicycle_rcn=yes/.test(tags)) bikeroute = "rcn"; + else if (/route_bicycle_lcn=yes/.test(tags)) bikeroute = "lcn"; + bikeroutes.set(i - 1, bikeroute); + } + return { surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes }; +} + +// Stable pair key for the client-side segment cache. Profile and a hash of +// the no-go polygons are part of the key because both affect BRouter's +// segment output — changing either invalidates every cached pair. +export function pairKey( + from: Waypoint, + to: Waypoint, + profile: string, + noGoHash: string, +): string { + return `${from.lon},${from.lat}|${to.lon},${to.lat}|${profile}|${noGoHash}`; +} + +export function hashNoGoAreas(noGoAreas?: NoGoArea[]): string { + if (!noGoAreas || noGoAreas.length === 0) return ""; + // Order and coordinate precision matter: BRouter treats re-ordered + // polygons the same, but we hash the literal shape to avoid accidental + // "miss by floating-point drift". djb2 keeps keys short. + const input = noGoAreas + .map((a) => a.points.map((p) => `${p.lon},${p.lat}`).join(";")) + .join("|"); + let h = 5381; + for (let i = 0; i < input.length; i++) { + h = ((h << 5) + h + input.charCodeAt(i)) | 0; + } + return (h >>> 0).toString(36); +} diff --git a/apps/planner/app/lib/segment-cache.test.ts b/apps/planner/app/lib/segment-cache.test.ts new file mode 100644 index 0000000..c9e9e44 --- /dev/null +++ b/apps/planner/app/lib/segment-cache.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { SegmentCache } from "./segment-cache"; + +describe("SegmentCache", () => { + it("returns undefined for missing keys", () => { + const cache = new SegmentCache(); + expect(cache.get("missing")).toBeUndefined(); + expect(cache.has("missing")).toBe(false); + }); + + it("stores and retrieves segments", () => { + const cache = new SegmentCache(); + const segment = { type: "FeatureCollection" }; + cache.set("k1", segment); + expect(cache.has("k1")).toBe(true); + expect(cache.get("k1")).toBe(segment); + expect(cache.size).toBe(1); + }); + + it("overwrites existing keys without changing size", () => { + const cache = new SegmentCache(); + cache.set("k1", { v: 1 }); + cache.set("k1", { v: 2 }); + expect(cache.size).toBe(1); + expect(cache.get("k1")).toEqual({ v: 2 }); + }); + + it("evicts the oldest entry when over capacity", () => { + const cache = new SegmentCache(3); + cache.set("a", {}); + cache.set("b", {}); + cache.set("c", {}); + cache.set("d", {}); + expect(cache.has("a")).toBe(false); + expect(cache.has("b")).toBe(true); + expect(cache.has("c")).toBe(true); + expect(cache.has("d")).toBe(true); + expect(cache.size).toBe(3); + }); + + it("bumps an entry to most-recent on get, delaying its eviction", () => { + const cache = new SegmentCache(3); + cache.set("a", {}); + cache.set("b", {}); + cache.set("c", {}); + // Touch "a" so it's most-recent; the next insert should evict "b" + cache.get("a"); + cache.set("d", {}); + expect(cache.has("a")).toBe(true); + expect(cache.has("b")).toBe(false); + expect(cache.has("c")).toBe(true); + expect(cache.has("d")).toBe(true); + }); + + it("clear() removes all entries", () => { + const cache = new SegmentCache(); + cache.set("a", {}); + cache.set("b", {}); + cache.clear(); + expect(cache.size).toBe(0); + expect(cache.has("a")).toBe(false); + }); +}); diff --git a/apps/planner/app/lib/segment-cache.ts b/apps/planner/app/lib/segment-cache.ts new file mode 100644 index 0000000..57fba27 --- /dev/null +++ b/apps/planner/app/lib/segment-cache.ts @@ -0,0 +1,53 @@ +// Host-local LRU cache of BRouter segment responses, keyed by +// (from, to, profile, noGoHash). Shrinks BRouter load on rapid edits: +// moving one waypoint only invalidates the two adjacent pair keys, so we +// fetch 2 segments instead of N-1 on every recompute. +// +// Lives only in the elected Yjs host's tab. When the host changes, the +// new host starts cold — acceptable because host handover is rare and +// the penalty is one full recompute. + +const DEFAULT_MAX = 500; + +export class SegmentCache { + // Map preserves insertion order, which we use for an LRU eviction + // policy: get() re-inserts the entry to move it to the tail; set() + // evicts from the head when over `max`. + private entries = new Map>(); + private readonly max: number; + + constructor(max: number = DEFAULT_MAX) { + this.max = max; + } + + get(key: string): Record | undefined { + const value = this.entries.get(key); + if (value === undefined) return undefined; + // LRU bump + this.entries.delete(key); + this.entries.set(key, value); + return value; + } + + set(key: string, value: Record): void { + if (this.entries.has(key)) this.entries.delete(key); + this.entries.set(key, value); + while (this.entries.size > this.max) { + const oldest = this.entries.keys().next().value; + if (oldest === undefined) break; + this.entries.delete(oldest); + } + } + + has(key: string): boolean { + return this.entries.has(key); + } + + clear(): void { + this.entries.clear(); + } + + get size(): number { + return this.entries.size; + } +} diff --git a/apps/planner/app/lib/use-routing.ts b/apps/planner/app/lib/use-routing.ts index cd96b9e..b329570 100644 --- a/apps/planner/app/lib/use-routing.ts +++ b/apps/planner/app/lib/use-routing.ts @@ -2,6 +2,13 @@ import { useCallback, useEffect, useRef, useState } from "react"; import * as Y from "yjs"; import type { YjsState } from "./use-yjs.ts"; import { electHost } from "./host-election.ts"; +import { + hashNoGoAreas, + mergeGeoJsonSegments, + pairKey, + type NoGoArea, +} from "./route-merge.ts"; +import { SegmentCache } from "./segment-cache.ts"; interface RouteStats { distance?: number; @@ -51,10 +58,14 @@ export function useRouting(yjs: YjsState | null, sessionId: string) { const debounceTimer = useRef>(undefined); const lastGoodWaypointsRef = useRef(null); const restoringRef = useRef(false); - // Cancels the in-flight /api/route call when a newer one starts. Without + // Cancels the in-flight fetch when a newer computeRoute starts. Without // this, rapid edits pile up on BRouter's thread pool and older requests // get killed by its contention watchdog, surfacing as spurious errors. const inflightAbortRef = useRef(null); + // Host-local segment cache. Moving a single waypoint invalidates only + // the two adjacent pair keys, so we fetch 2 segments from the server + // instead of N-1 on every recompute. + const segmentCacheRef = useRef(new SegmentCache()); // Host election via Yjs awareness useEffect(() => { @@ -81,7 +92,7 @@ export function useRouting(yjs: YjsState | null, sessionId: string) { if (!yjs || !isHost || waypoints.length < 2) return; // Collect no-go areas from Yjs - const noGoAreas = yjs.noGoAreas.toArray().map((yMap) => ({ + const noGoAreas: NoGoArea[] = yjs.noGoAreas.toArray().map((yMap) => ({ points: (yMap.get("points") as Array<{ lat: number; lon: number }>) ?? [], })).filter((a) => a.points.length >= 3); @@ -92,33 +103,72 @@ export function useRouting(yjs: YjsState | null, sessionId: string) { inflightAbortRef.current?.abort(); const controller = new AbortController(); inflightAbortRef.current = controller; + + const profile = (yjs.routeData.get("profile") as string) ?? "fastbike"; + const noGoHash = hashNoGoAreas(noGoAreas); + const cache = segmentCacheRef.current; + + // Build the ordered pair list. Each pair has a cache key; missing + // ones get collected for a single round-trip to the server. + const pairs = waypoints.slice(0, -1).map((from, i) => ({ + from, + to: waypoints[i + 1]!, + })); + const pairKeys = pairs.map((p) => pairKey(p.from, p.to, profile, noGoHash)); + const missingPairs: typeof pairs = []; + const missingIdx: number[] = []; + pairKeys.forEach((k, i) => { + if (!cache.has(k)) { + missingPairs.push(pairs[i]!); + missingIdx.push(i); + } + }); + try { - const response = await fetch("/api/route", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - waypoints, - profile: (yjs.routeData.get("profile") as string) ?? "fastbike", - noGoAreas: noGoAreas.length > 0 ? noGoAreas : undefined, - sessionId, - }), - signal: controller.signal, - }); + // Only hit the server when we actually need a segment we don't + // have. A pure drag-refinement that happens to re-land on cached + // coordinates short-circuits here. + if (missingPairs.length > 0) { + const response = await fetch("/api/route-segments", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + pairs: missingPairs, + profile, + noGoAreas: noGoAreas.length > 0 ? noGoAreas : undefined, + sessionId, + }), + signal: controller.signal, + }); - if (response.status === 429) { - setRouteError("rate_limit"); - restoreWaypoints(yjs, lastGoodWaypointsRef.current ?? snapshotBeforeCompute, restoringRef); - return; - } - if (!response.ok) { - const body = await response.json().catch(() => ({})); - const code = (body as { code?: string }).code; - setRouteError(code === "no_route" ? "no_route" : "failed"); - restoreWaypoints(yjs, lastGoodWaypointsRef.current ?? snapshotBeforeCompute, restoringRef); - return; + if (response.status === 429) { + setRouteError("rate_limit"); + restoreWaypoints(yjs, lastGoodWaypointsRef.current ?? snapshotBeforeCompute, restoringRef); + return; + } + if (!response.ok) { + const body = await response.json().catch(() => ({})); + const code = (body as { code?: string }).code; + setRouteError(code === "no_route" ? "no_route" : "failed"); + restoreWaypoints(yjs, lastGoodWaypointsRef.current ?? snapshotBeforeCompute, restoringRef); + return; + } + + const payload = (await response.json()) as { + segments: Record[]; + }; + payload.segments.forEach((seg, i) => { + cache.set(pairKeys[missingIdx[i]!]!, seg); + }); } - const enriched = await response.json(); + // Assemble the merged route from the cache. Guard against being + // superseded between the fetch completing and the merge — a newer + // call already owns the in-flight ref and will drive state. + if (inflightAbortRef.current !== controller) return; + + const orderedSegments = pairKeys.map((k) => cache.get(k)!); + const enriched = mergeGeoJsonSegments(orderedSegments); setRouteError(null); lastGoodWaypointsRef.current = snapshotBeforeCompute; diff --git a/apps/planner/app/routes.ts b/apps/planner/app/routes.ts index bfead19..3fa2ab5 100644 --- a/apps/planner/app/routes.ts +++ b/apps/planner/app/routes.ts @@ -5,6 +5,7 @@ export default [ route("new", "routes/new.tsx"), route("api/sessions", "routes/api.sessions.ts"), route("api/route", "routes/api.route.ts"), + route("api/route-segments", "routes/api.route-segments.ts"), route("api/overpass", "routes/api.overpass.ts"), route("session/:id", "routes/session.$id.tsx"), ] satisfies RouteConfig; diff --git a/apps/planner/app/routes/api.route-segments.ts b/apps/planner/app/routes/api.route-segments.ts new file mode 100644 index 0000000..eb2b634 --- /dev/null +++ b/apps/planner/app/routes/api.route-segments.ts @@ -0,0 +1,59 @@ +import { data } from "react-router"; +import type { Route } from "./+types/api.route-segments"; +import { fetchSegments, BRouterError } from "~/lib/brouter"; +import { checkRateLimit } from "~/lib/rate-limit"; +import { requireSession } from "~/lib/require-session"; + +// Client-side segment cache endpoint: the browser sends only the +// waypoint pairs it doesn't already have cached; the server fetches those +// from BRouter in parallel and returns the raw responses in order. The +// merge into an EnrichedRoute happens in the browser so unchanged pairs +// never cross the network. +// +// Session-binding + per-session rate limiting mirror /api/route: the +// Planner is not an anonymous BRouter proxy. +export async function action({ request }: Route.ActionArgs) { + if (request.method !== "POST") { + return data({ error: "Method not allowed" }, { status: 405 }); + } + + const body = await request.json(); + const { pairs, profile, sessionId, noGoAreas } = body as { + pairs: Array<{ from: { lat: number; lon: number }; to: { lat: number; lon: number } }>; + profile?: string; + sessionId?: string; + noGoAreas?: Array<{ points: Array<{ lat: number; lon: number }> }>; + }; + + if (!Array.isArray(pairs) || pairs.length === 0) { + return data({ error: "At least 1 pair is required" }, { status: 400 }); + } + + const session = await requireSession(sessionId); + if (session instanceof Response) return session; + + const limit = checkRateLimit(`route:${session.id}`); + if (!limit.allowed) { + return data( + { error: "Rate limit exceeded" }, + { + status: 429, + headers: { "Retry-After": String(limit.retryAfterSeconds) }, + }, + ); + } + + try { + const segments = await fetchSegments({ pairs, profile, noGoAreas }); + return data( + { segments }, + { headers: { "X-RateLimit-Remaining": String(limit.remaining) } }, + ); + } catch (e) { + if (e instanceof BRouterError && e.statusCode >= 400 && e.statusCode < 500) { + return data({ error: e.message, code: "no_route" }, { status: 422 }); + } + const message = e instanceof Error ? e.message : "Route computation failed"; + return data({ error: message, code: "server_error" }, { status: 502 }); + } +}