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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
6e462720a1
commit
6bf072973c
@@ -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
|
||||
|
||||
@@ -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 & 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" : 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 > {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 > {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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -34,18 +34,125 @@ export function groupCallsByIncident(calls: CallRecord[]): Map<string, CallRecor
|
||||
/**
|
||||
* An incident's geography, oldest first: its geocoded call stops (the path —
|
||||
* same order as the call spine) or, failing that, its pin. Empty = not plotted.
|
||||
* Ghosted stops (an implied speed no vehicle makes, server-26#236) are left
|
||||
* out, so one bad geocode can't drag "nearest cameras" across the county.
|
||||
*/
|
||||
export function incidentPoints(inc: IncidentRecord, calls: CallRecord[] | undefined): LatLon[] {
|
||||
const stops = (calls ?? [])
|
||||
.filter((c) => c.location_coords)
|
||||
.slice()
|
||||
.sort((a, b) => a.started_at.localeCompare(b.started_at))
|
||||
.map((c) => [c.location_coords!.lat, c.location_coords!.lng] as LatLon);
|
||||
const stops = pathStops(inc, calls).stops.filter((s) => !s.ghost).map((s) => s.at);
|
||||
if (stops.length >= 2) return stops;
|
||||
if (inc.location_coords) return [[inc.location_coords.lat, inc.location_coords.lng]];
|
||||
return stops;
|
||||
}
|
||||
|
||||
// ── Honest paths (server-26#236) ─────────────────────────────────────────────
|
||||
// Pure, and shared by MapView and the call spine (WallPanels), so a stop's
|
||||
// number is identical on both — and only what the map numbers is numbered.
|
||||
|
||||
/**
|
||||
* Mirrors `_PURSUIT_TAGS` in drb-c2-core/app/internal/incident_correlator.py.
|
||||
* Copied, not imported: keep the two in step by hand.
|
||||
*/
|
||||
export const PURSUIT_TAGS: ReadonlySet<string> = new Set([
|
||||
"vehicle-pursuit", "pursuit", "foot-pursuit", "chase",
|
||||
"fleeing-vehicle", "suspect-vehicle", "eluding",
|
||||
]);
|
||||
|
||||
/** Mirrors `_MAX_PURSUIT_SPEED_KM_PER_MIN` (incident_correlator.py) — ~300 km/h. */
|
||||
export const MAX_PATH_SPEED_KM_PER_MIN = 8.0;
|
||||
/**
|
||||
* Mirrors settings.location_proximity_km: the backend only runs its speed
|
||||
* check past this distance, so two geocodes of one scene a few seconds apart
|
||||
* are never called impossible.
|
||||
*/
|
||||
export const SPEED_CHECK_MIN_KM = 0.5;
|
||||
/** A segment spanning a longer silence than this is drawn dashed — we don't know the route. */
|
||||
export const PATH_GAP_DASH_MIN = 10;
|
||||
/** Mirrors `_PURSUIT_PROXIMITY_KM`: a path longer than a pursuit's own link radius is a merge suspect. */
|
||||
export const MAY_BE_MERGED_KM = 20;
|
||||
|
||||
/** Police or accident incident carrying a pursuit tag — the only kind that draws a numbered path. */
|
||||
export function isMovementIncident(inc: Pick<IncidentRecord, "type" | "tags">): boolean {
|
||||
if (inc.type !== "police" && inc.type !== "accident") return false;
|
||||
return (inc.tags ?? []).some((t) => PURSUIT_TAGS.has(t.toLowerCase()));
|
||||
}
|
||||
|
||||
export interface PathStop {
|
||||
call: CallRecord;
|
||||
at: LatLon;
|
||||
/** Shared map/spine stop number; null = not numbered (ghosted, or not a movement incident). */
|
||||
n: number | null;
|
||||
/** Reaching this stop from the previous kept one implies > MAX_PATH_SPEED_KM_PER_MIN. */
|
||||
ghost: boolean;
|
||||
/** The segment INTO this stop is dashed: a long silence or a talkgroup change. Never set on the first kept stop. */
|
||||
dashedIn: boolean;
|
||||
}
|
||||
|
||||
export interface IncidentPath {
|
||||
movement: boolean;
|
||||
/** Every geocoded call, oldest first — ghosts included, so the map can still show them faintly. */
|
||||
stops: PathStop[];
|
||||
/** Sum of the kept (non-ghost) segments. */
|
||||
lengthKm: number;
|
||||
mayBeMerged: boolean;
|
||||
}
|
||||
|
||||
function minutesBetween(a: CallRecord, b: CallRecord): number {
|
||||
return Math.abs(Date.parse(b.started_at) - Date.parse(a.started_at)) / 60_000;
|
||||
}
|
||||
|
||||
function impossible(a: PathStop, b: PathStop): boolean {
|
||||
const d = km(a.at, b.at);
|
||||
if (d <= SPEED_CHECK_MIN_KM) return false;
|
||||
// Same floor the backend uses (incident_correlator.py: max(idle, 0.1)).
|
||||
return d / Math.max(minutesBetween(a.call, b.call), 0.1) > MAX_PATH_SPEED_KM_PER_MIN;
|
||||
}
|
||||
|
||||
/**
|
||||
* An incident's geocoded calls as path stops, oldest first.
|
||||
*
|
||||
* Ghosting walks forward from the last kept stop: a stop that could only be
|
||||
* reached faster than a pursuit can drive is ghosted and does not move the
|
||||
* anchor. A bad FIRST geocode would otherwise ghost everything after it, so
|
||||
* when stop 1 is impossible from stop 0 but stop 2 agrees with stop 1 and not
|
||||
* with stop 0, stop 0 is the one ghosted.
|
||||
*/
|
||||
export function pathStops(inc: IncidentRecord, calls: CallRecord[] | undefined): IncidentPath {
|
||||
const movement = isMovementIncident(inc);
|
||||
const stops: PathStop[] = (calls ?? [])
|
||||
.filter((c) => c.location_coords)
|
||||
.slice()
|
||||
.sort((a, b) => a.started_at.localeCompare(b.started_at))
|
||||
.map((c) => ({ call: c, at: [c.location_coords!.lat, c.location_coords!.lng] as LatLon, n: null, ghost: false, dashedIn: false }));
|
||||
|
||||
let start = 0;
|
||||
if (stops.length >= 3 && impossible(stops[0], stops[1]) && !impossible(stops[1], stops[2]) && impossible(stops[0], stops[2])) {
|
||||
stops[0].ghost = true;
|
||||
start = 1;
|
||||
}
|
||||
let prev: PathStop | null = null;
|
||||
let lengthKm = 0;
|
||||
let n = 0;
|
||||
for (let i = start; i < stops.length; i++) {
|
||||
const s = stops[i];
|
||||
if (prev && impossible(prev, s)) { s.ghost = true; continue; }
|
||||
if (prev) {
|
||||
lengthKm += km(prev.at, s.at);
|
||||
s.dashedIn = minutesBetween(prev.call, s.call) > PATH_GAP_DASH_MIN
|
||||
|| (prev.call.talkgroup_id != null && s.call.talkgroup_id != null && prev.call.talkgroup_id !== s.call.talkgroup_id);
|
||||
}
|
||||
if (movement) s.n = ++n;
|
||||
prev = s;
|
||||
}
|
||||
return { movement, stops, lengthKm, mayBeMerged: lengthKm > MAY_BE_MERGED_KM };
|
||||
}
|
||||
|
||||
/** call_id → stop number, for the spine. Empty for a non-movement incident. */
|
||||
export function stopNumbers(path: IncidentPath): Map<string, number> {
|
||||
const m = new Map<string, number>();
|
||||
for (const s of path.stops) if (s.n != null) m.set(s.call.call_id, s.n);
|
||||
return m;
|
||||
}
|
||||
|
||||
/** Worst-first, then most recent (UI_REDESIGN.md §5.1). */
|
||||
export function worstFirst(incidents: IncidentRecord[]): IncidentRecord[] {
|
||||
return incidents
|
||||
|
||||
@@ -344,6 +344,20 @@ export interface TranscriptSegment {
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Geocoding precision, persisted with the coords since server-26#236.
|
||||
* ROOFTOP = a street address; RANGE_INTERPOLATED = estimated between two
|
||||
* address points; GEOMETRIC_CENTER = the centre of a road, intersection or
|
||||
* named place — can be hundreds of metres off. Absent on older docs.
|
||||
*/
|
||||
export type GeoLocationType = "ROOFTOP" | "RANGE_INTERPOLATED" | "GEOMETRIC_CENTER";
|
||||
|
||||
export interface GeoPoint {
|
||||
lat: number;
|
||||
lng: number;
|
||||
location_type?: GeoLocationType | string | null;
|
||||
}
|
||||
|
||||
export interface CallRecord {
|
||||
call_id: string;
|
||||
node_id: string;
|
||||
@@ -370,7 +384,7 @@ export interface CallRecord {
|
||||
incident_id?: string | null;
|
||||
location: string | null;
|
||||
/** Per-call geocode, written by intelligence.py. Powers the incident-path polyline on the map. */
|
||||
location_coords?: { lat: number; lng: number } | null;
|
||||
location_coords?: GeoPoint | null;
|
||||
/** Unit callsigns mentioned in this specific transmission (e.g. "15-9", "E-41"). */
|
||||
units?: string[];
|
||||
vehicles?: string[];
|
||||
@@ -403,7 +417,13 @@ export interface IncidentRecord {
|
||||
type: string | null;
|
||||
status: "active" | "resolved";
|
||||
location: string | null;
|
||||
location_coords: { lat: number; lng: number } | null;
|
||||
location_coords: GeoPoint | null;
|
||||
/**
|
||||
* The label the pin was geocoded from (server-26#23). Absent on incidents
|
||||
* written before that fix — their pin cannot be tied to their label and is
|
||||
* drawn ghosted (DEFERRED.md `_verified_pin` row, server-26#28).
|
||||
*/
|
||||
location_coords_source?: string | null;
|
||||
call_ids: string[];
|
||||
system_ids: string[];
|
||||
talkgroup_ids: string[];
|
||||
|
||||
Reference in New Issue
Block a user