@@ -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 © <a href="https://mesonet.agron.iastate.edu/">IEM/NWS</a>'
opacity = { 0.65 }
/ >
) ;
}
// ── 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 ` <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 ( {
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 ] ,
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 < string , { glyph : string ; fill : string ; label : string } > = {
accidentsAndIncidents : { glyph : "!" , fill : "#dc2626" , label : "Accident / incident" } ,
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" } ,
} ;
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 ;
return L . divIcon ( {
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 ] ,
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 {
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 < 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() {
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 < Ny511Camera [ ] > ( [ ] ) ;
const pinnedIds = useMemo ( ( ) = > new Set ( pinned . map ( ( c ) = > c . id ) ) , [ pinned ] ) ;
const [ open , setOpen ] = useState < OpenCamera [ ] > ( [ ] ) ;
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 && < 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 ( ) } >
< Popup minWidth = { 260 } maxWidth = { 340 } >
< 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 . image_url && (
// 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 */ }
< img src = { ` ${ c . image_url } ?t= ${ Date . now ( ) } ` } alt = { ` Camera: ${ c . name } ` } className = "w-full rounded border border-line" loading = "lazy" / >
< / 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" >
Click the image to pin { c . video_url ? "the l ive feed" : "it" } to the map · 511 NY / NYSDOT
{ c . video_url ? "L ive feed" : "Snapshot" } · 511 NY / NYSDOT
< / div >
< / div >
< / Popup >
< / Marker >
) ) }
{ /* Players only exist while the overlay is on, so hiding it stops every stream. */ }
{ shown && pinned . map ( ( c ) = > (
{ /* Pinned p layers only exist while the overlay is on, so hiding it stops their streams . */ }
{ shown && open . filter ( ( o ) = > o . mode === "pin" ) . map ( ( { cam : c } ) = > (
< 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" >
< div className = "w-[192px]" >
< 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 >
< 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 >
< CameraFeed camera = { c } className = "block w-[192px] h-[108px] object-cover bg-black rounded-b" / >
< / div >
< / Tooltip >
< / 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() {
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 && < 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 start = fmtLocal ( e . start_local ) ;
const end = fmtLocal ( e . planned_end_local ) ;
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 } >
< div className = "space-y-1" >
< 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 [ 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 = []
< / FeatureGroup >
< / 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" >
< TileLayer
key = { radarEpoch }
url = "https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0r-900913/{z}/{x}/{y}.png"
attribution = 'Radar © <a href="https://mesonet.agron.iastate.edu/">IEM/NWS</a>'
opacity = { 0.65 }
/ >
< RadarLayer / >
< / LayersControl.Overlay >
< / LayersControl >
< / MapContainer >