Compare commits

..
Author SHA1 Message Date
Logan CusanoandClaude Opus 5.5 c1a2721fbe Map: pin 511NY camera feeds as tiles on the map (#183)
Clicking a camera's snapshot pins a 192x108 tile anchored at the camera --
the live HLS stream when the camera has one (hls.js; Safari native), else the
snapshot refreshed every 60s, falling back to the snapshot if the stream dies.
Up to 6 pins, oldest dropped. Players exist only while the DOT Cameras overlay
is on, so hiding it stops every stream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:43:40 -04:00
Logan Cusano c95498e7fe Merge feat/511ny-layer: 511NY DOT cameras + traffic events (#183)
Build & Deploy / Build & push images (push) Successful in 4m20s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 31s
Build & Deploy / Deploy to VM (push) Successful in 1m40s
Build & Deploy / Report a failed deploy (push) Skipped
2026-09-27 14:27:32 -04:00
Logan CusanoandClaude Opus 5.5 3347ced863 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>
2026-09-27 14:26:38 -04:00
Logan CusanoandClaude Opus 5.5 1a01497f4d Merge feat/secondary-sdr-priority: secondary SDR priority (C2 + dashboard)
Build & Deploy / Build & push images (push) Successful in 5m47s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 35s
Build & Deploy / Deploy to VM (push) Successful in 1m36s
Build & Deploy / Report a failed deploy (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:14:46 -04:00
Logan CusanoandClaude Opus 5.5 012cca402a Secondary SDR panel: never present an unreported SDR count or run state as fact
QA (drb-qa-review) blockers: sdr_count defaulted to 1 for nodes that
never sent it, so the panel claimed 'reports 1 SDR (0 spare)'. It is now
None until reported, and the count is only quoted alongside a real
secondary_sdr_running report. Rows read 'Not reported' instead of
'Waiting for SDR' when the node hasn't said what's running (closes #187).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:14:42 -04:00
16 changed files with 659 additions and 9 deletions
+1
View File
@@ -26,6 +26,7 @@ NODE_OFFLINE_THRESHOLD=90
# Google Maps — for geocoding location strings extracted from transcripts # Google Maps — for geocoding location strings extracted from transcripts
# Enable "Geocoding API" in Cloud Console for this key # Enable "Geocoding API" in Cloud Console for this key
GOOGLE_MAPS_API_KEY= GOOGLE_MAPS_API_KEY=
NY511_API_KEY=
# OpenAI — for transcription (Whisper), intelligence extraction, embeddings, and summaries # OpenAI — for transcription (Whisper), intelligence extraction, embeddings, and summaries
OPENAI_API_KEY= OPENAI_API_KEY=
+3
View File
@@ -32,6 +32,9 @@ class Settings(BaseSettings):
# Google Maps (geocoding) # Google Maps (geocoding)
google_maps_api_key: Optional[str] = None google_maps_api_key: Optional[str] = None
# 511NY developer key (server-26#183). Optional: the API answered without one
# as of 2026-09-27, but its terms require a registered key.
ny511_api_key: Optional[str] = None
# Gemini (intelligence extraction, embeddings, incident summaries) # Gemini (intelligence extraction, embeddings, incident summaries)
gemini_api_key: Optional[str] = None gemini_api_key: Optional[str] = None
+1 -1
View File
@@ -114,7 +114,7 @@ class MQTTHandler:
"secondary_sdr_mode": payload.get("secondary_sdr_mode", "none"), "secondary_sdr_mode": payload.get("secondary_sdr_mode", "none"),
"secondary_sdr_priority": payload.get("secondary_sdr_priority", []), "secondary_sdr_priority": payload.get("secondary_sdr_priority", []),
"secondary_sdr_running": payload.get("secondary_sdr_running"), "secondary_sdr_running": payload.get("secondary_sdr_running"),
"sdr_count": payload.get("sdr_count", 1), "sdr_count": payload.get("sdr_count"), # None until reported, never a guessed 1
"enforce_override_timeout": payload.get("enforce_override_timeout", True), "enforce_override_timeout": payload.get("enforce_override_timeout", True),
"is_overridden": False, "is_overridden": False,
"override_system_id": None, "override_system_id": None,
+140
View File
@@ -0,0 +1,140 @@
"""511NY (NYSDOT) traffic cameras and events, cached in memory (server-26#183).
Public data, identical for every org, so it is NOT written to Firestore: the
statewide feeds are ~3k cameras and ~2.3k events, and re-writing them every
poll would be millions of writes a day for data nobody needs history of. The
cache is filled lazily on request and refreshed per TTL, so an idle deploy
makes no 511 calls at all.
A failed refresh keeps serving the last good data and reports the error and
its age to the caller -- an empty layer must never be the only symptom of a
dead feed (the AI-silent-failures lesson).
"""
import asyncio
import time
from datetime import datetime
from typing import Any, Dict, List, Optional
import httpx
from app.config import settings
from app.internal.logger import logger
_BASE = "https://511ny.org/api"
CAMERAS_TTL_S = 60 * 60 # camera list is near-static
EVENTS_TTL_S = 2 * 60 # accidents/closures change minute to minute
RETRY_AFTER_FAILURE_S = 60
_DESCRIPTION_MAX = 500
class _Feed:
def __init__(self, path: str, ttl_s: int, normalize):
self.path = path
self.ttl_s = ttl_s
self.normalize = normalize
self.items: List[Dict[str, Any]] = []
self.fetched_at: Optional[float] = None # epoch s of last SUCCESSFUL fetch
self.error: Optional[str] = None
self._next_attempt = 0.0
self._lock = asyncio.Lock()
async def get(self) -> "_Feed":
if time.time() < self._next_attempt:
return self
async with self._lock:
if time.time() < self._next_attempt:
return self # another request refreshed while we waited
try:
self.items = await _fetch(self.path, self.normalize)
self.fetched_at = time.time()
self.error = None
self._next_attempt = time.time() + self.ttl_s
except Exception as e:
self._next_attempt = time.time() + min(self.ttl_s, RETRY_AFTER_FAILURE_S)
self.error = f"{type(e).__name__}: {e}"[:300]
logger.warning(f"511NY {self.path} refresh failed, serving {len(self.items)} cached: {self.error}")
return self
async def _fetch(path: str, normalize) -> List[Dict[str, Any]]:
params = {"format": "json"}
if settings.ny511_api_key:
params["key"] = settings.ny511_api_key
async with httpx.AsyncClient(timeout=20.0) as client:
r = await client.get(f"{_BASE}/{path}", params=params)
r.raise_for_status()
raw = r.json()
if not isinstance(raw, list):
raise ValueError(f"expected a JSON list, got {type(raw).__name__}")
out = [n for n in (normalize(x) for x in raw) if n is not None]
if raw and not out:
# every record failed to normalize: the schema changed under us
raise ValueError(f"0 of {len(raw)} records parsed -- 511NY schema change?")
return out
def _coords(x: Dict[str, Any]) -> Optional[tuple]:
try:
lat, lon = float(x["Latitude"]), float(x["Longitude"])
except (KeyError, TypeError, ValueError):
return None
if lat == 0 and lon == 0:
return None
return lat, lon
def _local_iso(s: Any) -> Optional[str]:
"""511NY stamps are 'DD/MM/YYYY HH:MM:SS' New York local time. Returned as a
naive ISO string (no offset) -- display-only, never compared to UTC."""
if not s:
return None
try:
return datetime.strptime(s, "%d/%m/%Y %H:%M:%S").isoformat()
except (TypeError, ValueError):
return None
def normalize_camera(x: Dict[str, Any]) -> Optional[Dict[str, Any]]:
c = _coords(x)
if c is None or x.get("Disabled") or x.get("Blocked") or not x.get("ID"):
return None
return {
"id": x["ID"],
"lat": c[0],
"lon": c[1],
"name": x.get("Name") or "",
"roadway": x.get("RoadwayName") or "",
"direction": x.get("DirectionOfTravel") or "",
"image_url": x.get("Url"), # 511NY serves the current still at this URL
"video_url": x.get("VideoUrl"), # HLS playlist, when the camera streams
}
def normalize_event(x: Dict[str, Any]) -> Optional[Dict[str, Any]]:
c = _coords(x)
if c is None or not x.get("ID"):
return None
desc = x.get("Description") or ""
return {
"id": x["ID"],
"lat": c[0],
"lon": c[1],
"type": x.get("EventType") or "",
"subtype": x.get("EventSubType") or "",
"severity": x.get("Severity") or "",
"roadway": x.get("RoadwayName") or "",
"direction": x.get("DirectionOfTravel") or "",
"county": x.get("CountyName") or "",
"description": desc[:_DESCRIPTION_MAX] + ("…" if len(desc) > _DESCRIPTION_MAX else ""),
"start_local": _local_iso(x.get("StartDate")),
"planned_end_local": _local_iso(x.get("PlannedEndDate")),
"updated_local": _local_iso(x.get("LastUpdated")),
}
cameras = _Feed("getcameras", CAMERAS_TTL_S, normalize_camera)
events = _Feed("getevents", EVENTS_TTL_S, normalize_event)
def in_bbox(items: List[Dict[str, Any]], south: float, west: float, north: float, east: float) -> List[Dict[str, Any]]:
return [i for i in items if south <= i["lat"] <= north and west <= i["lon"] <= east]
+2 -1
View File
@@ -17,7 +17,7 @@ from app.internal.auth import (
require_node_service_or_firebase_token, require_node_service_or_firebase_token,
) )
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users
from app.routers import enrollment, media, org, waitlist, telemetry, replay from app.routers import enrollment, media, org, waitlist, telemetry, replay, traffic
from app.internal import dynsec from app.internal import dynsec
from app.internal import firestore as fstore from app.internal import firestore as fstore
@@ -127,6 +127,7 @@ app.include_router(incidents.router, dependencies=[Depends(require_service_or_fi
app.include_router(alerts.router, dependencies=[Depends(require_service_or_firebase_token)]) app.include_router(alerts.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(trips.router, dependencies=[Depends(require_service_or_firebase_token)]) app.include_router(trips.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(places.router, dependencies=[Depends(require_service_or_firebase_token)]) app.include_router(places.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(traffic.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(upload.router) # auth is per-node, handled inline app.include_router(upload.router) # auth is per-node, handled inline
app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin) app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin)
app.include_router(replay.router) # auth: admin only (every route spends or reads a replay run) app.include_router(replay.router) # auth: admin only (every route spends or reads a replay run)
+1 -1
View File
@@ -68,7 +68,7 @@ class NodeRecord(BaseModel):
# checkin, which is the source of truth; set via PATCH /nodes/{id}. # checkin, which is the source of truth; set via PATCH /nodes/{id}.
secondary_sdr_priority: List[str] = [] secondary_sdr_priority: List[str] = []
secondary_sdr_running: Optional[List[str]] = None # what the node reports actually running secondary_sdr_running: Optional[List[str]] = None # what the node reports actually running
sdr_count: int = 1 # self-reported by the node's checkin, best-effort sdr_count: Optional[int] = None # self-reported by the node's checkin; None = never reported
enforce_override_timeout: bool = True enforce_override_timeout: bool = True
is_overridden: bool = False is_overridden: bool = False
override_system_id: Optional[str] = None override_system_id: Optional[str] = None
+38
View File
@@ -0,0 +1,38 @@
import asyncio
from typing import Optional
from fastapi import APIRouter, HTTPException, Query
from app.internal import ny511
router = APIRouter(prefix="/traffic", tags=["traffic"])
# Per-layer cap per response. A statewide view is ~3k cameras; past this the
# map is unreadable anyway, and the client is told the list was cut.
MAX_ITEMS = 1500
def _feed_status(feed: "ny511._Feed") -> dict:
return {"fetched_at": feed.fetched_at, "error": feed.error}
@router.get("/511")
async def get_511(
south: float = Query(..., ge=-90, le=90),
west: float = Query(..., ge=-180, le=180),
north: float = Query(..., ge=-90, le=90),
east: float = Query(..., ge=-180, le=180),
layers: Optional[str] = Query("cameras,events", description="comma list: cameras, events"),
):
if south > north or west > east:
raise HTTPException(400, "bbox must satisfy south<=north and west<=east")
wanted = {s.strip() for s in (layers or "").split(",") if s.strip()}
feeds = {name: getattr(ny511, name) for name in ("cameras", "events") if name in wanted}
await asyncio.gather(*(f.get() for f in feeds.values()))
out: dict = {}
for name, feed in feeds.items():
hits = ny511.in_bbox(feed.items, south, west, north, east)
out[name] = hits[:MAX_ITEMS]
out[f"{name}_status"] = {**_feed_status(feed), "total_in_bbox": len(hits), "truncated": len(hits) > MAX_ITEMS}
return out
+98
View File
@@ -0,0 +1,98 @@
"""
server-26#183 — 511NY cameras/events layer.
The feed is scraped from a third party, so the tests pin the two failure shapes
that would otherwise look like "no traffic right now": a refresh error must keep
the last good data AND report the error, and a schema change (every record
unparseable) must be an error, not an empty list.
"""
import asyncio
from unittest.mock import AsyncMock, patch
from fastapi.testclient import TestClient
from app.main import app
from app.internal import ny511
from app.internal.auth import require_service_or_firebase_token
client = TestClient(app)
CAM = {"Latitude": 41.03, "Longitude": -73.76, "ID": "NYSDOT-1", "Name": "I-287 at Exit 5",
"DirectionOfTravel": "Unknown", "RoadwayName": "I-287", "Url": "https://511ny.org/map/Cctv/1",
"VideoUrl": None, "Disabled": False, "Blocked": False}
EVENT = {"Latitude": 41.019265, "Longitude": -73.797869, "ID": "TRANSCOM-1", "EventType": "roadwork",
"EventSubType": "Gas main repairs", "Severity": "Unknown", "RoadwayName": "NY 100",
"DirectionOfTravel": "Both directions", "CountyName": "Westchester", "Description": "x" * 900,
"StartDate": "28/09/2026 09:00:00", "PlannedEndDate": "", "LastUpdated": "26/09/2026 14:01:12"}
def setup_function():
app.dependency_overrides[require_service_or_firebase_token] = lambda: {"admin": True}
for feed in (ny511.cameras, ny511.events):
feed.items, feed.fetched_at, feed.error, feed._next_attempt = [], None, None, 0.0
def teardown_function():
app.dependency_overrides.pop(require_service_or_firebase_token, None)
def test_normalize_camera_skips_disabled_blocked_and_zero_coords():
assert ny511.normalize_camera(CAM)["image_url"] == "https://511ny.org/map/Cctv/1"
assert ny511.normalize_camera({**CAM, "Disabled": True}) is None
assert ny511.normalize_camera({**CAM, "Blocked": True}) is None
assert ny511.normalize_camera({**CAM, "Latitude": 0, "Longitude": 0}) is None
def test_normalize_event_parses_day_first_dates_and_truncates_description():
e = ny511.normalize_event(EVENT)
assert e["start_local"] == "2026-09-28T09:00:00" # DD/MM, not MM/DD
assert e["planned_end_local"] is None
assert len(e["description"]) == ny511._DESCRIPTION_MAX + 1
def test_failed_refresh_keeps_last_good_data_and_reports_error():
feed = ny511._Feed("getcameras", 3600, ny511.normalize_camera)
with patch.object(ny511, "_fetch", AsyncMock(return_value=[ny511.normalize_camera(CAM)])):
asyncio.run(feed.get())
feed._next_attempt = 0.0
with patch.object(ny511, "_fetch", AsyncMock(side_effect=RuntimeError("boom"))):
asyncio.run(feed.get())
assert len(feed.items) == 1 and feed.fetched_at is not None
assert "boom" in feed.error
assert feed._next_attempt - feed.fetched_at <= ny511.RETRY_AFTER_FAILURE_S + 5 # retries soon, not after the full TTL
def test_schema_change_is_an_error_not_an_empty_layer():
class Resp:
def raise_for_status(self): pass
def json(self): return [{"lat": 1, "lng": 2}] # renamed fields -> nothing parses
class Client:
async def __aenter__(self): return self
async def __aexit__(self, *a): pass
async def get(self, *a, **k): return Resp()
with patch.object(ny511.httpx, "AsyncClient", lambda **k: Client()):
try:
asyncio.run(ny511._fetch("getcameras", ny511.normalize_camera))
assert False, "expected a schema-change error"
except ValueError as e:
assert "schema" in str(e)
def test_endpoint_filters_to_bbox_and_reports_status():
far = {**CAM, "ID": "NYSDOT-2", "Latitude": 42.9, "Longitude": -78.8} # Buffalo
for feed, rows, norm in ((ny511.cameras, [CAM, far], ny511.normalize_camera), (ny511.events, [EVENT], ny511.normalize_event)):
feed.items = [norm(r) for r in rows]
feed.fetched_at, feed._next_attempt = 1.0, float("inf")
r = client.get("/traffic/511", params={"south": 40.9, "west": -74.0, "north": 41.4, "east": -73.4})
assert r.status_code == 200
body = r.json()
assert [c["id"] for c in body["cameras"]] == ["NYSDOT-1"]
assert body["cameras_status"] == {"fetched_at": 1.0, "error": None, "total_in_bbox": 1, "truncated": False}
assert len(body["events"]) == 1
def test_endpoint_rejects_inverted_bbox():
r = client.get("/traffic/511", params={"south": 41.4, "west": -74.0, "north": 40.9, "east": -73.4})
assert r.status_code == 400
+12
View File
@@ -177,6 +177,18 @@ html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
z-index: 0; z-index: 0;
} }
/* Pinned 511 camera tiles (MapView DotCameraLayer): strip Leaflet's tooltip
* padding and let the tile's own theme colours show, so the tile is no bigger
* than the video it holds. */
.leaflet-tooltip.drb-cam-pin {
padding: 0;
border-radius: 6px;
background: var(--surface);
border: 1px solid var(--line-strong);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
white-space: normal;
}
/* ── Form inputs ─────────────────────────────────────────────────────────── */ /* ── Form inputs ─────────────────────────────────────────────────────────── */
html:not(.dark) input:not([type="submit"]):not([type="button"]):not([type="reset"]), html:not(.dark) input:not([type="submit"]):not([type="button"]):not([type="reset"]),
html:not(.dark) select, html:not(.dark) select,
+64
View File
@@ -0,0 +1,64 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type HlsType from "hls.js";
import type { Ny511Camera } from "@/lib/types";
// 511NY stills are served with max-age=60, so refreshing faster buys nothing.
const SNAPSHOT_REFRESH_MS = 60_000;
/**
* One 511NY camera: the live HLS stream when the camera has one (NYSDOT's
* skyvdn hosts send Access-Control-Allow-Origin: *), otherwise its snapshot on
* a timer. A stream that fails falls back to the snapshot rather than a black box.
*/
export function CameraFeed({ camera, className }: { camera: Ny511Camera; className?: string }) {
const videoRef = useRef<HTMLVideoElement>(null);
const [videoFailed, setVideoFailed] = useState(false);
const [tick, setTick] = useState(() => Date.now());
const useVideo = !!camera.video_url && !videoFailed;
useEffect(() => {
if (useVideo) return;
const id = setInterval(() => setTick(Date.now()), SNAPSHOT_REFRESH_MS);
return () => clearInterval(id);
}, [useVideo]);
useEffect(() => {
const video = videoRef.current;
const src = camera.video_url;
if (!useVideo || !video || !src) return;
let hls: HlsType | null = null;
let cancelled = false;
if (video.canPlayType("application/vnd.apple.mpegurl")) {
video.src = src; // Safari/iOS play HLS natively
} else {
import("hls.js")
.then(({ default: Hls }) => {
if (cancelled) return;
if (!Hls.isSupported()) { setVideoFailed(true); return; }
hls = new Hls({ maxBufferLength: 10, backBufferLength: 0 });
hls.on(Hls.Events.ERROR, (_evt, data) => { if (data.fatal) setVideoFailed(true); });
hls.loadSource(src);
hls.attachMedia(video);
})
.catch(() => setVideoFailed(true));
}
return () => {
cancelled = true;
hls?.destroy();
video.removeAttribute("src");
video.load(); // stop the download, not just the playback
};
}, [useVideo, camera.video_url]);
if (useVideo) {
return <video ref={videoRef} muted autoPlay playsInline className={className} onError={() => setVideoFailed(true)} />;
}
if (!camera.image_url) {
return <div className={`${className ?? ""} flex items-center justify-center text-[10px] text-ink-muted`}>No image</div>;
}
// eslint-disable-next-line @next/next/no-img-element
return <img src={`${camera.image_url}?t=${tick}`} alt={`Camera: ${camera.name}`} className={className} />;
}
+171
View File
@@ -21,6 +21,9 @@ import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import { useAircraft } from "@/lib/useAircraft"; import { useAircraft } from "@/lib/useAircraft";
import { useAircraftTrail } from "@/lib/useAircraftTrail"; import { useAircraftTrail } from "@/lib/useAircraftTrail";
import { useVessels } from "@/lib/useVessels"; import { useVessels } from "@/lib/useVessels";
import { use511 } from "@/lib/use511";
import type { Ny511Camera, Ny511Event, Ny511FeedStatus } from "@/lib/types";
import { CameraFeed } from "@/components/CameraFeed";
// ── Leaflet icon fix ────────────────────────────────────────────────────────── // ── Leaflet icon fix ──────────────────────────────────────────────────────────
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl; delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl;
@@ -310,6 +313,162 @@ 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(),
);
}
// 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;
function DotCameraLayer() {
const map = useMap();
const shown = useOverlayShown(map, OVERLAY_DOT_CAMERAS);
const { data, error } = use511(map, "cameras", shown);
const [pinned, setPinned] = useState<Ny511Camera[]>([]);
const pinnedIds = useMemo(() => new Set(pinned.map((c) => c.id)), [pinned]);
const pin = (c: Ny511Camera) => {
map.closePopup();
setPinned((prev) => (prev.some((p) => p.id === c.id) ? prev : [...prev, c].slice(-MAX_CAMERA_PINS)));
};
const unpin = (id: string) => setPinned((prev) => prev.filter((p) => p.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) => (
<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.
<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">
{/* 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="text-[10px] text-ink-muted">
Click the image to pin {c.video_url ? "the live feed" : "it"} to the map · 511NY / NYSDOT
</div>
</div>
</Popup>
</Marker>
))}
{/* Players only exist while the overlay is on, so hiding it stops every stream. */}
{shown && pinned.map((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>
</div>
<CameraFeed camera={c} className="block w-[192px] h-[108px] object-cover bg-black rounded-b" />
</div>
</Tooltip>
</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 { function nodeFanIcon(members: NodeRecord[]): L.DivIcon {
const n = members.length; const n = members.length;
const CARD = 13; const CARD = 13;
@@ -826,6 +985,18 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
</FeatureGroup> </FeatureGroup>
</LayersControl.Overlay> </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 */} {/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet; key forces remount on refresh */}
<LayersControl.Overlay name="Weather Radar"> <LayersControl.Overlay name="Weather Radar">
<TileLayer <TileLayer
@@ -22,6 +22,9 @@ function rowsFrom(priority: string[]): Row[] {
export function SecondarySdrPriority({ node, canEdit }: { node: NodeRecord; canEdit: boolean }) { export function SecondarySdrPriority({ node, canEdit }: { node: NodeRecord; canEdit: boolean }) {
const priority = node.secondary_sdr_priority ?? []; const priority = node.secondary_sdr_priority ?? [];
// null/absent = the node has never reported (older firmware, container down
// at checkin): unknown, not "nothing running" (server-26#187).
const reported = node.secondary_sdr_running != null;
const running = node.secondary_sdr_running ?? []; const running = node.secondary_sdr_running ?? [];
const [rows, setRows] = useState<Row[]>(() => rowsFrom(priority)); const [rows, setRows] = useState<Row[]>(() => rowsFrom(priority));
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
@@ -62,8 +65,9 @@ export function SecondarySdrPriority({ node, canEdit }: { node: NodeRecord; canE
} }
} }
const sdrCount = node.sdr_count ?? 1; // Only quote a count the node actually sent alongside its running list; a
const spare = Math.max(sdrCount - 1, 0); // bare sdr_count may be the Firestore default, not a report.
const sdrCount = reported ? node.sdr_count : undefined;
let rank = 0; let rank = 0;
return ( return (
@@ -71,13 +75,24 @@ export function SecondarySdrPriority({ node, canEdit }: { node: NodeRecord; canE
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-1">Secondary SDRs</h2> <h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-1">Secondary SDRs</h2>
<p className="text-xs text-gray-500 font-mono mb-3"> <p className="text-xs text-gray-500 font-mono mb-3">
OP25 always keeps its own SDR. Every other SDR runs the next enabled item, top first. OP25 always keeps its own SDR. Every other SDR runs the next enabled item, top first.
{" "}This node reports {sdrCount} SDR{sdrCount === 1 ? "" : "s"} ({spare} spare). {" "}
{sdrCount != null
? `This node reports ${sdrCount} SDR${sdrCount === 1 ? "" : "s"} (${Math.max(sdrCount - 1, 0)} spare).`
: "This node hasn't reported its SDRs yet."}
</p> </p>
<div className="bg-gray-900 border border-gray-800 rounded-lg divide-y divide-gray-800 font-mono text-sm"> <div className="bg-gray-900 border border-gray-800 rounded-lg divide-y divide-gray-800 font-mono text-sm">
{rows.map((row, i) => { {rows.map((row, i) => {
const meta = MODES.find((x) => x.mode === row.mode)!; const meta = MODES.find((x) => x.mode === row.mode)!;
const isRunning = running.includes(row.mode); const isRunning = running.includes(row.mode);
const state = !row.enabled ? "Off" : dirty ? "Unsaved" : isRunning ? "Running" : "Waiting for SDR"; const state = !row.enabled
? "Off"
: dirty
? "Unsaved"
: !reported
? "Not reported"
: isRunning
? "Running"
: "Waiting for SDR";
return ( return (
<div key={row.mode} className="flex items-center gap-3 px-4 py-2.5"> <div key={row.mode} className="flex items-center gap-3 px-4 py-2.5">
<span className="w-4 text-right text-gray-600 text-xs">{row.enabled ? ++rank : ""}</span> <span className="w-4 text-right text-gray-600 text-xs">{row.enabled ? ++rank : ""}</span>
+7 -1
View File
@@ -1,5 +1,5 @@
import { auth } from "@/lib/firebase"; import { auth } from "@/lib/firebase";
import type { AreaContext, TalkgroupPending } from "@/lib/types"; import type { AreaContext, Ny511Response, TalkgroupPending } from "@/lib/types";
const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000"; const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000";
@@ -370,6 +370,12 @@ export const c2api = {
revokeEnrollmentToken: (tokenId: string) => revokeEnrollmentToken: (tokenId: string) =>
request(`/org/enrollment-tokens/${tokenId}`, { method: "DELETE" }), request(`/org/enrollment-tokens/${tokenId}`, { method: "DELETE" }),
// 511NY cameras/events inside a map bbox (server-26#183)
get511: (bbox: { south: number; west: number; north: number; east: number }, layers: string) =>
request<Ny511Response>(`/traffic/511?${new URLSearchParams({
south: String(bbox.south), west: String(bbox.west), north: String(bbox.north), east: String(bbox.east), layers,
})}`),
// Public waitlist — no auth, see routers/waitlist.py // Public waitlist — no auth, see routers/waitlist.py
joinWaitlist: (body: { email: string; org_name?: string; note?: string }) => joinWaitlist: (body: { email: string; org_name?: string; note?: string }) =>
request<{ ok: boolean }>("/waitlist", { method: "POST", body: JSON.stringify(body) }), request<{ ok: boolean }>("/waitlist", { method: "POST", body: JSON.stringify(body) }),
+44 -1
View File
@@ -58,7 +58,7 @@ export interface NodeRecord {
secondary_sdr_priority?: string[]; secondary_sdr_priority?: string[];
/** What the node's last checkin reported actually running. */ /** What the node's last checkin reported actually running. */
secondary_sdr_running?: string[] | null; secondary_sdr_running?: string[] | null;
sdr_count?: number; sdr_count?: number | null; // null = never reported
enforce_override_timeout?: boolean; enforce_override_timeout?: boolean;
is_overridden?: boolean; is_overridden?: boolean;
override_system_id?: string | null; override_system_id?: string | null;
@@ -98,6 +98,49 @@ export interface VesselTrack {
last_seen: string; last_seen: string;
} }
// 511NY traffic layer (server-26#183) — GET /traffic/511, cached server-side,
// not Firestore. *_local times are New York local, no offset: display only.
export interface Ny511Camera {
id: string;
lat: number;
lon: number;
name: string;
roadway: string;
direction: string;
image_url: string | null;
video_url: string | null;
}
export interface Ny511Event {
id: string;
lat: number;
lon: number;
type: string;
subtype: string;
severity: string;
roadway: string;
direction: string;
county: string;
description: string;
start_local: string | null;
planned_end_local: string | null;
updated_local: string | null;
}
export interface Ny511FeedStatus {
fetched_at: number | null; // epoch seconds of the last successful 511NY fetch
error: string | null; // set when the latest refresh failed (data is then stale)
total_in_bbox: number;
truncated: boolean;
}
export interface Ny511Response {
cameras?: Ny511Camera[];
cameras_status?: Ny511FeedStatus;
events?: Ny511Event[];
events_status?: Ny511FeedStatus;
}
export interface VocabularyPendingTerm { export interface VocabularyPendingTerm {
term: string; term: string;
source: "induction" | "correction"; source: "induction" | "correction";
+57
View File
@@ -0,0 +1,57 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type L from "leaflet";
import { c2api } from "@/lib/c2api";
import type { Ny511Response } from "@/lib/types";
// 511NY cameras/events for the current map view (server-26#183). Unlike
// useAircraft/useVessels this is not a Firestore listener: the data is public
// and statewide, so c2-core caches it in memory and serves a bbox slice.
// Fetches only while `enabled` (the overlay is on), on pan/zoom (debounced),
// and on a poll matching the server's events TTL.
const POLL_MS = 2 * 60 * 1000;
const MOVE_DEBOUNCE_MS = 400;
const BBOX_PAD = 0.2; // fetch a little past the edges so small pans don't blank the layer
export function use511(map: L.Map, layers: "cameras" | "events", enabled: boolean) {
const [data, setData] = useState<Ny511Response>({});
const [error, setError] = useState<string | null>(null);
const seq = useRef(0);
useEffect(() => {
if (!enabled) { setData({}); return; }
let debounce: ReturnType<typeof setTimeout> | undefined;
const load = async () => {
const b = map.getBounds().pad(BBOX_PAD);
const mine = ++seq.current;
try {
const res = await c2api.get511(
{ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() },
layers,
);
if (mine !== seq.current) return; // a newer pan's response wins
setData(res);
setError(null);
} catch (e) {
if (mine !== seq.current) return;
console.error("use511:", e);
setError(e instanceof Error ? e.message : String(e));
}
};
const onMove = () => { clearTimeout(debounce); debounce = setTimeout(load, MOVE_DEBOUNCE_MS); };
load();
map.on("moveend", onMove);
const poll = setInterval(load, POLL_MS);
return () => {
map.off("moveend", onMove);
clearTimeout(debounce);
clearInterval(poll);
seq.current++; // drop any in-flight response
};
}, [map, layers, enabled]);
return { data, error };
}
+1
View File
@@ -13,6 +13,7 @@
"react": "^18.3.0", "react": "^18.3.0",
"react-dom": "^18.3.0", "react-dom": "^18.3.0",
"firebase": "^10.12.0", "firebase": "^10.12.0",
"hls.js": "^1.5.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"react-leaflet": "^4.2.1", "react-leaflet": "^4.2.1",
"react-markdown": "^9.0.1" "react-markdown": "^9.0.1"