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
79 changes: 52 additions & 27 deletions src/app/api/route-detour/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const coordSchema = z.tuple([
const bodySchema = z.object({
stations: z.array(stationSchema).min(1).max(500),
routeCoordinates: z.array(coordSchema).min(2).max(3000),
routeDuration: z.number().min(0),
});

/** Stream per-station detour times as NDJSON. Each line: `{"id":"…","detourMin":…}` */
Expand All @@ -43,7 +44,7 @@ export async function POST(request: NextRequest) {
);
}

const { stations, routeCoordinates } = parseResult.data;
const { stations, routeCoordinates, routeDuration } = parseResult.data;
const numCoords = routeCoordinates.length;

// Pre-compute cumulative segment lengths for consistent length-based fractions.
Expand Down Expand Up @@ -75,7 +76,9 @@ export async function POST(request: NextRequest) {
if (totalLen === 0) return { id: s.id, detourMin: 0 };

const stationDist = s.routeFraction * totalLen;
const windowDist = totalLen * 0.03;
// Use a wide 15% window so Valhalla can find the truly optimal path
// through the station (not constrained to the nearest highway point).
const windowDist = totalLen * 0.15;
const exitDist = Math.max(0, stationDist - windowDist);
const rejoinDist = Math.min(totalLen, stationDist + windowDist);

Expand All @@ -93,39 +96,61 @@ export async function POST(request: NextRequest) {
const exitCoord = routeCoordinates[exitIdx];
const rejoinCoord = routeCoordinates[rejoinIdx];

// Pin baseline to the actual highway by adding intermediate waypoints
// from the original route geometry (type: "through" = pass without stopping)
const midPoints: { lat: number; lon: number; type: "through" }[] = [];
const span = rejoinIdx - exitIdx;
if (span > 3) {
// Build the via-station route with through-waypoints pinning the highway
// segments before and after the station. This lets Valhalla find the
// optimal detour while keeping the highway portions on the correct road.
const locations: { lat: number; lon: number; type?: "through" }[] = [];

// Start: exit point
locations.push({ lat: exitCoord[1], lon: exitCoord[0] });

// Through-waypoints before station (pin highway)
const stationIdx = distToIndex(stationDist);
const preSeg = stationIdx - exitIdx;
if (preSeg > 4) {
for (let i = 1; i <= 3; i++) {
const midIdx = exitIdx + Math.round(i * span / 4);
if (midIdx > exitIdx && midIdx < rejoinIdx) {
const c = routeCoordinates[midIdx];
midPoints.push({ lat: c[1], lon: c[0], type: "through" });
const idx = exitIdx + Math.round(i * preSeg / 4);
if (idx > exitIdx && idx < stationIdx) {
const c = routeCoordinates[idx];
locations.push({ lat: c[1], lon: c[0], type: "through" });
}
}
}

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] },
...midPoints,
{ lat: rejoinCoord[1], lon: rejoinCoord[0] },
], "auto", signal),
]);

