Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 50 additions & 43 deletions src/app/api/route-detour/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ const stationSchema = z.object({
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),
routeDuration: z.number().positive(),
});

interface DetourResult {
Expand All @@ -38,13 +37,13 @@ export async function POST(request: NextRequest) {
);
}

const { stations, routeCoordinates, routeDuration } = parseResult.data;
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 — windows and baselines.
// 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];
Expand All @@ -71,53 +70,61 @@ export async function POST(request: NextRequest) {
async function processStation(
s: z.infer<typeof stationSchema>,
): Promise<DetourResult> {
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);

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

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) {
return { id: s.id, detourMin: -1 };
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];
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 };
}

// Valhalla route: exit → station → rejoin
const detourDuration = await getRouteDuration([
{ lat: exitCoord[1], lon: exitCoord[0] },
{ lat: s.lat, lon: s.lon },
{ lat: rejoinCoord[1], lon: rejoinCoord[0] },
]);
// 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

if (detourDuration == null) {
return { id: s.id, detourMin };
} catch (err) {
console.warn(`[route-detour] station ${s.id} failed:`, err);
return { id: s.id, detourMin: -1 };
}

// Baseline: time for the actual exit/rejoin vertices Valhalla routes from
// (matches widened window when the distance-based window collapsed)
const exitFrac = cumLen[exitIdx] / totalLen;
const rejoinFrac = cumLen[rejoinIdx] / totalLen;
const originalSegmentDuration =
routeDuration * (rejoinFrac - exitFrac);

// Detour = new leg duration - what you'd normally drive for that segment
const detourSec = Math.max(0, detourDuration - originalSegmentDuration);
const detourMin = Math.round(detourSec / 6) / 10; // 1 decimal place

return { id: s.id, detourMin };
}

