feat: physical heading for ultrabeam antennas
This commit is contained in:
@@ -809,6 +809,17 @@ export default function App() {
|
|||||||
return [base];
|
return [base];
|
||||||
}, [rotatorHeading.enabled, rotatorHeading.ok, rotatorHeading.azimuth, ubStatus.enabled, ubStatus.connected, ubStatus.direction]);
|
}, [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).
|
// Portable UI toggles (mirrored to the DB via writeUiPref / syncPortablePrefs).
|
||||||
const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0');
|
const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0');
|
||||||
const [showBeamOnMap, setShowBeamOnMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
|
const [showBeamOnMap, setShowBeamOnMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
|
||||||
@@ -2475,6 +2486,7 @@ export default function App() {
|
|||||||
fromLabel={station.callsign}
|
fromLabel={station.callsign}
|
||||||
toLabel={callsign}
|
toLabel={callsign}
|
||||||
beamAzimuths={showBeamOnMap ? beamHeadings : []}
|
beamAzimuths={showBeamOnMap ? beamHeadings : []}
|
||||||
|
boomAzimuth={showBeamOnMap && ubPattern && ubPattern !== 'normal' ? boomHeading : null}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 'map2':
|
case 'map2':
|
||||||
@@ -2986,6 +2998,8 @@ export default function App() {
|
|||||||
<RotorCompass
|
<RotorCompass
|
||||||
bearing={dxPath?.bearingShort ?? null}
|
bearing={dxPath?.bearingShort ?? null}
|
||||||
headings={beamHeadings}
|
headings={beamHeadings}
|
||||||
|
boomHeading={boomHeading}
|
||||||
|
pattern={ubPattern}
|
||||||
centerLat={gridToLatLon(station.my_grid)?.lat ?? null}
|
centerLat={gridToLatLon(station.my_grid)?.lat ?? null}
|
||||||
centerLon={gridToLatLon(station.my_grid)?.lon ?? null}
|
centerLon={gridToLatLon(station.my_grid)?.lon ?? null}
|
||||||
rotorEnabled={rotatorHeading.enabled && rotatorHeading.ok}
|
rotorEnabled={rotatorHeading.enabled && rotatorHeading.ok}
|
||||||
|
|||||||
@@ -60,12 +60,13 @@ interface WorldProps {
|
|||||||
toGrid: string; // contacted-station grid
|
toGrid: string; // contacted-station grid
|
||||||
fromLabel?: string; // operator callsign
|
fromLabel?: string; // operator callsign
|
||||||
toLabel?: string; // DX 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
|
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.
|
// 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 worldRef = useRef<HTMLDivElement>(null);
|
||||||
const worldMap = useRef<L.Map | null>(null);
|
const worldMap = useRef<L.Map | null>(null);
|
||||||
const worldOverlay = useRef<L.LayerGroup | 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) {
|
if (autoZoom) {
|
||||||
const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]);
|
const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]);
|
||||||
pts.forEach((p) => bounds.extend(p as L.LatLngExpression));
|
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);
|
setTimeout(() => { wm.invalidateSize(); }, 0);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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);
|
const path = pathBetween(fromGrid, toGrid);
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ const GRATICULE = geoGraticule10();
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
bearing?: number | null; // short-path azimuth to DX (deg)
|
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)
|
centerLat?: number | null; // operator latitude (projection centre)
|
||||||
centerLon?: number | null; // operator longitude
|
centerLon?: number | null; // operator longitude
|
||||||
rotorEnabled?: boolean;
|
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)];
|
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(
|
const cardinals = useMemo(
|
||||||
() => [ { d: 0, l: 'N' }, { d: 45, l: 'NE' }, { d: 90, l: 'E' }, { d: 135, l: 'SE' },
|
() => [ { 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' } ],
|
{ 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')}
|
<span className={cn('size-2 rounded-full', rotorEnabled ? 'bg-emerald-500' : 'bg-muted-foreground/40')}
|
||||||
title={rotorEnabled ? 'Rotator connected' : 'Rotator disabled'} />
|
title={rotorEnabled ? 'Rotator connected' : 'Rotator disabled'} />
|
||||||
<div className="flex-1" />
|
<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">
|
<span className="font-mono text-sm font-bold text-emerald-700 tabular-nums">
|
||||||
{headLabel != null ? `${Math.round(headLabel).toString().padStart(3, '0')}°` : '—'}
|
{headLabel != null ? `${Math.round(headLabel).toString().padStart(3, '0')}°` : '—'}
|
||||||
</span>
|
</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} />
|
<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 (
|
{headings.map((h, i) => { const [x, y] = pt(h, MAP_R - 2); return (
|
||||||
<g key={i}>
|
<g key={i}>
|
||||||
<line x1={C} y1={C} x2={x} y2={y} stroke="#15803d" strokeWidth={3} strokeLinecap="round" opacity={i === 0 ? 1 : 0.55} />
|
<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