Cache BRouter segments client-side, fetch only the diff

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) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-24 17:57:54 +02:00
parent 2d3c45e929
commit c0c3ce98d0
8 changed files with 574 additions and 248 deletions

View file

@ -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"; const BROUTER_URL = process.env.BROUTER_URL ?? "http://localhost:17777";
// The BRouter host's Caddy sidecar requires every request to carry a // 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 } : {}; return token ? { "X-BRouter-Auth": token } : {};
} }
export interface NoGoArea {
points: Array<{ lat: number; lon: number }>;
}
export interface RouteRequest { export interface RouteRequest {
waypoints: Array<{ lat: number; lon: number }>; waypoints: Array<{ lat: number; lon: number }>;
profile?: string; profile?: string;
@ -30,22 +31,6 @@ export interface RouteRequest {
noGoAreas?: NoGoArea[]; 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. * Compute a route segment-by-segment between consecutive waypoints.
* This matches bikerouter.de's behavior and guarantees the route * This matches bikerouter.de's behavior and guarantees the route
@ -56,32 +41,15 @@ export async function computeRoute(request: RouteRequest): Promise<EnrichedRoute
throw new Error("At least 2 waypoints are required"); throw new Error("At least 2 waypoints are required");
} }
const profile = request.profile ?? "trekking"; const pairs = request.waypoints.slice(0, -1).map((from, i) => ({
const format = request.format ?? "geojson"; from,
to: request.waypoints[i + 1]!,
// Build no-go parameter if areas exist }));
const nogoParam = request.noGoAreas?.length const segments = await fetchSegments({
? noGoAreasToParam(request.noGoAreas) pairs,
: undefined; profile: request.profile,
noGoAreas: request.noGoAreas,
// Route each segment independently });
const segments = await Promise.all(
request.waypoints.slice(0, -1).map((wp, i) => {
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
return mergeGeoJsonSegments(segments); return mergeGeoJsonSegments(segments);
} }
@ -103,186 +71,34 @@ async function fetchSegment(url: string): Promise<Record<string, unknown>> {
return response.json() as Promise<Record<string, unknown>>; return response.json() as Promise<Record<string, unknown>>;
} }
interface GeoJsonFeature {
type: string;
properties: Record<string, unknown>;
geometry: { type: string; coordinates: number[][] };
}
interface GeoJsonCollection {
type: string;
features: GeoJsonFeature[];
}
export function mergeGeoJsonSegments(segments: Record<string, unknown>[]): 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<number, string>;
highways: Map<number, string>;
maxspeeds: Map<number, string>;
smoothnesses: Map<number, string>;
tracktypes: Map<number, string>;
cycleways: Map<number, string>;
bikeroutes: Map<number, string>;
}
/** /**
* Extract surface and highway type per message row from BRouter's tiledesc messages. * Fetch a set of waypoint-pair segments from BRouter in parallel. Returned
* Messages is an array of arrays: first row is headers, subsequent rows are data. * segments are in the same order as the input `pairs`. Used by the
* We look for the "WayTags" column which contains OSM tags like "surface=asphalt" and "highway=cycleway". * `/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<string, unknown>): WayTagData { export async function fetchSegments(request: {
const surfaces = new Map<number, string>(); pairs: Array<{ from: Waypoint; to: Waypoint }>;
const highways = new Map<number, string>(); profile?: string;
const maxspeeds = new Map<number, string>(); noGoAreas?: NoGoArea[];
const smoothnesses = new Map<number, string>(); }): Promise<Record<string, unknown>[]> {
const tracktypes = new Map<number, string>(); const profile = request.profile ?? "trekking";
const cycleways = new Map<number, string>(); const nogoParam = request.noGoAreas?.length ? noGoAreasToParam(request.noGoAreas) : undefined;
const bikeroutes = new Map<number, string>();
const messages = properties.messages as string[][] | undefined;
if (!messages || messages.length < 2) return { surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes };
const headers = messages[0]!; return Promise.all(
const wayTagsIdx = headers.indexOf("WayTags"); request.pairs.map(({ from, to }) => {
if (wayTagsIdx === -1) return { surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes }; const lonlats = `${from.lon},${from.lat}|${to.lon},${to.lat}`;
const params = new URLSearchParams({
for (let i = 1; i < messages.length; i++) { lonlats,
const row = messages[i]!; profile,
const tags = row[wayTagsIdx] ?? ""; alternativeidx: "0",
const surfaceMatch = tags.match(/surface=(\S+)/); format: "geojson",
surfaces.set(i - 1, surfaceMatch ? surfaceMatch[1]! : "unknown"); tiledesc: "true",
const highwayMatch = tags.match(/highway=(\S+)/); });
highways.set(i - 1, highwayMatch ? highwayMatch[1]! : "unknown"); if (nogoParam) params.set("polygons", nogoParam);
return fetchSegment(`${BROUTER_URL}/brouter?${params}`);
// 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 };
} }
/** /**

View file

@ -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);
});
});

View file

@ -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<string, unknown>;
geometry: { type: string; coordinates: number[][] };
}
interface GeoJsonCollection {
type: string;
features: GeoJsonFeature[];
}
export function mergeGeoJsonSegments(segments: Record<string, unknown>[]): 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<number, string>;
highways: Map<number, string>;
maxspeeds: Map<number, string>;
smoothnesses: Map<number, string>;
tracktypes: Map<number, string>;
cycleways: Map<number, string>;
bikeroutes: Map<number, string>;
}
function extractWayTagData(properties: Record<string, unknown>): WayTagData {
const surfaces = new Map<number, string>();
const highways = new Map<number, string>();
const maxspeeds = new Map<number, string>();
const smoothnesses = new Map<number, string>();
const tracktypes = new Map<number, string>();
const cycleways = new Map<number, string>();
const bikeroutes = new Map<number, string>();
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);
}

View file

@ -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);
});
});

View file

@ -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<string, Record<string, unknown>>();
private readonly max: number;
constructor(max: number = DEFAULT_MAX) {
this.max = max;
}
get(key: string): Record<string, unknown> | 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<string, unknown>): 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;
}
}

View file

@ -2,6 +2,13 @@ import { useCallback, useEffect, useRef, useState } from "react";
import * as Y from "yjs"; import * as Y from "yjs";
import type { YjsState } from "./use-yjs.ts"; import type { YjsState } from "./use-yjs.ts";
import { electHost } from "./host-election.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 { interface RouteStats {
distance?: number; distance?: number;
@ -51,10 +58,14 @@ export function useRouting(yjs: YjsState | null, sessionId: string) {
const debounceTimer = useRef<ReturnType<typeof setTimeout>>(undefined); const debounceTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
const lastGoodWaypointsRef = useRef<WaypointData[] | null>(null); const lastGoodWaypointsRef = useRef<WaypointData[] | null>(null);
const restoringRef = useRef(false); 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 // this, rapid edits pile up on BRouter's thread pool and older requests
// get killed by its contention watchdog, surfacing as spurious errors. // get killed by its contention watchdog, surfacing as spurious errors.
const inflightAbortRef = useRef<AbortController | null>(null); const inflightAbortRef = useRef<AbortController | null>(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<SegmentCache>(new SegmentCache());
// Host election via Yjs awareness // Host election via Yjs awareness
useEffect(() => { useEffect(() => {
@ -81,7 +92,7 @@ export function useRouting(yjs: YjsState | null, sessionId: string) {
if (!yjs || !isHost || waypoints.length < 2) return; if (!yjs || !isHost || waypoints.length < 2) return;
// Collect no-go areas from Yjs // 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 }>) ?? [], points: (yMap.get("points") as Array<{ lat: number; lon: number }>) ?? [],
})).filter((a) => a.points.length >= 3); })).filter((a) => a.points.length >= 3);
@ -92,33 +103,72 @@ export function useRouting(yjs: YjsState | null, sessionId: string) {
inflightAbortRef.current?.abort(); inflightAbortRef.current?.abort();
const controller = new AbortController(); const controller = new AbortController();
inflightAbortRef.current = controller; 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 { try {
const response = await fetch("/api/route", { // Only hit the server when we actually need a segment we don't
method: "POST", // have. A pure drag-refinement that happens to re-land on cached
headers: { "Content-Type": "application/json" }, // coordinates short-circuits here.
body: JSON.stringify({ if (missingPairs.length > 0) {
waypoints, const response = await fetch("/api/route-segments", {
profile: (yjs.routeData.get("profile") as string) ?? "fastbike", method: "POST",
noGoAreas: noGoAreas.length > 0 ? noGoAreas : undefined, headers: { "Content-Type": "application/json" },
sessionId, body: JSON.stringify({
}), pairs: missingPairs,
signal: controller.signal, profile,
}); noGoAreas: noGoAreas.length > 0 ? noGoAreas : undefined,
sessionId,
}),
signal: controller.signal,
});
if (response.status === 429) { if (response.status === 429) {
setRouteError("rate_limit"); setRouteError("rate_limit");
restoreWaypoints(yjs, lastGoodWaypointsRef.current ?? snapshotBeforeCompute, restoringRef); restoreWaypoints(yjs, lastGoodWaypointsRef.current ?? snapshotBeforeCompute, restoringRef);
return; return;
} }
if (!response.ok) { if (!response.ok) {
const body = await response.json().catch(() => ({})); const body = await response.json().catch(() => ({}));
const code = (body as { code?: string }).code; const code = (body as { code?: string }).code;
setRouteError(code === "no_route" ? "no_route" : "failed"); setRouteError(code === "no_route" ? "no_route" : "failed");
restoreWaypoints(yjs, lastGoodWaypointsRef.current ?? snapshotBeforeCompute, restoringRef); restoreWaypoints(yjs, lastGoodWaypointsRef.current ?? snapshotBeforeCompute, restoringRef);
return; return;
}
const payload = (await response.json()) as {
segments: Record<string, unknown>[];
};
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); setRouteError(null);
lastGoodWaypointsRef.current = snapshotBeforeCompute; lastGoodWaypointsRef.current = snapshotBeforeCompute;

View file

@ -5,6 +5,7 @@ export default [
route("new", "routes/new.tsx"), route("new", "routes/new.tsx"),
route("api/sessions", "routes/api.sessions.ts"), route("api/sessions", "routes/api.sessions.ts"),
route("api/route", "routes/api.route.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("api/overpass", "routes/api.overpass.ts"),
route("session/:id", "routes/session.$id.tsx"), route("session/:id", "routes/session.$id.tsx"),
] satisfies RouteConfig; ] satisfies RouteConfig;

View file

@ -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 });
}
}