Files
OpsLog/frontend/src/components/RotorCompass.tsx
T
rouggy 23323c91e0 fix(autocall): the QSO in progress outranks the ladder
Six faults from an evening on 60 m, all in the same family: the engine
judging a station by what the log wants from it and forgetting what is
already under way.

- An exchange was abandoned mid-QSO. The reply lands in the same period
  the ladder is re-read, and that period was judged before the reply was
  taken into account, so a better-ranked caller took the slot from a
  station that had just come back to us. The answer is settled first now,
  and our own report counts as being inside the exchange too — which also
  protects a QSO the operator started by hand.
- A station just picked started with misses against it. Its transmit slot
  was unknown until a second decode, and with the parity unknown every
  period counted, including the one spent transmitting to it.
- The freed slot after "it is working somebody else" was thrown away: the
  period's decodes are in hand, so the next station is picked from them
  rather than fifteen seconds later. Never mid-over.
- Auto-call is never armed from a stored setting — not at launch, not on
  a profile switch. It is the one feature that puts the station on the
  air by itself and OpsLog starts with Windows.
- It says what it is waiting for: a wanted station in a QSO with somebody
  else now shows beside the Auto button instead of looking idle.
- Switching profile left the previous logbook's verdicts on screen. The
  worked-index, chase-new and the frontend's cached verdicts are dropped
  when the logbook changes.

FT decodes: distance column, a message addressed to you set whole in
green (the station you are calling keeps a tint — most of what it sends
goes to other people), badge order L / Wkd / WL, list cleared when the
RIG changes band.

Rotor: new world-map compass from EC1KD's design, with the Ultrabeam boom
and second lobe restored and the compact form preserved; the classic dial
is kept and Settings → Rotator chooses between them. Stop no longer
flickers on a rotor standing still — movement was inferred from a degree,
less than the jitter a controller reports at rest.
2026-09-05 23:02:31 +02:00

899 lines
40 KiB
TypeScript

// 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<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,
}: {
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<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]);
// 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<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);
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);
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(angle));
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(angle));
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` }} />
);
};
// 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 (
<g
className={colour ? undefined : 'text-success'}
pointerEvents="none"
style={{
transform: `rotate(${rotation}deg)`,
transformOrigin: `${CENTER}px ${CENTER}px`,
opacity,
transition: animated ? 'transform 350ms ease-out' : 'transform 70ms linear, opacity 120ms ease-out',
}}
>
<path d={`M ${CENTER - 13} ${baseY} L ${CENTER} ${tipY} L ${CENTER + 13} ${baseY}`}
fill="none" stroke={colour ?? 'currentColor'} strokeWidth="5.5"
strokeLinecap="round" strokeLinejoin="round" />
<path d={`M ${CENTER - 4.5} ${baseY - 2} L ${CENTER} ${innerTipY} L ${CENTER + 4.5} ${baseY - 2}`}
fill="none" stroke={colour ?? 'currentColor'} strokeWidth="2"
strokeLinecap="round" strokeLinejoin="round" opacity="0.6" />
</g>
);
};
return (
<div className="w-full h-full min-w-0 aspect-square rounded-md border border-border bg-background 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>
</defs>
<rect x="0" y="0" width={SIZE} height={SIZE} fill={`url(#${bgGradientId})`} />
{landPath && (
<g mask={`url(#${mapFadeMaskId})`} opacity="0.78" 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 = 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 (
<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 = squarePoint(angle, SCALE_HALF + 20);
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>
<g pointerEvents="none">
{([['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]) => (
<text key={label} x={x} y={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 && 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 (
<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: same pointer, dimmed. */}
{secondary != null && renderPointer(normalizeAzimuth(secondary), null, 0.42, true)}
{azimuth != null && renderPointer(normalizeAzimuth(azimuth), null, 0.96, 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);
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]);
// 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) => (
<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 grid-rows-[60px_60px_60px] gap-1.5 min-h-0">
{[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 grid-rows-[60px_60px_60px] gap-1.5 min-h-0">
{/* 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">
<div className={cn(
'absolute left-1/2 top-1/2 -translate-x-1/2 font-mono text-[30px] leading-none font-bold tabular-nums whitespace-nowrap transition-all duration-300 ease-out',
targetAzimuth != null ? '-translate-y-[72%]' : '-translate-y-1/2',
displayAzimuth != null ? 'text-success' : 'text-muted-foreground',
)}>
{displayAzimuth != null ? `${displayAzimuth}°` : '—'}
</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}
/>
);
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.5">
{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 ? (
<div className="flex items-stretch gap-1.5 p-2 min-h-0">
<div className="w-[192px] min-w-[192px] h-[192px] min-h-[192px] shrink-0">{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>
);
}