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.
899 lines
40 KiB
TypeScript
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>
|
|
);
|
|
}
|