From 16dc864dbdb1757cd8b30d3da40433055300f7bf Mon Sep 17 00:00:00 2001 From: Gregory Salaun Date: Tue, 16 Jun 2026 21:25:04 +0200 Subject: [PATCH] feat: physical heading for ultrabeam antennas --- frontend/src/App.tsx | 14 ++++++++++ frontend/src/components/MainMap.tsx | 22 ++++++++++++--- frontend/src/components/RotorCompass.tsx | 34 +++++++++++++++++++++--- 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 61b8b6a..0d3690c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -809,6 +809,17 @@ export default function App() { return [base]; }, [rotatorHeading.enabled, rotatorHeading.ok, rotatorHeading.azimuth, ubStatus.enabled, ubStatus.connected, ubStatus.direction]); + // Mechanical boom (rotor) heading + Ultrabeam pattern — so the compass/map can + // show where the antenna physically points (boom) vs where it radiates when + // the Ultrabeam is reversed/bidirectional. + const boomHeading = useMemo(() => ( + rotatorHeading.enabled && rotatorHeading.ok ? ((rotatorHeading.azimuth % 360) + 360) % 360 : null + ), [rotatorHeading.enabled, rotatorHeading.ok, rotatorHeading.azimuth]); + const ubPattern = useMemo<'normal' | 'reverse' | 'bi' | null>(() => { + if (!(ubStatus.enabled && ubStatus.connected)) return null; + return ubStatus.direction === 1 ? 'reverse' : ubStatus.direction === 2 ? 'bi' : 'normal'; + }, [ubStatus.enabled, ubStatus.connected, ubStatus.direction]); + // Portable UI toggles (mirrored to the DB via writeUiPref / syncPortablePrefs). const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0'); const [showBeamOnMap, setShowBeamOnMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0'); @@ -2475,6 +2486,7 @@ export default function App() { fromLabel={station.callsign} toLabel={callsign} beamAzimuths={showBeamOnMap ? beamHeadings : []} + boomAzimuth={showBeamOnMap && ubPattern && ubPattern !== 'normal' ? boomHeading : null} /> ); case 'map2': @@ -2986,6 +2998,8 @@ export default function App() { (null); const worldMap = useRef(null); const worldOverlay = useRef(null); @@ -146,6 +147,21 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b } } + // Mechanical boom (rotor) direction — thin grey dashed line. Drawn when the + // Ultrabeam radiates elsewhere (reverse/bi) so the boom heading stays visible + // next to the red radiating lobe(s). + if (boomAzimuth != null) { + const bpts: [number, number][] = [[from.lat, from.lon]]; + const N = 64, D = 5500; + for (let i = 1; i <= N; i++) { + const d = destinationPoint(from.lat, from.lon, boomAzimuth, (D * i) / N); + bpts.push([d.lat, d.lon]); + if (Math.abs(d.lat) > 86) break; + } + L.polyline(unwrapLon(bpts) as L.LatLngExpression[], { color: '#64748b', weight: 1.5, opacity: 0.85, dashArray: '3 4' }) + .bindTooltip(`Boom ${Math.round(boomAzimuth)}°`, { permanent: false, direction: 'top' }).addTo(wo); + } + if (autoZoom) { const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]); pts.forEach((p) => bounds.extend(p as L.LatLngExpression)); @@ -158,7 +174,7 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b } setTimeout(() => { wm.invalidateSize(); }, 0); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, autoZoom]); + }, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, boomAzimuth, autoZoom]); const path = pathBetween(fromGrid, toGrid); diff --git a/frontend/src/components/RotorCompass.tsx b/frontend/src/components/RotorCompass.tsx index 7fa2119..507728e 100644 --- a/frontend/src/components/RotorCompass.tsx +++ b/frontend/src/components/RotorCompass.tsx @@ -18,7 +18,9 @@ const GRATICULE = geoGraticule10(); interface Props { bearing?: number | null; // short-path azimuth to DX (deg) - headings: number[]; // antenna heading(s) — rotor + Ultrabeam pattern + headings: number[]; // radiating heading(s) — rotor + Ultrabeam pattern + boomHeading?: number | null; // mechanical boom (rotor) azimuth, shown grey when it differs + pattern?: 'normal' | 'reverse' | 'bi' | null; // Ultrabeam pattern (for the badge) centerLat?: number | null; // operator latitude (projection centre) centerLon?: number | null; // operator longitude rotorEnabled?: boolean; @@ -36,7 +38,7 @@ function pt(az: number, radius: number): [number, number] { return [C + radius * Math.cos(a), C + radius * Math.sin(a)]; } -export function RotorCompass({ bearing, headings, centerLat, centerLon, rotorEnabled, onGoto, onClose }: Props) { +export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, onGoto, onClose }: Props) { const cardinals = useMemo( () => [ { d: 0, l: 'N' }, { d: 45, l: 'NE' }, { d: 90, l: 'E' }, { d: 135, l: 'SE' }, { d: 180, l: 'S' }, { d: 225, l: 'SW' }, { d: 270, l: 'W' }, { d: 315, l: 'NW' } ], @@ -76,6 +78,18 @@ export function RotorCompass({ bearing, headings, centerLat, centerLon, rotorEna
+ {pattern && ( + + {pattern === 'reverse' ? 'REV' : pattern === 'bi' ? 'BI' : 'NORM'} + + )} {headLabel != null ? `${Math.round(headLabel).toString().padStart(3, '0')}°` : '—'} @@ -123,7 +137,21 @@ export function RotorCompass({ bearing, headings, centerLat, centerLon, rotorEna ); })()} - {/* antenna heading needle(s) — green; two when bidirectional */} + {/* mechanical boom (rotor) heading — grey dashed needle, shown when the + Ultrabeam radiates somewhere other than the boom (reverse/bi) so the + operator sees where the antenna physically points vs where it boom-sits */} + {boomHeading != null && pattern && pattern !== 'normal' && (() => { + const [x, y] = pt(boomHeading, MAP_R - 2); + return ( + + Boom (rotor) {Math.round(boomHeading)}° + + + + ); + })()} + + {/* radiating heading needle(s) — green; two when bidirectional */} {headings.map((h, i) => { const [x, y] = pt(h, MAP_R - 2); return (