/// import { APIProvider, InfoWindow, Map as GoogleMap, useMap } from '@vis.gl/react-google-maps'; import { useEffect, useRef, useState } from 'react'; import { JobMapProps, LiveStatus, liveStatusToMapPoint, MapPoint, pointDetailRows, UTILITY_COLORS } from '../types'; const API_KEY = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || ''; const LIVE_COLOR = '#1a73e8'; const DEFAULT_CENTER = { lat: 39.5, lng: -98.35 }; // continental US const DEFAULT_ZOOM = 4; function colorFor(utilityType: string): string { return UTILITY_COLORS[utilityType] ?? UTILITY_COLORS.UNKNOWN; } function orderKey(p: MapPoint): number { return p.sequence ?? new Date(p.recordedAt).getTime(); } // Identifies whether the set of points (including each one's own position) // has actually changed, so a moving point re-fits bounds even when the // count doesn't change (e.g. a device's single "current location" point). function pointsSignature(points: MapPoint[]): string { return points.map((p) => `${p.id}:${p.lat.toFixed(7)}:${p.lng.toFixed(7)}`).join('|'); } // Draws points as colored circles plus one polyline per utility run. // Imperative overlays (google.maps.Marker/Polyline) are used because // @vis.gl/react-google-maps has no polyline component. function PointsLayer({ points, fitBounds, onSelect, }: { points: MapPoint[]; fitBounds: boolean; onSelect: (point: MapPoint) => void; }) { const map = useMap(); const overlaysRef = useRef<{ setMap: (m: google.maps.Map | null) => void }[]>([]); const fittedSignatureRef = useRef(''); const onSelectRef = useRef(onSelect); onSelectRef.current = onSelect; useEffect(() => { if (!map) { return; } overlaysRef.current.forEach((o) => o.setMap(null)); overlaysRef.current = []; if (points.length === 0) { return; } const byUtility = new Map(); for (const p of points) { const group = byUtility.get(p.utilityType) ?? []; group.push(p); byUtility.set(p.utilityType, group); } for (const [utility, group] of byUtility) { const sorted = [...group].sort((a, b) => orderKey(a) - orderKey(b)); const color = colorFor(utility); const line = new google.maps.Polyline({ path: sorted.map((p) => ({ lat: p.lat, lng: p.lng })), strokeColor: color, strokeOpacity: 0.8, strokeWeight: 3, map, }); overlaysRef.current.push(line); for (const p of sorted) { const marker = new google.maps.Marker({ position: { lat: p.lat, lng: p.lng }, map, cursor: 'pointer', icon: { path: google.maps.SymbolPath.CIRCLE, scale: 5, fillColor: color, fillOpacity: 1, strokeColor: '#ffffff', strokeWeight: 1.5, }, }); marker.addListener('click', () => onSelectRef.current(p)); overlaysRef.current.push(marker); } } const signature = pointsSignature(points); if (fitBounds && signature !== fittedSignatureRef.current) { fittedSignatureRef.current = signature; if (points.length === 1) { // A single (often moving) point has no useful bounds to fit — just // recenter on it, preserving the current zoom. map.panTo({ lat: points[0].lat, lng: points[0].lng }); } else { const bounds = new google.maps.LatLngBounds(); points.forEach((p) => bounds.extend({ lat: p.lat, lng: p.lng })); map.fitBounds(bounds, 48); } } }, [map, points, fitBounds]); return null; } // Renders the transmitter's current position as a "blue dot" (marker + soft // accuracy halo), updated in place as new status messages arrive. function LiveMarker({ status, onSelect }: { status: LiveStatus | null; onSelect: (point: MapPoint) => void }) { const map = useMap(); const markerRef = useRef(null); const haloRef = useRef(null); const statusRef = useRef(status); statusRef.current = status; const onSelectRef = useRef(onSelect); onSelectRef.current = onSelect; useEffect(() => { if (!map) { return; } if (!status) { markerRef.current?.setMap(null); haloRef.current?.setMap(null); markerRef.current = null; haloRef.current = null; return; } const position = { lat: status.lat, lng: status.lng }; if (!markerRef.current) { markerRef.current = new google.maps.Marker({ map, position, zIndex: 999, cursor: 'pointer', icon: { path: google.maps.SymbolPath.CIRCLE, scale: 8, fillColor: LIVE_COLOR, fillOpacity: 1, strokeColor: '#ffffff', strokeWeight: 2, }, }); markerRef.current.addListener('click', () => { if (statusRef.current) { onSelectRef.current(liveStatusToMapPoint(statusRef.current)); } }); haloRef.current = new google.maps.Circle({ map, center: position, radius: status.hAccuracy ?? 5, fillColor: LIVE_COLOR, fillOpacity: 0.15, strokeColor: LIVE_COLOR, strokeOpacity: 0.3, strokeWeight: 1, clickable: false, }); } else { markerRef.current.setPosition(position); haloRef.current?.setCenter(position); haloRef.current?.setRadius(status.hAccuracy ?? 5); } // Follow the transmitter as its position updates. map.panTo(position); }, [map, status]); useEffect( () => () => { markerRef.current?.setMap(null); haloRef.current?.setMap(null); }, [], ); return null; } function PointDetails({ point, onClose }: { point: MapPoint; onClose: () => void }) { return (
{pointDetailRows(point).map(([label, value]) => ( ))}
{label} {value}
); } export default function GoogleJobMap({ points, liveStatus = null, fitBounds = true, heightPx = 480 }: JobMapProps) { const [selected, setSelected] = useState(null); if (!API_KEY) { return (
Set NEXT_PUBLIC_GOOGLE_MAPS_API_KEY in .env to enable the map.
); } // A point can be re-fetched (new object identity) between renders; keep the // InfoWindow anchored to the latest copy of the same point by id. The // liveStatus marker's "point" is synthesized fresh from liveStatus each // time — but only for the id it actually owns. Other callers (e.g. the // devices page's single current-position point) may also use a "live-" // prefixed id without ever passing a liveStatus prop, so that id alone // can't be used to decide which source to read from. const selectedCurrent = (() => { if (!selected) { return null; } if (liveStatus && selected.id === `live-${liveStatus.deviceId}`) { return liveStatusToMapPoint(liveStatus); } return points.find((p) => p.id === selected.id) ?? selected; })(); return (
setSelected(null)} > {selectedCurrent && setSelected(null)} />}
); }