// 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 square scale. A green pointer shows the antenna heading, an orange one // follows the mouse so a click lands where it is aimed, and a yellow dot marks // the azimuth that was ordered until the rotor gets there. Green and red dots on // the inner ring are the short and long path to the DX. // // Two shapes, chosen by the caller: with presets or a Stop handler it draws the // full widget (dial + readout + azimuth box + quick turns), without them the // dial alone. Station Control draws its own controls around the compass, and two // sets of the same buttons side by side is nothing but confusing. import { useEffect, useId, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'; import { geoAzimuthalEquidistant, geoPath } from 'd3-geo'; import { feature } from 'topojson-client'; import landTopo from 'world-atlas/land-110m.json'; import { Compass, X } 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); 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; presets?: { label: string; azimuth: number }[]; onStop?: () => void; } type RotorPreset = { label: string; azimuth: number }; // ── Timings ──────────────────────────────────────────────────────────────── // A rotor is slow and its readout is coarse, so "is it moving" is inferred // rather than reported: a heading that changes by more than a degree means it // is, and it is considered stopped once the readout has been still for a while. const MOVEMENT_SETTLE_MS = 1600; // Four degrees, not one. // // A rotor at rest does not report a constant heading: the potentiometer and the // controller's rounding walk the reading a degree or two either side, and at one // degree that jitter WAS movement — Stop lit for a second and a half, went out, // and lit again, for an antenna that had not turned all evening. The reference // is only moved when the threshold is crossed, so a rotor genuinely turning // accumulates towards it however slowly it goes; noise around a value never // gets there. const MOVEMENT_TRIGGER_DEG = 4; // How long an order is given to produce movement before the widget stops // claiming the antenna is turning — the rotor may already have been there. const COMMAND_START_TIMEOUT_MS = 2500; // Arrival: within a degree of the target, held for long enough not to be one // noisy reading, then the marker fades rather than vanishing (a marker that // disappears between two frames reads as a fault). const TARGET_REACHED_DEG = 1; const TARGET_CONFIRM_MS = 700; const TARGET_FADE_MS = 500; // ── Palette ──────────────────────────────────────────────────────────────── // Deliberately fixed, not themed: this is a map, and a map that repaints itself // in four colour schemes stops being readable. The chrome around it — card, // borders, buttons — follows the theme as everything else does. const COMPASS_ORANGE = '#F97316'; // The target is yellow in BOTH places it appears: the figure under the current // azimuth and the dot on the dial. One idea, one colour. const TARGET_YELLOW = '#FBBF24'; const MAP_BG_TOP = '#0B1015'; const MAP_BG_BOTTOM = '#080C11'; const MAP_LAND = '#202832'; const MAP_LAND_SECONDARY = '#25303A'; // What each rotor was last seen at, and what it was last told to do, kept // OUTSIDE the component and keyed by rotor index. // // The widget is unmounted whenever its panel is hidden or the layout reflows, // and telemetry only arrives every second or so: without this, coming back // showed a blank dial and forgot the azimuth that had been ordered thirty // seconds earlier, which is exactly when an operator looks. const rememberedAzimuths = new Map(); const rememberedTargets = new Map(); function normalizeAzimuth(value: number): number { return ((Math.round(value) % 360) + 360) % 360; } function normalizeRotation(value: number): number { return ((value % 360) + 360) % 360; } function angularDistance(a: number, b: number): number { const aa = normalizeAzimuth(a); const bb = normalizeAzimuth(b); return Math.abs(((aa - bb + 540) % 360) - 180); } // unwrapRotation keeps a CSS rotation continuous across north. // // 358 → 359 → 0 → 1 is a pointer that spins the long way round the dial on // every pass; expressed as 358 → 359 → 360 → 361 the transition animates the // way the antenna actually moves. function unwrapRotation(nextAngle: number, previousRotation: number | null): number { if (previousRotation == null) return nextAngle; const previousNormalized = normalizeRotation(previousRotation); const delta = ((nextAngle - previousNormalized + 540) % 360) - 180; return previousRotation + delta; } // ── The dial ─────────────────────────────────────────────────────────────── function RotorCompassDial({ azimuth, secondary, boom, targetAzimuth, targetFading, shortPath, longPath, centerLat, centerLon, onGoto, }: { azimuth: number | null; // The second lobe of a bidirectional Ultrabeam, and the mechanical boom when // the antenna radiates somewhere other than where it points. secondary?: number | null; boom?: number | null; targetAzimuth: number | null; targetFading: boolean; shortPath: number | null; longPath: number | null; centerLat?: number | null; centerLon?: number | null; onGoto?: (az: number) => void; }) { // Gradient and mask ids must be unique per instance: two compasses on one // screen (docked widget + Station Control) would otherwise share the first // one's definitions. const uid = useId().replace(/:/g, ''); const bgGradientId = `rotor-bg-${uid}`; const mapFadeGradientId = `rotor-map-fade-grad-${uid}`; const mapFadeMaskId = `rotor-map-fade-mask-${uid}`; const SIZE = 320; const CENTER = SIZE / 2; // The scale is a SQUARE ring, not a circle: it puts the tick marks at the // edge of the panel, which is where the room is. const SCALE_HALF = 108; const CARDINAL_TICK_OUTER_HALF = SCALE_HALF + 7; const MAJOR_TICK_INNER_HALF = SCALE_HALF - 17; const POINTER_TIP_RADIUS = 72; const CARDINAL_LABEL_INSET = 23; const CENTER_DOT_RADIUS = 4.2; const MAP_RADIUS = CENTER - 1; // Around the centre the cursor's direction is meaningless — a pixel either // way is forty degrees — so no heading is derived there. const HOVER_DEAD_ZONE = 14; const [hoverAzimuth, setHoverAzimuth] = useState(null); const [hoverRotation, setHoverRotation] = useState(null); const hoverRotationRef = useRef(null); const landPath = useMemo(() => { if (centerLat == null || centerLon == null) return ''; const projection = geoAzimuthalEquidistant() .rotate([-centerLon, -centerLat]) .clipAngle(179.9) .scale(MAP_RADIUS / Math.PI) .translate([CENTER, CENTER]); return geoPath(projection as any)(LAND as any) || ''; }, [centerLat, centerLon]); // A point on the square scale at a given azimuth. const squarePoint = (angle: number, halfExtent: number) => { const radians = (normalizeAzimuth(angle) * Math.PI) / 180; const dx = Math.sin(radians); const dy = -Math.cos(radians); const divisor = Math.max(Math.abs(dx), Math.abs(dy), 0.0001); const scale = halfExtent / divisor; return { x: CENTER + dx * scale, y: CENTER + dy * scale }; }; const radialPoint = (angle: number, radius: number) => { const radians = (normalizeAzimuth(angle) * Math.PI) / 180; return { x: CENTER + Math.sin(radians) * radius, y: CENTER - Math.cos(radians) * radius }; }; const radialDistanceToSquare = (angle: number, halfExtent: number) => { const radians = (normalizeAzimuth(angle) * Math.PI) / 180; const dx = Math.sin(radians); const dy = -Math.cos(radians); return halfExtent / Math.max(Math.abs(dx), Math.abs(dy), 0.0001); }; // Markers sit halfway between the pointer's tip and the scale, so they stay // clear of both whatever direction they are in — the ring is a square. const markerRadius = (angle: number) => { const tickInnerRadius = radialDistanceToSquare(angle, MAJOR_TICK_INNER_HALF); return POINTER_TIP_RADIUS + (tickInnerRadius - POINTER_TIP_RADIUS) / 2; }; const azimuthFromMouseEvent = (event: ReactMouseEvent): number | null => { const rect = event.currentTarget.getBoundingClientRect(); const svgX = ((event.clientX - rect.left) / rect.width) * SIZE; const svgY = ((event.clientY - rect.top) / rect.height) * SIZE; const x = svgX - CENTER; const y = svgY - CENTER; if (Math.hypot(x, y) < HOVER_DEAD_ZONE) return null; let angle = (Math.atan2(y, x) * 180) / Math.PI + 90; angle = ((angle % 360) + 360) % 360; return normalizeAzimuth(angle); }; const clearHover = () => { setHoverAzimuth(null); setHoverRotation(null); hoverRotationRef.current = null; }; const handleMouseMove = (event: ReactMouseEvent) => { if (!onGoto) { clearHover(); return; } const nextAzimuth = azimuthFromMouseEvent(event); if (nextAzimuth == null) { clearHover(); return; } const nextRotation = unwrapRotation(nextAzimuth, hoverRotationRef.current); hoverRotationRef.current = nextRotation; setHoverAzimuth(nextAzimuth); setHoverRotation(nextRotation); }; const handleDialClick = (event: ReactMouseEvent) => { if (!onGoto) return; const requested = azimuthFromMouseEvent(event); if (requested == null) return; onGoto(requested); }; const tickAngles = Array.from({ length: 72 }, (_, index) => index * 5); const degreeLabels = [30, 60, 120, 150, 210, 240, 300, 330]; const renderPathDot = (angle: number, type: 'sp' | 'lp') => { const point = radialPoint(angle, markerRadius(angle)); return ( ); }; const renderTargetDot = (angle: number) => { const point = radialPoint(angle, markerRadius(angle)); return ( ); }; // The pointer is drawn once and rotated, so the browser animates the turn // instead of the component redrawing a triangle every telemetry read. const renderPointer = (rotation: number, colour: string | null, opacity: number, animated: boolean) => { const tipY = 88; const baseY = 110; const innerTipY = 98; return ( ); }; return (
{/* The map fades out towards the rim rather than being cut off by a circle: at the edge of an azimuthal-equidistant projection the antipode is smeared right around the disc, and a hard edge there draws the eye to the least meaningful part of the map. */} {landPath && ( )} {/* The scale: every 5°, thicker every 10°, 30° and at the cardinals. */} {tickAngles.map((angle) => { const cardinal = angle % 90 === 0; const major = angle % 30 === 0; const medium = !major && angle % 10 === 0; const outer = squarePoint(angle, cardinal ? CARDINAL_TICK_OUTER_HALF : SCALE_HALF); const inner = squarePoint(angle, major ? MAJOR_TICK_INNER_HALF : medium ? SCALE_HALF - 11 : SCALE_HALF - 6); return ( ); })} {degreeLabels.map((angle) => { const position = squarePoint(angle, SCALE_HALF + 20); return ( {angle} ); })} {([['N', CENTER, CARDINAL_LABEL_INSET], ['E', SIZE - CARDINAL_LABEL_INSET, CENTER], ['S', CENTER, SIZE - CARDINAL_LABEL_INSET], ['W', CARDINAL_LABEL_INSET, CENTER]] as const).map( ([label, x, y]) => ( {label} ), )} {/* Where the mouse is pointing, in the same shape as the antenna's own pointer: the click sends the antenna there, so the preview should look like what it will produce. */} {hoverAzimuth != null && hoverRotation != null && renderPointer(hoverRotation, COMPASS_ORANGE, 0.78, false)} {shortPath != null && renderPathDot(shortPath, 'sp')} {longPath != null && renderPathDot(longPath, 'lp')} {targetAzimuth != null && renderTargetDot(targetAzimuth)} {/* The mechanical boom, dashed and grey, drawn only when the antenna radiates somewhere else (Ultrabeam reversed or bidirectional): where it POINTS and where it TRANSMITS are then two different answers, and the operator needs both. */} {boom != null && (() => { const tip = radialPoint(boom, POINTER_TIP_RADIUS + 18); return ( Boom {normalizeAzimuth(boom)}° ); })()} {/* The second lobe of a bidirectional antenna: same pointer, dimmed. */} {secondary != null && renderPointer(normalizeAzimuth(secondary), null, 0.42, true)} {azimuth != null && renderPointer(normalizeAzimuth(azimuth), null, 0.96, true)}
); } // ── The widget ───────────────────────────────────────────────────────────── export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, rotors, activeRotor, onSelectRotor, onGoto, onClose, presets, onStop, }: Props) { const { t } = useI18n(); const rotorKey = activeRotor ?? 0; const showControls = !!(presets || onStop); // The boom is what the ROTOR reports; headings[0] is where the antenna // radiates, which is the same thing unless an Ultrabeam is reversed. The dial // shows the radiating direction and the figures follow it. const rawAzimuth = boomHeading != null ? normalizeAzimuth(boomHeading) : headings.length > 0 ? normalizeAzimuth(headings[0]) : null; const radiating = headings.length > 0 ? normalizeAzimuth(headings[0]) : rawAzimuth; const secondLobe = headings.length > 1 ? normalizeAzimuth(headings[1]) : null; const showBoom = boomHeading != null && !!pattern && pattern !== 'normal' ? normalizeAzimuth(boomHeading) : null; const [displayAzimuth, setDisplayAzimuth] = useState( () => (rawAzimuth != null ? rawAzimuth : rememberedAzimuths.get(rotorKey) ?? null), ); // 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 [flashIdx, setFlashIdx] = useState(null); const [isMoving, setIsMoving] = useState(() => rememberedTargets.has(rotorKey)); const [targetAzimuth, setTargetAzimuthState] = useState( () => rememberedTargets.get(rotorKey) ?? null, ); const [targetFading, setTargetFading] = useState(false); const flashTimerRef = useRef(undefined); const movementTimerRef = useRef(undefined); const commandTimerRef = useRef(undefined); const targetArrivalTimerRef = useRef(undefined); const targetFadeTimerRef = useRef(undefined); const latestAzimuthRef = useRef(displayAzimuth); const movementReferenceRef = useRef(displayAzimuth); const movementSeenRef = useRef(false); const rememberTarget = (value: number | null) => { if (value == null) rememberedTargets.delete(rotorKey); else rememberedTargets.set(rotorKey, value); setTargetAzimuthState(value); }; // Telemetry. useEffect(() => { if (rawAzimuth == null) return; rememberedAzimuths.set(rotorKey, rawAzimuth); latestAzimuthRef.current = rawAzimuth; setDisplayAzimuth(rawAzimuth); }, [rawAzimuth, rotorKey]); // Switching rotor: everything on screen belongs to the other one. Its own // last reading and its own pending order are restored, and every timer that // was counting for the previous rotor is dropped. useEffect(() => { const rememberedAzimuth = rememberedAzimuths.get(rotorKey); const nextAzimuth = rawAzimuth ?? rememberedAzimuth ?? null; if (rawAzimuth != null) rememberedAzimuths.set(rotorKey, rawAzimuth); setDisplayAzimuth(nextAzimuth); latestAzimuthRef.current = nextAzimuth; movementReferenceRef.current = nextAzimuth; const rememberedTarget = rememberedTargets.get(rotorKey) ?? null; setTargetAzimuthState(rememberedTarget); setTargetFading(false); setIsMoving(rememberedTarget != null); movementSeenRef.current = false; window.clearTimeout(movementTimerRef.current); window.clearTimeout(commandTimerRef.current); window.clearTimeout(targetArrivalTimerRef.current); window.clearTimeout(targetFadeTimerRef.current); // eslint-disable-next-line react-hooks/exhaustive-deps }, [rotorKey]); useEffect(() => () => { window.clearTimeout(flashTimerRef.current); window.clearTimeout(movementTimerRef.current); window.clearTimeout(commandTimerRef.current); window.clearTimeout(targetArrivalTimerRef.current); window.clearTimeout(targetFadeTimerRef.current); }, []); // Movement is inferred from the readout itself, so a rotor turned by its own // controller — or by another program — reads as moving here too. useEffect(() => { if (rawAzimuth == null) return; const reference = movementReferenceRef.current; if (reference == null) { movementReferenceRef.current = rawAzimuth; return; } if (angularDistance(rawAzimuth, reference) >= MOVEMENT_TRIGGER_DEG) { movementSeenRef.current = true; setIsMoving(true); window.clearTimeout(commandTimerRef.current); window.clearTimeout(movementTimerRef.current); movementTimerRef.current = window.setTimeout(() => { setIsMoving(false); movementSeenRef.current = false; }, MOVEMENT_SETTLE_MS); movementReferenceRef.current = rawAzimuth; } }, [rawAzimuth, rotorKey]); // Arrival: confirmed over time, then faded. Every check re-reads the // remembered target, so an order given while this was counting cancels it // instead of clearing the NEW target on the old one's arrival. useEffect(() => { if (targetAzimuth == null || rawAzimuth == null || targetFading) { window.clearTimeout(targetArrivalTimerRef.current); targetArrivalTimerRef.current = undefined; return; } if (angularDistance(rawAzimuth, targetAzimuth) > TARGET_REACHED_DEG) { window.clearTimeout(targetArrivalTimerRef.current); targetArrivalTimerRef.current = undefined; return; } if (targetArrivalTimerRef.current !== undefined) return; const targetSnapshot = targetAzimuth; targetArrivalTimerRef.current = window.setTimeout(() => { targetArrivalTimerRef.current = undefined; const latest = latestAzimuthRef.current; if (rememberedTargets.get(rotorKey) !== targetSnapshot) return; if (latest == null || angularDistance(latest, targetSnapshot) > TARGET_REACHED_DEG) return; setIsMoving(false); movementSeenRef.current = false; setTargetFading(true); window.clearTimeout(movementTimerRef.current); window.clearTimeout(commandTimerRef.current); window.clearTimeout(targetFadeTimerRef.current); targetFadeTimerRef.current = window.setTimeout(() => { if (rememberedTargets.get(rotorKey) === targetSnapshot) { rememberedTargets.delete(rotorKey); setTargetAzimuthState(null); } setTargetFading(false); }, TARGET_FADE_MS); }, TARGET_CONFIRM_MS); }, [rawAzimuth, targetAzimuth, targetFading, rotorKey]); // The rotor went away: nothing more will arrive, so nothing should keep // claiming it is turning. useEffect(() => { if (rotorEnabled === false) { movementSeenRef.current = false; window.clearTimeout(movementTimerRef.current); setIsMoving(false); } }, [rotorEnabled]); // An order that produces no movement at all: the antenna was already there, // or nothing is listening. Either way the widget stops saying "turning". useEffect(() => { if (targetAzimuth == null) return; window.clearTimeout(commandTimerRef.current); commandTimerRef.current = window.setTimeout(() => { if (!movementSeenRef.current) setIsMoving(false); }, COMMAND_START_TIMEOUT_MS); return () => window.clearTimeout(commandTimerRef.current); }, [targetAzimuth, rotorKey]); const shortPath = bearing != null ? normalizeAzimuth(bearing) : null; const longPath = bearing != null ? normalizeAzimuth(bearing + 180) : null; // Six presets to a column — two per row, three rows, level with the dial — // and a seventh starts another column rather than making the widget taller. // It sits in a row whose height is set by the entry strip: it may grow // sideways, never downwards. const presetColumns = useMemo(() => { const all = presets ?? []; const columns: RotorPreset[][] = []; for (let i = 0; i < all.length; i += 6) columns.push(all.slice(i, i + 6)); return columns; }, [presets]); // 192 dial + 6 gap + 154 controls + 16 padding, plus 60 per preset column. const controlsWidth = 154 + presetColumns.length * 60; const widgetWidth = 368 + presetColumns.length * 60; const markMovementCommanded = () => { movementSeenRef.current = false; setIsMoving(true); movementReferenceRef.current = latestAzimuthRef.current; window.clearTimeout(movementTimerRef.current); window.clearTimeout(commandTimerRef.current); }; const gotoAzimuth = (azimuth: number) => { if (!onGoto) return; const target = normalizeAzimuth(azimuth); window.clearTimeout(targetArrivalTimerRef.current); targetArrivalTimerRef.current = undefined; window.clearTimeout(targetFadeTimerRef.current); rememberTarget(target); setTargetFading(false); markMovementCommanded(); onGoto(target); }; // 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 manualAzimuth = (() => { const value = azText.trim(); if (value === '') return null; const n = Number(value); if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0 || n > 359) return null; return n; })(); const manualAzimuthValid = manualAzimuth != null && !!onGoto; const sendAzimuth = () => { if (manualAzimuth == null || !onGoto) return; gotoAzimuth(manualAzimuth); setAzText(''); }; // A rotor takes seconds to start moving and the pointer 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 pressPreset = (index: number, azimuth: number) => { if (!onGoto) return; setFlashIdx(index); window.clearTimeout(flashTimerRef.current); flashTimerRef.current = window.setTimeout(() => setFlashIdx(null), 450); gotoAzimuth(azimuth); }; const pressStop = () => { if (!onStop) return; onStop(); setIsMoving(false); movementSeenRef.current = false; movementReferenceRef.current = latestAzimuthRef.current; window.clearTimeout(movementTimerRef.current); window.clearTimeout(commandTimerRef.current); window.clearTimeout(targetArrivalTimerRef.current); targetArrivalTimerRef.current = undefined; // The order is void, so its marker goes — faded, not snatched away, so the // press is visibly what did it. if (targetAzimuth != null) { const targetSnapshot = targetAzimuth; setTargetFading(true); window.clearTimeout(targetFadeTimerRef.current); targetFadeTimerRef.current = window.setTimeout(() => { if (rememberedTargets.get(rotorKey) === targetSnapshot) { rememberedTargets.delete(rotorKey); setTargetAzimuthState(null); } setTargetFading(false); }, TARGET_FADE_MS); } }; const renderPresetButton = (preset: RotorPreset, index: number) => ( ); const renderPresetColumn = (column: RotorPreset[], columnIndex: number) => (
{[0, 2, 4].map((row) => (
{column[row] && renderPresetButton(column[row], columnIndex * 6 + row)} {column[row + 1] && renderPresetButton(column[row + 1], columnIndex * 6 + row + 1)}
))}
); // The SP/LP pair as buttons: the dial carries them as dots, but a dot is a // direction and not a number — the same pair sits in the status bar at 10px // and operators reported not being able to read it. Part of the controls // column, so it appears with the rest of them and never on its own under a // dial that has already used up the height. const pathButton = (label: 'SP' | 'LP', az: number | null) => ( ); const mainControls = (
{/* Where the antenna is, and under it — smaller, yellow, and only while it matters — where it was told to go. */}
{displayAzimuth != null ? `${displayAzimuth}°` : '—'}
{targetAzimuth != null && (
{targetAzimuth}°
)}
{/* 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(); sendAzimuth(); } }} className="w-full min-w-0 h-full rounded-md border border-border bg-background px-0.5 text-[10px] font-mono tabular-nums text-center disabled:opacity-50 outline-none focus:outline-none focus-visible:outline-none focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-warning focus-visible:border-warning transition-colors duration-150" />
{/* Stop is lit while the antenna is actually turning: it is pressed when something is already wrong, and a button that looks inert gets hit again and again. */} {onStop && ( )}
{pathButton('SP', shortPath)} {pathButton('LP', longPath)}
); const dial = ( ); return (
Rotor
{pattern && ( {pattern === 'reverse' ? 'REV' : pattern === 'bi' ? 'BI' : 'NORM'} )} {/* Without the controls column there is no big readout, so the heading goes in the header — the dial-only form is still expected to answer "where is it pointing" without being measured by eye. */} {!showControls && ( {displayAzimuth != null ? `${displayAzimuth.toString().padStart(3, '0')}°` : '—'} )} {onClose && ( )}
{rotors && rotors.length > 1 && (
{rotors.map((name, index) => { const active = (activeRotor ?? 0) === index; const label = name?.trim() || `Rotor ${index + 1}`; return ( ); })}
)} {showControls ? (
{dial}
{mainControls} {presetColumns.map((column, columnIndex) => renderPresetColumn(column, columnIndex))}
) : ( // THE DIAL AND NOTHING ELSE. // // The compact form is dropped into a box whose height belongs to the row // around it, and a square dial fills that on its own — the SP/LP pair // underneath was pushed off the bottom edge. The dots on the dial still // carry both paths, and the caller (Station Control, the status bar) has // the figures where it wants them.
{dial}
)}
); }