Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1a2721fbe | ||
|
|
c95498e7fe | ||
|
|
3347ced863 | ||
|
|
1a01497f4d | ||
|
|
012cca402a |
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) }),
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user