From 366cde0ccb2affc5fec152d73f677dff1435c6da Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:43:01 +0200 Subject: [PATCH 1/4] feat: stream detour results per-station via NDJSON Replace batch-then-JSON detour API with NDJSON streaming. Each station's detour appears on the map and in the list as soon as its Valhalla calls finish, instead of waiting for the whole batch. - API returns application/x-ndjson, one JSON line per station - Client reads the stream with getReader(), debounce-flushes to React state every 150ms to avoid excessive re-renders - Corridor slider disabled while detours stream to prevent restarts - Bump version to 1.3.0 --- package.json | 2 +- src/app/api/route-detour/route.ts | 194 ++++++++++++------------- src/components/home-client.tsx | 120 ++++++++------- src/components/search/search-panel.tsx | 3 +- 4 files changed, 166 insertions(+), 153 deletions(-) diff --git a/package.json b/package.json index 4ed2da5..6f0dcd6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pumperly", - "version": "1.2.0", + "version": "1.3.0", "description": "Open-source energy route planner for fuel and electric vehicles. Self-hostable.", "private": true, "scripts": { diff --git a/src/app/api/route-detour/route.ts b/src/app/api/route-detour/route.ts index 5a5a3ba..c963433 100644 --- a/src/app/api/route-detour/route.ts +++ b/src/app/api/route-detour/route.ts @@ -16,11 +16,6 @@ const bodySchema = z.object({ routeCoordinates: z.array(z.tuple([z.number(), z.number()])).min(2).max(3000), }); -interface DetourResult { - id: string; - detourMin: number; -} - export async function POST(request: NextRequest) { let body: unknown; try { @@ -40,114 +35,111 @@ export async function POST(request: NextRequest) { const { stations, routeCoordinates } = parseResult.data; const numCoords = routeCoordinates.length; - try { - // Pre-compute cumulative segment lengths for consistent length-based fractions. - // routeFraction from PostGIS (ST_LineLocatePoint) is a fraction of total line - // length, so we need length-based — not vertex-index-based — exit/rejoin windows. - const cumLen: number[] = [0]; - for (let i = 1; i < numCoords; i++) { - const dx = routeCoordinates[i][0] - routeCoordinates[i - 1][0]; - const dy = routeCoordinates[i][1] - routeCoordinates[i - 1][1]; - cumLen.push(cumLen[i - 1] + Math.sqrt(dx * dx + dy * dy)); - } - const totalLen = cumLen[numCoords - 1]; - - // Binary-search: find the vertex index where cumLen >= targetLen - function distToIndex(targetLen: number): number { - let lo = 0, hi = numCoords - 1; - while (lo < hi) { - const mid = (lo + hi) >> 1; - if (cumLen[mid] < targetLen) lo = mid + 1; - else hi = mid; - } - return lo; + // Pre-compute cumulative segment lengths for consistent length-based fractions. + // routeFraction from PostGIS (ST_LineLocatePoint) is a fraction of total line + // length, so we need length-based — not vertex-index-based — exit/rejoin windows. + const cumLen: number[] = [0]; + for (let i = 1; i < numCoords; i++) { + const dx = routeCoordinates[i][0] - routeCoordinates[i - 1][0]; + const dy = routeCoordinates[i][1] - routeCoordinates[i - 1][1]; + cumLen.push(cumLen[i - 1] + Math.sqrt(dx * dx + dy * dy)); + } + const totalLen = cumLen[numCoords - 1]; + + // Binary-search: find the vertex index where cumLen >= targetLen + function distToIndex(targetLen: number): number { + let lo = 0, hi = numCoords - 1; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (cumLen[mid] < targetLen) lo = mid + 1; + else hi = mid; } + return lo; + } - // Process stations with controlled concurrency - const results: DetourResult[] = []; - const queue = [...stations]; - - async function processStation( - s: z.infer, - ): Promise { - try { - if (totalLen === 0) return { id: s.id, detourMin: 0 }; + async function processStation( + s: z.infer, + ): Promise<{ id: string; detourMin: number }> { + try { + if (totalLen === 0) return { id: s.id, detourMin: 0 }; - // Symmetric window: 3% of route length each side. - const stationDist = s.routeFraction * totalLen; - const windowDist = totalLen * 0.03; - const exitDist = Math.max(0, stationDist - windowDist); - const rejoinDist = Math.min(totalLen, stationDist + windowDist); + const stationDist = s.routeFraction * totalLen; + const windowDist = totalLen * 0.03; + const exitDist = Math.max(0, stationDist - windowDist); + const rejoinDist = Math.min(totalLen, stationDist + windowDist); - let exitIdx = distToIndex(exitDist); - let rejoinIdx = Math.min(numCoords - 1, distToIndex(rejoinDist)); + let exitIdx = distToIndex(exitDist); + let rejoinIdx = Math.min(numCoords - 1, distToIndex(rejoinDist)); - // If the window collapsed to a single vertex (sparse/downsampled geometry), - // widen to guarantee at least two distinct vertices for Valhalla routing. + if (exitIdx === rejoinIdx) { + exitIdx = Math.max(0, exitIdx - 1); + rejoinIdx = Math.min(numCoords - 1, rejoinIdx + 1); if (exitIdx === rejoinIdx) { - exitIdx = Math.max(0, exitIdx - 1); - rejoinIdx = Math.min(numCoords - 1, rejoinIdx + 1); - if (exitIdx === rejoinIdx) { - return { id: s.id, detourMin: -1 }; - } - } - - const exitCoord = routeCoordinates[exitIdx]; - const rejoinCoord = routeCoordinates[rejoinIdx]; - - // Two parallel Valhalla calls: detour leg and direct baseline. - // The old linear-interpolation baseline (routeDuration * fraction) assumed - // uniform speed, which is wildly wrong on long mixed highway/town routes. - const [detourDuration, baselineDuration] = await Promise.all([ - // exit → station → rejoin - getRouteDuration([ - { lat: exitCoord[1], lon: exitCoord[0] }, - { lat: s.lat, lon: s.lon }, - { lat: rejoinCoord[1], lon: rejoinCoord[0] }, - ]), - // exit → rejoin (actual road time for this segment) - getRouteDuration([ - { lat: exitCoord[1], lon: exitCoord[0] }, - { lat: rejoinCoord[1], lon: rejoinCoord[0] }, - ]), - ]); - - if (detourDuration == null || baselineDuration == null) { return { id: s.id, detourMin: -1 }; } + } - // Detour = via-station time minus direct time for same segment - const detourSec = Math.max(0, detourDuration - baselineDuration); - const detourMin = Math.round(detourSec / 6) / 10; // 1 decimal place - - return { id: s.id, detourMin }; - } catch (err) { - console.warn(`[route-detour] station ${s.id} failed:`, err); + const exitCoord = routeCoordinates[exitIdx]; + const rejoinCoord = routeCoordinates[rejoinIdx]; + + const [detourDuration, baselineDuration] = await Promise.all([ + getRouteDuration([ + { lat: exitCoord[1], lon: exitCoord[0] }, + { lat: s.lat, lon: s.lon }, + { lat: rejoinCoord[1], lon: rejoinCoord[0] }, + ]), + getRouteDuration([ + { lat: exitCoord[1], lon: exitCoord[0] }, + { lat: rejoinCoord[1], lon: rejoinCoord[0] }, + ]), + ]); + + if (detourDuration == null || baselineDuration == null) { return { id: s.id, detourMin: -1 }; } - } - // Run with concurrency limit - const workers: Promise[] = []; - for (let i = 0; i < CONCURRENCY; i++) { - workers.push( - (async () => { - while (queue.length > 0) { - const station = queue.shift()!; - const result = await processStation(station); - results.push(result); - } - })(), - ); - } - await Promise.all(workers); + const detourSec = Math.max(0, detourDuration - baselineDuration); + const detourMin = Math.round(detourSec / 6) / 10; - return NextResponse.json({ detours: results }); - } catch (err) { - console.error("[route-detour] failed:", err); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 }, - ); + return { id: s.id, detourMin }; + } catch (err) { + console.warn(`[route-detour] station ${s.id} failed:`, err); + return { id: s.id, detourMin: -1 }; + } } + + const encoder = new TextEncoder(); + const queue = [...stations]; + + const stream = new ReadableStream({ + async start(controller) { + try { + const workers: Promise[] = []; + for (let i = 0; i < CONCURRENCY; i++) { + workers.push( + (async () => { + while (queue.length > 0) { + const station = queue.shift()!; + const result = await processStation(station); + controller.enqueue(encoder.encode(JSON.stringify(result) + "\n")); + } + })(), + ); + } + await Promise.all(workers); + } catch (err) { + console.error("[route-detour] stream failed:", err); + } finally { + controller.close(); + } + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "application/x-ndjson", + "Transfer-Encoding": "chunked", + "Cache-Control": "no-cache", + }, + }); } diff --git a/src/components/home-client.tsx b/src/components/home-client.tsx index add6ce5..1fefba9 100644 --- a/src/components/home-client.tsx +++ b/src/components/home-client.tsx @@ -11,7 +11,9 @@ import { Navbar } from "@/components/nav/navbar"; import { MapView } from "@/components/map/map-view"; import { SearchPanel } from "@/components/search/search-panel"; -const DETOUR_BATCH_SIZE = 15; +// Debounce interval (ms) for batching per-station stream updates into +// a single React state update, avoiding excessive re-renders. +const DETOUR_FLUSH_MS = 150; interface Props { defaultFuel: string; @@ -247,13 +249,12 @@ export function HomeClient({ defaultFuel, center, zoom, clusterStations, locale setPrimaryStations(stations); }, []); - // Progressive Valhalla-based detour calculation + // Streaming Valhalla-based detour calculation — results appear per-station const detourAbortRef = useRef(null); const [detourMap, setDetourMap] = useState>({}); const [detoursLoading, setDetoursLoading] = useState(false); useEffect(() => { - // Abort any previous detour fetch if (detourAbortRef.current) detourAbortRef.current.abort(); setDetourMap({}); @@ -263,7 +264,6 @@ export function HomeClient({ defaultFuel, center, zoom, clusterStations, locale return; } - // All stations with routeFraction (price may be null for EV chargers) const eligible = primaryStations.features .filter((f) => f.properties.routeFraction != null) .sort((a, b) => (a.properties.routeFraction ?? 0) - (b.properties.routeFraction ?? 0)); @@ -280,54 +280,74 @@ export function HomeClient({ defaultFuel, center, zoom, clusterStations, locale (async () => { const coords = route.geometry.coordinates as [number, number][]; - // Process in batches, sorted by routeFraction (first-visible first) - for (let i = 0; i < eligible.length; i += DETOUR_BATCH_SIZE) { - if (controller.signal.aborted) return; - - const batch = eligible.slice(i, i + DETOUR_BATCH_SIZE); - try { - const res = await fetch("/api/route-detour", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - stations: batch.map((f) => ({ - id: f.properties.id, - lon: f.geometry.coordinates[0], - lat: f.geometry.coordinates[1], - routeFraction: f.properties.routeFraction, - })), - routeCoordinates: coords, - }), - signal: controller.signal, - }); - if (!res.ok) { - // Mark all stations in this batch as failed so they don't - // pass the detour filter as if they were still loading - setDetourMap((prev) => { - const next = { ...prev }; - for (const f of batch) next[f.properties.id] = -1; - return next; - }); - continue; + try { + const res = await fetch("/api/route-detour", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + stations: eligible.map((f) => ({ + id: f.properties.id, + lon: f.geometry.coordinates[0], + lat: f.geometry.coordinates[1], + routeFraction: f.properties.routeFraction, + })), + routeCoordinates: coords, + }), + signal: controller.signal, + }); + + if (!res.ok || !res.body) { + // Mark all as failed + setDetourMap(Object.fromEntries(eligible.map((f) => [f.properties.id, -1]))); + setDetoursLoading(false); + return; + } + + // Read NDJSON stream, flush to React state every DETOUR_FLUSH_MS + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let pending: Record = {}; + let flushTimer: ReturnType | null = null; + + function flush() { + flushTimer = null; + const batch = pending; + pending = {}; + setDetourMap((prev) => ({ ...prev, ...batch })); + } + + function scheduleFlush() { + if (flushTimer == null) { + flushTimer = setTimeout(flush, DETOUR_FLUSH_MS); + } + } + + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop()!; // keep incomplete trailing chunk + + for (const line of lines) { + if (!line) continue; + try { + const { id, detourMin } = JSON.parse(line); + pending[id] = detourMin; + } catch { /* skip malformed line */ } } - const data: { detours: { id: string; detourMin: number }[] } = await res.json(); - if (controller.signal.aborted) return; - - setDetourMap((prev) => { - const next = { ...prev }; - for (const d of data.detours) next[d.id] = d.detourMin; - return next; - }); - } catch (err) { - if (err instanceof DOMException && err.name === "AbortError") return; - // Network/parse failures: mark batch as failed so stations - // don't pass maxDetour as "unknown" after loading finishes - setDetourMap((prev) => { - const next = { ...prev }; - for (const f of batch) next[f.properties.id] = -1; - return next; - }); + + if (Object.keys(pending).length > 0) scheduleFlush(); } + + // Flush any remaining results + if (flushTimer != null) clearTimeout(flushTimer); + if (Object.keys(pending).length > 0) flush(); + } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") return; + setDetourMap(Object.fromEntries(eligible.map((f) => [f.properties.id, -1]))); } if (!controller.signal.aborted) setDetoursLoading(false); })(); diff --git a/src/components/search/search-panel.tsx b/src/components/search/search-panel.tsx index c21f15a..6e2b725 100644 --- a/src/components/search/search-panel.tsx +++ b/src/components/search/search-panel.tsx @@ -781,8 +781,9 @@ export function SearchPanel({ max={25} step={1} value={corridorKm} + disabled={detoursLoading} onChange={(e) => onCorridorKmChange?.(parseInt(e.target.value))} - className="mt-1 h-1 w-full cursor-pointer touch-none accent-emerald-500" + className={`mt-1 h-1 w-full touch-none accent-emerald-500 ${detoursLoading ? "cursor-not-allowed opacity-40" : "cursor-pointer"}`} /> {stationLegMsg && ( From 39d053306a5fb7cdd55a8925b70b3675c6e62d2f Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:52:13 +0200 Subject: [PATCH 2/4] fix: lift 50-station cap, cancel stale flush, preserve streamed results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove .max(50) from detour schema — streaming handles any count via the concurrency queue, and route-stations can return up to 5000 - Hoist flushTimer so cleanup cancels it; flush() checks aborted before writing state, preventing stale detours leaking into the next route - Late stream errors only mark unseen stations as -1, keeping already-flushed successes instead of wiping the whole map - Sync package-lock.json to 1.3.0 --- package-lock.json | 4 ++-- src/app/api/route-detour/route.ts | 2 +- src/components/home-client.tsx | 32 ++++++++++++++++++++++--------- 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5d3725c..29ea865 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pumperly", - "version": "1.2.0", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pumperly", - "version": "1.2.0", + "version": "1.3.0", "license": "GPL-3.0-only", "dependencies": { "@prisma/adapter-pg": "^7.6.0", diff --git a/src/app/api/route-detour/route.ts b/src/app/api/route-detour/route.ts index c963433..4911547 100644 --- a/src/app/api/route-detour/route.ts +++ b/src/app/api/route-detour/route.ts @@ -12,7 +12,7 @@ const stationSchema = z.object({ }); const bodySchema = z.object({ - stations: z.array(stationSchema).min(1).max(50), + stations: z.array(stationSchema).min(1), routeCoordinates: z.array(z.tuple([z.number(), z.number()])).min(2).max(3000), }); diff --git a/src/components/home-client.tsx b/src/components/home-client.tsx index 1fefba9..db77c45 100644 --- a/src/components/home-client.tsx +++ b/src/components/home-client.tsx @@ -277,8 +277,12 @@ export function HomeClient({ defaultFuel, center, zoom, clusterStations, locale detourAbortRef.current = controller; setDetoursLoading(true); + let flushTimer: ReturnType | null = null; + (async () => { const coords = route.geometry.coordinates as [number, number][]; + const eligibleIds = new Set(eligible.map((f) => f.properties.id)); + const seen = new Set(); try { const res = await fetch("/api/route-detour", { @@ -297,9 +301,10 @@ export function HomeClient({ defaultFuel, center, zoom, clusterStations, locale }); if (!res.ok || !res.body) { - // Mark all as failed - setDetourMap(Object.fromEntries(eligible.map((f) => [f.properties.id, -1]))); - setDetoursLoading(false); + if (!controller.signal.aborted) { + setDetourMap(Object.fromEntries(eligible.map((f) => [f.properties.id, -1]))); + setDetoursLoading(false); + } return; } @@ -308,10 +313,10 @@ export function HomeClient({ defaultFuel, center, zoom, clusterStations, locale const decoder = new TextDecoder(); let buffer = ""; let pending: Record = {}; - let flushTimer: ReturnType | null = null; function flush() { flushTimer = null; + if (controller.signal.aborted) return; const batch = pending; pending = {}; setDetourMap((prev) => ({ ...prev, ...batch })); @@ -336,23 +341,32 @@ export function HomeClient({ defaultFuel, center, zoom, clusterStations, locale try { const { id, detourMin } = JSON.parse(line); pending[id] = detourMin; + seen.add(id); } catch { /* skip malformed line */ } } if (Object.keys(pending).length > 0) scheduleFlush(); } - // Flush any remaining results - if (flushTimer != null) clearTimeout(flushTimer); - if (Object.keys(pending).length > 0) flush(); + // Flush remaining results + if (flushTimer != null) { clearTimeout(flushTimer); flushTimer = null; } + if (!controller.signal.aborted && Object.keys(pending).length > 0) flush(); } catch (err) { if (err instanceof DOMException && err.name === "AbortError") return; - setDetourMap(Object.fromEntries(eligible.map((f) => [f.properties.id, -1]))); + // Only mark unseen stations as failed — keep already-flushed successes + if (!controller.signal.aborted) { + const failed: Record = {}; + for (const id of eligibleIds) { if (!seen.has(id)) failed[id] = -1; } + if (Object.keys(failed).length > 0) setDetourMap((prev) => ({ ...prev, ...failed })); + } } if (!controller.signal.aborted) setDetoursLoading(false); })(); - return () => { controller.abort(); }; + return () => { + controller.abort(); + if (flushTimer != null) { clearTimeout(flushTimer); flushTimer = null; } + }; }, [primaryStations, routeState]); // Enrich primary stations with real detour values From f6b7167c5af27e941d3ee8576195c530efb73ec7 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:57:29 +0200 Subject: [PATCH 3/4] fix: cap stations at 500, validate bounds, observe request.signal - Cap stations array at 500 (route-stations returns max 5000 but real corridor results are far lower) - Validate lon/lat bounds (-180..180, -90..90) and routeFraction (0..1) matching the rest of the routing surface - Observe request.signal: drain the queue on client disconnect so workers stop picking up new stations, and skip enqueue if aborted --- src/app/api/route-detour/route.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/app/api/route-detour/route.ts b/src/app/api/route-detour/route.ts index 4911547..18b15da 100644 --- a/src/app/api/route-detour/route.ts +++ b/src/app/api/route-detour/route.ts @@ -6,14 +6,19 @@ const CONCURRENCY = 8; const stationSchema = z.object({ id: z.string(), - lon: z.number(), - lat: z.number(), - routeFraction: z.number(), + lon: z.number().min(-180).max(180), + lat: z.number().min(-90).max(90), + routeFraction: z.number().min(0).max(1), }); +const coordSchema = z.tuple([ + z.number().min(-180).max(180), + z.number().min(-90).max(90), +]); + const bodySchema = z.object({ - stations: z.array(stationSchema).min(1), - routeCoordinates: z.array(z.tuple([z.number(), z.number()])).min(2).max(3000), + stations: z.array(stationSchema).min(1).max(500), + routeCoordinates: z.array(coordSchema).min(2).max(3000), }); export async function POST(request: NextRequest) { @@ -110,9 +115,13 @@ export async function POST(request: NextRequest) { const encoder = new TextEncoder(); const queue = [...stations]; + const { signal } = request; const stream = new ReadableStream({ async start(controller) { + // Drain queue on client disconnect so workers stop picking up new stations + const onAbort = () => { queue.length = 0; }; + signal.addEventListener("abort", onAbort); try { const workers: Promise[] = []; for (let i = 0; i < CONCURRENCY; i++) { @@ -121,6 +130,7 @@ export async function POST(request: NextRequest) { while (queue.length > 0) { const station = queue.shift()!; const result = await processStation(station); + if (signal.aborted) return; controller.enqueue(encoder.encode(JSON.stringify(result) + "\n")); } })(), @@ -130,6 +140,7 @@ export async function POST(request: NextRequest) { } catch (err) { console.error("[route-detour] stream failed:", err); } finally { + signal.removeEventListener("abort", onAbort); controller.close(); } }, From 8674ed06c275ff9efdbf12dbef7055cde02a8aad Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Mon, 6 Apr 2026 16:09:06 +0200 Subject: [PATCH 4/4] fix: backfill unseen stations, chunk <=500, propagate abort to Valhalla - Backfill unseen station IDs as -1 on both clean EOF and error, so they can't bypass the detour filter as "unknown" after loading - Client-side chunking in groups of 500 reconciles the cap mismatch between route-stations (5000) and route-detour (500) - getRouteDuration() accepts optional AbortSignal, combined with its 5s timeout via AbortSignal.any(). route-detour passes request.signal so in-flight Valhalla calls abort when the client disconnects - Validate detour result with Zod before enqueue - Rename DETOUR_FLUSH_MS/DETOUR_CHUNK_SIZE to camelCase - Add JSDoc to streaming endpoint --- src/app/api/route-detour/route.ts | 12 ++- src/components/home-client.tsx | 161 +++++++++++++++++------------- src/lib/valhalla.ts | 5 +- 3 files changed, 103 insertions(+), 75 deletions(-) diff --git a/src/app/api/route-detour/route.ts b/src/app/api/route-detour/route.ts index 18b15da..8483eae 100644 --- a/src/app/api/route-detour/route.ts +++ b/src/app/api/route-detour/route.ts @@ -4,6 +4,11 @@ import { getRouteDuration } from "@/lib/valhalla"; const CONCURRENCY = 8; +const detourResultSchema = z.object({ + id: z.string(), + detourMin: z.number(), +}); + const stationSchema = z.object({ id: z.string(), lon: z.number().min(-180).max(180), @@ -21,6 +26,7 @@ const bodySchema = z.object({ routeCoordinates: z.array(coordSchema).min(2).max(3000), }); +/** Stream per-station detour times as NDJSON. Each line: `{"id":"…","detourMin":…}` */ export async function POST(request: NextRequest) { let body: unknown; try { @@ -92,11 +98,11 @@ export async function POST(request: NextRequest) { { lat: exitCoord[1], lon: exitCoord[0] }, { lat: s.lat, lon: s.lon }, { lat: rejoinCoord[1], lon: rejoinCoord[0] }, - ]), + ], "auto", signal), getRouteDuration([ { lat: exitCoord[1], lon: exitCoord[0] }, { lat: rejoinCoord[1], lon: rejoinCoord[0] }, - ]), + ], "auto", signal), ]); if (detourDuration == null || baselineDuration == null) { @@ -129,7 +135,7 @@ export async function POST(request: NextRequest) { (async () => { while (queue.length > 0) { const station = queue.shift()!; - const result = await processStation(station); + const result = detourResultSchema.parse(await processStation(station)); if (signal.aborted) return; controller.enqueue(encoder.encode(JSON.stringify(result) + "\n")); } diff --git a/src/components/home-client.tsx b/src/components/home-client.tsx index db77c45..760dd7f 100644 --- a/src/components/home-client.tsx +++ b/src/components/home-client.tsx @@ -13,7 +13,9 @@ import { SearchPanel } from "@/components/search/search-panel"; // Debounce interval (ms) for batching per-station stream updates into // a single React state update, avoiding excessive re-renders. -const DETOUR_FLUSH_MS = 150; +const detourFlushMs = 150; +// Must match the .max() on the detour API schema +const detourChunkSize = 500; interface Props { defaultFuel: string; @@ -278,88 +280,105 @@ export function HomeClient({ defaultFuel, center, zoom, clusterStations, locale setDetoursLoading(true); let flushTimer: ReturnType | null = null; + let pending: Record = {}; + + function flush() { + flushTimer = null; + if (controller.signal.aborted) return; + const batch = pending; + pending = {}; + setDetourMap((prev) => ({ ...prev, ...batch })); + } + + function scheduleFlush() { + if (flushTimer == null) { + flushTimer = setTimeout(flush, detourFlushMs); + } + } + + // Backfill unseen stations as -1 so they don't bypass detour filter + function backfillUnseen(ids: Set, seen: Set) { + if (controller.signal.aborted) return; + const failed: Record = {}; + for (const id of ids) { if (!seen.has(id)) failed[id] = -1; } + if (Object.keys(failed).length > 0) setDetourMap((prev) => ({ ...prev, ...failed })); + } (async () => { const coords = route.geometry.coordinates as [number, number][]; const eligibleIds = new Set(eligible.map((f) => f.properties.id)); const seen = new Set(); - try { - const res = await fetch("/api/route-detour", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - stations: eligible.map((f) => ({ - id: f.properties.id, - lon: f.geometry.coordinates[0], - lat: f.geometry.coordinates[1], - routeFraction: f.properties.routeFraction, - })), - routeCoordinates: coords, - }), - signal: controller.signal, - }); - - if (!res.ok || !res.body) { - if (!controller.signal.aborted) { - setDetourMap(Object.fromEntries(eligible.map((f) => [f.properties.id, -1]))); - setDetoursLoading(false); + // Chunk eligible stations so each request stays within the API's .max(500) + for (let offset = 0; offset < eligible.length; offset += detourChunkSize) { + if (controller.signal.aborted) return; + const chunk = eligible.slice(offset, offset + detourChunkSize); + + try { + const res = await fetch("/api/route-detour", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + stations: chunk.map((f) => ({ + id: f.properties.id, + lon: f.geometry.coordinates[0], + lat: f.geometry.coordinates[1], + routeFraction: f.properties.routeFraction, + })), + routeCoordinates: coords, + }), + signal: controller.signal, + }); + + if (!res.ok || !res.body) { + // Mark this chunk as failed, continue to next chunk + if (!controller.signal.aborted) { + setDetourMap((prev) => { + const next = { ...prev }; + for (const f of chunk) { next[f.properties.id] = -1; seen.add(f.properties.id); } + return next; + }); + } + continue; } - return; - } - - // Read NDJSON stream, flush to React state every DETOUR_FLUSH_MS - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - let pending: Record = {}; - - function flush() { - flushTimer = null; - if (controller.signal.aborted) return; - const batch = pending; - pending = {}; - setDetourMap((prev) => ({ ...prev, ...batch })); - } - - function scheduleFlush() { - if (flushTimer == null) { - flushTimer = setTimeout(flush, DETOUR_FLUSH_MS); - } - } - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop()!; // keep incomplete trailing chunk - - for (const line of lines) { - if (!line) continue; - try { - const { id, detourMin } = JSON.parse(line); - pending[id] = detourMin; - seen.add(id); - } catch { /* skip malformed line */ } + // Read NDJSON stream + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop()!; + + for (const line of lines) { + if (!line) continue; + try { + const { id, detourMin } = JSON.parse(line); + pending[id] = detourMin; + seen.add(id); + } catch { /* skip malformed line */ } + } + + if (Object.keys(pending).length > 0) scheduleFlush(); } - if (Object.keys(pending).length > 0) scheduleFlush(); - } - - // Flush remaining results - if (flushTimer != null) { clearTimeout(flushTimer); flushTimer = null; } - if (!controller.signal.aborted && Object.keys(pending).length > 0) flush(); - } catch (err) { - if (err instanceof DOMException && err.name === "AbortError") return; - // Only mark unseen stations as failed — keep already-flushed successes - if (!controller.signal.aborted) { - const failed: Record = {}; - for (const id of eligibleIds) { if (!seen.has(id)) failed[id] = -1; } - if (Object.keys(failed).length > 0) setDetourMap((prev) => ({ ...prev, ...failed })); + // Flush remaining from this chunk + if (flushTimer != null) { clearTimeout(flushTimer); flushTimer = null; } + if (Object.keys(pending).length > 0) flush(); + } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") return; + // Mark unseen stations in this chunk as failed, keep flushed successes + backfillUnseen(new Set(chunk.map((f) => f.properties.id)), seen); } } + + // Backfill any stations never seen across all chunks (skipped lines, etc.) + backfillUnseen(eligibleIds, seen); if (!controller.signal.aborted) setDetoursLoading(false); })(); diff --git a/src/lib/valhalla.ts b/src/lib/valhalla.ts index 41a99e0..6bda207 100644 --- a/src/lib/valhalla.ts +++ b/src/lib/valhalla.ts @@ -123,6 +123,7 @@ export async function getRoute( export async function getRouteDuration( locations: { lat: number; lon: number }[], costing: string = "auto", + signal?: AbortSignal, ): Promise { if (!VALHALLA_URL) return null; @@ -135,7 +136,9 @@ export async function getRouteDuration( directions_options: { units: "kilometers" }, directions_type: "none", }), - signal: AbortSignal.timeout(5000), + signal: signal + ? AbortSignal.any([signal, AbortSignal.timeout(5000)]) + : AbortSignal.timeout(5000), }); if (!res.ok) return null;