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:
co-authored by
Claude Opus 5.5
parent
5d97058f9d
commit
e5fc6ca838
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
FeatureGroup,
|
||||
LayersControl,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
TileLayer,
|
||||
Tooltip,
|
||||
useMap,
|
||||
useMapEvents,
|
||||
} from "react-leaflet";
|
||||
import L from "leaflet";
|
||||
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() {
|
||||
const map = useMap();
|
||||
const { aircraft } = useAircraft();
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const positioned = aircraft.filter((a) => a.lat != null && a.lon != null);
|
||||
const selectedTrack = positioned.find((a) => a.icao === selected);
|
||||
|
||||
// Clicking empty map deselects; marker clicks don't reach the map.
|
||||
useMapEvents({ click: () => setSelected(null) });
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedTrack && <AircraftTrail icao={selectedTrack.icao} current={selectedTrack} />}
|
||||
{selectedTrack &&
|
||||
createPortal(<AircraftPanel a={selectedTrack} onClose={() => setSelected(null)} />, map.getContainer())}
|
||||
{positioned.map((a) => (
|
||||
<Marker
|
||||
key={a.icao}
|
||||
position={[a.lat as number, a.lon as number]}
|
||||
icon={aircraftIcon(a.track_deg, a.altitude_ft, a.icao === selected)}
|
||||
zIndexOffset={a.icao === selected ? 1000 : 0}
|
||||
eventHandlers={{
|
||||
click: () => setSelected(a.icao),
|
||||
popupclose: () => setSelected((cur) => (cur === a.icao ? null : cur)),
|
||||
}}
|
||||
eventHandlers={{ click: () => setSelected((cur) => (cur === a.icao ? null : a.icao)) }}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -14]}>
|
||||
{a.callsign || a.icao}
|
||||
{a.altitude_ft != null && ` · ${Math.round(a.altitude_ft).toLocaleString()} ft`}
|
||||
</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>
|
||||
))}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user