511NY DOT cameras + traffic events map layers (#183)
GET /traffic/511 serves a bbox slice of the statewide 511NY camera and event feeds from an in-memory cache (cameras 1h, events 2m TTL, fetched lazily) -- public data, so no Firestore writes. A failed refresh keeps the last good data and reports the error; a schema change (nothing parses) is an error, not an empty layer. Frontend: opt-in "DOT Cameras" and "Traffic Events" overlays that fetch only while shown, with an on-map notice when the feed is down or stale. NY511_API_KEY is optional in config (the API answers without one today; the terms require a registered key). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1a01497f4d
commit
3347ced863
@@ -21,6 +21,8 @@ import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||
import { useAircraft } from "@/lib/useAircraft";
|
||||
import { useAircraftTrail } from "@/lib/useAircraftTrail";
|
||||
import { useVessels } from "@/lib/useVessels";
|
||||
import { use511 } from "@/lib/use511";
|
||||
import type { Ny511Event, Ny511FeedStatus } from "@/lib/types";
|
||||
|
||||
// ── Leaflet icon fix ──────────────────────────────────────────────────────────
|
||||
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl;
|
||||
@@ -310,6 +312,130 @@ function VesselLayer() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── 511NY traffic layers (server-26#183) ─────────────────────────────────────
|
||||
// Overlay names double as the event keys for overlayadd/overlayremove.
|
||||
const OVERLAY_DOT_CAMERAS = "DOT Cameras";
|
||||
const OVERLAY_TRAFFIC_EVENTS = "Traffic Events";
|
||||
|
||||
/** True while the named LayersControl overlay is checked. Overlays start unchecked. */
|
||||
function useOverlayShown(map: L.Map, name: string): boolean {
|
||||
const [shown, setShown] = useState(false);
|
||||
useEffect(() => {
|
||||
const on = (e: L.LayersControlEvent) => e.name === name && setShown(true);
|
||||
const off = (e: L.LayersControlEvent) => e.name === name && setShown(false);
|
||||
map.on("overlayadd", on);
|
||||
map.on("overlayremove", off);
|
||||
return () => { map.off("overlayadd", on); map.off("overlayremove", off); };
|
||||
}, [map, name]);
|
||||
return shown;
|
||||
}
|
||||
|
||||
function cameraIcon(): 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>`,
|
||||
iconSize: [16, 12],
|
||||
iconAnchor: [8, 6],
|
||||
});
|
||||
}
|
||||
|
||||
// Shape + glyph per 511 event type, so the layer reads without colour alone.
|
||||
const EVENT_STYLE: Record<string, { glyph: string; fill: string; label: string }> = {
|
||||
accidentsAndIncidents: { glyph: "!", fill: "#dc2626", label: "Accident / incident" },
|
||||
closures: { glyph: "×", fill: "#ea580c", label: "Closure" },
|
||||
roadwork: { glyph: "W", fill: "#ca8a04", label: "Roadwork" },
|
||||
specialEvents: { glyph: "E", fill: "#7c3aed", label: "Special event" },
|
||||
transitOperations: { glyph: "T", fill: "#0891b2", label: "Transit" },
|
||||
};
|
||||
const EVENT_STYLE_OTHER = { glyph: "i", fill: "#6b7280", label: "Other" };
|
||||
|
||||
function trafficEventIcon(type: string): 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>`,
|
||||
iconSize: [16, 16],
|
||||
iconAnchor: [8, 8],
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
return isNaN(d.getTime()) ? null : d.toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
||||
}
|
||||
|
||||
/** Surfaces a dead or stale 511 feed on the map instead of an unexplained empty layer. */
|
||||
function FeedProblem({ map, label, status, fetchError }: { map: L.Map; label: string; status?: Ny511FeedStatus; fetchError: string | null }) {
|
||||
let msg: string | null = null;
|
||||
if (fetchError) msg = `${label}: could not reach server`;
|
||||
else if (status?.error) {
|
||||
const age = status.fetched_at ? `showing data from ${Math.round((Date.now() / 1000 - status.fetched_at) / 60)} min ago` : "no data yet";
|
||||
msg = `${label}: 511NY feed error, ${age}`;
|
||||
} else if (status?.truncated) msg = `${label}: showing ${1500} of ${status.total_in_bbox} — zoom in`;
|
||||
if (!msg) return null;
|
||||
return createPortal(
|
||||
<div className="absolute bottom-8 left-3 z-[1001] bg-surface/90 border border-line rounded px-2 py-1 text-xs text-ink-2 pointer-events-none">{msg}</div>,
|
||||
map.getContainer(),
|
||||
);
|
||||
}
|
||||
|
||||
function DotCameraLayer() {
|
||||
const map = useMap();
|
||||
const shown = useOverlayShown(map, OVERLAY_DOT_CAMERAS);
|
||||
const { data, error } = use511(map, "cameras", shown);
|
||||
return (
|
||||
<>
|
||||
{shown && <FeedProblem map={map} label="DOT cameras" status={data.cameras_status} fetchError={error} />}
|
||||
{(data.cameras ?? []).map((c) => (
|
||||
<Marker key={c.id} position={[c.lat, c.lon]} icon={cameraIcon()}>
|
||||
<Popup minWidth={260} maxWidth={340}>
|
||||
<div className="space-y-1">
|
||||
<div className="font-semibold">{c.name}</div>
|
||||
{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.
|
||||
// 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" />
|
||||
)}
|
||||
<div className="text-[10px] text-ink-muted">Snapshot: 511NY / NYSDOT</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TrafficEventLayer() {
|
||||
const map = useMap();
|
||||
const shown = useOverlayShown(map, OVERLAY_TRAFFIC_EVENTS);
|
||||
const { data, error } = use511(map, "events", shown);
|
||||
return (
|
||||
<>
|
||||
{shown && <FeedProblem map={map} label="Traffic events" status={data.events_status} fetchError={error} />}
|
||||
{(data.events ?? []).map((e: Ny511Event) => {
|
||||
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}>
|
||||
<Popup minWidth={220} maxWidth={320}>
|
||||
<div className="space-y-1">
|
||||
<div className="font-semibold">{st.label}{e.subtype ? `: ${e.subtype}` : ""}</div>
|
||||
<div className="text-xs text-ink-muted">{[e.roadway, e.direction, e.county].filter(Boolean).join(" · ")}</div>
|
||||
{(start || end) && <div className="text-xs">{start ? `From ${start}` : ""}{end ? ` until ${end}` : ""}</div>}
|
||||
<div className="text-xs whitespace-pre-line">{e.description}</div>
|
||||
<div className="text-[10px] text-ink-muted">511NY{e.updated_local ? ` · updated ${fmtLocal(e.updated_local)}` : ""}</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function nodeFanIcon(members: NodeRecord[]): L.DivIcon {
|
||||
const n = members.length;
|
||||
const CARD = 13;
|
||||
@@ -826,6 +952,18 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
||||
</FeatureGroup>
|
||||
</LayersControl.Overlay>
|
||||
|
||||
{/* Overlays: 511NY DOT cameras + traffic events (server-26#183), opt-in */}
|
||||
<LayersControl.Overlay name={OVERLAY_DOT_CAMERAS}>
|
||||
<FeatureGroup>
|
||||
<DotCameraLayer />
|
||||
</FeatureGroup>
|
||||
</LayersControl.Overlay>
|
||||
<LayersControl.Overlay name={OVERLAY_TRAFFIC_EVENTS}>
|
||||
<FeatureGroup>
|
||||
<TrafficEventLayer />
|
||||
</FeatureGroup>
|
||||
</LayersControl.Overlay>
|
||||
|
||||
{/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet; key forces remount on refresh */}
|
||||
<LayersControl.Overlay name="Weather Radar">
|
||||
<TileLayer
|
||||
|
||||
Reference in New Issue
Block a user