Three faults an operator's log finally made visible, plus the interface work that came out of the same session. Reliability: - UDP events were dropped on backpressure without a word. A period hands over twenty-odd decodes at once, and one slow write to the radio was enough to fill the queue — so a decode simply never appeared, and the only detector was the operator comparing the panel with JTDX. The drop is now counted and logged, panadapter spots went to their own goroutine so the radio can no longer hold the decode stream up, and the queue is deep enough for a full period. - The CAT manager waited for its poll loop with a bare <-done. A loop wedged in a serial read then blocked every later restart inside Start, before it could even try to connect: the rig stayed dead, no line was written anywhere, and only killing the process recovered it. The wait is bounded at ten seconds and says what it abandoned and why the next connect may fail. - Shutdown had no logging at all, so a hang left nothing to go on and a process the operator had to kill — which then blocked the restart after an update. Every step is logged, and a watchdog forces the exit if one of them never returns. Auto-call: - A QSO in progress is now held by OpsLog itself rather than inferred from the sender's Status. The moment WSJT-X/JTDX dropped the DX call or the Enable-Tx flag between overs, the exchange looked finished and the next CQ was answered, interleaving two and then three QSOs on one slice. Released on log, on halt, on taking over, and by a watchdog. Cluster console: - Replies to a command were buried under the spot flood; a Replies toggle hides the DX spots, which the list above already shows. - Twelve named command buttons beside the input, configured in Settings -> Cluster; a button with no command is not drawn. - Following the tail is now an explicit switch, and sending a command re-arms it. It used to measure "am I at the bottom" AFTER committing the new lines, so a ten-line reply looked like the operator had scrolled up and was never followed — the one case it exists for. Awards: - An award can name NO field. The matching controls disappear with it and only hand-assigned references count, which is the only thing that can feed a reference like WWBOTA. A test pins that nothing else is scanned. - WWBOTA added to the catalogue with its 31 342 references. Elsewhere: the rotor widget's Stop button acknowledges the press like the direction presets already did, and the docked band map can be switched to fit-to-band from its own header.
374 lines
18 KiB
TypeScript
374 lines
18 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 green azimuth bezel. A green needle shows the antenna heading (two needles
|
|
// when an Ultrabeam is bidirectional, the opposite one when reversed); a small
|
|
// red marker on the bezel shows the short-path bearing to the DX. Click the dial
|
|
// to turn the antenna there.
|
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { geoAzimuthalEquidistant, geoPath, geoGraticule10 } from 'd3-geo';
|
|
import { feature } from 'topojson-client';
|
|
import landTopo from 'world-atlas/land-110m.json';
|
|
import { Compass, X, Play, Square } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { useI18n } from '@/lib/i18n';
|
|
|
|
// Decode the coastline outline once (≈110 m simplified land polygons).
|
|
const LAND = feature(landTopo as any, (landTopo as any).objects.land);
|
|
const GRATICULE = geoGraticule10();
|
|
|
|
interface Props {
|
|
bearing?: number | null; // short-path azimuth to DX (deg)
|
|
headings: number[]; // radiating heading(s) — rotor + Ultrabeam pattern
|
|
boomHeading?: number | null; // mechanical boom (rotor) azimuth, shown grey when it differs
|
|
pattern?: 'normal' | 'reverse' | 'bi' | null; // Ultrabeam pattern (for the badge)
|
|
centerLat?: number | null; // operator latitude (projection centre)
|
|
centerLon?: number | null; // operator longitude
|
|
rotorEnabled?: boolean;
|
|
rotors?: string[]; // logical rotor names; >1 → show a selector
|
|
activeRotor?: number; // index of the selected rotor (0-based)
|
|
onSelectRotor?: (i: number) => void; // switch the active rotor
|
|
onGoto?: (az: number) => void; // click-to-turn
|
|
onClose?: () => void;
|
|
// Quick-turn buttons and the azimuth box, shown only where the caller wants
|
|
// them: Station Control draws its own GoTo/Stop around this compass, and two
|
|
// sets of the same controls side by side would be nothing but confusing.
|
|
presets?: { label: string; azimuth: number }[];
|
|
onStop?: () => void;
|
|
}
|
|
|
|
const SIZE = 168;
|
|
const C = SIZE / 2;
|
|
const R = C - 6; // outer bezel radius
|
|
const MAP_R = R - 6; // map/clip radius (inside the bezel)
|
|
|
|
function pt(az: number, radius: number): [number, number] {
|
|
const a = ((az - 90) * Math.PI) / 180;
|
|
return [C + radius * Math.cos(a), C + radius * Math.sin(a)];
|
|
}
|
|
|
|
export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, rotors, activeRotor, onSelectRotor, onGoto, onClose, presets, onStop }: Props) {
|
|
const { t } = useI18n();
|
|
// Raw text, not a number: binding the input to a normalised value makes
|
|
// Backspace fight the operator on the way from "230" to "23". It is parsed
|
|
// when it is sent, and only then.
|
|
const [azText, setAzText] = useState('');
|
|
const showControls = !!(presets || onStop);
|
|
|
|
// Which preset was just pressed, so it can light up for a moment.
|
|
//
|
|
// A rotor takes seconds to start moving and the needle barely twitches at
|
|
// first, so without this the only answer to "did that register?" is to press
|
|
// it again — which is how an antenna ends up ordered somewhere twice. The
|
|
// acknowledgement has to come from the button itself, at once.
|
|
const [flashIdx, setFlashIdx] = useState<number | null>(null);
|
|
const flashTimer = useRef<number | undefined>(undefined);
|
|
useEffect(() => () => window.clearTimeout(flashTimer.current), []);
|
|
const pressPreset = (i: number, az: number) => {
|
|
if (!onGoto) return;
|
|
setFlashIdx(i);
|
|
window.clearTimeout(flashTimer.current);
|
|
flashTimer.current = window.setTimeout(() => setFlashIdx(null), 450);
|
|
onGoto(az);
|
|
};
|
|
|
|
// Stop needs the same acknowledgement, for the same reason and one more: it
|
|
// is pressed when something is already wrong, and a button that stays inert
|
|
// gets hit again and again. Its own flag, so stopping does not blank a preset
|
|
// that is still lit.
|
|
const [stopFlash, setStopFlash] = useState(false);
|
|
const stopTimer = useRef<number | undefined>(undefined);
|
|
useEffect(() => () => window.clearTimeout(stopTimer.current), []);
|
|
const pressStop = () => {
|
|
if (!onStop) return;
|
|
setStopFlash(true);
|
|
window.clearTimeout(stopTimer.current);
|
|
stopTimer.current = window.setTimeout(() => setStopFlash(false), 450);
|
|
onStop();
|
|
};
|
|
|
|
// 0-359 and nothing else. 360 is refused rather than folded to 0 — it is
|
|
// almost always a typo for 36 or 306, and a rotor swinging through north on a
|
|
// slip of the finger is worth one rejected keypress.
|
|
const sendAz = () => {
|
|
const s = azText.trim();
|
|
if (s === '' || !onGoto) return;
|
|
const n = Number(s);
|
|
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0 || n > 359) return;
|
|
onGoto(n);
|
|
setAzText('');
|
|
};
|
|
|
|
const cardinals = useMemo(
|
|
() => [ { d: 0, l: 'N' }, { d: 45, l: 'NE' }, { d: 90, l: 'E' }, { d: 135, l: 'SE' },
|
|
{ d: 180, l: 'S' }, { d: 225, l: 'SW' }, { d: 270, l: 'W' }, { d: 315, l: 'NW' } ],
|
|
[],
|
|
);
|
|
|
|
// Project the world centred on the QTH (north up; antipode at the bezel).
|
|
const { land, grat } = useMemo(() => {
|
|
if (centerLat == null || centerLon == null) return { land: '', grat: '' };
|
|
const proj = geoAzimuthalEquidistant()
|
|
.rotate([-centerLon, -centerLat])
|
|
.clipAngle(179.9)
|
|
.scale(MAP_R / Math.PI)
|
|
.translate([C, C]);
|
|
const path = geoPath(proj as any);
|
|
return { land: path(LAND as any) || '', grat: path(GRATICULE as any) || '' };
|
|
}, [centerLat, centerLon]);
|
|
|
|
function handleClick(e: React.MouseEvent<SVGSVGElement>) {
|
|
if (!onGoto) return;
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const x = ((e.clientX - rect.left) / rect.width) * SIZE - C;
|
|
const y = ((e.clientY - rect.top) / rect.height) * SIZE - C;
|
|
let az = (Math.atan2(y, x) * 180) / Math.PI + 90;
|
|
az = ((az % 360) + 360) % 360;
|
|
onGoto(Math.round(az));
|
|
}
|
|
|
|
const headLabel = headings.length ? headings[0] : null;
|
|
|
|
// Short and long path to the DX, in figures.
|
|
//
|
|
// The bezel already carries the short path as a red marker, but a marker is a
|
|
// direction, not a number — the same pair sits in the status bar at 10px and
|
|
// operators reported not being able to read it. It lives here because it
|
|
// belongs to the compass: every place that draws one gets the readout, instead
|
|
// of each caller inventing its own. Clickable when the caller can turn, like
|
|
// the status bar's. Built as a value because it is placed in one of two
|
|
// columns depending on whether the controls are shown.
|
|
const pathReadout = (
|
|
<div className="flex gap-1.5 mt-2 font-mono w-full">
|
|
{([['SP', bearing ?? null], ['LP', bearing == null ? null : (bearing + 180) % 360]] as const).map(([lbl, az]) => (
|
|
<button key={lbl} type="button" disabled={az == null || !onGoto}
|
|
onClick={() => { if (az != null && onGoto) onGoto(Math.round(az)); }}
|
|
title={az == null ? '' : `${lbl} ${Math.round(az)}°`}
|
|
className={
|
|
'flex-1 rounded-md border py-1 text-xs font-semibold tabular-nums transition-colors active:scale-95 ' +
|
|
(az == null
|
|
? 'border-border text-muted-foreground/50 cursor-not-allowed'
|
|
: onGoto
|
|
? 'border-info-border text-info-muted-foreground hover:bg-info-muted cursor-pointer'
|
|
: 'border-border text-muted-foreground cursor-default')
|
|
}>
|
|
{lbl} {az == null ? '—' : `${Math.round(az)}°`}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<section className="flex flex-col h-full min-h-0 rounded-lg border border-border bg-card overflow-hidden">
|
|
{/* Header — matches the WinKeyer / Voice keyer panels. */}
|
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 border-b border-border 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 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>
|
|
)}
|
|
<span className="font-mono text-sm font-bold text-success tabular-nums">
|
|
{headLabel != null ? `${Math.round(headLabel).toString().padStart(3, '0')}°` : '—'}
|
|
</span>
|
|
{onClose && (
|
|
<button className="text-muted-foreground hover:text-foreground" title="Hide rotor" onClick={onClose}>
|
|
<X className="size-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Multiple rotors — pick which one the dial shows and turns. */}
|
|
{rotors && rotors.length > 1 && (
|
|
<div className="flex flex-wrap gap-1 px-2 pt-1.5">
|
|
{rotors.map((nm, i) => {
|
|
const active = (activeRotor ?? 0) === i;
|
|
const label = nm?.trim() || `Rotor ${i + 1}`;
|
|
return (
|
|
<button
|
|
key={i}
|
|
onClick={() => onSelectRotor?.(i)}
|
|
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',
|
|
)}
|
|
title={label}
|
|
>
|
|
{label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Dial on the left, controls on the right. The controls column is sized to
|
|
fit WITHIN the dial's height: the widget sits in a row whose height is
|
|
set by the entry strip, so it may grow sideways but never downwards. */}
|
|
<div className="flex items-start gap-2 p-2 min-h-0">
|
|
{/* flex-col: the readout goes BELOW the dial. This wrapper was a row, so a
|
|
sibling of the <svg> landed beside it. */}
|
|
<div className="flex flex-col items-center justify-center min-h-0 shrink-0">
|
|
<svg
|
|
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
|
className={onGoto ? 'cursor-pointer select-none' : 'select-none'}
|
|
style={{ width: SIZE, height: SIZE }}
|
|
onClick={handleClick}
|
|
>
|
|
<defs>
|
|
<clipPath id="rotorDial"><circle cx={C} cy={C} r={MAP_R} /></clipPath>
|
|
</defs>
|
|
{/* water + world map, clipped to the dial */}
|
|
<circle cx={C} cy={C} r={MAP_R} fill="#d3e7f1" />
|
|
<g clipPath="url(#rotorDial)">
|
|
{grat && <path d={grat} fill="none" stroke="#9cc0d6" strokeWidth={0.4} opacity={0.7} />}
|
|
{land && <path d={land} fill="#dfe2cf" stroke="#9aa589" strokeWidth={0.4} />}
|
|
</g>
|
|
{/* green azimuth bezel */}
|
|
<circle cx={C} cy={C} r={R} fill="none" stroke="#16a34a" strokeWidth={5} />
|
|
|
|
{/* ticks every 10°, longer at 30° */}
|
|
{Array.from({ length: 36 }, (_, i) => i * 10).map((d) => {
|
|
const major = d % 30 === 0;
|
|
const [x1, y1] = pt(d, MAP_R);
|
|
const [x2, y2] = pt(d, MAP_R - (major ? 7 : 4));
|
|
return <line key={d} x1={x1} y1={y1} x2={x2} y2={y2} stroke="#475569" strokeWidth={major ? 1 : 0.6} opacity={0.7} />;
|
|
})}
|
|
{/* cardinal labels + degree numbers at 45° */}
|
|
{cardinals.map(({ d, l }) => {
|
|
const [x, y] = pt(d, MAP_R - 13);
|
|
return <text key={l} x={x} y={y} textAnchor="middle" dominantBaseline="central" className="fill-slate-700" style={{ fontSize: l.length > 1 ? 7 : 9, fontWeight: 700 }}>{l}</text>;
|
|
})}
|
|
|
|
{/* DX short-path bearing → small red marker on the bezel */}
|
|
{bearing != null && (() => { const [x, y] = pt(bearing, MAP_R); return (
|
|
<circle cx={x} cy={y} r={3} fill="#dc2626" stroke="#fff" strokeWidth={1} />
|
|
); })()}
|
|
|
|
{/* mechanical boom (rotor) heading — grey dashed needle, shown when the
|
|
Ultrabeam radiates somewhere other than the boom (reverse/bi) so the
|
|
operator sees where the antenna physically points vs where it boom-sits */}
|
|
{boomHeading != null && pattern && pattern !== 'normal' && (() => {
|
|
const [x, y] = pt(boomHeading, MAP_R - 2);
|
|
return (
|
|
<g>
|
|
<title>Boom (rotor) {Math.round(boomHeading)}°</title>
|
|
<line x1={C} y1={C} x2={x} y2={y} stroke="#64748b" strokeWidth={2} strokeDasharray="3 3" strokeLinecap="round" />
|
|
<circle cx={x} cy={y} r={3} fill="#64748b" stroke="#fff" strokeWidth={1} />
|
|
</g>
|
|
);
|
|
})()}
|
|
|
|
{/* radiating heading needle(s) — green; two when bidirectional */}
|
|
{headings.map((h, i) => { const [x, y] = pt(h, MAP_R - 2); return (
|
|
<g key={i}>
|
|
<line x1={C} y1={C} x2={x} y2={y} stroke="#15803d" strokeWidth={3} strokeLinecap="round" opacity={i === 0 ? 1 : 0.55} />
|
|
<polygon points={`${x},${y} ${pt(h - 5, MAP_R - 12).join(',')} ${pt(h + 5, MAP_R - 12).join(',')}`} fill="#15803d" opacity={i === 0 ? 1 : 0.55} />
|
|
</g>
|
|
); })}
|
|
<circle cx={C} cy={C} r={3.5} fill="#15803d" stroke="#fff" strokeWidth={1} />
|
|
</svg>
|
|
|
|
{/* With the controls column present the readout goes at the FOOT OF IT
|
|
instead: the dial sets the widget's height, the controls are shorter
|
|
than the dial, and that leftover space is exactly the right size for
|
|
the pair. Under the dial it would push the whole widget taller. */}
|
|
{!showControls && pathReadout}
|
|
</div>
|
|
|
|
{/* Quick turns + free azimuth + Stop. */}
|
|
{showControls && (
|
|
<div className="flex flex-col gap-1.5 flex-1 min-w-0">
|
|
{/* Two columns so six regions fit beside the dial rather than under
|
|
it. An operator with fewer keeps the same compact block. */}
|
|
{!!presets?.length && (
|
|
<div className="grid grid-cols-2 gap-1">
|
|
{presets.map((p, i) => (
|
|
<button
|
|
key={`${p.label}-${i}`}
|
|
type="button"
|
|
disabled={!onGoto}
|
|
onClick={() => pressPreset(i, p.azimuth)}
|
|
title={`${p.label} — ${p.azimuth}°`}
|
|
className={cn(
|
|
'rounded-md border py-1 text-xs font-semibold truncate transition-all duration-150 active:scale-95',
|
|
flashIdx === i
|
|
// Lit, and showing the azimuth it just sent: the label alone
|
|
// would only say the press landed, not what was ordered.
|
|
? 'border-success bg-success text-success-foreground scale-95'
|
|
: 'border-border bg-muted/40',
|
|
onGoto && flashIdx !== i ? 'hover:bg-muted' : '',
|
|
!onGoto ? 'opacity-50 cursor-not-allowed' : '',
|
|
)}
|
|
>
|
|
{flashIdx === i ? `${p.azimuth}°` : p.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Free azimuth: Enter sends, so the whole thing is type-three-digits
|
|
-and-go without reaching for the mouse. */}
|
|
<div className="flex gap-1">
|
|
<input
|
|
type="text"
|
|
inputMode="numeric"
|
|
value={azText}
|
|
onChange={(e) => setAzText(e.target.value.replace(/[^0-9]/g, '').slice(0, 3))}
|
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendAz(); } }}
|
|
placeholder={t('rotor.azPh')}
|
|
title={t('rotor.azTitle')}
|
|
disabled={!onGoto}
|
|
className="min-w-0 flex-1 rounded-md border border-border bg-background px-2 py-1 text-xs font-mono tabular-nums text-center disabled:opacity-50"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={sendAz}
|
|
disabled={!onGoto || azText.trim() === ''}
|
|
title={t('rotor.go')}
|
|
className="flex items-center gap-1 rounded-md border border-success/60 bg-success-muted px-2 py-1 text-xs font-bold text-success-muted-foreground transition-transform hover:bg-success/25 active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
>
|
|
<Play className="size-3" /> {t('rotor.go')}
|
|
</button>
|
|
</div>
|
|
|
|
{onStop && (
|
|
<button
|
|
type="button"
|
|
onClick={pressStop}
|
|
title={t('rotor.stop')}
|
|
className={cn(
|
|
'flex items-center justify-center gap-1.5 rounded-md border py-1 text-xs font-bold transition-all duration-150 active:scale-95',
|
|
stopFlash
|
|
// Solid, not a tint: STOP reads the same in both languages, so
|
|
// the fill is the whole acknowledgement.
|
|
? 'border-destructive bg-destructive text-destructive-foreground scale-95'
|
|
: 'border-destructive/60 bg-destructive/15 text-destructive hover:bg-destructive/25',
|
|
)}
|
|
>
|
|
<Square className="size-3 fill-current" /> {t('rotor.stop')}
|
|
</button>
|
|
)}
|
|
|
|
{/* mt-auto: the readout sits at the FOOT of the column, level with the
|
|
bottom of the dial, instead of floating under the Stop button. */}
|
|
<div className="mt-auto">{pathReadout}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|