diff --git a/drb-frontend/components/MapView.tsx b/drb-frontend/components/MapView.tsx index 2cafd44..bc2e5c4 100644 --- a/drb-frontend/components/MapView.tsx +++ b/drb-frontend/components/MapView.tsx @@ -313,6 +313,30 @@ function VesselLayer() { ); } +// ── Weather radar ──────────────────────────────────────────────────────────── +// IEM tiles are static once loaded, so refresh them in place with a +// cache-buster. Never remount the layer (e.g. via `key`) to refresh it: a +// remounted child of an unchecked LayersControl.Overlay is re-registered with +// the control but not re-added to the map, which switched radar off every 5 min. +const RADAR_URL = "https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0r-900913/{z}/{x}/{y}.png"; +const RADAR_REFRESH_MS = 5 * 60 * 1000; + +function RadarLayer() { + const ref = useRef(null); + useEffect(() => { + const id = setInterval(() => ref.current?.setUrl(`${RADAR_URL}?_=${Date.now()}`), RADAR_REFRESH_MS); + return () => clearInterval(id); + }, []); + return ( + + ); +} + // ── 511NY traffic layers (server-26#183) ───────────────────────────────────── // Overlay names double as the event keys for overlayadd/overlayremove. const OVERLAY_DOT_CAMERAS = "DOT Cameras"; @@ -331,16 +355,24 @@ function useOverlayShown(map: L.Map, name: string): boolean { return shown; } -function cameraIcon(): L.DivIcon { +/** Small count badge for an LOD cluster icon; empty when the marker stands alone. */ +function countBadge(count: number): string { + if (count < 2) return ""; + const label = count > 99 ? "99+" : String(count); + return `${label}`; +} + +function cameraIcon(count = 1): L.DivIcon { return L.divIcon({ className: "", - html: ``, + html: `
${countBadge(count)}
`, iconSize: [16, 12], iconAnchor: [8, 6], }); } // Shape + glyph per 511 event type, so the layer reads without colour alone. +// Order is LOD priority: when events share a cell, the earliest type represents it. const EVENT_STYLE: Record = { accidentsAndIncidents: { glyph: "!", fill: "#dc2626", label: "Accident / incident" }, closures: { glyph: "×", fill: "#ea580c", label: "Closure" }, @@ -349,17 +381,65 @@ const EVENT_STYLE: Record { + const r = EVENT_RANK.indexOf(e.type); + return r < 0 ? EVENT_RANK.length : r; +}; -function trafficEventIcon(type: string): L.DivIcon { +function trafficEventIcon(type: string, count = 1): L.DivIcon { const st = EVENT_STYLE[type] ?? EVENT_STYLE_OTHER; return L.divIcon({ className: "", - html: `${st.glyph}`, + html: `
${st.glyph}${countBadge(count)}
`, iconSize: [16, 16], iconAnchor: [8, 8], }); } +// ── 511 level of detail ────────────────────────────────────────────────────── +// Zoomed out, a county's worth of cameras/events buries the map. Below +// LOD_FULL_ZOOM, markers are bucketed into a grid of LOD_CELL_PX screen pixels +// and each cell draws one representative with a count badge; clicking a +// cluster zooms in on it. The grid is in world pixels (map.project), so it +// only changes on zoom — panning never reshuffles which marker represents a cell. +const LOD_CELL_PX = 40; +const LOD_FULL_ZOOM = 14; + +interface LodCell { rep: T; count: number } + +function useMapZoom(map: L.Map): number { + const [zoom, setZoom] = useState(() => map.getZoom()); + useEffect(() => { + const h = () => setZoom(map.getZoom()); + map.on("zoomend", h); + return () => { map.off("zoomend", h); }; + }, [map]); + return zoom; +} + +function lodCells( + items: T[], map: L.Map, zoom: number, rank: (t: T) => number = () => 0, +): LodCell[] { + if (zoom >= LOD_FULL_ZOOM) return items.map((rep) => ({ rep, count: 1 })); + const cells = new Map>(); + for (const it of items) { + const p = map.project([it.lat, it.lon], zoom); + const key = `${Math.floor(p.x / LOD_CELL_PX)}:${Math.floor(p.y / LOD_CELL_PX)}`; + const cell = cells.get(key); + if (!cell) cells.set(key, { rep: it, count: 1 }); + else { + cell.count++; + if (rank(it) < rank(cell.rep)) cell.rep = it; + } + } + return Array.from(cells.values()); +} + +function zoomInto(map: L.Map, lat: number, lon: number) { + map.setView([lat, lon], Math.min(map.getZoom() + 2, LOD_FULL_ZOOM)); +} + function fmtLocal(iso: string | null): string | null { if (!iso) return null; const d = new Date(iso); // naive ISO parses as browser-local; 511NY stamps are NY local @@ -381,28 +461,79 @@ function FeedProblem({ map, label, status, fetchError }: { map: L.Map; label: st ); } -// Pinned cameras: clicking a camera's snapshot pins its live feed as a small -// tile anchored at the camera, so several views along one road can be read -// against the map. Oldest pin drops past the cap. -const MAX_CAMERA_PINS = 6; +// Open cameras: each is either pinned (a small tile anchored at the camera, so +// several views along one road read against the map) or docked (a larger tile +// in a strip over the map, which stays put while you pan and survives the +// overlay being switched off). Oldest camera drops past the cap. +const MAX_OPEN_CAMERAS = 6; +type CameraMode = "pin" | "dock"; +interface OpenCamera { cam: Ny511Camera; mode: CameraMode } + +const camBtn = "text-ink-muted hover:text-ink text-xs leading-none px-0.5"; + +function CameraDock({ map, open, onMode, onClose }: { + map: L.Map; + open: Ny511Camera[]; + onMode: (id: string, mode: CameraMode) => void; + onClose: (id: string) => void; +}) { + const ref = useRef(null); + useEffect(() => { + // Portaled into the Leaflet container — keep clicks/scrolls off the map. + if (!ref.current) return; + L.DomEvent.disableClickPropagation(ref.current); + L.DomEvent.disableScrollPropagation(ref.current); + }, [open.length > 0]); // eslint-disable-line react-hooks/exhaustive-deps + if (!open.length) return null; + return createPortal( +
+ {open.map((c) => ( +
+
+ {c.name} + + +
+ +
+ ))} +
, + map.getContainer(), + ); +} function DotCameraLayer() { const map = useMap(); const shown = useOverlayShown(map, OVERLAY_DOT_CAMERAS); + const zoom = useMapZoom(map); const { data, error } = use511(map, "cameras", shown); - const [pinned, setPinned] = useState([]); - const pinnedIds = useMemo(() => new Set(pinned.map((c) => c.id)), [pinned]); + const [open, setOpen] = useState([]); - const pin = (c: Ny511Camera) => { + const pinnedIds = useMemo(() => new Set(open.filter((o) => o.mode === "pin").map((o) => o.cam.id)), [open]); + const cells = useMemo( + () => lodCells((data.cameras ?? []).filter((c) => !pinnedIds.has(c.id)), map, zoom), + [data.cameras, pinnedIds, map, zoom], + ); + + const show = (cam: Ny511Camera, mode: CameraMode) => { map.closePopup(); - setPinned((prev) => (prev.some((p) => p.id === c.id) ? prev : [...prev, c].slice(-MAX_CAMERA_PINS))); + setOpen((prev) => [...prev.filter((o) => o.cam.id !== cam.id), { cam, mode }].slice(-MAX_OPEN_CAMERAS)); }; - const unpin = (id: string) => setPinned((prev) => prev.filter((p) => p.id !== id)); + const setMode = (id: string, mode: CameraMode) => + setOpen((prev) => prev.map((o) => (o.cam.id === id ? { ...o, mode } : o))); + const close = (id: string) => setOpen((prev) => prev.filter((o) => o.cam.id !== id)); return ( <> {shown && } - {(data.cameras ?? []).filter((c) => !pinnedIds.has(c.id)).map((c) => ( + {cells.map(({ rep: c, count }) => count > 1 ? ( + zoomInto(map, c.lat, c.lon) }} /> + ) : (
@@ -410,32 +541,38 @@ function DotCameraLayer() { {c.roadway &&
{c.roadway}{c.direction && c.direction !== "Unknown" ? ` · ${c.direction}` : ""}
} {c.image_url && ( // Popup content mounts on open, so the timestamp busts the cache per open. - )} +
+ + +
- Click the image to pin {c.video_url ? "the live feed" : "it"} to the map · 511NY / NYSDOT + {c.video_url ? "Live feed" : "Snapshot"} · 511NY / NYSDOT
))} - {/* Players only exist while the overlay is on, so hiding it stops every stream. */} - {shown && pinned.map((c) => ( + {/* Pinned players only exist while the overlay is on, so hiding it stops their streams. */} + {shown && open.filter((o) => o.mode === "pin").map(({ cam: c }) => (
{c.name} - + +
))} + o.mode === "dock").map((o) => o.cam)} onMode={setMode} onClose={close} /> ); } @@ -443,16 +580,26 @@ function DotCameraLayer() { function TrafficEventLayer() { const map = useMap(); const shown = useOverlayShown(map, OVERLAY_TRAFFIC_EVENTS); + const zoom = useMapZoom(map); const { data, error } = use511(map, "events", shown); + const cells = useMemo(() => lodCells(data.events ?? [], map, zoom, eventRank), [data.events, map, zoom]); return ( <> {shown && } - {(data.events ?? []).map((e: Ny511Event) => { + {cells.map(({ rep: e, count }) => { + const zIndexOffset = e.type === "accidentsAndIncidents" ? 200 : 0; + if (count > 1) { + return ( + zoomInto(map, e.lat, e.lon) }} /> + ); + } const st = EVENT_STYLE[e.type] ?? EVENT_STYLE_OTHER; const start = fmtLocal(e.start_local); const end = fmtLocal(e.planned_end_local); return ( - +
{st.label}{e.subtype ? `: ${e.subtype}` : ""}
@@ -837,7 +984,6 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = [] const [mapInstance, setMapInstance] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); const [agoClock, setAgoClock] = useState(0); - const [radarEpoch, setRadarEpoch] = useState(() => Date.now()); const [aircraftShown, setAircraftShown] = useState(false); // The altitude key only belongs in the legend while the opt-in Aircraft @@ -859,12 +1005,6 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = [] return () => clearInterval(id); }, []); - // Radar tiles are static once loaded — force remount every 5 min to refresh - useEffect(() => { - const id = setInterval(() => setRadarEpoch(Date.now()), 5 * 60 * 1000); - return () => clearInterval(id); - }, []); - // eslint-disable-next-line react-hooks/exhaustive-deps const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]); @@ -997,14 +1137,9 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = [] - {/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet; key forces remount on refresh */} + {/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet */} - +