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 { isKnownSeverity } from "@/lib/severity";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
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 MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
|
||||||
|
|
||||||
const EARLIER_PAGE_SIZE = 8;
|
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 {
|
function elapsedLabel(startedAt: string, active: boolean, updatedAt: string): string {
|
||||||
const start = new Date(startedAt).getTime();
|
const start = new Date(startedAt).getTime();
|
||||||
const end = active ? Date.now() : new Date(updatedAt).getTime();
|
const end = active ? Date.now() : new Date(updatedAt).getTime();
|
||||||
@@ -51,29 +41,12 @@ export default function IncidentDetailPage() {
|
|||||||
const [resolving, setResolving] = useState(false);
|
const [resolving, setResolving] = useState(false);
|
||||||
const [earlierShown, setEarlierShown] = useState(EARLIER_PAGE_SIZE);
|
const [earlierShown, setEarlierShown] = useState(EARLIER_PAGE_SIZE);
|
||||||
|
|
||||||
// Same ordering/filtering MapView's IncidentPathLayer uses, so the stop
|
// Stop numbers and path length come from the same liveWall.pathStops() the
|
||||||
// number shown on a spine entry matches the number on its map marker.
|
// map's IncidentPathLayer draws from (server-26#236), so the spine numbers
|
||||||
const geocodedCalls = useMemo(
|
// exactly what the map numbers: only a movement incident (police/accident
|
||||||
() =>
|
// with a pursuit tag) is numbered, and an impossible-speed stop never is.
|
||||||
calls
|
const path = useMemo(() => (incident ? pathStops(incident, calls) : null), [incident, calls]);
|
||||||
.filter((c): c is CallRecord & { location_coords: { lat: number; lng: number } } => !!c.location_coords)
|
const stopNumberByCallId = useMemo(() => (path ? stopNumbers(path) : new Map<string, number>()), [path]);
|
||||||
.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]);
|
|
||||||
|
|
||||||
const newestFirst = useMemo(
|
const newestFirst = useMemo(
|
||||||
() => calls.slice().sort((a, b) => b.started_at.localeCompare(a.started_at)),
|
() => calls.slice().sort((a, b) => b.started_at.localeCompare(a.started_at)),
|
||||||
@@ -130,7 +103,12 @@ export default function IncidentDetailPage() {
|
|||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-ink-muted font-mono">
|
<p className="text-xs text-ink-muted font-mono">
|
||||||
{elapsedLabel(incident.started_at, active, incident.updated_at)} elapsed
|
{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" : ""}
|
{" · "}{incident.call_ids.length} call{incident.call_ids.length !== 1 ? "s" : ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1658,7 +1658,7 @@ export default function MapView({
|
|||||||
<div key={p} className="flex items-center gap-2">
|
<div key={p} className="flex items-center gap-2">
|
||||||
<span style={{ width: 16, height: 20, display: "inline-flex", justifyContent: "center", alignItems: "center" }}
|
<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"') }} />
|
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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user