Live wall: filter bar, discipline presets and text search (#235)
lib/liveFilter.ts (pure): WallFilter {types, sevFloor, systems, talkgroups,
ageH, tags, q, preset}, applyFilter over loaded incidents + calls, parse /
serialise to the f.* hash keys #234 reserved. Presets: Fire (fire + EMS
assists heard on a fire channel; Radar on), Storm (weather/wires/tree/flood
tags; Radar on), Traffic (accident / MVA tags; Traffic Events on).
components/live/FilterBar.tsx: one wrapping row under the wall bar; less-used
controls fold behind a toggle below md; read-only summary in kiosk.
LiveView applies the filter once and feeds the filtered lists to MapView
(pins, paths, rail), RegionPanel, TranscriptTicker, TimeScrubber and the
auto-tour; KPIs count the filtered set and say "filtered". Presets turn
layers on through the saved-view layer state, not a MapView edit. Pipeline
health stays unfiltered. liveWall.ts keeps commas literal in f.* values.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f3b21cd432
commit
f479578942
@@ -13,10 +13,16 @@
|
||||
* back the same; each browser also remembers its last view (lib/wallPrefs.ts).
|
||||
* Precedence: hash > remembered > defaults. `?kiosk=1` / `#kiosk` is the
|
||||
* wall-display mode (lib/useKiosk.ts).
|
||||
*
|
||||
* Filter bar (server-26#235): the `f.*` hash keys (lib/liveFilter.ts) are
|
||||
* applied ONCE here and the filtered lists feed the map (pins, paths, rail),
|
||||
* the region panel, ticker, scrubber bands, auto-tour and KPIs. Pipeline
|
||||
* health stays on the unfiltered data — a filter must never hide an outage.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useNodes } from "@/lib/useNodes";
|
||||
import { useSystems } from "@/lib/useSystems";
|
||||
import { useActiveCalls, useCalls } from "@/lib/useCalls";
|
||||
import { useActiveIncidents } from "@/lib/useIncidents";
|
||||
import { useAircraft } from "@/lib/useAircraft";
|
||||
@@ -26,9 +32,11 @@ 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, sameView, wallHash, worstFirst, type LatLon, type NearCamera, type WallLink,
|
||||
DEFAULT_LAYERS, NEAR_CAMERA_KM, bboxAround, groupCallsByIncident, incidentPoints, km, nearestCameras, normaliseLayers,
|
||||
onAirIncidentIds, parseWallHash, sameView, wallHash, worstFirst, type LatLon, type NearCamera, type WallLink,
|
||||
} from "@/lib/liveWall";
|
||||
import { PRESETS, applyFilter, parseFilter, serialiseFilter, type PresetKey, type WallFilter } from "@/lib/liveFilter";
|
||||
import { FilterBar, type FilterOption } from "@/components/live/FilterBar";
|
||||
import { loadWallPrefs, resolveWallView, saveWallPrefs } from "@/lib/wallPrefs";
|
||||
import { announceKioskChange, kioskInQuery, stripKioskQuery, useKiosk } from "@/lib/useKiosk";
|
||||
import { KioskBanner, KioskControls } from "@/components/live/KioskControls";
|
||||
@@ -55,6 +63,7 @@ const KIOSK_TYPE = [
|
||||
"[&[data-kiosk]_.w-56]:w-80",
|
||||
].join(" ");
|
||||
const PIN_KEY = "drb.wall.pinnedCameras";
|
||||
const MAX_TAG_CHIPS = 8;
|
||||
const MAX_PINNED = 6;
|
||||
const EVENTS_POLL_MS = 2 * 60 * 1000;
|
||||
|
||||
@@ -114,6 +123,7 @@ export function LiveView() {
|
||||
// Wide-ish recent window: feeds incident paths, the call spine, the ticker
|
||||
// and the scrubber's density bars. Real recent calls, not a fixture.
|
||||
const { calls: recentCalls, loading: callsLoading, error: callsError } = useCalls(500);
|
||||
const { systems } = useSystems();
|
||||
const { aircraft } = useAircraft();
|
||||
const { vessels } = useVessels();
|
||||
const [clock, setClock] = useState<string>("");
|
||||
@@ -166,6 +176,22 @@ export function LiveView() {
|
||||
announceKioskChange();
|
||||
}, [link, mapState]);
|
||||
const patchLink = useCallback((p: Partial<WallLink>) => setLinkState((cur) => ({ ...cur, ...p })), []);
|
||||
// ── Filter (server-26#235) ────────────────────────────────────────────────
|
||||
const filterKey = JSON.stringify(link.filter ?? {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const filter = useMemo(() => parseFilter(link.filter), [filterKey]);
|
||||
const setFilter = useCallback((f: WallFilter) => patchLink({ filter: serialiseFilter(f) }), [patchLink]);
|
||||
/** A preset switches its map layers on through the saved-view layer state (no MapView edit). */
|
||||
const onPreset = useCallback((p: PresetKey) => {
|
||||
const cur = mapStateRef.current;
|
||||
if (!cur) return;
|
||||
const have = cur.layers ?? DEFAULT_LAYERS;
|
||||
if (PRESETS[p].layers.every((l) => have.includes(l))) return;
|
||||
const next = { ...cur, layers: normaliseLayers([...have, ...PRESETS[p].layers]) };
|
||||
saveWallPrefs(next);
|
||||
setMapState(next);
|
||||
setMapGen((g) => g + 1);
|
||||
}, []);
|
||||
const onViewStateChange = useCallback((s: MapViewState) => {
|
||||
setMapState((cur) => {
|
||||
// Keep a known viewport if the map hasn't reported one yet.
|
||||
@@ -214,7 +240,11 @@ export function LiveView() {
|
||||
|
||||
// ── Geography ─────────────────────────────────────────────────────────────
|
||||
const callsByIncident = useMemo(() => groupCallsByIncident(recentCalls), [recentCalls]);
|
||||
const sorted = useMemo(() => worstFirst(activeIncidents), [activeIncidents]);
|
||||
const filtered = useMemo(
|
||||
() => applyFilter(activeIncidents, recentCalls, filter, callsByIncident),
|
||||
[activeIncidents, recentCalls, filter, callsByIncident],
|
||||
);
|
||||
const sorted = useMemo(() => worstFirst(filtered.incidents), [filtered.incidents]);
|
||||
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)));
|
||||
@@ -247,6 +277,44 @@ export function LiveView() {
|
||||
const onAir = useMemo(() => onAirIncidentIds(activeIncidents, activeCalls), [activeIncidents, activeCalls]);
|
||||
|
||||
const selected = link.inc ? activeIncidents.find((i) => i.incident_id === link.inc) ?? null : null;
|
||||
// A drilled-into incident stays on the map even if the filter excludes it
|
||||
// (a shared #inc= link must still land); it is not added to the tour.
|
||||
const keepSelected = !!selected && !filtered.ids.has(selected.incident_id);
|
||||
const mapIncidents = useMemo(
|
||||
() => (keepSelected && selected ? [...filtered.incidents, selected] : filtered.incidents),
|
||||
[keepSelected, selected, filtered.incidents],
|
||||
);
|
||||
const mapCalls = useMemo(() => {
|
||||
if (!keepSelected || !selected) return filtered.calls;
|
||||
const have = new Set(filtered.calls.map((c) => c.call_id));
|
||||
return [...filtered.calls, ...(callsByIncident.get(selected.incident_id) ?? []).filter((c) => !have.has(c.call_id))];
|
||||
}, [keepSelected, selected, filtered.calls, callsByIncident]);
|
||||
|
||||
// Filter choices come from what the wall has loaded (plus system names).
|
||||
const filterOptions = useMemo(() => {
|
||||
const sysNames = new Map(systems.map((s) => [s.system_id, s.name]));
|
||||
const sysIds = new Set<string>();
|
||||
const tgNames = new Map<string, string>();
|
||||
for (const c of recentCalls) {
|
||||
if (c.system_id) sysIds.add(c.system_id);
|
||||
if (c.talkgroup_id != null) {
|
||||
const id = String(c.talkgroup_id);
|
||||
if (c.talkgroup_name || !tgNames.has(id)) tgNames.set(id, c.talkgroup_name || id);
|
||||
}
|
||||
}
|
||||
const tagCount = new Map<string, number>();
|
||||
for (const i of activeIncidents) {
|
||||
for (const s of i.system_ids ?? []) sysIds.add(String(s));
|
||||
for (const t of i.talkgroup_ids ?? []) if (!tgNames.has(String(t))) tgNames.set(String(t), String(t));
|
||||
for (const t of i.tags ?? []) tagCount.set(t.toLowerCase(), (tagCount.get(t.toLowerCase()) ?? 0) + 1);
|
||||
}
|
||||
const byLabel = (a: FilterOption, b: FilterOption) => a.label.localeCompare(b.label);
|
||||
return {
|
||||
systems: Array.from(sysIds).map((id) => ({ id, label: sysNames.get(id) ?? id })).sort(byLabel),
|
||||
talkgroups: Array.from(tgNames.entries()).map(([id, name]) => ({ id, label: name === id ? id : `${name} (${id})` })).sort(byLabel),
|
||||
tags: Array.from(tagCount.entries()).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, MAX_TAG_CHIPS).map(([t]) => t),
|
||||
};
|
||||
}, [systems, recentCalls, activeIncidents]);
|
||||
const selectedPoints = selected ? pointsById.get(selected.incident_id) ?? [] : [];
|
||||
const near = selected ? nearById.get(selected.incident_id) ?? [] : [];
|
||||
const drillCam: Camera | null = link.cam
|
||||
@@ -305,7 +373,7 @@ export function LiveView() {
|
||||
const tourPct = link.tour && tourStart ? Math.min(100, ((tourNow - tourStart) / tourMs) * 100) : 0;
|
||||
|
||||
// ── KPIs ──────────────────────────────────────────────────────────────────
|
||||
const count = (sev: string) => activeIncidents.filter((i) => i.severity === sev).length;
|
||||
const count = (sev: string) => filtered.incidents.filter((i) => i.severity === sev).length;
|
||||
// "Nodes hearing": a call in the last N min, not merely not-offline — a node
|
||||
// that checks in but hears nothing is not contributing to the picture.
|
||||
const nodesHearing = health.nodesHearing;
|
||||
@@ -337,7 +405,8 @@ export function LiveView() {
|
||||
<div className="hidden md:flex items-center gap-3 flex-wrap">
|
||||
<Kpi value={count("major")} label="major" />
|
||||
<Kpi value={count("moderate")} label="moderate" />
|
||||
<Kpi value={activeIncidents.length} label="active" />
|
||||
<Kpi value={filtered.active ? `${filtered.incidents.length} of ${activeIncidents.length}` : activeIncidents.length}
|
||||
label={filtered.active ? "active · filtered" : "active"} />
|
||||
<Kpi value={`${nodesHearing}/${nodes.length}`} label={`nodes hearing (${health.staleMin}m)`} />
|
||||
<Kpi value={cams.cameras.length} label="cams" />
|
||||
<Kpi value={aircraft.length} label="aircraft" />
|
||||
@@ -364,6 +433,17 @@ export function LiveView() {
|
||||
<span className="font-mono text-ink-2 tabular-nums">{clock}</span>
|
||||
</div>
|
||||
</div>
|
||||
<FilterBar
|
||||
filter={filter}
|
||||
onChange={setFilter}
|
||||
onPreset={onPreset}
|
||||
systems={filterOptions.systems}
|
||||
talkgroups={filterOptions.talkgroups}
|
||||
tags={filterOptions.tags}
|
||||
shown={filtered.incidents.length}
|
||||
total={activeIncidents.length}
|
||||
readOnly={kiosk}
|
||||
/>
|
||||
<KioskBanner banner={kioskState.banner} onRetry={kioskState.retryNow} />
|
||||
<TruthStrip health={health} placement="bar" />
|
||||
|
||||
@@ -378,8 +458,8 @@ export function LiveView() {
|
||||
<MapView
|
||||
nodes={nodes}
|
||||
activeCalls={activeCalls}
|
||||
incidents={activeIncidents}
|
||||
calls={recentCalls}
|
||||
incidents={mapIncidents}
|
||||
calls={mapCalls}
|
||||
selectedIncidentId={selected?.incident_id ?? null}
|
||||
onSelectIncident={selectIncident}
|
||||
nearCameras={near}
|
||||
@@ -425,8 +505,8 @@ export function LiveView() {
|
||||
/>
|
||||
) : (
|
||||
<RegionPanel
|
||||
incidents={activeIncidents}
|
||||
calls={recentCalls}
|
||||
incidents={filtered.incidents}
|
||||
calls={filtered.calls}
|
||||
onAirCount={onAir.size}
|
||||
nodes={nodes}
|
||||
wallCameras={wallCameras}
|
||||
@@ -443,8 +523,8 @@ export function LiveView() {
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<TranscriptTicker calls={recentCalls} onSelectIncident={selectIncident} />
|
||||
<TimeScrubber calls={recentCalls} incidents={activeIncidents} selectedIncidentId={selected?.incident_id ?? null} onSelectIncident={selectIncident} />
|
||||
<TranscriptTicker calls={filtered.calls} onSelectIncident={selectIncident} />
|
||||
<TimeScrubber calls={filtered.calls} incidents={filtered.incidents} selectedIncidentId={selected?.incident_id ?? null} onSelectIncident={selectIncident} />
|
||||
|
||||
{drillCam && (
|
||||
<CameraDrill
|
||||
|
||||
Reference in New Issue
Block a user