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.
This commit is contained in:
@@ -0,0 +1,381 @@
|
||||
// RotorCompassClassic — the original rotor dial, kept as an option.
|
||||
//
|
||||
// Settings → Rotator chooses between this and the current compass. It was
|
||||
// replaced rather than removed because the two answer the same question in
|
||||
// different ways: this one is a small light-map dial with the quick turns in a
|
||||
// column beside it, and an operator used to it should not have to relearn a
|
||||
// panel to keep working.
|
||||
//
|
||||
// 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 RotorCompassClassic({ 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user