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:
+38
-3
@@ -119,6 +119,8 @@ import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
|
||||
import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
|
||||
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
|
||||
import { RotorCompass } from '@/components/RotorCompass';
|
||||
import { RotorCompassClassic } from '@/components/RotorCompassClassic';
|
||||
import { rotorStyle, subscribeRotorStyle } from '@/lib/rotorStyle';
|
||||
import { GridSquareMap } from '@/components/GridSquareMap';
|
||||
import { loadClusterMacros, visibleClusterMacros } from '@/lib/clusterMacros';
|
||||
import { DecodesPanel, type Decode as DecodeRow, type TxMsg as TxMsgRow } from '@/components/DecodesPanel';
|
||||
@@ -2612,6 +2614,30 @@ export default function App() {
|
||||
// The band each receiver was last decoding on. A band change empties that
|
||||
// receiver's list — see the flush below.
|
||||
const decoderBandRef = useRef<Map<string, string>>(new Map());
|
||||
|
||||
// The RIG changing band empties the list too, without waiting for the
|
||||
// decoder to catch up.
|
||||
//
|
||||
// Clicking a watchlist row or a spot tunes the radio; the decoder follows on
|
||||
// its own clock and its first decode on the new band is up to a period away.
|
||||
// Until then the operator reads a screen of stations from a band they have
|
||||
// left — which is the moment the list is most misleading, because it looks
|
||||
// current. Only the receivers that were on the band we came FROM are
|
||||
// cleared: a second decoder parked on another band has not moved.
|
||||
const rigBandRef = useRef<string>('');
|
||||
useEffect(() => {
|
||||
const band = (catState.band ?? '').toLowerCase();
|
||||
const was = rigBandRef.current;
|
||||
rigBandRef.current = band;
|
||||
if (!band || !was || was === band) return;
|
||||
const stale = new Set<string>();
|
||||
decoderBandRef.current.forEach((b2, inst) => { if (b2 === was) stale.add(inst); });
|
||||
if (stale.size === 0) return;
|
||||
stale.forEach((inst) => decoderBandRef.current.delete(inst));
|
||||
pendingDecodesRef.current = pendingDecodesRef.current.filter((d) => !stale.has(d.instance ?? ''));
|
||||
setDecodes((arr) => arr.filter((d) => !stale.has(d.instance ?? '')));
|
||||
setTxMsgs((arr) => arr.filter((m) => !stale.has(m.instance ?? '')));
|
||||
}, [catState.band]);
|
||||
const pendingDecodeTimer = useRef<number | undefined>(undefined);
|
||||
|
||||
// Rotor quick-turn buttons (Settings → Rotator). Same reload trigger as the
|
||||
@@ -2774,6 +2800,11 @@ export default function App() {
|
||||
// withholding them IS the compact mode, so there is no second layout to keep
|
||||
// in step with the first.
|
||||
const [rotorCompact, setRotorCompact] = useState(() => localStorage.getItem('opslog.rotorCompact') === '1');
|
||||
// Which dial (Settings → Rotator). Subscribed rather than re-read on close, so
|
||||
// the choice shows the moment it is made — it is picked by looking at it.
|
||||
const [rotorDial, setRotorDial] = useState(() => rotorStyle());
|
||||
useEffect(() => subscribeRotorStyle(() => setRotorDial(rotorStyle())), []);
|
||||
const Compass2 = rotorDial === 'classic' ? RotorCompassClassic : RotorCompass;
|
||||
|
||||
// Award code → scanned field (e.g. POTA→pota_ref, WWFF→wwff). Used to route
|
||||
// picked award references to the QSO field/extras each award actually reads.
|
||||
@@ -6373,6 +6404,7 @@ export default function App() {
|
||||
// compare with", never "the rig is on no band".
|
||||
rigBand={catState.connected ? (catState.band || '') : ''}
|
||||
myCall={station.callsign}
|
||||
myGrid={station.my_grid}
|
||||
// A DOUBLE click answers the station: it hands the decode back to
|
||||
// WSJT-X/MSHV as a Reply, the same thing as double-clicking the line in
|
||||
// their own window. A single click only selects it — see onSelect below,
|
||||
@@ -7558,10 +7590,13 @@ export default function App() {
|
||||
{/* Rotor compass: azimuth dial + needles + click-to-turn. Shows when a
|
||||
rotator is configured or a DX bearing exists. Compact mode drops the
|
||||
controls column, so the widget is just the dial and needs only its
|
||||
width. */}
|
||||
width. The classic dial sizes itself from the inside and expects a fixed
|
||||
column; the current one asks for the width it needs. */}
|
||||
{showRotor && (rotatorHeading.enabled || dxPath) && (
|
||||
<div className={cn('shrink-0 min-h-0', rotorCompact ? 'w-[196px]' : 'w-[320px]')} style={{ order: wOrder('rotor') }}>
|
||||
<RotorCompass
|
||||
<div className={cn('shrink-0 min-h-0',
|
||||
rotorCompact ? 'w-[196px]' : rotorDial === 'classic' ? 'w-[320px]' : 'w-auto')}
|
||||
style={{ order: wOrder('rotor') }}>
|
||||
<Compass2
|
||||
presets={rotorCompact ? undefined : rotorPresets}
|
||||
onStop={rotorCompact ? undefined : () => { RotatorStop().then(pokeRotorHeading).catch((err) => setError(String(err?.message ?? err))); }}
|
||||
bearing={dxPath?.bearingShort ?? null}
|
||||
|
||||
@@ -17,6 +17,8 @@ import { AlertTriangle, Radio, Search, X, Signal, ArrowUpRight, Timer, Trash2, B
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { chaseAllows } from '@/lib/spotDisplay';
|
||||
import { pathBetween } from '@/lib/maidenhead';
|
||||
import { distanceValue, distanceUnit, subscribeDistanceUnit } from '@/lib/units';
|
||||
import { markerColour, type SpotMarkerKey } from '@/lib/spotMarkers';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { SetAutoCallVisible } from '../../wailsjs/go/main/App';
|
||||
@@ -105,6 +107,9 @@ interface Props {
|
||||
// point the panels at it. Absent, a click falls back to onCall.
|
||||
onSelect?: (d: Decode) => void;
|
||||
myCall?: string;
|
||||
// The station's own square: distance is measured from it, and without one the
|
||||
// column stays empty rather than guessing.
|
||||
myGrid?: string;
|
||||
// Drop every decode and transmit message held for this panel. The list is a
|
||||
// live view, not data — clearing it costs nothing but the seconds until the
|
||||
// next period lands.
|
||||
@@ -121,7 +126,7 @@ interface Props {
|
||||
autoCallOn?: boolean;
|
||||
onToggleAutoCall?: () => void;
|
||||
// The engine's own account of what it is doing, straight from the backend.
|
||||
autoCall?: { target: string; calls: number; max: number; misses: number; max_miss: number; stopped: boolean; reason: string };
|
||||
autoCall?: { target: string; waiting: string; calls: number; max: number; misses: number; max_miss: number; stopped: boolean; reason: string };
|
||||
// The chase list, here as well as in Preferences: naming the station you are
|
||||
// waiting for is done WHILE watching the band, not in a settings tree.
|
||||
autoCallOnly?: string;
|
||||
@@ -271,7 +276,7 @@ const CELL_LAST = 'flex items-center min-w-0 px-2 gap-1 overflow-hidden';
|
||||
// One declaration per column, in display order: the header, the widths and the
|
||||
// resize handles all read from this, so a column cannot be resized in the header
|
||||
// and stay the old width in the body.
|
||||
type ColKey = 'time' | 'rx' | 'snr' | 'dt' | 'freq' | 'band' | 'mode' | 'msg' | 'grid' | 'state' | 'country' | 'status';
|
||||
type ColKey = 'time' | 'rx' | 'snr' | 'dt' | 'freq' | 'band' | 'mode' | 'msg' | 'grid' | 'dist' | 'state' | 'country' | 'status';
|
||||
const COLS: { key: ColKey; tkey: string; def: number; min: number }[] = [
|
||||
{ key: 'time', tkey: 'dec.colTime', def: 64, min: 44 },
|
||||
// WHICH RECEIVER heard it. Shown only while more than one is feeding, and
|
||||
@@ -292,6 +297,11 @@ const COLS: { key: ColKey; tkey: string; def: number; min: number }[] = [
|
||||
{ key: 'grid', tkey: 'dec.colGrid', def: 62, min: 46 },
|
||||
// For the WAS chasers: the badge carries the two letters, the name spells
|
||||
// them out — "SD" alone is a quiz for a European.
|
||||
// Next to the square it is computed from. A four-character grid is a square
|
||||
// tens of kilometres wide, so this is rounded to whole units and never
|
||||
// pretends to more: it answers "is that station across the pond or across the
|
||||
// valley", which is what decides whether the report is worth anything.
|
||||
{ key: 'dist', tkey: 'dec.colDist', def: 70, min: 46 },
|
||||
{ key: 'state', tkey: 'dec.colState', def: 120, min: 56 },
|
||||
{ key: 'country', tkey: 'dec.colCountry', def: 140, min: 70 },
|
||||
{ key: 'status', tkey: 'dec.colStatus', def: 186, min: 80 },
|
||||
@@ -446,16 +456,31 @@ function periodLabel(ms: number, trSec: number): string {
|
||||
// read "CQ CQ PE1NAO JO32" — the badge and the message's own first word saying
|
||||
// the same thing twice. Highlighting the word already in the line keeps the
|
||||
// scannability and drops the stutter.
|
||||
function renderMsg(msg: string, me: string, calling: string) {
|
||||
function renderMsg(msg: string, me: string, calling: string, toMe: boolean) {
|
||||
if (!msg) return null;
|
||||
// THE WHOLE LINE, when it is addressed to YOU.
|
||||
//
|
||||
// Token colouring is for reading the band — it picks your call out of a wall
|
||||
// of other people's traffic. A message sent TO you is a different question:
|
||||
// not "is my call in there somewhere" but "what did he just send me", read
|
||||
// from the far side of the shack, so the whole line carries the colour.
|
||||
//
|
||||
// Only that case. The station you are calling also transmits to everybody
|
||||
// else — "LA8WRA LA1RAU/P +00" is your target reporting to another caller —
|
||||
// and setting those lines in the same strong colour said you were in a QSO
|
||||
// you were not in. Its callsign is picked out by the token pass below, which
|
||||
// is what "he is on the air, working somebody else" should look like.
|
||||
if (toMe) {
|
||||
return <span className="font-bold text-success">{msg}</span>;
|
||||
}
|
||||
// Split on whitespace and colour the tokens that matter, rather than the
|
||||
// whole line: an operator scanning a slot is looking for their own call in
|
||||
// the first position (someone answering) and for the station being called.
|
||||
const parts = msg.split(/(s+)/);
|
||||
const parts = msg.split(/(\s+)/);
|
||||
return (
|
||||
<>
|
||||
{parts.map((tok, i) => {
|
||||
if (/^s+$/.test(tok)) return tok;
|
||||
if (/^\s+$/.test(tok)) return tok;
|
||||
const bare = tok.replace(/[<>]/g, '').toUpperCase();
|
||||
if (i === 0 && /^CQ$/i.test(tok)) return <span key={i} className="font-bold text-success">{tok.toUpperCase()}</span>;
|
||||
if (me && bare === me) return <span key={i} className="font-bold text-success">{tok}</span>;
|
||||
@@ -583,7 +608,7 @@ function buildPeriods(filtered: Decode[], txMsgs: TxMsg[]) {
|
||||
}));
|
||||
}
|
||||
|
||||
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, onCall, onSelect, myCall, onClear, onHalt, autoCallOn, onToggleAutoCall, autoCall, autoCallOnly, onSetAutoCallOnly, watchlist }: Props) {
|
||||
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, onCall, onSelect, myCall, myGrid, onClear, onHalt, autoCallOn, onToggleAutoCall, autoCall, autoCallOnly, onSetAutoCallOnly, watchlist }: Props) {
|
||||
const { t } = useI18n();
|
||||
// Column widths, dragged in the header and shared by every row. Persisted
|
||||
// through writeUiPref (not raw localStorage) so the layout travels with data/
|
||||
@@ -674,6 +699,8 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
||||
// opens with our callsign, sometimes bracketed when the sender compressed a
|
||||
// non-standard call.
|
||||
const me = (myCall ?? '').toUpperCase();
|
||||
const [, bumpUnit] = useState(0);
|
||||
useEffect(() => subscribeDistanceUnit(() => bumpUnit((n) => n + 1)), []);
|
||||
const calling = (txState?.dx_call ?? '').toUpperCase();
|
||||
const answersMe = (msg?: string): boolean => {
|
||||
if (!me || !msg) return false;
|
||||
@@ -988,6 +1015,14 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
||||
{autoCall.misses > 0 ? ` ·${autoCall.misses}/${autoCall.max_miss}` : ''}
|
||||
</span>
|
||||
)}
|
||||
{/* Wanted, decoded, and in a QSO with somebody else. Holding fire
|
||||
looks exactly like having nothing to do, and the operator had no
|
||||
way to tell them apart. */}
|
||||
{autoCallOn && !autoCall?.target && autoCall?.waiting && (
|
||||
<span className="font-mono text-xs animate-pulse" title={t('dec.autoWaitTip', { call: autoCall.waiting })}>
|
||||
{autoCall.waiting} ⏳
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{/* The chase list. Raw text while typing, committed on blur or Enter:
|
||||
@@ -1145,9 +1180,9 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
||||
i < cols.length - 1 && 'border-r border-border/30',
|
||||
// The three numeric columns label their own right edge, where the
|
||||
// figures are.
|
||||
(c.key === 'snr' || c.key === 'dt' || c.key === 'freq') && 'justify-end')}
|
||||
(c.key === 'snr' || c.key === 'dt' || c.key === 'freq' || c.key === 'dist') && 'justify-end')}
|
||||
title={c.key === 'dt' ? t('dec.colDtTitle') : c.key === 'freq' ? t('dec.colFreqTitle') : undefined}>
|
||||
<span className="truncate">{t(c.tkey)}</span>
|
||||
<span className="truncate">{c.key === 'dist' ? `${t(c.tkey)} (${distanceUnit()})` : t(c.tkey)}</span>
|
||||
<ColResizer
|
||||
onResize={(dx) => setColWidth(c.key, colw[c.key] + dx)}
|
||||
onReset={() => setColWidth(c.key, c.def)}
|
||||
@@ -1227,7 +1262,10 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
||||
title={t('dec.callTitle', { call: d.call })}
|
||||
style={{ gridTemplateColumns: template, width: tableW }}
|
||||
className={cn(ROW, 'text-left border-b border-border/20 transition-colors',
|
||||
replying ? 'bg-success/20 hover:bg-success/25'
|
||||
replying ? 'bg-success/25 hover:bg-success/30 border-l-2 border-l-success'
|
||||
// The station being called: a tint saying "he is on the
|
||||
// air", not the QSO treatment above — most of what he
|
||||
// sends is to other people.
|
||||
: worked ? 'bg-danger/15 hover:bg-danger/20'
|
||||
: mine ? 'bg-info/10'
|
||||
: 'hover:bg-muted/50')}
|
||||
@@ -1282,7 +1320,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
||||
{/* The message carries the callsign already — which is why
|
||||
there is no column repeating it. */}
|
||||
<span className={cn(CELL, 'font-mono text-[13px]')}>
|
||||
<span className="truncate">{renderMsg(d.msg ?? '', me, calling)}</span>
|
||||
<span className="truncate">{renderMsg(d.msg ?? '', me, calling, replying)}</span>
|
||||
</span>
|
||||
|
||||
{/* The decode's own grid first, the remembered one as the
|
||||
@@ -1295,6 +1333,14 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
||||
<span className="truncate">{d.grid || e?.grid || ''}</span>
|
||||
</span>
|
||||
|
||||
<span className={cn(CELL, 'justify-end font-mono text-[11px] tabular-nums text-muted-foreground/80')}>
|
||||
{(() => {
|
||||
const g = d.grid || e?.grid || '';
|
||||
const path = myGrid && g ? pathBetween(myGrid, g) : null;
|
||||
return path ? distanceValue(path.distanceShort).toLocaleString() : '';
|
||||
})()}
|
||||
</span>
|
||||
|
||||
<span className={cn(CELL, 'gap-1.5')}>
|
||||
{e?.state && (
|
||||
<>
|
||||
@@ -1315,20 +1361,20 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
||||
{e?.lotw && (
|
||||
<span className="text-[10px] font-bold text-info-muted-foreground shrink-0" title="LoTW">L</span>
|
||||
)}
|
||||
{/* After the L, which is one letter and always in the same
|
||||
place: a badge in front of it moved the whole column
|
||||
sideways from row to row. */}
|
||||
{e?.worked_call && (
|
||||
<span className="rounded px-1 py-px text-[10px] font-medium bg-muted text-muted-foreground shrink-0">
|
||||
{t('dec.wkd')}
|
||||
</span>
|
||||
)}
|
||||
{/* Plainest first: the LoTW letter, the worked note, then
|
||||
the coloured badges — the watch list leading them, being
|
||||
the operator's own answer rather than the log's. */}
|
||||
{isWatched(d.call, watchlist) && (
|
||||
<span className="rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide shrink-0 text-white"
|
||||
style={{ background: '#f472b6' }} title={t('dec.wlTip')}>
|
||||
{t('dec.wl')}
|
||||
</span>
|
||||
)}
|
||||
{e?.worked_call && (
|
||||
<span className="rounded px-1 py-px text-[10px] font-medium bg-muted text-muted-foreground shrink-0">
|
||||
{t('dec.wkd')}
|
||||
</span>
|
||||
)}
|
||||
{entities.map((b) => (
|
||||
<span key={b.label}
|
||||
title={e?.unconf_status ? t('dec.unconfTip') : undefined}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -83,6 +83,7 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { setUseMiles } from '@/lib/units';
|
||||
import { rotorStyle, setRotorStyle, type RotorStyle } from '@/lib/rotorStyle';
|
||||
import { iaruRegion, setIaruRegion, type IaruRegion } from '@/lib/bandplan';
|
||||
import { getDateFormat, setDateFormat, type DateFormat } from '@/lib/dateFormat';
|
||||
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
|
||||
@@ -1765,6 +1766,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
// the full widget is what the operator has today, and a setting that changes
|
||||
// a panel the moment you upgrade is a setting that gets blamed for it.
|
||||
const [rotorCompact, setRotorCompact] = useState(() => localStorage.getItem('opslog.rotorCompact') === '1');
|
||||
const [rotorDial, setRotorDial] = useState<RotorStyle>(() => rotorStyle());
|
||||
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
|
||||
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
|
||||
const [groupDigital, setGroupDigital] = useState(() => localStorage.getItem('opslog.groupDigitalSlots') === '1');
|
||||
@@ -4873,6 +4875,20 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
dial to take a column, not a panel. */}
|
||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||
<div className="text-sm font-semibold">{t('rot.widget')}</div>
|
||||
{/* Which dial. Both are kept: the new one reads at a glance across
|
||||
the shack, the old one is the compact dial operators learned
|
||||
first, and neither is wrong. */}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">{t('rot.dial')}</span>
|
||||
<select
|
||||
value={rotorDial}
|
||||
onChange={(e) => { const v = e.target.value as RotorStyle; setRotorDial(v); setRotorStyle(v); }}
|
||||
className="h-8 rounded-md border border-border bg-background px-2 text-sm"
|
||||
>
|
||||
<option value="modern">{t('rot.dialModern')}</option>
|
||||
<option value="classic">{t('rot.dialClassic')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={rotorCompact}
|
||||
onCheckedChange={(c) => { const v = !!c; setRotorCompact(v); writeUiPref('opslog.rotorCompact', v ? '1' : '0'); }} />
|
||||
|
||||
@@ -11,6 +11,8 @@ import { useI18n } from '@/lib/i18n';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading';
|
||||
import { RotorCompass } from '@/components/RotorCompass';
|
||||
import { RotorCompassClassic } from '@/components/RotorCompassClassic';
|
||||
import { rotorStyle, subscribeRotorStyle } from '@/lib/rotorStyle';
|
||||
import { AmpCard } from '@/components/AmpCard';
|
||||
import { TunerCard } from '@/components/TunerCard';
|
||||
import type { TGStatus } from '@/components/TunerGeniusPanel';
|
||||
@@ -128,6 +130,11 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
|
||||
}) {
|
||||
const [goto, setGoto] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
// Both compasses in the app follow the same preference (Settings → Rotator):
|
||||
// one operator's choice of dial, not one per panel.
|
||||
const [dial, setDial] = useState(() => rotorStyle());
|
||||
useEffect(() => subscribeRotorStyle(() => setDial(rotorStyle())), []);
|
||||
const Dial = dial === 'classic' ? RotorCompassClassic : RotorCompass;
|
||||
|
||||
const turn = (az: number) => {
|
||||
const a = ((Math.round(az) % 360) + 360) % 360;
|
||||
@@ -145,9 +152,12 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
|
||||
title={hd.ok ? t('station.online') : t('station.rotatorNoRead')} />
|
||||
</div>
|
||||
<div className="p-3 flex gap-4 items-start">
|
||||
{/* The SP/LP readout lives INSIDE RotorCompass, so every compass in the
|
||||
app carries it rather than each caller drawing its own. */}
|
||||
<RotorCompass
|
||||
{/* The compact compass is the dial alone — short and long path are the
|
||||
two coloured dots on it. The figures are in the status bar. */}
|
||||
{/* The dial-only form is square and fills the box it is given, so its
|
||||
size is set here rather than baked into the component. */}
|
||||
<div className={dial === 'classic' ? 'shrink-0' : 'w-[210px] shrink-0'}>
|
||||
<Dial
|
||||
bearing={bearing ?? null}
|
||||
headings={hd.ok ? [hd.azimuth] : []}
|
||||
centerLat={centerLat ?? null}
|
||||
@@ -158,6 +168,7 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
|
||||
onSelectRotor={(i) => { SetActiveRotor(i).then(refetch).catch((e) => setErr(String(e?.message ?? e))); }}
|
||||
onGoto={(az) => turn(az)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="font-mono">
|
||||
<span className="text-2xl font-bold tabular-nums">{hd.ok ? `${hd.azimuth}°` : '—'}</span>
|
||||
|
||||
@@ -194,7 +194,7 @@ const en: Dict = {
|
||||
'ac.only': 'Chase only', 'ac.onlyPh': 'VP6D 3Y0J (space or comma)', 'ac.onlyHint': 'one or more callsigns — nothing else is called; empty means call whatever the log needs',
|
||||
'ac.attempts': 'Calls before giving up', 'ac.watchedAttempts': 'if watched', 'ac.misses': 'Missed periods',
|
||||
'ac.rounds': 'Series per station', 'ac.rest': 'Rest (min)', 'ac.trace': 'Log every decision', 'ac.traceHint': '— one line per period in the diagnostic log: what was on the air, why each station was refused, and what was decided. For working out why nothing is being called. A line every fifteen seconds, so leave it off otherwise.', 'ac.onScreen': 'Call only what the decodes list is showing', 'ac.onScreenHint': '— the filters above the list (CQ only, LoTW only, the new-category chips, continents, report, search) steer the transmitter too: a station filtered off the screen is not called. With the list closed nothing is filtered.',
|
||||
'dec.chasePh': 'chase…', 'dec.chaseTip': 'Call ONLY these stations — one or more callsigns, spaces or commas. Empty means call whatever the log needs. Same field as Preferences → DXHunter.', 'dec.autoStoppedTip': 'Auto-call has given up on a station from the chase list — click to switch it off, or Halt to clear it.',
|
||||
'dec.chasePh': 'chase…', 'dec.chaseTip': 'Call ONLY these stations — one or more callsigns, spaces or commas. Empty means call whatever the log needs. Same field as Preferences → DXHunter.', 'dec.autoWaitTip': '{call} is wanted and is working somebody else — it is called the moment it is free.', 'dec.autoStoppedTip': 'Auto-call has given up on a station from the chase list — click to switch it off, or Halt to clear it.',
|
||||
// Contest watchlist (Settings → DXHunter): how a special-event fleet joins the list on its own
|
||||
'wlc.title': 'Contest', 'wlc.pattern': 'Auto-add on', 'wlc.patternPh': 'WWA', 'wlc.patternHint': 'any spotted callsign CONTAINING this joins the watchlist as a contest entry (TM29WWA, HB9WWA, F4WWA/P)',
|
||||
'wlc.calls': 'And these callsigns, one per line', 'wlc.callsPh': 'R7W DL0ABC', 'wlc.callsHint': 'For the entries a pattern cannot catch: a station taking part under a callsign that says nothing about the event. Named here, it joins the contest watchlist the moment it is spotted. Commas and spaces work too.',
|
||||
@@ -213,7 +213,7 @@ const en: Dict = {
|
||||
'dec.colFreq': 'Freq', 'dec.colFreqTitle': 'Audio offset inside the passband (Hz)',
|
||||
'dec.txNow': 'Transmitting', 'dec.txIdle': 'Transmit', 'dec.working': 'calling', 'dec.toYou': 'to you',
|
||||
'dec.txUnknown': 'transmitting — text not reported', 'dec.txNothing': 'nothing being sent',
|
||||
'dec.colTime': 'Time', 'dec.colRx': 'RX', 'dec.colSnr': 'SNR', 'dec.colMsg': 'Message', 'dec.bgGridUnconf': 'GRID?', 'dec.bgGridUnconfTip': 'This square is worked but not yet confirmed — a QSL to chase, not a QSO to make.', 'dec.colGrid': 'Grid', 'dec.colState': 'State', 'dec.colCountry': 'Country', 'dec.colBand': 'Band', 'dec.colMode': 'Mode', 'dec.colStatus': 'Status', 'dec.stateTip': 'US state', 'dec.wl': 'WL', 'dec.wlTip': 'On your watch list', 'dec.wkd': 'Wkd',
|
||||
'dec.colTime': 'Time', 'dec.colRx': 'RX', 'dec.colSnr': 'SNR', 'dec.colMsg': 'Message', 'dec.bgGridUnconf': 'GRID?', 'dec.bgGridUnconfTip': 'This square is worked but not yet confirmed — a QSL to chase, not a QSO to make.', 'dec.colGrid': 'Grid', 'dec.colDist': 'Dist', 'dec.colState': 'State', 'dec.colCountry': 'Country', 'dec.colBand': 'Band', 'dec.colMode': 'Mode', 'dec.colStatus': 'Status', 'dec.stateTip': 'US state', 'dec.wl': 'WL', 'dec.wlTip': 'On your watch list', 'dec.wkd': 'Wkd',
|
||||
'dec.bgPota': 'POTA', 'dec.bgGrid': 'GRID', 'dec.bgPfx': 'PFX', 'dec.bgState': 'New State', 'dec.bgCounty': 'CTY',
|
||||
'dec.stNew': 'NEW', 'dec.stBand': 'BAND', 'dec.stMode': 'MODE', 'dec.stSlot': 'SLOT', 'dec.stCall': 'CALL',
|
||||
'dec.empty': 'Nothing decoded yet. Decodes arrive from WSJT-X, JTDX or MSHV over the inbound UDP link (Settings -> UDP).',
|
||||
@@ -400,7 +400,7 @@ const en: Dict = {
|
||||
'ag2.hint': 'OpsLog talks to the 4O3A Antenna Genius switch over TCP (GSCP protocol). The port is fixed at 9007, so only the device IP is needed. A docked widget then lets you switch antennas per port (A/B).', 'ag2.password': 'Remote password', 'ag2.passwordPh': 'blank on LAN', 'ag2.useForMyAnt': 'Use the selected antenna as MY_ANTENNA', 'ag2.useForMyAntHint': 'The antenna selected on the switch, under the name it carries there, is written into every QSO as it is logged — ahead of the band default from Operating conditions. Which port counts is decided by the antenna jack the radio is transmitting on.', 'ag2.ant1Port': 'The radio\u2019s ANT1 jack is wired to', 'ag2.portA': 'Port A', 'ag2.portB': 'Port B', 'ag2.ant1PortHint': 'Station wiring — neither the radio nor the switch can report it. ANT2 then goes to the other port.', 'ag2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AG AUTH" and rejects commands until you log in. Leave blank on the local network.',
|
||||
'tg2.hint': 'OpsLog talks to the 4O3A Tuner Genius XL over TCP (port fixed at 9010), so only the device IP is needed. A docked widget then shows SWR and forward power and offers Tune, Bypass and Operate/Standby. Control it directly (not through the radio) so OpsLog uses just one of the box\'s connection slots.', 'tg2.enable': 'Enable Tuner Genius control', 'tg2.portHint': 'The TCP port is fixed at 9010 on the device.', 'tg2.password': 'Remote code', 'tg2.passwordPh': 'blank on LAN', 'tg2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AUTH" and rejects commands until you log in. Leave blank on the local network.',
|
||||
'rot.spidHint': 'Native SPID protocol over the controller’s COM port — no PstRotator needed. Pick the dialect above: Rot2Prog answers with azimuth and elevation at 600 baud, Rot1Prog with azimuth only at 1200.', 'rot.spidModel': 'SPID protocol',
|
||||
'rot.widget': 'Widget', 'rot.compact': 'Compact mode', 'rot.compactHint': 'Show only the dial and the short/long-path azimuths. The quick-turn buttons, the azimuth box and Stop are hidden — turn the antenna from the bearing pill in the entry strip instead.',
|
||||
'rot.dial': 'Dial', 'rot.dialModern': 'World map (new)', 'rot.dialClassic': 'Classic dial', 'rot.widget': 'Widget', 'rot.compact': 'Compact mode', 'rot.compactHint': 'Show only the dial and the short/long-path azimuths. The quick-turn buttons, the azimuth box and Stop are hidden — turn the antenna from the bearing pill in the entry strip instead.',
|
||||
'rot.presets': 'Quick-turn buttons', 'rot.presetName': 'Name', 'rot.presetAdd': 'Add button',
|
||||
'rot.presetsHint': 'Shown beside the rotor dial. The azimuths started out computed from your station square, so they point at those regions FROM HERE — change either field, or clear a name to drop the button.',
|
||||
'rot.presetsReset': 'Recompute from my square',
|
||||
@@ -751,7 +751,7 @@ const fr: Dict = {
|
||||
'ac.only': 'Chasser uniquement', 'ac.onlyPh': 'VP6D 3Y0J (espace ou virgule)', 'ac.onlyHint': 'un ou plusieurs indicatifs — rien d’autre n’est appelé ; vide = appeler ce dont le log a besoin',
|
||||
'ac.attempts': 'Appels avant abandon', 'ac.watchedAttempts': 'si surveillé', 'ac.misses': 'Périodes manquées',
|
||||
'ac.rounds': 'Séries par station', 'ac.rest': 'Repos (min)', 'ac.trace': 'Journaliser chaque décision', 'ac.traceHint': '— une ligne par période dans le journal de diagnostic : ce qu’il y avait sur l’air, pourquoi chaque station a été écartée, et ce qui a été décidé. Pour comprendre pourquoi rien n’est appelé. Une ligne toutes les quinze secondes : à laisser éteint le reste du temps.', 'ac.onScreen': 'N’appeler que ce qu’affiche la liste des décodages', 'ac.onScreenHint': '— les filtres au-dessus de la liste (CQ seuls, LoTW, les pastilles de catégorie, continents, report, recherche) pilotent aussi l’émetteur : une station filtrée hors écran n’est pas appelée. La liste fermée, rien n’est filtré.',
|
||||
'dec.chasePh': 'chasser…', 'dec.chaseTip': "N'appeler QUE ces stations — un ou plusieurs indicatifs, espaces ou virgules. Vide = appeler ce dont le log a besoin. Même champ que Réglages → DXHunter.", 'dec.autoStoppedTip': "L'appel automatique a renoncé à une station de la liste de chasse — cliquez pour l'éteindre, ou Stop pour effacer.",
|
||||
'dec.chasePh': 'chasser…', 'dec.chaseTip': "N'appeler QUE ces stations — un ou plusieurs indicatifs, espaces ou virgules. Vide = appeler ce dont le log a besoin. Même champ que Réglages → DXHunter.", 'dec.autoWaitTip': '{call} est voulue et travaille quelqu’un d’autre — elle sera appelée dès qu’elle sera libre.', 'dec.autoStoppedTip': "L'appel automatique a renoncé à une station de la liste de chasse — cliquez pour l'éteindre, ou Stop pour effacer.",
|
||||
// Watchlist contest (Réglages → DXHunter) : comment une flotte d’événement rejoint la liste toute seule
|
||||
'wlc.title': 'Contest', 'wlc.pattern': 'Ajout auto sur', 'wlc.patternPh': 'WWA', 'wlc.patternHint': 'tout indicatif spotté CONTENANT ceci rejoint la watchlist comme entrée contest (TM29WWA, HB9WWA, F4WWA/P)',
|
||||
'wlc.calls': 'Et ces indicatifs, un par ligne', 'wlc.callsPh': 'R7W DL0ABC', 'wlc.callsHint': 'Pour les participants qu’aucun motif ne peut attraper : une station engagée sous un indicatif qui ne dit rien de l’événement. Nommée ici, elle rejoint la watchlist contest dès qu’elle est spottée. Les virgules et les espaces marchent aussi.',
|
||||
@@ -770,7 +770,7 @@ const fr: Dict = {
|
||||
'dec.colFreq': 'Freq', 'dec.colFreqTitle': 'Decalage audio dans la bande passante (Hz)',
|
||||
'dec.txNow': 'En emission', 'dec.txIdle': 'Emission', 'dec.working': 'appelle', 'dec.toYou': 'pour toi',
|
||||
'dec.txUnknown': 'en émission — texte non communiqué', 'dec.txNothing': 'rien en cours d’émission',
|
||||
'dec.colTime': 'Heure', 'dec.colRx': 'RX', 'dec.colSnr': 'SNR', 'dec.colMsg': 'Message', 'dec.bgGridUnconf': 'GRID?', 'dec.bgGridUnconfTip': 'Ce carré est contacté mais pas encore confirmé — une QSL à relancer, pas un QSO à faire.', 'dec.colGrid': 'Locator', 'dec.colState': 'État', 'dec.colCountry': 'Pays', 'dec.colBand': 'Bande', 'dec.colMode': 'Mode', 'dec.colStatus': 'Statut', 'dec.stateTip': 'État US', 'dec.wl': 'WL', 'dec.wlTip': 'Sur votre liste de surveillance', 'dec.wkd': 'Fait',
|
||||
'dec.colTime': 'Heure', 'dec.colRx': 'RX', 'dec.colSnr': 'SNR', 'dec.colMsg': 'Message', 'dec.bgGridUnconf': 'GRID?', 'dec.bgGridUnconfTip': 'Ce carré est contacté mais pas encore confirmé — une QSL à relancer, pas un QSO à faire.', 'dec.colGrid': 'Locator', 'dec.colDist': 'Dist', 'dec.colState': 'État', 'dec.colCountry': 'Pays', 'dec.colBand': 'Bande', 'dec.colMode': 'Mode', 'dec.colStatus': 'Statut', 'dec.stateTip': 'État US', 'dec.wl': 'WL', 'dec.wlTip': 'Sur votre liste de surveillance', 'dec.wkd': 'Fait',
|
||||
'dec.bgPota': 'POTA', 'dec.bgGrid': 'LOC', 'dec.bgPfx': 'PFX', 'dec.bgState': 'Nouvel État', 'dec.bgCounty': 'CTY',
|
||||
'dec.stNew': 'NOUV', 'dec.stBand': 'BANDE', 'dec.stMode': 'MODE', 'dec.stSlot': 'SLOT', 'dec.stCall': 'IND',
|
||||
'dec.empty': "Aucun decode pour l'instant. Ils arrivent de WSJT-X, JTDX ou MSHV par le lien UDP entrant (Reglages -> UDP).",
|
||||
@@ -949,7 +949,7 @@ const fr: Dict = {
|
||||
'ag2.hint': "OpsLog dialogue avec le switch 4O3A Antenna Genius en TCP (protocole GSCP). Le port est fixé à 9007, seule l'IP de l'appareil est nécessaire. Un widget ancré permet ensuite de commuter les antennes par port (A/B).", 'ag2.password': 'Mot de passe distant', 'ag2.passwordPh': 'vide en LAN', 'ag2.useForMyAnt': "Utiliser l'antenne sélectionnée comme MY_ANTENNA", 'ag2.useForMyAntHint': "L'antenne sélectionnée sur le switch, sous le nom qu'elle y porte, est inscrite dans chaque QSO au moment où il est enregistré — avant l'antenne par défaut des conditions de trafic. C'est la prise d'antenne sur laquelle la radio émet qui décide du port retenu.", 'ag2.ant1Port': 'La prise ANT1 de la radio est câblée sur', 'ag2.portA': 'Port A', 'ag2.portB': 'Port B', 'ag2.ant1PortHint': "Câblage de la station — ni la radio ni le switch ne peuvent le dire. ANT2 va alors sur l'autre port.", 'ag2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AG AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.",
|
||||
'tg2.hint': "OpsLog dialogue avec le 4O3A Tuner Genius XL en TCP (port fixé à 9010), seule l'IP de l'appareil est nécessaire. Un widget ancré affiche le ROS et la puissance directe et propose Accord, Bypass et Operate/Standby. Pilotage direct (pas via la radio) pour n'utiliser qu'une des connexions de la boîte.", 'tg2.enable': "Activer le contrôle du Tuner Genius", 'tg2.portHint': "Le port TCP est fixé à 9010 sur l'appareil.", 'tg2.password': 'Code distant', 'tg2.passwordPh': 'vide en LAN', 'tg2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.",
|
||||
'rot.spidHint': 'Protocole SPID natif sur le port COM du contrôleur — sans PstRotator. Choisissez le dialecte ci-dessus : Rot2Prog répond azimut et élévation à 600 bauds, Rot1Prog azimut seul à 1200.', 'rot.spidModel': 'Protocole SPID',
|
||||
'rot.widget': 'Widget', 'rot.compact': 'Mode compact', 'rot.compactHint': 'N’afficher que le cadran et les azimuts courte/longue distance. Les boutons de rotation rapide, la case d’azimut et Stop sont masqués — tourne l’antenne depuis la pastille de cap de la barre de saisie.',
|
||||
'rot.dial': 'Cadran', 'rot.dialModern': 'Carte du monde (nouveau)', 'rot.dialClassic': 'Cadran classique', 'rot.widget': 'Widget', 'rot.compact': 'Mode compact', 'rot.compactHint': 'N’afficher que le cadran et les azimuts courte/longue distance. Les boutons de rotation rapide, la case d’azimut et Stop sont masqués — tourne l’antenne depuis la pastille de cap de la barre de saisie.',
|
||||
'rot.presets': 'Boutons de rotation rapide', 'rot.presetName': 'Nom', 'rot.presetAdd': 'Ajouter un bouton',
|
||||
'rot.presetsHint': "Affichés à côté du cadran du rotor. Les azimuts ont été calculés depuis ton carré : ils visent ces régions DEPUIS ICI. Modifie l'un ou l'autre champ, ou vide le nom pour retirer le bouton.",
|
||||
'rot.presetsReset': 'Recalculer depuis mon carré',
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Which rotor dial to draw.
|
||||
//
|
||||
// Two compasses ship: the current one (night map, square scale, mouse pointer,
|
||||
// target marker) and the original small light-map dial. Neither is more correct
|
||||
// than the other — one operator reads the big map at a glance, another wants the
|
||||
// compact dial that was there before — so it is a preference, not a migration.
|
||||
//
|
||||
// A UI preference rather than a settings-database key: it decides what a widget
|
||||
// looks like, nothing is transmitted from it, and it belongs to the screen it is
|
||||
// read on. It is portable all the same (see lib/uiPref), so a copied folder
|
||||
// keeps the dial its owner chose.
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
|
||||
export const KEY_ROTOR_STYLE = 'opslog.rotorStyle';
|
||||
|
||||
export type RotorStyle = 'modern' | 'classic';
|
||||
|
||||
export function rotorStyle(): RotorStyle {
|
||||
try {
|
||||
return localStorage.getItem(KEY_ROTOR_STYLE) === 'classic' ? 'classic' : 'modern';
|
||||
} catch {
|
||||
return 'modern';
|
||||
}
|
||||
}
|
||||
|
||||
// Same shape as the distance unit: the compasses are rendered from two call
|
||||
// sites, and a preference changed in Settings has to reach both without either
|
||||
// of them polling for it.
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function setRotorStyle(style: RotorStyle): void {
|
||||
writeUiPref(KEY_ROTOR_STYLE, style);
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
export function subscribeRotorStyle(fn: () => void): () => void {
|
||||
listeners.add(fn);
|
||||
return () => { listeners.delete(fn); };
|
||||
}
|
||||
@@ -1986,6 +1986,7 @@ export namespace main {
|
||||
enabled: boolean;
|
||||
only: string;
|
||||
target: string;
|
||||
waiting: string;
|
||||
calls: number;
|
||||
max: number;
|
||||
misses: number;
|
||||
@@ -2003,6 +2004,7 @@ export namespace main {
|
||||
this.enabled = source["enabled"];
|
||||
this.only = source["only"];
|
||||
this.target = source["target"];
|
||||
this.waiting = source["waiting"];
|
||||
this.calls = source["calls"];
|
||||
this.max = source["max"];
|
||||
this.misses = source["misses"];
|
||||
|
||||
Reference in New Issue
Block a user