Merge pull request 'Honest pins and paths: geocode precision, type-gated paths, impossible-speed stops (#236)' (#274) from feat/236-honest-pins into main
Build & Deploy / Build & push images (push) Failing after 15s
Build & Deploy / Deploy to VM (push) Skipped
Build & Deploy / Deploy Firestore rules & indexes (push) Skipped
Build & Deploy / Report a failed deploy (push) Successful in 2s

This commit was merged in pull request #274.
This commit is contained in:
2026-10-05 03:33:20 -04:00
8 changed files with 512 additions and 109 deletions
+112 -5
View File
@@ -34,18 +34,125 @@ export function groupCallsByIncident(calls: CallRecord[]): Map<string, CallRecor
/**
* An incident's geography, oldest first: its geocoded call stops (the path —
* same order as the call spine) or, failing that, its pin. Empty = not plotted.
* Ghosted stops (an implied speed no vehicle makes, server-26#236) are left
* out, so one bad geocode can't drag "nearest cameras" across the county.
*/
export function incidentPoints(inc: IncidentRecord, calls: CallRecord[] | undefined): LatLon[] {
const stops = (calls ?? [])
.filter((c) => c.location_coords)
.slice()
.sort((a, b) => a.started_at.localeCompare(b.started_at))
.map((c) => [c.location_coords!.lat, c.location_coords!.lng] as LatLon);
const stops = pathStops(inc, calls).stops.filter((s) => !s.ghost).map((s) => s.at);
if (stops.length >= 2) return stops;
if (inc.location_coords) return [[inc.location_coords.lat, inc.location_coords.lng]];
return stops;
}
// ── Honest paths (server-26#236) ─────────────────────────────────────────────
// Pure, and shared by MapView and the call spine (WallPanels), so a stop's
// number is identical on both — and only what the map numbers is numbered.
/**
* Mirrors `_PURSUIT_TAGS` in drb-c2-core/app/internal/incident_correlator.py.
* Copied, not imported: keep the two in step by hand.
*/
export const PURSUIT_TAGS: ReadonlySet<string> = new Set([
"vehicle-pursuit", "pursuit", "foot-pursuit", "chase",
"fleeing-vehicle", "suspect-vehicle", "eluding",
]);
/** Mirrors `_MAX_PURSUIT_SPEED_KM_PER_MIN` (incident_correlator.py) — ~300 km/h. */
export const MAX_PATH_SPEED_KM_PER_MIN = 8.0;
/**
* Mirrors settings.location_proximity_km: the backend only runs its speed
* check past this distance, so two geocodes of one scene a few seconds apart
* are never called impossible.
*/
export const SPEED_CHECK_MIN_KM = 0.5;
/** A segment spanning a longer silence than this is drawn dashed — we don't know the route. */
export const PATH_GAP_DASH_MIN = 10;
/** Mirrors `_PURSUIT_PROXIMITY_KM`: a path longer than a pursuit's own link radius is a merge suspect. */
export const MAY_BE_MERGED_KM = 20;
/** Police or accident incident carrying a pursuit tag — the only kind that draws a numbered path. */
export function isMovementIncident(inc: Pick<IncidentRecord, "type" | "tags">): boolean {
if (inc.type !== "police" && inc.type !== "accident") return false;
return (inc.tags ?? []).some((t) => PURSUIT_TAGS.has(t.toLowerCase()));
}
export interface PathStop {
call: CallRecord;
at: LatLon;
/** Shared map/spine stop number; null = not numbered (ghosted, or not a movement incident). */
n: number | null;
/** Reaching this stop from the previous kept one implies > MAX_PATH_SPEED_KM_PER_MIN. */
ghost: boolean;
/** The segment INTO this stop is dashed: a long silence or a talkgroup change. Never set on the first kept stop. */
dashedIn: boolean;
}
export interface IncidentPath {
movement: boolean;
/** Every geocoded call, oldest first — ghosts included, so the map can still show them faintly. */
stops: PathStop[];
/** Sum of the kept (non-ghost) segments. */
lengthKm: number;
mayBeMerged: boolean;
}
function minutesBetween(a: CallRecord, b: CallRecord): number {
return Math.abs(Date.parse(b.started_at) - Date.parse(a.started_at)) / 60_000;
}
function impossible(a: PathStop, b: PathStop): boolean {
const d = km(a.at, b.at);
if (d <= SPEED_CHECK_MIN_KM) return false;
// Same floor the backend uses (incident_correlator.py: max(idle, 0.1)).
return d / Math.max(minutesBetween(a.call, b.call), 0.1) > MAX_PATH_SPEED_KM_PER_MIN;
}
/**
* An incident's geocoded calls as path stops, oldest first.
*
* Ghosting walks forward from the last kept stop: a stop that could only be
* reached faster than a pursuit can drive is ghosted and does not move the
* anchor. A bad FIRST geocode would otherwise ghost everything after it, so
* when stop 1 is impossible from stop 0 but stop 2 agrees with stop 1 and not
* with stop 0, stop 0 is the one ghosted.
*/
export function pathStops(inc: IncidentRecord, calls: CallRecord[] | undefined): IncidentPath {
const movement = isMovementIncident(inc);
const stops: PathStop[] = (calls ?? [])
.filter((c) => c.location_coords)
.slice()
.sort((a, b) => a.started_at.localeCompare(b.started_at))
.map((c) => ({ call: c, at: [c.location_coords!.lat, c.location_coords!.lng] as LatLon, n: null, ghost: false, dashedIn: false }));
let start = 0;
if (stops.length >= 3 && impossible(stops[0], stops[1]) && !impossible(stops[1], stops[2]) && impossible(stops[0], stops[2])) {
stops[0].ghost = true;
start = 1;
}
let prev: PathStop | null = null;
let lengthKm = 0;
let n = 0;
for (let i = start; i < stops.length; i++) {
const s = stops[i];
if (prev && impossible(prev, s)) { s.ghost = true; continue; }
if (prev) {
lengthKm += km(prev.at, s.at);
s.dashedIn = minutesBetween(prev.call, s.call) > PATH_GAP_DASH_MIN
|| (prev.call.talkgroup_id != null && s.call.talkgroup_id != null && prev.call.talkgroup_id !== s.call.talkgroup_id);
}
if (movement) s.n = ++n;
prev = s;
}
return { movement, stops, lengthKm, mayBeMerged: lengthKm > MAY_BE_MERGED_KM };
}
/** call_id → stop number, for the spine. Empty for a non-movement incident. */
export function stopNumbers(path: IncidentPath): Map<string, number> {
const m = new Map<string, number>();
for (const s of path.stops) if (s.n != null) m.set(s.call.call_id, s.n);
return m;
}
/** Worst-first, then most recent (UI_REDESIGN.md §5.1). */
export function worstFirst(incidents: IncidentRecord[]): IncidentRecord[] {
return incidents
+22 -2
View File
@@ -344,6 +344,20 @@ export interface TranscriptSegment {
text: string;
}
/**
* Google Geocoding precision, persisted with the coords since server-26#236.
* ROOFTOP = a street address; RANGE_INTERPOLATED = estimated between two
* address points; GEOMETRIC_CENTER = the centre of a road, intersection or
* named place — can be hundreds of metres off. Absent on older docs.
*/
export type GeoLocationType = "ROOFTOP" | "RANGE_INTERPOLATED" | "GEOMETRIC_CENTER";
export interface GeoPoint {
lat: number;
lng: number;
location_type?: GeoLocationType | string | null;
}
export interface CallRecord {
call_id: string;
node_id: string;
@@ -370,7 +384,7 @@ export interface CallRecord {
incident_id?: string | null;
location: string | null;
/** Per-call geocode, written by intelligence.py. Powers the incident-path polyline on the map. */
location_coords?: { lat: number; lng: number } | null;
location_coords?: GeoPoint | null;
/** Unit callsigns mentioned in this specific transmission (e.g. "15-9", "E-41"). */
units?: string[];
vehicles?: string[];
@@ -403,7 +417,13 @@ export interface IncidentRecord {
type: string | null;
status: "active" | "resolved";
location: string | null;
location_coords: { lat: number; lng: number } | null;
location_coords: GeoPoint | null;
/**
* The label the pin was geocoded from (server-26#23). Absent on incidents
* written before that fix — their pin cannot be tied to their label and is
* drawn ghosted (DEFERRED.md `_verified_pin` row, server-26#28).
*/
location_coords_source?: string | null;
call_ids: string[];
system_ids: string[];
talkgroup_ids: string[];