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/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..8483eae 100644 --- a/src/app/api/route-detour/route.ts +++ b/src/app/api/route-detour/route.ts @@ -4,23 +4,29 @@ 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(), - 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).max(50), - 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), }); -interface DetourResult { - id: string; - detourMin: number; -} - +/** Stream per-station detour times as NDJSON. Each line: `{"id":"…","detourMin":…}` */ export async function POST(request: NextRequest) { let body: unknown; try { @@ -40,114 +46,117 @@ 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<{ id: string; detourMin: number }> { + try { + if (totalLen === 0) return { id: s.id, detourMin: 0 }; - async function processStation( - s: z.infer, - ): Promise { - try { - if (totalLen === 0) return { id: s.id, detourMin: 0 }; + const stationDist = s.routeFraction * totalLen; + const windowDist = totalLen * 0.03; + const exitDist = Math.max(0, stationDist - windowDist); + const rejoinDist = Math.min(totalLen, stationDist + windowDist); - // 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); + 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] }, + ], "auto", signal), + getRouteDuration([ + { lat: exitCoord[1], lon: exitCoord[0] }, + { lat: rejoinCoord[1], lon: rejoinCoord[0] }, + ], "auto", signal), + ]); + + 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 { 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++) { + workers.push( + (async () => { + while (queue.length > 0) { + const station = queue.shift()!; + const result = detourResultSchema.parse(await processStation(station)); + if (signal.aborted) return; + controller.enqueue(encoder.encode(JSON.stringify(result) + "\n")); + } + })(), + ); + } + await Promise.all(workers); + } catch (err) { + console.error("[route-detour] stream failed:", err); + } finally { + signal.removeEventListener("abort", onAbort); + 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..760dd7f 100644 --- a/src/components/home-client.tsx +++ b/src/components/home-client.tsx @@ -11,7 +11,11 @@ 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 detourFlushMs = 150; +// Must match the .max() on the detour API schema +const detourChunkSize = 500; interface Props { defaultFuel: string; @@ -247,13 +251,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 +266,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)); @@ -277,20 +279,47 @@ export function HomeClient({ defaultFuel, center, zoom, clusterStations, locale detourAbortRef.current = controller; 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(); - // Process in batches, sorted by routeFraction (first-visible first) - for (let i = 0; i < eligible.length; i += DETOUR_BATCH_SIZE) { + // 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); - 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) => ({ + stations: chunk.map((f) => ({ id: f.properties.id, lon: f.geometry.coordinates[0], lat: f.geometry.coordinates[1], @@ -300,39 +329,63 @@ export function HomeClient({ defaultFuel, center, zoom, clusterStations, locale }), 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; - }); + + 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; } - 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; - }); + // 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(); + } + + // 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; - // 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; - }); + // 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); })(); - return () => { controller.abort(); }; + return () => { + controller.abort(); + if (flushTimer != null) { clearTimeout(flushTimer); flushTimer = null; } + }; }, [primaryStations, routeState]); // Enrich primary stations with real detour values 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 && ( 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;