// Run with concurrency limit
Expand Down
85 changes: 67 additions & 18 deletions src/components/home-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,16 @@ export function HomeClient({ defaultFuel, center, zoom, clusterStations, locale
const [selectedFuel, setSelectedFuel] = useState<FuelType>(defaultFuel as FuelType);
const [corridorKm, setCorridorKm] = useState(5);
const [routeState, setRouteState] = useState<RouteState | null>(null);
// Station-leg routes: when a user clicks a station, we recalculate the route
// through that station but DON'T re-fetch corridor stations. The base routes
// in routeState still control corridor fetching.
const [stationLegRoutes, setStationLegRoutes] = useState<Route[] | null>(null);
const [isRouteLoading, setIsRouteLoading] = useState(false);
const [primaryStations, setPrimaryStations] = useState<StationsGeoJSONCollection>({ type: "FeatureCollection", features: [] });
const mapRef = useRef<MapRef | null>(null);

const routeAbortRef = useRef<AbortController | null>(null);
const stationLegAbortRef = useRef<AbortController | null>(null);
const [mapCenter, setMapCenter] = useState<[number, number]>(center);
const [selectedStationId, setSelectedStationId] = useState<string | null>(null);
const [maxPrice, setMaxPrice] = useState<number | null>(null);
Expand Down Expand Up @@ -100,10 +105,19 @@ export function HomeClient({ defaultFuel, center, zoom, clusterStations, locale
}, []);

const handleRoute = useCallback(
async (origin: [number, number], destination: [number, number], waypoints?: [number, number][]) => {
if (routeAbortRef.current) routeAbortRef.current.abort();
async (origin: [number, number], destination: [number, number], waypoints?: [number, number][], options?: { isStationLeg?: boolean }) => {
const isStationLeg = options?.isStationLeg ?? false;
// Use separate abort refs so clearing a station-leg doesn't cancel a normal route and vice versa
const abortRef = isStationLeg ? stationLegAbortRef : routeAbortRef;
if (abortRef.current) abortRef.current.abort();
const controller = new AbortController();
routeAbortRef.current = controller;
abortRef.current = controller;

// A normal route supersedes any pending station-leg preview
if (!isStationLeg && stationLegAbortRef.current) {
stationLegAbortRef.current.abort();
stationLegAbortRef.current = null;
}

setIsRouteLoading(true);
setRouteError(null);
Expand All @@ -115,27 +129,37 @@ export function HomeClient({ defaultFuel, center, zoom, clusterStations, locale
signal: controller.signal,
});
if (!res.ok) {
if (routeAbortRef.current === controller) {
setRouteState(null);
if (abortRef.current === controller) {
if (!isStationLeg) setRouteState(null);
setStationLegRoutes(null);
setRouteError("route.error");
}
return;
}
const data: { routes: Route[] } = await res.json();
if (data.routes.length === 0) {
if (routeAbortRef.current === controller) {
setRouteState(null);
if (abortRef.current === controller) {
if (!isStationLeg) setRouteState(null);
setStationLegRoutes(null);
setRouteError("route.noRoute");
}
return;
}
// Only write state if this is still the active request
if (routeAbortRef.current !== controller) return;

setRouteState({ routes: data.routes, primaryIndex: 0 });
// Clear stale corridor data so the detour effect doesn't pair
// the new route geometry with the previous corridor's stations
setPrimaryStations({ type: "FeatureCollection", features: [] });
if (abortRef.current !== controller) return;

if (isStationLeg) {
// Station-leg: only update the display route, keep base routes
// and corridor stations untouched
setStationLegRoutes(data.routes);
} else {
// Normal route: update base routes and trigger corridor fetch
setRouteState({ routes: data.routes, primaryIndex: 0 });
setStationLegRoutes(null);
// Clear stale corridor data so the detour effect doesn't pair
// the new route geometry with the previous corridor's stations
setPrimaryStations({ type: "FeatureCollection", features: [] });
}

const primary = data.routes[0];
mapRef.current?.fitBounds(
Expand All @@ -148,11 +172,14 @@ export function HomeClient({ defaultFuel, center, zoom, clusterStations, locale
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
console.error("Route calculation failed:", err);
if (routeAbortRef.current === controller) {
setRouteState(null);
if (abortRef.current === controller) {
if (!isStationLeg) setRouteState(null);
setStationLegRoutes(null);
setRouteError("route.error");
}
} finally {
// Null out the ref so clear handlers know no request is in flight
if (abortRef.current === controller) abortRef.current = null;
if (!controller.signal.aborted) setIsRouteLoading(false);
}
},
Expand All @@ -161,6 +188,8 @@ export function HomeClient({ defaultFuel, center, zoom, clusterStations, locale

const handleSelectRoute = useCallback((index: number) => {
setSelectedStationId(null);
if (stationLegAbortRef.current) { stationLegAbortRef.current.abort(); stationLegAbortRef.current = null; }
setStationLegRoutes(null);
// Clear stale corridor so detour effect doesn't pair new primary route
// with previous route's stations while MapView lifts the update
setPrimaryStations({ type: "FeatureCollection", features: [] });
Expand All @@ -183,13 +212,32 @@ export function HomeClient({ defaultFuel, center, zoom, clusterStations, locale

const handleClearRoute = useCallback(() => {
if (routeAbortRef.current) routeAbortRef.current.abort();
if (stationLegAbortRef.current) stationLegAbortRef.current.abort();
setRouteState(null);
setStationLegRoutes(null);
setIsRouteLoading(false);
setRouteError(null);
setPrimaryStations({ type: "FeatureCollection", features: [] });
setSelectedStationId(null);
}, []);

const handleSelectStation = useCallback((id: string | null) => {
setSelectedStationId(id);
// Deselect clears station-leg preview — search-panel's effect handles waypoint cleanup
if (id == null) {
if (stationLegAbortRef.current) { stationLegAbortRef.current.abort(); stationLegAbortRef.current = null; }
setStationLegRoutes(null);
// Only clear loading if no normal route request is in flight
if (!routeAbortRef.current) setIsRouteLoading(false);
}
}, []);

const handleClearStationLeg = useCallback(() => {
if (stationLegAbortRef.current) { stationLegAbortRef.current.abort(); stationLegAbortRef.current = null; }
setStationLegRoutes(null);
if (!routeAbortRef.current) setIsRouteLoading(false);
}, []);

const handleFlyTo = useCallback((coords: [number, number], stationId?: string) => {
mapRef.current?.flyTo({ center: coords, zoom: 14, duration: 1500 });
if (stationId) setSelectedStationId(stationId);
Expand Down Expand Up @@ -231,7 +279,6 @@ export function HomeClient({ defaultFuel, center, zoom, clusterStations, locale

(async () => {
const coords = route.geometry.coordinates as [number, number][];
const duration = route.duration;

// Process in batches, sorted by routeFraction (first-visible first)
for (let i = 0; i < eligible.length; i += DETOUR_BATCH_SIZE) {
Expand All @@ -250,7 +297,6 @@ export function HomeClient({ defaultFuel, center, zoom, clusterStations, locale
routeFraction: f.properties.routeFraction,
})),
routeCoordinates: coords,
routeDuration: duration,
}),
signal: controller.signal,
});
Expand Down Expand Up @@ -322,9 +368,10 @@ export function HomeClient({ defaultFuel, center, zoom, clusterStations, locale
clusterStations={clusterStations}
corridorKm={corridorKm}
routes={routeState?.routes ?? null}
displayRoutes={stationLegRoutes ?? routeState?.routes ?? null}
primaryRouteIndex={routeState?.primaryIndex ?? 0}
selectedStationId={selectedStationId}
onSelectStation={setSelectedStationId}
onSelectStation={handleSelectStation}
maxPrice={maxPrice}
onMaxPriceChange={setMaxPrice}
maxDetour={maxDetour}
Expand All @@ -342,7 +389,9 @@ export function HomeClient({ defaultFuel, center, zoom, clusterStations, locale
onFlyTo={handleFlyTo}
onRoute={handleRoute}
onClearRoute={handleClearRoute}
onClearStationLeg={handleClearStationLeg}
onSelectRoute={handleSelectRoute}
selectedStationId={selectedStationId}
routeError={routeError}
routes={routeState?.routes ?? null}
primaryRouteIndex={routeState?.primaryIndex ?? 0}
Expand Down
12 changes: 7 additions & 5 deletions src/components/map/map-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ interface MapViewProps {
clusterStations: boolean;
corridorKm: number;
routes: Route[] | null;
/** Routes to render on the map (may differ from `routes` during station-leg preview). */
displayRoutes?: Route[] | null;
primaryRouteIndex: number;
selectedStationId?: string | null;
onSelectStation?: (id: string | null) => void;
Expand All @@ -45,7 +47,7 @@ interface MapViewProps {
}

export const MapView = forwardRef<MapRef, MapViewProps>(function MapView(
{ selectedFuel, center, zoom, clusterStations, corridorKm, routes, primaryRouteIndex, selectedStationId, onSelectStation, maxPrice, onMaxPriceChange, maxDetour, onMapMove, onSelectRoute, onPrimaryStationsChange, onStationsLoadingChange, onStationsErrorChange, detourMap, userLocation, onMapReady },
{ selectedFuel, center, zoom, clusterStations, corridorKm, routes, displayRoutes, primaryRouteIndex, selectedStationId, onSelectStation, maxPrice, onMaxPriceChange, maxDetour, onMapMove, onSelectRoute, onPrimaryStationsChange, onStationsLoadingChange, onStationsErrorChange, detourMap, userLocation, onMapReady },
ref,
) {
const { mapStyle } = useTheme();
Expand Down Expand Up @@ -319,11 +321,11 @@ export const MapView = forwardRef<MapRef, MapViewProps>(function MapView(
style={{ width: "100%", height: "100%" }}
>
{showCountryMarkers && <CountryMarkers />}
{routes && routes.length > 0 && (
{(displayRoutes ?? routes) && (displayRoutes ?? routes)!.length > 0 && (
<RouteLayer
routes={routes}
primaryIndex={primaryRouteIndex}
onSelectRoute={onSelectRoute}
routes={(displayRoutes ?? routes)!}
primaryIndex={displayRoutes ? 0 : primaryRouteIndex}
onSelectRoute={displayRoutes ? undefined : onSelectRoute}
beforeLayerId={stationBeforeId}
/>
)}
Expand Down
Loading
Loading