/** * 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.` extension point in lib/liveWall.ts): * f.type=fire,ems f.sev=moderate f.sys=,… f.tg=,… f.age=6 * f.tag=wires,tree f.q= 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 | 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 | undefined { const out: Record = {}; 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; /** Talkgroups (as strings) that carry fire traffic in the loaded data. */ fireTgs: Set; 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; } /** * 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, 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(); 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(" · "); }