Live map follow-ups: stable pin icons, resolved IncidentPanel, filtered rail, weather vs camera dock
- #279: cache incident/fan divIcons by their visual inputs and React.memo MapView, so LiveView's 1s clock no longer rebuilds icons / calls setIcon and the #238 pulse ring completes its cycle. - #275: IncidentPanel shows "resolved", "lasted X" frozen at resolved_at, and labels live cameras/context as current, not incident-time. - #268: rail header (desktop + mobile drawer) reads "N of M · filtered" via a new filteredFrom prop while the wall filter is on. - #278: while camera tiles are docked bottom-centre, the weather status chip and radar badge stack under the tiles inside the dock instead of being covered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
3c5496acb1
commit
9698d463ad
@@ -32,8 +32,10 @@ function sevColor(s: string | null | undefined) {
|
||||
function sevLabel(s: string | null | undefined) {
|
||||
return isKnownSeverity(s) ? SEVERITY_LABEL[s] : "Unknown";
|
||||
}
|
||||
function elapsed(iso: string): string {
|
||||
const m = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 60_000));
|
||||
/** Minutes from `iso` to `endIso` (or now), as "42m" / "1h 5m". */
|
||||
function elapsed(iso: string, endIso?: string | null): string {
|
||||
const end = endIso ? new Date(endIso).getTime() : Date.now();
|
||||
const m = Math.max(0, Math.round((end - new Date(iso).getTime()) / 60_000));
|
||||
return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`;
|
||||
}
|
||||
function hhmm(iso: string): string {
|
||||
@@ -422,6 +424,13 @@ export function IncidentPanel({
|
||||
onOpenCamera: (c: Camera) => void;
|
||||
}) {
|
||||
const color = sevColor(inc.severity);
|
||||
// A resolved incident opened from an #inc= link (server-26#275): nothing in
|
||||
// the panel may read as still running. Its duration freezes at resolved_at
|
||||
// (the correlator writes it; the frontend type doesn't declare it yet), and
|
||||
// the live camera / context feeds are labelled as "now", not incident time.
|
||||
const resolved = inc.status === "resolved";
|
||||
const resolvedAt = (inc as IncidentRecord & { resolved_at?: string | null }).resolved_at ?? null;
|
||||
const resolvedAtOk = !!resolvedAt && !Number.isNaN(new Date(resolvedAt).getTime());
|
||||
const ctx = useMemo(() => contextFor(points, aircraft, vessels, events), [points, aircraft, vessels, events]);
|
||||
const head = points.at(-1) ?? null;
|
||||
const radar = useRadarDbz(head);
|
||||
@@ -455,13 +464,17 @@ export function IncidentPanel({
|
||||
<div className="flex items-center gap-2 text-[11px] mb-1">
|
||||
<span className="font-semibold uppercase tracking-wide" style={{ color }}>{sevLabel(inc.severity)}</span>
|
||||
<span className="text-ink-muted">{inc.type ?? "other"}</span>
|
||||
{onAir ? <span className="bg-accent text-white rounded px-1 text-[9px] font-semibold">ON AIR</span> : <span className="text-ink-muted">active</span>}
|
||||
{resolved ? <span className="text-ink-muted">resolved</span>
|
||||
: onAir ? <span className="bg-accent text-white rounded px-1 text-[9px] font-semibold">ON AIR</span>
|
||||
: <span className="text-ink-muted">active</span>}
|
||||
<span className="ml-auto font-mono text-ink-muted">{inc.incident_id.slice(0, 8)}</span>
|
||||
</div>
|
||||
<h2 className="text-base font-semibold text-ink leading-snug">{inc.title ?? "Incident"}</h2>
|
||||
<div className="text-xs text-ink-muted mt-1 flex flex-wrap gap-x-3">
|
||||
{inc.location && <span>{inc.location}</span>}
|
||||
<span className="font-mono">{elapsed(inc.started_at)} elapsed</span>
|
||||
{!resolved ? <span className="font-mono">{elapsed(inc.started_at)} elapsed</span>
|
||||
: resolvedAtOk ? <span className="font-mono">lasted {elapsed(inc.started_at, resolvedAt)}</span>
|
||||
: null}
|
||||
<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"
|
||||
@@ -480,6 +493,7 @@ export function IncidentPanel({
|
||||
|
||||
<div className={SEC}>
|
||||
<h3 className={H3}>Nearest cameras · auto-surfaced{points.length > 1 ? " (weighted to the path's head)" : ""}</h3>
|
||||
{resolved && <p className="text-[10px] text-ink-muted mb-1.5">Live views as of now, not from when this incident was active.</p>}
|
||||
{near.length ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{near.map(({ cam, km }) => (
|
||||
@@ -498,6 +512,7 @@ export function IncidentPanel({
|
||||
|
||||
<div className={SEC}>
|
||||
<h3 className={H3}>Situational context · fused layers</h3>
|
||||
{resolved && <p className="text-[10px] text-ink-muted mb-1.5">Current conditions near the location, not at incident time.</p>}
|
||||
<ul className="space-y-1 text-xs text-ink-2">
|
||||
{ctx.aircraft.map(({ a, km }) => (
|
||||
<li key={a.icao}><span className="font-mono text-[10px] text-ink-muted mr-1.5">ADS-B</span>
|
||||
@@ -533,7 +548,7 @@ export function IncidentPanel({
|
||||
|
||||
<div className={`${SEC} grid grid-cols-2 gap-3`}>
|
||||
<div>
|
||||
<h3 className={H3}>On scene</h3>
|
||||
<h3 className={H3}>{resolved ? "Last on scene" : "On scene"}</h3>
|
||||
<div className="flex flex-wrap gap-1">{unitsOn.length ? unitsOn.map((u) => <span key={u} className="font-mono text-[10px] px-1 rounded bg-raised text-ink-2">{u}</span>) : <span className="text-xs text-ink-muted">—</span>}</div>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user