Map: dock/pin cameras, 511 icon LOD, stop radar switching itself off
Build & Deploy / Build & push images (push) Successful in 4m12s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 28s
Build & Deploy / Deploy to VM (push) Successful in 1m31s
Build & Deploy / Report a failed deploy (push) Skipped

- DOT cameras can be pinned at the camera or docked in a strip over the
  map; up to 6 open at once, switchable between modes.
- Below zoom 14, 511 cameras/events bucket into a 40px world-pixel grid,
  one icon per cell with a count badge; click zooms in.
- Weather radar refreshed by remounting via `key`, and a remounted child of
  an unchecked LayersControl.Overlay isn't re-added to the map — radar went
  off every 5 min. Now refreshes in place with setUrl + cache-buster.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-09-27 19:22:07 -04:00
co-authored by Claude Opus 5.5
parent 6658c26fe0
commit 3a1dca9533
+170 -35
View File
@@ -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<L.TileLayer>(null);
useEffect(() => {
const id = setInterval(() => ref.current?.setUrl(`${RADAR_URL}?_=${Date.now()}`), RADAR_REFRESH_MS);
return () => clearInterval(id);
}, []);
return (
<TileLayer
ref={ref}
url={RADAR_URL}
attribution='Radar &copy; <a href="https://mesonet.agron.iastate.edu/">IEM/NWS</a>'
opacity={0.65}
/>
);
}
// ── 511NY traffic layers (server-26#183) ───────────────────────────────────── // ── 511NY traffic layers (server-26#183) ─────────────────────────────────────
// Overlay names double as the event keys for overlayadd/overlayremove. // Overlay names double as the event keys for overlayadd/overlayremove.
const OVERLAY_DOT_CAMERAS = "DOT Cameras"; const OVERLAY_DOT_CAMERAS = "DOT Cameras";
@@ -331,16 +355,24 @@ function useOverlayShown(map: L.Map, name: string): boolean {
return shown; 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 `<span style="position:absolute;top:-7px;right:-9px;min-width:14px;height:14px;padding:0 3px;border-radius:7px;background:#0f172a;color:#fff;border:1px solid #fff;font:700 9px/12px sans-serif;text-align:center;box-sizing:border-box">${label}</span>`;
}
function cameraIcon(count = 1): L.DivIcon {
return L.divIcon({ return L.divIcon({
className: "", className: "",
html: `<svg width="16" height="12" viewBox="0 0 16 12"><rect x="0.5" y="1.5" width="11" height="9" rx="2" fill="#1e3a5f" stroke="#93c5fd" stroke-width="1"/><circle cx="6" cy="6" r="2.5" fill="none" stroke="#93c5fd" stroke-width="1.25"/><polygon points="12,4 15.5,2 15.5,10 12,8" fill="#93c5fd"/></svg>`, html: `<div style="position:relative;width:16px;height:12px"><svg width="16" height="12" viewBox="0 0 16 12"><rect x="0.5" y="1.5" width="11" height="9" rx="2" fill="#1e3a5f" stroke="#93c5fd" stroke-width="1"/><circle cx="6" cy="6" r="2.5" fill="none" stroke="#93c5fd" stroke-width="1.25"/><polygon points="12,4 15.5,2 15.5,10 12,8" fill="#93c5fd"/></svg>${countBadge(count)}</div>`,
iconSize: [16, 12], iconSize: [16, 12],
iconAnchor: [8, 6], iconAnchor: [8, 6],
}); });
} }
// Shape + glyph per 511 event type, so the layer reads without colour alone. // 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<string, { glyph: string; fill: string; label: string }> = { const EVENT_STYLE: Record<string, { glyph: string; fill: string; label: string }> = {
accidentsAndIncidents: { glyph: "!", fill: "#dc2626", label: "Accident / incident" }, accidentsAndIncidents: { glyph: "!", fill: "#dc2626", label: "Accident / incident" },
closures: { glyph: "×", fill: "#ea580c", label: "Closure" }, closures: { glyph: "×", fill: "#ea580c", label: "Closure" },
@@ -349,17 +381,65 @@ const EVENT_STYLE: Record<string, { glyph: string; fill: string; label: string }
transitOperations: { glyph: "T", fill: "#0891b2", label: "Transit" }, transitOperations: { glyph: "T", fill: "#0891b2", label: "Transit" },
}; };
const EVENT_STYLE_OTHER = { glyph: "i", fill: "#6b7280", label: "Other" }; const EVENT_STYLE_OTHER = { glyph: "i", fill: "#6b7280", label: "Other" };
const EVENT_RANK = Object.keys(EVENT_STYLE);
const eventRank = (e: Ny511Event) => {
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; const st = EVENT_STYLE[type] ?? EVENT_STYLE_OTHER;
return L.divIcon({ return L.divIcon({
className: "", className: "",
html: `<svg width="16" height="16" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="${st.fill}" stroke="#000" stroke-width="1"/><text x="8" y="12" text-anchor="middle" font-size="11" font-weight="700" font-family="sans-serif" fill="#fff">${st.glyph}</text></svg>`, html: `<div style="position:relative;width:16px;height:16px"><svg width="16" height="16" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="${st.fill}" stroke="#000" stroke-width="1"/><text x="8" y="12" text-anchor="middle" font-size="11" font-weight="700" font-family="sans-serif" fill="#fff">${st.glyph}</text></svg>${countBadge(count)}</div>`,
iconSize: [16, 16], iconSize: [16, 16],
iconAnchor: [8, 8], 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<T> { 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<T extends { lat: number; lon: number }>(
items: T[], map: L.Map, zoom: number, rank: (t: T) => number = () => 0,
): LodCell<T>[] {
if (zoom >= LOD_FULL_ZOOM) return items.map((rep) => ({ rep, count: 1 }));
const cells = new Map<string, LodCell<T>>();
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 { function fmtLocal(iso: string | null): string | null {
if (!iso) return null; if (!iso) return null;
const d = new Date(iso); // naive ISO parses as browser-local; 511NY stamps are NY local 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 // Open cameras: each is either pinned (a small tile anchored at the camera, so
// tile anchored at the camera, so several views along one road can be read // several views along one road read against the map) or docked (a larger tile
// against the map. Oldest pin drops past the cap. // in a strip over the map, which stays put while you pan and survives the
const MAX_CAMERA_PINS = 6; // 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<HTMLDivElement>(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(
<div
ref={ref}
className="absolute z-[1002] left-3 right-3 top-12 md:top-auto md:bottom-8 md:left-[16rem] md:right-[12rem] flex flex-wrap-reverse justify-center gap-2 max-h-[calc(100%-8rem)] overflow-y-auto pointer-events-none"
>
{open.map((c) => (
<div key={c.id} className="pointer-events-auto w-[240px] bg-surface border border-line-strong rounded-md shadow-lg">
<div className="flex items-center gap-1 px-1.5 h-5">
<span className="flex-1 truncate text-[11px] leading-5 text-ink-2" title={c.name}>{c.name}</span>
<button type="button" onClick={() => onMode(c.id, "pin")} title="Pin to the map at the camera" aria-label={`Pin ${c.name} to the map`} className={camBtn}>⌖</button>
<button type="button" onClick={() => onClose(c.id)} aria-label={`Close ${c.name}`} className={camBtn}>×</button>
</div>
<CameraFeed camera={c} className="block w-[240px] h-[135px] object-cover bg-black rounded-b-md" />
</div>
))}
</div>,
map.getContainer(),
);
}
function DotCameraLayer() { function DotCameraLayer() {
const map = useMap(); const map = useMap();
const shown = useOverlayShown(map, OVERLAY_DOT_CAMERAS); const shown = useOverlayShown(map, OVERLAY_DOT_CAMERAS);
const zoom = useMapZoom(map);
const { data, error } = use511(map, "cameras", shown); const { data, error } = use511(map, "cameras", shown);
const [pinned, setPinned] = useState<Ny511Camera[]>([]); const [open, setOpen] = useState<OpenCamera[]>([]);
const pinnedIds = useMemo(() => new Set(pinned.map((c) => c.id)), [pinned]);
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(); 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 ( return (
<> <>
{shown && <FeedProblem map={map} label="DOT cameras" status={data.cameras_status} fetchError={error} />} {shown && <FeedProblem map={map} label="DOT cameras" status={data.cameras_status} fetchError={error} />}
{(data.cameras ?? []).filter((c) => !pinnedIds.has(c.id)).map((c) => ( {cells.map(({ rep: c, count }) => count > 1 ? (
<Marker key={`lod-${c.id}`} position={[c.lat, c.lon]} icon={cameraIcon(count)}
title={`${count} cameras — click to zoom in`}
eventHandlers={{ click: () => zoomInto(map, c.lat, c.lon) }} />
) : (
<Marker key={c.id} position={[c.lat, c.lon]} icon={cameraIcon()}> <Marker key={c.id} position={[c.lat, c.lon]} icon={cameraIcon()}>
<Popup minWidth={260} maxWidth={340}> <Popup minWidth={260} maxWidth={340}>
<div className="space-y-1"> <div className="space-y-1">
@@ -410,32 +541,38 @@ function DotCameraLayer() {
{c.roadway && <div className="text-xs text-ink-muted">{c.roadway}{c.direction && c.direction !== "Unknown" ? ` · ${c.direction}` : ""}</div>} {c.roadway && <div className="text-xs text-ink-muted">{c.roadway}{c.direction && c.direction !== "Unknown" ? ` · ${c.direction}` : ""}</div>}
{c.image_url && ( {c.image_url && (
// Popup content mounts on open, so the timestamp busts the cache per open. // Popup content mounts on open, so the timestamp busts the cache per open.
<button type="button" onClick={() => pin(c)} title="Pin this camera to the map" className="block w-full p-0 border-0 bg-transparent cursor-pointer"> <button type="button" onClick={() => show(c, "pin")} title="Pin this camera to the map" className="block w-full p-0 border-0 bg-transparent cursor-pointer">
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img src={`${c.image_url}?t=${Date.now()}`} alt={`Camera: ${c.name}`} className="w-full rounded border border-line" loading="lazy" /> <img src={`${c.image_url}?t=${Date.now()}`} alt={`Camera: ${c.name}`} className="w-full rounded border border-line" loading="lazy" />
</button> </button>
)} )}
<div className="flex gap-1.5 pt-0.5">
<button type="button" onClick={() => show(c, "pin")} className="flex-1 text-xs border border-line rounded px-2 py-1 hover:bg-raised">Pin to map</button>
<button type="button" onClick={() => show(c, "dock")} className="flex-1 text-xs border border-line rounded px-2 py-1 hover:bg-raised">Dock</button>
</div>
<div className="text-[10px] text-ink-muted"> <div className="text-[10px] text-ink-muted">
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
</div> </div>
</div> </div>
</Popup> </Popup>
</Marker> </Marker>
))} ))}
{/* Players only exist while the overlay is on, so hiding it stops every stream. */} {/* Pinned players only exist while the overlay is on, so hiding it stops their streams. */}
{shown && pinned.map((c) => ( {shown && open.filter((o) => o.mode === "pin").map(({ cam: c }) => (
<Marker key={`pin-${c.id}`} position={[c.lat, c.lon]} icon={cameraIcon()} zIndexOffset={300}> <Marker key={`pin-${c.id}`} position={[c.lat, c.lon]} icon={cameraIcon()} zIndexOffset={300}>
<Tooltip permanent interactive direction="top" offset={[0, -8]} opacity={1} className="drb-cam-pin"> <Tooltip permanent interactive direction="top" offset={[0, -8]} opacity={1} className="drb-cam-pin">
<div className="w-[192px]"> <div className="w-[192px]">
<div className="flex items-center gap-1 px-1.5 h-4"> <div className="flex items-center gap-1 px-1.5 h-4">
<span className="flex-1 truncate text-[10px] leading-4 text-ink-2" title={c.name}>{c.name}</span> <span className="flex-1 truncate text-[10px] leading-4 text-ink-2" title={c.name}>{c.name}</span>
<button type="button" onClick={() => unpin(c.id)} aria-label={`Unpin ${c.name}`} className="text-ink-muted hover:text-ink text-xs leading-none px-0.5">×</button> <button type="button" onClick={() => setMode(c.id, "dock")} title="Dock" aria-label={`Dock ${c.name}`} className={camBtn}>⇲</button>
<button type="button" onClick={() => close(c.id)} aria-label={`Close ${c.name}`} className={camBtn}>×</button>
</div> </div>
<CameraFeed camera={c} className="block w-[192px] h-[108px] object-cover bg-black rounded-b" /> <CameraFeed camera={c} className="block w-[192px] h-[108px] object-cover bg-black rounded-b" />
</div> </div>
</Tooltip> </Tooltip>
</Marker> </Marker>
))} ))}
<CameraDock map={map} open={open.filter((o) => o.mode === "dock").map((o) => o.cam)} onMode={setMode} onClose={close} />
</> </>
); );
} }
@@ -443,16 +580,26 @@ function DotCameraLayer() {
function TrafficEventLayer() { function TrafficEventLayer() {
const map = useMap(); const map = useMap();
const shown = useOverlayShown(map, OVERLAY_TRAFFIC_EVENTS); const shown = useOverlayShown(map, OVERLAY_TRAFFIC_EVENTS);
const zoom = useMapZoom(map);
const { data, error } = use511(map, "events", shown); const { data, error } = use511(map, "events", shown);
const cells = useMemo(() => lodCells(data.events ?? [], map, zoom, eventRank), [data.events, map, zoom]);
return ( return (
<> <>
{shown && <FeedProblem map={map} label="Traffic events" status={data.events_status} fetchError={error} />} {shown && <FeedProblem map={map} label="Traffic events" status={data.events_status} fetchError={error} />}
{(data.events ?? []).map((e: Ny511Event) => { {cells.map(({ rep: e, count }) => {
const zIndexOffset = e.type === "accidentsAndIncidents" ? 200 : 0;
if (count > 1) {
return (
<Marker key={`lod-${e.id}`} position={[e.lat, e.lon]} icon={trafficEventIcon(e.type, count)} zIndexOffset={zIndexOffset}
title={`${count} traffic events — click to zoom in`}
eventHandlers={{ click: () => zoomInto(map, e.lat, e.lon) }} />
);
}
const st = EVENT_STYLE[e.type] ?? EVENT_STYLE_OTHER; const st = EVENT_STYLE[e.type] ?? EVENT_STYLE_OTHER;
const start = fmtLocal(e.start_local); const start = fmtLocal(e.start_local);
const end = fmtLocal(e.planned_end_local); const end = fmtLocal(e.planned_end_local);
return ( return (
<Marker key={e.id} position={[e.lat, e.lon]} icon={trafficEventIcon(e.type)} zIndexOffset={e.type === "accidentsAndIncidents" ? 200 : 0}> <Marker key={e.id} position={[e.lat, e.lon]} icon={trafficEventIcon(e.type)} zIndexOffset={zIndexOffset}>
<Popup minWidth={220} maxWidth={320}> <Popup minWidth={220} maxWidth={320}>
<div className="space-y-1"> <div className="space-y-1">
<div className="font-semibold">{st.label}{e.subtype ? `: ${e.subtype}` : ""}</div> <div className="font-semibold">{st.label}{e.subtype ? `: ${e.subtype}` : ""}</div>
@@ -837,7 +984,6 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
const [mapInstance, setMapInstance] = useState<L.Map | null>(null); const [mapInstance, setMapInstance] = useState<L.Map | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [agoClock, setAgoClock] = useState(0); const [agoClock, setAgoClock] = useState(0);
const [radarEpoch, setRadarEpoch] = useState(() => Date.now());
const [aircraftShown, setAircraftShown] = useState(false); const [aircraftShown, setAircraftShown] = useState(false);
// The altitude key only belongs in the legend while the opt-in Aircraft // 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); 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 // eslint-disable-next-line react-hooks/exhaustive-deps
const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]); const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]);
@@ -997,14 +1137,9 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
</FeatureGroup> </FeatureGroup>
</LayersControl.Overlay> </LayersControl.Overlay>
{/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet; key forces remount on refresh */} {/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet */}
<LayersControl.Overlay name="Weather Radar"> <LayersControl.Overlay name="Weather Radar">
<TileLayer <RadarLayer />
key={radarEpoch}
url="https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0r-900913/{z}/{x}/{y}.png"
attribution='Radar &copy; <a href="https://mesonet.agron.iastate.edu/">IEM/NWS</a>'
opacity={0.65}
/>
</LayersControl.Overlay> </LayersControl.Overlay>
</LayersControl> </LayersControl>
</MapContainer> </MapContainer>