Honest pins and paths: geocode precision, type-gated paths, impossible-speed stops (#236) #274

Merged
logan merged 2 commits from feat/236-honest-pins into main 2026-10-05 03:33:21 -04:00
8 changed files with 512 additions and 109 deletions
+10 -3
View File
@@ -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}]"
@@ -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"
@@ -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
+13 -35
View File
@@ -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<string, number>();
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<string, number>()), [path]);
const newestFirst = useMemo(
() => calls.slice().sort((a, b) => b.started_at.localeCompare(a.started_at)),
@@ -130,7 +103,12 @@ export default function IncidentDetailPage() {
</h1>
<p className="text-xs text-ink-muted font-mono">
{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 && (
<span title={`The calls' locations span ${path.lengthKm.toFixed(0)} km (over ${MAY_BE_MERGED_KM} km) — this may be more than one event`}>
{" · "}may be merged
</span>
)}
{" · "}{incident.call_ids.length} call{incident.call_ids.length !== 1 ? "s" : ""}
</p>
</div>
+276 -53
View File
@@ -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<PinPrecision, string> = {
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 = `
<svg width="28" height="36" viewBox="0 0 28 36">
<path d="M14 1C6.8 1 1 6.8 1 14c0 9.5 13 21 13 21s13-11.5 13-21C27 6.8 21.2 1 14 1Z"
fill="${fill}" stroke="${stroke}" stroke-width="2"/>
<g transform="translate(7.5,7.5)">${typeGlyphSvg(type, glyphColor, 13)}</g>
const dash = unverified ? ` stroke-dasharray="3 2"` : "";
const tip =
precision === "interpolated" ? `<circle cx="16" cy="37" r="4.5" fill="none" stroke="${color}" stroke-width="1.75"/>`
: precision === "centre" ? `<circle cx="16" cy="37" r="8.5" fill="none" stroke="${color}" stroke-width="1.5" stroke-dasharray="2.5 2"/>`
: "";
return `<svg width="32" height="48" viewBox="0 0 32 48" style="overflow:visible${unverified ? ";opacity:0.55" : ""}">
${tip}
<path d="M16 3C8.8 3 3 8.8 3 16c0 9.5 13 21 13 21s13-11.5 13-21C29 8.8 23.2 3 16 3Z"
fill="${fill}" stroke="${stroke}" stroke-width="2"${dash}/>
<g transform="translate(9.5,9.5)">${typeGlyphSvg(type, glyphColor, 13)}</g>
</svg>`;
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
? `<div class="node-pulse-ring" style="position:absolute;width:26px;height:26px;border-radius:50%;border:2px solid ${severityColor(severity)};left:3px;top:3px;pointer-events:none;"></div>`
: "";
return L.divIcon({
className: "",
html: `<div style="position:relative;width:32px;height:48px">${ring}${teardropSvg(type, severity, precision)}</div>`,
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: `<div style="position:relative;width:${totalW}px;height:${CARD + 6}px">${cards}</div>`,
html: `<div style="position:relative;width:${totalW}px;height:${CARD + 6}px">${pulse
? `<div class="node-pulse-ring" style="position:absolute;width:${CARD + 4}px;height:${CARD + 4}px;border-radius:50%;border:2px solid var(--accent);left:${totalW / 2 - CARD / 2 - 2}px;top:-2px;pointer-events:none;"></div>`
: ""}${cards}</div>`,
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<string>;
}) {
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) => (
<Circle
key={`unc-${p.id}`}
center={[p.lat, p.lng]}
radius={CENTRE_UNCERTAINTY_M}
interactive={!!p.inc.location}
pathOptions={{ color: severityColor(p.inc.severity), weight: 1.5, dashArray: "4 4", fillOpacity: 0.06 }}
>
{p.inc.location && (
<Tooltip direction="bottom" permanent={p.id === selectedId} opacity={0.9}>
≈ {p.inc.location}
</Tooltip>
)}
</Circle>
))}
{(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({
<Marker
key={repId}
position={[repPlot.lat, repPlot.lng]}
icon={members.length > 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"}
</p>
{inc.location && <p className="text-xs text-gray-600">{inc.location}</p>}
<p className="text-[11px] text-gray-500">Pin: {PIN_PRECISION_LABEL[pinPrecision(inc)]}</p>
<a
href={`/incidents/${inc.incident_id}`}
onClick={(e) => { 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: `<svg width="16" height="16" viewBox="0 0 16 16" style="opacity:0.55">
<circle cx="8" cy="8" r="5.5" fill="var(--surface)" stroke="${color}" stroke-width="1.5" stroke-dasharray="2 2"/>
<text x="8" y="11" text-anchor="middle" font-size="8" font-weight="600" fill="${color}" font-family="var(--font-sans)">?</text>
</svg>`,
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: `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
${playing ? `<circle cx="${c}" cy="${c}" r="${c - 1.5}" fill="none" stroke="var(--accent)" stroke-width="2.5"/>` : ""}
<circle cx="${c}" cy="${c}" r="3.5" fill="${color}" stroke="var(--page)" stroke-width="1.25"/>
</svg>`,
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: `<div style="width:${w}px;font:600 10px var(--font-sans);color:var(--ink-2);background:var(--surface);border:1px dashed var(--ink-muted);border-radius:4px;padding:1px 4px;white-space:nowrap;text-align:center">${label}</div>`,
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 (
<FeatureGroup key={inc.incident_id}>
<Polyline
positions={positions}
pathOptions={{ color, weight: 3, opacity: 0.85, lineJoin: "round" }}
/>
{calls.map((c, i) => (
<Marker
key={c.call_id}
position={[c.location_coords!.lat, c.location_coords!.lng]}
icon={pathStopIcon(i + 1, i === 0, i === calls.length - 1, color, c.call_id === playingCallId)}
zIndexOffset={c.call_id === playingCallId ? 1000 : 0}
>
<Popup minWidth={160}>
<div className="text-gray-900">
<p className="text-xs text-gray-500">Stop {i + 1} of {calls.length}</p>
<p className="font-semibold text-sm mt-0.5">{inc.title ?? "Incident"}</p>
{c.location && <p className="text-xs text-gray-600 mt-0.5">{c.location}</p>}
{hasAudio(c) && (
<div className="flex items-center gap-1.5 mt-1">
<PlayButton callId={c.call_id} label={`Play stop ${i + 1}`} />
<span className="text-xs text-gray-600">Play this call</span>
</div>
)}
<a href={`/incidents/${inc.incident_id}`} className="text-xs text-blue-600 hover:underline block mt-1">
View incident →
</a>
{/* Gate A / A2 (server-26#46) — the stop location and its
ordering come from the transcript, not from GPS. */}
<MachineOutputNotice variant="popup" />
</div>
</Popup>
</Marker>
{segments.map((seg) => (
<Polyline
key={`seg-${seg.key}`}
positions={[seg.from, seg.to]}
pathOptions={{ color, weight: 3, opacity: seg.dashed ? 0.7 : 0.85, lineJoin: "round", dashArray: seg.dashed ? "6 7" : undefined }}
/>
))}
{spurs.map((sp) => (
<Polyline
key={`spur-${sp.key}`}
positions={[sp.from, sp.to]}
interactive={false}
pathOptions={{ color, weight: 1.5, opacity: 0.35, dashArray: "1 5", lineCap: "round" }}
/>
))}
{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 (
<Marker
key={c.call_id}
position={s.at}
icon={icon}
zIndexOffset={isPlaying ? 1000 : s.ghost ? -100 : 0}
>
<Popup minWidth={160}>
<div className="text-gray-900">
<p className="text-xs text-gray-500">{heading}</p>
<p className="font-semibold text-sm mt-0.5">{inc.title ?? "Incident"}</p>
{c.location && <p className="text-xs text-gray-600 mt-0.5">{c.location}</p>}
{c.location_coords?.location_type === "GEOMETRIC_CENTER" && (
<p className="text-[11px] text-gray-500">Approximate: centre of a road, junction or place</p>
)}
{s.dashedIn && !s.ghost && path.movement && (
<p className="text-[11px] text-gray-500">Dashed in: long gap or talkgroup change — route unknown</p>
)}
{hasAudio(c) && (
<div className="flex items-center gap-1.5 mt-1">
<PlayButton callId={c.call_id} label={s.n != null ? `Play stop ${s.n}` : "Play this call"} />
<span className="text-xs text-gray-600">Play this call</span>
</div>
)}
<a href={`/incidents/${inc.incident_id}`} className="text-xs text-blue-600 hover:underline block mt-1">
View incident →
</a>
{/* Gate A / A2 (server-26#46) — the stop location and its
ordering come from the transcript, not from GPS. */}
<MachineOutputNotice variant="popup" />
</div>
</Popup>
</Marker>
);
})}
{path.mayBeMerged && (
<Marker position={head.at} icon={mayBeMergedIcon(path.lengthKm)} interactive={false} keyboard={false} />
)}
</FeatureGroup>
);
})}
@@ -1139,6 +1309,11 @@ interface Props {
onAir?: Set<string>;
/** Cameras within range of each incident (rail "N cams"). */
camCountByIncident?: Map<string, number>;
/**
* Incident pins that get a pulsing ring. Generic on purpose — the caller
* decides what deserves attention (server-26#236; wired by #238).
*/
pulseIncidentIds?: Set<string>;
// ── 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<L.Map | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
@@ -1364,7 +1539,7 @@ export default function MapView({
{/* Overlay: Active Incidents */}
<LayersControl.Overlay checked={layerOn("inc")} name={LAYER_NAMES.inc}>
<FeatureGroup>
<FanIncidentLayer incidents={incidents} onSelect={handleIncidentSelect} selectedId={selectedIncidentId} />
<FanIncidentLayer incidents={incidents} onSelect={handleIncidentSelect} selectedId={selectedIncidentId} pulseIds={pulseIncidentIds} />
</FeatureGroup>
</LayersControl.Overlay>
@@ -1471,6 +1646,54 @@ export default function MapView({
</div>
))}
</div>
{/* 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). */}
<details className="border-t border-line pt-1.5 pointer-events-auto group">
<summary className="cursor-pointer list-none text-ink-muted font-medium text-[10px] uppercase tracking-wide select-none">
<span className="group-open:hidden">▸</span><span className="hidden group-open:inline">▾</span> Pin precision &amp; paths
</summary>
<div className="space-y-1 mt-1">
{(["rooftop", "interpolated", "centre", "unverified"] as PinPrecision[]).map((p) => (
<div key={p} className="flex items-center gap-2">
<span style={{ width: 16, height: 20, display: "inline-flex", justifyContent: "center", alignItems: "center" }}
dangerouslySetInnerHTML={{ __html: teardropSvg(null, "major", p).replace('width="32" height="48"', 'width="13" height="20"') }} />
<span className="text-ink-2">{p === "rooftop" ? "Street address / not recorded" : p === "interpolated" ? "Interpolated" : p === "centre" ? "Road/place centre ≈" : "Unverified (older)"}</span>
</div>
))}
</div>
<div className="pt-1 space-y-1">
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Paths</p>
<div className="flex items-center gap-2">
<span style={{ width: 16, display: "inline-flex", justifyContent: "center" }}>
<svg width="14" height="14" viewBox="0 0 14 14"><circle cx="7" cy="7" r="5.5" fill="var(--ink-2)" /><text x="7" y="10" textAnchor="middle" fontSize="8" fontWeight={600} fill="var(--page)">1</text></svg>
</span>
<span className="text-ink-2">Pursuit stop (numbered)</span>
</div>
<div className="flex items-center gap-2">
<span style={{ width: 16, display: "inline-flex", justifyContent: "center" }}>
<svg width="16" height="6" viewBox="0 0 16 6"><line x1="0" y1="3" x2="16" y2="3" stroke="var(--ink-2)" strokeWidth={2} strokeDasharray="4 3" /></svg>
</span>
<span className="text-ink-2">Gap &gt; {PATH_GAP_DASH_MIN} min or TG change</span>
</div>
<div className="flex items-center gap-2">
<span style={{ width: 16, display: "inline-flex", justifyContent: "center", opacity: 0.55 }}>
<svg width="12" height="12" viewBox="0 0 12 12"><circle cx="6" cy="6" r="4.5" fill="none" stroke="var(--ink-2)" strokeWidth={1.25} strokeDasharray="2 2" /></svg>
</span>
<span className="text-ink-2">Impossible speed — off path</span>
</div>
<div className="flex items-center gap-2">
<span style={{ width: 16, display: "inline-flex", justifyContent: "center" }}>
<svg width="8" height="8" viewBox="0 0 8 8"><circle cx="4" cy="4" r="3" fill="var(--ink-2)" /></svg>
</span>
<span className="text-ink-2">Call location (not a pursuit)</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[9px] px-0.5 rounded border border-dashed border-line text-ink-2 leading-tight">merged?</span>
<span className="text-ink-2">Path &gt; {MAY_BE_MERGED_KM} km — may be 2+ events</span>
</div>
</div>
</details>
{aircraftShown && (
<div className="border-t border-line pt-1.5 space-y-1">
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Aircraft altitude</p>
+21 -9
View File
@@ -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<string, number>();
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 && <span>{inc.location}</span>}
<span className="font-mono">{elapsed(inc.started_at)} elapsed</span>
<span>{inc.call_ids?.length ?? 0} calls</span>
{path.mayBeMerged && (
<span className="text-[10px] px-1 rounded border border-dashed border-line text-ink-2"
title={`The calls' locations span ${path.lengthKm.toFixed(0)} km (over ${MAY_BE_MERGED_KM} km) — this may be more than one event`}>
may be merged · {path.lengthKm.toFixed(0)} km
</span>
)}
</div>
<Link href={`/incidents/${inc.incident_id}`} className="text-xs text-accent hover:underline mt-1 inline-block">Open incident page →</Link>
</div>
@@ -441,6 +449,7 @@ export function IncidentPanel({
<ol className="space-y-2">
{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}>
<span className="shrink-0 w-5 h-5 rounded-full flex items-center justify-center font-mono text-[10px]"
style={stop ? { background: color, color: "var(--page)" } : { background: "var(--raised)", color: "var(--ink-muted)" }}>{stop ?? "·"}</span>
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 ? "?" : "·")}</span>
<div className="min-w-0 flex-1">
<div className="flex gap-1.5 items-center">
<span className="font-mono text-ink-muted">{hhmm(c.started_at)}</span>
+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[];