Skip to content
Open
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
1 change: 1 addition & 0 deletions app/ui/.env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Copy to .env.local and fill in your values (get key + Map ID from Google Cloud Console)
# Enable "Maps JavaScript API" and "Directions API" for the key used below.
NEXT_PUBLIC_GOOGLE_MAPS_KEY=
NEXT_PUBLIC_GOOGLE_MAPS_MAP_ID=
# DELIVERYOPTIMIZER_API_URL= # Cloud Run URL — only needed in production (leave unset for local dev)
271 changes: 178 additions & 93 deletions app/ui/src/app/results/components/Map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,18 @@ declare const process: {
const DAVIS_CENTER = { lat: 38.5449, lng: -121.7405 };
const MARKER_ICON_WIDTH = 28;
const MARKER_ICON_HEIGHT = 40;
const STOP_MARKER_ANCHOR = {
x: MARKER_ICON_WIDTH / 2,
y: MARKER_ICON_HEIGHT,
} as const;

function getMarkerScaledSize(): google.maps.Size | undefined {
function createStopMarkerIcon(iconUrl: string): google.maps.Icon | undefined {
if (typeof google === "undefined") return undefined;
return new google.maps.Size(MARKER_ICON_WIDTH, MARKER_ICON_HEIGHT);
return {
url: iconUrl,
scaledSize: new google.maps.Size(MARKER_ICON_WIDTH, MARKER_ICON_HEIGHT),
anchor: new google.maps.Point(STOP_MARKER_ANCHOR.x, STOP_MARKER_ANCHOR.y),
};
}

// fillColor is always a route palette hex from routeColorHex, never user input.
Expand All @@ -44,8 +52,8 @@ function createRoutePinElement(fillColor: string): HTMLElement {
const wrapper = document.createElement("div");
wrapper.style.width = `${MARKER_ICON_WIDTH}px`;
wrapper.style.height = `${MARKER_ICON_HEIGHT}px`;
// Anchor bottom-center of the pin on the stop (tip is at y=40 in the SVG).
wrapper.style.transform = "translate(-50%, -100%)";
// AdvancedMarkerElement anchors at bottom-center by default; do not apply an
// extra translate or the pin tip drifts by a fixed pixel offset when zooming.
wrapper.style.filter = "drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))";

const img = document.createElement("img");
Expand Down Expand Up @@ -90,6 +98,52 @@ function routeCacheKey(path: google.maps.LatLngLiteral[]): string {
return path.map((p) => `${p.lat.toFixed(6)},${p.lng.toFixed(6)}`).join("|");
}

function routePathsKey(routes: Route[]): string {
return routes
.map((route) => {
const path = buildRoutePath(route, null);
return `${route.vehicleId}:${routeCacheKey(path)}`;
})
.join("|");
}

function requestDrivingDirections(
service: google.maps.DirectionsService,
request: google.maps.DirectionsRequest,
): Promise<google.maps.DirectionsResult> {
return new Promise((resolve, reject) => {
service.route(request, (result, status) => {
if (status === google.maps.DirectionsStatus.OK && result) {
resolve(result);
return;
}
reject(new Error(`Directions request failed: ${status}`));
});
});
}

/** Prefer overview_path; fall back to leg step paths when overview is missing. */
function extractRoadPath(
result: google.maps.DirectionsResult,
): google.maps.LatLng[] {
const route = result.routes[0];
if (!route) return [];

if (route.overview_path && route.overview_path.length >= 2) {
return route.overview_path;
}

const path: google.maps.LatLng[] = [];
for (const leg of route.legs ?? []) {
for (const [i, step] of (leg.steps ?? []).entries()) {
if (step.path?.length) {
path.push(...(i === 0 ? step.path : step.path.slice(1)));
}
}
}
return path;
}

function buildRoutePath(
route: Route,
pendingPinMove: PendingPinMove | null,
Expand Down Expand Up @@ -127,10 +181,22 @@ function RoutePolylinesOverlay({
{},
);
const directionsCacheRef = useRef(new Map<string, CachedDirections>());
const onRouteDistanceUpdateRef = useRef(onRouteDistanceUpdate);
const routesRef = useRef(routes);
const routesPathKey = useMemo(() => routePathsKey(routes), [routes]);

useEffect(() => {
routesRef.current = routes;
}, [routes]);

useEffect(() => {
onRouteDistanceUpdateRef.current = onRouteDistanceUpdate;
}, [onRouteDistanceUpdate]);

useEffect(() => {
if (!map || typeof google === "undefined") return;

const routesSnapshot = routesRef.current;
Object.values(polylinesByVehicleRef.current).forEach((p) => p.setMap(null));
polylinesByVehicleRef.current = {};

Expand All @@ -149,87 +215,100 @@ function RoutePolylinesOverlay({
polylinesByVehicleRef.current[route.vehicleId] = fallbackPoly;
};

void Promise.allSettled(
routes.map(async (route, routeIndex) => {
const strokeColor = routeColorHex(routeIndex);
const path = buildRoutePath(route, null);
if (path.length < 2) return;
const origin = path[0]!;
const destination = path[path.length - 1]!;

const waypoints = path
.slice(1, -1)
.map((location) => ({ location, stopover: true }));
if (waypoints.length > 25) {
drawFallback(route, strokeColor);
return;
}

const cacheKey = routeCacheKey(path);
const cached = directionsCacheRef.current.get(cacheKey);
if (cached && cached.path.length >= 2) {
if (cancelled) return;
const cachedPoly = new google.maps.Polyline({
map,
path: cached.path,
...routePolylineOptions(strokeColor),
});
polylinesByVehicleRef.current[route.vehicleId] = cachedPoly;
if (cancelled) return;
if (cached.meters > 0 && onRouteDistanceUpdate) {
const distanceMi = Number((cached.meters / 1609.344).toFixed(1));
onRouteDistanceUpdate(route.vehicleId, distanceMi);
}
return;
}
const drawRoutePolyline = async (route: Route, routeIndex: number) => {
const strokeColor = routeColorHex(routeIndex);
const path = buildRoutePath(route, null);
if (path.length < 2) return;

try {
const result = await directionsService.route({
origin,
destination,
waypoints,
optimizeWaypoints: false,
travelMode: google.maps.TravelMode.DRIVING,
});
if (cancelled) return;
const origin = path[0]!;
const destination = path[path.length - 1]!;
const waypoints = path
.slice(1, -1)
.map((location) => ({ location, stopover: true }));

const roadPath = result.routes[0]?.overview_path;
if (!roadPath || roadPath.length < 2) {
drawFallback(route, strokeColor);
return;
}
if (waypoints.length > 25) {
drawFallback(route, strokeColor);
return;
}

const totalMeters = (result.routes[0]?.legs ?? []).reduce(
(sum, leg) => sum + (leg.distance?.value ?? 0),
0,
);
if (cancelled) return;
if (totalMeters > 0 && onRouteDistanceUpdate) {
const distanceMi = Number((totalMeters / 1609.344).toFixed(1));
onRouteDistanceUpdate(route.vehicleId, distanceMi);
}
if (cancelled) return;
const cacheKey = routeCacheKey(path);
const cached = directionsCacheRef.current.get(cacheKey);
if (cached && cached.path.length >= 2) {
if (cancelled) return;
const cachedPoly = new google.maps.Polyline({
map,
path: cached.path,
...routePolylineOptions(strokeColor),
});
polylinesByVehicleRef.current[route.vehicleId] = cachedPoly;
if (cached.meters > 0 && onRouteDistanceUpdateRef.current) {
const distanceMi = Number((cached.meters / 1609.344).toFixed(1));
onRouteDistanceUpdateRef.current(route.vehicleId, distanceMi);
}
return;
}

rememberDirections(directionsCacheRef.current, cacheKey, {
path: roadPath,
meters: totalMeters,
});
try {
const result = await requestDrivingDirections(directionsService, {
origin,
destination,
waypoints,
optimizeWaypoints: false,
travelMode: google.maps.TravelMode.DRIVING,
});
if (cancelled) return;

const roadPoly = new google.maps.Polyline({
map,
path: roadPath,
...routePolylineOptions(strokeColor),
});
polylinesByVehicleRef.current[route.vehicleId] = roadPoly;
} catch (err) {
const roadPath = extractRoadPath(result);
if (roadPath.length < 2) {
console.warn(
"[Map] DirectionsService failed, falling back to straight line:",
err,
`[Map] Directions returned no road path for vehicle ${route.vehicleId}; falling back to straight line.`,
);
drawFallback(route, strokeColor);
return;
}
}),
);

const totalMeters = (result.routes[0]?.legs ?? []).reduce(
(sum, leg) => sum + (leg.distance?.value ?? 0),
0,
);
if (cancelled) return;

rememberDirections(directionsCacheRef.current, cacheKey, {
path: roadPath,
meters: totalMeters,
});

const roadPoly = new google.maps.Polyline({
map,
path: roadPath,
...routePolylineOptions(strokeColor),
});
polylinesByVehicleRef.current[route.vehicleId] = roadPoly;

if (totalMeters > 0 && onRouteDistanceUpdateRef.current) {
const distanceMi = Number((totalMeters / 1609.344).toFixed(1));
onRouteDistanceUpdateRef.current(route.vehicleId, distanceMi);
}
} catch (err) {
console.warn(
`[Map] DirectionsService failed for vehicle ${route.vehicleId}, falling back to straight line:`,
err,
);
drawFallback(route, strokeColor);
}
};

void (async () => {
// Request one route at a time to avoid Directions API rate-limit failures.
for (
let routeIndex = 0;
routeIndex < routesSnapshot.length;
routeIndex += 1
) {
if (cancelled) return;
await drawRoutePolyline(routesSnapshot[routeIndex]!, routeIndex);
}
})();

return () => {
cancelled = true;
Expand All @@ -238,7 +317,7 @@ function RoutePolylinesOverlay({
);
polylinesByVehicleRef.current = {};
};
}, [map, routes, onRouteDistanceUpdate]);
}, [map, routesPathKey]);

useEffect(() => {
if (!map || typeof google === "undefined") return;
Expand All @@ -257,16 +336,33 @@ function RoutePolylinesOverlay({
}

for (const route of routes) {
const poly = byVehicle[route.vehicleId];
if (!poly) continue;
const committed = buildRoutePath(route, null);
if (committed.length < 2) continue;
const key = routeCacheKey(committed);
const cached = directionsCacheRef.current.get(key);
const routeIndex = routes.findIndex(
(r) => r.vehicleId === route.vehicleId,
);
const strokeColor = routeColorHex(routeIndex);

let poly = byVehicle[route.vehicleId];
if (!poly) {
// Effect 1 may have cleared refs before this runs; show a path immediately.
const path =
cached && cached.path.length >= 2 ? cached.path : committed;
poly = new google.maps.Polyline({
map,
path,
...routePolylineOptions(strokeColor),
});
byVehicle[route.vehicleId] = poly;
continue;
}

if (cached && cached.path.length >= 2) {
poly.setPath(cached.path);
} else {
poly.setPath(committed);
poly.setPath(committed); // straight-line until async fetch fills the cache
}
}
}, [map, routes, pendingPinMove]);
Expand Down Expand Up @@ -645,7 +741,7 @@ export default function MapComponent({
routes.map((route, routeIndex) => {
const accentColor = routeColorHex(routeIndex);
const iconUrl = markerSvgDataUrl(accentColor);
const scaledSize = getMarkerScaledSize();
const stopIcon = createStopMarkerIcon(iconUrl);
const sorted = [...route.stops].sort(
(a, b) => a.sequence - b.sequence,
);
Expand Down Expand Up @@ -682,18 +778,7 @@ export default function MapComponent({
<Marker
key={stop.id}
position={position}
icon={
scaledSize
? {
url: iconUrl,
scaledSize,
anchor: new google.maps.Point(
MARKER_ICON_WIDTH / 2,
MARKER_ICON_HEIGHT,
),
}
: { url: iconUrl }
}
icon={stopIcon}
draggable={isEditMode}
onMouseOver={() =>
handleStopHover({
Expand Down
Loading
Loading