// RotorCompass — an azimuthal-equidistant rotor display (à la 4O3A RotorGenius). // // A world map centred on the operator's QTH (north up) fills the dial, ringed by // a green azimuth bezel. A green needle shows the antenna heading (two needles // when an Ultrabeam is bidirectional, the opposite one when reversed); a small // red marker on the bezel shows the short-path bearing to the DX. Click the dial // to turn the antenna there. import { useEffect, useMemo, useRef, useState } from 'react'; import { geoAzimuthalEquidistant, geoPath, geoGraticule10 } from 'd3-geo'; import { feature } from 'topojson-client'; import landTopo from 'world-atlas/land-110m.json'; import { Compass, X, Play, Square } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; // Decode the coastline outline once (≈110 m simplified land polygons). const LAND = feature(landTopo as any, (landTopo as any).objects.land); const GRATICULE = geoGraticule10(); interface Props { bearing?: number | null; // short-path azimuth to DX (deg) 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; rotors?: string[]; // logical rotor names; >1 → show a selector activeRotor?: number; // index of the selected rotor (0-based) onSelectRotor?: (i: number) => void; // switch the active rotor onGoto?: (az: number) => void; // click-to-turn onClose?: () => void; // Quick-turn buttons and the azimuth box, shown only where the caller wants // them: Station Control draws its own GoTo/Stop around this compass, and two // sets of the same controls side by side would be nothing but confusing. presets?: { label: string; azimuth: number }[]; onStop?: () => void; } const SIZE = 168; const C = SIZE / 2; const R = C - 6; // outer bezel radius const MAP_R = R - 6; // map/clip radius (inside the bezel) function pt(az: number, radius: number): [number, number] { const a = ((az - 90) * Math.PI) / 180; return [C + radius * Math.cos(a), C + radius * Math.sin(a)]; } export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, rotors, activeRotor, onSelectRotor, onGoto, onClose, presets, onStop }: Props) { const { t } = useI18n(); // Raw text, not a number: binding the input to a normalised value makes // Backspace fight the operator on the way from "230" to "23". It is parsed // when it is sent, and only then. const [azText, setAzText] = useState(''); const showControls = !!(presets || onStop); // Which preset was just pressed, so it can light up for a moment. // // A rotor takes seconds to start moving and the needle barely twitches at // first, so without this the only answer to "did that register?" is to press // it again — which is how an antenna ends up ordered somewhere twice. The // acknowledgement has to come from the button itself, at once. const [flashIdx, setFlashIdx] = useState(null); const flashTimer = useRef(undefined); useEffect(() => () => window.clearTimeout(flashTimer.current), []); const pressPreset = (i: number, az: number) => { if (!onGoto) return; setFlashIdx(i); window.clearTimeout(flashTimer.current); flashTimer.current = window.setTimeout(() => setFlashIdx(null), 450); onGoto(az); }; // Stop needs the same acknowledgement, for the same reason and one more: it // is pressed when something is already wrong, and a button that stays inert // gets hit again and again. Its own flag, so stopping does not blank a preset // that is still lit. const [stopFlash, setStopFlash] = useState(false); const stopTimer = useRef(undefined); useEffect(() => () => window.clearTimeout(stopTimer.current), []); const pressStop = () => { if (!onStop) return; setStopFlash(true); window.clearTimeout(stopTimer.current); stopTimer.current = window.setTimeout(() => setStopFlash(false), 450); onStop(); }; // 0-359 and nothing else. 360 is refused rather than folded to 0 — it is // almost always a typo for 36 or 306, and a rotor swinging through north on a // slip of the finger is worth one rejected keypress. const sendAz = () => { const s = azText.trim(); if (s === '' || !onGoto) return; const n = Number(s); if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0 || n > 359) return; onGoto(n); setAzText(''); }; 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' } ], [], ); // Project the world centred on the QTH (north up; antipode at the bezel). const { land, grat } = useMemo(() => { if (centerLat == null || centerLon == null) return { land: '', grat: '' }; const proj = geoAzimuthalEquidistant() .rotate([-centerLon, -centerLat]) .clipAngle(179.9) .scale(MAP_R / Math.PI) .translate([C, C]); const path = geoPath(proj as any); return { land: path(LAND as any) || '', grat: path(GRATICULE as any) || '' }; }, [centerLat, centerLon]); function handleClick(e: React.MouseEvent) { if (!onGoto) return; const rect = e.currentTarget.getBoundingClientRect(); const x = ((e.clientX - rect.left) / rect.width) * SIZE - C; const y = ((e.clientY - rect.top) / rect.height) * SIZE - C; let az = (Math.atan2(y, x) * 180) / Math.PI + 90; az = ((az % 360) + 360) % 360; onGoto(Math.round(az)); } const headLabel = headings.length ? headings[0] : null; // Short and long path to the DX, in figures. // // The bezel already carries the short path as a red marker, but a marker is a // direction, not a number — the same pair sits in the status bar at 10px and // operators reported not being able to read it. It lives here because it // belongs to the compass: every place that draws one gets the readout, instead // of each caller inventing its own. Clickable when the caller can turn, like // the status bar's. Built as a value because it is placed in one of two // columns depending on whether the controls are shown. const pathReadout = (
{([['SP', bearing ?? null], ['LP', bearing == null ? null : (bearing + 180) % 360]] as const).map(([lbl, az]) => ( ))}
); return (
{/* Header — matches the WinKeyer / Voice keyer panels. */}
Rotor
{pattern && ( {pattern === 'reverse' ? 'REV' : pattern === 'bi' ? 'BI' : 'NORM'} )} {headLabel != null ? `${Math.round(headLabel).toString().padStart(3, '0')}°` : '—'} {onClose && ( )}
{/* Multiple rotors — pick which one the dial shows and turns. */} {rotors && rotors.length > 1 && (
{rotors.map((nm, i) => { const active = (activeRotor ?? 0) === i; const label = nm?.trim() || `Rotor ${i + 1}`; return ( ); })}
)} {/* Dial on the left, controls on the right. The controls column is sized to fit WITHIN the dial's height: the widget sits in a row whose height is set by the entry strip, so it may grow sideways but never downwards. */}
{/* flex-col: the readout goes BELOW the dial. This wrapper was a row, so a sibling of the landed beside it. */}
{/* water + world map, clipped to the dial */} {grat && } {land && } {/* green azimuth bezel */} {/* ticks every 10°, longer at 30° */} {Array.from({ length: 36 }, (_, i) => i * 10).map((d) => { const major = d % 30 === 0; const [x1, y1] = pt(d, MAP_R); const [x2, y2] = pt(d, MAP_R - (major ? 7 : 4)); return ; })} {/* cardinal labels + degree numbers at 45° */} {cardinals.map(({ d, l }) => { const [x, y] = pt(d, MAP_R - 13); return 1 ? 7 : 9, fontWeight: 700 }}>{l}; })} {/* DX short-path bearing → small red marker on the bezel */} {bearing != null && (() => { const [x, y] = pt(bearing, MAP_R); return ( ); })()} {/* 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 ( ); })} {/* With the controls column present the readout goes at the FOOT OF IT instead: the dial sets the widget's height, the controls are shorter than the dial, and that leftover space is exactly the right size for the pair. Under the dial it would push the whole widget taller. */} {!showControls && pathReadout}
{/* Quick turns + free azimuth + Stop. */} {showControls && (
{/* Two columns so six regions fit beside the dial rather than under it. An operator with fewer keeps the same compact block. */} {!!presets?.length && (
{presets.map((p, i) => ( ))}
)} {/* Free azimuth: Enter sends, so the whole thing is type-three-digits -and-go without reaching for the mouse. */}
setAzText(e.target.value.replace(/[^0-9]/g, '').slice(0, 3))} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendAz(); } }} placeholder={t('rotor.azPh')} title={t('rotor.azTitle')} disabled={!onGoto} className="min-w-0 flex-1 rounded-md border border-border bg-background px-2 py-1 text-xs font-mono tabular-nums text-center disabled:opacity-50" />
{onStop && ( )} {/* mt-auto: the readout sits at the FOOT of the column, level with the bottom of the dial, instead of floating under the Stop button. */}
{pathReadout}
)}
); }