Incident page spine: number stops from pathStops, not its own index (#236)
The /incidents/[id] page kept its own stop numbering (every geocoded call, 1..N) and path length, while its MapView now numbers only movement incidents' non-ghosted stops. A fire with 3 geocoded calls showed stops 1-3 on the spine and unnumbered dots on the map. Use the shared pathStops()/stopNumbers(), show 'km path' only for movement incidents, and surface the 'may be merged' flag in the header. Legend: a plain pin is also what an older (pre-#236) pin with no recorded precision looks like; say so instead of claiming 'Street address'. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
6bf072973c
commit
e1bd3feac5
@@ -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>
|
||||
|
||||
@@ -1658,7 +1658,7 @@ export default function MapView({
|
||||
<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>
|
||||
<span className="text-ink-2">{p === "rooftop" ? "Street address / not recorded" : p === "interpolated" ? "Interpolated" : p === "centre" ? "Road/place centre ≈" : "Unverified (older)"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user