diff --git a/app/ui/.env.example b/app/ui/.env.example index 71899d2c..55b12b0f 100644 --- a/app/ui/.env.example +++ b/app/ui/.env.example @@ -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) diff --git a/app/ui/src/app/results/components/Map.tsx b/app/ui/src/app/results/components/Map.tsx index 3ce831d9..141c00f1 100644 --- a/app/ui/src/app/results/components/Map.tsx +++ b/app/ui/src/app/results/components/Map.tsx @@ -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. @@ -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"); @@ -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 { + 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, @@ -127,10 +181,22 @@ function RoutePolylinesOverlay({ {}, ); const directionsCacheRef = useRef(new Map()); + 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 = {}; @@ -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; @@ -238,7 +317,7 @@ function RoutePolylinesOverlay({ ); polylinesByVehicleRef.current = {}; }; - }, [map, routes, onRouteDistanceUpdate]); + }, [map, routesPathKey]); useEffect(() => { if (!map || typeof google === "undefined") return; @@ -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]); @@ -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, ); @@ -682,18 +778,7 @@ export default function MapComponent({ handleStopHover({ diff --git a/app/ui/src/app/results/page.tsx b/app/ui/src/app/results/page.tsx index 9e3bb9be..4b2dc7df 100644 --- a/app/ui/src/app/results/page.tsx +++ b/app/ui/src/app/results/page.tsx @@ -69,8 +69,6 @@ function readInitialRoutes(): RouteLoadResult { } if (!stored) { - // No prior optimize run for this session (e.g. /results visited directly): there is - // nothing to show, so the page falls through to its normal empty-routes UI. cachedRouteLoadKey = cacheKey; cachedRouteLoadResult = EMPTY_ROUTE_LOAD_RESULT; return cachedRouteLoadResult; @@ -83,11 +81,7 @@ function readInitialRoutes(): RouteLoadResult { return cachedRouteLoadResult; } catch { cachedRouteLoadKey = cacheKey; - cachedRouteLoadResult = { - routes: [], - error: - "Could not read saved route data. Please run optimize again from the edit page.", - }; + cachedRouteLoadResult = EMPTY_ROUTE_LOAD_RESULT; return cachedRouteLoadResult; } } @@ -101,15 +95,64 @@ function subscribeToRouteStorage(onChange: () => void): () => void { }; } +let clientValidationPending = true; + +function readClientValidationError(): string | null { + if (typeof window === "undefined" || clientValidationPending) { + return null; + } + + const forceMock = MOCK_DATA_ENABLED + ? new URLSearchParams(window.location.search).get("mock") + : null; + if (forceMock === "1") return null; + + const stored = sessionStorage.getItem("optimizeResults"); + if (!stored) { + return "No optimized routes found. Please run optimize from the edit page."; + } + + try { + JSON.parse(stored); + return null; + } catch { + return "Could not read saved route data. Please run optimize again from the edit page."; + } +} + +function subscribeToClientValidation(onChange: () => void): () => void { + if (typeof window === "undefined") return () => {}; + + clientValidationPending = true; + const hydrationId = requestAnimationFrame(() => { + clientValidationPending = false; + onChange(); + }); + + window.addEventListener("storage", onChange); + window.addEventListener("optimize-results-updated", onChange); + + return () => { + cancelAnimationFrame(hydrationId); + window.removeEventListener("storage", onChange); + window.removeEventListener("optimize-results-updated", onChange); + }; +} + export default function ResultsPage() { const routeLoadResult = useSyncExternalStore( subscribeToRouteStorage, readInitialRoutes, () => EMPTY_ROUTE_LOAD_RESULT, ); + const clientValidationError = useSyncExternalStore( + subscribeToClientValidation, + readClientValidationError, + () => null, + ); const [draftRoutes, setDraftRoutes] = useState(null); const routes = draftRoutes ?? routeLoadResult.routes; - const error = routeLoadResult.error; + const error = clientValidationError; const routesRef = useRef(routes); useEffect(() => { routesRef.current = routes;