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:
Logan Cusano
2026-10-05 03:11:02 -04:00
co-authored by Claude Opus 5.5
parent f3b21cd432
commit f479578942
4 changed files with 504 additions and 15 deletions
+91 -11
View File
@@ -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
+171
View File
@@ -0,0 +1,171 @@
"use client";
/**
* Live wall filter bar (server-26#235): one row under the wall bar — presets,
* free-text search, type chips, severity floor, system/talkgroup, age window,
* tag chips, clear. Presentation only; the filter lives in the URL hash
* (lib/liveFilter.ts) and LiveView applies it once to everything it plots.
* Wraps on narrow screens; below md the less-used controls fold behind a
* "Filters" toggle so the map keeps its height.
*/
import { useEffect, useRef, useState } from "react";
import { SEVERITY_LABEL, type Severity } from "@/lib/severity";
import {
AGE_WINDOWS_H, EMPTY_FILTER, INCIDENT_TYPES, PRESETS, PRESET_KEYS, describeFilter, isFilterActive,
type IncidentType, type PresetKey, type WallFilter,
} from "@/lib/liveFilter";
export interface FilterOption { id: string; label: string }
const TYPE_LABEL: Record<IncidentType, string> = {
fire: "Fire", ems: "EMS", police: "Police", accident: "Accident", other: "Other",
};
const SEV_FLOORS: Severity[] = ["minor", "moderate", "major"];
const Q_DEBOUNCE_MS = 250;
const chip = (on: boolean) =>
`px-2 py-0.5 rounded-full border whitespace-nowrap ${on ? "bg-accent text-white border-accent" : "border-line text-ink-2 hover:text-ink"}`;
const select = "px-1.5 py-0.5 rounded border border-line bg-surface text-ink-2 max-w-[11rem]";
const toggle = <T,>(arr: T[], v: T): T[] => (arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v]);
export function FilterBar({
filter, onChange, onPreset, systems, talkgroups, tags, shown, total, readOnly = false,
}: {
filter: WallFilter;
onChange: (f: WallFilter) => void;
/** A preset was switched on — the caller also turns its map layers on. */
onPreset: (p: PresetKey) => void;
systems: FilterOption[];
talkgroups: FilterOption[];
/** Most common tags in the loaded incidents (selected ones are always shown). */
tags: string[];
shown: number;
total: number;
/** Kiosk: no controls, just the summary when a filter is on. */
readOnly?: boolean;
}) {
const active = isFilterActive(filter);
const [more, setMore] = useState(false);
// Search is debounced locally so each keystroke doesn't rewrite the hash
// and re-filter every transcript; an outside change (hash edit, Clear) wins.
const [q, setQ] = useState(filter.q);
const committed = useRef(filter.q);
useEffect(() => {
if (filter.q !== committed.current) { committed.current = filter.q; setQ(filter.q); }
}, [filter.q]);
const filterRef = useRef(filter);
filterRef.current = filter;
useEffect(() => {
if (q === committed.current) return;
const id = setTimeout(() => { committed.current = q; onChange({ ...filterRef.current, q }); }, Q_DEBOUNCE_MS);
return () => clearTimeout(id);
}, [q, onChange]);
const sysName = (id: string) => systems.find((s) => s.id === id)?.label ?? id;
const tgName = (id: string) => talkgroups.find((t) => t.id === id)?.label ?? id;
const summary = (
<span className={`whitespace-nowrap ${active ? "text-ink font-semibold" : "text-ink-muted"}`} aria-live="polite">
{active ? `Filtered: ${shown} of ${total}` : `${total} active`}
</span>
);
if (readOnly) {
if (!active) return null;
return (
<div className="shrink-0 flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-1 border-b border-line bg-surface/95 text-xs">
{summary}
<span className="text-ink-2 min-w-0 break-words">{describeFilter(filter, { sys: sysName, tg: tgName })}</span>
</div>
);
}
const set = (p: Partial<WallFilter>) => onChange({ ...filter, ...p });
const tagList = Array.from(new Set([...filter.tags, ...tags]));
const tgValue = filter.talkgroups.length === 1 ? filter.talkgroups[0] : "";
const sysValue = filter.systems.length === 1 ? filter.systems[0] : "";
return (
<div role="search" aria-label="Filter the live map"
className="shrink-0 flex flex-wrap items-center gap-x-2 gap-y-1 px-3 py-1.5 border-b border-line bg-surface/95 text-xs">
<div className="flex items-center gap-1" role="group" aria-label="Presets">
{PRESET_KEYS.map((k) => (
<button key={k} type="button" aria-pressed={filter.preset === k} title={PRESETS[k].title} className={chip(filter.preset === k)}
onClick={() => {
if (filter.preset === k) { set({ preset: null }); return; }
set({ preset: k });
onPreset(k);
}}>
{PRESETS[k].label}
</button>
))}
</div>
<input type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search title, location, transcript"
aria-label="Search incidents and transcripts"
className="min-w-0 flex-1 basis-40 max-w-xs px-2 py-0.5 rounded border border-line bg-surface text-ink placeholder:text-ink-muted" />
<button type="button" onClick={() => setMore((m) => !m)} aria-expanded={more} className={`md:hidden ${chip(false)}`}>
{more ? "Fewer filters" : "Filters"}
</button>
<div className={`${more ? "flex" : "hidden"} md:flex flex-wrap items-center gap-x-2 gap-y-1 w-full md:w-auto`}>
<div className="flex flex-wrap items-center gap-1" role="group" aria-label="Incident type">
{INCIDENT_TYPES.map((t) => (
<button key={t} type="button" aria-pressed={filter.types.includes(t)} className={chip(filter.types.includes(t))}
onClick={() => set({ types: toggle(filter.types, t) })}>
{TYPE_LABEL[t]}
</button>
))}
</div>
<select aria-label="Severity floor" className={select} value={filter.sevFloor ?? ""}
onChange={(e) => set({ sevFloor: (e.target.value || null) as Severity | null })}>
<option value="">Any severity</option>
{SEV_FLOORS.map((s) => <option key={s} value={s}>{SEVERITY_LABEL[s]}{s === "major" ? "" : "+"}</option>)}
</select>
<select aria-label="Age window" className={select} value={filter.ageH ?? ""}
onChange={(e) => set({ ageH: e.target.value ? Number(e.target.value) : null })}>
<option value="">Any age</option>
{AGE_WINDOWS_H.map((h) => <option key={h} value={h}>Last {h}h</option>)}
{filter.ageH && !(AGE_WINDOWS_H as readonly number[]).includes(filter.ageH) && (
<option value={filter.ageH}>Last {filter.ageH}h</option>
)}
</select>
{systems.length > 1 || sysValue ? (
<select aria-label="Radio system" className={select} value={sysValue}
onChange={(e) => set({ systems: e.target.value ? [e.target.value] : [] })}>
<option value="">All systems</option>
{filter.systems.length > 1 && <option value="" disabled>{filter.systems.length} systems</option>}
{systems.map((s) => <option key={s.id} value={s.id}>{s.label}</option>)}
</select>
) : null}
{talkgroups.length > 0 || tgValue ? (
<select aria-label="Talkgroup" className={select} value={tgValue}
onChange={(e) => set({ talkgroups: e.target.value ? [e.target.value] : [] })}>
<option value="">All talkgroups</option>
{filter.talkgroups.length > 1 && <option value="" disabled>{filter.talkgroups.length} talkgroups</option>}
{talkgroups.map((t) => <option key={t.id} value={t.id}>{t.label}</option>)}
</select>
) : null}
{tagList.length > 0 && (
<div className="flex flex-wrap items-center gap-1" role="group" aria-label="Tags">
{tagList.map((t) => (
<button key={t} type="button" aria-pressed={filter.tags.includes(t)} className={chip(filter.tags.includes(t))}
onClick={() => set({ tags: toggle(filter.tags, t) })}>
#{t}
</button>
))}
</div>
)}
</div>
<div className="ml-auto flex items-center gap-2">
{summary}
{active && (
<button type="button" onClick={() => { committed.current = ""; setQ(""); onChange(EMPTY_FILTER); }} className={chip(false)}>
Clear
</button>
)}
</div>
</div>
);
}