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 = ` - - - ${typeGlyphSvg(type, glyphColor, 13)} + const dash = unverified ? ` stroke-dasharray="3 2"` : ""; + const tip = + precision === "interpolated" ? `` + : precision === "centre" ? `` + : ""; + return ` + ${tip} + + ${typeGlyphSvg(type, glyphColor, 13)} `; - return L.divIcon({ className: "", html: svg, iconSize: [28, 36], iconAnchor: [14, 34] }); } -function incidentFanIcon(members: IncidentRecord[]): L.DivIcon { +function incidentIcon( + type: string | null, severity: string | null | undefined, precision: PinPrecision = "rooftop", pulse = false, +): L.DivIcon { + const ring = pulse + ? `
` + : ""; + return L.divIcon({ + className: "", + html: `
${ring}${teardropSvg(type, severity, precision)}
`, + iconSize: [32, 48], + iconAnchor: [16, 37], + }); +} + +function incidentFanIcon(members: IncidentRecord[], pulse = false): L.DivIcon { const n = members.length; const CARD = 16; const STEP = 9; @@ -802,7 +853,9 @@ function incidentFanIcon(members: IncidentRecord[]): L.DivIcon { .join(""); return L.divIcon({ className: "", - html: `
${cards}
`, + html: `
${pulse + ? `
` + : ""}${cards}
`, iconSize: [totalW, CARD + 6], iconAnchor: [totalW / 2, CARD + 6], }); @@ -951,10 +1004,13 @@ function FanIncidentLayer({ incidents, onSelect, selectedId, + pulseIds, }: { incidents: IncidentRecord[]; onSelect: (inc: IncidentRecord) => void; selectedId?: string | null; + /** Incident pins that get a pulsing ring (generic — the caller decides why). */ + pulseIds?: Set; }) { const map = useMap(); const [tick, setTick] = useState(0); @@ -990,6 +1046,25 @@ function FanIncidentLayer({ return ( <> + {/* GEOMETRIC_CENTER pins (server-26#236): a dashed "around here" ring on + the ground, labelled with the place as it was spoken. */} + {plotted + .filter((p) => pinPrecision(p.inc) === "centre") + .map((p) => ( + + {p.inc.location && ( + + ≈ {p.inc.location} + + )} + + ))} {(Array.from(groups.entries()) as Array<[string, { id: string; lat: number; lng: number }[]]>).map(([repId, raw]) => { const members = raw.map((r) => incById.get(r.id)!).filter(Boolean); const repPlot = plotted.find((p: { id: string }) => p.id === repId); @@ -998,7 +1073,9 @@ function FanIncidentLayer({ 1 ? incidentFanIcon(members) : incidentIcon(repPlot.inc.type, repPlot.inc.severity)} + icon={members.length > 1 + ? incidentFanIcon(members, members.some((m) => pulseIds?.has(m.incident_id))) + : incidentIcon(repPlot.inc.type, repPlot.inc.severity, pinPrecision(repPlot.inc), !!pulseIds?.has(repPlot.inc.incident_id))} opacity={selectedId && !members.some((m) => m.incident_id === selectedId) ? 0.45 : 1} zIndexOffset={members.some((m) => m.incident_id === selectedId) ? 1000 : 0} eventHandlers={{ click: () => onSelect(repPlot.inc) }} @@ -1017,6 +1094,7 @@ function FanIncidentLayer({ {isKnownSeverity(inc.severity) ? SEVERITY_LABEL[inc.severity] : "Unknown"} · {inc.type ?? "other"}

{inc.location &&

{inc.location}

} +

Pin: {PIN_PRECISION_LABEL[pinPrecision(inc)]}

{ e.stopPropagation(); window.location.href = `/incidents/${inc.incident_id}`; e.preventDefault(); }} @@ -1043,6 +1121,53 @@ function FanIncidentLayer({ // its call timeline, so "where was he when he said that" is answered by // looking, not cross-referencing timestamps. Needs no backend — per-call // location_coords are already written by intelligence.py. +// +// server-26#236 — honest paths. Stops come from liveWall.pathStops(), the same +// helper the call spine numbers from: +// • only a movement incident (police/accident with a pursuit tag) draws a +// numbered path; anything else draws small unnumbered call dots, so a fire +// with mutual aid "responding from Station 3" never reads as a chase; +// • a segment over a long silence or across a talkgroup change is dashed; +// • a stop that implies > 8 km/min is ghosted and left off the path, with a +// faint dotted spur so the bad geocode is still visible; +// • a path longer than MAY_BE_MERGED_KM carries a "may be merged" badge. +function ghostStopIcon(color: string): L.DivIcon { + return L.divIcon({ + className: "", + html: ` + + ? + `, + iconSize: [16, 16], + iconAnchor: [8, 8], + }); +} + +function callDotIcon(color: string, playing: boolean): L.DivIcon { + const size = playing ? 18 : 10; + const c = size / 2; + return L.divIcon({ + className: "", + html: ` + ${playing ? `` : ""} + + `, + iconSize: [size, size], + iconAnchor: [c, c], + }); +} + +function mayBeMergedIcon(lengthKm: number): L.DivIcon { + const label = `may be merged · ${Math.round(lengthKm)} km`; + const w = 8 + label.length * 5.6; + return L.divIcon({ + className: "", + html: `
${label}
`, + iconSize: [w, 16], + iconAnchor: [w / 2, -10], + }); +} + function IncidentPathLayer({ incidents, callsByIncident, @@ -1052,50 +1177,95 @@ function IncidentPathLayer({ }) { // Only the playing id, so a timeupdate never re-renders the map. const playingCallId = useAudio((s) => s.playingCallId); + const paths = useMemo( + () => incidents.map((inc) => ({ inc, path: pathStops(inc, callsByIncident.get(inc.incident_id)) })), + [incidents, callsByIncident], + ); return ( <> - {incidents.map((inc) => { - const calls = (callsByIncident.get(inc.incident_id) ?? []) - .filter((c) => c.location_coords) - .slice() - .sort((a, b) => a.started_at.localeCompare(b.started_at)); - if (calls.length < 2) return null; + {paths.map(({ inc, path }) => { + if (path.stops.length < 2) return null; const color = severityColor(inc.severity); - const positions = calls.map((c) => [c.location_coords!.lat, c.location_coords!.lng] as [number, number]); + const kept = path.stops.filter((s) => !s.ghost); + const numbered = kept.length; + // Segments between consecutive kept stops — movement incidents only. + const segments = path.movement + ? kept.slice(1).map((s, i) => ({ from: kept[i].at, to: s.at, dashed: s.dashedIn, key: s.call.call_id })) + : []; + // A ghost's spur runs from the kept stop before it (or after it, for a ghosted first stop). + const spurs = path.stops.flatMap((s, i) => { + if (!s.ghost) return []; + const before = path.stops.slice(0, i).reverse().find((x) => !x.ghost); + const anchor = before ?? path.stops.slice(i + 1).find((x) => !x.ghost); + return anchor ? [{ from: anchor.at, to: s.at, key: s.call.call_id }] : []; + }); + const head = kept.at(-1) ?? path.stops.at(-1)!; return ( - - {calls.map((c, i) => ( - - -
-

Stop {i + 1} of {calls.length}

-

{inc.title ?? "Incident"}

- {c.location &&

{c.location}

} - {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. */} - -
- -
+ {segments.map((seg) => ( + ))} + {spurs.map((sp) => ( + + ))} + {path.stops.map((s) => { + const c = s.call; + const isPlaying = c.call_id === playingCallId; + const icon = s.ghost + ? ghostStopIcon(color) + : s.n != null + ? pathStopIcon(s.n, s.n === 1, s.n === numbered, color, isPlaying) + : callDotIcon(color, isPlaying); + const heading = s.ghost + ? "Off the path — this location implies an impossible speed" + : s.n != null ? `Stop ${s.n} of ${numbered}` : `Call at ${new Date(c.started_at).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}`; + return ( + + +
+

{heading}

+

{inc.title ?? "Incident"}

+ {c.location &&

{c.location}

} + {c.location_coords?.location_type === "GEOMETRIC_CENTER" && ( +

Approximate: centre of a road, junction or place

+ )} + {s.dashedIn && !s.ghost && path.movement && ( +

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() {

    {elapsedLabel(incident.started_at, active, incident.updated_at)} elapsed - {pathLengthKm > 0 && <> · {pathLengthKm.toFixed(1)} km path} + {path?.movement && path.lengthKm > 0 && <> · {path.lengthKm.toFixed(1)} km path} + {path?.mayBeMerged && ( + + {" · "}may be merged + + )} {" · "}{incident.call_ids.length} call{incident.call_ids.length !== 1 ? "s" : ""}

    diff --git a/drb-frontend/components/MapView.tsx b/drb-frontend/components/MapView.tsx index f0d7696..aae37ee 100644 --- a/drb-frontend/components/MapView.tsx +++ b/drb-frontend/components/MapView.tsx @@ -1658,7 +1658,7 @@ export default function MapView({
    - {p === "rooftop" ? "Street address" : p === "interpolated" ? "Interpolated" : p === "centre" ? "Road/place centre ≈" : "Unverified (older)"} + {p === "rooftop" ? "Street address / not recorded" : p === "interpolated" ? "Interpolated" : p === "centre" ? "Road/place centre ≈" : "Unverified (older)"}
    ))}
    -- 2.54.0