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
+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>
);
}