Live ops wall + pluggable camera providers with licence gating (server-26#198)
Backend (c2-core):
- app/internal/cameras/: provider interface, licence model, per-provider
catalog cache; one adapter per source in design-canvas/camera-sources.md.
Implemented: 511NY (legacy shape + tolerant XML inventory parser, 511x
endpoints UNVERIFIED), Caltrans, DelDOT, Ontario 511, DriveBC, Calgary,
TfL, NSW, Fintraffic. Stubs: NYC DOT, Westchester DPW, TRANSCOM, CTroads,
511NJ, Austin, TxDOT, MD CHART, WSDOT, Tallinn, Tarktee, Warendorf,
TrafficLand, Windy, EarthCam.
- Every provider defaults OFF (config/camera_providers). Gated licences
(agreement required / unverified / non-commercial / paid) need an explicit
acknowledgement stored with who/when and written to audit_log; the audit
write is a precondition. Stubs and prohibited sources cannot be enabled.
Ack is bound to the licence status it was given for.
- GET /cameras (signed-in), GET/PUT /admin/camera-providers (admin only).
- /traffic/511 follows the ny511 provider switch and returns the DAA credit
+ NYSDOT as-is disclaimer; 511NY event descriptions no longer truncated
("may not alter the substance of a report").
Frontend:
- LiveView becomes the ops wall: Region > Incident > Camera drill-down,
worst-first rail (MapView), KPIs, transcript ticker, scrubber incident
bands, auto-tour, deep links (#inc=&cam=&tour), Esc steps up.
- Incident panel: nearest cameras (path-weighted), fused situational context
(ADS-B, AIS, 511NY events, NEXRAD tile sample), units, call spine.
- MapView keeps every layer; adds Camera Viewsheds (approx.) overlay and a
nearest-camera layer for the selected incident.
- CameraFeed hygiene: hidden-tab/off-screen gating, 2 concurrent HLS cap,
last good frame + stale badge, frozen-frame signature, HLS stall watchdog,
provider attribution rendered on the imagery.
- Camera/event popups show source credit, update time and the disclaimer.
- Admin > Camera providers tab with licence status, health, gated confirm.
- Camera detection is not built (blocked on #199/#200); seam only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d57738a8f6
commit
aa5b71d853
@@ -27,6 +27,10 @@ NODE_OFFLINE_THRESHOLD=90
|
|||||||
# 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=
|
NY511_API_KEY=
|
||||||
|
# 511NY feed base URL from the DAA grant (default: legacy 511ny.org/api, dead since the 511x cutover)
|
||||||
|
NY511_API_BASE=
|
||||||
|
# Ontario 511 developer key (camera provider "ontario511"; providers default OFF)
|
||||||
|
ONTARIO511_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,9 +32,15 @@ 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
|
# 511NY developer key (server-26#183/#198). Issued with the NYSDOT Developer
|
||||||
# as of 2026-09-27, but its terms require a registered key.
|
# Access Agreement grant; the keyless legacy API is gone since the 511x cutover.
|
||||||
ny511_api_key: Optional[str] = None
|
ny511_api_key: Optional[str] = None
|
||||||
|
# Base URL for the 511NY feed. Defaults to the legacy API, which is dead since
|
||||||
|
# the 511x cutover (server-26#198); set it to whatever the DAA grant documents.
|
||||||
|
ny511_api_base: Optional[str] = None
|
||||||
|
# Camera providers (app/internal/cameras). Keys only; every provider is still
|
||||||
|
# OFF until an admin enables it, and licence-gated ones need an acknowledgement.
|
||||||
|
ontario511_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
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""Pluggable public-camera providers (live ops wall).
|
||||||
|
|
||||||
|
Every source in ``design-canvas/camera-sources.md`` is one adapter here, each
|
||||||
|
carrying its licence status from that catalog. Providers default OFF; the
|
||||||
|
licence gate and the admin toggle live in ``registry``.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
"""Provider interface, licence model and the per-provider catalog cache.
|
||||||
|
|
||||||
|
A provider is metadata (who runs the cameras, what their terms say, what
|
||||||
|
region they cover) plus one async ``fetch_catalog`` that returns normalised
|
||||||
|
camera records. Catalogs are public and identical for every org, so -- like
|
||||||
|
ny511.py -- they are cached in memory, fetched lazily on request, and never
|
||||||
|
written to Firestore. A failed refresh keeps serving the last good catalog and
|
||||||
|
reports the error and its age (the AI-silent-failures lesson: an empty layer
|
||||||
|
must never be the only symptom of a dead feed).
|
||||||
|
|
||||||
|
Camera detection (God's Eye View idea #5) is NOT built: it is blocked on
|
||||||
|
server-26#199 (NYSDOT's written answer on imagery analytics) and #200 (CISO
|
||||||
|
ruling on frame retention). ``CameraSignalSink`` is the seam it would plug into.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Dict, List, Optional, Protocol, Tuple
|
||||||
|
|
||||||
|
from app.internal.logger import logger
|
||||||
|
|
||||||
|
RETRY_AFTER_FAILURE_S = 60
|
||||||
|
DEFAULT_CATALOG_TTL_S = 15 * 60 # camera lists change rarely (GEV uses the same 15 min)
|
||||||
|
FETCH_TIMEOUT_S = 20.0
|
||||||
|
USER_AGENT = "DRB-c2-core/1.0 (+public camera catalog; contact via 511/agency programs)"
|
||||||
|
|
||||||
|
Camera = Dict[str, Any]
|
||||||
|
BBox = Tuple[float, float, float, float] # south, west, north, east
|
||||||
|
|
||||||
|
|
||||||
|
class Licence(str, Enum):
|
||||||
|
"""Licence status, as catalogued in design-canvas/camera-sources.md."""
|
||||||
|
COMMERCIAL_OK = "commercial_ok"
|
||||||
|
ATTRIBUTION_REQUIRED = "attribution_required" # commercial OK, credit must render with imagery
|
||||||
|
AGREEMENT_REQUIRED = "agreement_required" # signed agreement / written permission DRB does not hold
|
||||||
|
NON_COMMERCIAL = "non_commercial"
|
||||||
|
UNVERIFIED = "unverified" # no licence read first-hand
|
||||||
|
PAID = "paid" # needs a commercial contract
|
||||||
|
PROHIBITED = "prohibited" # terms forbid this use outright -- cannot be enabled
|
||||||
|
|
||||||
|
|
||||||
|
# Statuses that may only be enabled after an admin explicitly acknowledges the
|
||||||
|
# warning (recorded with who/when in the settings doc and audit_log).
|
||||||
|
GATED = frozenset({Licence.AGREEMENT_REQUIRED, Licence.NON_COMMERCIAL, Licence.UNVERIFIED, Licence.PAID})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LicenceInfo:
|
||||||
|
status: Licence
|
||||||
|
label: str # one line, shown prominently in Admin
|
||||||
|
terms: str # what the source's own terms say (quoted where read)
|
||||||
|
terms_url: Optional[str] = None
|
||||||
|
attribution: Optional[str] = None # must render wherever this provider's imagery appears
|
||||||
|
attribution_url: Optional[str] = None
|
||||||
|
disclaimer: Optional[str] = None # pass-through disclaimer (e.g. NYSDOT DAA)
|
||||||
|
issue: Optional[str] = None # tracking issue, e.g. "server-26#198"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def gated(self) -> bool:
|
||||||
|
return self.status in GATED
|
||||||
|
|
||||||
|
def warning(self) -> Optional[str]:
|
||||||
|
"""The blocking warning an admin must acknowledge, or None when ungated."""
|
||||||
|
if self.status == Licence.PROHIBITED:
|
||||||
|
return f"{self.label}. This source's terms forbid this use; it cannot be enabled."
|
||||||
|
if not self.gated:
|
||||||
|
return None
|
||||||
|
return (f"{self.label}. {self.terms} Enabling it means DRB displays this source's imagery "
|
||||||
|
f"to every signed-in user. Only confirm if DRB actually holds the right to do so.")
|
||||||
|
|
||||||
|
def as_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"status": self.status.value, "label": self.label, "terms": self.terms,
|
||||||
|
"terms_url": self.terms_url, "attribution": self.attribution,
|
||||||
|
"attribution_url": self.attribution_url, "disclaimer": self.disclaimer,
|
||||||
|
"issue": self.issue, "gated": self.gated, "warning": self.warning(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ProviderMeta:
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
region: str
|
||||||
|
coverage: BBox # rough bbox; a query outside it never triggers a fetch
|
||||||
|
licence: LicenceInfo
|
||||||
|
formats: str # "stills", "stills + HLS", ...
|
||||||
|
implemented: bool
|
||||||
|
notes: str # what is verified vs unverified about this adapter
|
||||||
|
homepage: Optional[str] = None
|
||||||
|
key_setting: Optional[str] = None # settings attribute holding a required API key
|
||||||
|
ttl_s: int = DEFAULT_CATALOG_TTL_S
|
||||||
|
|
||||||
|
|
||||||
|
class CameraSignalSink(Protocol):
|
||||||
|
"""Future seam (NOT implemented): aggregate per-class counts from a camera
|
||||||
|
still attached to an incident as corroborating evidence. Blocked on
|
||||||
|
server-26#199 and #200. No frames, plates or faces -- class counts only."""
|
||||||
|
|
||||||
|
async def submit(self, camera_id: str, incident_id: str, counts: Dict[str, int], observed_at: float) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class CameraProvider:
|
||||||
|
"""Base adapter. Subclasses set ``meta`` and implement ``fetch_catalog``."""
|
||||||
|
meta: ProviderMeta
|
||||||
|
|
||||||
|
async def fetch_catalog(self) -> List[Camera]:
|
||||||
|
raise NotImplementedError(f"{self.meta.id}: adapter is a stub ({self.meta.notes})")
|
||||||
|
|
||||||
|
|
||||||
|
class StubProvider(CameraProvider):
|
||||||
|
"""A catalogued source with no working adapter -- listed so Admin shows its
|
||||||
|
licence status, but it can never be enabled."""
|
||||||
|
|
||||||
|
def __init__(self, meta: ProviderMeta):
|
||||||
|
assert not meta.implemented
|
||||||
|
self.meta = meta
|
||||||
|
|
||||||
|
|
||||||
|
class Catalog:
|
||||||
|
"""In-memory cache of one provider's normalised camera list."""
|
||||||
|
|
||||||
|
def __init__(self, provider: CameraProvider):
|
||||||
|
self.provider = provider
|
||||||
|
self.items: List[Camera] = []
|
||||||
|
self.fetched_at: Optional[float] = None # epoch s of last SUCCESSFUL fetch
|
||||||
|
self.last_attempt_at: Optional[float] = None
|
||||||
|
self.error: Optional[str] = None
|
||||||
|
self._next_attempt = 0.0
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def get(self) -> "Catalog":
|
||||||
|
if time.time() < self._next_attempt:
|
||||||
|
return self
|
||||||
|
async with self._lock:
|
||||||
|
if time.time() < self._next_attempt:
|
||||||
|
return self
|
||||||
|
ttl = self.provider.meta.ttl_s
|
||||||
|
self.last_attempt_at = time.time()
|
||||||
|
try:
|
||||||
|
items = await self.provider.fetch_catalog()
|
||||||
|
self.items = items
|
||||||
|
self.fetched_at = time.time()
|
||||||
|
self.error = None
|
||||||
|
self._next_attempt = time.time() + ttl
|
||||||
|
except Exception as e:
|
||||||
|
self._next_attempt = time.time() + min(ttl, RETRY_AFTER_FAILURE_S)
|
||||||
|
self.error = f"{type(e).__name__}: {e}"[:300]
|
||||||
|
logger.warning(f"cameras/{self.provider.meta.id}: refresh failed, serving {len(self.items)} cached: {self.error}")
|
||||||
|
return self
|
||||||
|
|
||||||
|
def status(self) -> dict:
|
||||||
|
return {"fetched_at": self.fetched_at, "last_attempt_at": self.last_attempt_at,
|
||||||
|
"error": self.error, "count": len(self.items)}
|
||||||
|
|
||||||
|
|
||||||
|
# ── helpers shared by adapters ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def make_camera(provider_id: str, raw_id: Any, lat: float, lon: float, *, name: str = "", roadway: str = "",
|
||||||
|
direction: str = "", image_url: Optional[str] = None, video_url: Optional[str] = None,
|
||||||
|
heading_deg: Optional[float] = None) -> Camera:
|
||||||
|
"""One normalised record. ``id`` is provider-prefixed so two catalogs can't collide."""
|
||||||
|
if heading_deg is None:
|
||||||
|
heading_deg = heading_from_text(direction)
|
||||||
|
return {
|
||||||
|
"id": f"{provider_id}:{raw_id}",
|
||||||
|
"provider": provider_id,
|
||||||
|
"lat": lat, "lon": lon,
|
||||||
|
"name": name, "roadway": roadway, "direction": direction,
|
||||||
|
# Approximate at best: most sources publish the road's direction of
|
||||||
|
# travel, not the camera's true heading. The UI labels viewsheds so.
|
||||||
|
"heading_deg": heading_deg,
|
||||||
|
"image_url": image_url, "video_url": video_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_COMPASS = {"N": 0, "NE": 45, "E": 90, "SE": 135, "S": 180, "SW": 225, "W": 270, "NW": 315}
|
||||||
|
_WORDS = {"north": "N", "south": "S", "east": "E", "west": "W",
|
||||||
|
"northeast": "NE", "northwest": "NW", "southeast": "SE", "southwest": "SW"}
|
||||||
|
|
||||||
|
|
||||||
|
def heading_from_text(text: Optional[str]) -> Optional[float]:
|
||||||
|
"""'Northbound' / 'NB' / 'West' / 'SE' -> degrees; None when it can't tell.
|
||||||
|
'Both directions', 'Unknown', 'Eastbound/Westbound' etc. are None on purpose."""
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
t = text.strip().lower()
|
||||||
|
t = re.sub(r"bound$", "", t).strip()
|
||||||
|
if t in _WORDS:
|
||||||
|
return float(_COMPASS[_WORDS[t]])
|
||||||
|
t2 = re.sub(r"b$", "", t).upper() if re.fullmatch(r"(n|s|e|w|ne|nw|se|sw)b?", t) else t.upper()
|
||||||
|
return float(_COMPASS[t2]) if t2 in _COMPASS else None
|
||||||
|
|
||||||
|
|
||||||
|
def finite(v: Any) -> Optional[float]:
|
||||||
|
try:
|
||||||
|
f = float(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return f if f == f and f not in (float("inf"), float("-inf")) else None
|
||||||
|
|
||||||
|
|
||||||
|
def plausible(lat: Optional[float], lon: Optional[float], coverage: BBox) -> bool:
|
||||||
|
if lat is None or lon is None or (lat == 0 and lon == 0):
|
||||||
|
return False
|
||||||
|
s, w, n, e = coverage
|
||||||
|
return s <= lat <= n and w <= lon <= e
|
||||||
|
|
||||||
|
|
||||||
|
def https_on(url: Any, *hosts: str) -> Optional[str]:
|
||||||
|
"""Return ``url`` only when it is https on one of ``hosts`` (or a subdomain).
|
||||||
|
Pins imagery to the agency's own hosts so no catalog field can point the
|
||||||
|
browser somewhere else."""
|
||||||
|
if not isinstance(url, str) or not url.startswith("https://"):
|
||||||
|
return None
|
||||||
|
host = url[len("https://"):].split("/", 1)[0].split(":", 1)[0].lower()
|
||||||
|
return url if any(host == h or host.endswith("." + h) for h in hosts) else None
|
||||||
|
|
||||||
|
|
||||||
|
def bbox_intersects(a: BBox, b: BBox) -> bool:
|
||||||
|
return not (a[2] < b[0] or b[2] < a[0] or a[3] < b[1] or b[3] < a[1])
|
||||||
|
|
||||||
|
|
||||||
|
def in_bbox(items: List[Camera], bbox: BBox) -> List[Camera]:
|
||||||
|
s, w, n, e = bbox
|
||||||
|
return [i for i in items if s <= i["lat"] <= n and w <= i["lon"] <= e]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_json(url: str, params: Optional[dict] = None, headers: Optional[dict] = None) -> Any:
|
||||||
|
"""GET a JSON catalog. Redirects are refused: a catalog that moves is a
|
||||||
|
change to review, not something to follow to an unknown host."""
|
||||||
|
import httpx # local import keeps module import cheap for the test stubs
|
||||||
|
h = {"User-Agent": USER_AGENT, "Accept": "application/json", **(headers or {})}
|
||||||
|
async with httpx.AsyncClient(timeout=FETCH_TIMEOUT_S, follow_redirects=False) as client:
|
||||||
|
r = await client.get(url, params=params, headers=h)
|
||||||
|
r.raise_for_status()
|
||||||
|
ctype = r.headers.get("content-type", "")
|
||||||
|
if "json" not in ctype:
|
||||||
|
# 511NY's dead legacy API answers 200 text/html (server-26#198).
|
||||||
|
raise ValueError(f"expected JSON, got {ctype or 'no content-type'} (HTTP {r.status_code})")
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def require_parsed(raw_count: int, out: List[Camera], what: str) -> List[Camera]:
|
||||||
|
"""Every record failing to normalise means the schema changed under us --
|
||||||
|
that is an error, not an empty catalog."""
|
||||||
|
if raw_count and not out:
|
||||||
|
raise ValueError(f"0 of {raw_count} {what} records parsed -- upstream schema change?")
|
||||||
|
return out
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
"""General / other-region camera sources -- design-canvas/camera-sources.md §C,
|
||||||
|
including every God's Eye View (GEV) source.
|
||||||
|
|
||||||
|
Endpoints and record shapes for the implemented adapters were checked
|
||||||
|
first-hand against each live catalog on 2026-10-04 (Ontario 511 excepted: it now
|
||||||
|
answers "Invalid Key" without a developer key, so its shape is the legacy CARS
|
||||||
|
v2 shape, UNVERIFIED). Imagery URLs are pinned to each agency's own hosts. None
|
||||||
|
of these cover DRB's region; they exist so a non-NY customer has a path, and
|
||||||
|
every one of them is OFF until an admin enables it.
|
||||||
|
|
||||||
|
GEV is MIT-licensed code, but nothing here is copied from it, and none of GEV's
|
||||||
|
bundled data (config/*.json curated catalogs) is used -- the MIT grant does not
|
||||||
|
cover data.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.internal.cameras.base import (
|
||||||
|
Camera, CameraProvider, Licence, LicenceInfo, ProviderMeta, StubProvider, finite, get_json, https_on,
|
||||||
|
make_camera, plausible, require_parsed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _unverified(label: str, terms: str, url: str = None) -> LicenceInfo:
|
||||||
|
return LicenceInfo(status=Licence.UNVERIFIED, label=label, terms=terms, terms_url=url)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Caltrans CWWP2 ───────────────────────────────────────────────────────────
|
||||||
|
class CaltransProvider(CameraProvider):
|
||||||
|
DISTRICTS = (4, 7, 11, 3) # SF Bay, LA, San Diego, Sacramento
|
||||||
|
meta = ProviderMeta(
|
||||||
|
id="caltrans", name="Caltrans CWWP2", region="California (districts 4, 7, 11, 3)",
|
||||||
|
coverage=(32.4, -124.6, 42.1, -114.0),
|
||||||
|
licence=_unverified(
|
||||||
|
"Licence UNVERIFIED -- likely commercial OK; Conditions of Use not yet read",
|
||||||
|
"\"Commercial Wholesale Web Portal\", \"no charge\"; Conditions of Use apply and have not been read.",
|
||||||
|
"https://cwwp2.dot.ca.gov/documentation/cctv/cctv.htm"),
|
||||||
|
formats="stills (~5 min) + HLS", implemented=True,
|
||||||
|
notes="Schema verified 2026-10-04 (D4). HLS host CORS unverified -- the player falls back to the still.",
|
||||||
|
homepage="https://cwwp2.dot.ca.gov",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _district(self, d: int) -> list:
|
||||||
|
payload = await get_json(f"https://cwwp2.dot.ca.gov/data/d{d}/cctv/cctvStatusD{d:02d}.json")
|
||||||
|
return payload.get("data") or [] if isinstance(payload, dict) else []
|
||||||
|
|
||||||
|
async def fetch_catalog(self) -> List[Camera]:
|
||||||
|
results = await asyncio.gather(*(self._district(d) for d in self.DISTRICTS), return_exceptions=True)
|
||||||
|
ok = [r for r in results if not isinstance(r, BaseException)]
|
||||||
|
if not ok:
|
||||||
|
raise RuntimeError(f"every Caltrans district failed: {results[0]}")
|
||||||
|
rows = [row for district in ok for row in district]
|
||||||
|
out: List[Camera] = []
|
||||||
|
for row in rows:
|
||||||
|
c = (row or {}).get("cctv") or {}
|
||||||
|
if str(c.get("inService", "")).lower() != "true":
|
||||||
|
continue
|
||||||
|
loc = c.get("location") or {}
|
||||||
|
lat, lon = finite(loc.get("latitude")), finite(loc.get("longitude"))
|
||||||
|
if not plausible(lat, lon, self.meta.coverage):
|
||||||
|
continue
|
||||||
|
img = https_on(((c.get("imageData") or {}).get("static") or {}).get("currentImageURL"), "dot.ca.gov")
|
||||||
|
if not img:
|
||||||
|
continue
|
||||||
|
vid = https_on((c.get("imageData") or {}).get("streamingVideoURL"), "dot.ca.gov")
|
||||||
|
name = str(loc.get("locationName") or "")
|
||||||
|
code = name.split("--", 1)[0].strip() or str(c.get("index"))
|
||||||
|
out.append(make_camera("caltrans", f"d{loc.get('district')}-{code}", lat, lon,
|
||||||
|
name=name.split("--", 1)[-1].strip() or name, roadway=str(loc.get("route") or ""),
|
||||||
|
direction=str(loc.get("direction") or ""), image_url=img, video_url=vid))
|
||||||
|
return require_parsed(len(rows), out, "Caltrans") if rows else out
|
||||||
|
|
||||||
|
|
||||||
|
# ── DelDOT ───────────────────────────────────────────────────────────────────
|
||||||
|
class DelDotProvider(CameraProvider):
|
||||||
|
meta = ProviderMeta(
|
||||||
|
id="deldot", name="DelDOT TMC", region="Delaware",
|
||||||
|
coverage=(38.4, -75.8, 39.9, -75.0),
|
||||||
|
licence=_unverified("Licence UNVERIFIED -- \"public data\", no explicit licence found",
|
||||||
|
"No licence or terms of reuse located for tmc.deldot.gov's camera catalog."),
|
||||||
|
formats="live HLS only (no stills)", implemented=True,
|
||||||
|
notes="Schema verified 2026-10-04 (361 cams); https HLS host sends ACAO: * (verified). Video-only, so a "
|
||||||
|
"camera shows nothing when its stream is down. Played directly from the browser -- no DRB egress.",
|
||||||
|
homepage="https://deldot.gov/map/",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fetch_catalog(self) -> List[Camera]:
|
||||||
|
payload = await get_json("https://tmc.deldot.gov/json/videocamera.json")
|
||||||
|
rows = payload.get("videoCameras") or [] if isinstance(payload, dict) else []
|
||||||
|
out: List[Camera] = []
|
||||||
|
for r in rows:
|
||||||
|
if r.get("enabled") is not True or r.get("status") != "Active" or not r.get("id"):
|
||||||
|
continue
|
||||||
|
lat, lon = finite(r.get("lat")), finite(r.get("lon"))
|
||||||
|
vid = https_on((r.get("urls") or {}).get("m3u8s"), "deldot.gov")
|
||||||
|
if not plausible(lat, lon, self.meta.coverage) or not vid:
|
||||||
|
continue
|
||||||
|
out.append(make_camera("deldot", r["id"], lat, lon, name=str(r.get("title") or r["id"]),
|
||||||
|
roadway=str(r.get("county") or ""), video_url=vid))
|
||||||
|
return require_parsed(len(rows), out, "DelDOT")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Ontario 511 ──────────────────────────────────────────────────────────────
|
||||||
|
class Ontario511Provider(CameraProvider):
|
||||||
|
meta = ProviderMeta(
|
||||||
|
id="ontario511", name="Ontario 511", region="Ontario, Canada",
|
||||||
|
coverage=(41.6, -95.2, 57.0, -74.3),
|
||||||
|
licence=_unverified("Licence UNVERIFIED -- Ontario open-data terms page not read",
|
||||||
|
"GEV reports the Open Government Licence - Ontario; not confirmed first-hand.",
|
||||||
|
"https://511on.ca/developers/doc"),
|
||||||
|
formats="stills", implemented=True,
|
||||||
|
notes="Needs ONTARIO511_API_KEY (verified 2026-10-04: keyless now answers \"Invalid Key\"); 10 calls/60 s. "
|
||||||
|
"Record shape is the CARS v2 shape (Id/Latitude/Longitude/Views[]), UNVERIFIED since the key change.",
|
||||||
|
homepage="https://511on.ca", key_setting="ontario511_api_key",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fetch_catalog(self) -> List[Camera]:
|
||||||
|
if not settings.ontario511_api_key:
|
||||||
|
raise RuntimeError("ONTARIO511_API_KEY not set")
|
||||||
|
rows = await get_json("https://511on.ca/api/v2/get/cameras",
|
||||||
|
params={"format": "json", "lang": "en", "key": settings.ontario511_api_key})
|
||||||
|
rows = rows if isinstance(rows, list) else []
|
||||||
|
out: List[Camera] = []
|
||||||
|
for r in rows:
|
||||||
|
lat, lon = finite(r.get("Latitude")), finite(r.get("Longitude"))
|
||||||
|
if not r.get("Id") or not plausible(lat, lon, self.meta.coverage):
|
||||||
|
continue
|
||||||
|
views = [v for v in (r.get("Views") or []) if str(v.get("Status", "")).lower() == "enabled"]
|
||||||
|
img = next((https_on(v.get("Url"), "511on.ca") for v in views if https_on(v.get("Url"), "511on.ca")), None)
|
||||||
|
if not img:
|
||||||
|
continue
|
||||||
|
out.append(make_camera("ontario511", r["Id"], lat, lon, name=str(r.get("Location") or ""),
|
||||||
|
roadway=str(r.get("Roadway") or ""), direction=str(r.get("Direction") or ""),
|
||||||
|
image_url=img))
|
||||||
|
return require_parsed(len(rows), out, "Ontario 511")
|
||||||
|
|
||||||
|
|
||||||
|
# ── DriveBC ──────────────────────────────────────────────────────────────────
|
||||||
|
class DriveBcProvider(CameraProvider):
|
||||||
|
meta = ProviderMeta(
|
||||||
|
id="drivebc", name="DriveBC", region="British Columbia, Canada",
|
||||||
|
coverage=(48.2, -139.1, 60.1, -114.0),
|
||||||
|
licence=LicenceInfo(
|
||||||
|
status=Licence.ATTRIBUTION_REQUIRED, label="Commercial OK with attribution (OGL - British Columbia)",
|
||||||
|
terms="Open Government Licence - British Columbia: reuse permitted with attribution.",
|
||||||
|
terms_url="https://www2.gov.bc.ca/gov/content/data/policy-standards/open-data/open-government-licence-bc",
|
||||||
|
attribution="DriveBC -- contains information licensed under the Open Government Licence - British Columbia",
|
||||||
|
attribution_url="https://www.drivebc.ca"),
|
||||||
|
formats="stills", implemented=True, notes="Schema verified 2026-10-04 (1,066 cams).",
|
||||||
|
homepage="https://www.drivebc.ca",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fetch_catalog(self) -> List[Camera]:
|
||||||
|
rows = await get_json("https://www.drivebc.ca/api/webcams/")
|
||||||
|
rows = rows if isinstance(rows, list) else []
|
||||||
|
out: List[Camera] = []
|
||||||
|
for r in rows:
|
||||||
|
if r.get("is_on") is not True or r.get("should_appear") is not True:
|
||||||
|
continue
|
||||||
|
cid = r.get("id")
|
||||||
|
coords = (r.get("location") or {}).get("coordinates") or []
|
||||||
|
if not isinstance(cid, int) or len(coords) < 2:
|
||||||
|
continue
|
||||||
|
lon, lat = finite(coords[0]), finite(coords[1])
|
||||||
|
if not plausible(lat, lon, self.meta.coverage):
|
||||||
|
continue
|
||||||
|
out.append(make_camera("drivebc", cid, lat, lon, name=str(r.get("name") or ""),
|
||||||
|
roadway=str(r.get("highway_description") or ""),
|
||||||
|
direction=str(r.get("orientation") or ""),
|
||||||
|
image_url=f"https://www.drivebc.ca/images/{cid}.jpg"))
|
||||||
|
return require_parsed(len(rows), out, "DriveBC")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Open Calgary ─────────────────────────────────────────────────────────────
|
||||||
|
class CalgaryProvider(CameraProvider):
|
||||||
|
meta = ProviderMeta(
|
||||||
|
id="calgary", name="Open Calgary traffic cameras", region="Calgary, Alberta",
|
||||||
|
coverage=(50.8, -114.5, 51.3, -113.8),
|
||||||
|
licence=LicenceInfo(
|
||||||
|
status=Licence.ATTRIBUTION_REQUIRED, label="Commercial OK with attribution (OGL - City of Calgary)",
|
||||||
|
terms="Open Government Licence - City of Calgary: reuse permitted with attribution.",
|
||||||
|
terms_url="https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa",
|
||||||
|
attribution="Contains information licensed under the Open Government Licence - City of Calgary",
|
||||||
|
attribution_url="https://data.calgary.ca"),
|
||||||
|
formats="stills", implemented=True,
|
||||||
|
notes="Schema verified 2026-10-04 (216 cams). Catalog lists http:// stills; upgraded to https on the same host.",
|
||||||
|
homepage="https://data.calgary.ca",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fetch_catalog(self) -> List[Camera]:
|
||||||
|
rows = await get_json("https://data.calgary.ca/resource/k7p9-kppz.json", params={"$limit": "1000"})
|
||||||
|
rows = rows if isinstance(rows, list) else []
|
||||||
|
out: List[Camera] = []
|
||||||
|
for r in rows:
|
||||||
|
url = str((r.get("camera_url") or {}).get("url") or "")
|
||||||
|
if url.startswith("http://"):
|
||||||
|
url = "https://" + url[len("http://"):]
|
||||||
|
img = https_on(url, "trafficcam.calgary.ca")
|
||||||
|
coords = (r.get("point") or {}).get("coordinates") or []
|
||||||
|
if not img or len(coords) < 2:
|
||||||
|
continue
|
||||||
|
lon, lat = finite(coords[0]), finite(coords[1])
|
||||||
|
if not plausible(lat, lon, self.meta.coverage):
|
||||||
|
continue
|
||||||
|
cid = img.rsplit("/", 1)[-1].rsplit(".", 1)[0]
|
||||||
|
out.append(make_camera("calgary", cid, lat, lon, name=str(r.get("camera_location") or cid),
|
||||||
|
roadway=str(r.get("quadrant") or ""), image_url=img))
|
||||||
|
return require_parsed(len(rows), out, "Calgary")
|
||||||
|
|
||||||
|
|
||||||
|
# ── TfL JamCams ──────────────────────────────────────────────────────────────
|
||||||
|
class TflProvider(CameraProvider):
|
||||||
|
IMAGE_PREFIX = "https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/"
|
||||||
|
meta = ProviderMeta(
|
||||||
|
id="tfl", name="TfL JamCams", region="London, UK",
|
||||||
|
coverage=(51.2, -0.7, 51.8, 0.4),
|
||||||
|
licence=LicenceInfo(
|
||||||
|
status=Licence.ATTRIBUTION_REQUIRED,
|
||||||
|
label="Commercial OK with attribution (\"Powered by TfL Open Data\") -- TfL terms page not re-read",
|
||||||
|
terms="TfL transport data terms: free incl. commercial, with attribution (per GEV; the terms page "
|
||||||
|
"returned 403 during the survey -- re-read before selling).",
|
||||||
|
terms_url="https://tfl.gov.uk/info-for/open-data-users/our-open-data",
|
||||||
|
attribution="Powered by TfL Open Data", attribution_url="https://tfl.gov.uk"),
|
||||||
|
formats="stills (+ short MP4, unused)", implemented=True, notes="Schema verified 2026-10-04 (890 cams).",
|
||||||
|
homepage="https://api.tfl.gov.uk",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fetch_catalog(self) -> List[Camera]:
|
||||||
|
rows = await get_json("https://api.tfl.gov.uk/Place/Type/JamCam")
|
||||||
|
rows = rows if isinstance(rows, list) else []
|
||||||
|
out: List[Camera] = []
|
||||||
|
for r in rows:
|
||||||
|
props = {p.get("key"): p.get("value") for p in (r.get("additionalProperties") or []) if isinstance(p, dict)}
|
||||||
|
if str(props.get("available", "")).lower() != "true":
|
||||||
|
continue
|
||||||
|
img = str(props.get("imageUrl") or "")
|
||||||
|
lat, lon = finite(r.get("lat")), finite(r.get("lon"))
|
||||||
|
raw_id = str(r.get("id") or "").replace("JamCams_", "")
|
||||||
|
if not img.startswith(self.IMAGE_PREFIX) or not raw_id or not plausible(lat, lon, self.meta.coverage):
|
||||||
|
continue
|
||||||
|
out.append(make_camera("tfl", raw_id, lat, lon, name=str(r.get("commonName") or raw_id),
|
||||||
|
direction=str(props.get("view") or ""), image_url=img))
|
||||||
|
return require_parsed(len(rows), out, "TfL")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Live Traffic NSW ─────────────────────────────────────────────────────────
|
||||||
|
class NswProvider(CameraProvider):
|
||||||
|
meta = ProviderMeta(
|
||||||
|
id="nsw", name="Live Traffic NSW", region="New South Wales, Australia",
|
||||||
|
coverage=(-37.6, 140.9, -28.0, 153.7),
|
||||||
|
licence=LicenceInfo(
|
||||||
|
status=Licence.ATTRIBUTION_REQUIRED, label="Commercial OK with attribution (CC BY 4.0)",
|
||||||
|
terms="Catalog's own rights block: copyright Transport for NSW, licence per the Open Data Hub dataset "
|
||||||
|
"(CC BY 4.0).",
|
||||||
|
terms_url="https://opendata.transport.nsw.gov.au/dataset/live-traffic-cameras",
|
||||||
|
attribution="Transport for NSW, CC BY 4.0", attribution_url="https://www.livetraffic.com"),
|
||||||
|
formats="stills", implemented=True, notes="Schema verified 2026-10-04 (241 cams).",
|
||||||
|
homepage="https://www.livetraffic.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fetch_catalog(self) -> List[Camera]:
|
||||||
|
payload = await get_json("https://data.livetraffic.com/cameras/traffic-cam.json")
|
||||||
|
feats = payload.get("features") or [] if isinstance(payload, dict) else []
|
||||||
|
out: List[Camera] = []
|
||||||
|
for f in feats:
|
||||||
|
p = f.get("properties") or {}
|
||||||
|
coords = (f.get("geometry") or {}).get("coordinates") or []
|
||||||
|
img = https_on(p.get("href"), "transport.nsw.gov.au")
|
||||||
|
if not img or len(coords) < 2 or not f.get("id"):
|
||||||
|
continue
|
||||||
|
lon, lat = finite(coords[0]), finite(coords[1])
|
||||||
|
if not plausible(lat, lon, self.meta.coverage):
|
||||||
|
continue
|
||||||
|
out.append(make_camera("nsw", f["id"], lat, lon, name=str(p.get("title") or ""),
|
||||||
|
roadway=str(p.get("region") or ""), direction=str(p.get("direction") or ""),
|
||||||
|
image_url=img))
|
||||||
|
return require_parsed(len(feats), out, "NSW")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Fintraffic / Digitraffic weathercams ─────────────────────────────────────
|
||||||
|
class FintrafficProvider(CameraProvider):
|
||||||
|
meta = ProviderMeta(
|
||||||
|
id="fintraffic", name="Fintraffic / Digitraffic weathercams", region="Finland",
|
||||||
|
coverage=(59.5, 19.0, 70.2, 31.7),
|
||||||
|
licence=LicenceInfo(
|
||||||
|
status=Licence.ATTRIBUTION_REQUIRED, label="Commercial OK with attribution (CC BY 4.0)",
|
||||||
|
terms="\"Source: Fintraffic / digitraffic.fi, license CC 4.0 BY\".",
|
||||||
|
terms_url="https://www.digitraffic.fi/en/terms-of-service/",
|
||||||
|
attribution="Source: Fintraffic / digitraffic.fi, license CC 4.0 BY",
|
||||||
|
attribution_url="https://www.digitraffic.fi"),
|
||||||
|
formats="stills (~10 min)", implemented=True,
|
||||||
|
notes="Schema verified 2026-10-04 (810 stations, several presets each). Digitraffic asks clients to "
|
||||||
|
"identify themselves (Digitraffic-User header).",
|
||||||
|
homepage="https://www.digitraffic.fi", ttl_s=30 * 60,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fetch_catalog(self) -> List[Camera]:
|
||||||
|
payload = await get_json("https://tie.digitraffic.fi/api/weathercam/v1/stations",
|
||||||
|
headers={"Digitraffic-User": "DRB/c2-core"})
|
||||||
|
feats = payload.get("features") or [] if isinstance(payload, dict) else []
|
||||||
|
out: List[Camera] = []
|
||||||
|
for f in feats:
|
||||||
|
p = f.get("properties") or {}
|
||||||
|
coords = (f.get("geometry") or {}).get("coordinates") or []
|
||||||
|
sid = str(p.get("id") or "")
|
||||||
|
if not sid or str(p.get("collectionStatus", "")).upper() != "GATHERING" or len(coords) < 2:
|
||||||
|
continue
|
||||||
|
lon, lat = finite(coords[0]), finite(coords[1])
|
||||||
|
if not plausible(lat, lon, self.meta.coverage):
|
||||||
|
continue
|
||||||
|
for preset in p.get("presets") or []:
|
||||||
|
pid = str((preset or {}).get("id") or "")
|
||||||
|
if preset.get("inCollection") is not True or not pid.startswith(sid) or not pid[1:].isdigit():
|
||||||
|
continue
|
||||||
|
out.append(make_camera("fintraffic", pid, lat, lon, name=f"{p.get('name') or sid} ({pid[-2:]})",
|
||||||
|
image_url=f"https://weathercam.digitraffic.fi/{pid}.jpg"))
|
||||||
|
return require_parsed(len(feats), out, "Fintraffic")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Stubs: catalogued, no adapter ────────────────────────────────────────────
|
||||||
|
def _stub(id, name, region, coverage, licence, formats, notes, homepage=None) -> StubProvider:
|
||||||
|
return StubProvider(ProviderMeta(id=id, name=name, region=region, coverage=coverage, licence=licence,
|
||||||
|
formats=formats, implemented=False, notes=notes, homepage=homepage))
|
||||||
|
|
||||||
|
|
||||||
|
STUBS = [
|
||||||
|
_stub("austin", "City of Austin open data", "Austin, Texas", (30.0, -98.1, 30.6, -97.5),
|
||||||
|
_unverified("Licence UNVERIFIED -- city open data, no explicit licence", "No explicit licence per GEV."),
|
||||||
|
"stills (5 min)", "Stub: Socrata column mapping not built.", "https://data.austintexas.gov"),
|
||||||
|
_stub("txdot", "TxDOT ITS", "Texas (per district)", (25.8, -106.7, 36.6, -93.5),
|
||||||
|
_unverified("Licence UNVERIFIED -- \"public data\"", "No explicit licence per GEV."),
|
||||||
|
"base64 JPEG inside JSON", "Stub: frames are base64 inside JSON, so the browser cannot show them "
|
||||||
|
"without a DRB frame proxy (video/image egress on DRB's bill -- not built).", "https://its.txdot.gov"),
|
||||||
|
_stub("md_chart", "Maryland CHART", "Maryland", (37.9, -79.5, 39.8, -75.0),
|
||||||
|
_unverified("Licence UNVERIFIED -- MD open data portal terms not read", "Not read."),
|
||||||
|
"live video URLs", "Stub: catalog schema not verified.", "https://chart.maryland.gov"),
|
||||||
|
_stub("wsdot", "WSDOT Traveler Information API", "Washington State", (45.5, -124.9, 49.1, -116.9),
|
||||||
|
_unverified("Licence UNVERIFIED -- WSDOT disclaimer only", "Free access code required; terms not read."),
|
||||||
|
"stills", "Stub: needs a WSDOT access code; schema not verified.", "https://wsdot.wa.gov/traffic/api/"),
|
||||||
|
_stub("tallinn", "Tallinn city cameras", "Tallinn, Estonia", (59.35, 24.55, 59.5, 24.95),
|
||||||
|
_unverified("Licence UNVERIFIED", "No licence found."),
|
||||||
|
"stills", "Stub: GEV uses a curated catalog file; GEV's data is not covered by its MIT licence.",
|
||||||
|
"https://ristmikud.tallinn.ee"),
|
||||||
|
_stub("tarktee", "Tarktee (Estonian Transport Administration)", "Estonia", (57.5, 21.7, 59.8, 28.3),
|
||||||
|
_unverified("Licence UNVERIFIED", "No licence found."),
|
||||||
|
"DATEX2 stills", "Stub: DATEX2 XML parser not built.", "https://tarktee.transpordiamet.ee"),
|
||||||
|
_stub("warendorf", "Warendorf municipal webcam", "Warendorf, Germany", (51.9, 7.9, 52.0, 8.1),
|
||||||
|
_unverified("Licence UNVERIFIED", "No licence found."),
|
||||||
|
"stills", "Stub: single curated webcam; no catalog API.", "https://www.warendorf.de"),
|
||||||
|
_stub("trafficland", "TrafficLand", "US (18k cams, 50+ DOTs)", (18.0, -170.0, 72.0, -60.0),
|
||||||
|
LicenceInfo(status=Licence.PAID, label="Paid commercial licence -- no contract",
|
||||||
|
terms="Licensed API; contractual redistribution with 50+ DOTs. Price UNVERIFIED.",
|
||||||
|
terms_url="http://www.trafficland.com/api.html"),
|
||||||
|
"live video", "Stub: the clean route if DRB ever needs video beyond 511NY; needs a contract."),
|
||||||
|
_stub("windy", "Windy Webcams API", "Global (scenic/weather)", (-90.0, -180.0, 90.0, 180.0),
|
||||||
|
LicenceInfo(status=Licence.PAID, label="Paid professional tier for commercial use -- no contract",
|
||||||
|
terms="Free tier image URLs expire after 10 min; commercial terms UNVERIFIED.",
|
||||||
|
terms_url="https://api.windy.com/webcams/docs"),
|
||||||
|
"tokenised stills", "Stub: scenic/weather cams, not traffic."),
|
||||||
|
_stub("earthcam", "EarthCam", "Global", (-90.0, -180.0, 90.0, 180.0),
|
||||||
|
LicenceInfo(status=Licence.PAID, label="Paid commercial licence -- no contract",
|
||||||
|
terms="Commercial licensing exists; terms UNVERIFIED."),
|
||||||
|
"live video", "Stub: low local value."),
|
||||||
|
]
|
||||||
|
|
||||||
|
PROVIDERS = [CaltransProvider(), DelDotProvider(), Ontario511Provider(), DriveBcProvider(), CalgaryProvider(),
|
||||||
|
TflProvider(), NswProvider(), FintrafficProvider(), *STUBS]
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"""Tri-state (Westchester-first) camera sources -- design-canvas/camera-sources.md §A.
|
||||||
|
|
||||||
|
Only 511NY has a working adapter. Every other regional source is listed so
|
||||||
|
Admin shows its licence status, and is a stub because DRB has no right to use
|
||||||
|
it yet (NYC DOT, Westchester DPW), it carries no cameras (CTroads), or no
|
||||||
|
developer program exists (511NJ). Do not "fix" a stub by scraping: CTroads,
|
||||||
|
511NJ's app and the new 511ny.org backend all ban robots/reverse engineering.
|
||||||
|
"""
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from app.internal import ny511
|
||||||
|
from app.internal.cameras.base import (
|
||||||
|
Camera, CameraProvider, Licence, LicenceInfo, ProviderMeta, StubProvider, make_camera, require_parsed,
|
||||||
|
)
|
||||||
|
|
||||||
|
NY_BBOX = (40.4, -80.0, 45.1, -71.7)
|
||||||
|
TRISTATE_BBOX = (40.3, -75.6, 42.1, -71.7)
|
||||||
|
|
||||||
|
NYSDOT_DISCLAIMER = (
|
||||||
|
"NYSDOT compiles the data from the best available sources and makes a best effort to disseminate it. "
|
||||||
|
"Data availability and accuracy are subject to change, and may change as often as daily. Posted "
|
||||||
|
"restrictions in the field may differ from, and supersede, the information in this feed. NYSDOT and its "
|
||||||
|
"partner agencies make no warranty regarding the data, express or implied, and expressly disclaim all "
|
||||||
|
"warranties of merchantability and fitness for any particular purpose. NYSDOT makes no warranty that the "
|
||||||
|
"data will be provided without interruption or that it will be free of errors. Access is provided on an "
|
||||||
|
"\"as is\" and \"with all faults\" basis."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Ny511Provider(CameraProvider):
|
||||||
|
meta = ProviderMeta(
|
||||||
|
id="ny511",
|
||||||
|
name="511NY (NYSDOT)",
|
||||||
|
region="New York State (aggregates NYSDOT, Thruway, NYC DOT, MTA B&T, PANYNJ, TRANSCOM partners)",
|
||||||
|
coverage=NY_BBOX,
|
||||||
|
licence=LicenceInfo(
|
||||||
|
status=Licence.AGREEMENT_REQUIRED,
|
||||||
|
label="Requires NYSDOT Developer Access Agreement -- access to the new 511x feed NOT granted yet",
|
||||||
|
terms=("Commercial redistribution is allowed under the DAA (\"You may redistribute, enhance, repackage, "
|
||||||
|
"and add value to the data\"; \"You may not alter the substance of a report\"; must display the "
|
||||||
|
"time the data was published or last updated). Access is granted per request via the 511NY "
|
||||||
|
"Data Feed Request Form; DRB's 2026-09-27 request went to the retired system. Overlays, "
|
||||||
|
"detection and frame archiving are not addressed (server-26#199)."),
|
||||||
|
terms_url="https://511ny.org/help/24",
|
||||||
|
# DAA permitted use: tag line + logo + link. The approved logo is not
|
||||||
|
# in the repo yet (owner action), so only the tag line + link render.
|
||||||
|
attribution="powered by 511NY",
|
||||||
|
attribution_url="https://511ny.org",
|
||||||
|
disclaimer=NYSDOT_DISCLAIMER,
|
||||||
|
issue="server-26#198",
|
||||||
|
),
|
||||||
|
formats="stills (60 s) + HLS on NYSDOT hosts (legacy shape)",
|
||||||
|
implemented=True,
|
||||||
|
notes=("Legacy request/record shape behind settings.ny511_api_base + ny511_api_key, plus a tolerant "
|
||||||
|
"parser for the 511x XML camera inventory. UNVERIFIED until the DAA grant documents the new "
|
||||||
|
"endpoints: base URL, query params, XML element names, still/HLS URL shape and CORS. The "
|
||||||
|
"legacy endpoint has returned HTML since the 511x cutover, so expect a feed error until then."),
|
||||||
|
homepage="https://511ny.org",
|
||||||
|
key_setting="ny511_api_key",
|
||||||
|
ttl_s=ny511.CAMERAS_TTL_S,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fetch_catalog(self) -> List[Camera]:
|
||||||
|
rows = await ny511._fetch("getcameras", ny511.normalize_camera)
|
||||||
|
out = [
|
||||||
|
make_camera("ny511", r["id"], r["lat"], r["lon"], name=r["name"], roadway=r["roadway"],
|
||||||
|
direction=r["direction"], image_url=r["image_url"], video_url=r["video_url"])
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
return require_parsed(len(rows), out, "511NY")
|
||||||
|
|
||||||
|
|
||||||
|
NYC_DOT = StubProvider(ProviderMeta(
|
||||||
|
id="nyc_dot",
|
||||||
|
name="NYC DOT TMC (webcams.nyctmc.org)",
|
||||||
|
region="New York City (~975 cams, ~80 in the Bronx)",
|
||||||
|
coverage=(40.45, -74.30, 40.95, -73.65),
|
||||||
|
licence=LicenceInfo(
|
||||||
|
status=Licence.AGREEMENT_REQUIRED,
|
||||||
|
label="Requires a signed NYC DOT data-sharing agreement -- NOT signed",
|
||||||
|
terms=("NYC DOT Data Feeds page: \"Developers must sign a data-sharing agreement\" (tmcdot@dot.nyc.gov). "
|
||||||
|
"The API answers keyless, but keyless use is outside those terms. Agreement terms unpublished."),
|
||||||
|
terms_url="https://www.nyc.gov/html/dot/html/about/datafeeds.shtml",
|
||||||
|
issue="server-26#201",
|
||||||
|
),
|
||||||
|
formats="JPEG stills (~15 s), no CORS",
|
||||||
|
implemented=False,
|
||||||
|
notes="Stub on purpose: do not integrate until the agreement is signed (server-26#201). Many of these cams "
|
||||||
|
"are likely already re-published by 511NY.",
|
||||||
|
homepage="https://webcams.nyctmc.org",
|
||||||
|
))
|
||||||
|
|
||||||
|
WESTCHESTER_DPW = StubProvider(ProviderMeta(
|
||||||
|
id="westchester_dpw",
|
||||||
|
name="Westchester County DPW traffic cameras",
|
||||||
|
region="Westchester County (~21 locations, mostly Bronx River Pkwy)",
|
||||||
|
coverage=(40.88, -73.99, 41.37, -73.48),
|
||||||
|
licence=LicenceInfo(
|
||||||
|
status=Licence.AGREEMENT_REQUIRED,
|
||||||
|
label="No reuse licence -- written permission from County DPW required, NOT obtained",
|
||||||
|
terms="Only a disclaimer: data \"provided 'as is' without warranty\". No API, no reuse licence.",
|
||||||
|
terms_url="https://experience.arcgis.com/experience/a59314e926e94301b9083edd6bb52a11",
|
||||||
|
issue="server-26#202",
|
||||||
|
),
|
||||||
|
formats="stills (5 s), viewer only",
|
||||||
|
implemented=False,
|
||||||
|
notes="Stub: there is no documented API or feed, and no permission. Ask DPW in writing (server-26#202).",
|
||||||
|
homepage="https://www.westchestergov.com/trafficcamera",
|
||||||
|
))
|
||||||
|
|
||||||
|
TRANSCOM = StubProvider(ProviderMeta(
|
||||||
|
id="transcom_xcm",
|
||||||
|
name="TRANSCOM XCM data exchange",
|
||||||
|
region="NY/NJ/CT tri-state",
|
||||||
|
coverage=TRISTATE_BBOX,
|
||||||
|
licence=LicenceInfo(
|
||||||
|
status=Licence.UNVERIFIED,
|
||||||
|
label="Events/travel times commercial OK; cameras UNVERIFIED",
|
||||||
|
terms="Free registration, open to \"commercial vendors ... for use in their applications\". Whether the "
|
||||||
|
"feed carries camera URLs is unverified.",
|
||||||
|
terms_url="https://data.xcmdata.org/DEWeb/Pages/aboutus",
|
||||||
|
),
|
||||||
|
formats="XML events + link travel times (cameras unverified)",
|
||||||
|
implemented=False,
|
||||||
|
notes="Stub: register first, then confirm whether any camera inventory exists. Its events feed would be a "
|
||||||
|
"separate (event) provider, not a camera one.",
|
||||||
|
homepage="https://data.xcmdata.org",
|
||||||
|
))
|
||||||
|
|
||||||
|
CTROADS = StubProvider(ProviderMeta(
|
||||||
|
id="ctroads",
|
||||||
|
name="CTDOT / CTroads",
|
||||||
|
region="Connecticut",
|
||||||
|
coverage=(40.95, -73.75, 42.06, -71.78),
|
||||||
|
licence=LicenceInfo(
|
||||||
|
status=Licence.PROHIBITED,
|
||||||
|
label="No sanctioned camera path -- site terms forbid camera reuse",
|
||||||
|
terms="The keyed developer API covers Message Signs, Events and Advisories only -- no cameras. The site "
|
||||||
|
"T&C forbid use \"for any personal, public or commercial purpose\" and robots.",
|
||||||
|
terms_url="https://ctroads.org/termsandconditions",
|
||||||
|
),
|
||||||
|
formats="events/signs only (no cameras)",
|
||||||
|
implemented=False,
|
||||||
|
notes="Cannot be enabled. CT cameras may surface via 511NY's \"Connecticut Statewide\" region once the DAA "
|
||||||
|
"grant arrives.",
|
||||||
|
homepage="https://ctroads.org/developers/doc",
|
||||||
|
))
|
||||||
|
|
||||||
|
NJ511 = StubProvider(ProviderMeta(
|
||||||
|
id="nj511",
|
||||||
|
name="511NJ / NJDOT / NJTA / PANYNJ",
|
||||||
|
region="New Jersey",
|
||||||
|
coverage=(38.9, -75.6, 41.4, -73.9),
|
||||||
|
licence=LicenceInfo(
|
||||||
|
status=Licence.UNVERIFIED,
|
||||||
|
label="No published developer program -- reuse UNVERIFIED",
|
||||||
|
terms="511NJ is a JS app with no published developer program; NJDOT only cites RSS/Twitter.",
|
||||||
|
),
|
||||||
|
formats="unknown",
|
||||||
|
implemented=False,
|
||||||
|
notes="Stub: ask NJDOT 511 if northern NJ matters. Some cams appear in 511NY.",
|
||||||
|
))
|
||||||
|
|
||||||
|
PROVIDERS = [Ny511Provider(), NYC_DOT, WESTCHESTER_DPW, TRANSCOM, CTROADS, NJ511]
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"""Camera provider registry: settings, licence gate, and bbox queries.
|
||||||
|
|
||||||
|
Settings live in Firestore ``config/camera_providers``::
|
||||||
|
|
||||||
|
{"providers": {"<id>": {"enabled": bool, "updated_at": iso, "updated_by": {...},
|
||||||
|
"acknowledgement": {"by_uid", "by_email", "at", "licence_status", "warning"} | None}}}
|
||||||
|
|
||||||
|
Rules (owner brief, 2026-10-04):
|
||||||
|
* every provider defaults OFF -- a missing doc/entry means off;
|
||||||
|
* a stub (no adapter) or a PROHIBITED source can never be enabled;
|
||||||
|
* a GATED licence (agreement required / non-commercial / unverified / paid)
|
||||||
|
can only be enabled with an explicit acknowledgement, recorded with who and
|
||||||
|
when in the settings doc AND audit_log. The audit write is a precondition
|
||||||
|
for a gated enable: no record, no enable;
|
||||||
|
* an acknowledgement is bound to the licence status it was given for. If a
|
||||||
|
provider's catalogued status changes in code, the old acknowledgement no
|
||||||
|
longer counts and the provider stops serving until re-acknowledged.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.internal import firestore as fstore
|
||||||
|
from app.internal.cameras import general, regional
|
||||||
|
from app.internal.cameras.base import (
|
||||||
|
BBox, Camera, CameraProvider, Catalog, Licence, bbox_intersects, in_bbox,
|
||||||
|
)
|
||||||
|
from app.internal.logger import logger
|
||||||
|
|
||||||
|
COLLECTION, DOC_ID = "config", "camera_providers"
|
||||||
|
_TTL_S = 30.0
|
||||||
|
MAX_ITEMS = 1500 # per response, same cap as /traffic/511
|
||||||
|
|
||||||
|
PROVIDERS: Dict[str, CameraProvider] = {p.meta.id: p for p in [*regional.PROVIDERS, *general.PROVIDERS]}
|
||||||
|
CATALOGS: Dict[str, Catalog] = {pid: Catalog(p) for pid, p in PROVIDERS.items() if p.meta.implemented}
|
||||||
|
|
||||||
|
_cache: Dict[str, dict] = {}
|
||||||
|
_cache_ts = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderError(Exception):
|
||||||
|
"""A refused settings change. ``code`` is the HTTP status the router returns."""
|
||||||
|
|
||||||
|
def __init__(self, code: int, message: str, **extra):
|
||||||
|
super().__init__(message)
|
||||||
|
self.code, self.message, self.extra = code, message, extra
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_cache() -> None:
|
||||||
|
global _cache_ts
|
||||||
|
_cache_ts = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
async def get_settings() -> Dict[str, dict]:
|
||||||
|
"""Per-provider settings. Unreadable Firestore means everything is OFF (fail closed)."""
|
||||||
|
global _cache, _cache_ts
|
||||||
|
if _cache_ts and time.monotonic() - _cache_ts < _TTL_S:
|
||||||
|
return _cache
|
||||||
|
try:
|
||||||
|
doc = await fstore.doc_get(COLLECTION, DOC_ID) or {}
|
||||||
|
provs = doc.get("providers") if isinstance(doc.get("providers"), dict) else {}
|
||||||
|
_cache = {k: v for k, v in provs.items() if isinstance(v, dict)}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"camera providers: settings unreadable ({e}); treating every provider as OFF")
|
||||||
|
_cache = {}
|
||||||
|
_cache_ts = time.monotonic()
|
||||||
|
return _cache
|
||||||
|
|
||||||
|
|
||||||
|
def effective(provider: CameraProvider, entry: Optional[dict]) -> Tuple[bool, Optional[str]]:
|
||||||
|
"""(serving?, reason-if-not) for a provider given its settings entry."""
|
||||||
|
meta = provider.meta
|
||||||
|
if not meta.implemented:
|
||||||
|
return False, "adapter is a stub"
|
||||||
|
if meta.licence.status == Licence.PROHIBITED:
|
||||||
|
return False, "source terms forbid this use"
|
||||||
|
if not entry or not entry.get("enabled"):
|
||||||
|
return False, "disabled (default)"
|
||||||
|
if meta.licence.gated:
|
||||||
|
ack = entry.get("acknowledgement") or {}
|
||||||
|
if ack.get("licence_status") != meta.licence.status.value:
|
||||||
|
return False, "licence acknowledgement missing or out of date"
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
|
||||||
|
async def is_serving(provider_id: str) -> bool:
|
||||||
|
p = PROVIDERS.get(provider_id)
|
||||||
|
if p is None:
|
||||||
|
return False
|
||||||
|
return effective(p, (await get_settings()).get(provider_id))[0]
|
||||||
|
|
||||||
|
|
||||||
|
async def set_enabled(provider_id: str, enabled: bool, actor: Tuple[str, str],
|
||||||
|
acknowledge_licence: bool = False) -> dict:
|
||||||
|
p = PROVIDERS.get(provider_id)
|
||||||
|
if p is None:
|
||||||
|
raise ProviderError(404, f"unknown camera provider '{provider_id}'")
|
||||||
|
meta, lic = p.meta, p.meta.licence
|
||||||
|
uid, email = actor
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
entry: dict = {"enabled": bool(enabled), "updated_at": now, "updated_by": {"uid": uid, "email": email}}
|
||||||
|
|
||||||
|
if enabled:
|
||||||
|
if not meta.implemented:
|
||||||
|
raise ProviderError(409, f"{meta.name}: no working adapter -- {meta.notes}")
|
||||||
|
if lic.status == Licence.PROHIBITED:
|
||||||
|
raise ProviderError(409, lic.warning() or "prohibited")
|
||||||
|
if lic.gated:
|
||||||
|
if not acknowledge_licence:
|
||||||
|
raise ProviderError(409, f"{meta.name}: licence acknowledgement required",
|
||||||
|
requires_acknowledgement=True, warning=lic.warning(),
|
||||||
|
licence=lic.as_dict())
|
||||||
|
entry["acknowledgement"] = {"by_uid": uid, "by_email": email, "at": now,
|
||||||
|
"licence_status": lic.status.value, "warning": lic.warning()}
|
||||||
|
# Precondition, not a side effect: a gated enable with no audit
|
||||||
|
# record must not happen. A failure here aborts before the write.
|
||||||
|
try:
|
||||||
|
await _audit(actor, "camera_provider.enable_acknowledged", provider_id,
|
||||||
|
{"licence_status": lic.status.value, "licence_label": lic.label,
|
||||||
|
"warning": lic.warning(), "acknowledged_at": now})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"camera providers: audit write failed, refusing gated enable of {provider_id} ({e})")
|
||||||
|
raise ProviderError(503, "audit log unavailable -- a licence-gated provider cannot be enabled "
|
||||||
|
"without an audit record; try again")
|
||||||
|
else:
|
||||||
|
entry["acknowledgement"] = None # re-enabling a gated source needs a fresh acknowledgement
|
||||||
|
|
||||||
|
before = (await get_settings()).get(provider_id) or {}
|
||||||
|
await fstore.doc_set(COLLECTION, DOC_ID, {"providers": {provider_id: entry}}, merge=True)
|
||||||
|
invalidate_cache()
|
||||||
|
if not (enabled and lic.gated):
|
||||||
|
try:
|
||||||
|
await _audit(actor, "camera_provider.enable" if enabled else "camera_provider.disable", provider_id,
|
||||||
|
{"from": bool(before.get("enabled")), "to": bool(enabled),
|
||||||
|
"licence_status": lic.status.value})
|
||||||
|
except Exception as e: # the toggle already happened; never 500 a completed write
|
||||||
|
logger.error(f"camera providers: audit write failed for {provider_id} ({e})")
|
||||||
|
logger.info(f"camera provider {provider_id} -> enabled={enabled} by {email or uid}")
|
||||||
|
return await describe(provider_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def _audit(actor: Tuple[str, str], action: str, provider_id: str, details: dict) -> None:
|
||||||
|
from app.internal import audit
|
||||||
|
await audit.write_audit(actor_uid=actor[0], actor_email=actor[1], action=action,
|
||||||
|
details={"provider_id": provider_id, **details})
|
||||||
|
|
||||||
|
|
||||||
|
async def describe(provider_id: str) -> dict:
|
||||||
|
p = PROVIDERS[provider_id]
|
||||||
|
entry = (await get_settings()).get(provider_id) or {}
|
||||||
|
serving, reason = effective(p, entry)
|
||||||
|
m = p.meta
|
||||||
|
cat = CATALOGS.get(provider_id)
|
||||||
|
key_missing = bool(m.key_setting) and not getattr(settings, m.key_setting, None)
|
||||||
|
return {
|
||||||
|
"id": m.id, "name": m.name, "region": m.region, "formats": m.formats,
|
||||||
|
"implemented": m.implemented, "notes": m.notes, "homepage": m.homepage,
|
||||||
|
"licence": m.licence.as_dict(),
|
||||||
|
"enabled": bool(entry.get("enabled")),
|
||||||
|
"serving": serving, "not_serving_reason": reason,
|
||||||
|
"acknowledgement": entry.get("acknowledgement"),
|
||||||
|
"updated_at": entry.get("updated_at"), "updated_by": entry.get("updated_by"),
|
||||||
|
"key_setting": m.key_setting, "key_missing": key_missing,
|
||||||
|
"health": cat.status() if cat else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def list_providers() -> List[dict]:
|
||||||
|
await get_settings()
|
||||||
|
return [await describe(pid) for pid in PROVIDERS]
|
||||||
|
|
||||||
|
|
||||||
|
def credit(provider_id: str) -> dict:
|
||||||
|
lic = PROVIDERS[provider_id].meta.licence
|
||||||
|
return {"attribution": lic.attribution, "attribution_url": lic.attribution_url, "disclaimer": lic.disclaimer}
|
||||||
|
|
||||||
|
|
||||||
|
async def cameras_in_bbox(bbox: BBox) -> dict:
|
||||||
|
"""Cameras from every SERVING provider whose coverage meets ``bbox``.
|
||||||
|
Providers that are off are never fetched."""
|
||||||
|
conf = await get_settings()
|
||||||
|
live = [pid for pid, cat in CATALOGS.items()
|
||||||
|
if effective(PROVIDERS[pid], conf.get(pid))[0] and bbox_intersects(PROVIDERS[pid].meta.coverage, bbox)]
|
||||||
|
await asyncio.gather(*(CATALOGS[pid].get() for pid in live))
|
||||||
|
cams: List[Camera] = []
|
||||||
|
providers = []
|
||||||
|
for pid in live:
|
||||||
|
cat = CATALOGS[pid]
|
||||||
|
hits = in_bbox(cat.items, bbox)
|
||||||
|
room = max(0, MAX_ITEMS - len(cams))
|
||||||
|
cams.extend(hits[:room])
|
||||||
|
providers.append({"id": pid, "name": PROVIDERS[pid].meta.name, **credit(pid),
|
||||||
|
"fetched_at": cat.fetched_at, "error": cat.error,
|
||||||
|
"total_in_bbox": len(hits), "truncated": len(hits) > room})
|
||||||
|
return {"cameras": cams, "providers": providers,
|
||||||
|
"serving_count": sum(1 for pid in CATALOGS if effective(PROVIDERS[pid], conf.get(pid))[0])}
|
||||||
@@ -9,6 +9,25 @@ makes no 511 calls at all.
|
|||||||
A failed refresh keeps serving the last good data and reports the error and
|
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
|
its age to the caller -- an empty layer must never be the only symptom of a
|
||||||
dead feed (the AI-silent-failures lesson).
|
dead feed (the AI-silent-failures lesson).
|
||||||
|
|
||||||
|
**Feed status (server-26#198), 2026-10-04:** 511ny.org cut over to the CARS
|
||||||
|
"511x" platform around 2026-09-24..30 and the legacy ``/api/getcameras`` and
|
||||||
|
``/api/getevents`` endpoints now answer HTTP 200 with the SPA's HTML. The new
|
||||||
|
developer feed is granted only after the NYSDOT Developer Access Agreement
|
||||||
|
request is approved ("If approved for use, you will receive instructions for
|
||||||
|
accessing the data feeds & documentation" -- 511NY "For Developers" page), so
|
||||||
|
its URL shape is NOT public. This client therefore keeps the LEGACY request and
|
||||||
|
record shape behind ``settings.ny511_api_base`` (default: the legacy base) and
|
||||||
|
``settings.ny511_api_key``. UNVERIFIED until the grant arrives:
|
||||||
|
* the new base URL and whether it still takes ``?key=&format=json``;
|
||||||
|
* whether cameras still come as JSON (the public feed list says
|
||||||
|
"Camera inventory (XML)") and keep the legacy field names;
|
||||||
|
* the still/HLS URL shape and its CORS headers.
|
||||||
|
Never point this at the new site's own backend (api-511x-nysdot.carsprogram.org):
|
||||||
|
the 511NY Terms of Use ban robots and reverse engineering outside the DAA.
|
||||||
|
|
||||||
|
Access is also gated by the camera-provider registry (``ny511`` provider,
|
||||||
|
default OFF, DAA acknowledgement required) -- see app/internal/cameras/.
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
@@ -20,11 +39,14 @@ import httpx
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.internal.logger import logger
|
from app.internal.logger import logger
|
||||||
|
|
||||||
_BASE = "https://511ny.org/api"
|
LEGACY_BASE = "https://511ny.org/api"
|
||||||
CAMERAS_TTL_S = 60 * 60 # camera list is near-static
|
CAMERAS_TTL_S = 60 * 60 # camera list is near-static
|
||||||
EVENTS_TTL_S = 2 * 60 # accidents/closures change minute to minute
|
EVENTS_TTL_S = 2 * 60 # accidents/closures change minute to minute
|
||||||
RETRY_AFTER_FAILURE_S = 60
|
RETRY_AFTER_FAILURE_S = 60
|
||||||
_DESCRIPTION_MAX = 500
|
# The DAA forbids altering "the substance of a report", so descriptions are
|
||||||
|
# shown as published. This cap only guards against a pathological record; no
|
||||||
|
# real 511NY description comes near it, and a cut one is marked with an ellipsis.
|
||||||
|
_DESCRIPTION_MAX = 4000
|
||||||
|
|
||||||
|
|
||||||
class _Feed:
|
class _Feed:
|
||||||
@@ -61,9 +83,22 @@ async def _fetch(path: str, normalize) -> List[Dict[str, Any]]:
|
|||||||
if settings.ny511_api_key:
|
if settings.ny511_api_key:
|
||||||
params["key"] = settings.ny511_api_key
|
params["key"] = settings.ny511_api_key
|
||||||
async with httpx.AsyncClient(timeout=20.0) as client:
|
async with httpx.AsyncClient(timeout=20.0) as client:
|
||||||
r = await client.get(f"{_BASE}/{path}", params=params)
|
base = (settings.ny511_api_base or LEGACY_BASE).rstrip("/")
|
||||||
|
r = await client.get(f"{base}/{path}", params=params)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
raw = r.json()
|
text = getattr(r, "text", None)
|
||||||
|
if isinstance(text, str) and text.lstrip()[:1] == "<":
|
||||||
|
# The 511x camera inventory is published as XML; HTML is the dead
|
||||||
|
# legacy endpoint serving the SPA shell.
|
||||||
|
raw = parse_inventory_xml(text)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
raw = r.json()
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValueError(
|
||||||
|
"511NY answered neither JSON nor XML (the legacy API has served the 511x "
|
||||||
|
"site's HTML since the cutover, server-26#198)"
|
||||||
|
) from e
|
||||||
if not isinstance(raw, list):
|
if not isinstance(raw, list):
|
||||||
raise ValueError(f"expected a JSON list, got {type(raw).__name__}")
|
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]
|
out = [n for n in (normalize(x) for x in raw) if n is not None]
|
||||||
@@ -73,6 +108,69 @@ async def _fetch(path: str, normalize) -> List[Dict[str, Any]]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ── XML camera inventory (511x) -- UNVERIFIED shape ──────────────────────────
|
||||||
|
# The 511NY "For Developers" page lists the camera feed as "Camera inventory
|
||||||
|
# (XML)" but publishes no schema (it arrives with the DAA grant). This parser is
|
||||||
|
# deliberately tolerant: any element carrying latitude + longitude children (or
|
||||||
|
# attributes) is a record, and child names are matched case-insensitively onto
|
||||||
|
# the legacy JSON field names so normalize_camera() works unchanged. Replace the
|
||||||
|
# alias table with the real element names once the grant's documentation is in hand.
|
||||||
|
_XML_ALIASES = {
|
||||||
|
"ID": ("id", "cameraid", "camera_id", "deviceid", "device_id"),
|
||||||
|
"Latitude": ("latitude", "lat"),
|
||||||
|
"Longitude": ("longitude", "lon", "lng", "long"),
|
||||||
|
"Name": ("name", "cameraname", "location", "description", "title"),
|
||||||
|
"RoadwayName": ("roadwayname", "roadway", "road", "route"),
|
||||||
|
"DirectionOfTravel": ("directionoftravel", "direction"),
|
||||||
|
"Url": ("url", "imageurl", "image_url", "snapshoturl", "image", "stillurl"),
|
||||||
|
"VideoUrl": ("videourl", "video_url", "streamurl", "video", "hlsurl"),
|
||||||
|
"Disabled": ("disabled",),
|
||||||
|
"Blocked": ("blocked",),
|
||||||
|
}
|
||||||
|
_XML_LOOKUP = {alias: key for key, aliases in _XML_ALIASES.items() for alias in aliases}
|
||||||
|
_XML_MAX_BYTES = 32 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def _local(tag: str) -> str:
|
||||||
|
return tag.rsplit("}", 1)[-1].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _xml_bool(v: Any) -> Any:
|
||||||
|
if isinstance(v, str) and v.strip().lower() in ("true", "false", "1", "0"):
|
||||||
|
return v.strip().lower() in ("true", "1")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def parse_inventory_xml(text: str) -> List[Dict[str, Any]]:
|
||||||
|
"""Map an XML camera inventory onto legacy-shaped dicts. Raises on HTML or
|
||||||
|
on any DTD/entity declaration (stdlib expat would expand entities)."""
|
||||||
|
head = text.lstrip()[:512].lower()
|
||||||
|
if head.startswith("<!doctype html") or head.startswith("<html"):
|
||||||
|
raise ValueError("511NY answered HTML, not a feed (legacy API dead since the 511x cutover, server-26#198)")
|
||||||
|
if len(text) > _XML_MAX_BYTES:
|
||||||
|
raise ValueError("511NY XML inventory larger than the 32 MB safety cap")
|
||||||
|
if "<!doctype" in text.lower() or "<!entity" in text.lower():
|
||||||
|
raise ValueError("511NY XML carries a DTD/entity declaration -- refused")
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
root = ET.fromstring(text)
|
||||||
|
out: List[Dict[str, Any]] = []
|
||||||
|
for el in root.iter():
|
||||||
|
fields: Dict[str, Any] = {}
|
||||||
|
for k, v in el.attrib.items():
|
||||||
|
key = _XML_LOOKUP.get(_local(k))
|
||||||
|
if key:
|
||||||
|
fields.setdefault(key, v)
|
||||||
|
for child in el:
|
||||||
|
key = _XML_LOOKUP.get(_local(child.tag))
|
||||||
|
if key and len(child) == 0 and child.text is not None:
|
||||||
|
fields.setdefault(key, child.text.strip())
|
||||||
|
if "Latitude" in fields and "Longitude" in fields:
|
||||||
|
fields["Disabled"] = _xml_bool(fields.get("Disabled", False))
|
||||||
|
fields["Blocked"] = _xml_bool(fields.get("Blocked", False))
|
||||||
|
out.append(fields)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _coords(x: Dict[str, Any]) -> Optional[tuple]:
|
def _coords(x: Dict[str, Any]) -> Optional[tuple]:
|
||||||
try:
|
try:
|
||||||
lat, lon = float(x["Latitude"]), float(x["Longitude"])
|
lat, lon = float(x["Latitude"]), float(x["Longitude"])
|
||||||
|
|||||||
@@ -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, traffic
|
from app.routers import enrollment, media, org, waitlist, telemetry, replay, traffic, cameras
|
||||||
from app.internal import dynsec
|
from app.internal import dynsec
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
|
|
||||||
@@ -128,6 +128,8 @@ app.include_router(alerts.router, dependencies=[Depends(require_service_or_fi
|
|||||||
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(traffic.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||||
|
app.include_router(cameras.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||||
|
app.include_router(cameras.admin_router) # auth: admin only, per endpoint (needs the principal for audit)
|
||||||
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)
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Public-camera endpoints for the live ops wall.
|
||||||
|
|
||||||
|
``GET /cameras`` -- any signed-in user (auth applied at include time in main.py):
|
||||||
|
cameras inside a bbox from every provider an admin has enabled, each with the
|
||||||
|
provider's required attribution, disclaimer and catalog freshness.
|
||||||
|
|
||||||
|
``/admin/camera-providers`` -- platform admin only: list every catalogued
|
||||||
|
provider with its licence status and health, and toggle it. Gated licences
|
||||||
|
need ``acknowledge_licence: true``; that acknowledgement is stored with who and
|
||||||
|
when and written to audit_log (see app/internal/cameras/registry.py).
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.internal.auth import describe_actor, require_admin_token
|
||||||
|
from app.internal.cameras import registry
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/cameras", tags=["cameras"])
|
||||||
|
admin_router = APIRouter(prefix="/admin/camera-providers", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def get_cameras(
|
||||||
|
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),
|
||||||
|
):
|
||||||
|
if south > north or west > east:
|
||||||
|
raise HTTPException(400, "bbox must satisfy south<=north and west<=east")
|
||||||
|
return await registry.cameras_in_bbox((south, west, north, east))
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderToggle(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
acknowledge_licence: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.get("")
|
||||||
|
async def list_camera_providers(_=Depends(require_admin_token)):
|
||||||
|
return {"providers": await registry.list_providers()}
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.put("/{provider_id}")
|
||||||
|
async def set_camera_provider(provider_id: str, body: ProviderToggle, principal: dict = Depends(require_admin_token)):
|
||||||
|
try:
|
||||||
|
return await registry.set_enabled(provider_id, body.enabled, describe_actor(principal),
|
||||||
|
acknowledge_licence=body.acknowledge_licence)
|
||||||
|
except registry.ProviderError as e:
|
||||||
|
return JSONResponse(status_code=e.code, content={"detail": e.message, **e.extra})
|
||||||
@@ -4,6 +4,7 @@ from typing import Optional
|
|||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
|
||||||
from app.internal import ny511
|
from app.internal import ny511
|
||||||
|
from app.internal.cameras import registry
|
||||||
|
|
||||||
router = APIRouter(prefix="/traffic", tags=["traffic"])
|
router = APIRouter(prefix="/traffic", tags=["traffic"])
|
||||||
|
|
||||||
@@ -27,6 +28,13 @@ async def get_511(
|
|||||||
if south > north or west > east:
|
if south > north or west > east:
|
||||||
raise HTTPException(400, "bbox must satisfy south<=north and 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()}
|
wanted = {s.strip() for s in (layers or "").split(",") if s.strip()}
|
||||||
|
# 511NY data (events included) is covered by the same NYSDOT DAA as its
|
||||||
|
# cameras, so the whole feed follows the ny511 camera provider's switch,
|
||||||
|
# which defaults OFF and needs a licence acknowledgement (server-26#198).
|
||||||
|
if not await registry.is_serving("ny511"):
|
||||||
|
off = {"fetched_at": None, "error": None, "total_in_bbox": 0, "truncated": False, "disabled": True}
|
||||||
|
return {**{n: [] for n in ("cameras", "events") if n in wanted},
|
||||||
|
**{f"{n}_status": off for n in ("cameras", "events") if n in wanted}}
|
||||||
feeds = {name: getattr(ny511, name) for name in ("cameras", "events") if name in wanted}
|
feeds = {name: getattr(ny511, name) for name in ("cameras", "events") if name in wanted}
|
||||||
await asyncio.gather(*(f.get() for f in feeds.values()))
|
await asyncio.gather(*(f.get() for f in feeds.values()))
|
||||||
|
|
||||||
@@ -35,4 +43,5 @@ async def get_511(
|
|||||||
hits = ny511.in_bbox(feed.items, south, west, north, east)
|
hits = ny511.in_bbox(feed.items, south, west, north, east)
|
||||||
out[name] = hits[:MAX_ITEMS]
|
out[name] = hits[:MAX_ITEMS]
|
||||||
out[f"{name}_status"] = {**_feed_status(feed), "total_in_bbox": len(hits), "truncated": len(hits) > MAX_ITEMS}
|
out[f"{name}_status"] = {**_feed_status(feed), "total_in_bbox": len(hits), "truncated": len(hits) > MAX_ITEMS}
|
||||||
|
out["credit"] = registry.credit("ny511") # DAA: source credit + as-is disclaimer travel with the data
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
"""Live ops wall camera providers: registry, licence gate, admin routes, adapters.
|
||||||
|
|
||||||
|
What is held here:
|
||||||
|
* every provider defaults OFF, and an off provider is never fetched;
|
||||||
|
* stubs and PROHIBITED sources can never be enabled;
|
||||||
|
* a gated licence needs ``acknowledge_licence``, which is stored with who/when
|
||||||
|
and audited -- and an audit failure refuses the enable (fail closed);
|
||||||
|
* an acknowledgement given for a different licence status no longer counts;
|
||||||
|
* the admin routes are admin-only;
|
||||||
|
* each implemented adapter parses its (verified) upstream shape, pins imagery
|
||||||
|
to the agency's hosts, and treats an all-unparseable payload as an error.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.internal.auth import require_admin_token, require_service_or_firebase_token
|
||||||
|
from app.internal.cameras import base, general, regional, registry
|
||||||
|
from app.internal.cameras.base import Licence
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
ADMIN = {"uid": "admin-1", "email": "admin@example.com", "role": "admin"}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeStore:
|
||||||
|
def __init__(self):
|
||||||
|
self.docs = {}
|
||||||
|
self.audit = []
|
||||||
|
|
||||||
|
async def doc_get(self, col, doc):
|
||||||
|
return self.docs.get((col, doc))
|
||||||
|
|
||||||
|
async def doc_set(self, col, doc, data, merge=True):
|
||||||
|
cur = self.docs.setdefault((col, doc), {})
|
||||||
|
if col == "audit_log":
|
||||||
|
self.audit.append(data)
|
||||||
|
return
|
||||||
|
for k, v in data.items(): # one-level deep merge is all registry needs
|
||||||
|
if merge and isinstance(v, dict) and isinstance(cur.get(k), dict):
|
||||||
|
cur[k] = {**cur[k], **v}
|
||||||
|
else:
|
||||||
|
cur[k] = v
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def store():
|
||||||
|
s = FakeStore()
|
||||||
|
registry.invalidate_cache()
|
||||||
|
for cat in registry.CATALOGS.values():
|
||||||
|
cat.items, cat.fetched_at, cat.error, cat._next_attempt = [], None, None, 0.0
|
||||||
|
with patch.object(registry.fstore, "doc_get", s.doc_get), patch.object(registry.fstore, "doc_set", s.doc_set), \
|
||||||
|
patch("app.internal.audit.fstore.doc_set", s.doc_set):
|
||||||
|
yield s
|
||||||
|
registry.invalidate_cache()
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def run(coro):
|
||||||
|
return asyncio.run(coro)
|
||||||
|
|
||||||
|
|
||||||
|
# ── registry / gate ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_every_catalogued_source_is_registered_and_defaults_off():
|
||||||
|
listed = run(registry.list_providers())
|
||||||
|
ids = {p["id"] for p in listed}
|
||||||
|
assert {"ny511", "nyc_dot", "westchester_dpw", "caltrans", "deldot", "tfl", "ontario511", "drivebc",
|
||||||
|
"calgary", "nsw", "fintraffic", "txdot", "austin", "tallinn", "tarktee", "warendorf"} <= ids
|
||||||
|
assert all(p["enabled"] is False and p["serving"] is False for p in listed)
|
||||||
|
assert all(p["licence"]["label"] for p in listed)
|
||||||
|
|
||||||
|
|
||||||
|
def test_off_provider_is_never_fetched():
|
||||||
|
fetch = AsyncMock(return_value=[])
|
||||||
|
with patch.object(registry.PROVIDERS["tfl"], "fetch_catalog", fetch):
|
||||||
|
out = run(registry.cameras_in_bbox((51.4, -0.2, 51.6, 0.1)))
|
||||||
|
assert out["cameras"] == [] and out["providers"] == [] and fetch.await_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_stub_and_prohibited_cannot_be_enabled():
|
||||||
|
for pid in ("nyc_dot", "westchester_dpw", "ctroads", "trafficland"):
|
||||||
|
with pytest.raises(registry.ProviderError) as e:
|
||||||
|
run(registry.set_enabled(pid, True, ("u", "e"), acknowledge_licence=True))
|
||||||
|
assert e.value.code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_attribution_licence_enables_without_acknowledgement_and_is_audited(store):
|
||||||
|
out = run(registry.set_enabled("nsw", True, ("u1", "a@x")))
|
||||||
|
assert out["serving"] is True and out["acknowledgement"] is None
|
||||||
|
assert store.audit[-1]["action"] == "camera_provider.enable"
|
||||||
|
assert store.audit[-1]["actor_email"] == "a@x"
|
||||||
|
|
||||||
|
|
||||||
|
def test_gated_licence_requires_acknowledgement(store):
|
||||||
|
with pytest.raises(registry.ProviderError) as e:
|
||||||
|
run(registry.set_enabled("ny511", True, ("u1", "a@x")))
|
||||||
|
assert e.value.code == 409 and e.value.extra["requires_acknowledgement"] is True
|
||||||
|
assert "Developer Access Agreement" in e.value.extra["warning"]
|
||||||
|
assert not store.audit and not run(registry.is_serving("ny511"))
|
||||||
|
|
||||||
|
out = run(registry.set_enabled("ny511", True, ("u1", "a@x"), acknowledge_licence=True))
|
||||||
|
assert out["serving"] is True
|
||||||
|
ack = out["acknowledgement"]
|
||||||
|
assert ack["by_uid"] == "u1" and ack["by_email"] == "a@x" and ack["at"]
|
||||||
|
assert ack["licence_status"] == Licence.AGREEMENT_REQUIRED.value
|
||||||
|
assert store.audit[-1]["action"] == "camera_provider.enable_acknowledged"
|
||||||
|
assert store.audit[-1]["details"]["provider_id"] == "ny511"
|
||||||
|
|
||||||
|
|
||||||
|
def test_gated_enable_refused_when_audit_cannot_be_written(store):
|
||||||
|
with patch.object(registry, "_audit", AsyncMock(side_effect=RuntimeError("firestore down"))):
|
||||||
|
with pytest.raises(registry.ProviderError) as e:
|
||||||
|
run(registry.set_enabled("deldot", True, ("u", "e"), acknowledge_licence=True))
|
||||||
|
assert e.value.code == 503
|
||||||
|
assert not run(registry.is_serving("deldot"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_disable_clears_acknowledgement(store):
|
||||||
|
run(registry.set_enabled("deldot", True, ("u", "e"), acknowledge_licence=True))
|
||||||
|
out = run(registry.set_enabled("deldot", False, ("u", "e")))
|
||||||
|
assert out["serving"] is False and out["acknowledgement"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_acknowledgement_stops_serving():
|
||||||
|
p = registry.PROVIDERS["deldot"]
|
||||||
|
entry = {"enabled": True, "acknowledgement": {"licence_status": "non_commercial"}}
|
||||||
|
assert registry.effective(p, entry) == (False, "licence acknowledgement missing or out of date")
|
||||||
|
entry["acknowledgement"]["licence_status"] = p.meta.licence.status.value
|
||||||
|
assert registry.effective(p, entry) == (True, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unreadable_settings_fail_closed():
|
||||||
|
with patch.object(registry.fstore, "doc_get", AsyncMock(side_effect=RuntimeError("boom"))):
|
||||||
|
registry.invalidate_cache()
|
||||||
|
assert run(registry.get_settings()) == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_returns_attribution_and_skips_providers_outside_bbox(store):
|
||||||
|
run(registry.set_enabled("nsw", True, ("u", "e")))
|
||||||
|
run(registry.set_enabled("tfl", True, ("u", "e")))
|
||||||
|
cam = base.make_camera("nsw", "x", -33.87, 151.2, name="Sydney", image_url="https://webcams.transport.nsw.gov.au/a.jpg")
|
||||||
|
nsw_fetch, tfl_fetch = AsyncMock(return_value=[cam]), AsyncMock(return_value=[])
|
||||||
|
with patch.object(registry.PROVIDERS["nsw"], "fetch_catalog", nsw_fetch), \
|
||||||
|
patch.object(registry.PROVIDERS["tfl"], "fetch_catalog", tfl_fetch):
|
||||||
|
out = run(registry.cameras_in_bbox((-34.0, 151.0, -33.7, 151.4)))
|
||||||
|
assert [c["id"] for c in out["cameras"]] == ["nsw:x"]
|
||||||
|
assert out["providers"][0]["attribution"] == "Transport for NSW, CC BY 4.0"
|
||||||
|
assert tfl_fetch.await_count == 0 # London never fetched for a Sydney bbox
|
||||||
|
|
||||||
|
|
||||||
|
# ── admin routes ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_admin_routes_require_admin():
|
||||||
|
def deny():
|
||||||
|
raise HTTPException(403, "Admin access required")
|
||||||
|
app.dependency_overrides[require_admin_token] = deny
|
||||||
|
assert client.get("/admin/camera-providers").status_code == 403
|
||||||
|
assert client.put("/admin/camera-providers/nsw", json={"enabled": True}).status_code == 403
|
||||||
|
assert client.get("/admin/camera-providers", headers={}).status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_route_toggle_and_gate(store):
|
||||||
|
app.dependency_overrides[require_admin_token] = lambda: ADMIN
|
||||||
|
r = client.put("/admin/camera-providers/caltrans", json={"enabled": True})
|
||||||
|
assert r.status_code == 409 and r.json()["requires_acknowledgement"] is True
|
||||||
|
r = client.put("/admin/camera-providers/caltrans", json={"enabled": True, "acknowledge_licence": True})
|
||||||
|
assert r.status_code == 200 and r.json()["acknowledgement"]["by_email"] == "admin@example.com"
|
||||||
|
assert client.put("/admin/camera-providers/nope", json={"enabled": True}).status_code == 404
|
||||||
|
listed = client.get("/admin/camera-providers").json()["providers"]
|
||||||
|
assert next(p for p in listed if p["id"] == "caltrans")["serving"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_cameras_route_rejects_inverted_bbox():
|
||||||
|
app.dependency_overrides[require_service_or_firebase_token] = lambda: {"uid": "v"}
|
||||||
|
assert client.get("/cameras", params={"south": 2, "west": 0, "north": 1, "east": 1}).status_code == 400
|
||||||
|
r = client.get("/cameras", params={"south": 40.9, "west": -74.0, "north": 41.4, "east": -73.4})
|
||||||
|
assert r.status_code == 200 and r.json()["cameras"] == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── adapters ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _with_payload(payload):
|
||||||
|
return patch.object(general, "get_json", AsyncMock(return_value=payload))
|
||||||
|
|
||||||
|
|
||||||
|
def test_heading_from_text():
|
||||||
|
assert base.heading_from_text("Northbound") == 0
|
||||||
|
assert base.heading_from_text("WB") == 270
|
||||||
|
assert base.heading_from_text("SE") == 135
|
||||||
|
assert base.heading_from_text("Both directions") is None
|
||||||
|
assert base.heading_from_text("Unknown") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_https_on_pins_hosts():
|
||||||
|
assert base.https_on("https://cwwp2.dot.ca.gov/x.jpg", "dot.ca.gov")
|
||||||
|
assert base.https_on("http://cwwp2.dot.ca.gov/x.jpg", "dot.ca.gov") is None
|
||||||
|
assert base.https_on("https://dot.ca.gov.evil.com/x.jpg", "dot.ca.gov") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_caltrans_adapter():
|
||||||
|
row = {"cctv": {"inService": "true", "index": "1", "location": {
|
||||||
|
"district": "4", "locationName": "TV102 -- I-580 : West of SR-24", "latitude": "37.82539",
|
||||||
|
"longitude": "-122.27291", "direction": "West", "route": "I-580"},
|
||||||
|
"imageData": {"streamingVideoURL": "https://wzmedia.dot.ca.gov/D4/x.stream/playlist.m3u8",
|
||||||
|
"static": {"currentImageURL": "https://cwwp2.dot.ca.gov/data/d4/cctv/image/a/a.jpg"}}}}
|
||||||
|
off = {"cctv": {**row["cctv"], "inService": "false"}}
|
||||||
|
with _with_payload({"data": [row, off]}):
|
||||||
|
cams = run(general.CaltransProvider().fetch_catalog())
|
||||||
|
assert len(cams) == len(general.CaltransProvider.DISTRICTS) # same row per district fetch
|
||||||
|
c = cams[0]
|
||||||
|
assert c["id"] == "caltrans:d4-TV102" and c["heading_deg"] == 270 and c["video_url"].endswith(".m3u8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_deldot_adapter_requires_https_hls():
|
||||||
|
good = {"id": "KCAM001", "title": "DE 1", "lat": 38.99, "lon": -75.44, "enabled": True, "status": "Active",
|
||||||
|
"urls": {"m3u8s": "https://video.deldot.gov:443/live/KCAM001.stream/playlist.m3u8",
|
||||||
|
"m3u8": "http://video.deldot.gov:1935/live/KCAM001.stream/playlist.m3u8"}}
|
||||||
|
down = {**good, "id": "KCAM002", "status": "Unavailable"}
|
||||||
|
with _with_payload({"videoCameras": [good, down]}):
|
||||||
|
cams = run(general.DelDotProvider().fetch_catalog())
|
||||||
|
assert [c["id"] for c in cams] == ["deldot:KCAM001"] and cams[0]["image_url"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_drivebc_calgary_tfl_nsw_fintraffic_adapters():
|
||||||
|
with _with_payload([{"id": 1176, "name": "Lougheed", "is_on": True, "should_appear": True, "orientation": "W",
|
||||||
|
"location": {"coordinates": [-122.5, 49.2]}, "highway_description": "Hwy 7"}]):
|
||||||
|
assert run(general.DriveBcProvider().fetch_catalog())[0]["image_url"] == "https://www.drivebc.ca/images/1176.jpg"
|
||||||
|
with _with_payload([{"camera_url": {"url": "http://trafficcam.calgary.ca/loc86.jpg"}, "camera_location": "Stoney",
|
||||||
|
"point": {"coordinates": [-113.97, 50.9]}, "quadrant": "SE"}]):
|
||||||
|
c = run(general.CalgaryProvider().fetch_catalog())[0]
|
||||||
|
assert c["image_url"] == "https://trafficcam.calgary.ca/loc86.jpg" and c["id"] == "calgary:loc86"
|
||||||
|
with _with_payload([{"id": "JamCams_00002.00865", "commonName": "A406", "lat": 51.6, "lon": -0.016,
|
||||||
|
"additionalProperties": [{"key": "available", "value": "true"},
|
||||||
|
{"key": "imageUrl", "value": general.TflProvider.IMAGE_PREFIX + "a.jpg"}]}]):
|
||||||
|
assert run(general.TflProvider().fetch_catalog())[0]["id"] == "tfl:00002.00865"
|
||||||
|
with _with_payload({"features": [{"id": "abc", "geometry": {"coordinates": [151.1, -34.0]},
|
||||||
|
"properties": {"title": "5 Ways", "direction": "W",
|
||||||
|
"href": "https://webcams.transport.nsw.gov.au/x.jpeg"}}]}):
|
||||||
|
assert run(general.NswProvider().fetch_catalog())[0]["heading_deg"] == 270
|
||||||
|
with _with_payload({"features": [{"geometry": {"coordinates": [23.99, 60.05, 0]},
|
||||||
|
"properties": {"id": "C01503", "name": "kt51", "collectionStatus": "GATHERING",
|
||||||
|
"presets": [{"id": "C0150301", "inCollection": True},
|
||||||
|
{"id": "C0150302", "inCollection": False}]}}]}):
|
||||||
|
cams = run(general.FintrafficProvider().fetch_catalog())
|
||||||
|
assert [c["image_url"] for c in cams] == ["https://weathercam.digitraffic.fi/C0150301.jpg"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_schema_change_is_an_error_not_an_empty_catalog():
|
||||||
|
with _with_payload([{"renamed": True}]):
|
||||||
|
with pytest.raises(ValueError, match="schema"):
|
||||||
|
run(general.DriveBcProvider().fetch_catalog())
|
||||||
|
|
||||||
|
|
||||||
|
def test_ontario_needs_its_key(monkeypatch):
|
||||||
|
monkeypatch.setattr(general.settings, "ontario511_api_key", None, raising=False)
|
||||||
|
with pytest.raises(RuntimeError, match="ONTARIO511_API_KEY"):
|
||||||
|
run(general.Ontario511Provider().fetch_catalog())
|
||||||
|
|
||||||
|
|
||||||
|
def test_ny511_provider_wraps_legacy_client_and_carries_daa_metadata():
|
||||||
|
row = {"id": "NYSDOT-1", "lat": 41.03, "lon": -73.76, "name": "I-287", "roadway": "I-287",
|
||||||
|
"direction": "Northbound", "image_url": "https://x/1", "video_url": None}
|
||||||
|
with patch.object(regional.ny511, "_fetch", AsyncMock(return_value=[row])):
|
||||||
|
cams = run(regional.Ny511Provider().fetch_catalog())
|
||||||
|
assert cams[0]["id"] == "ny511:NYSDOT-1" and cams[0]["heading_deg"] == 0
|
||||||
|
lic = regional.Ny511Provider.meta.licence
|
||||||
|
assert lic.attribution == "powered by 511NY" and lic.attribution_url == "https://511ny.org"
|
||||||
|
assert "with all faults" in lic.disclaimer and lic.gated
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_refresh_keeps_last_good_catalog():
|
||||||
|
cat = registry.CATALOGS["nsw"]
|
||||||
|
cam = base.make_camera("nsw", "x", -33.8, 151.2)
|
||||||
|
with patch.object(cat.provider, "fetch_catalog", AsyncMock(return_value=[cam])):
|
||||||
|
run(cat.get())
|
||||||
|
cat._next_attempt = 0.0
|
||||||
|
with patch.object(cat.provider, "fetch_catalog", AsyncMock(side_effect=RuntimeError("down"))):
|
||||||
|
run(cat.get())
|
||||||
|
assert cat.items == [cam] and "down" in cat.error and cat.fetched_at
|
||||||
@@ -43,11 +43,14 @@ def test_normalize_camera_skips_disabled_blocked_and_zero_coords():
|
|||||||
assert ny511.normalize_camera({**CAM, "Latitude": 0, "Longitude": 0}) is None
|
assert ny511.normalize_camera({**CAM, "Latitude": 0, "Longitude": 0}) is None
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_event_parses_day_first_dates_and_truncates_description():
|
def test_normalize_event_parses_day_first_dates_and_keeps_description_whole():
|
||||||
e = ny511.normalize_event(EVENT)
|
e = ny511.normalize_event(EVENT)
|
||||||
assert e["start_local"] == "2026-09-28T09:00:00" # DD/MM, not MM/DD
|
assert e["start_local"] == "2026-09-28T09:00:00" # DD/MM, not MM/DD
|
||||||
assert e["planned_end_local"] is None
|
assert e["planned_end_local"] is None
|
||||||
assert len(e["description"]) == ny511._DESCRIPTION_MAX + 1
|
# DAA: "You may not alter the substance of a report" -- no 500-char cut any more.
|
||||||
|
assert e["description"] == EVENT["Description"]
|
||||||
|
long = ny511.normalize_event({**EVENT, "Description": "y" * (ny511._DESCRIPTION_MAX + 50)})
|
||||||
|
assert len(long["description"]) == ny511._DESCRIPTION_MAX + 1 # pathological only
|
||||||
|
|
||||||
|
|
||||||
def test_failed_refresh_keeps_last_good_data_and_reports_error():
|
def test_failed_refresh_keeps_last_good_data_and_reports_error():
|
||||||
@@ -80,14 +83,29 @@ def test_schema_change_is_an_error_not_an_empty_layer():
|
|||||||
assert "schema" in str(e)
|
assert "schema" in str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def _serving(value: bool):
|
||||||
|
from app.internal.cameras import registry
|
||||||
|
return patch.object(registry, "is_serving", AsyncMock(return_value=value))
|
||||||
|
|
||||||
|
|
||||||
|
def test_endpoint_is_off_until_the_ny511_provider_is_enabled():
|
||||||
|
with _serving(False):
|
||||||
|
r = client.get("/traffic/511", params={"south": 40.9, "west": -74.0, "north": 41.4, "east": -73.4})
|
||||||
|
body = r.json()
|
||||||
|
assert r.status_code == 200 and body["cameras"] == [] and body["events"] == []
|
||||||
|
assert body["events_status"]["disabled"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_endpoint_filters_to_bbox_and_reports_status():
|
def test_endpoint_filters_to_bbox_and_reports_status():
|
||||||
far = {**CAM, "ID": "NYSDOT-2", "Latitude": 42.9, "Longitude": -78.8} # Buffalo
|
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)):
|
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.items = [norm(r) for r in rows]
|
||||||
feed.fetched_at, feed._next_attempt = 1.0, float("inf")
|
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})
|
with _serving(True):
|
||||||
|
r = client.get("/traffic/511", params={"south": 40.9, "west": -74.0, "north": 41.4, "east": -73.4})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
body = r.json()
|
body = r.json()
|
||||||
|
assert body["credit"]["attribution"] == "powered by 511NY" and "as is" in body["credit"]["disclaimer"]
|
||||||
assert [c["id"] for c in body["cameras"]] == ["NYSDOT-1"]
|
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 body["cameras_status"] == {"fetched_at": 1.0, "error": None, "total_in_bbox": 1, "truncated": False}
|
||||||
assert len(body["events"]) == 1
|
assert len(body["events"]) == 1
|
||||||
@@ -96,3 +114,24 @@ def test_endpoint_filters_to_bbox_and_reports_status():
|
|||||||
def test_endpoint_rejects_inverted_bbox():
|
def test_endpoint_rejects_inverted_bbox():
|
||||||
r = client.get("/traffic/511", params={"south": 41.4, "west": -74.0, "north": 40.9, "east": -73.4})
|
r = client.get("/traffic/511", params={"south": 41.4, "west": -74.0, "north": 40.9, "east": -73.4})
|
||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_xml_inventory_maps_onto_legacy_fields():
|
||||||
|
xml = """<?xml version="1.0"?><cameras xmlns="urn:x">
|
||||||
|
<camera><ID>NYSDOT-9</ID><Latitude>41.05</Latitude><Longitude>-73.78</Longitude>
|
||||||
|
<Name>I-287 at Exit 4</Name><RoadwayName>I-287</RoadwayName><DirectionOfTravel>Eastbound</DirectionOfTravel>
|
||||||
|
<ImageUrl>https://example.invalid/still.jpg</ImageUrl><Disabled>false</Disabled></camera>
|
||||||
|
<camera id="NYSDOT-10" latitude="41.1" longitude="-73.7"><Disabled>true</Disabled></camera>
|
||||||
|
</cameras>"""
|
||||||
|
rows = ny511.parse_inventory_xml(xml)
|
||||||
|
cams = [ny511.normalize_camera(r) for r in rows]
|
||||||
|
assert cams[0]["id"] == "NYSDOT-9" and cams[0]["image_url"] == "https://example.invalid/still.jpg"
|
||||||
|
assert cams[1] is None # disabled flag honoured from XML text
|
||||||
|
|
||||||
|
|
||||||
|
def test_xml_parser_refuses_html_and_entities():
|
||||||
|
import pytest
|
||||||
|
with pytest.raises(ValueError, match="HTML"):
|
||||||
|
ny511.parse_inventory_xml("<!doctype html><html><body>511NY</body></html>")
|
||||||
|
with pytest.raises(ValueError, match="DTD"):
|
||||||
|
ny511.parse_inventory_xml('<?xml version="1.0"?><!DOCTYPE x [<!ENTITY a "b">]><x>&a;</x>')
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useAuth } from "@/components/AuthProvider";
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
import { c2api } from "@/lib/c2api";
|
import { c2api } from "@/lib/c2api";
|
||||||
import { ReplayTab } from "@/components/admin/ReplayTab";
|
import { ReplayTab } from "@/components/admin/ReplayTab";
|
||||||
|
import { CameraProvidersTab } from "@/components/admin/CameraProvidersTab";
|
||||||
import { useEffect, useState, useRef, useCallback } from "react";
|
import { useEffect, useState, useRef, useCallback } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { UserRecord, AuditEntry, UserRole, CallRecord } from "@/lib/types";
|
import type { UserRecord, AuditEntry, UserRole, CallRecord } from "@/lib/types";
|
||||||
@@ -1267,10 +1268,11 @@ function SttEvalTab() {
|
|||||||
// Main admin page
|
// Main admin page
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
type AdminTab = "features" | "correlation" | "replay" | "users" | "audit" | "calls" | "eval";
|
type AdminTab = "features" | "cameras" | "correlation" | "replay" | "users" | "audit" | "calls" | "eval";
|
||||||
|
|
||||||
const TAB_LABELS: { key: AdminTab; label: string }[] = [
|
const TAB_LABELS: { key: AdminTab; label: string }[] = [
|
||||||
{ key: "features", label: "AI Features" },
|
{ key: "features", label: "AI Features" },
|
||||||
|
{ key: "cameras", label: "Camera providers" },
|
||||||
{ key: "correlation", label: "Correlation Debug" },
|
{ key: "correlation", label: "Correlation Debug" },
|
||||||
{ key: "replay", label: "Replay" },
|
{ key: "replay", label: "Replay" },
|
||||||
{ key: "calls", label: "Calls" },
|
{ key: "calls", label: "Calls" },
|
||||||
@@ -1297,7 +1299,7 @@ export default function AdminPage() {
|
|||||||
if (!isAdmin) return null;
|
if (!isAdmin) return null;
|
||||||
|
|
||||||
// Users/Audit tabs benefit from full width; everything else is narrow
|
// Users/Audit tabs benefit from full width; everything else is narrow
|
||||||
const wide = tab === "users" || tab === "audit" || tab === "replay";
|
const wide = tab === "users" || tab === "audit" || tab === "replay" || tab === "cameras";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`space-y-6 ${wide ? "" : "max-w-2xl"}`}>
|
<div className={`space-y-6 ${wide ? "" : "max-w-2xl"}`}>
|
||||||
@@ -1318,6 +1320,7 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tab === "features" && <FeaturesTab />}
|
{tab === "features" && <FeaturesTab />}
|
||||||
|
{tab === "cameras" && <CameraProvidersTab />}
|
||||||
{tab === "correlation" && <CorrelationDebugTab />}
|
{tab === "correlation" && <CorrelationDebugTab />}
|
||||||
{tab === "replay" && <ReplayTab />}
|
{tab === "replay" && <ReplayTab />}
|
||||||
{tab === "calls" && <StaleCallsTab />}
|
{tab === "calls" && <StaleCallsTab />}
|
||||||
|
|||||||
@@ -246,3 +246,18 @@ html:not(.dark) .marketing-hero-bg {
|
|||||||
.skeleton {
|
.skeleton {
|
||||||
animation: skeleton-pulse 1.6s ease-in-out infinite;
|
animation: skeleton-pulse 1.6s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Live ops wall transcript ticker ─────────────────────────────────────────
|
||||||
|
* The belt holds the items twice, so translating by -50% loops seamlessly.
|
||||||
|
* Paused on hover/focus; reduced-motion users get a static, scrollable row. */
|
||||||
|
@keyframes drb-ticker {
|
||||||
|
from { transform: translateX(0); }
|
||||||
|
to { transform: translateX(-50%); }
|
||||||
|
}
|
||||||
|
.drb-ticker-belt { animation: drb-ticker var(--drb-ticker-duration, 90s) linear infinite; }
|
||||||
|
.drb-ticker:hover .drb-ticker-belt,
|
||||||
|
.drb-ticker:focus-within .drb-ticker-belt { animation-play-state: paused; }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.drb-ticker-belt { animation: none; }
|
||||||
|
.drb-ticker { overflow-x: auto; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,41 +1,217 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||||
import type HlsType from "hls.js";
|
import type HlsType from "hls.js";
|
||||||
import type { Ny511Camera } from "@/lib/types";
|
import type { Camera, CameraCredit } from "@/lib/types";
|
||||||
|
|
||||||
// 511NY stills are served with max-age=60, so refreshing faster buys nothing.
|
// ── Feed hygiene (design-canvas/gods-eye-view-notes.md §2, top-5 #2) ─────────
|
||||||
|
// A 24/7 wall must not burn bandwidth on feeds nobody can see:
|
||||||
|
// * hidden tab or off-screen tile → no fetches, live video torn down;
|
||||||
|
// * at most MAX_LIVE_HLS live decoders page-wide (GEV uses 2); extra tiles
|
||||||
|
// show their still with a "live cap" badge (the drill-down gets one extra);
|
||||||
|
// * a failed refresh keeps the last good frame with a "stale Nm" badge;
|
||||||
|
// * a 64×36 downsample signature detects a frozen camera (same frame for
|
||||||
|
// FROZEN_AFTER_MS) without repainting it; a stalled HLS stream falls back.
|
||||||
|
// Video is played straight from the agency's host (no DRB egress).
|
||||||
|
|
||||||
|
// Most stills are cached ~60 s upstream, so refreshing faster buys nothing.
|
||||||
const SNAPSHOT_REFRESH_MS = 60_000;
|
const SNAPSHOT_REFRESH_MS = 60_000;
|
||||||
// A failed stream is not given up on for good: 511NY feeds drop out briefly,
|
|
||||||
// and a permanent fallback left pinned tiles showing a stale still image.
|
|
||||||
const VIDEO_RETRY_MS = 30_000;
|
const VIDEO_RETRY_MS = 30_000;
|
||||||
// In-place recoveries hls.js gets per session before we fall back to the snapshot.
|
|
||||||
const MAX_HLS_RECOVERIES = 3;
|
const MAX_HLS_RECOVERIES = 3;
|
||||||
|
const VIDEO_STALL_MS = 20_000;
|
||||||
|
const FROZEN_AFTER_MS = 20 * 60_000; // > the slowest provider cadence (Fintraffic ~10 min)
|
||||||
|
export const MAX_LIVE_HLS = 2;
|
||||||
|
|
||||||
|
// ── Page-wide live-stream leases ─────────────────────────────────────────────
|
||||||
|
const holders = new Set<number>();
|
||||||
|
const leaseListeners = new Set<() => void>();
|
||||||
|
let nextLeaseId = 1;
|
||||||
|
|
||||||
|
function tryAcquire(id: number, high: boolean): boolean {
|
||||||
|
if (holders.has(id)) return true;
|
||||||
|
if (holders.size < MAX_LIVE_HLS + (high ? 1 : 0)) {
|
||||||
|
holders.add(id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function release(id: number) {
|
||||||
|
if (holders.delete(id)) leaseListeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
function useLiveLease(want: boolean, high: boolean): boolean {
|
||||||
|
const idRef = useRef(0);
|
||||||
|
if (!idRef.current) idRef.current = nextLeaseId++;
|
||||||
|
const [granted, setGranted] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const id = idRef.current;
|
||||||
|
if (!want) { release(id); setGranted(false); return; }
|
||||||
|
const attempt = () => setGranted(tryAcquire(id, high));
|
||||||
|
attempt();
|
||||||
|
leaseListeners.add(attempt);
|
||||||
|
return () => { leaseListeners.delete(attempt); release(id); };
|
||||||
|
}, [want, high]);
|
||||||
|
return granted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Visibility: tab shown AND element on screen ──────────────────────────────
|
||||||
|
function useOnScreen(ref: RefObject<HTMLElement | null>): boolean {
|
||||||
|
const [tabVisible, setTabVisible] = useState(true);
|
||||||
|
const [inView, setInView] = useState(true);
|
||||||
|
useEffect(() => {
|
||||||
|
const onVis = () => setTabVisible(!document.hidden);
|
||||||
|
onVis();
|
||||||
|
document.addEventListener("visibilitychange", onVis);
|
||||||
|
return () => document.removeEventListener("visibilitychange", onVis);
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el || typeof IntersectionObserver === "undefined") return;
|
||||||
|
const io = new IntersectionObserver((entries) => setInView(entries.some((e) => e.isIntersecting)), { rootMargin: "100px" });
|
||||||
|
io.observe(el);
|
||||||
|
return () => io.disconnect();
|
||||||
|
}, [ref]);
|
||||||
|
return tabVisible && inView;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Origins whose stills refuse CORS: shown without crossOrigin, no signature.
|
||||||
|
const noCorsOrigins = new Set<string>();
|
||||||
|
function originOf(url: string): string {
|
||||||
|
try { return new URL(url).origin; } catch { return url; }
|
||||||
|
}
|
||||||
|
function withBust(url: string, t: number): string {
|
||||||
|
return `${url}${url.includes("?") ? "&" : "?"}t=${t}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function signature(img: HTMLImageElement): number[] | null {
|
||||||
|
try {
|
||||||
|
const c = document.createElement("canvas");
|
||||||
|
c.width = 64;
|
||||||
|
c.height = 36;
|
||||||
|
const ctx = c.getContext("2d", { willReadFrequently: true });
|
||||||
|
if (!ctx) return null;
|
||||||
|
ctx.drawImage(img, 0, 0, 64, 36);
|
||||||
|
const d = ctx.getImageData(0, 0, 64, 36).data;
|
||||||
|
const out: number[] = [];
|
||||||
|
for (let i = 0; i < d.length; i += 4) out.push((d[i] + d[i + 1] + d[i + 2]) / 3);
|
||||||
|
return out;
|
||||||
|
} catch {
|
||||||
|
return null; // tainted canvas
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function sameFrame(a: number[] | null, b: number[] | null): boolean {
|
||||||
|
if (!a || !b || a.length !== b.length) return false;
|
||||||
|
let diff = 0;
|
||||||
|
for (let i = 0; i < a.length; i++) diff += Math.abs(a[i] - b[i]);
|
||||||
|
return diff / a.length < 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ageLabel(ms: number): string {
|
||||||
|
const m = Math.floor(ms / 60_000);
|
||||||
|
return m < 1 ? "<1m" : m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${m % 60 ? ` ${m % 60}m` : ""}`;
|
||||||
|
}
|
||||||
|
function clock(t: number): string {
|
||||||
|
return new Date(t).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
camera: Camera;
|
||||||
|
className?: string;
|
||||||
|
/** "still" never starts video (popups, thumbnails). */
|
||||||
|
mode?: "auto" | "still";
|
||||||
|
/** The drill-down camera may take one stream past the page-wide cap. */
|
||||||
|
priority?: "normal" | "high";
|
||||||
|
/** Required attribution for this provider — rendered on the imagery itself. */
|
||||||
|
credit?: CameraCredit | null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One 511NY camera: the live HLS stream when the camera has one (NYSDOT's
|
* One public camera: live HLS when the camera has a stream and a lease is
|
||||||
* skyvdn hosts send Access-Control-Allow-Origin: *), otherwise its snapshot on
|
* free, otherwise its still on a timer — with honest state badges and the
|
||||||
* a timer. A stream that fails falls back to the snapshot rather than a black box,
|
* provider's attribution on the frame. A stream that fails or stalls falls
|
||||||
* and video is retried every VIDEO_RETRY_MS while it stays down.
|
* back to the still and is retried every VIDEO_RETRY_MS.
|
||||||
*/
|
*/
|
||||||
export function CameraFeed({ camera, className }: { camera: Ny511Camera; className?: string }) {
|
export function CameraFeed({ camera, className, mode = "auto", priority = "normal", credit }: Props) {
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const onScreen = useOnScreen(rootRef);
|
||||||
const [videoFailed, setVideoFailed] = useState(false);
|
const [videoFailed, setVideoFailed] = useState(false);
|
||||||
const [tick, setTick] = useState(() => Date.now());
|
const wantsVideo = mode === "auto" && !!camera.video_url && !videoFailed && onScreen;
|
||||||
const useVideo = !!camera.video_url && !videoFailed;
|
const leased = useLiveLease(wantsVideo, priority === "high");
|
||||||
|
const useVideo = wantsVideo && leased;
|
||||||
|
const capped = wantsVideo && !leased;
|
||||||
|
|
||||||
|
// Still state
|
||||||
|
const [shownSrc, setShownSrc] = useState<string | null>(null);
|
||||||
|
const [corsOk, setCorsOk] = useState(true);
|
||||||
|
const [lastGoodAt, setLastGoodAt] = useState<number | null>(null);
|
||||||
|
const [frameSince, setFrameSince] = useState<number | null>(null); // when the shown frame first appeared
|
||||||
|
const [stale, setStale] = useState(false);
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
const sigRef = useRef<number[] | null>(null);
|
||||||
|
|
||||||
|
// Reset when the camera changes
|
||||||
|
useEffect(() => {
|
||||||
|
setShownSrc(null); setLastGoodAt(null); setFrameSince(null); setStale(false); setVideoFailed(false);
|
||||||
|
sigRef.current = null;
|
||||||
|
}, [camera.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (useVideo) return;
|
const id = setInterval(() => { if (!document.hidden) setNow(Date.now()); }, 15_000);
|
||||||
const id = setInterval(() => setTick(Date.now()), SNAPSHOT_REFRESH_MS);
|
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, [useVideo]);
|
}, []);
|
||||||
|
|
||||||
|
// Still polling — only while on screen and not playing video
|
||||||
|
useEffect(() => {
|
||||||
|
const url = camera.image_url;
|
||||||
|
if (useVideo || !onScreen || !url) return;
|
||||||
|
let cancelled = false;
|
||||||
|
let probe: HTMLImageElement | null = null;
|
||||||
|
const load = (allowCors: boolean) => {
|
||||||
|
const origin = originOf(url);
|
||||||
|
const cors = allowCors && !noCorsOrigins.has(origin);
|
||||||
|
const src = withBust(url, Date.now());
|
||||||
|
probe = new Image();
|
||||||
|
if (cors) probe.crossOrigin = "anonymous";
|
||||||
|
probe.onload = () => {
|
||||||
|
if (cancelled || !probe) return;
|
||||||
|
const t = Date.now();
|
||||||
|
const sig = cors ? signature(probe) : null;
|
||||||
|
const unchanged = sameFrame(sig, sigRef.current);
|
||||||
|
sigRef.current = sig;
|
||||||
|
setStale(false);
|
||||||
|
setLastGoodAt(t);
|
||||||
|
setCorsOk(cors);
|
||||||
|
if (!unchanged) {
|
||||||
|
setShownSrc(src);
|
||||||
|
setFrameSince(t);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
probe.onerror = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (cors) { noCorsOrigins.add(origin); load(false); return; }
|
||||||
|
setStale(true); // keep the last good frame on screen
|
||||||
|
};
|
||||||
|
probe.src = src;
|
||||||
|
};
|
||||||
|
load(true);
|
||||||
|
const id = setInterval(() => load(true), SNAPSHOT_REFRESH_MS);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(id);
|
||||||
|
if (probe) { probe.onload = null; probe.onerror = null; probe.src = ""; } // abort in-flight
|
||||||
|
};
|
||||||
|
}, [camera.image_url, useVideo, onScreen]);
|
||||||
|
|
||||||
|
// Retry a failed stream later
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!videoFailed || !camera.video_url) return;
|
if (!videoFailed || !camera.video_url) return;
|
||||||
const id = setTimeout(() => setVideoFailed(false), VIDEO_RETRY_MS);
|
const id = setTimeout(() => setVideoFailed(false), VIDEO_RETRY_MS);
|
||||||
return () => clearTimeout(id);
|
return () => clearTimeout(id);
|
||||||
}, [videoFailed, camera.video_url]);
|
}, [videoFailed, camera.video_url]);
|
||||||
|
|
||||||
|
// HLS player + stall watchdog
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
const src = camera.video_url;
|
const src = camera.video_url;
|
||||||
@@ -65,20 +241,58 @@ export function CameraFeed({ camera, className }: { camera: Ny511Camera; classNa
|
|||||||
})
|
})
|
||||||
.catch(() => setVideoFailed(true));
|
.catch(() => setVideoFailed(true));
|
||||||
}
|
}
|
||||||
|
let lastT = -1;
|
||||||
|
let lastMove = Date.now();
|
||||||
|
const watchdog = setInterval(() => {
|
||||||
|
if (video.currentTime !== lastT) { lastT = video.currentTime; lastMove = Date.now(); return; }
|
||||||
|
if (Date.now() - lastMove > VIDEO_STALL_MS) setVideoFailed(true); // frozen stream → still
|
||||||
|
}, 5_000);
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
clearInterval(watchdog);
|
||||||
hls?.destroy();
|
hls?.destroy();
|
||||||
video.removeAttribute("src");
|
video.removeAttribute("src");
|
||||||
video.load(); // stop the download, not just the playback
|
video.load(); // stop the download, not just the playback
|
||||||
};
|
};
|
||||||
}, [useVideo, camera.video_url]);
|
}, [useVideo, camera.video_url]);
|
||||||
|
|
||||||
if (useVideo) {
|
// ── Badge ──
|
||||||
return <video ref={videoRef} muted autoPlay playsInline className={className} onError={() => setVideoFailed(true)} />;
|
let badge: { text: string; tone: "live" | "ok" | "warn" | "err" };
|
||||||
}
|
if (useVideo) badge = { text: "LIVE", tone: "live" };
|
||||||
if (!camera.image_url) {
|
else if (!camera.image_url) badge = { text: capped ? "LIVE CAP · STREAM PAUSED" : camera.video_url ? "STREAM DOWN" : "NO IMAGE", tone: "err" };
|
||||||
return <div className={`${className ?? ""} flex items-center justify-center text-[10px] text-ink-muted`}>No image</div>;
|
else if (!shownSrc) badge = { text: stale ? "FRAME · UNAVAILABLE" : "FRAME · LOADING", tone: stale ? "err" : "warn" };
|
||||||
}
|
else if (stale && lastGoodAt) badge = { text: `STALE · ${ageLabel(now - lastGoodAt)}`, tone: "warn" };
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
else if (frameSince && now - frameSince > FROZEN_AFTER_MS) badge = { text: `FROZEN? · unchanged ${ageLabel(now - frameSince)}`, tone: "warn" };
|
||||||
return <img src={`${camera.image_url}?t=${tick}`} alt={`Camera: ${camera.name}`} className={className} />;
|
else badge = { text: `STILL · ${frameSince ? clock(frameSince) : "—"}${capped ? " · LIVE CAP" : ""}`, tone: "ok" };
|
||||||
|
const toneClass = {
|
||||||
|
live: "bg-red-600/90 text-white",
|
||||||
|
ok: "bg-black/65 text-white",
|
||||||
|
warn: "bg-amber-500/90 text-black",
|
||||||
|
err: "bg-black/75 text-red-300",
|
||||||
|
}[badge.tone];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={rootRef} className={`relative overflow-hidden ${className ?? ""}`}>
|
||||||
|
{useVideo ? (
|
||||||
|
<video ref={videoRef} muted autoPlay playsInline className="w-full h-full object-cover" onError={() => setVideoFailed(true)} />
|
||||||
|
) : shownSrc ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={shownSrc} crossOrigin={corsOk ? "anonymous" : undefined} alt={`Camera: ${camera.name}`} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-[10px] text-ink-muted bg-black/60">
|
||||||
|
{camera.image_url ? "…" : "No still for this camera"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className={`absolute top-1 left-1 px-1 rounded-sm font-mono text-[9px] leading-[14px] tracking-wide ${toneClass}`} title="Frame time is when DRB first saw this frame">
|
||||||
|
{badge.text}
|
||||||
|
</span>
|
||||||
|
{credit?.attribution && (
|
||||||
|
<span className="absolute bottom-0 left-0 right-0 px-1 bg-black/60 text-white text-[9px] leading-[13px] truncate">
|
||||||
|
{credit.attribution_url ? (
|
||||||
|
<a href={credit.attribution_url} target="_blank" rel="noopener noreferrer" className="hover:underline">{credit.attribution}</a>
|
||||||
|
) : credit.attribution}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,39 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Live — the default landing for every authenticated user (UI_REDESIGN.md
|
* Live — the default landing for every authenticated user (UI_REDESIGN.md
|
||||||
* §3, §5.1). Full-bleed map with the incident rail/legend MapView already
|
* §3, §5.1), now the live ops wall (design-canvas/live-ops-wall-mockup.html):
|
||||||
* renders (chunk 5), plus a time-density scrubber strip below it.
|
* Region → Incident → Camera drill-down, worst-first incident rail (MapView),
|
||||||
|
* KPIs, transcript ticker, scrubber with incident bands, auto-tour and deep
|
||||||
|
* links (#inc=…&cam=…&tour). Every MapView layer stays — nodes, incidents,
|
||||||
|
* paths, ADS-B, AIS, cameras, viewsheds, traffic events, NEXRAD.
|
||||||
*/
|
*/
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { useNodes } from "@/lib/useNodes";
|
import { useNodes } from "@/lib/useNodes";
|
||||||
import { useActiveCalls, useCalls } from "@/lib/useCalls";
|
import { useActiveCalls, useCalls } from "@/lib/useCalls";
|
||||||
import { useActiveIncidents } from "@/lib/useIncidents";
|
import { useActiveIncidents } from "@/lib/useIncidents";
|
||||||
|
import { useAircraft } from "@/lib/useAircraft";
|
||||||
|
import { useVessels } from "@/lib/useVessels";
|
||||||
|
import { useCamerasInBbox } from "@/lib/useCameras";
|
||||||
|
import { c2api } from "@/lib/c2api";
|
||||||
|
import { severityRank } from "@/lib/severity";
|
||||||
|
import type { Camera, Ny511Response } from "@/lib/types";
|
||||||
|
import {
|
||||||
|
NEAR_CAMERA_KM, bboxAround, groupCallsByIncident, incidentPoints, km, nearestCameras, onAirIncidentIds,
|
||||||
|
parseWallHash, wallHash, worstFirst, type LatLon, type NearCamera, type WallLink,
|
||||||
|
} from "@/lib/liveWall";
|
||||||
import { TimeScrubber } from "@/components/TimeScrubber";
|
import { TimeScrubber } from "@/components/TimeScrubber";
|
||||||
|
import { TranscriptTicker } from "@/components/live/TranscriptTicker";
|
||||||
|
import { CameraDrill } from "@/components/live/CameraDrill";
|
||||||
|
import { IncidentPanel, RegionPanel } from "@/components/live/WallPanels";
|
||||||
|
|
||||||
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
|
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
|
||||||
|
|
||||||
|
const TOUR_MS = 20_000;
|
||||||
|
const PIN_KEY = "drb.wall.pinnedCameras";
|
||||||
|
const MAX_PINNED = 6;
|
||||||
|
const EVENTS_POLL_MS = 2 * 60 * 1000;
|
||||||
|
|
||||||
function timeAgo(date: Date): string {
|
function timeAgo(date: Date): string {
|
||||||
const s = Math.floor((Date.now() - date.getTime()) / 1000);
|
const s = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||||
if (s < 60) return `${s}s ago`;
|
if (s < 60) return `${s}s ago`;
|
||||||
@@ -21,59 +42,314 @@ function timeAgo(date: Date): string {
|
|||||||
return `${Math.floor(s / 3600)}h ago`;
|
return `${Math.floor(s / 3600)}h ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Per-viewer convenience only; the wall works without it. */
|
||||||
|
function loadPinned(): Camera[] {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(PIN_KEY);
|
||||||
|
const v = raw ? JSON.parse(raw) : [];
|
||||||
|
return Array.isArray(v) ? v.filter((c) => c && typeof c.id === "string").slice(0, MAX_PINNED) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function savePinned(cams: Camera[]) {
|
||||||
|
try { window.localStorage.setItem(PIN_KEY, JSON.stringify(cams)); } catch { /* storage blocked */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 511NY road events around the region (situational context + KPI). */
|
||||||
|
function useRegionEvents(box: ReturnType<typeof bboxAround>) {
|
||||||
|
const [data, setData] = useState<Ny511Response>({});
|
||||||
|
const key = box ? [box.south, box.west, box.north, box.east].map((n) => n.toFixed(2)).join(",") : "";
|
||||||
|
useEffect(() => {
|
||||||
|
if (!box) return;
|
||||||
|
const b = box;
|
||||||
|
let cancelled = false;
|
||||||
|
const load = () => {
|
||||||
|
if (document.hidden) return;
|
||||||
|
c2api.get511(b, "events").then((r) => { if (!cancelled) setData(r); }).catch((e) => console.error("region events:", e));
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
const id = setInterval(load, EVENTS_POLL_MS);
|
||||||
|
return () => { cancelled = true; clearInterval(id); };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [key]);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Kpi({ value, label }: { value: string | number; label: string }) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-baseline gap-1 text-xs text-ink-muted whitespace-nowrap">
|
||||||
|
<b className="text-ink font-semibold tabular-nums">{value}</b>{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function LiveView() {
|
export function LiveView() {
|
||||||
const { nodes, loading: nodesLoading } = useNodes();
|
const { nodes, loading: nodesLoading } = useNodes();
|
||||||
const activeCalls = useActiveCalls();
|
const activeCalls = useActiveCalls();
|
||||||
const activeIncidents = useActiveIncidents();
|
const activeIncidents = useActiveIncidents();
|
||||||
// Wide-ish recent window: feeds both the incident-path polyline (chunk 5)
|
// Wide-ish recent window: feeds incident paths, the call spine, the ticker
|
||||||
// and the scrubber's density bars. Not a fixture — real recent calls.
|
// and the scrubber's density bars. Real recent calls, not a fixture.
|
||||||
const { calls: recentCalls, loading: callsLoading } = useCalls(500);
|
const { calls: recentCalls, loading: callsLoading } = useCalls(500);
|
||||||
|
const { aircraft } = useAircraft();
|
||||||
|
const { vessels } = useVessels();
|
||||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||||
|
const [clock, setClock] = useState<string>("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!nodesLoading) setLastUpdated(new Date());
|
if (!nodesLoading) setLastUpdated(new Date());
|
||||||
}, [nodes, activeCalls, activeIncidents, nodesLoading]);
|
}, [nodes, activeCalls, activeIncidents, nodesLoading]);
|
||||||
|
useEffect(() => {
|
||||||
|
const tick = () => setClock(new Date().toLocaleTimeString([], { hour12: false }));
|
||||||
|
tick();
|
||||||
|
const id = setInterval(tick, 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Wall state, mirrored in the URL hash ──────────────────────────────────
|
||||||
|
const [link, setLinkState] = useState<WallLink>({ inc: null, cam: null, tour: false });
|
||||||
|
useEffect(() => {
|
||||||
|
const read = () => setLinkState(parseWallHash(window.location.hash));
|
||||||
|
read();
|
||||||
|
window.addEventListener("hashchange", read);
|
||||||
|
return () => window.removeEventListener("hashchange", read);
|
||||||
|
}, []);
|
||||||
|
const setLink = useCallback((next: WallLink) => {
|
||||||
|
setLinkState(next);
|
||||||
|
const h = wallHash(next);
|
||||||
|
window.history.replaceState(null, "", h || window.location.pathname + window.location.search);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [pinned, setPinned] = useState<Camera[]>([]);
|
||||||
|
useEffect(() => { setPinned(loadPinned()); }, []);
|
||||||
|
const pinnedIds = useMemo(() => new Set(pinned.map((c) => c.id)), [pinned]);
|
||||||
|
const togglePin = useCallback((cam: Camera) => {
|
||||||
|
setPinned((prev) => {
|
||||||
|
const next = prev.some((c) => c.id === cam.id) ? prev.filter((c) => c.id !== cam.id) : [...prev, cam].slice(-MAX_PINNED);
|
||||||
|
savePinned(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Geography ─────────────────────────────────────────────────────────────
|
||||||
|
const callsByIncident = useMemo(() => groupCallsByIncident(recentCalls), [recentCalls]);
|
||||||
|
const sorted = useMemo(() => worstFirst(activeIncidents), [activeIncidents]);
|
||||||
|
const pointsById = useMemo(() => {
|
||||||
|
const m = new Map<string, LatLon[]>();
|
||||||
|
for (const inc of activeIncidents) m.set(inc.incident_id, incidentPoints(inc, callsByIncident.get(inc.incident_id)));
|
||||||
|
return m;
|
||||||
|
}, [activeIncidents, callsByIncident]);
|
||||||
|
const regionBox = useMemo(() => {
|
||||||
|
const pts = Array.from(pointsById.values()).flat();
|
||||||
|
return bboxAround(pts.length ? pts : nodes.map((n) => [n.lat, n.lon] as LatLon), NEAR_CAMERA_KM + 0.5);
|
||||||
|
}, [pointsById, nodes]);
|
||||||
|
const { data: cams, loaded: camsLoaded } = useCamerasInBbox(regionBox);
|
||||||
|
const feeds = useMemo(() => new Map(cams.providers.map((p) => [p.id, p])), [cams.providers]);
|
||||||
|
const nearById = useMemo(() => {
|
||||||
|
const m = new Map<string, NearCamera[]>();
|
||||||
|
pointsById.forEach((pts, id) => m.set(id, nearestCameras(pts, cams.cameras)));
|
||||||
|
return m;
|
||||||
|
}, [pointsById, cams.cameras]);
|
||||||
|
const camCountByIncident = useMemo(() => new Map(Array.from(nearById.entries()).map(([id, n]) => [id, n.length])), [nearById]);
|
||||||
|
const events = useRegionEvents(regionBox);
|
||||||
|
const onAir = useMemo(() => onAirIncidentIds(activeIncidents, activeCalls), [activeIncidents, activeCalls]);
|
||||||
|
|
||||||
|
const selected = link.inc ? activeIncidents.find((i) => i.incident_id === link.inc) ?? null : null;
|
||||||
|
const selectedPoints = selected ? pointsById.get(selected.incident_id) ?? [] : [];
|
||||||
|
const near = selected ? nearById.get(selected.incident_id) ?? [] : [];
|
||||||
|
const drillCam: Camera | null = link.cam
|
||||||
|
? cams.cameras.find((c) => c.id === link.cam) ?? pinned.find((c) => c.id === link.cam) ?? null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Region wall: pinned first, then the nearest camera to each major/moderate incident.
|
||||||
|
const wallCameras = useMemo(() => {
|
||||||
|
const out: { cam: Camera; km?: number }[] = pinned.map((cam) => ({ cam }));
|
||||||
|
for (const inc of sorted) {
|
||||||
|
if (severityRank(inc.severity) < 2) continue;
|
||||||
|
const first = nearById.get(inc.incident_id)?.[0];
|
||||||
|
if (first && !out.some((o) => o.cam.id === first.cam.id)) out.push({ cam: first.cam, km: first.km });
|
||||||
|
}
|
||||||
|
return out.slice(0, 6);
|
||||||
|
}, [pinned, sorted, nearById]);
|
||||||
|
|
||||||
|
// ── Navigation ────────────────────────────────────────────────────────────
|
||||||
|
const selectIncident = useCallback((id: string) => setLink({ inc: id, cam: null, tour: false }), [setLink]);
|
||||||
|
const openCamera = useCallback((c: Camera) => setLink({ ...link, cam: c.id, tour: false }), [link, setLink]);
|
||||||
|
const stepUp = useCallback(() => {
|
||||||
|
if (link.cam) setLink({ ...link, cam: null, tour: false });
|
||||||
|
else if (link.inc) setLink({ inc: null, cam: null, tour: false });
|
||||||
|
else if (link.tour) setLink({ ...link, tour: false });
|
||||||
|
}, [link, setLink]);
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") stepUp(); };
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [stepUp]);
|
||||||
|
|
||||||
|
// ── Auto-tour: worst-first through major/moderate (else all plotted) ──────
|
||||||
|
const tourList = useMemo(() => {
|
||||||
|
const plotted = sorted.filter((i) => (pointsById.get(i.incident_id) ?? []).length);
|
||||||
|
const serious = plotted.filter((i) => severityRank(i.severity) >= 2);
|
||||||
|
return (serious.length ? serious : plotted).map((i) => i.incident_id);
|
||||||
|
}, [sorted, pointsById]);
|
||||||
|
const [tourStart, setTourStart] = useState(0);
|
||||||
|
const [tourNow, setTourNow] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!link.tour || !tourList.length) return;
|
||||||
|
const next = () => {
|
||||||
|
setLinkState((cur) => {
|
||||||
|
const i = cur.inc ? tourList.indexOf(cur.inc) : -1;
|
||||||
|
const nxt: WallLink = { inc: tourList[(i + 1) % tourList.length], cam: null, tour: true };
|
||||||
|
window.history.replaceState(null, "", wallHash(nxt));
|
||||||
|
return nxt;
|
||||||
|
});
|
||||||
|
setTourStart(Date.now());
|
||||||
|
};
|
||||||
|
if (!link.inc || !tourList.includes(link.inc)) next();
|
||||||
|
else setTourStart(Date.now());
|
||||||
|
const id = setInterval(next, TOUR_MS);
|
||||||
|
const prog = setInterval(() => setTourNow(Date.now()), 500);
|
||||||
|
return () => { clearInterval(id); clearInterval(prog); };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [link.tour, tourList.join(",")]);
|
||||||
|
const tourPct = link.tour && tourStart ? Math.min(100, ((tourNow - tourStart) / TOUR_MS) * 100) : 0;
|
||||||
|
|
||||||
|
// ── KPIs ──────────────────────────────────────────────────────────────────
|
||||||
|
const count = (sev: string) => activeIncidents.filter((i) => i.severity === sev).length;
|
||||||
|
const nodesUp = nodes.filter((n) => n.status !== "offline").length;
|
||||||
|
const eventsOff = !!events.events_status?.disabled;
|
||||||
|
|
||||||
const configuredButQuiet = !nodesLoading && nodes.length > 0 && activeIncidents.length === 0;
|
const configuredButQuiet = !nodesLoading && nodes.length > 0 && activeIncidents.length === 0;
|
||||||
const mostRecentSeen = configuredButQuiet
|
const mostRecentSeen = configuredButQuiet
|
||||||
? nodes
|
? nodes.map((n) => n.last_seen).filter((s): s is string => !!s).sort().at(-1)
|
||||||
.map((n) => n.last_seen)
|
|
||||||
.filter((s): s is string => !!s)
|
|
||||||
.sort()
|
|
||||||
.at(-1)
|
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const crumb = (label: string, active: boolean, enabled: boolean, onClick: () => void) => (
|
||||||
|
<button type="button" disabled={!enabled} onClick={onClick} aria-current={active ? "true" : undefined}
|
||||||
|
className={`px-1.5 py-0.5 rounded ${active ? "text-ink font-semibold" : enabled ? "text-ink-2 hover:text-ink" : "text-ink-muted"}`}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="relative flex-1 min-h-0">
|
{/* ── Wall bar: drill crumbs · KPIs · auto-tour · clock ── */}
|
||||||
{nodesLoading || callsLoading ? (
|
<div className="shrink-0 flex flex-wrap items-center gap-x-4 gap-y-1 px-3 py-1.5 border-b border-line bg-surface/95 text-xs">
|
||||||
<div className="w-full h-full flex items-center justify-center text-ink-muted text-sm">
|
<nav aria-label="Drill level" className="flex items-center gap-0.5">
|
||||||
Loading map…
|
{crumb("Region", !link.inc, true, () => setLink({ inc: null, cam: null, tour: false }))}
|
||||||
</div>
|
<span className="text-ink-muted">›</span>
|
||||||
) : (
|
{crumb(selected?.title ? `Incident: ${selected.title.slice(0, 32)}` : "Incident", !!link.inc && !link.cam, !!link.inc,
|
||||||
<MapView
|
() => setLink({ ...link, cam: null, tour: false }))}
|
||||||
nodes={nodes}
|
<span className="text-ink-muted">›</span>
|
||||||
activeCalls={activeCalls}
|
{crumb("Camera", !!link.cam, !!link.cam, () => undefined)}
|
||||||
incidents={activeIncidents}
|
</nav>
|
||||||
calls={recentCalls}
|
<div className="hidden md:flex items-center gap-3 flex-wrap">
|
||||||
lastUpdated={lastUpdated}
|
<Kpi value={count("major")} label="major" />
|
||||||
/>
|
<Kpi value={count("moderate")} label="moderate" />
|
||||||
)}
|
<Kpi value={activeIncidents.length} label="active" />
|
||||||
|
<Kpi value={`${nodesUp}/${nodes.length}`} label="nodes" />
|
||||||
{/* Configured-and-quiet — distinct from "no nodes at all" (chunk 10's
|
<Kpi value={cams.cameras.length} label="cams" />
|
||||||
Activation screen handles the zero-node case). A node that's
|
<Kpi value={aircraft.length} label="aircraft" />
|
||||||
online but has heard nothing is NOT the same empty state as an
|
<Kpi value={vessels.length} label="vessels" />
|
||||||
org with no equipment. */}
|
<Kpi value={eventsOff ? "—" : events.events?.length ?? 0} label="road events" />
|
||||||
{configuredButQuiet && (
|
</div>
|
||||||
<div className="absolute top-12 left-1/2 -translate-x-1/2 z-[1001] pointer-events-none">
|
<div className="ml-auto flex items-center gap-3">
|
||||||
<span className="bg-surface/90 border border-line rounded-full px-3 py-1 text-xs text-ink-2 whitespace-nowrap">
|
<button type="button" aria-pressed={link.tour} onClick={() => setLink({ ...link, cam: null, tour: !link.tour })}
|
||||||
● Listening{mostRecentSeen ? ` — last check-in ${timeAgo(new Date(mostRecentSeen))}` : ""}
|
title="Wall mode: cycle through active incidents, worst first"
|
||||||
|
className={`px-2 py-0.5 rounded border ${link.tour ? "bg-accent text-white border-accent" : "border-line text-ink-2 hover:text-ink"}`}>
|
||||||
|
Auto-tour
|
||||||
|
</button>
|
||||||
|
{link.tour && (
|
||||||
|
<span className="w-20 h-1 bg-raised rounded overflow-hidden" aria-hidden="true">
|
||||||
|
<span className="block h-full bg-accent" style={{ width: `${tourPct}%` }} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
)}
|
||||||
)}
|
<span className="font-mono text-ink-2 tabular-nums">{clock}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TimeScrubber calls={recentCalls} />
|
<div className="flex-1 min-h-0 flex flex-col lg:flex-row">
|
||||||
|
<div className="relative h-[55vh] lg:h-auto lg:flex-1 min-w-0 shrink-0">
|
||||||
|
{nodesLoading || callsLoading ? (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-ink-muted text-sm">Loading map…</div>
|
||||||
|
) : (
|
||||||
|
<MapView
|
||||||
|
nodes={nodes}
|
||||||
|
activeCalls={activeCalls}
|
||||||
|
incidents={activeIncidents}
|
||||||
|
calls={recentCalls}
|
||||||
|
lastUpdated={lastUpdated}
|
||||||
|
selectedIncidentId={selected?.incident_id ?? null}
|
||||||
|
onSelectIncident={selectIncident}
|
||||||
|
nearCameras={near}
|
||||||
|
onOpenCamera={openCamera}
|
||||||
|
onAir={onAir}
|
||||||
|
camCountByIncident={camCountByIncident}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Configured-and-quiet — distinct from "no nodes at all" (chunk 10's
|
||||||
|
Activation screen handles the zero-node case). */}
|
||||||
|
{configuredButQuiet && (
|
||||||
|
<div className="absolute top-12 left-1/2 -translate-x-1/2 z-[1001] pointer-events-none">
|
||||||
|
<span className="bg-surface/90 border border-line rounded-full px-3 py-1 text-xs text-ink-2 whitespace-nowrap">
|
||||||
|
● Listening{mostRecentSeen ? ` — last check-in ${timeAgo(new Date(mostRecentSeen))}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="lg:w-[360px] lg:shrink-0 flex-1 lg:flex-none min-h-0 overflow-y-auto border-t lg:border-t-0 lg:border-l border-line bg-surface"
|
||||||
|
aria-live="polite" aria-label={selected ? "Incident detail" : "Region overview"}>
|
||||||
|
{selected ? (
|
||||||
|
<IncidentPanel
|
||||||
|
inc={selected}
|
||||||
|
calls={callsByIncident.get(selected.incident_id) ?? []}
|
||||||
|
points={selectedPoints}
|
||||||
|
near={near}
|
||||||
|
feeds={feeds}
|
||||||
|
servingCount={camsLoaded ? cams.serving_count : 1}
|
||||||
|
onAir={onAir.has(selected.incident_id)}
|
||||||
|
aircraft={aircraft}
|
||||||
|
vessels={vessels}
|
||||||
|
events={events.events ?? []}
|
||||||
|
eventsCredit={{ credit: events.credit, fetched_at: events.events_status?.fetched_at ?? null }}
|
||||||
|
onOpenCamera={openCamera}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<RegionPanel
|
||||||
|
incidents={activeIncidents}
|
||||||
|
calls={recentCalls}
|
||||||
|
onAirCount={onAir.size}
|
||||||
|
nodes={nodes}
|
||||||
|
wallCameras={wallCameras}
|
||||||
|
feeds={feeds}
|
||||||
|
servingCount={camsLoaded ? cams.serving_count : 1}
|
||||||
|
aircraftCount={aircraft.length}
|
||||||
|
vesselCount={vessels.length}
|
||||||
|
events={events.events ?? []}
|
||||||
|
eventsOff={eventsOff}
|
||||||
|
pinnedIds={pinnedIds}
|
||||||
|
onOpenCamera={openCamera}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TranscriptTicker calls={recentCalls} onSelectIncident={selectIncident} />
|
||||||
|
<TimeScrubber calls={recentCalls} incidents={activeIncidents} selectedIncidentId={selected?.incident_id ?? null} onSelectIncident={selectIncident} />
|
||||||
|
|
||||||
|
{drillCam && (
|
||||||
|
<CameraDrill
|
||||||
|
camera={drillCam}
|
||||||
|
feed={feeds.get(drillCam.provider)}
|
||||||
|
distanceKm={selectedPoints.length ? Math.min(...selectedPoints.map((p) => km(p, [drillCam.lat, drillCam.lon]))) : null}
|
||||||
|
incidentTitle={selected?.title}
|
||||||
|
pinned={pinnedIds.has(drillCam.id)}
|
||||||
|
onTogglePin={() => togglePin(drillCam)}
|
||||||
|
onClose={() => setLink({ ...link, cam: null })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import {
|
|||||||
FeatureGroup,
|
FeatureGroup,
|
||||||
LayersControl,
|
LayersControl,
|
||||||
MapContainer,
|
MapContainer,
|
||||||
|
Circle,
|
||||||
Marker,
|
Marker,
|
||||||
|
Polygon,
|
||||||
Polyline,
|
Polyline,
|
||||||
Popup,
|
Popup,
|
||||||
TileLayer,
|
TileLayer,
|
||||||
@@ -22,8 +24,11 @@ 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 { use511 } from "@/lib/use511";
|
||||||
import type { Ny511Camera, Ny511Event, Ny511FeedStatus } from "@/lib/types";
|
import { useMapCameras } from "@/lib/useCameras";
|
||||||
|
import type { Camera, CameraCredit, CameraProviderFeed, CamerasResponse, Ny511Event, Ny511FeedStatus } from "@/lib/types";
|
||||||
import { CameraFeed } from "@/components/CameraFeed";
|
import { CameraFeed } from "@/components/CameraFeed";
|
||||||
|
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
|
||||||
|
import { NEAR_CAMERA_KM, groupCallsByIncident, incidentPoints, viewshedWedge, worstFirst } from "@/lib/liveWall";
|
||||||
|
|
||||||
// ── 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;
|
||||||
@@ -341,6 +346,7 @@ function RadarLayer() {
|
|||||||
// Overlay names double as the event keys for overlayadd/overlayremove.
|
// Overlay names double as the event keys for overlayadd/overlayremove.
|
||||||
const OVERLAY_DOT_CAMERAS = "DOT Cameras";
|
const OVERLAY_DOT_CAMERAS = "DOT Cameras";
|
||||||
const OVERLAY_TRAFFIC_EVENTS = "Traffic Events";
|
const OVERLAY_TRAFFIC_EVENTS = "Traffic Events";
|
||||||
|
const OVERLAY_VIEWSHEDS = "Camera Viewsheds (approx.)";
|
||||||
|
|
||||||
/** True while the named LayersControl overlay is checked. Overlays start unchecked. */
|
/** True while the named LayersControl overlay is checked. Overlays start unchecked. */
|
||||||
function useOverlayShown(map: L.Map, name: string): boolean {
|
function useOverlayShown(map: L.Map, name: string): boolean {
|
||||||
@@ -450,6 +456,7 @@ function fmtLocal(iso: string | null): string | null {
|
|||||||
function FeedProblem({ map, label, status, fetchError }: { map: L.Map; label: string; status?: Ny511FeedStatus; fetchError: string | null }) {
|
function FeedProblem({ map, label, status, fetchError }: { map: L.Map; label: string; status?: Ny511FeedStatus; fetchError: string | null }) {
|
||||||
let msg: string | null = null;
|
let msg: string | null = null;
|
||||||
if (fetchError) msg = `${label}: could not reach server`;
|
if (fetchError) msg = `${label}: could not reach server`;
|
||||||
|
else if (status?.disabled) msg = `${label}: 511NY feed is off — an admin enables it under Admin → Camera providers`;
|
||||||
else if (status?.error) {
|
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";
|
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}`;
|
msg = `${label}: 511NY feed error, ${age}`;
|
||||||
@@ -461,19 +468,66 @@ function FeedProblem({ map, label, status, fetchError }: { map: L.Map; label: st
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Provider id → what must render with its imagery. */
|
||||||
|
function creditsByProvider(providers: CameraProviderFeed[]): Map<string, CameraProviderFeed> {
|
||||||
|
return new Map(providers.map((p) => [p.id, p]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same honesty as FeedProblem, for the multi-provider camera layer. */
|
||||||
|
function CameraProblem({ map, data, loaded, fetchError }: { map: L.Map; data: CamerasResponse; loaded: boolean; fetchError: string | null }) {
|
||||||
|
let msg: string | null = null;
|
||||||
|
if (fetchError) msg = "Cameras: could not reach server";
|
||||||
|
else if (loaded && data.serving_count === 0) msg = "Cameras: no camera provider is enabled — Admin → Camera providers";
|
||||||
|
else {
|
||||||
|
const bad = data.providers.find((p) => p.error);
|
||||||
|
const cut = data.providers.find((p) => p.truncated);
|
||||||
|
if (bad) msg = `${bad.name}: feed error, ${bad.fetched_at ? `showing data from ${fetchedLabel(bad.fetched_at)}` : "no data yet"}`;
|
||||||
|
else if (cut) msg = `Cameras: too many in view — 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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Popup body for one camera: still, credit, catalog freshness, actions. */
|
||||||
|
function CameraPopupBody({ c, feed, onPin, onDock, onOpen }: {
|
||||||
|
c: Camera; feed?: CameraProviderFeed; onPin: () => void; onDock: () => void; onOpen?: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<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>}
|
||||||
|
{/* Popup content mounts on open, so the still is fetched per open. */}
|
||||||
|
<button type="button" onClick={onOpen ?? onPin} title="Open this camera" className="block w-full p-0 border-0 bg-transparent cursor-pointer">
|
||||||
|
<CameraFeed camera={c} mode="still" credit={feed} className="w-full h-[150px] rounded border border-line bg-black" />
|
||||||
|
</button>
|
||||||
|
<div className="flex gap-1.5 pt-0.5">
|
||||||
|
{onOpen && <button type="button" onClick={onOpen} className="flex-1 text-xs border border-line rounded px-2 py-1 hover:bg-raised">Open</button>}
|
||||||
|
<button type="button" onClick={onPin} className="flex-1 text-xs border border-line rounded px-2 py-1 hover:bg-raised">Pin to map</button>
|
||||||
|
<button type="button" onClick={onDock} className="flex-1 text-xs border border-line rounded px-2 py-1 hover:bg-raised">Dock</button>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-ink-muted">{c.video_url ? "Live feed available" : "Snapshot"}</div>
|
||||||
|
<SourceCredit credit={feed} fallbackName={feed?.name} updated={feed ? `camera list ${fetchedLabel(feed.fetched_at) ?? "—"}` : null} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Open cameras: each is either pinned (a small tile anchored at the camera, so
|
// Open cameras: each is either pinned (a small tile anchored at the camera, so
|
||||||
// several views along one road read against the map) or docked (a larger tile
|
// several views along one road read against the map) or docked (a larger tile
|
||||||
// in a strip over the map, which stays put while you pan and survives the
|
// in a strip over the map, which stays put while you pan and survives the
|
||||||
// overlay being switched off). Oldest camera drops past the cap.
|
// overlay being switched off). Oldest camera drops past the cap.
|
||||||
const MAX_OPEN_CAMERAS = 6;
|
const MAX_OPEN_CAMERAS = 6;
|
||||||
type CameraMode = "pin" | "dock";
|
type CameraMode = "pin" | "dock";
|
||||||
interface OpenCamera { cam: Ny511Camera; mode: CameraMode }
|
interface OpenCamera { cam: Camera; mode: CameraMode }
|
||||||
|
|
||||||
const camBtn = "text-ink-muted hover:text-ink text-xs leading-none px-0.5";
|
const camBtn = "text-ink-muted hover:text-ink text-xs leading-none px-0.5";
|
||||||
|
|
||||||
function CameraDock({ map, open, onMode, onClose }: {
|
function CameraDock({ map, open, credits, onMode, onClose }: {
|
||||||
map: L.Map;
|
map: L.Map;
|
||||||
open: Ny511Camera[];
|
open: Camera[];
|
||||||
|
credits: Map<string, CameraCredit>;
|
||||||
onMode: (id: string, mode: CameraMode) => void;
|
onMode: (id: string, mode: CameraMode) => void;
|
||||||
onClose: (id: string) => void;
|
onClose: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -497,7 +551,7 @@ function CameraDock({ map, open, onMode, onClose }: {
|
|||||||
<button type="button" onClick={() => onMode(c.id, "pin")} title="Pin to the map at the camera" aria-label={`Pin ${c.name} to the map`} className={camBtn}>⌖</button>
|
<button type="button" onClick={() => onMode(c.id, "pin")} title="Pin to the map at the camera" aria-label={`Pin ${c.name} to the map`} className={camBtn}>⌖</button>
|
||||||
<button type="button" onClick={() => onClose(c.id)} aria-label={`Close ${c.name}`} className={camBtn}>×</button>
|
<button type="button" onClick={() => onClose(c.id)} aria-label={`Close ${c.name}`} className={camBtn}>×</button>
|
||||||
</div>
|
</div>
|
||||||
<CameraFeed camera={c} className="block w-[240px] h-[135px] object-cover bg-black rounded-b-md" />
|
<CameraFeed camera={c} credit={credits.get(c.provider)} className="block w-[240px] h-[135px] bg-black rounded-b-md" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>,
|
</div>,
|
||||||
@@ -505,20 +559,21 @@ function CameraDock({ map, open, onMode, onClose }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DotCameraLayer() {
|
function DotCameraLayer({ onOpenCamera }: { onOpenCamera?: (c: Camera) => void }) {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
const shown = useOverlayShown(map, OVERLAY_DOT_CAMERAS);
|
const shown = useOverlayShown(map, OVERLAY_DOT_CAMERAS);
|
||||||
const zoom = useMapZoom(map);
|
const zoom = useMapZoom(map);
|
||||||
const { data, error } = use511(map, "cameras", shown);
|
const { data, error, loaded } = useMapCameras(map, shown);
|
||||||
const [open, setOpen] = useState<OpenCamera[]>([]);
|
const [open, setOpen] = useState<OpenCamera[]>([]);
|
||||||
|
const feeds = useMemo(() => creditsByProvider(data.providers), [data.providers]);
|
||||||
|
|
||||||
const pinnedIds = useMemo(() => new Set(open.filter((o) => o.mode === "pin").map((o) => o.cam.id)), [open]);
|
const pinnedIds = useMemo(() => new Set(open.filter((o) => o.mode === "pin").map((o) => o.cam.id)), [open]);
|
||||||
const cells = useMemo(
|
const cells = useMemo(
|
||||||
() => lodCells((data.cameras ?? []).filter((c) => !pinnedIds.has(c.id)), map, zoom),
|
() => lodCells(data.cameras.filter((c) => !pinnedIds.has(c.id)), map, zoom),
|
||||||
[data.cameras, pinnedIds, map, zoom],
|
[data.cameras, pinnedIds, map, zoom],
|
||||||
);
|
);
|
||||||
|
|
||||||
const show = (cam: Ny511Camera, mode: CameraMode) => {
|
const show = (cam: Camera, mode: CameraMode) => {
|
||||||
map.closePopup();
|
map.closePopup();
|
||||||
setOpen((prev) => [...prev.filter((o) => o.cam.id !== cam.id), { cam, mode }].slice(-MAX_OPEN_CAMERAS));
|
setOpen((prev) => [...prev.filter((o) => o.cam.id !== cam.id), { cam, mode }].slice(-MAX_OPEN_CAMERAS));
|
||||||
};
|
};
|
||||||
@@ -528,7 +583,7 @@ function DotCameraLayer() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{shown && <FeedProblem map={map} label="DOT cameras" status={data.cameras_status} fetchError={error} />}
|
{shown && <CameraProblem map={map} data={data} loaded={loaded} fetchError={error} />}
|
||||||
{cells.map(({ rep: c, count }) => count > 1 ? (
|
{cells.map(({ rep: c, count }) => count > 1 ? (
|
||||||
<Marker key={`lod-${c.id}`} position={[c.lat, c.lon]} icon={cameraIcon(count)}
|
<Marker key={`lod-${c.id}`} position={[c.lat, c.lon]} icon={cameraIcon(count)}
|
||||||
title={`${count} cameras — click to zoom in`}
|
title={`${count} cameras — click to zoom in`}
|
||||||
@@ -536,24 +591,8 @@ function DotCameraLayer() {
|
|||||||
) : (
|
) : (
|
||||||
<Marker key={c.id} position={[c.lat, c.lon]} icon={cameraIcon()}>
|
<Marker key={c.id} position={[c.lat, c.lon]} icon={cameraIcon()}>
|
||||||
<Popup minWidth={260} maxWidth={340}>
|
<Popup minWidth={260} maxWidth={340}>
|
||||||
<div className="space-y-1">
|
<CameraPopupBody c={c} feed={feeds.get(c.provider)} onPin={() => show(c, "pin")} onDock={() => show(c, "dock")}
|
||||||
<div className="font-semibold">{c.name}</div>
|
onOpen={onOpenCamera ? () => { map.closePopup(); onOpenCamera(c); } : undefined} />
|
||||||
{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={() => show(c, "pin")} title="Pin this camera to the map" className="block w-full p-0 border-0 bg-transparent cursor-pointer">
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img src={`${c.image_url}?t=${Date.now()}`} alt={`Camera: ${c.name}`} className="w-full rounded border border-line" loading="lazy" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div className="flex gap-1.5 pt-0.5">
|
|
||||||
<button type="button" onClick={() => show(c, "pin")} className="flex-1 text-xs border border-line rounded px-2 py-1 hover:bg-raised">Pin to map</button>
|
|
||||||
<button type="button" onClick={() => show(c, "dock")} className="flex-1 text-xs border border-line rounded px-2 py-1 hover:bg-raised">Dock</button>
|
|
||||||
</div>
|
|
||||||
<div className="text-[10px] text-ink-muted">
|
|
||||||
{c.video_url ? "Live feed" : "Snapshot"} · 511NY / NYSDOT
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Popup>
|
</Popup>
|
||||||
</Marker>
|
</Marker>
|
||||||
))}
|
))}
|
||||||
@@ -567,12 +606,70 @@ function DotCameraLayer() {
|
|||||||
<button type="button" onClick={() => setMode(c.id, "dock")} title="Dock" aria-label={`Dock ${c.name}`} className={camBtn}>⇲</button>
|
<button type="button" onClick={() => setMode(c.id, "dock")} title="Dock" aria-label={`Dock ${c.name}`} className={camBtn}>⇲</button>
|
||||||
<button type="button" onClick={() => close(c.id)} aria-label={`Close ${c.name}`} className={camBtn}>×</button>
|
<button type="button" onClick={() => close(c.id)} aria-label={`Close ${c.name}`} className={camBtn}>×</button>
|
||||||
</div>
|
</div>
|
||||||
<CameraFeed camera={c} className="block w-[192px] h-[108px] object-cover bg-black rounded-b" />
|
<CameraFeed camera={c} credit={feeds.get(c.provider)} className="block w-[192px] h-[108px] bg-black rounded-b" />
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Marker>
|
</Marker>
|
||||||
))}
|
))}
|
||||||
<CameraDock map={map} open={open.filter((o) => o.mode === "dock").map((o) => o.cam)} onMode={setMode} onClose={close} />
|
<CameraDock map={map} open={open.filter((o) => o.mode === "dock").map((o) => o.cam)} credits={feeds} onMode={setMode} onClose={close} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Camera viewsheds — GEV idea #4, coarse 2D cones, APPROXIMATE ─────────────
|
||||||
|
// Built from the published direction of travel, not a surveyed heading/FOV.
|
||||||
|
function CameraViewshedLayer({ highlightIds }: { highlightIds: Set<string> }) {
|
||||||
|
const map = useMap();
|
||||||
|
const shown = useOverlayShown(map, OVERLAY_VIEWSHEDS);
|
||||||
|
const zoom = useMapZoom(map);
|
||||||
|
const { data } = useMapCameras(map, shown && zoom >= 11);
|
||||||
|
if (!shown) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{data.cameras.map((c) => {
|
||||||
|
const wedge = viewshedWedge(c);
|
||||||
|
if (!wedge) return null;
|
||||||
|
const hot = highlightIds.has(c.id);
|
||||||
|
return (
|
||||||
|
<Polygon key={`vs-${c.id}`} positions={wedge}
|
||||||
|
pathOptions={{ color: hot ? "#3987E5" : "#A5B0C2", weight: hot ? 1 : 0.5, opacity: hot ? 0.8 : 0.4, fillOpacity: hot ? 0.18 : 0.06 }}>
|
||||||
|
<Tooltip sticky>{c.name} — approximate view (from the road's direction of travel, not a surveyed heading)</Tooltip>
|
||||||
|
</Polygon>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Nearest cameras to the selected incident (live ops wall) ─────────────────
|
||||||
|
// Always drawn while an incident is selected, independent of the DOT Cameras
|
||||||
|
// overlay, so the wall never hides the cameras it is recommending.
|
||||||
|
function nearCameraIcon(): L.DivIcon {
|
||||||
|
return L.divIcon({
|
||||||
|
className: "",
|
||||||
|
html: `<div style="width:20px;height:16px;border-radius:4px;background:#0b1f3a;border:2px solid #3987E5;display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 3px rgba(57,135,229,.25)"><svg width="12" height="9" viewBox="0 0 16 12"><rect x="0.5" y="1.5" width="11" height="9" rx="2" fill="none" stroke="#8DB9F0" stroke-width="1.5"/><polygon points="12,4 15.5,2 15.5,10 12,8" fill="#8DB9F0"/></svg></div>`,
|
||||||
|
iconSize: [20, 16],
|
||||||
|
iconAnchor: [10, 8],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function NearCameraLayer({ cameras, head, onOpenCamera }: {
|
||||||
|
cameras: { cam: Camera; km: number }[];
|
||||||
|
head: [number, number] | null;
|
||||||
|
onOpenCamera?: (c: Camera) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{head && (
|
||||||
|
<Circle center={head} radius={NEAR_CAMERA_KM * 1000} interactive={false}
|
||||||
|
pathOptions={{ color: "#3987E5", weight: 1, opacity: 0.6, fill: false, dashArray: "2 5" }} />
|
||||||
|
)}
|
||||||
|
{cameras.map(({ cam, km: d }) => (
|
||||||
|
<Marker key={`near-${cam.id}`} position={[cam.lat, cam.lon]} icon={nearCameraIcon()} zIndexOffset={600}
|
||||||
|
eventHandlers={{ click: () => onOpenCamera?.(cam) }}>
|
||||||
|
<Tooltip direction="right" offset={[10, 0]}>{cam.name} · {d.toFixed(1)} km</Tooltip>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -605,8 +702,10 @@ function TrafficEventLayer() {
|
|||||||
<div className="font-semibold">{st.label}{e.subtype ? `: ${e.subtype}` : ""}</div>
|
<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>
|
<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>}
|
{(start || end) && <div className="text-xs">{start ? `From ${start}` : ""}{end ? ` until ${end}` : ""}</div>}
|
||||||
|
{/* DAA: shown exactly as NYSDOT published it — never summarised or rewritten. */}
|
||||||
<div className="text-xs whitespace-pre-line">{e.description}</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>
|
<SourceCredit credit={data.credit} fallbackName="511NY / NYSDOT"
|
||||||
|
updated={fmtLocal(e.updated_local) ?? fetchedLabel(data.events_status?.fetched_at)} />
|
||||||
</div>
|
</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
</Marker>
|
</Marker>
|
||||||
@@ -816,9 +915,11 @@ function FanNodeLayer({
|
|||||||
function FanIncidentLayer({
|
function FanIncidentLayer({
|
||||||
incidents,
|
incidents,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
selectedId,
|
||||||
}: {
|
}: {
|
||||||
incidents: IncidentRecord[];
|
incidents: IncidentRecord[];
|
||||||
onSelect: (inc: IncidentRecord) => void;
|
onSelect: (inc: IncidentRecord) => void;
|
||||||
|
selectedId?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
const [tick, setTick] = useState(0);
|
const [tick, setTick] = useState(0);
|
||||||
@@ -863,6 +964,8 @@ function FanIncidentLayer({
|
|||||||
key={repId}
|
key={repId}
|
||||||
position={[repPlot.lat, repPlot.lng]}
|
position={[repPlot.lat, repPlot.lng]}
|
||||||
icon={members.length > 1 ? incidentFanIcon(members) : incidentIcon(repPlot.inc.type, repPlot.inc.severity)}
|
icon={members.length > 1 ? incidentFanIcon(members) : incidentIcon(repPlot.inc.type, repPlot.inc.severity)}
|
||||||
|
opacity={selectedId && !members.some((m) => m.incident_id === selectedId) ? 0.45 : 1}
|
||||||
|
zIndexOffset={members.some((m) => m.incident_id === selectedId) ? 1000 : 0}
|
||||||
eventHandlers={{ click: () => onSelect(repPlot.inc) }}
|
eventHandlers={{ click: () => onSelect(repPlot.inc) }}
|
||||||
>
|
>
|
||||||
<Popup minWidth={180}>
|
<Popup minWidth={180}>
|
||||||
@@ -964,6 +1067,8 @@ function timeAgo(date: Date): string {
|
|||||||
return `${Math.floor(s / 3600)}h ago`;
|
return `${Math.floor(s / 3600)}h ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const NO_NEAR: { cam: Camera; km: number }[] = [];
|
||||||
|
|
||||||
// ── Main MapView ──────────────────────────────────────────────────────────────
|
// ── Main MapView ──────────────────────────────────────────────────────────────
|
||||||
interface Props {
|
interface Props {
|
||||||
nodes: NodeRecord[];
|
nodes: NodeRecord[];
|
||||||
@@ -978,9 +1083,24 @@ interface Props {
|
|||||||
*/
|
*/
|
||||||
calls?: CallRecord[];
|
calls?: CallRecord[];
|
||||||
lastUpdated?: Date | null;
|
lastUpdated?: Date | null;
|
||||||
|
// ── Live ops wall (all optional; without them MapView behaves as before) ──
|
||||||
|
/** The incident drilled into; the map fits its path/pin and dims the rest. */
|
||||||
|
selectedIncidentId?: string | null;
|
||||||
|
/** When set, rail cards and pins select instead of only flying to the pin. */
|
||||||
|
onSelectIncident?: (id: string) => void;
|
||||||
|
/** Nearest cameras to the selected incident — always drawn, highlighted. */
|
||||||
|
nearCameras?: { cam: Camera; km: number }[];
|
||||||
|
onOpenCamera?: (c: Camera) => void;
|
||||||
|
/** Incident ids with a live call on one of their talkgroups. */
|
||||||
|
onAir?: Set<string>;
|
||||||
|
/** Cameras within range of each incident (rail "N cams"). */
|
||||||
|
camCountByIncident?: Map<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MapView({ nodes, activeCalls, incidents = [], calls = [], lastUpdated }: Props) {
|
export default function MapView({
|
||||||
|
nodes, activeCalls, incidents = [], calls = [], lastUpdated,
|
||||||
|
selectedIncidentId = null, onSelectIncident, nearCameras = NO_NEAR, onOpenCamera, onAir, camCountByIncident,
|
||||||
|
}: Props) {
|
||||||
const [mapInstance, setMapInstance] = useState<L.Map | null>(null);
|
const [mapInstance, setMapInstance] = useState<L.Map | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [agoClock, setAgoClock] = useState(0);
|
const [agoClock, setAgoClock] = useState(0);
|
||||||
@@ -1008,17 +1128,13 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]);
|
const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]);
|
||||||
|
|
||||||
const callsByIncident = useMemo(() => {
|
const callsByIncident = useMemo(() => groupCallsByIncident(calls), [calls]);
|
||||||
const map = new Map<string, CallRecord[]>();
|
const railIncidents = useMemo(() => worstFirst(incidents), [incidents]);
|
||||||
for (const c of calls) {
|
const highlightIds = useMemo(() => new Set(nearCameras.map((n) => n.cam.id)), [nearCameras]);
|
||||||
const ids = c.incident_ids?.length ? c.incident_ids : c.incident_id ? [c.incident_id] : [];
|
const selectedPoints = useMemo(() => {
|
||||||
for (const id of ids) {
|
const inc = incidents.find((i) => i.incident_id === selectedIncidentId);
|
||||||
if (!map.has(id)) map.set(id, []);
|
return inc ? incidentPoints(inc, callsByIncident.get(inc.incident_id)) : [];
|
||||||
map.get(id)!.push(c);
|
}, [incidents, selectedIncidentId, callsByIncident]);
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}, [calls]);
|
|
||||||
|
|
||||||
const allPositions = useMemo(
|
const allPositions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -1050,12 +1166,22 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
|||||||
|
|
||||||
const handleIncidentSelect = useCallback(
|
const handleIncidentSelect = useCallback(
|
||||||
(inc: IncidentRecord) => {
|
(inc: IncidentRecord) => {
|
||||||
|
if (onSelectIncident) { onSelectIncident(inc.incident_id); return; } // the fit happens below
|
||||||
if (!mapInstance || !inc.location_coords) return;
|
if (!mapInstance || !inc.location_coords) return;
|
||||||
mapInstance.flyTo([inc.location_coords.lat, inc.location_coords.lng], 15, { duration: 1.2 });
|
mapInstance.flyTo([inc.location_coords.lat, inc.location_coords.lng], 15, { duration: 1.2 });
|
||||||
},
|
},
|
||||||
[mapInstance]
|
[mapInstance, onSelectIncident]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Wall drill-down: fit the selected incident's whole path (or its pin).
|
||||||
|
const selectedKey = selectedPoints.map((p) => p.join(",")).join(";");
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapInstance || !selectedPoints.length) return;
|
||||||
|
if (selectedPoints.length === 1) mapInstance.flyTo(selectedPoints[0], 14, { duration: 1.0 });
|
||||||
|
else mapInstance.flyToBounds(L.latLngBounds(selectedPoints), { padding: [60, 60], maxZoom: 15, duration: 1.0 });
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [mapInstance, selectedIncidentId, selectedKey]);
|
||||||
|
|
||||||
const onMapReady = useCallback((m: L.Map) => setMapInstance(m), []);
|
const onMapReady = useCallback((m: L.Map) => setMapInstance(m), []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1100,7 +1226,7 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
|||||||
{/* Overlay: Active Incidents */}
|
{/* Overlay: Active Incidents */}
|
||||||
<LayersControl.Overlay checked name="Active Incidents">
|
<LayersControl.Overlay checked name="Active Incidents">
|
||||||
<FeatureGroup>
|
<FeatureGroup>
|
||||||
<FanIncidentLayer incidents={incidents} onSelect={handleIncidentSelect} />
|
<FanIncidentLayer incidents={incidents} onSelect={handleIncidentSelect} selectedId={selectedIncidentId} />
|
||||||
</FeatureGroup>
|
</FeatureGroup>
|
||||||
</LayersControl.Overlay>
|
</LayersControl.Overlay>
|
||||||
|
|
||||||
@@ -1128,7 +1254,12 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
|||||||
{/* Overlays: 511NY DOT cameras + traffic events (server-26#183), opt-in */}
|
{/* Overlays: 511NY DOT cameras + traffic events (server-26#183), opt-in */}
|
||||||
<LayersControl.Overlay name={OVERLAY_DOT_CAMERAS}>
|
<LayersControl.Overlay name={OVERLAY_DOT_CAMERAS}>
|
||||||
<FeatureGroup>
|
<FeatureGroup>
|
||||||
<DotCameraLayer />
|
<DotCameraLayer onOpenCamera={onOpenCamera} />
|
||||||
|
</FeatureGroup>
|
||||||
|
</LayersControl.Overlay>
|
||||||
|
<LayersControl.Overlay name={OVERLAY_VIEWSHEDS}>
|
||||||
|
<FeatureGroup>
|
||||||
|
<CameraViewshedLayer highlightIds={highlightIds} />
|
||||||
</FeatureGroup>
|
</FeatureGroup>
|
||||||
</LayersControl.Overlay>
|
</LayersControl.Overlay>
|
||||||
<LayersControl.Overlay name={OVERLAY_TRAFFIC_EVENTS}>
|
<LayersControl.Overlay name={OVERLAY_TRAFFIC_EVENTS}>
|
||||||
@@ -1142,6 +1273,10 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
|||||||
<RadarLayer />
|
<RadarLayer />
|
||||||
</LayersControl.Overlay>
|
</LayersControl.Overlay>
|
||||||
</LayersControl>
|
</LayersControl>
|
||||||
|
|
||||||
|
{selectedIncidentId && (
|
||||||
|
<NearCameraLayer cameras={nearCameras} head={selectedPoints.at(-1) ?? null} onOpenCamera={onOpenCamera} />
|
||||||
|
)}
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
|
|
||||||
{/* ── Live timestamp ───────────────────────────────────────────────────── */}
|
{/* ── Live timestamp ───────────────────────────────────────────────────── */}
|
||||||
@@ -1236,11 +1371,16 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
|||||||
<MachineOutputNotice variant="inline" className="text-[10px] leading-snug items-start" />
|
<MachineOutputNotice variant="inline" className="text-[10px] leading-snug items-start" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5 overflow-y-auto min-h-0 pointer-events-auto">
|
<div className="flex flex-col gap-1.5 overflow-y-auto min-h-0 pointer-events-auto">
|
||||||
{incidents.map((inc) => {
|
{railIncidents.map((inc) => {
|
||||||
const color = severityColor(inc.severity);
|
const color = severityColor(inc.severity);
|
||||||
const age = inc.started_at ? timeAgo(new Date(inc.started_at)) : null;
|
const age = inc.started_at ? timeAgo(new Date(inc.started_at)) : null;
|
||||||
const unitCount = inc.units_active?.length ?? inc.units?.length ?? 0;
|
const unitCount = inc.units_active?.length ?? inc.units?.length ?? 0;
|
||||||
const baseClass = "w-full text-left bg-surface/90 backdrop-blur-sm border rounded-lg px-3 py-2 text-xs hover:brightness-110 transition-all";
|
const units = (inc.units_active?.length ? inc.units_active : inc.units) ?? [];
|
||||||
|
const selected = inc.incident_id === selectedIncidentId;
|
||||||
|
const live = onAir?.has(inc.incident_id) ?? false;
|
||||||
|
const cams = camCountByIncident?.get(inc.incident_id) ?? 0;
|
||||||
|
const callCount = inc.call_ids?.length ?? 0;
|
||||||
|
const baseClass = `w-full text-left bg-surface/90 backdrop-blur-sm border rounded-lg px-3 py-2 text-xs hover:brightness-110 transition-all ${selected ? "ring-2 ring-accent" : ""}`;
|
||||||
const cardBody = (
|
const cardBody = (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-1.5 mb-0.5">
|
<div className="flex items-center gap-1.5 mb-0.5">
|
||||||
@@ -1248,6 +1388,7 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
|||||||
<span className="uppercase tracking-wide font-semibold text-[10px]" style={{ color }}>
|
<span className="uppercase tracking-wide font-semibold text-[10px]" style={{ color }}>
|
||||||
{isKnownSeverity(inc.severity) ? SEVERITY_LABEL[inc.severity] : "Unknown"}
|
{isKnownSeverity(inc.severity) ? SEVERITY_LABEL[inc.severity] : "Unknown"}
|
||||||
</span>
|
</span>
|
||||||
|
{live && <span className="ml-auto bg-accent text-white rounded px-1 text-[9px] font-semibold tracking-wide">ON AIR</span>}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-ink font-semibold leading-snug truncate">
|
<p className="text-ink font-semibold leading-snug truncate">
|
||||||
{inc.title ?? "Incident"}
|
{inc.title ?? "Incident"}
|
||||||
@@ -1255,22 +1396,33 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
|||||||
{inc.location && (
|
{inc.location && (
|
||||||
<p className="text-ink-muted truncate mt-0.5">{inc.location}</p>
|
<p className="text-ink-muted truncate mt-0.5">{inc.location}</p>
|
||||||
)}
|
)}
|
||||||
|
{onSelectIncident && units.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
|
{units.slice(0, 4).map((u) => (
|
||||||
|
<span key={u} className="font-mono text-[10px] px-1 rounded bg-raised text-ink-2">{u}</span>
|
||||||
|
))}
|
||||||
|
{units.length > 4 && <span className="font-mono text-[10px] px-1 rounded bg-raised text-ink-muted">+{units.length - 4}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex items-center justify-between mt-0.5">
|
<div className="flex items-center justify-between mt-0.5">
|
||||||
{age && <span className="text-ink-muted font-mono">{age}</span>}
|
{age && <span className="text-ink-muted font-mono">{age}</span>}
|
||||||
{unitCount > 0 && (
|
{onSelectIncident ? (
|
||||||
|
<span className="text-ink-muted font-mono">{callCount} call{callCount !== 1 ? "s" : ""}{cams ? ` · ${cams} cam${cams !== 1 ? "s" : ""}` : ""}</span>
|
||||||
|
) : unitCount > 0 && (
|
||||||
<span className="text-ink-muted font-mono">{unitCount} unit{unitCount !== 1 ? "s" : ""}</span>
|
<span className="text-ink-muted font-mono">{unitCount} unit{unitCount !== 1 ? "s" : ""}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!inc.location_coords && (
|
{!inc.location_coords && !onSelectIncident && (
|
||||||
<p className="text-[10px] text-accent mt-1">View details →</p>
|
<p className="text-[10px] text-accent mt-1">View details →</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
if (inc.location_coords) {
|
if (inc.location_coords || onSelectIncident) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={inc.incident_id}
|
key={inc.incident_id}
|
||||||
onClick={() => handleIncidentSelect(inc)}
|
onClick={() => handleIncidentSelect(inc)}
|
||||||
|
aria-pressed={selected}
|
||||||
className={baseClass}
|
className={baseClass}
|
||||||
style={{ borderColor: color + "55" }}
|
style={{ borderColor: color + "55" }}
|
||||||
>
|
>
|
||||||
@@ -1305,7 +1457,7 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
|||||||
<div className="bg-surface/95 border-t border-line max-h-52 overflow-y-auto px-3 py-2 space-y-1.5">
|
<div className="bg-surface/95 border-t border-line max-h-52 overflow-y-auto px-3 py-2 space-y-1.5">
|
||||||
{/* Gate A / A2 (server-26#46) */}
|
{/* Gate A / A2 (server-26#46) */}
|
||||||
<MachineOutputNotice variant="inline" className="text-[10px] items-start" />
|
<MachineOutputNotice variant="inline" className="text-[10px] items-start" />
|
||||||
{incidents.map((inc) => {
|
{railIncidents.map((inc) => {
|
||||||
const color = severityColor(inc.severity);
|
const color = severityColor(inc.severity);
|
||||||
const label = (
|
const label = (
|
||||||
<>
|
<>
|
||||||
@@ -1316,7 +1468,7 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
|||||||
<span className="text-ink">{inc.title ?? "Incident"}</span>
|
<span className="text-ink">{inc.title ?? "Incident"}</span>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
if (inc.location_coords) {
|
if (inc.location_coords || onSelectIncident) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={inc.incident_id}
|
key={inc.incident_id}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { CameraCredit } from "@/lib/types";
|
||||||
|
|
||||||
|
/** "3 min ago (14:02)" for an epoch-seconds catalog/feed fetch time. */
|
||||||
|
export function fetchedLabel(epochS: number | null | undefined): string | null {
|
||||||
|
if (!epochS) return null;
|
||||||
|
const ms = epochS * 1000;
|
||||||
|
const m = Math.round((Date.now() - ms) / 60_000);
|
||||||
|
const t = new Date(ms).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
||||||
|
return m < 1 ? `just now (${t})` : `${m} min ago (${t})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Source credit + freshness + pass-through disclaimer for third-party data
|
||||||
|
* (511NY DAA: credit as a source credit only, "display the time at which the
|
||||||
|
* data was published or last updated wherever you present it", and "best
|
||||||
|
* efforts" to pass on NYSDOT's as-is disclaimer). Render it wherever that
|
||||||
|
* provider's data or imagery is shown.
|
||||||
|
*/
|
||||||
|
export function SourceCredit({
|
||||||
|
credit,
|
||||||
|
updated,
|
||||||
|
fallbackName,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
credit?: CameraCredit | null;
|
||||||
|
/** Human-readable "updated …" text; required by the DAA wherever 511NY data shows. */
|
||||||
|
updated?: string | null;
|
||||||
|
fallbackName?: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const name = credit?.attribution ?? fallbackName ?? null;
|
||||||
|
return (
|
||||||
|
<div className={`text-[10px] text-ink-muted space-y-0.5 ${className ?? ""}`}>
|
||||||
|
<div>
|
||||||
|
{name && (credit?.attribution_url ? (
|
||||||
|
<a href={credit.attribution_url} target="_blank" rel="noopener noreferrer" className="underline hover:text-ink-2">{name}</a>
|
||||||
|
) : <span>{name}</span>)}
|
||||||
|
{name && updated ? " · " : ""}
|
||||||
|
{updated ? `updated ${updated}` : ""}
|
||||||
|
</div>
|
||||||
|
{credit?.disclaimer && (
|
||||||
|
<details>
|
||||||
|
<summary className="cursor-pointer select-none">Provided “as is”, without warranty — details</summary>
|
||||||
|
<p className="mt-0.5 leading-snug whitespace-normal">{credit.disclaimer}</p>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,8 +10,8 @@
|
|||||||
* fixture — only the scrub interaction is deferred.
|
* fixture — only the scrub interaction is deferred.
|
||||||
*/
|
*/
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import type { CallRecord } from "@/lib/types";
|
import type { CallRecord, IncidentRecord } from "@/lib/types";
|
||||||
import { SEVERITY_COLORS, severityRank } from "@/lib/severity";
|
import { isKnownSeverity, SEVERITY_COLORS, severityRank } from "@/lib/severity";
|
||||||
|
|
||||||
const RANGES = [
|
const RANGES = [
|
||||||
{ label: "1h", ms: 60 * 60 * 1000 },
|
{ label: "1h", ms: 60 * 60 * 1000 },
|
||||||
@@ -22,7 +22,17 @@ const RANGES = [
|
|||||||
|
|
||||||
const BUCKETS = 48;
|
const BUCKETS = 48;
|
||||||
|
|
||||||
export function TimeScrubber({ calls }: { calls: CallRecord[] }) {
|
/**
|
||||||
|
* `incidents` (optional) adds one band per active incident from its start to
|
||||||
|
* its last update, tinted by severity — click a band to drill into it (live
|
||||||
|
* ops wall). The playhead still does not move; see the header.
|
||||||
|
*/
|
||||||
|
export function TimeScrubber({ calls, incidents = [], selectedIncidentId = null, onSelectIncident }: {
|
||||||
|
calls: CallRecord[];
|
||||||
|
incidents?: IncidentRecord[];
|
||||||
|
selectedIncidentId?: string | null;
|
||||||
|
onSelectIncident?: (id: string) => void;
|
||||||
|
}) {
|
||||||
const [rangeIdx, setRangeIdx] = useState(2); // default 24h
|
const [rangeIdx, setRangeIdx] = useState(2); // default 24h
|
||||||
|
|
||||||
const range = RANGES[rangeIdx];
|
const range = RANGES[rangeIdx];
|
||||||
@@ -46,9 +56,38 @@ export function TimeScrubber({ calls }: { calls: CallRecord[] }) {
|
|||||||
|
|
||||||
const maxCount = Math.max(1, ...buckets.map((b) => b.count));
|
const maxCount = Math.max(1, ...buckets.map((b) => b.count));
|
||||||
|
|
||||||
|
const bands = useMemo(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
const start = now - range.ms;
|
||||||
|
return incidents
|
||||||
|
.map((inc) => {
|
||||||
|
const a = Math.max(start, new Date(inc.started_at).getTime());
|
||||||
|
const b = Math.min(now, new Date(inc.updated_at || inc.started_at).getTime());
|
||||||
|
if (Number.isNaN(a) || Number.isNaN(b) || b < start) return null;
|
||||||
|
return { inc, left: ((a - start) / range.ms) * 100, width: Math.max(0.6, ((Math.max(a, b) - a) / range.ms) * 100) };
|
||||||
|
})
|
||||||
|
.filter((x): x is { inc: IncidentRecord; left: number; width: number } => x !== null)
|
||||||
|
.sort((x, y) => severityRank(x.inc.severity) - severityRank(y.inc.severity));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [incidents, rangeIdx]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-[84px] bg-surface/90 border-t border-line px-4 flex items-center gap-4">
|
<div className="h-[84px] bg-surface/90 border-t border-line px-4 flex items-center gap-4">
|
||||||
<div className="flex-1 h-12 flex items-end gap-[2px]">
|
<div className="flex-1 h-12 flex items-end gap-[2px] relative">
|
||||||
|
{bands.length > 0 && (
|
||||||
|
<div className="absolute left-0 right-0 -top-3 h-2" aria-label="Incident bands">
|
||||||
|
{bands.map(({ inc, left, width }) => {
|
||||||
|
const color = isKnownSeverity(inc.severity) ? SEVERITY_COLORS[inc.severity] : "var(--ink-muted)";
|
||||||
|
const sel = inc.incident_id === selectedIncidentId;
|
||||||
|
return (
|
||||||
|
<button key={inc.incident_id} type="button" title={inc.title ?? "Incident"}
|
||||||
|
onClick={() => onSelectIncident?.(inc.incident_id)}
|
||||||
|
className={`absolute top-0 h-2 rounded-sm ${sel ? "ring-1 ring-ink" : ""}`}
|
||||||
|
style={{ left: `${left}%`, width: `${width}%`, background: color, opacity: sel ? 1 : 0.55 }} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{buckets.map((b, i) => {
|
{buckets.map((b, i) => {
|
||||||
const heightPct = b.count === 0 ? 4 : Math.max(10, (b.count / maxCount) * 100);
|
const heightPct = b.count === 0 ? 4 : Math.max(10, (b.count / maxCount) * 100);
|
||||||
const color =
|
const color =
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin → Camera providers. Every source in design-canvas/camera-sources.md,
|
||||||
|
* each with its licence status shown first. All default OFF. A licence-gated
|
||||||
|
* source (agreement required / unverified / non-commercial / paid) shows a
|
||||||
|
* blocking warning and needs an explicit confirmation, which c2-core records
|
||||||
|
* with who/when in config/camera_providers and audit_log. Stubs and sources
|
||||||
|
* whose terms forbid the use cannot be switched on at all.
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { c2api } from "@/lib/c2api";
|
||||||
|
import type { CameraProviderAdmin, LicenceStatus } from "@/lib/types";
|
||||||
|
|
||||||
|
const LICENCE_STYLE: Record<LicenceStatus, { label: string; cls: string }> = {
|
||||||
|
commercial_ok: { label: "Commercial OK", cls: "bg-green-900 text-green-300 border-green-700" },
|
||||||
|
attribution_required: { label: "Attribution required", cls: "bg-sky-950 text-sky-300 border-sky-700" },
|
||||||
|
agreement_required: { label: "Requires agreement — not signed", cls: "bg-red-950 text-red-300 border-red-700" },
|
||||||
|
non_commercial: { label: "Non-commercial only", cls: "bg-red-950 text-red-300 border-red-700" },
|
||||||
|
unverified: { label: "Licence unverified", cls: "bg-amber-950 text-amber-300 border-amber-700" },
|
||||||
|
paid: { label: "Paid licence — no contract", cls: "bg-amber-950 text-amber-300 border-amber-700" },
|
||||||
|
prohibited: { label: "Terms forbid use", cls: "bg-red-950 text-red-300 border-red-700" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmtEpoch(s: number | null | undefined): string {
|
||||||
|
return s ? new Date(s * 1000).toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }) : "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CameraProvidersTab() {
|
||||||
|
const [providers, setProviders] = useState<CameraProviderAdmin[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
const [confirming, setConfirming] = useState<CameraProviderAdmin | null>(null);
|
||||||
|
const [ackChecked, setAckChecked] = useState(false);
|
||||||
|
|
||||||
|
const load = () =>
|
||||||
|
c2api.listCameraProviders().then((r) => setProviders(r.providers)).catch((e) => setError(String(e)));
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
async function toggle(p: CameraProviderAdmin, enabled: boolean, acknowledge = false) {
|
||||||
|
if (enabled && p.licence.gated && !acknowledge) {
|
||||||
|
setAckChecked(false);
|
||||||
|
setConfirming(p);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(p.id);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const updated = await c2api.setCameraProvider(p.id, enabled, acknowledge);
|
||||||
|
setProviders((cur) => cur?.map((x) => (x.id === p.id ? updated : x)) ?? null);
|
||||||
|
setConfirming(null);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<p className="text-gray-400 text-xs leading-snug">
|
||||||
|
Public camera sources for the Live wall. Every provider is <b>off</b> until enabled here. Licence status comes from the
|
||||||
|
camera-sources catalog (2026-10-04); a red or amber licence means DRB does not hold a confirmed right to show that imagery.
|
||||||
|
Required attributions render on every camera tile.
|
||||||
|
</p>
|
||||||
|
{error && <div className="bg-red-950 border border-red-800 rounded-lg p-3 text-red-400 text-sm font-mono break-words">{error}</div>}
|
||||||
|
{!providers ? (
|
||||||
|
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
||||||
|
) : (
|
||||||
|
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
|
||||||
|
{providers.map((p) => {
|
||||||
|
const lic = LICENCE_STYLE[p.licence.status];
|
||||||
|
const lockReason = !p.implemented ? "No working adapter (stub)" : p.licence.status === "prohibited" ? "Source terms forbid this use" : null;
|
||||||
|
return (
|
||||||
|
<div key={p.id} className="px-5 py-4 space-y-2">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<span className={`inline-block text-xs font-semibold px-2 py-0.5 rounded border ${lic.cls}`}>{lic.label}</span>
|
||||||
|
<p className="text-white text-sm font-semibold">{p.name}</p>
|
||||||
|
<p className="text-gray-400 text-xs">{p.licence.label}</p>
|
||||||
|
<p className="text-gray-500 text-xs">{p.region} · {p.formats}</p>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-right space-y-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={p.enabled}
|
||||||
|
aria-label={`${p.enabled ? "Disable" : "Enable"} ${p.name}`}
|
||||||
|
disabled={busy === p.id || (!!lockReason && !p.enabled)}
|
||||||
|
title={lockReason ?? undefined}
|
||||||
|
onClick={() => toggle(p, !p.enabled)}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-40 ${p.enabled ? "bg-indigo-600" : "bg-gray-700"}`}
|
||||||
|
>
|
||||||
|
<span className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${p.enabled ? "translate-x-6" : "translate-x-1"}`} />
|
||||||
|
</button>
|
||||||
|
<p className={`text-[11px] font-mono ${p.serving ? "text-green-400" : "text-gray-500"}`}>
|
||||||
|
{p.serving ? "serving" : lockReason ?? p.not_serving_reason ?? "off"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-[11px] font-mono text-gray-400">
|
||||||
|
<span>cameras: {p.health ? p.health.count : "—"}</span>
|
||||||
|
<span>last fetch: {fmtEpoch(p.health?.fetched_at)}</span>
|
||||||
|
<span className={p.health?.error ? "text-red-400" : ""}>health: {p.health ? (p.health.error ? "error" : p.health.fetched_at ? "ok" : "not fetched yet") : "n/a"}</span>
|
||||||
|
<span className={p.key_missing ? "text-amber-400" : ""}>{p.key_setting ? `key: ${p.key_missing ? "missing" : "set"}` : "no key needed"}</span>
|
||||||
|
</div>
|
||||||
|
{p.health?.error && <p className="text-[11px] font-mono text-red-400 break-words">{p.health.error}</p>}
|
||||||
|
{p.licence.attribution && <p className="text-[11px] text-gray-400">Attribution shown with imagery: “{p.licence.attribution}”</p>}
|
||||||
|
{p.acknowledgement && (
|
||||||
|
<p className="text-[11px] text-amber-300">
|
||||||
|
Licence warning acknowledged by {p.acknowledgement.by_email || p.acknowledgement.by_uid} on {new Date(p.acknowledgement.at).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<details className="text-[11px] text-gray-500">
|
||||||
|
<summary className="cursor-pointer select-none">Terms & adapter notes</summary>
|
||||||
|
<p className="mt-1">{p.licence.terms}</p>
|
||||||
|
<p className="mt-1">{p.notes}</p>
|
||||||
|
<p className="mt-1 space-x-2">
|
||||||
|
{p.licence.terms_url && <a className="underline" href={p.licence.terms_url} target="_blank" rel="noopener noreferrer">terms</a>}
|
||||||
|
{p.homepage && <a className="underline" href={p.homepage} target="_blank" rel="noopener noreferrer">source</a>}
|
||||||
|
{p.licence.issue && <span>tracking: {p.licence.issue}</span>}
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{confirming && (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4" role="alertdialog" aria-modal="true" aria-labelledby="cam-ack-title">
|
||||||
|
<div className="bg-gray-900 border border-red-700 rounded-xl max-w-lg w-full p-5 space-y-3">
|
||||||
|
<h2 id="cam-ack-title" className="text-white font-semibold">Enable {confirming.name}?</h2>
|
||||||
|
<p className="text-red-300 text-sm font-semibold">{LICENCE_STYLE[confirming.licence.status].label}</p>
|
||||||
|
<p className="text-gray-300 text-sm leading-snug">{confirming.licence.warning}</p>
|
||||||
|
<label className="flex items-start gap-2 text-sm text-gray-200">
|
||||||
|
<input type="checkbox" checked={ackChecked} onChange={(e) => setAckChecked(e.target.checked)} className="mt-1" />
|
||||||
|
<span>I confirm DRB holds the right to show this source's imagery. This confirmation is recorded with my account and the time in the audit log.</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button type="button" onClick={() => setConfirming(null)} className="text-sm px-3 py-1.5 rounded border border-gray-700 text-gray-300">Cancel</button>
|
||||||
|
<button type="button" disabled={!ackChecked || busy === confirming.id} onClick={() => toggle(confirming, true, true)}
|
||||||
|
className="text-sm px-3 py-1.5 rounded bg-red-700 text-white disabled:opacity-40">Enable and record</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import type { Camera, CameraProviderFeed } from "@/lib/types";
|
||||||
|
import { CameraFeed } from "@/components/CameraFeed";
|
||||||
|
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Level 3 of the wall: one camera, large. Esc / Back steps up to the incident.
|
||||||
|
*
|
||||||
|
* Camera detections (the mockup's boxes + per-class counts) are NOT built:
|
||||||
|
* blocked on server-26#199 (NYSDOT's written answer on imagery analytics) and
|
||||||
|
* #200 (CISO ruling on retention). The seam is `CameraSignalSink` in c2-core
|
||||||
|
* (app/internal/cameras/base.py); this panel only states that plainly.
|
||||||
|
*/
|
||||||
|
export function CameraDrill({
|
||||||
|
camera, feed, distanceKm, incidentTitle, pinned, onTogglePin, onClose,
|
||||||
|
}: {
|
||||||
|
camera: Camera;
|
||||||
|
feed?: CameraProviderFeed;
|
||||||
|
distanceKm?: number | null;
|
||||||
|
incidentTitle?: string | null;
|
||||||
|
pinned: boolean;
|
||||||
|
onTogglePin: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const closeRef = useRef<HTMLButtonElement>(null);
|
||||||
|
useEffect(() => { closeRef.current?.focus(); }, [camera.id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[2000] bg-black/70 flex items-center justify-center p-4" role="dialog" aria-modal="true"
|
||||||
|
aria-labelledby="drb-drill-title" onClick={onClose}>
|
||||||
|
<div className="bg-surface border border-line-strong rounded-xl shadow-2xl w-full max-w-5xl max-h-full overflow-y-auto grid md:grid-cols-[minmax(0,1fr)_300px]"
|
||||||
|
onClick={(e) => e.stopPropagation()}>
|
||||||
|
<CameraFeed camera={camera} priority="high" credit={feed} className="w-full aspect-video bg-black md:rounded-l-xl" />
|
||||||
|
<div className="p-4 space-y-3 text-sm">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<h2 id="drb-drill-title" className="flex-1 font-semibold text-ink leading-snug">{camera.name || camera.id}</h2>
|
||||||
|
<button ref={closeRef} type="button" onClick={onClose} className="text-xs border border-line rounded px-2 py-1 hover:bg-raised" aria-label="Close camera">Esc</button>
|
||||||
|
</div>
|
||||||
|
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
|
||||||
|
<dt className="text-ink-muted">Source</dt><dd className="text-ink-2">{feed?.name ?? camera.provider}</dd>
|
||||||
|
{camera.roadway && (<><dt className="text-ink-muted">Road</dt><dd className="text-ink-2">{camera.roadway}</dd></>)}
|
||||||
|
{camera.direction && (<><dt className="text-ink-muted">Direction</dt><dd className="text-ink-2">{camera.direction}{camera.heading_deg != null ? " (approx. view)" : ""}</dd></>)}
|
||||||
|
{distanceKm != null && (<><dt className="text-ink-muted">Distance</dt><dd className="text-ink-2">{distanceKm.toFixed(1)} km from {incidentTitle ?? "the incident"}</dd></>)}
|
||||||
|
<dt className="text-ink-muted">Feed</dt><dd className="text-ink-2">{camera.video_url ? "Live stream (falls back to still)" : "Still image"}</dd>
|
||||||
|
</dl>
|
||||||
|
<SourceCredit credit={feed} fallbackName={feed?.name} updated={feed ? `camera list ${fetchedLabel(feed.fetched_at) ?? "—"}` : null} />
|
||||||
|
<div className="border border-dashed border-line rounded-md p-2.5 text-xs text-ink-muted space-y-1">
|
||||||
|
<p className="font-medium text-ink-2">Camera signals · future</p>
|
||||||
|
<p>Object-class counts from camera stills (vehicle, truck, person, smoke) are not built. Blocked on
|
||||||
|
server-26#199 (NYSDOT written answer on imagery analytics) and #200 (CISO ruling on retention).
|
||||||
|
No plate or face recognition will ever be part of it.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<button type="button" onClick={onTogglePin} className="text-xs rounded px-3 py-1.5 bg-accent text-white">{pinned ? "Unpin from wall" : "Pin to wall"}</button>
|
||||||
|
<button type="button" onClick={onClose} className="text-xs border border-line rounded px-3 py-1.5 hover:bg-raised">Back</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, type CSSProperties } from "react";
|
||||||
|
import type { CallRecord } from "@/lib/types";
|
||||||
|
|
||||||
|
const WINDOW_MS = 60 * 60 * 1000;
|
||||||
|
const MAX_ITEMS = 25;
|
||||||
|
|
||||||
|
function hhmm(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live radio transcripts, newest first, as a slow belt along the bottom of
|
||||||
|
* the wall (mockup "Radio" ticker). Clicking an incident id drills into it.
|
||||||
|
* Transcripts are machine output — the same text the call list already shows.
|
||||||
|
*/
|
||||||
|
export function TranscriptTicker({ calls, onSelectIncident }: { calls: CallRecord[]; onSelectIncident: (id: string) => void }) {
|
||||||
|
const items = useMemo(() => {
|
||||||
|
const cutoff = Date.now() - WINDOW_MS;
|
||||||
|
return calls
|
||||||
|
.filter((c) => !c.duplicate_of && (c.transcript_corrected || c.transcript) && new Date(c.started_at).getTime() >= cutoff)
|
||||||
|
.sort((a, b) => b.started_at.localeCompare(a.started_at))
|
||||||
|
.slice(0, MAX_ITEMS);
|
||||||
|
}, [calls]);
|
||||||
|
|
||||||
|
const belt = (copy: number) =>
|
||||||
|
items.map((c) => {
|
||||||
|
const text = (c.transcript_corrected || c.transcript || "").replace(/\s+/g, " ").trim();
|
||||||
|
const inc = c.incident_ids?.[0] ?? c.incident_id ?? null;
|
||||||
|
return (
|
||||||
|
<span key={`${copy}-${c.call_id}`} className="inline-flex items-center gap-1.5 mr-8 whitespace-nowrap" aria-hidden={copy > 0}>
|
||||||
|
<span className="font-mono text-ink-muted">{hhmm(c.started_at)}</span>
|
||||||
|
{c.talkgroup_name && <span className="font-mono text-[10px] px-1 rounded bg-raised text-ink-2">{c.talkgroup_name}</span>}
|
||||||
|
{inc && (
|
||||||
|
<button type="button" tabIndex={copy > 0 ? -1 : 0} onClick={() => onSelectIncident(inc)}
|
||||||
|
className="font-mono text-[10px] text-accent hover:underline">{inc.slice(0, 8)}</button>
|
||||||
|
)}
|
||||||
|
<span className="text-ink-2">{text.length > 160 ? `${text.slice(0, 157)}…` : text}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-9 shrink-0 flex items-center border-t border-line bg-surface/95 text-xs" aria-label="Live radio transcripts">
|
||||||
|
<div className="px-3 shrink-0 font-semibold text-ink-2 uppercase tracking-wide text-[10px] flex items-center gap-1">
|
||||||
|
<span className="text-accent">●</span>Radio
|
||||||
|
</div>
|
||||||
|
<div className="drb-ticker flex-1 min-w-0 overflow-hidden">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<span className="text-ink-muted">No transcribed calls in the last hour.</span>
|
||||||
|
) : (
|
||||||
|
<div className="drb-ticker-belt inline-flex" style={{ "--drb-ticker-duration": `${Math.max(40, items.length * 8)}s` } as CSSProperties}>
|
||||||
|
{belt(0)}
|
||||||
|
{belt(1)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="px-3 shrink-0 text-[10px] text-ink-muted hidden md:inline">machine transcripts · unverified</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live ops wall side panel — Level 1 (region) and Level 2 (incident) of the
|
||||||
|
* approved mockup (design-canvas/live-ops-wall-mockup.html).
|
||||||
|
*/
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import type {
|
||||||
|
AircraftTrack, CallRecord, Camera, CameraCredit, CameraProviderFeed, IncidentRecord, Ny511Event, NodeRecord, VesselTrack,
|
||||||
|
} from "@/lib/types";
|
||||||
|
import { isKnownSeverity, SEVERITY_COLORS, SEVERITY_LABEL } from "@/lib/severity";
|
||||||
|
import { CameraFeed } from "@/components/CameraFeed";
|
||||||
|
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||||
|
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
|
||||||
|
import { CONTEXT_KM, NEAR_CAMERA_KM, contextFor, sampleRadarDbz, type LatLon, type NearCamera } from "@/lib/liveWall";
|
||||||
|
|
||||||
|
function sevColor(s: string | null | undefined) {
|
||||||
|
return isKnownSeverity(s) ? SEVERITY_COLORS[s] : "var(--ink-muted)";
|
||||||
|
}
|
||||||
|
function sevLabel(s: string | null | undefined) {
|
||||||
|
return isKnownSeverity(s) ? SEVERITY_LABEL[s] : "Unknown";
|
||||||
|
}
|
||||||
|
function elapsed(iso: string): string {
|
||||||
|
const m = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 60_000));
|
||||||
|
return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`;
|
||||||
|
}
|
||||||
|
function hhmm(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const H3 = "text-[11px] uppercase tracking-wide text-ink-muted font-medium mb-1.5";
|
||||||
|
const SEC = "px-4 py-3 border-b border-line";
|
||||||
|
|
||||||
|
export function CameraTile({ cam, feed, km, onOpen }: { cam: Camera; feed?: CameraProviderFeed; km?: number; onOpen: () => void }) {
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={onOpen} title={cam.name} className="relative block w-full text-left rounded-md overflow-hidden border border-line bg-black group">
|
||||||
|
<CameraFeed camera={cam} credit={feed} className="w-full aspect-video" />
|
||||||
|
{km != null && <span className="absolute top-1 right-1 bg-black/65 text-white font-mono text-[9px] px-1 rounded-sm">{km.toFixed(1)} km</span>}
|
||||||
|
<span className="block px-1.5 py-0.5 text-[10px] text-ink-2 bg-surface truncate group-hover:text-ink">{cam.name || cam.id}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoCameras({ servingCount, radiusKm }: { servingCount: number; radiusKm: number }) {
|
||||||
|
return (
|
||||||
|
<p className="text-xs text-ink-muted">
|
||||||
|
{servingCount === 0
|
||||||
|
? <>No camera provider is enabled. An admin turns them on under <Link href="/admin" className="underline">Admin → Camera providers</Link>.</>
|
||||||
|
: `No public camera within ${radiusKm} km.`}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Level 1: region ──────────────────────────────────────────────────────────
|
||||||
|
export function RegionPanel({
|
||||||
|
incidents, calls, onAirCount, nodes, wallCameras, feeds, servingCount, aircraftCount, vesselCount,
|
||||||
|
events, eventsOff, pinnedIds, onOpenCamera,
|
||||||
|
}: {
|
||||||
|
incidents: IncidentRecord[];
|
||||||
|
calls: CallRecord[];
|
||||||
|
onAirCount: number;
|
||||||
|
nodes: NodeRecord[];
|
||||||
|
wallCameras: { cam: Camera; km?: number }[];
|
||||||
|
feeds: Map<string, CameraProviderFeed>;
|
||||||
|
servingCount: number;
|
||||||
|
aircraftCount: number;
|
||||||
|
vesselCount: number;
|
||||||
|
events: Ny511Event[];
|
||||||
|
eventsOff: boolean;
|
||||||
|
pinnedIds: Set<string>;
|
||||||
|
onOpenCamera: (c: Camera) => void;
|
||||||
|
}) {
|
||||||
|
const calls6h = useMemo(() => {
|
||||||
|
const cutoff = Date.now() - 6 * 3600_000;
|
||||||
|
return calls.filter((c) => new Date(c.started_at).getTime() >= cutoff).length;
|
||||||
|
}, [calls]);
|
||||||
|
const providers = Array.from(feeds.values());
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={SEC}>
|
||||||
|
<h2 className="text-sm font-semibold text-ink mb-2">Region</h2>
|
||||||
|
<div className="grid grid-cols-3 gap-2 text-center">
|
||||||
|
{[[incidents.length, "active incidents"], [calls6h, "calls · last 6 h"], [onAirCount, "on air now"]].map(([v, l]) => (
|
||||||
|
<div key={String(l)} className="bg-raised rounded-md py-2">
|
||||||
|
<div className="text-lg font-semibold text-ink tabular-nums">{v}</div>
|
||||||
|
<div className="text-[10px] text-ink-muted">{l}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={SEC}>
|
||||||
|
<h3 className={H3}>Camera wall · pinned + nearest to major/moderate incidents</h3>
|
||||||
|
{wallCameras.length ? (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{wallCameras.map(({ cam, km }) => (
|
||||||
|
<div key={cam.id} className="relative">
|
||||||
|
<CameraTile cam={cam} feed={feeds.get(cam.provider)} km={km} onOpen={() => onOpenCamera(cam)} />
|
||||||
|
{pinnedIds.has(cam.id) && <span className="absolute bottom-6 right-1 bg-accent text-white text-[9px] px-1 rounded-sm">PINNED</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<NoCameras servingCount={servingCount} radiusKm={NEAR_CAMERA_KM} />
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] text-ink-muted mt-2">Off-screen and hidden-tab tiles stop fetching; at most two live streams play at once.</p>
|
||||||
|
</div>
|
||||||
|
<div className={SEC}>
|
||||||
|
<h3 className={H3}>Feeds</h3>
|
||||||
|
<ul className="space-y-1 text-xs text-ink-2">
|
||||||
|
<li><span className="font-mono text-[10px] text-ink-muted mr-1.5">ADS-B</span>{aircraftCount} aircraft heard by your nodes</li>
|
||||||
|
<li><span className="font-mono text-[10px] text-ink-muted mr-1.5">AIS</span>{vesselCount} vessels / aids to navigation</li>
|
||||||
|
<li><span className="font-mono text-[10px] text-ink-muted mr-1.5">511NY</span>{eventsOff ? "road events off (Admin → Camera providers)" : `${events.length} road events in region`}</li>
|
||||||
|
{providers.map((p) => (
|
||||||
|
<li key={p.id}><span className="font-mono text-[10px] text-ink-muted mr-1.5">CAM</span>{p.name}: {p.total_in_bbox} in region{p.error ? " · feed error" : ""}{p.fetched_at ? ` · list ${fetchedLabel(p.fetched_at)}` : ""}</li>
|
||||||
|
))}
|
||||||
|
<li><span className="font-mono text-[10px] text-ink-muted mr-1.5">NEXRAD</span>Weather Radar layer (map menu); sampled per incident</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className={SEC}>
|
||||||
|
<h3 className={H3}>Nodes</h3>
|
||||||
|
<ul className="space-y-1 text-xs">
|
||||||
|
{nodes.map((n) => (
|
||||||
|
<li key={n.node_id} className="flex justify-between gap-2">
|
||||||
|
<span className="text-ink-2 truncate">{n.name}</span>
|
||||||
|
<span className="font-mono text-ink-muted capitalize">{n.status}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p className="px-4 py-3 text-[10px] text-ink-muted">
|
||||||
|
Region → click an incident for its path, summary, units and nearest cameras → click a camera to open it. Esc steps back up.
|
||||||
|
Bookmark a view with <span className="font-mono">#inc=…&cam=…&tour</span>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Level 2: incident ────────────────────────────────────────────────────────
|
||||||
|
function useRadarDbz(head: LatLon | null): { dbz: number | null; done: boolean } {
|
||||||
|
const [state, setState] = useState<{ dbz: number | null; done: boolean }>({ dbz: null, done: false });
|
||||||
|
const key = head ? `${head[0].toFixed(3)},${head[1].toFixed(3)}` : "";
|
||||||
|
useEffect(() => {
|
||||||
|
if (!head) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setState({ dbz: null, done: false });
|
||||||
|
sampleRadarDbz(head[0], head[1]).then((dbz) => { if (!cancelled) setState({ dbz, done: true }); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [key]);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IncidentPanel({
|
||||||
|
inc, calls, points, near, feeds, servingCount, onAir, aircraft, vessels, events, eventsCredit, onOpenCamera,
|
||||||
|
}: {
|
||||||
|
inc: IncidentRecord;
|
||||||
|
calls: CallRecord[];
|
||||||
|
points: LatLon[];
|
||||||
|
near: NearCamera[];
|
||||||
|
feeds: Map<string, CameraProviderFeed>;
|
||||||
|
servingCount: number;
|
||||||
|
onAir: boolean;
|
||||||
|
aircraft: AircraftTrack[];
|
||||||
|
vessels: VesselTrack[];
|
||||||
|
events: Ny511Event[];
|
||||||
|
eventsCredit: { credit?: CameraCredit; fetched_at: number | null };
|
||||||
|
onOpenCamera: (c: Camera) => void;
|
||||||
|
}) {
|
||||||
|
const color = sevColor(inc.severity);
|
||||||
|
const ctx = useMemo(() => contextFor(points, aircraft, vessels, events), [points, aircraft, vessels, events]);
|
||||||
|
const radar = useRadarDbz(points.at(-1) ?? null);
|
||||||
|
// Stop numbers share the index with the map path: geocoded calls, oldest first.
|
||||||
|
const stopOf = useMemo(() => {
|
||||||
|
const m = new Map<string, number>();
|
||||||
|
calls.filter((c) => c.location_coords).slice().sort((a, b) => a.started_at.localeCompare(b.started_at))
|
||||||
|
.forEach((c, i) => m.set(c.call_id, i + 1));
|
||||||
|
return m;
|
||||||
|
}, [calls]);
|
||||||
|
const spine = useMemo(() => calls.slice().sort((a, b) => b.started_at.localeCompare(a.started_at)), [calls]);
|
||||||
|
const unitsOn = inc.units_active?.length ? inc.units_active : inc.units ?? [];
|
||||||
|
const cleared = inc.units_cleared ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={SEC}>
|
||||||
|
<div className="flex items-center gap-2 text-[11px] mb-1">
|
||||||
|
<span className="font-semibold uppercase tracking-wide" style={{ color }}>{sevLabel(inc.severity)}</span>
|
||||||
|
<span className="text-ink-muted">{inc.type ?? "other"}</span>
|
||||||
|
{onAir ? <span className="bg-accent text-white rounded px-1 text-[9px] font-semibold">ON AIR</span> : <span className="text-ink-muted">active</span>}
|
||||||
|
<span className="ml-auto font-mono text-ink-muted">{inc.incident_id.slice(0, 8)}</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-base font-semibold text-ink leading-snug">{inc.title ?? "Incident"}</h2>
|
||||||
|
<div className="text-xs text-ink-muted mt-1 flex flex-wrap gap-x-3">
|
||||||
|
{inc.location && <span>{inc.location}</span>}
|
||||||
|
<span className="font-mono">{elapsed(inc.started_at)} elapsed</span>
|
||||||
|
<span>{inc.call_ids?.length ?? 0} calls</span>
|
||||||
|
</div>
|
||||||
|
<Link href={`/incidents/${inc.incident_id}`} className="text-xs text-accent hover:underline mt-1 inline-block">Open incident page →</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={SEC}>
|
||||||
|
<MachineOutputNotice variant="inline" className="text-[10px] mb-1.5 items-start" />
|
||||||
|
{inc.summary ? <p className="text-sm text-ink-2 leading-relaxed">{inc.summary}</p> : <p className="text-xs text-ink-muted">No AI summary yet.</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={SEC}>
|
||||||
|
<h3 className={H3}>Nearest cameras · auto-surfaced{points.length > 1 ? " (weighted to the path's head)" : ""}</h3>
|
||||||
|
{near.length ? (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{near.map(({ cam, km }) => (
|
||||||
|
<CameraTile key={cam.id} cam={cam} feed={feeds.get(cam.provider)} km={km} onOpen={() => onOpenCamera(cam)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : points.length ? (
|
||||||
|
<NoCameras servingCount={servingCount} radiusKm={NEAR_CAMERA_KM} />
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-ink-muted">This incident has no geocoded location, so no cameras can be matched.</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-2 border border-dashed border-line rounded-md px-2 py-1.5 text-[10px] text-ink-muted">
|
||||||
|
Camera signals (detections as incident evidence) · future — blocked on server-26#199 and #200.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={SEC}>
|
||||||
|
<h3 className={H3}>Situational context · fused layers</h3>
|
||||||
|
<ul className="space-y-1 text-xs text-ink-2">
|
||||||
|
{ctx.aircraft.map(({ a, km }) => (
|
||||||
|
<li key={a.icao}><span className="font-mono text-[10px] text-ink-muted mr-1.5">ADS-B</span>
|
||||||
|
<b>{a.callsign || a.icao}</b>{a.altitude_ft != null ? ` · ${Math.round(a.altitude_ft).toLocaleString()} ft` : ""} · {km.toFixed(1)} km</li>
|
||||||
|
))}
|
||||||
|
{ctx.vessels.map(({ v, km }) => (
|
||||||
|
<li key={v.mmsi}><span className="font-mono text-[10px] text-ink-muted mr-1.5">AIS</span>
|
||||||
|
<b>{v.name || v.mmsi}</b>{v.speed_kt != null ? ` · ${Math.round(v.speed_kt)} kt` : ""} · {km.toFixed(1)} km</li>
|
||||||
|
))}
|
||||||
|
{ctx.road.map(({ e, km }) => (
|
||||||
|
<li key={e.id}>
|
||||||
|
<span className="font-mono text-[10px] text-ink-muted mr-1.5">511NY</span>
|
||||||
|
{/* DAA: NYSDOT's text as published, with its update time and source credit. */}
|
||||||
|
<span className="whitespace-pre-line">{e.description}</span> · {km.toFixed(1)} km
|
||||||
|
<SourceCredit credit={eventsCredit.credit} fallbackName="511NY / NYSDOT"
|
||||||
|
updated={e.updated_local ? new Date(e.updated_local).toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }) : fetchedLabel(eventsCredit.fetched_at)} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
<li>
|
||||||
|
<span className="font-mono text-[10px] text-ink-muted mr-1.5">NEXRAD</span>
|
||||||
|
{!points.length ? "—" : !radar.done ? "sampling…" : radar.dbz == null ? "radar sample unavailable"
|
||||||
|
: radar.dbz === 0 ? "no precipitation echo within ~5 km" : `up to ~${radar.dbz} dBZ within ~5 km (approx., from the radar tile)`}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{!ctx.aircraft.length && !ctx.vessels.length && !ctx.road.length && (
|
||||||
|
<p className="text-[10px] text-ink-muted mt-1">No aircraft within {CONTEXT_KM.aircraft} km, vessels within {CONTEXT_KM.vessel} km, or road events within {CONTEXT_KM.road} km.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`${SEC} grid grid-cols-2 gap-3`}>
|
||||||
|
<div>
|
||||||
|
<h3 className={H3}>On scene</h3>
|
||||||
|
<div className="flex flex-wrap gap-1">{unitsOn.length ? unitsOn.map((u) => <span key={u} className="font-mono text-[10px] px-1 rounded bg-raised text-ink-2">{u}</span>) : <span className="text-xs text-ink-muted">—</span>}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className={H3}>Cleared</h3>
|
||||||
|
<div className="flex flex-wrap gap-1">{cleared.length ? cleared.map((u) => <span key={u} className="font-mono text-[10px] px-1 rounded bg-raised text-ink-muted">{u}</span>) : <span className="text-xs text-ink-muted">—</span>}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={SEC}>
|
||||||
|
<h3 className={H3}>Call spine · newest first</h3>
|
||||||
|
{spine.length ? (
|
||||||
|
<ol className="space-y-2">
|
||||||
|
{spine.slice(0, 30).map((c) => {
|
||||||
|
const stop = stopOf.get(c.call_id);
|
||||||
|
const text = c.transcript_corrected || c.transcript;
|
||||||
|
return (
|
||||||
|
<li key={c.call_id} className="flex gap-2 text-xs">
|
||||||
|
<span className="shrink-0 w-5 h-5 rounded-full flex items-center justify-center font-mono text-[10px]"
|
||||||
|
style={stop ? { background: color, color: "var(--page)" } : { background: "var(--raised)", color: "var(--ink-muted)" }}>{stop ?? "·"}</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex gap-1.5 items-center">
|
||||||
|
<span className="font-mono text-ink-muted">{hhmm(c.started_at)}</span>
|
||||||
|
{c.talkgroup_name && <span className="font-mono text-[10px] px-1 rounded bg-raised text-ink-2 truncate">{c.talkgroup_name}</span>}
|
||||||
|
</div>
|
||||||
|
<p className="text-ink-2 leading-snug">{text || <span className="text-ink-muted">no transcript</span>}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-ink-muted">No calls for this incident in the loaded window.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { auth } from "@/lib/firebase";
|
import { auth } from "@/lib/firebase";
|
||||||
import type { AreaContext, Ny511Response, TalkgroupPending } from "@/lib/types";
|
import type { AreaContext, CameraProviderAdmin, CamerasResponse, 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";
|
||||||
|
|
||||||
@@ -390,6 +390,21 @@ export const c2api = {
|
|||||||
south: String(bbox.south), west: String(bbox.west), north: String(bbox.north), east: String(bbox.east), layers,
|
south: String(bbox.south), west: String(bbox.west), north: String(bbox.north), east: String(bbox.east), layers,
|
||||||
})}`),
|
})}`),
|
||||||
|
|
||||||
|
// Public cameras from every admin-enabled provider inside a bbox (live ops wall)
|
||||||
|
getCameras: (bbox: { south: number; west: number; north: number; east: number }) =>
|
||||||
|
request<CamerasResponse>(`/cameras?${new URLSearchParams({
|
||||||
|
south: String(bbox.south), west: String(bbox.west), north: String(bbox.north), east: String(bbox.east),
|
||||||
|
})}`),
|
||||||
|
|
||||||
|
// Admin → Camera providers. A licence-gated provider answers 409 with
|
||||||
|
// `requires_acknowledgement` until `acknowledge_licence` is sent.
|
||||||
|
listCameraProviders: () => request<{ providers: CameraProviderAdmin[] }>("/admin/camera-providers"),
|
||||||
|
setCameraProvider: (id: string, enabled: boolean, acknowledgeLicence = false) =>
|
||||||
|
request<CameraProviderAdmin>(`/admin/camera-providers/${encodeURIComponent(id)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ enabled, acknowledge_licence: acknowledgeLicence }),
|
||||||
|
}),
|
||||||
|
|
||||||
// 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) }),
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
/**
|
||||||
|
* Live ops wall — pure helpers (design-canvas/live-ops-wall-mockup.html,
|
||||||
|
* gods-eye-view-notes.md top 5). No React here, so the logic is testable in
|
||||||
|
* isolation and shared by MapView and LiveView.
|
||||||
|
*/
|
||||||
|
import type { AircraftTrack, CallRecord, Camera, IncidentRecord, Ny511Event, VesselTrack } from "@/lib/types";
|
||||||
|
import { severityRank } from "@/lib/severity";
|
||||||
|
|
||||||
|
export type LatLon = [number, number];
|
||||||
|
|
||||||
|
export function km(a: LatLon, b: LatLon): number {
|
||||||
|
const R = 6371;
|
||||||
|
const dLa = ((b[0] - a[0]) * Math.PI) / 180;
|
||||||
|
const dLo = ((b[1] - a[1]) * Math.PI) / 180;
|
||||||
|
const x =
|
||||||
|
Math.sin(dLa / 2) ** 2 +
|
||||||
|
Math.cos((a[0] * Math.PI) / 180) * Math.cos((b[0] * Math.PI) / 180) * Math.sin(dLo / 2) ** 2;
|
||||||
|
return 2 * R * Math.asin(Math.sqrt(x));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calls grouped by incident id (multi-scene calls count toward each incident). */
|
||||||
|
export function groupCallsByIncident(calls: CallRecord[]): Map<string, CallRecord[]> {
|
||||||
|
const map = new Map<string, CallRecord[]>();
|
||||||
|
for (const c of calls) {
|
||||||
|
const ids = c.incident_ids?.length ? c.incident_ids : c.incident_id ? [c.incident_id] : [];
|
||||||
|
for (const id of ids) {
|
||||||
|
if (!map.has(id)) map.set(id, []);
|
||||||
|
map.get(id)!.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An incident's geography, oldest first: its geocoded call stops (the path —
|
||||||
|
* same order as the call spine) or, failing that, its pin. Empty = not plotted.
|
||||||
|
*/
|
||||||
|
export function incidentPoints(inc: IncidentRecord, calls: CallRecord[] | undefined): LatLon[] {
|
||||||
|
const stops = (calls ?? [])
|
||||||
|
.filter((c) => c.location_coords)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.started_at.localeCompare(b.started_at))
|
||||||
|
.map((c) => [c.location_coords!.lat, c.location_coords!.lng] as LatLon);
|
||||||
|
if (stops.length >= 2) return stops;
|
||||||
|
if (inc.location_coords) return [[inc.location_coords.lat, inc.location_coords.lng]];
|
||||||
|
return stops;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Worst-first, then most recent (UI_REDESIGN.md §5.1). */
|
||||||
|
export function worstFirst(incidents: IncidentRecord[]): IncidentRecord[] {
|
||||||
|
return incidents
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => severityRank(b.severity) - severityRank(a.severity) || b.started_at.localeCompare(a.started_at));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NEAR_CAMERA_KM = 3.5;
|
||||||
|
|
||||||
|
export interface NearCamera { cam: Camera; km: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nearest cameras to an incident. For a path, distance is to ANY stop but
|
||||||
|
* weighted toward the newest one, so a pursuit surfaces the cameras at its
|
||||||
|
* head first (GEV "NEAREST", path-aware as in the mockup). `km` is the plain
|
||||||
|
* distance to the nearest stop/pin; ranking uses the weighted score.
|
||||||
|
*/
|
||||||
|
export function nearestCameras(points: LatLon[], cameras: Camera[], k = 4, maxKm = NEAR_CAMERA_KM): NearCamera[] {
|
||||||
|
if (!points.length) return [];
|
||||||
|
const last = points.length - 1;
|
||||||
|
return cameras
|
||||||
|
.map((cam) => {
|
||||||
|
let raw = Infinity;
|
||||||
|
let score = Infinity;
|
||||||
|
points.forEach((p, i) => {
|
||||||
|
const d = km(p, [cam.lat, cam.lon]);
|
||||||
|
raw = Math.min(raw, d);
|
||||||
|
score = Math.min(score, d * (1 + (last - i) * 0.5));
|
||||||
|
});
|
||||||
|
return { cam, km: raw, score };
|
||||||
|
})
|
||||||
|
.filter((x) => x.score < maxKm)
|
||||||
|
.sort((a, b) => a.score - b.score)
|
||||||
|
.slice(0, k)
|
||||||
|
.map(({ cam, km: d }) => ({ cam, km: d }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coarse 2D viewshed cone (GEV idea #4). APPROXIMATE: built from the
|
||||||
|
* published direction of travel, not a surveyed heading/FOV — label it so
|
||||||
|
* wherever it is drawn. Cameras with no usable direction get no cone rather
|
||||||
|
* than a made-up one.
|
||||||
|
*/
|
||||||
|
export function viewshedWedge(cam: Camera, rangeKm = 0.8, fovDeg = 50): LatLon[] | null {
|
||||||
|
if (cam.heading_deg == null) return null;
|
||||||
|
const pts: LatLon[] = [[cam.lat, cam.lon]];
|
||||||
|
const cosLat = Math.cos((cam.lat * Math.PI) / 180);
|
||||||
|
for (let a = -fovDeg / 2; a <= fovDeg / 2; a += 5) {
|
||||||
|
const b = ((cam.heading_deg + a) * Math.PI) / 180;
|
||||||
|
pts.push([cam.lat + (rangeKm / 111) * Math.cos(b), cam.lon + (rangeKm / (111 * cosLat)) * Math.sin(b)]);
|
||||||
|
}
|
||||||
|
return pts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fused situational context ────────────────────────────────────────────────
|
||||||
|
export const CONTEXT_KM = { aircraft: 4, vessel: 3, road: 2 };
|
||||||
|
|
||||||
|
export interface IncidentContext {
|
||||||
|
aircraft: { a: AircraftTrack; km: number }[];
|
||||||
|
vessels: { v: VesselTrack; km: number }[];
|
||||||
|
road: { e: Ny511Event; km: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestKm(points: LatLon[], lat: number, lon: number): number {
|
||||||
|
return points.reduce((m, p) => Math.min(m, km(p, [lat, lon])), Infinity);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contextFor(
|
||||||
|
points: LatLon[],
|
||||||
|
aircraft: AircraftTrack[],
|
||||||
|
vessels: VesselTrack[],
|
||||||
|
events: Ny511Event[],
|
||||||
|
): IncidentContext {
|
||||||
|
const near = <T,>(items: T[], pos: (t: T) => LatLon | null, r: number) =>
|
||||||
|
items
|
||||||
|
.map((t) => {
|
||||||
|
const p = pos(t);
|
||||||
|
return { t, km: p ? nearestKm(points, p[0], p[1]) : Infinity };
|
||||||
|
})
|
||||||
|
.filter((x) => x.km <= r)
|
||||||
|
.sort((a, b) => a.km - b.km);
|
||||||
|
return {
|
||||||
|
aircraft: near(aircraft, (a) => (a.lat != null && a.lon != null ? [a.lat, a.lon] : null), CONTEXT_KM.aircraft)
|
||||||
|
.map(({ t, km: d }) => ({ a: t, km: d })),
|
||||||
|
vessels: near(vessels, (v) => (v.lat != null && v.lon != null ? [v.lat, v.lon] : null), CONTEXT_KM.vessel)
|
||||||
|
.map(({ t, km: d }) => ({ v: t, km: d })),
|
||||||
|
road: near(events, (e) => [e.lat, e.lon], CONTEXT_KM.road).map(({ t, km: d }) => ({ e: t, km: d })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bbox around a set of points, padded by `padKm`. */
|
||||||
|
export function bboxAround(points: LatLon[], padKm: number) {
|
||||||
|
if (!points.length) return null;
|
||||||
|
const lats = points.map((p) => p[0]);
|
||||||
|
const lons = points.map((p) => p[1]);
|
||||||
|
const dLat = padKm / 111;
|
||||||
|
const midLat = (Math.min(...lats) + Math.max(...lats)) / 2;
|
||||||
|
const dLon = padKm / (111 * Math.max(0.2, Math.cos((midLat * Math.PI) / 180)));
|
||||||
|
return {
|
||||||
|
south: Math.max(-90, Math.min(...lats) - dLat),
|
||||||
|
north: Math.min(90, Math.max(...lats) + dLat),
|
||||||
|
west: Math.max(-180, Math.min(...lons) - dLon),
|
||||||
|
east: Math.min(180, Math.max(...lons) + dLon),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Deep links: #inc=<id>&cam=<cameraId>&tour ───────────────────────────────
|
||||||
|
export interface WallLink { inc: string | null; cam: string | null; tour: boolean }
|
||||||
|
|
||||||
|
export function parseWallHash(hash: string): WallLink {
|
||||||
|
const p = new URLSearchParams(hash.replace(/^#/, ""));
|
||||||
|
return { inc: p.get("inc") || null, cam: p.get("cam") || null, tour: p.has("tour") };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wallHash(link: WallLink): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (link.inc) parts.push(`inc=${encodeURIComponent(link.inc)}`);
|
||||||
|
if (link.cam) parts.push(`cam=${encodeURIComponent(link.cam)}`);
|
||||||
|
if (link.tour) parts.push("tour");
|
||||||
|
return parts.length ? `#${parts.join("&")}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "On air": a live call is on one of the incident's talkgroups right now. */
|
||||||
|
export function onAirIncidentIds(incidents: IncidentRecord[], activeCalls: CallRecord[]): Set<string> {
|
||||||
|
const live = new Set(activeCalls.map((c) => c.talkgroup_id).filter((t): t is number => t != null).map(String));
|
||||||
|
return new Set(
|
||||||
|
incidents.filter((i) => (i.talkgroup_ids ?? []).some((t) => live.has(String(t)))).map((i) => i.incident_id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── NEXRAD sample near an incident ───────────────────────────────────────────
|
||||||
|
// Reads the same IEM n0r composite tile the Weather Radar layer draws (IEM
|
||||||
|
// sends ACAO: *), and maps pixel colours back to the NWS reflectivity scale.
|
||||||
|
// Approximate by construction — label it so.
|
||||||
|
const N0R_PALETTE: [number, number, number, number][] = [
|
||||||
|
[5, 4, 233, 231], [10, 1, 159, 244], [15, 3, 0, 244], [20, 2, 253, 2], [25, 1, 197, 1], [30, 0, 142, 0],
|
||||||
|
[35, 253, 248, 2], [40, 229, 188, 0], [45, 253, 149, 0], [50, 253, 0, 0], [55, 212, 0, 0], [60, 188, 0, 0],
|
||||||
|
[65, 248, 0, 253], [70, 152, 84, 198], [75, 253, 253, 253],
|
||||||
|
];
|
||||||
|
const RADAR_TILE = "https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0r-900913";
|
||||||
|
|
||||||
|
function pixelDbz(r: number, g: number, b: number): number {
|
||||||
|
let best = 0;
|
||||||
|
let bestD = Infinity;
|
||||||
|
for (const [dbz, pr, pg, pb] of N0R_PALETTE) {
|
||||||
|
const d = (r - pr) ** 2 + (g - pg) ** 2 + (b - pb) ** 2;
|
||||||
|
if (d < bestD) { bestD = d; best = dbz; }
|
||||||
|
}
|
||||||
|
return bestD < 60 * 60 ? best : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Max reflectivity (dBZ) within ~radiusKm of a point, or null if it can't be read. */
|
||||||
|
export async function sampleRadarDbz(lat: number, lon: number, radiusKm = 5): Promise<number | null> {
|
||||||
|
const z = 9;
|
||||||
|
const n = 2 ** z;
|
||||||
|
const xf = ((lon + 180) / 360) * n;
|
||||||
|
const latR = (lat * Math.PI) / 180;
|
||||||
|
const yf = ((1 - Math.log(Math.tan(latR) + 1 / Math.cos(latR)) / Math.PI) / 2) * n;
|
||||||
|
const tx = Math.floor(xf);
|
||||||
|
const ty = Math.floor(yf);
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = "anonymous";
|
||||||
|
img.src = `${RADAR_TILE}/${z}/${tx}/${ty}.png?_=${Math.floor(Date.now() / 300000)}`;
|
||||||
|
try {
|
||||||
|
await img.decode();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = 256;
|
||||||
|
canvas.height = 256;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return null;
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
const px = Math.floor((xf - tx) * 256);
|
||||||
|
const py = Math.floor((yf - ty) * 256);
|
||||||
|
const kmPerPx = (40075 * Math.cos(latR)) / (n * 256);
|
||||||
|
const rad = Math.max(1, Math.round(radiusKm / kmPerPx));
|
||||||
|
const x0 = Math.max(0, px - rad);
|
||||||
|
const y0 = Math.max(0, py - rad);
|
||||||
|
const w = Math.min(256, px + rad) - x0;
|
||||||
|
const h = Math.min(256, py + rad) - y0;
|
||||||
|
if (w <= 0 || h <= 0) return null;
|
||||||
|
let data: Uint8ClampedArray;
|
||||||
|
try {
|
||||||
|
data = ctx.getImageData(x0, y0, w, h).data;
|
||||||
|
} catch {
|
||||||
|
return null; // tainted canvas — CORS changed upstream
|
||||||
|
}
|
||||||
|
let max = 0;
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
if (data[i + 3] < 32) continue;
|
||||||
|
max = Math.max(max, pixelDbz(data[i], data[i + 1], data[i + 2]));
|
||||||
|
}
|
||||||
|
return max;
|
||||||
|
}
|
||||||
@@ -139,16 +139,83 @@ export interface VesselTrack {
|
|||||||
|
|
||||||
// 511NY traffic layer (server-26#183) — GET /traffic/511, cached server-side,
|
// 511NY traffic layer (server-26#183) — GET /traffic/511, cached server-side,
|
||||||
// not Firestore. *_local times are New York local, no offset: display only.
|
// not Firestore. *_local times are New York local, no offset: display only.
|
||||||
export interface Ny511Camera {
|
/**
|
||||||
|
* One public camera from any enabled provider (GET /cameras, live ops wall).
|
||||||
|
* `id` is provider-prefixed ("ny511:NYSDOT-1"). `heading_deg` is APPROXIMATE —
|
||||||
|
* most sources publish the road's direction of travel, not the camera heading.
|
||||||
|
*/
|
||||||
|
export interface Camera {
|
||||||
id: string;
|
id: string;
|
||||||
|
provider: string;
|
||||||
lat: number;
|
lat: number;
|
||||||
lon: number;
|
lon: number;
|
||||||
name: string;
|
name: string;
|
||||||
roadway: string;
|
roadway: string;
|
||||||
direction: string;
|
direction: string;
|
||||||
|
heading_deg: number | null;
|
||||||
image_url: string | null;
|
image_url: string | null;
|
||||||
video_url: string | null;
|
video_url: string | null;
|
||||||
}
|
}
|
||||||
|
/** Kept for older call sites; every camera is now a provider camera. */
|
||||||
|
export type Ny511Camera = Camera;
|
||||||
|
|
||||||
|
/** What must travel with a provider's imagery: credit, link, pass-through disclaimer. */
|
||||||
|
export interface CameraCredit {
|
||||||
|
attribution: string | null;
|
||||||
|
attribution_url: string | null;
|
||||||
|
disclaimer: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CameraProviderFeed extends CameraCredit {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
fetched_at: number | null; // epoch s of the provider's last successful catalog fetch
|
||||||
|
error: string | null;
|
||||||
|
total_in_bbox: number;
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CamerasResponse {
|
||||||
|
cameras: Camera[];
|
||||||
|
providers: CameraProviderFeed[];
|
||||||
|
serving_count: number; // providers enabled anywhere (0 → "none enabled" empty state)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LicenceStatus =
|
||||||
|
| "commercial_ok" | "attribution_required" | "agreement_required"
|
||||||
|
| "non_commercial" | "unverified" | "paid" | "prohibited";
|
||||||
|
|
||||||
|
/** Admin → Camera providers (GET /admin/camera-providers). */
|
||||||
|
export interface CameraProviderAdmin {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
region: string;
|
||||||
|
formats: string;
|
||||||
|
implemented: boolean;
|
||||||
|
notes: string;
|
||||||
|
homepage: string | null;
|
||||||
|
licence: {
|
||||||
|
status: LicenceStatus;
|
||||||
|
label: string;
|
||||||
|
terms: string;
|
||||||
|
terms_url: string | null;
|
||||||
|
attribution: string | null;
|
||||||
|
attribution_url: string | null;
|
||||||
|
disclaimer: string | null;
|
||||||
|
issue: string | null;
|
||||||
|
gated: boolean;
|
||||||
|
warning: string | null;
|
||||||
|
};
|
||||||
|
enabled: boolean;
|
||||||
|
serving: boolean;
|
||||||
|
not_serving_reason: string | null;
|
||||||
|
acknowledgement: { by_uid: string; by_email: string; at: string; licence_status: string; warning: string } | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
updated_by: { uid: string; email: string } | null;
|
||||||
|
key_setting: string | null;
|
||||||
|
key_missing: boolean;
|
||||||
|
health: { fetched_at: number | null; last_attempt_at: number | null; error: string | null; count: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Ny511Event {
|
export interface Ny511Event {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -171,13 +238,17 @@ export interface Ny511FeedStatus {
|
|||||||
error: string | null; // set when the latest refresh failed (data is then stale)
|
error: string | null; // set when the latest refresh failed (data is then stale)
|
||||||
total_in_bbox: number;
|
total_in_bbox: number;
|
||||||
truncated: boolean;
|
truncated: boolean;
|
||||||
|
/** True when an admin has not enabled the 511NY provider (default OFF, DAA-gated). */
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Ny511Response {
|
export interface Ny511Response {
|
||||||
cameras?: Ny511Camera[];
|
cameras?: Camera[];
|
||||||
cameras_status?: Ny511FeedStatus;
|
cameras_status?: Ny511FeedStatus;
|
||||||
events?: Ny511Event[];
|
events?: Ny511Event[];
|
||||||
events_status?: Ny511FeedStatus;
|
events_status?: Ny511FeedStatus;
|
||||||
|
/** DAA: source credit + NYSDOT as-is disclaimer, present when the feed is served. */
|
||||||
|
credit?: CameraCredit;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VocabularyPendingTerm {
|
export interface VocabularyPendingTerm {
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type L from "leaflet";
|
||||||
|
import { c2api } from "@/lib/c2api";
|
||||||
|
import type { CamerasResponse } from "@/lib/types";
|
||||||
|
|
||||||
|
// Public cameras from every provider an admin has enabled (GET /cameras).
|
||||||
|
// Catalogs are cached server-side (15 min); this only re-asks for the bbox.
|
||||||
|
const POLL_MS = 5 * 60 * 1000;
|
||||||
|
const MOVE_DEBOUNCE_MS = 400;
|
||||||
|
const BBOX_PAD = 0.2;
|
||||||
|
|
||||||
|
export interface BBox { south: number; west: number; north: number; east: number }
|
||||||
|
|
||||||
|
const EMPTY: CamerasResponse = { cameras: [], providers: [], serving_count: 0 };
|
||||||
|
|
||||||
|
function useCameraFetch(getBox: (() => BBox | null) | null, deps: unknown[], subscribe?: (reload: () => void) => () => void) {
|
||||||
|
const [data, setData] = useState<CamerasResponse>(EMPTY);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const seq = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!getBox) { setData(EMPTY); setLoaded(false); return; }
|
||||||
|
const boxFn = getBox;
|
||||||
|
const load = async () => {
|
||||||
|
const box = boxFn();
|
||||||
|
if (!box) return;
|
||||||
|
const mine = ++seq.current;
|
||||||
|
try {
|
||||||
|
const res = await c2api.getCameras(box);
|
||||||
|
if (mine !== seq.current) return;
|
||||||
|
setData(res);
|
||||||
|
setError(null);
|
||||||
|
setLoaded(true);
|
||||||
|
} catch (e) {
|
||||||
|
if (mine !== seq.current) return;
|
||||||
|
console.error("useCameras:", e);
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
setLoaded(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
const unsub = subscribe ? subscribe(load) : undefined;
|
||||||
|
const poll = setInterval(() => { if (!document.hidden) load(); }, POLL_MS);
|
||||||
|
return () => {
|
||||||
|
unsub?.();
|
||||||
|
clearInterval(poll);
|
||||||
|
seq.current++;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, deps);
|
||||||
|
|
||||||
|
return { data, error, loaded };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cameras for the current map view, refetched on pan/zoom while `enabled`. */
|
||||||
|
export function useMapCameras(map: L.Map, enabled: boolean) {
|
||||||
|
return useCameraFetch(
|
||||||
|
enabled
|
||||||
|
? () => {
|
||||||
|
const b = map.getBounds().pad(BBOX_PAD);
|
||||||
|
return { south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() };
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
[map, enabled],
|
||||||
|
(reload) => {
|
||||||
|
let t: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const onMove = () => { clearTimeout(t); t = setTimeout(reload, MOVE_DEBOUNCE_MS); };
|
||||||
|
map.on("moveend", onMove);
|
||||||
|
return () => { map.off("moveend", onMove); clearTimeout(t); };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cameras inside a fixed bbox (e.g. around the active incidents). `null` = don't fetch. */
|
||||||
|
export function useCamerasInBbox(box: BBox | null) {
|
||||||
|
const key = box ? `${box.south.toFixed(3)},${box.west.toFixed(3)},${box.north.toFixed(3)},${box.east.toFixed(3)}` : "";
|
||||||
|
return useCameraFetch(box ? () => box : null, [key]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user