From fa207e494d399914ad2c08d5e49d318103f7abe0 Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Sun, 27 Sep 2026 13:45:19 -0400 Subject: [PATCH] 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 --- drb-frontend/components/MapView.tsx | 97 ++++++++++++++++++++++++----- 1 file changed, 80 insertions(+), 17 deletions(-) diff --git a/drb-frontend/components/MapView.tsx b/drb-frontend/components/MapView.tsx index 4e9e326..9767414 100644 --- a/drb-frontend/components/MapView.tsx +++ b/drb-frontend/components/MapView.tsx @@ -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(null); useEffect(() => { @@ -184,7 +190,7 @@ function AircraftPanel({ a, onClose }: { a: AircraftTrack; onClose: () => void } return (
@@ -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: `
`, + html: + `
` + + `
`, + iconSize: [size, size], + iconAnchor: [size / 2, size / 2], + }); +} + +function aidToNavigationIcon(): L.DivIcon { + const size = 12; + return L.divIcon({ + className: "", + html: ``, 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) => ( - - -
-
{v.name || v.mmsi}
-
MMSI {v.mmsi}
- {v.speed_kt != null &&
Speed: {Math.round(v.speed_kt)} kt
} -
-
-
- ))} + .map((v) => { + const aton = isAidToNavigation(v.mmsi); + return ( + + +
+
{aton ? `Aid to navigation${v.name ? ` ${v.name}` : ""}` : v.name || v.mmsi}
+
MMSI {v.mmsi}
+ {!aton && v.speed_kt != null &&
Speed: {Math.round(v.speed_kt)} kt
} +
+
+
+ ); + })} ); } @@ -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 = []
))}
+ {aircraftShown && ( +
+

Aircraft altitude

+
`${altitudeColor(ft)} ${(i / (ALTITUDE_LEGEND_TICKS.length - 1)) * 100}%`).join(", ")})` }} + /> +
+ {ALTITUDE_LEGEND_TICKS.map(([ft, label]) => {label})} +
+
+ )}

Nodes

{([