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";
|
"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>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user