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
@@ -1,5 +1,5 @@
|
||||
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";
|
||||
|
||||
@@ -390,6 +390,21 @@ export const c2api = {
|
||||
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
|
||||
joinWaitlist: (body: { email: string; org_name?: string; note?: string }) =>
|
||||
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,
|
||||
// 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;
|
||||
provider: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
name: string;
|
||||
roadway: string;
|
||||
direction: string;
|
||||
heading_deg: number | null;
|
||||
image_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 {
|
||||
id: string;
|
||||
@@ -171,13 +238,17 @@ export interface Ny511FeedStatus {
|
||||
error: string | null; // set when the latest refresh failed (data is then stale)
|
||||
total_in_bbox: number;
|
||||
truncated: boolean;
|
||||
/** True when an admin has not enabled the 511NY provider (default OFF, DAA-gated). */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface Ny511Response {
|
||||
cameras?: Ny511Camera[];
|
||||
cameras?: Camera[];
|
||||
cameras_status?: Ny511FeedStatus;
|
||||
events?: Ny511Event[];
|
||||
events_status?: Ny511FeedStatus;
|
||||
/** DAA: source credit + NYSDOT as-is disclaimer, present when the feed is served. */
|
||||
credit?: CameraCredit;
|
||||
}
|
||||
|
||||
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