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:
Logan Cusano
2026-10-05 03:28:00 -04:00
co-authored by Claude Opus 5.5
parent 6e462720a1
commit 6bf072973c
7 changed files with 499 additions and 74 deletions
+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" : 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>