511NY DOT cameras + traffic events map layers (#183)
GET /traffic/511 serves a bbox slice of the statewide 511NY camera and event feeds from an in-memory cache (cameras 1h, events 2m TTL, fetched lazily) -- public data, so no Firestore writes. A failed refresh keeps the last good data and reports the error; a schema change (nothing parses) is an error, not an empty layer. Frontend: opt-in "DOT Cameras" and "Traffic Events" overlays that fetch only while shown, with an on-map notice when the feed is down or stale. NY511_API_KEY is optional in config (the API answers without one today; the terms require a registered key). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1a01497f4d
commit
3347ced863
@@ -1,5 +1,5 @@
|
||||
import { auth } from "@/lib/firebase";
|
||||
import type { AreaContext, TalkgroupPending } from "@/lib/types";
|
||||
import type { AreaContext, Ny511Response, TalkgroupPending } from "@/lib/types";
|
||||
|
||||
const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000";
|
||||
|
||||
@@ -370,6 +370,12 @@ export const c2api = {
|
||||
revokeEnrollmentToken: (tokenId: string) =>
|
||||
request(`/org/enrollment-tokens/${tokenId}`, { method: "DELETE" }),
|
||||
|
||||
// 511NY cameras/events inside a map bbox (server-26#183)
|
||||
get511: (bbox: { south: number; west: number; north: number; east: number }, layers: string) =>
|
||||
request<Ny511Response>(`/traffic/511?${new URLSearchParams({
|
||||
south: String(bbox.south), west: String(bbox.west), north: String(bbox.north), east: String(bbox.east), layers,
|
||||
})}`),
|
||||
|
||||
// Public waitlist — no auth, see routers/waitlist.py
|
||||
joinWaitlist: (body: { email: string; org_name?: string; note?: string }) =>
|
||||
request<{ ok: boolean }>("/waitlist", { method: "POST", body: JSON.stringify(body) }),
|
||||
|
||||
@@ -98,6 +98,49 @@ export interface VesselTrack {
|
||||
last_seen: string;
|
||||
}
|
||||
|
||||
// 511NY traffic layer (server-26#183) — GET /traffic/511, cached server-side,
|
||||
// not Firestore. *_local times are New York local, no offset: display only.
|
||||
export interface Ny511Camera {
|
||||
id: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
name: string;
|
||||
roadway: string;
|
||||
direction: string;
|
||||
image_url: string | null;
|
||||
video_url: string | null;
|
||||
}
|
||||
|
||||
export interface Ny511Event {
|
||||
id: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
type: string;
|
||||
subtype: string;
|
||||
severity: string;
|
||||
roadway: string;
|
||||
direction: string;
|
||||
county: string;
|
||||
description: string;
|
||||
start_local: string | null;
|
||||
planned_end_local: string | null;
|
||||
updated_local: string | null;
|
||||
}
|
||||
|
||||
export interface Ny511FeedStatus {
|
||||
fetched_at: number | null; // epoch seconds of the last successful 511NY fetch
|
||||
error: string | null; // set when the latest refresh failed (data is then stale)
|
||||
total_in_bbox: number;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export interface Ny511Response {
|
||||
cameras?: Ny511Camera[];
|
||||
cameras_status?: Ny511FeedStatus;
|
||||
events?: Ny511Event[];
|
||||
events_status?: Ny511FeedStatus;
|
||||
}
|
||||
|
||||
export interface VocabularyPendingTerm {
|
||||
term: string;
|
||||
source: "induction" | "correction";
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type L from "leaflet";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import type { Ny511Response } from "@/lib/types";
|
||||
|
||||
// 511NY cameras/events for the current map view (server-26#183). Unlike
|
||||
// useAircraft/useVessels this is not a Firestore listener: the data is public
|
||||
// and statewide, so c2-core caches it in memory and serves a bbox slice.
|
||||
// Fetches only while `enabled` (the overlay is on), on pan/zoom (debounced),
|
||||
// and on a poll matching the server's events TTL.
|
||||
const POLL_MS = 2 * 60 * 1000;
|
||||
const MOVE_DEBOUNCE_MS = 400;
|
||||
const BBOX_PAD = 0.2; // fetch a little past the edges so small pans don't blank the layer
|
||||
|
||||
export function use511(map: L.Map, layers: "cameras" | "events", enabled: boolean) {
|
||||
const [data, setData] = useState<Ny511Response>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const seq = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) { setData({}); return; }
|
||||
let debounce: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const load = async () => {
|
||||
const b = map.getBounds().pad(BBOX_PAD);
|
||||
const mine = ++seq.current;
|
||||
try {
|
||||
const res = await c2api.get511(
|
||||
{ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() },
|
||||
layers,
|
||||
);
|
||||
if (mine !== seq.current) return; // a newer pan's response wins
|
||||
setData(res);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
if (mine !== seq.current) return;
|
||||
console.error("use511:", e);
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
const onMove = () => { clearTimeout(debounce); debounce = setTimeout(load, MOVE_DEBOUNCE_MS); };
|
||||
|
||||
load();
|
||||
map.on("moveend", onMove);
|
||||
const poll = setInterval(load, POLL_MS);
|
||||
return () => {
|
||||
map.off("moveend", onMove);
|
||||
clearTimeout(debounce);
|
||||
clearInterval(poll);
|
||||
seq.current++; // drop any in-flight response
|
||||
};
|
||||
}, [map, layers, enabled]);
|
||||
|
||||
return { data, error };
|
||||
}
|
||||
Reference in New Issue
Block a user