Files
OpsLog/frontend/src/components/RotorCompass.tsx
T
rouggyandClaude Opus 5 a89be4b86e fix(rotor): a disc on the panel, not a black tile in it
The dial painted its background as a full-bleed square, so inside the
rotor widget — which is already a card — it read as a hole punched in the
panel rather than an instrument sitting on it. It is a circle now, at the
radius the map already used, and the corners are left to whatever it is
drawn on. The wrapper loses its own border and background for the same
reason: one card, not two.

And the continents were barely there. At #202832 on a #0B1015 ground the
land was some eight per cent brighter than the sea — technically a map,
practically a dark square with a suggestion in it. The new shades read as
coastlines while staying well under the beams, which are what the dial is
actually for.

The palette stays deliberately unthemed. That was never the problem: a
map that repaints itself in four colour schemes stops being readable, and
the beams' green, orange and yellow have to mean the same thing in every
one of them.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-08 22:00:39 +02:00

973 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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';
// Three things are drawn on this dial and they must never be mistaken for one
// another: where the antenna IS (green, as everywhere else in OpsLog), where
// the mouse would send it (orange, the dial's own colour), and where it has
// been ORDERED to go (yellow, the same yellow as the figure under the readout).
const BEAM_GREEN = '#22C55E';
type BeamKind = 'antenna' | 'hover';
// 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';
// The continents, and they have to be VISIBLE. At #202832 on a #0B1015 ground
// the land was some eight per cent brighter than the sea — technically a map,
// practically a dark square with a suggestion in it. These read as coastlines
// while staying well under the beams, which are what the dial is for.
const MAP_LAND = '#33414F';
const MAP_LAND_SECONDARY = '#41525F';
// 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<number, number>();
const rememberedTargets = new Map<number, number>();
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, onHoverAzimuth,
}: {
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;
// What the mouse is over, so the readout can show it. The figure belongs
// beside the current heading, not on the map: a number floating over a beam
// is read by moving the eye, and this one is read while aiming.
onHoverAzimuth?: (az: number | null) => 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;
// A CIRCLE, not a square ring. The square put the ticks in the corners of
// the panel, which is where the room is — and made every distance from the
// centre depend on the direction, so a marker at 45° sat further out than one
// at north. A dial is read by angle; the ring it is read against has to be
// the same distance away all the way round.
const SCALE_RADIUS = 112;
const CARDINAL_TICK_OUTER_RADIUS = SCALE_RADIUS + 7;
const MAJOR_TICK_INNER_RADIUS = SCALE_RADIUS - 17;
const POINTER_TIP_RADIUS = 72;
const CARDINAL_LABEL_RADIUS = SCALE_RADIUS + 24;
const CENTER_DOT_RADIUS = 4.2;
const MAP_RADIUS = CENTER - 1;
// Around the centre the cursors 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<number | null>(null);
const [hoverRotation, setHoverRotation] = useState<number | null>(null);
const hoverRotationRef = useRef<number | null>(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]);
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 };
};
// Markers sit halfway between the beams tip and the scale, clear of both.
// One number now: on a circular ring the answer no longer depends on which
// way the marker lies.
const markerRadius = POINTER_TIP_RADIUS + (MAJOR_TICK_INNER_RADIUS - POINTER_TIP_RADIUS) / 2;
const azimuthFromMouseEvent = (event: ReactMouseEvent<SVGSVGElement>): 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);
onHoverAzimuth?.(null);
setHoverRotation(null);
hoverRotationRef.current = null;
};
const handleMouseMove = (event: ReactMouseEvent<SVGSVGElement>) => {
if (!onGoto) { clearHover(); return; }
const nextAzimuth = azimuthFromMouseEvent(event);
if (nextAzimuth == null) { clearHover(); return; }
const nextRotation = unwrapRotation(nextAzimuth, hoverRotationRef.current);
hoverRotationRef.current = nextRotation;
setHoverAzimuth(nextAzimuth);
onHoverAzimuth?.(nextAzimuth);
setHoverRotation(nextRotation);
};
const handleDialClick = (event: ReactMouseEvent<SVGSVGElement>) => {
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);
return (
<circle cx={point.x} cy={point.y} r="4.2" fill="currentColor"
className={type === 'sp' ? 'text-success' : 'text-destructive'}
opacity={type === 'sp' ? 0.95 : 0.88} pointerEvents="none" />
);
};
const renderTargetDot = (angle: number) => {
const point = radialPoint(angle, markerRadius);
return (
<circle cx={point.x} cy={point.y} r="4.2" fill={TARGET_YELLOW} pointerEvents="none"
style={{ opacity: targetFading ? 0 : 0.95, transition: `opacity ${TARGET_FADE_MS}ms ease-out` }} />
);
};
// A BEAM, not an arrow. The dial answers "where is the antenna looking", and
// an antenna does not look along a line — it looks through a lobe. Drawn as a
// sector that fades outwards, which is also the shape of the thing it stands
// for; the arrow said a precision the beamwidth does not have.
//
// Drawn pointing north and rotated as a whole, so the browser animates the
// turn instead of the component recomputing an arc on every telemetry read —
// and rotation is the ONLY source of angle here, which is what keeps the
// mouse preview exactly under the cursor.
const BEAM_HALF_ANGLE = 17.5;
const renderBeam = (rotation: number, kind: BeamKind, opacity: number, animated: boolean) => {
const left = radialPoint(-BEAM_HALF_ANGLE, SCALE_RADIUS);
const right = radialPoint(BEAM_HALF_ANGLE, SCALE_RADIUS);
return (
<g
pointerEvents="none"
style={{
transform: `rotate(${rotation}deg)`,
transformOrigin: `${CENTER}px ${CENTER}px`,
opacity,
transition: animated
? 'transform 500ms cubic-bezier(0.16,1,0.3,1), opacity 500ms ease-out'
: 'transform 70ms linear, opacity 120ms ease-out',
}}
>
<path
d={`M ${CENTER} ${CENTER} L ${left.x} ${left.y} A ${SCALE_RADIUS} ${SCALE_RADIUS} 0 0 1 ${right.x} ${right.y} Z`}
fill={`url(#beam-${kind}-${uid})`}
/>
{/* The axis: the heading itself, to the rim, fading outwards so the
eye is drawn to where it starts rather than where it ends. */}
<line x1={CENTER} y1={CENTER} x2={CENTER} y2={CENTER - SCALE_RADIUS}
stroke={`url(#axis-${kind}-${uid})`} strokeWidth="2" strokeLinecap="round" />
</g>
);
};
return (
// No card of its own, and no square: the dial is drawn as a disc and the
// corners are left to whatever it is sitting on. A black tile inside the
// rotor panel read as a hole punched in it — the widget is already a card,
// and this is an instrument on that card, not a second one.
<div className="w-full h-full min-w-0 aspect-square flex items-center justify-center overflow-hidden">
<svg
viewBox={`0 0 ${SIZE} ${SIZE}`}
className={cn('block w-full h-full select-none', onGoto ? 'cursor-crosshair' : 'cursor-default')}
aria-label="Rotor compass"
onMouseMove={handleMouseMove}
onMouseLeave={clearHover}
onClick={handleDialClick}
>
<defs>
<linearGradient id={bgGradientId} x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor={MAP_BG_TOP} />
<stop offset="100%" stopColor={MAP_BG_BOTTOM} />
</linearGradient>
{/* 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. */}
<radialGradient id={mapFadeGradientId} cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="white" />
<stop offset="72%" stopColor="white" />
<stop offset="84%" stopColor="#c9c9c9" />
<stop offset="92%" stopColor="#666666" />
<stop offset="97%" stopColor="#1a1a1a" />
<stop offset="100%" stopColor="black" />
</radialGradient>
<mask id={mapFadeMaskId}>
<rect x="0" y="0" width={SIZE} height={SIZE} fill={`url(#${mapFadeGradientId})`} />
</mask>
{/* One pair per beam kind: the sector's wash and its axis. Both fade
outwards — a lobe has no edge, and drawing one would claim a
beamwidth the antenna does not have. */}
{([['antenna', BEAM_GREEN], ['hover', COMPASS_ORANGE]] as const).map(([kind, colour]) => (
<g key={kind}>
<linearGradient id={`beam-${kind}-${uid}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={colour} stopOpacity="0.45" />
<stop offset="55%" stopColor={colour} stopOpacity="0.22" />
<stop offset="100%" stopColor={colour} stopOpacity="0" />
</linearGradient>
<linearGradient id={`axis-${kind}-${uid}`} x1="0" y1={CENTER} x2="0" y2={CENTER - SCALE_RADIUS}
gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor={colour} stopOpacity="1" />
<stop offset="55%" stopColor={colour} stopOpacity="0.45" />
<stop offset="100%" stopColor={colour} stopOpacity="0" />
</linearGradient>
</g>
))}
</defs>
<circle cx={CENTER} cy={CENTER} r={MAP_RADIUS} fill={`url(#${bgGradientId})`} />
{landPath && (
<g mask={`url(#${mapFadeMaskId})`} opacity="0.92" pointerEvents="none">
<path d={landPath} fill={MAP_LAND} />
<path d={landPath} fill={MAP_LAND_SECONDARY} opacity="0.22" transform="translate(0.35 0.35)" />
</g>
)}
{/* The scale: every 5°, thicker every 10°, 30° and at the cardinals. */}
<g pointerEvents="none">
{tickAngles.map((angle) => {
const cardinal = angle % 90 === 0;
const major = angle % 30 === 0;
const medium = !major && angle % 10 === 0;
const outer = radialPoint(angle, cardinal ? CARDINAL_TICK_OUTER_RADIUS : SCALE_RADIUS);
const inner = radialPoint(angle, major ? MAJOR_TICK_INNER_RADIUS : medium ? SCALE_RADIUS - 11 : SCALE_RADIUS - 6);
return (
<line key={`tick-${angle}`} x1={inner.x} y1={inner.y} x2={outer.x} y2={outer.y}
stroke="currentColor" strokeLinecap="round"
strokeWidth={cardinal ? 4.6 : major ? 3 : medium ? 1.7 : 0.9}
className={cardinal ? 'text-foreground/95' : major ? 'text-muted-foreground/85'
: medium ? 'text-muted-foreground/55' : 'text-muted-foreground/28'} />
);
})}
</g>
<g pointerEvents="none">
{degreeLabels.map((angle) => {
const position = radialPoint(angle, SCALE_RADIUS + 22);
return (
<text key={`degree-${angle}`} x={position.x} y={position.y}
textAnchor="middle" dominantBaseline="middle" fill="currentColor"
className="text-[16px] font-mono font-bold text-foreground/90">
{angle}
</text>
);
})}
</g>
{/* The cardinals sit on the same circle as everything else. Each carries
a small outward nudge: the letters are not the same height, and set
on a true circle S and W read as if they had slipped inwards. */}
<g pointerEvents="none">
{([['N', 0, 0], ['E', 90, 2], ['S', 180, 4], ['W', 270, 3]] as const).map(([label, angle, nudge]) => {
const position = radialPoint(angle, CARDINAL_LABEL_RADIUS + nudge);
return (
<text key={label} x={position.x} y={position.y} textAnchor="middle" dominantBaseline="middle"
fill={COMPASS_ORANGE} className="text-[27px] font-black">
{label}
</text>
);
})}
</g>
{/* 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 && renderBeam(hoverRotation, 'hover', 1, 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 (
<g pointerEvents="none">
<title>Boom {normalizeAzimuth(boom)}°</title>
<line x1={CENTER} y1={CENTER} x2={tip.x} y2={tip.y}
stroke="currentColor" className="text-muted-foreground" strokeWidth="2.5"
strokeDasharray="5 4" strokeLinecap="round" opacity="0.75" />
</g>
);
})()}
{/* The second lobe of a bidirectional antenna: the same beam, dimmed —
it radiates as much, and it is not where the operator aimed. */}
{secondary != null && renderBeam(normalizeAzimuth(secondary), 'antenna', 0.45, true)}
{azimuth != null && renderBeam(normalizeAzimuth(azimuth), 'antenna', 1, true)}
<circle cx={CENTER} cy={CENTER} r={CENTER_DOT_RADIUS} fill={COMPASS_ORANGE} pointerEvents="none" />
</svg>
</div>
);
}
// ── 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<number | null>(
() => (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<number | null>(null);
const [isMoving, setIsMoving] = useState(() => rememberedTargets.has(rotorKey));
const [targetAzimuth, setTargetAzimuthState] = useState<number | null>(
() => rememberedTargets.get(rotorKey) ?? null,
);
const [targetFading, setTargetFading] = useState(false);
// Where the mouse is aiming, while it is over the dial.
const [hoverAzimuth, setHoverAzimuth] = useState<number | null>(null);
const flashTimerRef = useRef<number | undefined>(undefined);
const movementTimerRef = useRef<number | undefined>(undefined);
const commandTimerRef = useRef<number | undefined>(undefined);
const targetArrivalTimerRef = useRef<number | undefined>(undefined);
const targetFadeTimerRef = useRef<number | undefined>(undefined);
const latestAzimuthRef = useRef<number | null>(displayAzimuth);
const movementReferenceRef = useRef<number | null>(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]);
// THE SELECTOR HAS TO COME OUT OF SOMEWHERE.
//
// With more than one rotor a row of buttons appears above the dial, and the
// widget's height is not its own to take: it sits in a strip whose height is
// set by the entry form beside it. The extra row simply pushed the bottom of
// the panel off the end — the SP/LP pair and half the Stop button gone.
//
// So the dial and the button rows give the row back, in proportion: 24 px off
// the dial and 8 off each of the three rows is the height of a selector, and
// nothing has to be dropped.
const tight = !!(rotors && rotors.length > 1);
const dialPx = tight ? 168 : 192;
const rowPx = tight ? 52 : 60;
// 6 gap + 154 controls + 16 padding, plus 60 per preset column.
const controlsWidth = 154 + presetColumns.length * 60;
const widgetWidth = dialPx + 176 + 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) => (
<button
key={`${preset.label}-${index}`}
type="button"
disabled={!onGoto}
onClick={() => pressPreset(index, preset.azimuth)}
title={`${preset.label}${normalizeAzimuth(preset.azimuth)}°`}
className={cn(
'h-full w-full min-h-0 rounded-md border px-0.5 text-[10px] font-bold truncate transition-all duration-150 active:scale-95',
flashIdx === index
? 'border-success bg-success text-success-foreground scale-95'
: 'border-border bg-muted/40 hover:bg-muted',
!onGoto && 'opacity-50 cursor-not-allowed',
)}
>
{/* Lit, and showing the azimuth it just sent: the label alone would only
say the press landed, not what was ordered. */}
{flashIdx === index ? `${normalizeAzimuth(preset.azimuth)}°` : preset.label}
</button>
);
const renderPresetColumn = (column: RotorPreset[], columnIndex: number) => (
<div key={`preset-column-${columnIndex}`}
className="w-[54px] min-w-[54px] shrink-0 grid gap-1.5 min-h-0"
style={{ gridTemplateRows: `repeat(3, ${rowPx}px)` }}>
{[0, 2, 4].map((row) => (
<div key={row} className="h-full min-h-0 grid grid-rows-2 gap-1">
{column[row] && renderPresetButton(column[row], columnIndex * 6 + row)}
{column[row + 1] && renderPresetButton(column[row + 1], columnIndex * 6 + row + 1)}
</div>
))}
</div>
);
// 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) => (
<button
type="button"
disabled={az == null || !onGoto}
onClick={() => { if (az != null && onGoto) gotoAzimuth(az); }}
title={az != null ? `${label === 'SP' ? 'Short Path' : 'Long Path'} ${az}°` : label}
className={cn(
'w-full min-w-0 h-full min-h-0 rounded-md border px-0.5 text-center flex flex-col items-center justify-center transition-all overflow-hidden',
az != null && onGoto
? 'border-info-border hover:bg-info-muted active:scale-95'
: 'border-border opacity-60 cursor-default',
)}
>
<div className="text-[9px] leading-none font-bold text-info-muted-foreground">{label}</div>
<div className="mt-1 font-mono text-[16px] leading-none font-bold text-info-muted-foreground tabular-nums whitespace-nowrap">
{az != null ? `${az}°` : '—'}
</div>
</button>
);
const mainControls = (
<div className="w-[154px] min-w-[154px] shrink-0 grid gap-1.5 min-h-0"
style={{ gridTemplateRows: `repeat(3, ${rowPx}px)` }}>
{/* Where the antenna is, and under it — smaller, yellow, and only while it
matters — where it was told to go. */}
<div className="h-full min-h-0 rounded-md border border-border bg-background/30 px-1 text-center relative overflow-hidden">
{/* Two readings in ONE place, cross-faded: where the antenna is, and —
while the mouse is over the dial — where a click would send it. The
aiming figure is what the operator is reading at that moment, and
putting it somewhere else means looking away from the beam to find
it. The green one does not move, so nothing jumps when the mouse
leaves the dial. */}
<div className={cn(
'absolute left-1/2 top-1/2 -translate-x-1/2 font-mono leading-none font-bold tabular-nums whitespace-nowrap transition-all duration-300 ease-out',
tight ? 'text-[26px]' : 'text-[30px]',
targetAzimuth != null ? '-translate-y-[72%]' : '-translate-y-1/2',
hoverAzimuth != null ? 'opacity-0 scale-95' : 'opacity-100 scale-100',
displayAzimuth != null ? 'text-success' : 'text-muted-foreground',
)}>
{displayAzimuth != null ? `${displayAzimuth}°` : '—'}
</div>
<div className={cn(
'absolute left-1/2 top-1/2 -translate-x-1/2 font-mono leading-none font-bold tabular-nums whitespace-nowrap transition-all duration-300 ease-out',
tight ? 'text-[26px]' : 'text-[30px]',
targetAzimuth != null ? '-translate-y-[72%]' : '-translate-y-1/2',
hoverAzimuth != null ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none',
)}
style={{ color: COMPASS_ORANGE }}>
{hoverAzimuth != null ? `${hoverAzimuth}°` : ''}
</div>
{targetAzimuth != null && (
<div
className={cn(
'absolute left-1/2 top-[39px] -translate-x-1/2 font-mono text-[12px] leading-none font-bold tabular-nums whitespace-nowrap transition-all duration-500 ease-out select-none',
targetFading ? 'opacity-0 -translate-y-[3px]' : 'opacity-90 translate-y-0',
)}
style={{ color: TARGET_YELLOW }}
>
{targetAzimuth}°
</div>
)}
</div>
{/* Free azimuth: Enter sends, so the whole thing is type-three-digits-
and-go without reaching for the mouse. */}
<div className={cn('h-full min-h-0 grid gap-1', onStop ? 'grid-rows-2' : 'grid-rows-1')}>
<div className="h-full min-h-0 grid grid-cols-[minmax(0,1fr)_38px] gap-1">
<input
type="text"
inputMode="numeric"
value={azText}
maxLength={3}
placeholder={t('rotor.azPh')}
title={t('rotor.azTitle')}
disabled={!onGoto}
onChange={(e) => 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"
/>
<button
type="button"
onClick={sendAzimuth}
disabled={!manualAzimuthValid}
title={t('rotor.go')}
className={cn(
'w-full min-w-0 h-full flex items-center justify-center rounded-md border border-success/60 bg-success-muted px-0.5 text-[10px] font-bold text-success-muted-foreground transition-all active:scale-95',
manualAzimuthValid ? 'opacity-100 hover:bg-success/25' : 'opacity-40 cursor-not-allowed',
)}
>
{t('rotor.go')}
</button>
</div>
{/* 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 && (
<button
type="button"
onClick={pressStop}
title={t('rotor.stop')}
className={cn(
'w-full min-w-0 h-full min-h-0 flex items-center justify-center rounded-md border text-[10px] font-bold transition-all duration-150 active:scale-95',
isMoving
? 'border-destructive/80 bg-destructive/25 text-destructive hover:bg-destructive/30'
: 'border-destructive/60 bg-destructive/15 text-destructive opacity-55 hover:opacity-70',
)}
>
{t('rotor.stop')}
</button>
)}
</div>
<div className="h-full min-h-0 grid grid-cols-2 gap-1">
{pathButton('SP', shortPath)}
{pathButton('LP', longPath)}
</div>
</div>
);
const dial = (
<RotorCompassDial
azimuth={radiating}
secondary={secondLobe}
boom={showBoom}
targetAzimuth={targetAzimuth}
targetFading={targetFading}
shortPath={shortPath}
longPath={longPath}
centerLat={centerLat}
centerLon={centerLon}
onGoto={onGoto ? gotoAzimuth : undefined}
onHoverAzimuth={setHoverAzimuth}
/>
);
return (
<section
className="flex flex-col h-full min-h-0 rounded-lg border border-border bg-card overflow-hidden max-w-none shrink-0"
// Only the full widget fixes its own width — it is a grid of buttons and
// it knows how many. The dial-only form takes whatever its caller gives.
style={showControls ? { width: `${widgetWidth}px`, minWidth: `${widgetWidth}px` } : undefined}
>
<div className="flex items-center gap-2 px-3 py-1.5 shrink-0">
<Compass className="size-4 text-primary shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Rotor</span>
<span className={cn('size-2 rounded-full', rotorEnabled ? 'bg-success' : 'bg-muted-foreground/40')}
title={rotorEnabled ? 'Rotator connected' : 'Rotator disabled'} />
<div className="flex-1" />
{pattern && (
<span
className={cn('px-1.5 py-px rounded text-[9px] font-bold tracking-wide',
pattern === 'reverse' ? 'bg-warning-muted text-warning-muted-foreground'
: pattern === 'bi' ? 'bg-info-muted text-info-muted-foreground'
: 'bg-success-muted text-success-muted-foreground')}
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>
)}
{/* 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 && (
<span className="font-mono text-sm font-bold text-success tabular-nums">
{displayAzimuth != null ? `${displayAzimuth.toString().padStart(3, '0')}°` : '—'}
</span>
)}
{onClose && (
<button type="button" onClick={onClose} title="Hide rotor"
className="text-muted-foreground hover:text-foreground transition-colors">
<X className="size-3.5" />
</button>
)}
</div>
{rotors && rotors.length > 1 && (
<div className="flex flex-wrap gap-1 px-2 pt-1">
{rotors.map((name, index) => {
const active = (activeRotor ?? 0) === index;
const label = name?.trim() || `Rotor ${index + 1}`;
return (
<button key={index} type="button" onClick={() => onSelectRotor?.(index)} title={label}
className={cn(
'flex-1 min-w-[48px] px-1.5 py-0.5 rounded text-[10px] font-semibold truncate transition-colors',
active ? 'bg-success text-success-foreground' : 'bg-muted text-muted-foreground hover:bg-muted/70',
)}>
{label}
</button>
);
})}
</div>
)}
{showControls ? (
// The padding gives its share too: four pixels, which is what the
// selector row still owed after the dial and the buttons had paid.
<div className={cn('flex items-stretch gap-1.5 min-h-0', tight ? 'p-1.5' : 'p-2')}>
<div className="shrink-0" style={{ width: dialPx, minWidth: dialPx, height: dialPx, minHeight: dialPx }}>{dial}</div>
<div className="shrink-0 flex gap-1.5 min-h-0"
style={{ width: `${controlsWidth}px`, minWidth: `${controlsWidth}px` }}>
{mainControls}
{presetColumns.map((column, columnIndex) => renderPresetColumn(column, columnIndex))}
</div>
</div>
) : (
// 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.
<div className="p-2 min-h-0">{dial}</div>
)}
</section>
);
}