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
{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: `Stop {i + 1} of {calls.length}
-{inc.title ?? "Incident"}
- {c.location &&{c.location}
} - {hasAudio(c) && ( -{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) && ( +Paths
+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