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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user