/// import { APIProvider, InfoWindow, Map as GoogleMap, useMap } from '@vis.gl/react-google-maps'; import { useEffect, useRef, useState } from 'react'; import { JobMapProps, MapPoint, pointDetailRows, UTILITY_COLORS } from '../types'; const API_KEY = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || ''; 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(); } // 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 fittedCountRef = useRef(0); 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); } } if (fitBounds && points.length !== fittedCountRef.current) { fittedCountRef.current = points.length; 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; } function PointDetails({ point, onClose }: { point: MapPoint; onClose: () => void }) { return (
{pointDetailRows(point).map(([label, value]) => ( ))}
{label} {value}
); } export default function GoogleJobMap({ points, 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. const selectedCurrent = selected ? (points.find((p) => p.id === selected.id) ?? selected) : null; return (
setSelected(null)} > {selectedCurrent && setSelected(null)} />}
); }