map: aircraft details dock at the right edge instead of a popup

The popup sat on top of the plane and hid the trail it had just drawn.
Details now render in a panel docked top-right (portaled into the
Leaflet container, click/scroll propagation disabled), so the map can
be panned to follow the path. Clicking empty map or the same plane
deselects; hover tooltip unchanged.

Verified: tsc --noEmit clean (node:20 on radio-box).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-09-27 13:32:24 -04:00
co-authored by Claude Opus 5.5
parent 5d97058f9d
commit e5fc6ca838
+58 -22
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { import {
FeatureGroup, FeatureGroup,
LayersControl, LayersControl,
@@ -11,6 +12,7 @@ import {
TileLayer, TileLayer,
Tooltip, Tooltip,
useMap, useMap,
useMapEvents,
} from "react-leaflet"; } from "react-leaflet";
import L from "leaflet"; import L from "leaflet";
import type { AircraftTrack, CallRecord, IncidentRecord, NodeRecord, NodeStatus } from "@/lib/types"; import type { AircraftTrack, CallRecord, IncidentRecord, NodeRecord, NodeStatus } from "@/lib/types";
@@ -158,47 +160,81 @@ function AircraftTrail({ icao, current }: { icao: string; current: AircraftTrack
); );
} }
function Stat({ label, value }: { label: string; value: string }) {
return (
<div>
<div className="text-[10px] uppercase tracking-wide text-ink-muted">{label}</div>
<div className="text-ink font-medium tabular-nums">{value}</div>
</div>
);
}
// Details dock at the right edge instead of a popup over the plane, so the
// trail stays visible and the map can be panned to follow it.
function AircraftPanel({ a, onClose }: { a: AircraftTrack; onClose: () => void }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
// The panel is portaled into the Leaflet container, whose native listeners
// would otherwise treat clicks/scrolls here as map clicks (deselect) or zoom.
if (!ref.current) return;
L.DomEvent.disableClickPropagation(ref.current);
L.DomEvent.disableScrollPropagation(ref.current);
}, []);
const fmt = (n: number | null, unit: string) => (n == null ? "—" : `${Math.round(n).toLocaleString()} ${unit}`);
return (
<div
ref={ref}
className="absolute top-[5.5rem] right-3 z-[1001] w-60 bg-surface/95 backdrop-blur-sm border border-line rounded-lg shadow-lg text-xs"
>
<div className="flex items-start justify-between gap-2 px-3 pt-2.5 pb-2 border-b border-line">
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="inline-block w-2.5 h-2.5 rounded-full shrink-0" style={{ background: altitudeColor(a.altitude_ft) }} />
<span className="text-ink font-semibold text-sm truncate">{a.callsign || a.icao}</span>
</div>
<div className="text-ink-muted mt-0.5">ICAO {a.icao}</div>
</div>
<button onClick={onClose} aria-label="Close aircraft details" className="text-ink-muted hover:text-ink px-1 leading-none text-base">
×
</button>
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-2 px-3 py-2.5">
<Stat label="Altitude" value={fmt(a.altitude_ft, "ft")} />
<Stat label="Speed" value={fmt(a.ground_speed_kt, "kt")} />
<Stat label="Heading" value={a.track_deg == null ? "—" : `${Math.round(a.track_deg)}°`} />
<Stat label="Last heard" value={timeAgo(new Date(a.last_seen))} />
</div>
</div>
);
}
function AircraftLayer() { function AircraftLayer() {
const map = useMap();
const { aircraft } = useAircraft(); const { aircraft } = useAircraft();
const [selected, setSelected] = useState<string | null>(null); const [selected, setSelected] = useState<string | null>(null);
const positioned = aircraft.filter((a) => a.lat != null && a.lon != null); const positioned = aircraft.filter((a) => a.lat != null && a.lon != null);
const selectedTrack = positioned.find((a) => a.icao === selected); const selectedTrack = positioned.find((a) => a.icao === selected);
// Clicking empty map deselects; marker clicks don't reach the map.
useMapEvents({ click: () => setSelected(null) });
return ( return (
<> <>
{selectedTrack && <AircraftTrail icao={selectedTrack.icao} current={selectedTrack} />} {selectedTrack && <AircraftTrail icao={selectedTrack.icao} current={selectedTrack} />}
{selectedTrack &&
createPortal(<AircraftPanel a={selectedTrack} onClose={() => setSelected(null)} />, map.getContainer())}
{positioned.map((a) => ( {positioned.map((a) => (
<Marker <Marker
key={a.icao} key={a.icao}
position={[a.lat as number, a.lon as number]} position={[a.lat as number, a.lon as number]}
icon={aircraftIcon(a.track_deg, a.altitude_ft, a.icao === selected)} icon={aircraftIcon(a.track_deg, a.altitude_ft, a.icao === selected)}
zIndexOffset={a.icao === selected ? 1000 : 0} zIndexOffset={a.icao === selected ? 1000 : 0}
eventHandlers={{ eventHandlers={{ click: () => setSelected((cur) => (cur === a.icao ? null : a.icao)) }}
click: () => setSelected(a.icao),
popupclose: () => setSelected((cur) => (cur === a.icao ? null : cur)),
}}
> >
<Tooltip direction="top" offset={[0, -14]}> <Tooltip direction="top" offset={[0, -14]}>
{a.callsign || a.icao} {a.callsign || a.icao}
{a.altitude_ft != null && ` · ${Math.round(a.altitude_ft).toLocaleString()} ft`} {a.altitude_ft != null && ` · ${Math.round(a.altitude_ft).toLocaleString()} ft`}
</Tooltip> </Tooltip>
<Popup minWidth={160}>
<div className="space-y-1">
<div className="font-semibold">{a.callsign || a.icao}</div>
<div className="text-xs text-ink-muted">ICAO {a.icao}</div>
{a.altitude_ft != null && (
<div className="text-xs">
<span
className="inline-block w-2 h-2 rounded-full mr-1 align-middle"
style={{ background: altitudeColor(a.altitude_ft) }}
/>
Altitude: {Math.round(a.altitude_ft).toLocaleString()} ft
</div>
)}
{a.ground_speed_kt != null && <div className="text-xs">Speed: {Math.round(a.ground_speed_kt)} kt</div>}
{a.track_deg != null && <div className="text-xs">Heading: {Math.round(a.track_deg)}°</div>}
</div>
</Popup>
</Marker> </Marker>
))} ))}
</> </>