map: altitude legend, buoys drawn as buoys, aircraft panel on phones
QA follow-ups on the ADS-B/AIS overlays: - Legend gains an aircraft altitude key (tar1090 ramp) while the Aircraft overlay is on (closes #185). - AIS aids to navigation (MMSI 99xxxxxxx) render as small yellow buoy diamonds labelled 'Aid to navigation', no speed; vessels get a 22px outlined hull, and heading 511/360 ('not available') no longer rotates the icon (closes #186). - Aircraft details panel becomes a bottom sheet above the incident drawer below md, instead of fighting the layers control at the top right. 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
b20196c499
commit
fa207e494d
@@ -119,6 +119,11 @@ function altitudeColor(altFt: number | null): string {
|
||||
return `hsl(${hue.toFixed(0)}, 88%, 48%)`;
|
||||
}
|
||||
|
||||
// Legend ticks for the altitude ramp, evenly spaced (the ramp itself isn't linear).
|
||||
const ALTITUDE_LEGEND_TICKS: [number, string][] = [
|
||||
[1000, "1k"], [4000, "4k"], [10000, "10k"], [20000, "20k"], [40000, "40k"],
|
||||
];
|
||||
|
||||
const AIRLINER_PATH =
|
||||
"M32 2 C34.2 2 35.2 5 35.2 8 L35.2 23 L61 37.5 L61 42 L35.2 35 L34.2 51 L42.5 57.5 L42.5 61 L32 58.5 " +
|
||||
"L21.5 61 L21.5 57.5 L29.8 51 L28.8 35 L3 42 L3 37.5 L28.8 23 L28.8 8 C28.8 5 29.8 2 32 2 Z";
|
||||
@@ -169,8 +174,9 @@ function Stat({ label, value }: { label: string; value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Details dock at the right edge (bottom sheet on phones, above the incident
|
||||
// drawer) 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(() => {
|
||||
@@ -184,7 +190,7 @@ function AircraftPanel({ a, onClose }: { a: AircraftTrack; onClose: () => void }
|
||||
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"
|
||||
className="absolute left-3 right-3 bottom-12 md:left-auto md:bottom-auto md:top-[5.5rem] md:right-3 md:w-60 z-[1002] 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">
|
||||
@@ -242,12 +248,34 @@ function AircraftLayer() {
|
||||
}
|
||||
|
||||
// ── Vessel icon — node-26#9 second-SDR AIS overlay ─────────────────────────────
|
||||
// MMSI 99xxxxxxx is an aid to navigation (buoy, beacon, light), not a vessel —
|
||||
// on the Hudson most of what a node hears is buoys (server-26#186).
|
||||
function isAidToNavigation(mmsi: string): boolean {
|
||||
return /^99\d{7}$/.test(mmsi);
|
||||
}
|
||||
|
||||
// AIS reports heading 511 (and course 360) for "not available".
|
||||
function aisHeading(deg: number | null): number | null {
|
||||
return deg == null || deg >= 360 ? null : deg;
|
||||
}
|
||||
|
||||
function vesselIcon(headingDeg: number | null): L.DivIcon {
|
||||
const size = 14;
|
||||
const rotation = headingDeg ?? 0;
|
||||
const size = 22;
|
||||
return L.divIcon({
|
||||
className: "",
|
||||
html: `<div style="width:${size}px;height:${size}px;transform:rotate(${rotation}deg)"><svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="var(--accent)" stroke="var(--surface)" stroke-width="1"><path d="M12 2 L18 14 L18 20 L6 20 L6 14 Z"/></svg></div>`,
|
||||
html:
|
||||
`<div style="width:${size}px;height:${size}px;transform:rotate(${aisHeading(headingDeg) ?? 0}deg);filter:drop-shadow(0 1px 1px rgba(0,0,0,.45))">` +
|
||||
`<svg width="${size}" height="${size}" viewBox="0 0 24 24"><path d="M12 2 L18 12 L18 21 L6 21 L6 12 Z" fill="hsl(190, 80%, 42%)" stroke="#000" stroke-width="1.25" stroke-linejoin="round"/></svg></div>`,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
});
|
||||
}
|
||||
|
||||
function aidToNavigationIcon(): L.DivIcon {
|
||||
const size = 12;
|
||||
return L.divIcon({
|
||||
className: "",
|
||||
html: `<svg width="${size}" height="${size}" viewBox="0 0 12 12"><rect x="2" y="2" width="8" height="8" transform="rotate(45 6 6)" fill="hsl(50, 95%, 55%)" stroke="#000" stroke-width="1"/></svg>`,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
});
|
||||
@@ -259,17 +287,25 @@ function VesselLayer() {
|
||||
<>
|
||||
{vessels
|
||||
.filter((v) => v.lat != null && v.lon != null)
|
||||
.map((v) => (
|
||||
<Marker key={v.mmsi} position={[v.lat as number, v.lon as number]} icon={vesselIcon(v.heading_deg)}>
|
||||
<Popup minWidth={160}>
|
||||
<div className="space-y-1">
|
||||
<div className="font-semibold">{v.name || v.mmsi}</div>
|
||||
<div className="text-xs text-ink-muted">MMSI {v.mmsi}</div>
|
||||
{v.speed_kt != null && <div className="text-xs">Speed: {Math.round(v.speed_kt)} kt</div>}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
.map((v) => {
|
||||
const aton = isAidToNavigation(v.mmsi);
|
||||
return (
|
||||
<Marker
|
||||
key={v.mmsi}
|
||||
position={[v.lat as number, v.lon as number]}
|
||||
icon={aton ? aidToNavigationIcon() : vesselIcon(v.heading_deg)}
|
||||
zIndexOffset={aton ? -100 : 0}
|
||||
>
|
||||
<Popup minWidth={160}>
|
||||
<div className="space-y-1">
|
||||
<div className="font-semibold">{aton ? `Aid to navigation${v.name ? ` ${v.name}` : ""}` : v.name || v.mmsi}</div>
|
||||
<div className="text-xs text-ink-muted">MMSI {v.mmsi}</div>
|
||||
{!aton && v.speed_kt != null && <div className="text-xs">Speed: {Math.round(v.speed_kt)} kt</div>}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -643,6 +679,21 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [agoClock, setAgoClock] = useState(0);
|
||||
const [radarEpoch, setRadarEpoch] = useState(() => Date.now());
|
||||
const [aircraftShown, setAircraftShown] = useState(false);
|
||||
|
||||
// The altitude key only belongs in the legend while the opt-in Aircraft
|
||||
// overlay is actually on (server-26#185).
|
||||
useEffect(() => {
|
||||
if (!mapInstance) return;
|
||||
const on = (e: L.LayersControlEvent) => e.name === "Aircraft" && setAircraftShown(true);
|
||||
const off = (e: L.LayersControlEvent) => e.name === "Aircraft" && setAircraftShown(false);
|
||||
mapInstance.on("overlayadd", on);
|
||||
mapInstance.on("overlayremove", off);
|
||||
return () => {
|
||||
mapInstance.off("overlayadd", on);
|
||||
mapInstance.off("overlayremove", off);
|
||||
};
|
||||
}, [mapInstance]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setAgoClock((t: number) => t + 1), 10_000);
|
||||
@@ -833,6 +884,18 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{aircraftShown && (
|
||||
<div className="border-t border-line pt-1.5 space-y-1">
|
||||
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Aircraft altitude</p>
|
||||
<div
|
||||
className="h-2 w-32 rounded-sm border border-line"
|
||||
style={{ background: `linear-gradient(to right, ${ALTITUDE_LEGEND_TICKS.map(([ft], i) => `${altitudeColor(ft)} ${(i / (ALTITUDE_LEGEND_TICKS.length - 1)) * 100}%`).join(", ")})` }}
|
||||
/>
|
||||
<div className="flex justify-between w-32 text-[10px] text-ink-2 tabular-nums">
|
||||
{ALTITUDE_LEGEND_TICKS.map(([ft, label]) => <span key={ft}>{label}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t border-line pt-1.5 space-y-1">
|
||||
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Nodes</p>
|
||||
{([
|
||||
|
||||
Reference in New Issue
Block a user