feat: physical heading for ultrabeam antennas
This commit is contained in:
@@ -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<number | null>(() => (
|
||||
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() {
|
||||
<RotorCompass
|
||||
bearing={dxPath?.bearingShort ?? null}
|
||||
headings={beamHeadings}
|
||||
boomHeading={boomHeading}
|
||||
pattern={ubPattern}
|
||||
centerLat={gridToLatLon(station.my_grid)?.lat ?? null}
|
||||
centerLon={gridToLatLon(station.my_grid)?.lon ?? null}
|
||||
rotorEnabled={rotatorHeading.enabled && rotatorHeading.ok}
|
||||
|
||||
@@ -60,12 +60,13 @@ interface WorldProps {
|
||||
toGrid: string; // contacted-station grid
|
||||
fromLabel?: string; // operator callsign
|
||||
toLabel?: string; // DX callsign
|
||||
beamAzimuths?: number[]; // antenna heading(s) (deg) → draw a beam lobe each
|
||||
beamAzimuths?: number[]; // radiating heading(s) (deg) → draw a beam lobe each
|
||||
beamWidth?: number; // beamwidth (deg), default 30
|
||||
boomAzimuth?: number | null; // mechanical boom (rotor) heading → grey reference line
|
||||
}
|
||||
|
||||
// WorldMap — great-circle path + beam lobe(s), the "map1" pane.
|
||||
export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, beamWidth }: WorldProps) {
|
||||
export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, beamWidth, boomAzimuth }: WorldProps) {
|
||||
const worldRef = useRef<HTMLDivElement>(null);
|
||||
const worldMap = useRef<L.Map | null>(null);
|
||||
const worldOverlay = useRef<L.LayerGroup | null>(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);
|
||||
|
||||
|
||||
@@ -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
|
||||
<span className={cn('size-2 rounded-full', rotorEnabled ? 'bg-emerald-500' : 'bg-muted-foreground/40')}
|
||||
title={rotorEnabled ? 'Rotator connected' : 'Rotator disabled'} />
|
||||
<div className="flex-1" />
|
||||
{pattern && (
|
||||
<span
|
||||
className={cn('px-1 py-px rounded text-[9px] font-bold tracking-wide',
|
||||
pattern === 'reverse' ? 'bg-amber-200 text-amber-900'
|
||||
: pattern === 'bi' ? 'bg-sky-200 text-sky-900'
|
||||
: 'bg-emerald-200 text-emerald-900')}
|
||||
title={pattern === 'reverse' ? 'Ultrabeam reversed — radiates opposite the boom'
|
||||
: pattern === 'bi' ? 'Ultrabeam bidirectional — radiates both ways'
|
||||
: 'Ultrabeam normal'}>
|
||||
{pattern === 'reverse' ? 'REV' : pattern === 'bi' ? 'BI' : 'NORM'}
|
||||
</span>
|
||||
)}
|
||||
<span className="font-mono text-sm font-bold text-emerald-700 tabular-nums">
|
||||
{headLabel != null ? `${Math.round(headLabel).toString().padStart(3, '0')}°` : '—'}
|
||||
</span>
|
||||
@@ -123,7 +137,21 @@ export function RotorCompass({ bearing, headings, centerLat, centerLon, rotorEna
|
||||
<circle cx={x} cy={y} r={3} fill="#dc2626" stroke="#fff" strokeWidth={1} />
|
||||
); })()}
|
||||
|
||||
{/* 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 (
|
||||
<g>
|
||||
<title>Boom (rotor) {Math.round(boomHeading)}°</title>
|
||||
<line x1={C} y1={C} x2={x} y2={y} stroke="#64748b" strokeWidth={2} strokeDasharray="3 3" strokeLinecap="round" />
|
||||
<circle cx={x} cy={y} r={3} fill="#64748b" stroke="#fff" strokeWidth={1} />
|
||||
</g>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* radiating heading needle(s) — green; two when bidirectional */}
|
||||
{headings.map((h, i) => { const [x, y] = pt(h, MAP_R - 2); return (
|
||||
<g key={i}>
|
||||
<line x1={C} y1={C} x2={x} y2={y} stroke="#15803d" strokeWidth={3} strokeLinecap="round" opacity={i === 0 ? 1 : 0.55} />
|
||||
|
||||
Reference in New Issue
Block a user