@@ -575,6 +575,7 @@ export function LiveView() {
|
|||||||
onAir={onAir}
|
onAir={onAir}
|
||||||
camCountByIncident={camCountByIncident}
|
camCountByIncident={camCountByIncident}
|
||||||
pulseIncidentIds={attention.pulseIds}
|
pulseIncidentIds={attention.pulseIds}
|
||||||
|
filteredFrom={filtered.active ? activeIncidents.length : undefined}
|
||||||
key={mapGen}
|
key={mapGen}
|
||||||
initialLayers={mapState.layers}
|
initialLayers={mapState.layers}
|
||||||
initialBase={mapState.base}
|
initialBase={mapState.base}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"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 { createPortal } from "react-dom";
|
||||||
import {
|
import {
|
||||||
FeatureGroup,
|
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";
|
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;
|
map: L.Map;
|
||||||
open: Camera[];
|
open: Camera[];
|
||||||
credits: Map<string, CameraCredit>;
|
credits: Map<string, CameraCredit>;
|
||||||
onMode: (id: string, mode: CameraMode) => void;
|
onMode: (id: string, mode: CameraMode) => void;
|
||||||
onClose: (id: string) => 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);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -772,8 +778,9 @@ function CameraDock({ map, open, credits, onMode, onClose }: {
|
|||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
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) => (
|
{open.map((c) => (
|
||||||
<div key={c.id} className="pointer-events-auto w-[240px] bg-surface border border-line-strong rounded-md shadow-lg">
|
<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">
|
<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" />
|
<CameraFeed camera={c} credit={credits.get(c.provider)} className="block w-[240px] h-[135px] bg-black rounded-b-md" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
{footer && <div className="shrink-0 flex flex-col items-center gap-1 max-w-full">{footer}</div>}
|
||||||
</div>,
|
</div>,
|
||||||
map.getContainer(),
|
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 map = useMap();
|
||||||
const shown = useOverlayShown(map, OVERLAY_DOT_CAMERAS);
|
const shown = useOverlayShown(map, OVERLAY_DOT_CAMERAS);
|
||||||
const zoom = useMapZoom(map);
|
const zoom = useMapZoom(map);
|
||||||
@@ -818,6 +832,9 @@ function DotCameraLayer({ onOpenCamera }: { onOpenCamera?: (c: Camera) => void }
|
|||||||
const setMode = (id: string, mode: CameraMode) =>
|
const setMode = (id: string, mode: CameraMode) =>
|
||||||
setOpen((prev) => prev.map((o) => (o.cam.id === id ? { ...o, mode } : o)));
|
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 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -849,7 +866,7 @@ function DotCameraLayer({ onOpenCamera }: { onOpenCamera?: (c: Camera) => void }
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Marker>
|
</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>`;
|
</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(
|
function incidentIcon(
|
||||||
type: string | null, severity: string | null | undefined, precision: PinPrecision = "rooftop", pulse = false,
|
type: string | null, severity: string | null | undefined, precision: PinPrecision = "rooftop", pulse = false,
|
||||||
): L.DivIcon {
|
): 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
|
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>`
|
? `<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 {
|
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 n = members.length;
|
||||||
const CARD = 16;
|
const CARD = 16;
|
||||||
const STEP = 9;
|
const STEP = 9;
|
||||||
@@ -1534,6 +1579,11 @@ interface Props {
|
|||||||
* view last seen with nothing selected is reported instead.
|
* view last seen with nothing selected is reported instead.
|
||||||
*/
|
*/
|
||||||
onViewStateChange?: (state: MapViewState) => void;
|
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 }
|
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 });
|
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,
|
nodes, activeCalls, incidents = [], calls = [], lastUpdated,
|
||||||
selectedIncidentId = null, onSelectIncident, nearCameras = NO_NEAR, onOpenCamera, onAir, camCountByIncident,
|
selectedIncidentId = null, onSelectIncident, nearCameras = NO_NEAR, onOpenCamera, onAir, camCountByIncident,
|
||||||
pulseIncidentIds, initialLayers, initialBase, initialView, onViewStateChange,
|
pulseIncidentIds, initialLayers, initialBase, initialView, onViewStateChange, filteredFrom,
|
||||||
}: Props) {
|
}: 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);
|
||||||
@@ -1607,6 +1662,14 @@ export default function MapView({
|
|||||||
// 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 [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 callsByIncident = useMemo(() => groupCallsByIncident(calls), [calls]);
|
||||||
const railIncidents = useMemo(() => worstFirst(incidents), [incidents]);
|
const railIncidents = useMemo(() => worstFirst(incidents), [incidents]);
|
||||||
const highlightIds = useMemo(() => new Set(nearCameras.map((n) => n.cam.id)), [nearCameras]);
|
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 */}
|
{/* Overlays: 511NY DOT cameras + traffic events (server-26#183), opt-in */}
|
||||||
<LayersControl.Overlay checked={layerOn("dotcam")} name={OVERLAY_DOT_CAMERAS}>
|
<LayersControl.Overlay checked={layerOn("dotcam")} name={OVERLAY_DOT_CAMERAS}>
|
||||||
<FeatureGroup>
|
<FeatureGroup>
|
||||||
<DotCameraLayer onOpenCamera={onOpenCamera} />
|
<DotCameraLayer onOpenCamera={onOpenCamera} dockFooter={weatherStatus} onDockedChange={setDockedCams} />
|
||||||
</FeatureGroup>
|
</FeatureGroup>
|
||||||
</LayersControl.Overlay>
|
</LayersControl.Overlay>
|
||||||
<LayersControl.Overlay checked={layerOn("vs")} name={OVERLAY_VIEWSHEDS}>
|
<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
|
{/* ── Weather status (server-26#237) — bottom centre, clear of the rail
|
||||||
(left), the legend (right) and the 511/outage banners (bottom-left). */}
|
(left), the legend (right) and the 511/outage banners (bottom-left).
|
||||||
{(radarShown || wxShown) && (
|
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">
|
<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} />}
|
{weatherStatus}
|
||||||
{radarShown && <RadarBadge meta={radarMeta} loop={radarLoop} loopClock={loopClock} onLoop={setRadarLoop} />}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -2020,6 +2084,9 @@ export default function MapView({
|
|||||||
unit counts are pipeline output. Pinned above the scroll area
|
unit counts are pipeline output. Pinned above the scroll area
|
||||||
so it cannot be scrolled off the screen it qualifies. */}
|
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">
|
<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" />
|
<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">
|
||||||
@@ -2116,7 +2183,7 @@ export default function MapView({
|
|||||||
onClick={() => setDrawerOpen((v: boolean) => !v)}
|
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"
|
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>
|
<span>{drawerOpen ? "▼" : "▲"}</span>
|
||||||
</button>
|
</button>
|
||||||
{drawerOpen && (
|
{drawerOpen && (
|
||||||
|
|||||||
@@ -32,8 +32,10 @@ function sevColor(s: string | null | undefined) {
|
|||||||
function sevLabel(s: string | null | undefined) {
|
function sevLabel(s: string | null | undefined) {
|
||||||
return isKnownSeverity(s) ? SEVERITY_LABEL[s] : "Unknown";
|
return isKnownSeverity(s) ? SEVERITY_LABEL[s] : "Unknown";
|
||||||
}
|
}
|
||||||
function elapsed(iso: string): string {
|
/** Minutes from `iso` to `endIso` (or now), as "42m" / "1h 5m". */
|
||||||
const m = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 60_000));
|
function elapsed(iso: string, endIso?: string | null): string {
|
||||||
|
const end = endIso ? new Date(endIso).getTime() : Date.now();
|
||||||
|
const m = Math.max(0, Math.round((end - new Date(iso).getTime()) / 60_000));
|
||||||
return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`;
|
return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`;
|
||||||
}
|
}
|
||||||
function hhmm(iso: string): string {
|
function hhmm(iso: string): string {
|
||||||
@@ -422,6 +424,13 @@ export function IncidentPanel({
|
|||||||
onOpenCamera: (c: Camera) => void;
|
onOpenCamera: (c: Camera) => void;
|
||||||
}) {
|
}) {
|
||||||
const color = sevColor(inc.severity);
|
const color = sevColor(inc.severity);
|
||||||
|
// A resolved incident opened from an #inc= link (server-26#275): nothing in
|
||||||
|
// the panel may read as still running. Its duration freezes at resolved_at
|
||||||
|
// (the correlator writes it; the frontend type doesn't declare it yet), and
|
||||||
|
// the live camera / context feeds are labelled as "now", not incident time.
|
||||||
|
const resolved = inc.status === "resolved";
|
||||||
|
const resolvedAt = (inc as IncidentRecord & { resolved_at?: string | null }).resolved_at ?? null;
|
||||||
|
const resolvedAtOk = !!resolvedAt && !Number.isNaN(new Date(resolvedAt).getTime());
|
||||||
const ctx = useMemo(() => contextFor(points, aircraft, vessels, events), [points, aircraft, vessels, events]);
|
const ctx = useMemo(() => contextFor(points, aircraft, vessels, events), [points, aircraft, vessels, events]);
|
||||||
const head = points.at(-1) ?? null;
|
const head = points.at(-1) ?? null;
|
||||||
const radar = useRadarDbz(head);
|
const radar = useRadarDbz(head);
|
||||||
@@ -455,13 +464,17 @@ export function IncidentPanel({
|
|||||||
<div className="flex items-center gap-2 text-[11px] mb-1">
|
<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="font-semibold uppercase tracking-wide" style={{ color }}>{sevLabel(inc.severity)}</span>
|
||||||
<span className="text-ink-muted">{inc.type ?? "other"}</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>}
|
{resolved ? <span className="text-ink-muted">resolved</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>
|
<span className="ml-auto font-mono text-ink-muted">{inc.incident_id.slice(0, 8)}</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-base font-semibold text-ink leading-snug">{inc.title ?? "Incident"}</h2>
|
<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">
|
<div className="text-xs text-ink-muted mt-1 flex flex-wrap gap-x-3">
|
||||||
{inc.location && <span>{inc.location}</span>}
|
{inc.location && <span>{inc.location}</span>}
|
||||||
<span className="font-mono">{elapsed(inc.started_at)} elapsed</span>
|
{!resolved ? <span className="font-mono">{elapsed(inc.started_at)} elapsed</span>
|
||||||
|
: resolvedAtOk ? <span className="font-mono">lasted {elapsed(inc.started_at, resolvedAt)}</span>
|
||||||
|
: null}
|
||||||
<span>{inc.call_ids?.length ?? 0} calls</span>
|
<span>{inc.call_ids?.length ?? 0} calls</span>
|
||||||
{path.mayBeMerged && (
|
{path.mayBeMerged && (
|
||||||
<span className="text-[10px] px-1 rounded border border-dashed border-line text-ink-2"
|
<span className="text-[10px] px-1 rounded border border-dashed border-line text-ink-2"
|
||||||
@@ -480,6 +493,7 @@ export function IncidentPanel({
|
|||||||
|
|
||||||
<div className={SEC}>
|
<div className={SEC}>
|
||||||
<h3 className={H3}>Nearest cameras · auto-surfaced{points.length > 1 ? " (weighted to the path's head)" : ""}</h3>
|
<h3 className={H3}>Nearest cameras · auto-surfaced{points.length > 1 ? " (weighted to the path's head)" : ""}</h3>
|
||||||
|
{resolved && <p className="text-[10px] text-ink-muted mb-1.5">Live views as of now, not from when this incident was active.</p>}
|
||||||
{near.length ? (
|
{near.length ? (
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{near.map(({ cam, km }) => (
|
{near.map(({ cam, km }) => (
|
||||||
@@ -498,6 +512,7 @@ export function IncidentPanel({
|
|||||||
|
|
||||||
<div className={SEC}>
|
<div className={SEC}>
|
||||||
<h3 className={H3}>Situational context · fused layers</h3>
|
<h3 className={H3}>Situational context · fused layers</h3>
|
||||||
|
{resolved && <p className="text-[10px] text-ink-muted mb-1.5">Current conditions near the location, not at incident time.</p>}
|
||||||
<ul className="space-y-1 text-xs text-ink-2">
|
<ul className="space-y-1 text-xs text-ink-2">
|
||||||
{ctx.aircraft.map(({ a, km }) => (
|
{ctx.aircraft.map(({ a, km }) => (
|
||||||
<li key={a.icao}><span className="font-mono text-[10px] text-ink-muted mr-1.5">ADS-B</span>
|
<li key={a.icao}><span className="font-mono text-[10px] text-ink-muted mr-1.5">ADS-B</span>
|
||||||
@@ -533,7 +548,7 @@ export function IncidentPanel({
|
|||||||
|
|
||||||
<div className={`${SEC} grid grid-cols-2 gap-3`}>
|
<div className={`${SEC} grid grid-cols-2 gap-3`}>
|
||||||
<div>
|
<div>
|
||||||
<h3 className={H3}>On scene</h3>
|
<h3 className={H3}>{resolved ? "Last on scene" : "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 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>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user