Merge pull request 'Honest pins and paths: geocode precision, type-gated paths, impossible-speed stops (#236)' (#274) from feat/236-honest-pins into main
This commit was merged in pull request #274.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user