Files
server-26/drb-frontend/lib/liveFilter.ts
T
Logan CusanoandClaude Opus 5.5 f479578942 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>
2026-10-05 03:11:02 -04:00

237 lines
11 KiB
TypeScript

/**
* 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)); });
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 tagHit(inc.tags, STORM_TAGS);
return inc.type === "accident" || tagHit(inc.tags, TRAFFIC_TAGS);
}
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(" · ");
}