From 6bf072973c0b649db89560adc34ec21baa530e29 Mon Sep 17 00:00:00 2001
From: Logan Cusano
Date: Mon, 5 Oct 2026 03:28:00 -0400
Subject: [PATCH 1/2] Honest pins and paths: geocode precision, type-gated
paths, impossible-speed stops (#236)
Backend: _geocode_location now returns {lat, lng, location_type}, so the
precision rides onto the call doc and, unchanged via _resolve_location_pair,
onto the incident pin. Display-only: every correlator read indexes lat/lng.
Frontend: pins styled by precision (rooftop / interpolated ring /
GEOMETRIC_CENTER dashed uncertainty circle labelled with the spoken place /
unverified pre-#23 pins ghosted). liveWall.pathStops() is shared by the map
and the call spine: only police/accident incidents with a pursuit tag draw a
numbered path; others get unnumbered call dots. Segments over >10 min gaps or
talkgroup changes are dashed, stops implying >8 km/min are ghosted off the
path, paths >20 km get a "may be merged" badge. New optional MapView prop
pulseIncidentIds (wired by #238).
Co-Authored-By: Claude Opus 5.5
---
drb-c2-core/app/internal/intelligence.py | 13 +-
.../tests/test_geocode_location_type.py | 56 +++
.../tests/test_geocode_named_region.py | 4 +-
drb-frontend/components/MapView.tsx | 329 +++++++++++++++---
drb-frontend/components/live/WallPanels.tsx | 30 +-
drb-frontend/lib/liveWall.ts | 117 ++++++-
drb-frontend/lib/types.ts | 24 +-
7 files changed, 499 insertions(+), 74 deletions(-)
create mode 100644 drb-c2-core/tests/test_geocode_location_type.py
diff --git a/drb-c2-core/app/internal/intelligence.py b/drb-c2-core/app/internal/intelligence.py
index cef7cbc..6e8705f 100644
--- a/drb-c2-core/app/internal/intelligence.py
+++ b/drb-c2-core/app/internal/intelligence.py
@@ -690,8 +690,15 @@ async def _geocode_location(
"""
Geocode using Google Maps Geocoding API, biased toward the channel's area.
- Returns {"lat": float, "lng": float}, or None if geocoding fails or the
- result lands outside the area this channel covers.
+ Returns {"lat": float, "lng": float, "location_type": str}, or None if
+ geocoding fails or the result lands outside the area this channel covers.
+
+ server-26#236: `location_type` (ROOFTOP / RANGE_INTERPOLATED /
+ GEOMETRIC_CENTER) rides along with the coordinates so the map can show how
+ precise a pin is — a route centroid must not draw the same teardrop as a
+ rooftop. It is display-only: every consumer of these coords reads only
+ "lat"/"lng", and the dict is stored as-is on the call doc and, through
+ incident_correlator._resolve_location_pair, on the incident pin.
THE REFERENCE POINT IS THE TALKGROUP, NOT THE NODE (server-26#6 / #37). This
used to reject anything more than geocode_max_km (40km) from the receiving
@@ -801,7 +808,7 @@ async def _geocode_location(
f"{dist_km:.1f}km from the receiving node, past {max_km:.1f}km — "
f"accepted anyway: the query names its own region, not the node's"
)
- coords = {"lat": lat, "lng": lng}
+ coords = {"lat": lat, "lng": lng, "location_type": location_type}
logger.info(
f"Geocoded '{location_str}' → {coords} "
f"({dist_km:.1f}km from {ref_label}) [{location_type}]"
diff --git a/drb-c2-core/tests/test_geocode_location_type.py b/drb-c2-core/tests/test_geocode_location_type.py
new file mode 100644
index 0000000..33e3f08
--- /dev/null
+++ b/drb-c2-core/tests/test_geocode_location_type.py
@@ -0,0 +1,56 @@
+"""
+server-26#236: geocode precision must travel with the coordinates.
+
+`_geocode_location` already judged a result by Google's `location_type` but
+returned only {lat, lng}, so a GEOMETRIC_CENTER route centroid and a ROOFTOP
+address drew the same pin. The type is now part of the returned dict, which
+is stored as-is on the call doc and carried unchanged onto the incident pin by
+`incident_correlator._resolve_location_pair`.
+
+Display-only: every correlator read of location_coords indexes ["lat"] and
+["lng"] directly, so the extra key cannot change a correlation decision.
+"""
+from unittest.mock import patch
+
+import pytest
+
+import app.internal.intelligence as intel
+from app.config import settings
+from tests.test_geocode_named_region import _maps_result
+
+
+@pytest.fixture(autouse=True)
+def _api_key():
+ with patch.object(settings, "google_maps_api_key", "test-key"):
+ yield
+
+
+ANCHOR = {"lat": 41.16, "lng": -73.86, "radius_km": 10.0}
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("location_type", ["ROOFTOP", "RANGE_INTERPOLATED", "GEOMETRIC_CENTER"])
+async def test_accepted_location_type_is_returned_with_the_coords(location_type):
+ with _maps_result(41.161, -73.861, location_type=location_type):
+ coords = await intel._geocode_location("1 Main Street, Ossining", anchor=ANCHOR)
+ assert coords == {"lat": 41.161, "lng": -73.861, "location_type": location_type}
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("location_type", ["APPROXIMATE", "", "SOMETHING_NEW"])
+async def test_rejected_location_type_still_returns_none(location_type):
+ with _maps_result(41.161, -73.861, location_type=location_type):
+ coords = await intel._geocode_location("Ossining", anchor=ANCHOR)
+ assert coords is None
+
+
+def test_location_type_survives_onto_the_incident_pin():
+ """`_resolve_location_pair` stores the call's coords dict as-is, so the
+ precision reaches the incident without any correlator change."""
+ from app.internal.incident_correlator import _resolve_location_pair
+
+ pin = {"lat": 41.161, "lng": -73.861, "location_type": "GEOMETRIC_CENTER"}
+ fields = _resolve_location_pair({}, "Route 9 and Main Street", pin)
+ assert fields["location_coords"] == pin
+ assert fields["location_coords_source"] == "Route 9 and Main Street"
+
diff --git a/drb-c2-core/tests/test_geocode_named_region.py b/drb-c2-core/tests/test_geocode_named_region.py
index 76f3802..e4e1c28 100644
--- a/drb-c2-core/tests/test_geocode_named_region.py
+++ b/drb-c2-core/tests/test_geocode_named_region.py
@@ -69,7 +69,7 @@ async def test_named_region_geocode_accepted_beyond_node_distance():
node_lat=NODE_LAT, node_lon=NODE_LON,
trust_named_region=True,
)
- assert coords == {"lat": FAR_LAT, "lng": FAR_LNG}
+ assert coords == {"lat": FAR_LAT, "lng": FAR_LNG, "location_type": "ROOFTOP"}
@pytest.mark.asyncio
@@ -110,7 +110,7 @@ async def test_named_region_geocode_within_node_distance_is_unaffected():
node_lat=NODE_LAT, node_lon=NODE_LON,
trust_named_region=True,
)
- assert coords == {"lat": near_lat, "lng": near_lng}
+ assert coords == {"lat": near_lat, "lng": near_lng, "location_type": "ROOFTOP"}
@pytest.mark.asyncio
diff --git a/drb-frontend/components/MapView.tsx b/drb-frontend/components/MapView.tsx
index cbb49c7..f0d7696 100644
--- a/drb-frontend/components/MapView.tsx
+++ b/drb-frontend/components/MapView.tsx
@@ -30,7 +30,8 @@ import { CameraFeed } from "@/components/CameraFeed";
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
import {
BASE_NAMES, DEFAULT_BASE, DEFAULT_LAYERS, LAYER_NAMES, NEAR_CAMERA_KM, baseKeyForName, groupCallsByIncident,
- incidentPoints, layerKeyForName, normaliseLayers, roundViewport, viewshedWedge, worstFirst,
+ MAY_BE_MERGED_KM, PATH_GAP_DASH_MIN, incidentPoints, layerKeyForName, normaliseLayers, pathStops, roundViewport,
+ viewshedWedge, worstFirst,
type BaseKey, type LayerKey, type MapViewport,
} from "@/lib/liveWall";
import { OutageLayer, outageGlyphSvg } from "@/components/map/OutageLayer";
@@ -766,25 +767,75 @@ function nodeFanIcon(members: NodeRecord[]): L.DivIcon {
});
}
+// ── Pin precision (server-26#236) ─────────────────────────────────────────────
+// A pin is only as exact as its geocode. `location_type` rides on the coords
+// since #236; `location_coords_source` (#23) ties a pin to the label it came
+// from — an incident without it predates that fix and its pin is unverifiable
+// (5-in-6 wrong on the 2026-08 dump; DEFERRED.md `_verified_pin` row, #28).
+type PinPrecision = "rooftop" | "interpolated" | "centre" | "unknown" | "unverified";
+
+function pinPrecision(inc: IncidentRecord): PinPrecision {
+ if (!inc.location_coords_source) return "unverified";
+ switch (inc.location_coords?.location_type) {
+ case "ROOFTOP": return "rooftop";
+ case "RANGE_INTERPOLATED": return "interpolated";
+ case "GEOMETRIC_CENTER": return "centre";
+ default: return "unknown"; // geocoded before #236 persisted the type
+ }
+}
+
+/** Nominal uncertainty drawn round a GEOMETRIC_CENTER pin. Google gives no error radius; this is a visual "around here", not a measurement. */
+const CENTRE_UNCERTAINTY_M = 250;
+
+const PIN_PRECISION_LABEL: Record = {
+ rooftop: "Street address (rooftop)",
+ interpolated: "Interpolated along the block",
+ centre: "Centre of a road, junction or place",
+ unknown: "Precision not recorded",
+ unverified: "Unverified pin (older incident)",
+};
+
// ── Incident pin — teardrop filled by severity, type glyph knocked out ───────
// Minor/routine (no alarm colour) render hollow with an ink stroke so the map
-// doesn't imply urgency that isn't there.
-function incidentIcon(type: string | null, severity: string | null | undefined): L.DivIcon {
+// doesn't imply urgency that isn't there. Precision is carried by what sits at
+// the tip (server-26#236): nothing = rooftop; a small solid ring = interpolated;
+// a wide dashed ring = centre of a road/place; the whole pin ghosted and
+// dashed = unverified. `pulse` adds a pulsing ring round the head.
+function teardropSvg(type: string | null, severity: string | null | undefined, precision: PinPrecision = "rooftop"): string {
const color = severityColor(severity);
- const hollow = !isKnownSeverity(severity) || severity === "minor" || severity === "routine";
+ const unverified = precision === "unverified";
+ const hollow = unverified || !isKnownSeverity(severity) || severity === "minor" || severity === "routine";
const glyphColor = hollow ? color : "var(--page)";
const fill = hollow ? "var(--surface)" : color;
const stroke = hollow ? color : "var(--page)";
- const svg = `
-
Dashed in: long gap or talkgroup change — route unknown
+ )}
+ {hasAudio(c) && (
+
+
+ Play this call
+
+ )}
+
+ View incident →
+
+ {/* Gate A / A2 (server-26#46) — the stop location and its
+ ordering come from the transcript, not from GPS. */}
+
+
+
+
+ );
+ })}
+ {path.mayBeMerged && (
+
+ )}
);
})}
@@ -1139,6 +1309,11 @@ interface Props {
onAir?: Set;
/** Cameras within range of each incident (rail "N cams"). */
camCountByIncident?: Map;
+ /**
+ * Incident pins that get a pulsing ring. Generic on purpose — the caller
+ * decides what deserves attention (server-26#236; wired by #238).
+ */
+ pulseIncidentIds?: Set;
// ── Saved views (server-26#234). Read once at mount — remount (key) to apply new ones. ──
/** Overlays switched on at mount; default = Nodes, Active Incidents, Incident Paths. */
initialLayers?: LayerKey[];
@@ -1165,7 +1340,7 @@ function currentViewport(map: L.Map): MapViewport {
export default function MapView({
nodes, activeCalls, incidents = [], calls = [], lastUpdated,
selectedIncidentId = null, onSelectIncident, nearCameras = NO_NEAR, onOpenCamera, onAir, camCountByIncident,
- initialLayers, initialBase, initialView, onViewStateChange,
+ pulseIncidentIds, initialLayers, initialBase, initialView, onViewStateChange,
}: Props) {
const [mapInstance, setMapInstance] = useState(null);
const [drawerOpen, setDrawerOpen] = useState(false);
@@ -1364,7 +1539,7 @@ export default function MapView({
{/* Overlay: Active Incidents */}
-
+
@@ -1471,6 +1646,54 @@ export default function MapView({
))}
+ {/* Pin precision + path honesty (server-26#236). Collapsed by default:
+ on the 352px incident-page map the full key would overflow a legend
+ that can't scroll (pointer-events are off so the map pans under it). */}
+
+
+ ▸▾ Pin precision & paths
+
+
+ {(["rooftop", "interpolated", "centre", "unverified"] as PinPrecision[]).map((p) => (
+
+
+ {p === "rooftop" ? "Street address" : p === "interpolated" ? "Interpolated" : p === "centre" ? "Road/place centre ≈" : "Unverified (older)"}
+
+ ))}
+
+
+
Paths
+
+
+ 1
+
+ Pursuit stop (numbered)
+
+
+
+
+
+ Gap > {PATH_GAP_DASH_MIN} min or TG change
+
+
+
+
+
+ Impossible speed — off path
+
+
+
+
+
+ Call location (not a pursuit)
+
+
+ merged?
+ Path > {MAY_BE_MERGED_KM} km — may be 2+ events
+
+
+
{aircraftShown && (
Aircraft altitude
diff --git a/drb-frontend/components/live/WallPanels.tsx b/drb-frontend/components/live/WallPanels.tsx
index 4535560..79b3596 100644
--- a/drb-frontend/components/live/WallPanels.tsx
+++ b/drb-frontend/components/live/WallPanels.tsx
@@ -16,7 +16,10 @@ import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
import { InlinePlayer } from "@/components/InlinePlayer";
import { canPlayAudio, hasAudio, playIncident, stop as stopAudio, useAudio } from "@/lib/audioQueue";
-import { CONTEXT_KM, NEAR_CAMERA_KM, bboxAround, contextFor, sampleRadarDbz, type LatLon, type NearCamera } from "@/lib/liveWall";
+import {
+ CONTEXT_KM, MAY_BE_MERGED_KM, NEAR_CAMERA_KM, bboxAround, contextFor, pathStops, sampleRadarDbz, stopNumbers,
+ type LatLon, type NearCamera,
+} from "@/lib/liveWall";
import { useOutagesIn, type OutagesState } from "@/lib/useOutages";
import { OUTAGE_POINT_KM, countyName, isUtilityHazard, matchOutages, staleCountiesAt } from "@/lib/outageMatch";
@@ -297,13 +300,12 @@ export function IncidentPanel({
const color = sevColor(inc.severity);
const ctx = useMemo(() => contextFor(points, aircraft, vessels, events), [points, aircraft, vessels, events]);
const radar = useRadarDbz(points.at(-1) ?? null);
- // Stop numbers share the index with the map path: geocoded calls, oldest first.
- const stopOf = useMemo(() => {
- const m = new Map();
- calls.filter((c) => c.location_coords).slice().sort((a, b) => a.started_at.localeCompare(b.started_at))
- .forEach((c, i) => m.set(c.call_id, i + 1));
- return m;
- }, [calls]);
+ // Stop numbers come from the same pathStops() the map draws from, so the
+ // spine numbers exactly what the map numbers (server-26#236): only movement
+ // incidents get numbers, and a ghosted (impossible-speed) stop gets none.
+ const path = useMemo(() => pathStops(inc, calls), [inc, calls]);
+ const stopOf = useMemo(() => stopNumbers(path), [path]);
+ const ghostIds = useMemo(() => new Set(path.stops.filter((s) => s.ghost).map((s) => s.call.call_id)), [path]);
const spine = useMemo(() => calls.slice().sort((a, b) => b.started_at.localeCompare(a.started_at)), [calls]);
// "Play incident" (server-26#233): every loaded call with a recording, oldest first.
const playable = useMemo(() => spine.filter(hasAudio).map((c) => c.call_id).reverse(), [spine]);
@@ -336,6 +338,12 @@ export function IncidentPanel({
{inc.location && {inc.location}}
{elapsed(inc.started_at)} elapsed{inc.call_ids?.length ?? 0} calls
+ {path.mayBeMerged && (
+
+ may be merged · {path.lengthKm.toFixed(0)} km
+
+ )}
Open incident page →
@@ -441,6 +449,7 @@ export function IncidentPanel({
{shown.map((c) => {
const stop = stopOf.get(c.call_id);
+ const ghost = ghostIds.has(c.call_id);
const text = c.transcript_corrected || c.transcript;
const isPlaying = c.call_id === playingCallId;
return (
@@ -448,7 +457,10 @@ export function IncidentPanel({
className={`flex gap-2 text-xs rounded ${isPlaying ? "bg-raised ring-1 ring-accent -mx-1 px-1 py-0.5" : ""}`}
aria-current={isPlaying ? "true" : undefined}>
{stop ?? "·"}
+ title={ghost ? "Geocoded too far from the previous stop to reach in time — left off the path" : undefined}
+ style={stop ? { background: color, color: "var(--page)" }
+ : ghost ? { border: "1.5px dashed var(--ink-muted)", color: "var(--ink-muted)" }
+ : { background: "var(--raised)", color: "var(--ink-muted)" }}>{stop ?? (ghost ? "?" : "·")}
{hhmm(c.started_at)}
diff --git a/drb-frontend/lib/liveWall.ts b/drb-frontend/lib/liveWall.ts
index a5c7d55..2c83eaa 100644
--- a/drb-frontend/lib/liveWall.ts
+++ b/drb-frontend/lib/liveWall.ts
@@ -34,18 +34,125 @@ export function groupCallsByIncident(calls: CallRecord[]): Map 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 = 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): 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 {
+ const m = new Map();
+ 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
diff --git a/drb-frontend/lib/types.ts b/drb-frontend/lib/types.ts
index 279f1fe..660deeb 100644
--- a/drb-frontend/lib/types.ts
+++ b/drb-frontend/lib/types.ts
@@ -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[];
--
2.54.0
From e1bd3feac52d1b7870fe5dfc167bed650c766c56 Mon Sep 17 00:00:00 2001
From: Logan Cusano
Date: Mon, 5 Oct 2026 03:32:36 -0400
Subject: [PATCH 2/2] Incident page spine: number stops from pathStops, not its
own index (#236)
The /incidents/[id] page kept its own stop numbering (every geocoded call,
1..N) and path length, while its MapView now numbers only movement
incidents' non-ghosted stops. A fire with 3 geocoded calls showed stops
1-3 on the spine and unnumbered dots on the map. Use the shared
pathStops()/stopNumbers(), show 'km path' only for movement incidents,
and surface the 'may be merged' flag in the header.
Legend: a plain pin is also what an older (pre-#236) pin with no recorded
precision looks like; say so instead of claiming 'Street address'.
Co-Authored-By: Claude Opus 5.5
---
drb-frontend/app/incidents/[id]/page.tsx | 48 +++++++-----------------
drb-frontend/components/MapView.tsx | 2 +-
2 files changed, 14 insertions(+), 36 deletions(-)
diff --git a/drb-frontend/app/incidents/[id]/page.tsx b/drb-frontend/app/incidents/[id]/page.tsx
index 01c961a..2221983 100644
--- a/drb-frontend/app/incidents/[id]/page.tsx
+++ b/drb-frontend/app/incidents/[id]/page.tsx
@@ -13,22 +13,12 @@ import { SeverityMark } from "@/components/marks/SeverityMark";
import { isKnownSeverity } from "@/lib/severity";
import { Button } from "@/components/ui/Button";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
-import type { CallRecord } from "@/lib/types";
+import { MAY_BE_MERGED_KM, pathStops, stopNumbers } from "@/lib/liveWall";
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
const EARLIER_PAGE_SIZE = 8;
-function haversineKm(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
- const R = 6371;
- const dLat = ((b.lat - a.lat) * Math.PI) / 180;
- const dLng = ((b.lng - a.lng) * Math.PI) / 180;
- const s =
- Math.sin(dLat / 2) ** 2 +
- Math.cos((a.lat * Math.PI) / 180) * Math.cos((b.lat * Math.PI) / 180) * Math.sin(dLng / 2) ** 2;
- return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s));
-}
-
function elapsedLabel(startedAt: string, active: boolean, updatedAt: string): string {
const start = new Date(startedAt).getTime();
const end = active ? Date.now() : new Date(updatedAt).getTime();
@@ -51,29 +41,12 @@ export default function IncidentDetailPage() {
const [resolving, setResolving] = useState(false);
const [earlierShown, setEarlierShown] = useState(EARLIER_PAGE_SIZE);
- // Same ordering/filtering MapView's IncidentPathLayer uses, so the stop
- // number shown on a spine entry matches the number on its map marker.
- const geocodedCalls = useMemo(
- () =>
- calls
- .filter((c): c is CallRecord & { location_coords: { lat: number; lng: number } } => !!c.location_coords)
- .slice()
- .sort((a, b) => a.started_at.localeCompare(b.started_at)),
- [calls]
- );
- const stopNumberByCallId = useMemo(() => {
- const m = new Map();
- geocodedCalls.forEach((c, i) => m.set(c.call_id, i + 1));
- return m;
- }, [geocodedCalls]);
-
- const pathLengthKm = useMemo(() => {
- let total = 0;
- for (let i = 1; i < geocodedCalls.length; i++) {
- total += haversineKm(geocodedCalls[i - 1].location_coords!, geocodedCalls[i].location_coords!);
- }
- return total;
- }, [geocodedCalls]);
+ // Stop numbers and path length come from the same liveWall.pathStops() the
+ // map's IncidentPathLayer draws from (server-26#236), so the spine numbers
+ // exactly what the map numbers: only a movement incident (police/accident
+ // with a pursuit tag) is numbered, and an impossible-speed stop never is.
+ const path = useMemo(() => (incident ? pathStops(incident, calls) : null), [incident, calls]);
+ const stopNumberByCallId = useMemo(() => (path ? stopNumbers(path) : new Map()), [path]);
const newestFirst = useMemo(
() => calls.slice().sort((a, b) => b.started_at.localeCompare(a.started_at)),
@@ -130,7 +103,12 @@ export default function IncidentDetailPage() {