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,236 @@
|
||||
/**
|
||||
* 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(" · ");
|
||||
}
|
||||
Reference in New Issue
Block a user