|
|
|
@@ -1,6 +1,6 @@
|
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
|
|
|
import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
|
|
|
|
|
import { createPortal } from "react-dom";
|
|
|
|
|
import {
|
|
|
|
|
FeatureGroup,
|
|
|
|
@@ -754,12 +754,18 @@ interface OpenCamera { cam: Camera; mode: CameraMode }
|
|
|
|
|
|
|
|
|
|
const camBtn = "text-ink-muted hover:text-ink text-xs leading-none px-0.5";
|
|
|
|
|
|
|
|
|
|
function CameraDock({ map, open, credits, onMode, onClose }: {
|
|
|
|
|
function CameraDock({ map, open, credits, onMode, onClose, footer }: {
|
|
|
|
|
map: L.Map;
|
|
|
|
|
open: Camera[];
|
|
|
|
|
credits: Map<string, CameraCredit>;
|
|
|
|
|
onMode: (id: string, mode: CameraMode) => void;
|
|
|
|
|
onClose: (id: string) => void;
|
|
|
|
|
/**
|
|
|
|
|
* Stacked under the tiles (server-26#278): the weather status chip and radar
|
|
|
|
|
* badge live in the same bottom-centre spot, so while the dock is open they
|
|
|
|
|
* ride in its column instead of being covered by it.
|
|
|
|
|
*/
|
|
|
|
|
footer?: ReactNode;
|
|
|
|
|
}) {
|
|
|
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
|
useEffect(() => {
|
|
|
|
@@ -772,8 +778,9 @@ function CameraDock({ map, open, credits, onMode, onClose }: {
|
|
|
|
|
return createPortal(
|
|
|
|
|
<div
|
|
|
|
|
ref={ref}
|
|
|
|
|
className="absolute z-[1002] left-3 right-3 top-12 md:top-auto md:bottom-8 md:left-[16rem] md:right-[12rem] flex flex-wrap-reverse justify-center gap-2 max-h-[calc(100%-8rem)] overflow-y-auto pointer-events-none"
|
|
|
|
|
className="absolute z-[1002] left-3 right-3 top-12 md:top-auto md:bottom-8 md:left-[16rem] md:right-[12rem] flex flex-col items-center gap-2 max-h-[calc(100%-8rem)] pointer-events-none"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex flex-wrap-reverse justify-center gap-2 min-h-0 max-w-full overflow-y-auto">
|
|
|
|
|
{open.map((c) => (
|
|
|
|
|
<div key={c.id} className="pointer-events-auto w-[240px] bg-surface border border-line-strong rounded-md shadow-lg">
|
|
|
|
|
<div className="flex items-center gap-1 px-1.5 h-5">
|
|
|
|
@@ -784,12 +791,19 @@ function CameraDock({ map, open, credits, onMode, onClose }: {
|
|
|
|
|
<CameraFeed camera={c} credit={credits.get(c.provider)} className="block w-[240px] h-[135px] bg-black rounded-b-md" />
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
{footer && <div className="shrink-0 flex flex-col items-center gap-1 max-w-full">{footer}</div>}
|
|
|
|
|
</div>,
|
|
|
|
|
map.getContainer(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function DotCameraLayer({ onOpenCamera }: { onOpenCamera?: (c: Camera) => void }) {
|
|
|
|
|
function DotCameraLayer({ onOpenCamera, dockFooter, onDockedChange }: {
|
|
|
|
|
onOpenCamera?: (c: Camera) => void;
|
|
|
|
|
dockFooter?: ReactNode;
|
|
|
|
|
/** How many tiles are docked bottom-centre, so MapView can move what sits there (server-26#278). */
|
|
|
|
|
onDockedChange?: (n: number) => void;
|
|
|
|
|
}) {
|
|
|
|
|
const map = useMap();
|
|
|
|
|
const shown = useOverlayShown(map, OVERLAY_DOT_CAMERAS);
|
|
|
|
|
const zoom = useMapZoom(map);
|
|
|
|
@@ -818,6 +832,9 @@ function DotCameraLayer({ onOpenCamera }: { onOpenCamera?: (c: Camera) => void }
|
|
|
|
|
const setMode = (id: string, mode: CameraMode) =>
|
|
|
|
|
setOpen((prev) => prev.map((o) => (o.cam.id === id ? { ...o, mode } : o)));
|
|
|
|
|
const close = (id: string) => setOpen((prev) => prev.filter((o) => o.cam.id !== id));
|
|
|
|
|
const docked = useMemo(() => open.filter((o) => o.mode === "dock").map((o) => o.cam), [open]);
|
|
|
|
|
useEffect(() => { onDockedChange?.(docked.length); }, [docked.length, onDockedChange]);
|
|
|
|
|
useEffect(() => () => onDockedChange?.(0), [onDockedChange]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
@@ -849,7 +866,7 @@ function DotCameraLayer({ onOpenCamera }: { onOpenCamera?: (c: Camera) => void }
|
|
|
|
|
</Tooltip>
|
|
|
|
|
</Marker>
|
|
|
|
|
))}
|
|
|
|
|
<CameraDock map={map} open={open.filter((o) => o.mode === "dock").map((o) => o.cam)} credits={feeds} onMode={setMode} onClose={close} />
|
|
|
|
|
<CameraDock map={map} open={docked} credits={feeds} onMode={setMode} onClose={close} footer={dockFooter} />
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
@@ -1029,9 +1046,32 @@ function teardropSvg(type: string | null, severity: string | null | undefined, p
|
|
|
|
|
</svg>`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Incident icons are cached by their visual inputs (server-26#279). react-leaflet
|
|
|
|
|
// calls setIcon whenever the icon prop's identity changes, and DivIcon rewrites
|
|
|
|
|
// innerHTML — so building a fresh icon on every render replaced every pin's DOM
|
|
|
|
|
// on each parent re-render and restarted the 1.8s pulse ring before it finished.
|
|
|
|
|
// Same inputs → same L.DivIcon object → no setIcon. Bounded: cleared when large
|
|
|
|
|
// (keys are a handful of type/severity/precision/pulse combos plus fan stacks).
|
|
|
|
|
const INCIDENT_ICON_CACHE_MAX = 500;
|
|
|
|
|
const incidentIconCache = new Map<string, L.DivIcon>();
|
|
|
|
|
function cachedIcon(key: string, build: () => L.DivIcon): L.DivIcon {
|
|
|
|
|
let icon = incidentIconCache.get(key);
|
|
|
|
|
if (!icon) {
|
|
|
|
|
if (incidentIconCache.size >= INCIDENT_ICON_CACHE_MAX) incidentIconCache.clear();
|
|
|
|
|
icon = build();
|
|
|
|
|
incidentIconCache.set(key, icon);
|
|
|
|
|
}
|
|
|
|
|
return icon;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function incidentIcon(
|
|
|
|
|
type: string | null, severity: string | null | undefined, precision: PinPrecision = "rooftop", pulse = false,
|
|
|
|
|
): L.DivIcon {
|
|
|
|
|
return cachedIcon(`pin|${type ?? ""}|${severity ?? ""}|${precision}|${pulse ? 1 : 0}`,
|
|
|
|
|
() => buildIncidentIcon(type, severity, precision, pulse));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildIncidentIcon(type: string | null, severity: string | null | undefined, precision: PinPrecision, pulse: boolean): L.DivIcon {
|
|
|
|
|
const ring = pulse
|
|
|
|
|
? `<div class="node-pulse-ring" style="position:absolute;width:26px;height:26px;border-radius:50%;border:2px solid ${severityColor(severity)};left:3px;top:3px;pointer-events:none;"></div>`
|
|
|
|
|
: "";
|
|
|
|
@@ -1044,6 +1084,11 @@ function incidentIcon(
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function incidentFanIcon(members: IncidentRecord[], pulse = false): L.DivIcon {
|
|
|
|
|
const key = `fan|${pulse ? 1 : 0}|${members.map((m) => `${m.type ?? ""}:${m.severity ?? ""}`).join(",")}`;
|
|
|
|
|
return cachedIcon(key, () => buildIncidentFanIcon(members, pulse));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildIncidentFanIcon(members: IncidentRecord[], pulse: boolean): L.DivIcon {
|
|
|
|
|
const n = members.length;
|
|
|
|
|
const CARD = 16;
|
|
|
|
|
const STEP = 9;
|
|
|
|
@@ -1534,6 +1579,11 @@ interface Props {
|
|
|
|
|
* view last seen with nothing selected is reported instead.
|
|
|
|
|
*/
|
|
|
|
|
onViewStateChange?: (state: MapViewState) => void;
|
|
|
|
|
/**
|
|
|
|
|
* Set while the caller's incident filter is on (server-26#268): the
|
|
|
|
|
* unfiltered active count, so the rail header reads "N of M · filtered".
|
|
|
|
|
*/
|
|
|
|
|
filteredFrom?: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface MapViewState { layers: LayerKey[]; base: BaseKey; view?: MapViewport }
|
|
|
|
@@ -1545,10 +1595,15 @@ function currentViewport(map: L.Map): MapViewport {
|
|
|
|
|
return roundViewport({ z: map.getZoom(), lat: c.lat, lon: c.lng });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function MapView({
|
|
|
|
|
// Memoised (server-26#279): the live wall re-renders every second for its
|
|
|
|
|
// clock; with stable props that tick no longer re-renders the whole map tree.
|
|
|
|
|
// MapView keeps its own 10s tick (agoClock) for the rail's relative ages.
|
|
|
|
|
export default memo(MapView);
|
|
|
|
|
|
|
|
|
|
function MapView({
|
|
|
|
|
nodes, activeCalls, incidents = [], calls = [], lastUpdated,
|
|
|
|
|
selectedIncidentId = null, onSelectIncident, nearCameras = NO_NEAR, onOpenCamera, onAir, camCountByIncident,
|
|
|
|
|
pulseIncidentIds, initialLayers, initialBase, initialView, onViewStateChange,
|
|
|
|
|
pulseIncidentIds, initialLayers, initialBase, initialView, onViewStateChange, filteredFrom,
|
|
|
|
|
}: Props) {
|
|
|
|
|
const [mapInstance, setMapInstance] = useState<L.Map | null>(null);
|
|
|
|
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
|
|
@@ -1607,6 +1662,14 @@ export default function MapView({
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]);
|
|
|
|
|
|
|
|
|
|
const [dockedCams, setDockedCams] = useState(0);
|
|
|
|
|
const weatherStatus = radarShown || wxShown ? (
|
|
|
|
|
<>
|
|
|
|
|
{wxShown && <WeatherWarningsStatus state={wxAlerts} />}
|
|
|
|
|
{radarShown && <RadarBadge meta={radarMeta} loop={radarLoop} loopClock={loopClock} onLoop={setRadarLoop} />}
|
|
|
|
|
</>
|
|
|
|
|
) : null;
|
|
|
|
|
|
|
|
|
|
const callsByIncident = useMemo(() => groupCallsByIncident(calls), [calls]);
|
|
|
|
|
const railIncidents = useMemo(() => worstFirst(incidents), [incidents]);
|
|
|
|
|
const highlightIds = useMemo(() => new Set(nearCameras.map((n) => n.cam.id)), [nearCameras]);
|
|
|
|
@@ -1792,7 +1855,7 @@ export default function MapView({
|
|
|
|
|
{/* Overlays: 511NY DOT cameras + traffic events (server-26#183), opt-in */}
|
|
|
|
|
<LayersControl.Overlay checked={layerOn("dotcam")} name={OVERLAY_DOT_CAMERAS}>
|
|
|
|
|
<FeatureGroup>
|
|
|
|
|
<DotCameraLayer onOpenCamera={onOpenCamera} />
|
|
|
|
|
<DotCameraLayer onOpenCamera={onOpenCamera} dockFooter={weatherStatus} onDockedChange={setDockedCams} />
|
|
|
|
|
</FeatureGroup>
|
|
|
|
|
</LayersControl.Overlay>
|
|
|
|
|
<LayersControl.Overlay checked={layerOn("vs")} name={OVERLAY_VIEWSHEDS}>
|
|
|
|
@@ -1844,11 +1907,12 @@ export default function MapView({
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* ── Weather status (server-26#237) — bottom centre, clear of the rail
|
|
|
|
|
(left), the legend (right) and the 511/outage banners (bottom-left). */}
|
|
|
|
|
{(radarShown || wxShown) && (
|
|
|
|
|
(left), the legend (right) and the 511/outage banners (bottom-left).
|
|
|
|
|
While camera tiles are docked in that same spot it moves into the
|
|
|
|
|
dock's column, under the tiles, so STALE / feed errors stay visible (#278). */}
|
|
|
|
|
{weatherStatus && dockedCams === 0 && (
|
|
|
|
|
<div className="absolute bottom-10 md:bottom-8 left-1/2 -translate-x-1/2 z-[1001] flex flex-col items-center gap-1 w-max max-w-[min(30rem,calc(100%-1.5rem))] pointer-events-none">
|
|
|
|
|
{wxShown && <WeatherWarningsStatus state={wxAlerts} />}
|
|
|
|
|
{radarShown && <RadarBadge meta={radarMeta} loop={radarLoop} loopClock={loopClock} onLoop={setRadarLoop} />}
|
|
|
|
|
{weatherStatus}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
@@ -2020,6 +2084,9 @@ export default function MapView({
|
|
|
|
|
unit counts are pipeline output. Pinned above the scroll area
|
|
|
|
|
so it cannot be scrolled off the screen it qualifies. */}
|
|
|
|
|
<div className="bg-surface/90 backdrop-blur-sm border border-line rounded-lg px-2 py-1.5 shrink-0 pointer-events-auto">
|
|
|
|
|
{filteredFrom != null && (
|
|
|
|
|
<p className="text-[11px] text-ink-2 mb-1"><b className="text-ink font-semibold tabular-nums">{incidents.length} of {filteredFrom}</b> · filtered</p>
|
|
|
|
|
)}
|
|
|
|
|
<MachineOutputNotice variant="inline" className="text-[10px] leading-snug items-start" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex flex-col gap-1.5 overflow-y-auto min-h-0 pointer-events-auto">
|
|
|
|
@@ -2116,7 +2183,7 @@ export default function MapView({
|
|
|
|
|
onClick={() => setDrawerOpen((v: boolean) => !v)}
|
|
|
|
|
className="w-full bg-surface/95 border-t border-line px-4 py-2 text-xs text-ink-2 flex items-center justify-between"
|
|
|
|
|
>
|
|
|
|
|
<span>Incidents ({incidents.length})</span>
|
|
|
|
|
<span>Incidents ({filteredFrom != null ? `${incidents.length} of ${filteredFrom} · filtered` : incidents.length})</span>
|
|
|
|
|
<span>{drawerOpen ? "▼" : "▲"}</span>
|
|
|
|
|
</button>
|
|
|
|
|
{drawerOpen && (
|
|
|
|
|