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%)`;
|
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 =
|
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 " +
|
"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";
|
"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
|
// Details dock at the right edge (bottom sheet on phones, above the incident
|
||||||
// trail stays visible and the map can be panned to follow it.
|
// 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 }) {
|
function AircraftPanel({ a, onClose }: { a: AircraftTrack; onClose: () => void }) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -184,7 +190,7 @@ function AircraftPanel({ a, onClose }: { a: AircraftTrack; onClose: () => void }
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
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="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="min-w-0">
|
||||||
@@ -242,12 +248,34 @@ function AircraftLayer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Vessel icon — node-26#9 second-SDR AIS overlay ─────────────────────────────
|
// ── 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 {
|
function vesselIcon(headingDeg: number | null): L.DivIcon {
|
||||||
const size = 14;
|
const size = 22;
|
||||||
const rotation = headingDeg ?? 0;
|
|
||||||
return L.divIcon({
|
return L.divIcon({
|
||||||
className: "",
|
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],
|
iconSize: [size, size],
|
||||||
iconAnchor: [size / 2, size / 2],
|
iconAnchor: [size / 2, size / 2],
|
||||||
});
|
});
|
||||||
@@ -259,17 +287,25 @@ function VesselLayer() {
|
|||||||
<>
|
<>
|
||||||
{vessels
|
{vessels
|
||||||
.filter((v) => v.lat != null && v.lon != null)
|
.filter((v) => v.lat != null && v.lon != null)
|
||||||
.map((v) => (
|
.map((v) => {
|
||||||
<Marker key={v.mmsi} position={[v.lat as number, v.lon as number]} icon={vesselIcon(v.heading_deg)}>
|
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}>
|
<Popup minWidth={160}>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="font-semibold">{v.name || v.mmsi}</div>
|
<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>
|
<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>}
|
{!aton && v.speed_kt != null && <div className="text-xs">Speed: {Math.round(v.speed_kt)} kt</div>}
|
||||||
</div>
|
</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
</Marker>
|
</Marker>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -643,6 +679,21 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
|||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [agoClock, setAgoClock] = useState(0);
|
const [agoClock, setAgoClock] = useState(0);
|
||||||
const [radarEpoch, setRadarEpoch] = useState(() => Date.now());
|
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(() => {
|
useEffect(() => {
|
||||||
const id = setInterval(() => setAgoClock((t: number) => t + 1), 10_000);
|
const id = setInterval(() => setAgoClock((t: number) => t + 1), 10_000);
|
||||||
@@ -833,6 +884,18 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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">
|
<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>
|
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Nodes</p>
|
||||||
{([
|
{([
|
||||||
|
|||||||
Reference in New Issue
Block a user