Merge pull request 'Live wall: filter bar, discipline presets and text search (#235)' (#267) from feat/235-filter-bar into main
This commit was merged in pull request #267.
This commit is contained in:
@@ -13,10 +13,16 @@
|
|||||||
* back the same; each browser also remembers its last view (lib/wallPrefs.ts).
|
* back the same; each browser also remembers its last view (lib/wallPrefs.ts).
|
||||||
* Precedence: hash > remembered > defaults. `?kiosk=1` / `#kiosk` is the
|
* Precedence: hash > remembered > defaults. `?kiosk=1` / `#kiosk` is the
|
||||||
* wall-display mode (lib/useKiosk.ts).
|
* 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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { useNodes } from "@/lib/useNodes";
|
import { useNodes } from "@/lib/useNodes";
|
||||||
|
import { useSystems } from "@/lib/useSystems";
|
||||||
import { useActiveCalls, useCalls } from "@/lib/useCalls";
|
import { useActiveCalls, useCalls } from "@/lib/useCalls";
|
||||||
import { useActiveIncidents } from "@/lib/useIncidents";
|
import { useActiveIncidents } from "@/lib/useIncidents";
|
||||||
import { useAircraft } from "@/lib/useAircraft";
|
import { useAircraft } from "@/lib/useAircraft";
|
||||||
@@ -26,9 +32,11 @@ import { c2api } from "@/lib/c2api";
|
|||||||
import { severityRank } from "@/lib/severity";
|
import { severityRank } from "@/lib/severity";
|
||||||
import type { Camera, Ny511Response } from "@/lib/types";
|
import type { Camera, Ny511Response } from "@/lib/types";
|
||||||
import {
|
import {
|
||||||
NEAR_CAMERA_KM, bboxAround, groupCallsByIncident, incidentPoints, km, nearestCameras, onAirIncidentIds,
|
DEFAULT_LAYERS, NEAR_CAMERA_KM, bboxAround, groupCallsByIncident, incidentPoints, km, nearestCameras, normaliseLayers,
|
||||||
parseWallHash, sameView, wallHash, worstFirst, type LatLon, type NearCamera, type WallLink,
|
onAirIncidentIds, parseWallHash, sameView, wallHash, worstFirst, type LatLon, type NearCamera, type WallLink,
|
||||||
} from "@/lib/liveWall";
|
} 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 { loadWallPrefs, resolveWallView, saveWallPrefs } from "@/lib/wallPrefs";
|
||||||
import { announceKioskChange, kioskInQuery, stripKioskQuery, useKiosk } from "@/lib/useKiosk";
|
import { announceKioskChange, kioskInQuery, stripKioskQuery, useKiosk } from "@/lib/useKiosk";
|
||||||
import { KioskBanner, KioskControls } from "@/components/live/KioskControls";
|
import { KioskBanner, KioskControls } from "@/components/live/KioskControls";
|
||||||
@@ -55,6 +63,7 @@ const KIOSK_TYPE = [
|
|||||||
"[&[data-kiosk]_.w-56]:w-80",
|
"[&[data-kiosk]_.w-56]:w-80",
|
||||||
].join(" ");
|
].join(" ");
|
||||||
const PIN_KEY = "drb.wall.pinnedCameras";
|
const PIN_KEY = "drb.wall.pinnedCameras";
|
||||||
|
const MAX_TAG_CHIPS = 8;
|
||||||
const MAX_PINNED = 6;
|
const MAX_PINNED = 6;
|
||||||
const EVENTS_POLL_MS = 2 * 60 * 1000;
|
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
|
// Wide-ish recent window: feeds incident paths, the call spine, the ticker
|
||||||
// and the scrubber's density bars. Real recent calls, not a fixture.
|
// and the scrubber's density bars. Real recent calls, not a fixture.
|
||||||
const { calls: recentCalls, loading: callsLoading, error: callsError } = useCalls(500);
|
const { calls: recentCalls, loading: callsLoading, error: callsError } = useCalls(500);
|
||||||
|
const { systems } = useSystems();
|
||||||
const { aircraft } = useAircraft();
|
const { aircraft } = useAircraft();
|
||||||
const { vessels } = useVessels();
|
const { vessels } = useVessels();
|
||||||
const [clock, setClock] = useState<string>("");
|
const [clock, setClock] = useState<string>("");
|
||||||
@@ -166,6 +176,22 @@ export function LiveView() {
|
|||||||
announceKioskChange();
|
announceKioskChange();
|
||||||
}, [link, mapState]);
|
}, [link, mapState]);
|
||||||
const patchLink = useCallback((p: Partial<WallLink>) => setLinkState((cur) => ({ ...cur, ...p })), []);
|
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) => {
|
const onViewStateChange = useCallback((s: MapViewState) => {
|
||||||
setMapState((cur) => {
|
setMapState((cur) => {
|
||||||
// Keep a known viewport if the map hasn't reported one yet.
|
// Keep a known viewport if the map hasn't reported one yet.
|
||||||
@@ -214,7 +240,11 @@ export function LiveView() {
|
|||||||
|
|
||||||
// ── Geography ─────────────────────────────────────────────────────────────
|
// ── Geography ─────────────────────────────────────────────────────────────
|
||||||
const callsByIncident = useMemo(() => groupCallsByIncident(recentCalls), [recentCalls]);
|
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 pointsById = useMemo(() => {
|
||||||
const m = new Map<string, LatLon[]>();
|
const m = new Map<string, LatLon[]>();
|
||||||
for (const inc of activeIncidents) m.set(inc.incident_id, incidentPoints(inc, callsByIncident.get(inc.incident_id)));
|
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 onAir = useMemo(() => onAirIncidentIds(activeIncidents, activeCalls), [activeIncidents, activeCalls]);
|
||||||
|
|
||||||
const selected = link.inc ? activeIncidents.find((i) => i.incident_id === link.inc) ?? null : null;
|
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 selectedPoints = selected ? pointsById.get(selected.incident_id) ?? [] : [];
|
||||||
const near = selected ? nearById.get(selected.incident_id) ?? [] : [];
|
const near = selected ? nearById.get(selected.incident_id) ?? [] : [];
|
||||||
const drillCam: Camera | null = link.cam
|
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;
|
const tourPct = link.tour && tourStart ? Math.min(100, ((tourNow - tourStart) / tourMs) * 100) : 0;
|
||||||
|
|
||||||
// ── KPIs ──────────────────────────────────────────────────────────────────
|
// ── 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
|
// "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.
|
// that checks in but hears nothing is not contributing to the picture.
|
||||||
const nodesHearing = health.nodesHearing;
|
const nodesHearing = health.nodesHearing;
|
||||||
@@ -337,7 +405,8 @@ export function LiveView() {
|
|||||||
<div className="hidden md:flex items-center gap-3 flex-wrap">
|
<div className="hidden md:flex items-center gap-3 flex-wrap">
|
||||||
<Kpi value={count("major")} label="major" />
|
<Kpi value={count("major")} label="major" />
|
||||||
<Kpi value={count("moderate")} label="moderate" />
|
<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={`${nodesHearing}/${nodes.length}`} label={`nodes hearing (${health.staleMin}m)`} />
|
||||||
<Kpi value={cams.cameras.length} label="cams" />
|
<Kpi value={cams.cameras.length} label="cams" />
|
||||||
<Kpi value={aircraft.length} label="aircraft" />
|
<Kpi value={aircraft.length} label="aircraft" />
|
||||||
@@ -364,6 +433,17 @@ export function LiveView() {
|
|||||||
<span className="font-mono text-ink-2 tabular-nums">{clock}</span>
|
<span className="font-mono text-ink-2 tabular-nums">{clock}</span>
|
||||||
</div>
|
</div>
|
||||||
</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} />
|
<KioskBanner banner={kioskState.banner} onRetry={kioskState.retryNow} />
|
||||||
<TruthStrip health={health} placement="bar" />
|
<TruthStrip health={health} placement="bar" />
|
||||||
|
|
||||||
@@ -378,8 +458,8 @@ export function LiveView() {
|
|||||||
<MapView
|
<MapView
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
activeCalls={activeCalls}
|
activeCalls={activeCalls}
|
||||||
incidents={activeIncidents}
|
incidents={mapIncidents}
|
||||||
calls={recentCalls}
|
calls={mapCalls}
|
||||||
selectedIncidentId={selected?.incident_id ?? null}
|
selectedIncidentId={selected?.incident_id ?? null}
|
||||||
onSelectIncident={selectIncident}
|
onSelectIncident={selectIncident}
|
||||||
nearCameras={near}
|
nearCameras={near}
|
||||||
@@ -425,8 +505,8 @@ export function LiveView() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<RegionPanel
|
<RegionPanel
|
||||||
incidents={activeIncidents}
|
incidents={filtered.incidents}
|
||||||
calls={recentCalls}
|
calls={filtered.calls}
|
||||||
onAirCount={onAir.size}
|
onAirCount={onAir.size}
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
wallCameras={wallCameras}
|
wallCameras={wallCameras}
|
||||||
@@ -443,8 +523,8 @@ export function LiveView() {
|
|||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TranscriptTicker calls={recentCalls} onSelectIncident={selectIncident} />
|
<TranscriptTicker calls={filtered.calls} onSelectIncident={selectIncident} />
|
||||||
<TimeScrubber calls={recentCalls} incidents={activeIncidents} selectedIncidentId={selected?.incident_id ?? null} onSelectIncident={selectIncident} />
|
<TimeScrubber calls={filtered.calls} incidents={filtered.incidents} selectedIncidentId={selected?.incident_id ?? null} onSelectIncident={selectIncident} />
|
||||||
|
|
||||||
{drillCam && (
|
{drillCam && (
|
||||||
<CameraDrill
|
<CameraDrill
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"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(() => {
|
||||||
|
// Compare trimmed: the hash stores q trimmed, so a just-typed trailing
|
||||||
|
// space ("main ") must not read as an outside change and get eaten.
|
||||||
|
if (filter.q !== committed.current.trim()) { 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* Live wall filter (server-26#235) — pure helpers, no React, no window.
|
||||||
|
*
|
||||||
|
* One filter narrows everything the wall plots: pins, paths, the rail, the
|
||||||
|
* region panel, the transcript ticker, the scrubber bands and the auto-tour.
|
||||||
|
* It is client-side over the incidents and calls the wall already loaded;
|
||||||
|
* server-side search is UI_REDESIGN chunk 12.
|
||||||
|
*
|
||||||
|
* Hash form (rides #234's `f.<name>` extension point in lib/liveWall.ts):
|
||||||
|
* f.type=fire,ems f.sev=moderate f.sys=<id>,… f.tg=<id>,… f.age=6
|
||||||
|
* f.tag=wires,tree f.q=<text> f.p=fire|storm|traffic
|
||||||
|
* "fire, moderate+, last 6h" = `#f.age=6&f.sev=moderate&f.type=fire`.
|
||||||
|
* Every key is optional; an unknown or malformed value is dropped, never fatal.
|
||||||
|
*/
|
||||||
|
import type { CallRecord, IncidentRecord } from "@/lib/types";
|
||||||
|
import { isKnownSeverity, severityRank, type Severity } from "@/lib/severity";
|
||||||
|
import type { LayerKey } from "@/lib/liveWall";
|
||||||
|
|
||||||
|
/** The incident types intelligence.py emits (`_VALID_INCIDENT_TYPES`). */
|
||||||
|
export const INCIDENT_TYPES = ["fire", "ems", "police", "accident", "other"] as const;
|
||||||
|
export type IncidentType = (typeof INCIDENT_TYPES)[number];
|
||||||
|
|
||||||
|
export const AGE_WINDOWS_H = [1, 6, 24] as const;
|
||||||
|
|
||||||
|
export const PRESETS = {
|
||||||
|
fire: {
|
||||||
|
label: "Fire",
|
||||||
|
title: "Fire incidents plus EMS assists heard on a fire channel; turns Weather Radar on",
|
||||||
|
layers: ["radar"] as LayerKey[],
|
||||||
|
},
|
||||||
|
storm: {
|
||||||
|
label: "Storm",
|
||||||
|
title: "Weather damage: wires, trees, flooding, storm tags; turns Weather Radar on",
|
||||||
|
layers: ["radar"] as LayerKey[],
|
||||||
|
},
|
||||||
|
traffic: {
|
||||||
|
label: "Traffic",
|
||||||
|
title: "Accidents / MVAs; turns the Traffic Events layer on",
|
||||||
|
layers: ["traffic"] as LayerKey[],
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
export type PresetKey = keyof typeof PRESETS;
|
||||||
|
export const PRESET_KEYS = Object.keys(PRESETS) as PresetKey[];
|
||||||
|
|
||||||
|
export interface WallFilter {
|
||||||
|
/** Incident type is one of these (any). */
|
||||||
|
types: IncidentType[];
|
||||||
|
/** Severity floor: severityRank(incident) >= this. */
|
||||||
|
sevFloor: Severity | null;
|
||||||
|
systems: string[];
|
||||||
|
talkgroups: string[];
|
||||||
|
/** Activity (last update) within this many hours. */
|
||||||
|
ageH: number | null;
|
||||||
|
/** Any incident tag contains any of these (case-insensitive). */
|
||||||
|
tags: string[];
|
||||||
|
/** Free text over title/location/summary and the incident's loaded transcripts. */
|
||||||
|
q: string;
|
||||||
|
/** Discipline preset rule, ANDed with everything above. */
|
||||||
|
preset: PresetKey | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_FILTER: WallFilter = {
|
||||||
|
types: [], sevFloor: null, systems: [], talkgroups: [], ageH: null, tags: [], q: "", preset: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isFilterActive(f: WallFilter): boolean {
|
||||||
|
return !!(f.types.length || f.sevFloor || f.systems.length || f.talkgroups.length || f.ageH
|
||||||
|
|| f.tags.length || f.q.trim() || f.preset);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hash (de)serialisation ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const list = (v: string | undefined): string[] =>
|
||||||
|
Array.from(new Set((v ?? "").split(",").map((s) => s.trim()).filter(Boolean)));
|
||||||
|
|
||||||
|
function isPresetKey(k: string): k is PresetKey {
|
||||||
|
return Object.prototype.hasOwnProperty.call(PRESETS, k);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `WallLink.filter` (the raw `f.*` hash pairs) → a validated filter. */
|
||||||
|
export function parseFilter(raw: Record<string, string> | undefined): WallFilter {
|
||||||
|
if (!raw) return EMPTY_FILTER;
|
||||||
|
const types = list(raw.type).map((t) => t.toLowerCase())
|
||||||
|
.filter((t): t is IncidentType => (INCIDENT_TYPES as readonly string[]).includes(t));
|
||||||
|
const sev = (raw.sev ?? "").toLowerCase();
|
||||||
|
const age = Number(raw.age);
|
||||||
|
const p = (raw.p ?? "").toLowerCase();
|
||||||
|
return {
|
||||||
|
types: INCIDENT_TYPES.filter((t) => types.includes(t)),
|
||||||
|
sevFloor: isKnownSeverity(sev) ? sev : null,
|
||||||
|
systems: list(raw.sys),
|
||||||
|
talkgroups: list(raw.tg),
|
||||||
|
ageH: Number.isFinite(age) && age > 0 ? Math.min(age, 24 * 30) : null,
|
||||||
|
tags: list(raw.tag).map((t) => t.toLowerCase()),
|
||||||
|
q: (raw.q ?? "").slice(0, 200),
|
||||||
|
preset: isPresetKey(p) ? p : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A filter → `WallLink.filter`; undefined when nothing is set, so the hash stays clean. */
|
||||||
|
export function serialiseFilter(f: WallFilter): Record<string, string> | undefined {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
if (f.types.length) out.type = f.types.join(",");
|
||||||
|
if (f.sevFloor) out.sev = f.sevFloor;
|
||||||
|
if (f.systems.length) out.sys = f.systems.join(",");
|
||||||
|
if (f.talkgroups.length) out.tg = f.talkgroups.join(",");
|
||||||
|
if (f.ageH) out.age = String(f.ageH);
|
||||||
|
if (f.tags.length) out.tag = f.tags.join(",");
|
||||||
|
if (f.q.trim()) out.q = f.q.trim();
|
||||||
|
if (f.preset) out.p = f.preset;
|
||||||
|
return Object.keys(out).length ? out : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Matching ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const FIRE_CHANNEL = /fire|\bfd\b|rescue/i;
|
||||||
|
const STORM_TAGS = ["weather", "storm", "wire", "tree", "flood", "lightning", "power-line", "pole"];
|
||||||
|
const TRAFFIC_TAGS = ["mva", "mvc", "crash", "collision", "accident", "rollover", "hit-and-run"];
|
||||||
|
|
||||||
|
const tagHit = (tags: string[] | undefined, terms: readonly string[]) =>
|
||||||
|
(tags ?? []).some((t) => { const l = t.toLowerCase(); return terms.some((term) => l.includes(term)); });
|
||||||
|
|
||||||
|
/** Preset tag terms match at the start of a word ("downed-tree", "trees"), so
|
||||||
|
* "tree" does not pull in "street-racing" or "street-fight". */
|
||||||
|
const wordStart = (terms: readonly string[]) => new RegExp(`(?:^|[^a-z])(?:${terms.join("|")})`);
|
||||||
|
const STORM_RE = wordStart(STORM_TAGS);
|
||||||
|
const TRAFFIC_RE = wordStart(TRAFFIC_TAGS);
|
||||||
|
const presetTagHit = (tags: string[] | undefined, re: RegExp) => (tags ?? []).some((t) => re.test(t.toLowerCase()));
|
||||||
|
|
||||||
|
const callText = (c: CallRecord) => `${c.transcript_corrected || c.transcript || ""} ${c.location ?? ""}`;
|
||||||
|
|
||||||
|
interface MatchCtx {
|
||||||
|
calls: Map<string, CallRecord[]>;
|
||||||
|
/** Talkgroups (as strings) that carry fire traffic in the loaded data. */
|
||||||
|
fireTgs: Set<string>;
|
||||||
|
now: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function presetMatch(p: PresetKey, inc: IncidentRecord, ctx: MatchCtx): boolean {
|
||||||
|
if (p === "fire") {
|
||||||
|
if (inc.type === "fire") return true;
|
||||||
|
if (inc.type !== "ems") return false;
|
||||||
|
// An EMS assist counts when it was heard on a fire channel.
|
||||||
|
return (inc.talkgroup_ids ?? []).some((t) => ctx.fireTgs.has(String(t)))
|
||||||
|
|| (ctx.calls.get(inc.incident_id) ?? []).some((c) => FIRE_CHANNEL.test(c.talkgroup_name ?? ""));
|
||||||
|
}
|
||||||
|
if (p === "storm") return presetTagHit(inc.tags, STORM_RE);
|
||||||
|
return inc.type === "accident" || presetTagHit(inc.tags, TRAFFIC_RE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastActivity(inc: IncidentRecord): number {
|
||||||
|
const ts = [inc.updated_at, inc.last_thin_at, inc.started_at].map((s) => (s ? Date.parse(s) : NaN));
|
||||||
|
return Math.max(...ts.map((t) => (Number.isFinite(t) ? t : -Infinity)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function incidentMatch(inc: IncidentRecord, f: WallFilter, ctx: MatchCtx): boolean {
|
||||||
|
if (f.types.length && !f.types.includes(inc.type as IncidentType)) return false;
|
||||||
|
if (f.sevFloor && severityRank(inc.severity) < severityRank(f.sevFloor)) return false;
|
||||||
|
if (f.systems.length && !(inc.system_ids ?? []).some((s) => f.systems.includes(String(s)))) return false;
|
||||||
|
if (f.talkgroups.length && !(inc.talkgroup_ids ?? []).some((t) => f.talkgroups.includes(String(t)))) return false;
|
||||||
|
if (f.ageH && lastActivity(inc) < ctx.now - f.ageH * 3_600_000) return false;
|
||||||
|
if (f.tags.length && !tagHit(inc.tags, f.tags)) return false;
|
||||||
|
if (f.preset && !presetMatch(f.preset, inc, ctx)) return false;
|
||||||
|
const q = f.q.trim().toLowerCase();
|
||||||
|
if (q) {
|
||||||
|
const own = `${inc.title ?? ""} ${inc.location ?? ""} ${inc.summary ?? ""} ${(inc.location_mentions ?? []).join(" ")}`;
|
||||||
|
if (!own.toLowerCase().includes(q)
|
||||||
|
&& !(ctx.calls.get(inc.incident_id) ?? []).some((c) => callText(c).toLowerCase().includes(q))) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An unlinked call shown only when the filter has no incident-only criteria (type/preset). */
|
||||||
|
function looseCallMatch(c: CallRecord, f: WallFilter, now: number): boolean {
|
||||||
|
if (f.types.length || f.preset) return false;
|
||||||
|
if (f.sevFloor && severityRank(c.severity) < severityRank(f.sevFloor)) return false;
|
||||||
|
if (f.systems.length && !f.systems.includes(String(c.system_id ?? ""))) return false;
|
||||||
|
if (f.talkgroups.length && !f.talkgroups.includes(String(c.talkgroup_id ?? ""))) return false;
|
||||||
|
if (f.ageH && Date.parse(c.started_at) < now - f.ageH * 3_600_000) return false;
|
||||||
|
if (f.tags.length && !tagHit(c.tags, f.tags)) return false;
|
||||||
|
const q = f.q.trim().toLowerCase();
|
||||||
|
if (q && !`${callText(c)} ${c.talkgroup_name ?? ""}`.toLowerCase().includes(q)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const callIncidentIds = (c: CallRecord) => (c.incident_ids?.length ? c.incident_ids : c.incident_id ? [c.incident_id] : []);
|
||||||
|
|
||||||
|
export interface FilterResult {
|
||||||
|
active: boolean;
|
||||||
|
incidents: IncidentRecord[];
|
||||||
|
calls: CallRecord[];
|
||||||
|
/** Ids of incidents that pass. */
|
||||||
|
ids: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the filter once. An incident passes on every set criterion; a call
|
||||||
|
* passes when it belongs to a passing incident, or — if the filter carries no
|
||||||
|
* incident-only criterion (type, preset) — when the call itself matches.
|
||||||
|
* `callsByIncident` is optional (pass the wall's existing grouping to avoid
|
||||||
|
* regrouping).
|
||||||
|
*/
|
||||||
|
export function applyFilter(
|
||||||
|
incidents: IncidentRecord[],
|
||||||
|
calls: CallRecord[],
|
||||||
|
f: WallFilter,
|
||||||
|
callsByIncident?: Map<string, CallRecord[]>,
|
||||||
|
now: number = Date.now(),
|
||||||
|
): FilterResult {
|
||||||
|
if (!isFilterActive(f)) return { active: false, incidents, calls, ids: new Set(incidents.map((i) => i.incident_id)) };
|
||||||
|
let grouped = callsByIncident;
|
||||||
|
if (!grouped) {
|
||||||
|
grouped = new Map();
|
||||||
|
for (const c of calls) for (const id of callIncidentIds(c)) {
|
||||||
|
if (!grouped.has(id)) grouped.set(id, []);
|
||||||
|
grouped.get(id)!.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fireTgs = new Set<string>();
|
||||||
|
if (f.preset === "fire") {
|
||||||
|
for (const c of calls) if (c.talkgroup_id != null && FIRE_CHANNEL.test(c.talkgroup_name ?? "")) fireTgs.add(String(c.talkgroup_id));
|
||||||
|
for (const i of incidents) if (i.type === "fire") for (const t of i.talkgroup_ids ?? []) fireTgs.add(String(t));
|
||||||
|
}
|
||||||
|
const ctx: MatchCtx = { calls: grouped, fireTgs, now };
|
||||||
|
const passing = incidents.filter((i) => incidentMatch(i, f, ctx));
|
||||||
|
const ids = new Set(passing.map((i) => i.incident_id));
|
||||||
|
const keptCalls = calls.filter((c) => callIncidentIds(c).some((id) => ids.has(id)) || looseCallMatch(c, f, now));
|
||||||
|
return { active: true, incidents: passing, calls: keptCalls, ids };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One-line human summary, e.g. "fire · moderate+ · last 6h". */
|
||||||
|
export function describeFilter(f: WallFilter, names?: { sys?: (id: string) => string; tg?: (id: string) => string }): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (f.preset) parts.push(`${PRESETS[f.preset].label} mode`);
|
||||||
|
if (f.types.length) parts.push(f.types.join("/"));
|
||||||
|
if (f.sevFloor) parts.push(`${f.sevFloor}+`);
|
||||||
|
if (f.ageH) parts.push(`last ${f.ageH}h`);
|
||||||
|
if (f.systems.length) parts.push(f.systems.map((s) => names?.sys?.(s) ?? s).join("/"));
|
||||||
|
if (f.talkgroups.length) parts.push(`TG ${f.talkgroups.map((t) => names?.tg?.(t) ?? t).join("/")}`);
|
||||||
|
if (f.tags.length) parts.push(f.tags.map((t) => `#${t}`).join(" "));
|
||||||
|
if (f.q.trim()) parts.push(`"${f.q.trim()}"`);
|
||||||
|
return parts.join(" · ");
|
||||||
|
}
|
||||||
@@ -213,9 +213,10 @@ export interface WallLink extends WallView {
|
|||||||
/** Wall/kiosk presentation (no nav, large type, wake lock). */
|
/** Wall/kiosk presentation (no nav, large type, wake lock). */
|
||||||
kiosk?: boolean;
|
kiosk?: boolean;
|
||||||
/**
|
/**
|
||||||
* Extension point for the filter bar (server-26#235): every `f.<name>=<v>`
|
* The filter bar's state (server-26#235): every `f.<name>=<v>` hash key
|
||||||
* hash key round-trips here untouched, so filters can be added without
|
* round-trips here untouched, so filters can be added without reshaping
|
||||||
* reshaping WallLink or breaking older links.
|
* WallLink or breaking older links. lib/liveFilter.ts parseFilter /
|
||||||
|
* serialiseFilter give it meaning.
|
||||||
*/
|
*/
|
||||||
filter?: Record<string, string>;
|
filter?: Record<string, string>;
|
||||||
}
|
}
|
||||||
@@ -280,7 +281,8 @@ export function wallHash(link: WallLink): string {
|
|||||||
}
|
}
|
||||||
if (link.base) parts.push(`base=${link.base}`);
|
if (link.base) parts.push(`base=${link.base}`);
|
||||||
if (link.filter) {
|
if (link.filter) {
|
||||||
for (const k of Object.keys(link.filter).sort()) parts.push(`f.${enc(k)}=${enc(link.filter[k])}`);
|
// Filter lists (f.type=fire,ems) keep literal commas too (server-26#235).
|
||||||
|
for (const k of Object.keys(link.filter).sort()) parts.push(`f.${enc(k)}=${enc(link.filter[k]).replace(/%2C/g, ",")}`);
|
||||||
}
|
}
|
||||||
return parts.length ? `#${parts.join("&")}` : "";
|
return parts.length ? `#${parts.join("&")}` : "";
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user