if (detourDuration == null || baselineDuration == null) {
// Station (break point — actual stop)
locations.push({ lat: s.lat, lon: s.lon });

// Through-waypoints after station (pin highway)
const postSeg = rejoinIdx - stationIdx;
if (postSeg > 4) {
for (let i = 1; i <= 3; i++) {
const idx = stationIdx + Math.round(i * postSeg / 4);
if (idx > stationIdx && idx < rejoinIdx) {
const c = routeCoordinates[idx];
locations.push({ lat: c[1], lon: c[0], type: "through" });
}
}
}

// End: rejoin point
locations.push({ lat: rejoinCoord[1], lon: rejoinCoord[0] });

// Single Valhalla call: exit → [highway waypoints] → station → [highway waypoints] → rejoin
const viaStationDuration = await getRouteDuration(locations, "auto", signal);

if (viaStationDuration == null) {
return { id: s.id, detourMin: -1 };
}

const detourSec = detourDuration - baselineDuration;
// Large negative means baseline was longer than detour — bad baseline match
// Baseline estimated from the original route's timing, proportional to
// the fraction of route covered by the exit→rejoin window. This is more
// accurate than a separate Valhalla call because it uses the actual
// route's speed profile instead of an independently-routed segment.
const segFraction = (cumLen[rejoinIdx] - cumLen[exitIdx]) / totalLen;
const estimatedBaseline = routeDuration * segFraction;

const detourSec = viaStationDuration - estimatedBaseline;
// Large negative means something went wrong
if (detourSec < -60) return { id: s.id, detourMin: -1 };
const detourMin = Math.round(Math.max(0, detourSec) / 6) / 10;

Expand Down
2 changes: 2 additions & 0 deletions src/components/home-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,7 @@ export function HomeClient({ defaultFuel, center, zoom, clusterStations, locale
routeFraction: f.properties.routeFraction,
})),
routeCoordinates: coords,
routeDuration: route.duration,
}),
signal: controller.signal,
});
Expand Down Expand Up @@ -462,6 +463,7 @@ export function HomeClient({ defaultFuel, center, zoom, clusterStations, locale
onClearStationLeg={handleClearStationLeg}
onSelectRoute={handleSelectRoute}
selectedStationId={selectedStationId}
onSelectStation={handleSelectStation}
routeError={routeError}
routes={routeState?.routes ?? null}
displayRoutes={stationLegRoutes}
Expand Down
5 changes: 3 additions & 2 deletions src/components/map/station-layer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,10 @@ export function StationLayer({ stations, onPriceRange, cluster = true, selectedS
}

const props = feature.properties as Record<string, unknown>;
setSelectedStationId(String(props.id ?? ""));
const clickedId = String(props.id ?? "");
setSelectedStationId(selectedStationId === clickedId ? null : clickedId);
},
[mapRef, setSelectedStationId],
[mapRef, setSelectedStationId, selectedStationId],
);

useEffect(() => {
Expand Down
14 changes: 12 additions & 2 deletions src/components/search/search-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ interface SearchPanelProps {
onClearStationLeg?: () => void;
onSelectRoute?: (index: number) => void;
selectedStationId?: string | null;
onSelectStation?: (id: string | null) => void;
routeError?: string | null;
routes: Route[] | null;
displayRoutes?: Route[] | null;
Expand Down Expand Up @@ -59,6 +60,7 @@ export function SearchPanel({
onClearStationLeg,
onSelectRoute,
selectedStationId,
onSelectStation,
routeError,
routes,
displayRoutes,
Expand Down Expand Up @@ -853,12 +855,20 @@ export function SearchPanel({
const isCheapest = sid === cheapestId;
const isShortest = sid === shortestDetourId;
const isBalanced = sid === balancedId;
const highlight = isCheapest ? "bg-emerald-50 dark:bg-emerald-950/40" : isShortest ? "bg-blue-50 dark:bg-blue-950/40" : isBalanced ? "bg-amber-50 dark:bg-amber-950/40" : "";
const isActive = sid === selectedStationId;
const highlight = isActive
? "bg-blue-100 ring-1 ring-inset ring-blue-300 dark:bg-blue-900/50 dark:ring-blue-700"
: isCheapest ? "bg-emerald-50 dark:bg-emerald-950/40" : isShortest ? "bg-blue-50 dark:bg-blue-950/40" : isBalanced ? "bg-amber-50 dark:bg-amber-950/40" : "";
return (
<button
key={sid}
onClick={() => {
onFlyTo(station.geometry.coordinates, sid);
if (isActive) {
// Toggle off: deselect station (clears leg preview)
onSelectStation?.(null);
} else {
onFlyTo(station.geometry.coordinates, sid);
}
if (window.matchMedia("(max-width: 639px)").matches) setCollapsed(true);
}}
className={`flex w-full items-center justify-between border-b border-gray-50 px-4 py-2 text-left last:border-b-0 dark:border-gray-800 ${highlight || "hover:bg-gray-50 dark:hover:bg-gray-800"}`}
Expand Down
Loading