Two faults with one cause: this radio does not echo 'tune:0,true'. So the panel never knew a tune was running. The button stayed on TUNE and every further press sent another START — there was no way to stop it from here at all. The state is recorded when the command is sent now; whatever the radio says afterwards still wins, it simply never says anything. And the transmit meters were asked for only while t.tx, which a tune carrier does not set: the radio reports tuning as its own state, not as a transmission. So power and SWR sat at zero for the whole tune — the exact carrier an operator holds a tune for in order to watch an SWR on. They now follow PTT or TUNE, and the S-meter reads '—' under our own carrier either way.
412 lines
21 KiB
TypeScript
412 lines
21 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
||
import { Radio, Activity, AudioLines, SlidersHorizontal, Mic } from 'lucide-react';
|
||
import {
|
||
GetTCIPanel, GetCATState,
|
||
SetTCIDrive, SetTCITuneDrive, SetTCIMicLevel, SetTCIVolume, SetTCIMute,
|
||
SetTCIAGC, SetTCISquelch, SetTCISquelchLevel,
|
||
SetTCINB, SetTCINR, SetTCIANF, SetTCIAPF, SetTCIFilter,
|
||
SetTCIRIT, SetTCIXIT, SetTCIRITOffset, SetTCIXITOffset, SetTCILock, SetTCITune,
|
||
} from '../../wailsjs/go/main/App';
|
||
import { cn } from '@/lib/utils';
|
||
import { useI18n } from '@/lib/i18n';
|
||
import { sMeterRST } from '@/lib/rst';
|
||
import { MeterBar } from '@/components/MeterBar';
|
||
import { WheelRange } from '@/components/WheelRange';
|
||
import { ShiftRow } from '@/components/ShiftRow';
|
||
import { LevelRow } from '@/components/LevelRow';
|
||
|
||
type TCIState = {
|
||
connected: boolean; device?: string; protocol?: string;
|
||
drive: number; tune_drive: number; mic_level: number; tx_enabled: boolean; tx: boolean; tuning: boolean;
|
||
volume: number; mute: boolean; agc?: string; squelch_on: boolean; squelch: number;
|
||
nb: boolean; nr: boolean; anf: boolean; apf: boolean;
|
||
filter_lo: number; filter_hi: number;
|
||
rit: boolean; rit_offset: number; xit: boolean; xit_offset: number; lock: boolean; split: boolean;
|
||
smeter: number; modulations?: string[];
|
||
tx_power_w: number; tx_swr: number;
|
||
};
|
||
|
||
const ZERO: TCIState = {
|
||
connected: false, drive: 0, tune_drive: 0, mic_level: 0, tx_enabled: false, tx: false, tuning: false,
|
||
volume: 0, mute: false, squelch_on: false, squelch: 0,
|
||
nb: false, nr: false, anf: false, apf: false, filter_lo: 0, filter_hi: 0,
|
||
rit: false, rit_offset: 0, xit: false, xit_offset: 0, lock: false, split: false, smeter: 0,
|
||
tx_power_w: 0, tx_swr: 0,
|
||
};
|
||
|
||
// The widths worth a button, PER MODE — because 250 Hz is useless in SSB and
|
||
// 2.8 kHz is useless in CW, and a row offering both is a row where half the
|
||
// buttons are never pressed.
|
||
//
|
||
// CW gets the narrow end, where the difference between 250 and 500 is the
|
||
// difference between one signal and three. Voice gets the range a passband is
|
||
// actually shaped over. Digital sits between: wide enough for a whole FT8
|
||
// sub-band, narrow enough for RTTY.
|
||
const WIDTHS_CW = [100, 250, 400, 500, 700, 1000, 1800];
|
||
const WIDTHS_SSB = [1800, 2100, 2400, 2700, 2800, 3000, 3500];
|
||
const WIDTHS_DIGI = [500, 1000, 1800, 2400, 2800, 3000, 3500];
|
||
|
||
// widthsFor picks the row from the mode the radio reports.
|
||
function widthsFor(mode: string): number[] {
|
||
if (/CW/i.test(mode)) return WIDTHS_CW;
|
||
if (/SSB|USB|LSB|AM|FM/i.test(mode)) return WIDTHS_SSB;
|
||
return WIDTHS_DIGI;
|
||
}
|
||
|
||
// isCW says whether the CW-only controls belong on screen at all.
|
||
function isCW(mode: string): boolean { return /CW/i.test(mode); }
|
||
|
||
function widthLabel(w: number): string {
|
||
return w >= 1000 ? `${(w / 1000).toFixed(1)}k` : String(w);
|
||
}
|
||
|
||
// edgesFor turns a width into the pair TCI wants: 0 to the width, and nothing
|
||
// clever.
|
||
//
|
||
// It centred narrow filters on the CW note first — 250 became 575-825 — on the
|
||
// reasoning that a CW filter should contain the note. That reasoning may even be
|
||
// right for a radio, but it is not what the button says, and a button that does
|
||
// not do what it says is worse than one that does something simple. 250 means
|
||
// 0-250. The two edges are editable underneath for anything else.
|
||
function edgesFor(w: number, _mode: string): { lo: number; hi: number } {
|
||
return { lo: 0, hi: w };
|
||
}
|
||
|
||
// dBm → S units. TCI reports a real signal level rather than a meter position,
|
||
// which is the useful way round: S9 is -73 dBm by the IARU definition and every
|
||
// S unit below it is 6 dB, so this is arithmetic rather than a calibration
|
||
// table — nothing here is provisional the way the K3's meter reading is.
|
||
function sParts(dbm: number): { s: number; over: number; label: string } {
|
||
if (dbm === 0) return { s: 0, over: 0, label: '—' };
|
||
if (dbm >= -73) {
|
||
const over = Math.round((dbm + 73) / 10) * 10;
|
||
return { s: 9, over, label: over > 0 ? `S9+${over}` : 'S9' };
|
||
}
|
||
const s = Math.max(0, Math.min(9, Math.round(9 + (dbm + 73) / 6)));
|
||
return { s, over: 0, label: `S${s}` };
|
||
}
|
||
|
||
// The meter bar wants 0-100; -127 dBm is the bottom of the scale and -13 dBm
|
||
// (S9+60) the top.
|
||
function sBar(dbm: number): number {
|
||
if (dbm === 0) return 0;
|
||
return Math.max(0, Math.min(100, ((dbm + 127) / 114) * 100));
|
||
}
|
||
|
||
function sSegColor(frac: number): string {
|
||
return frac > 0.75 ? '#dc2626' : frac > 0.55 ? '#f59e0b' : '#16a34a';
|
||
}
|
||
|
||
function Card({ icon: Icon, title, children }: { icon: any; title: string; children: React.ReactNode }) {
|
||
return (
|
||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||
<Icon className="size-4 text-primary" />
|
||
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
|
||
</div>
|
||
<div className="p-3 space-y-3">{children}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// One button shape for every on/off control, as on the other consoles.
|
||
function Toggle({ label, on, off, onClick, title }: {
|
||
label: string; on: boolean; off: boolean; onClick: () => void; title?: string;
|
||
}) {
|
||
return (
|
||
<button type="button" disabled={off} onClick={onClick} title={title}
|
||
className={cn('rounded-lg border-2 px-2 py-1.5 text-xs font-bold transition-all disabled:opacity-30',
|
||
on ? 'bg-primary text-primary-foreground border-primary shadow-[0_0_10px] shadow-primary/40'
|
||
: 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||
{label}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function Row({ label, value, children }: { label: string; value: string; children: React.ReactNode }) {
|
||
return (
|
||
<div className="space-y-1">
|
||
<div className="flex items-baseline justify-between">
|
||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{label}</span>
|
||
<span className="text-xs font-mono tabular-nums">{value}</span>
|
||
</div>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void } = {}) {
|
||
const { t } = useI18n();
|
||
const [st, setSt] = useState<TCIState>(ZERO);
|
||
const [freqHz, setFreqHz] = useState(0);
|
||
const [mode, setMode] = useState('');
|
||
const [err, setErr] = useState('');
|
||
|
||
// OPTIMISTIC, like the Icom console — and for a reason found on a real radio.
|
||
//
|
||
// This panel used to show only what the radio reported back, on the principle
|
||
// that the radio is the truth. But ExpertSDR3 does not echo every setting it
|
||
// is given: press MED and the radio changes, says nothing, and the button
|
||
// stays lit on SLOW. Waiting for an answer that never comes reads as a dead
|
||
// control.
|
||
//
|
||
// So a change is shown at once and held for a moment. Whatever the radio
|
||
// announces afterwards — the new value, or a refusal that leaves the old one
|
||
// — wins once the hold expires, which keeps a clamped or rejected setting
|
||
// honest without making every working one look broken.
|
||
// Held UNTIL THE RADIO SPEAKS, not for a fixed moment.
|
||
//
|
||
// A timeout was wrong in both directions. Too short and a setting the radio
|
||
// never echoes — AGC is one — snapped back to its old value a second after
|
||
// the click. Too long and a setting the radio REFUSES looked accepted: a real
|
||
// log shows this one answering 'sql_enable:0,false' and then, eighty
|
||
// milliseconds later, 'sql_enable:0,true' — it puts the squelch straight back
|
||
// on. Holding through that would have shown the operator a lie.
|
||
//
|
||
// So the requested value stands while the radio says nothing about it, and
|
||
// the instant it reports ANY change for that setting, its word replaces ours.
|
||
const holdRef = useRef<Record<string, { v: any; reported: any }>>({});
|
||
const [, forceRender] = useState(0);
|
||
const hold = <T,>(key: string, reported: T): T => {
|
||
const h = holdRef.current[key];
|
||
if (!h) return reported;
|
||
// The radio has said something different from what it was saying when the
|
||
// click happened — whether that is our value or a refusal, it is now the
|
||
// truth and the hold is over.
|
||
if (reported !== h.reported) {
|
||
delete holdRef.current[key];
|
||
return reported;
|
||
}
|
||
return h.v as T;
|
||
};
|
||
const setHold = (key: string, v: any, reported: any) => {
|
||
holdRef.current[key] = { v, reported };
|
||
forceRender((n) => n + 1);
|
||
};
|
||
|
||
useEffect(() => {
|
||
let alive = true;
|
||
const tick = async () => {
|
||
try {
|
||
const p: any = await GetTCIPanel();
|
||
if (!alive) return;
|
||
setSt(p as TCIState);
|
||
const cs: any = await GetCATState();
|
||
if (!alive) return;
|
||
setFreqHz(Number(cs?.rx_freq_hz) || Number(cs?.freq_hz) || 0);
|
||
setMode(String(cs?.mode || ''));
|
||
} catch (e: any) {
|
||
if (alive) setErr(String(e?.message ?? e));
|
||
}
|
||
};
|
||
tick();
|
||
const id = window.setInterval(tick, 400);
|
||
return () => { alive = false; window.clearInterval(id); };
|
||
}, []);
|
||
|
||
const off = !st.connected;
|
||
|
||
const call = (fn: () => Promise<any>) => { fn().catch((e: any) => setErr(String(e?.message ?? e))); };
|
||
|
||
const drive = hold('drive', st.drive);
|
||
const tuneDrive = hold('tune_drive', st.tune_drive);
|
||
const mic = hold('mic', st.mic_level);
|
||
const vol = hold('vol', st.volume);
|
||
const sql = hold('sql', st.squelch);
|
||
const agc = hold('agc', st.agc || '');
|
||
const nb = hold('nb', st.nb), nr = hold('nr', st.nr);
|
||
const anf = hold('anf', st.anf), apf = hold('apf', st.apf);
|
||
const sqlOn = hold('sql_on', st.squelch_on), muted = hold('mute', st.mute);
|
||
const rit = hold('rit', st.rit), xit = hold('xit', st.xit);
|
||
const ritHz = hold('rit_hz', st.rit_offset), xitHz = hold('xit_hz', st.xit_offset);
|
||
const s = sParts(st.smeter);
|
||
|
||
// Ctrl+Left/Right shifts the RIT by ±10 Hz, the same keys the Icom console
|
||
// uses. Two consoles for two radios should not need two habits.
|
||
const ritRef = useRef({ on: false, hz: 0, off: true });
|
||
ritRef.current = { on: rit, hz: ritHz, off };
|
||
useEffect(() => {
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (!e.ctrlKey || (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight')) return;
|
||
const r = ritRef.current;
|
||
if (r.off || !r.on) return;
|
||
e.preventDefault();
|
||
const v = r.hz + (e.key === 'ArrowRight' ? 10 : -10);
|
||
setHold('rit_hz', v, ritRef.current.hz);
|
||
SetTCIRITOffset(v).catch(() => {});
|
||
};
|
||
window.addEventListener('keydown', onKey);
|
||
return () => window.removeEventListener('keydown', onKey);
|
||
}, []);
|
||
|
||
return (
|
||
<div className="h-full min-h-0 overflow-auto bg-background">
|
||
{/* Capped and centred, like the Elecraft, Yaesu, Icom and Flex consoles.
|
||
Stretched across a wide window a console puts each slider a hand's
|
||
width from its own label and stops reading as one instrument. */}
|
||
<div className="max-w-5xl mx-auto p-3 space-y-3">
|
||
{/* VFO + identity */}
|
||
<div className="rounded-xl border border-border bg-card shadow-sm px-4 py-3 flex items-center justify-between gap-3 flex-wrap">
|
||
<div>
|
||
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
|
||
<Radio className="size-3.5" />
|
||
{st.device || 'SunSDR'} {st.protocol ? `· ${st.protocol}` : ''}
|
||
<span className={cn('size-2 rounded-full', off ? 'bg-muted-foreground/40' : 'bg-success')} />
|
||
</div>
|
||
<div className="text-2xl font-mono tabular-nums font-bold">
|
||
{freqHz > 0 ? (freqHz / 1e6).toFixed(6) : '—'}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{st.tx && <span className="rounded-md bg-danger px-2 py-1 text-[11px] font-bold text-danger-foreground">TX</span>}
|
||
{st.split && <span className="rounded-md border border-border px-2 py-1 text-[11px] font-bold">SPLIT</span>}
|
||
<Toggle label={t('tcip.lock')} on={st.lock} off={off} onClick={() => call(() => SetTCILock(!st.lock))} />
|
||
</div>
|
||
</div>
|
||
|
||
{off && <div className="text-xs text-muted-foreground px-1">{t('tcip.waiting')}</div>}
|
||
{!!err && <div className="text-[11px] text-danger px-1">{err}</div>}
|
||
|
||
{/* Meters. The S-meter while receiving, power and SWR while
|
||
transmitting — the radio answers TX_POWER and TX_SWR only when it is
|
||
keyed, so showing them the rest of the time would be showing the
|
||
last thing that happened as if it were now.
|
||
There is no temperature: the protocol has no such command, and a
|
||
made-up figure on a transmitter is the kind somebody trusts. */}
|
||
<Card icon={Activity} title={t('tcip.meters')}>
|
||
<MeterBar label="S-METER" value={st.tx || st.tuning ? 0 : sBar(st.smeter)} lo={0} hi={100}
|
||
accent="#16a34a" segColor={sSegColor}
|
||
display={st.tx || st.tuning ? '—' : `${s.label} ${st.smeter} dBm`}
|
||
onClick={() => {
|
||
if (st.tx || !onReportRST) return;
|
||
onReportRST(sMeterRST(s.s, s.over, mode));
|
||
}}
|
||
title={t('tcip.sMeterHint')} />
|
||
{(st.tx || st.tuning) && (
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||
<MeterBar label="PWR" value={st.tx_power_w} lo={0} hi={Math.max(10, Math.ceil(st.tx_power_w / 10) * 10)}
|
||
accent="#0ea5e9" display={`${st.tx_power_w.toFixed(1)} W`} />
|
||
{/* 0 is "not measured yet", and it must not draw as a perfect
|
||
match: an SWR of 1.0 on an antenna nobody has measured is the
|
||
one reading an operator should not be handed. */}
|
||
<MeterBar label="SWR" value={st.tx_swr > 0 ? Math.min(100, (st.tx_swr - 1) * 50) : 0} lo={0} hi={100}
|
||
accent="#f59e0b" display={st.tx_swr > 0 ? st.tx_swr.toFixed(1) : '—'}
|
||
segColor={(f) => (f > 0.5 ? '#dc2626' : f > 0.25 ? '#f59e0b' : '#16a34a')} />
|
||
</div>
|
||
)}
|
||
</Card>
|
||
|
||
{/* Transmit */}
|
||
<Card icon={SlidersHorizontal} title={t('tcip.transmit')}>
|
||
{/* One level per ROW, full width. Two half-width sliders side by side
|
||
left each of them a couple of centimetres long — small enough that
|
||
setting 15% took aim. */}
|
||
<LevelRow label={t('tcip.drive')} unit="%" value={drive} disabled={off}
|
||
onSet={(v) => { setHold('drive', v, st.drive); call(() => SetTCIDrive(v)); }} />
|
||
<LevelRow label={t('tcip.tuneDrive')} unit="%" value={tuneDrive} disabled={off} accent="#f59e0b"
|
||
onSet={(v) => { setHold('tune_drive', v, st.tune_drive); call(() => SetTCITuneDrive(v)); }} />
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
{/* TUNE transmits, and at the tune drive rather than the main one —
|
||
which is why both numbers are above the button rather than one of
|
||
them being in a menu somewhere. */}
|
||
<button type="button" disabled={off || !st.tx_enabled}
|
||
onClick={() => call(() => SetTCITune(!st.tuning))}
|
||
className={cn('rounded-lg border-2 px-4 py-2 text-sm font-extrabold tracking-wide transition-all disabled:opacity-30',
|
||
st.tuning ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50'
|
||
: 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||
{st.tuning ? t('tcip.tuning') : t('tcip.tune')}
|
||
</button>
|
||
{!st.tx_enabled && !off && (
|
||
<span className="text-[11px] text-muted-foreground">{t('tcip.txDisabled')}</span>
|
||
)}
|
||
</div>
|
||
<LevelRow label={t('tcip.mic')} unit="%" value={mic} disabled={off} accent="#a855f7"
|
||
onSet={(v) => { setHold('mic', v, st.mic_level); call(() => SetTCIMicLevel(v)); }} />
|
||
</Card>
|
||
|
||
{/* Receive */}
|
||
<Card icon={AudioLines} title={t('tcip.receive')}>
|
||
{/* Both in the radio's OWN units — volume in dB, negative, and the
|
||
squelch as a dBm threshold — so the numbers match the ones in
|
||
ExpertSDR3's window rather than being percentages of something. */}
|
||
<LevelRow label={t('tcip.volume')} unit=" dB" min={-60} max={0} value={vol} disabled={off}
|
||
onSet={(v) => { setHold('vol', v, st.volume); call(() => SetTCIVolume(v)); }} />
|
||
<LevelRow label={t('tcip.squelch')} unit=" dBm" min={-140} max={0} value={sql}
|
||
disabled={off || !sqlOn} accent="#38bdf8"
|
||
onSet={(v) => { setHold('sql', v, st.squelch); call(() => SetTCISquelchLevel(v)); }} />
|
||
<div className={cn('grid gap-2', isCW(mode) ? 'grid-cols-3 sm:grid-cols-6' : 'grid-cols-3 sm:grid-cols-5')}>
|
||
<Toggle label="NB" on={nb} off={off} onClick={() => { setHold('nb', !nb, st.nb); call(() => SetTCINB(!nb)); }} />
|
||
<Toggle label="NR" on={nr} off={off} onClick={() => { setHold('nr', !nr, st.nr); call(() => SetTCINR(!nr)); }} />
|
||
<Toggle label="ANF" on={anf} off={off} onClick={() => { setHold('anf', !anf, st.anf); call(() => SetTCIANF(!anf)); }} />
|
||
{/* APF is an audio PEAK filter — it rings a single tone out of the
|
||
noise, which is a CW tool and nothing else. Shown only there:
|
||
off CW it is not a control, it is a puzzle. */}
|
||
{isCW(mode) && (
|
||
<Toggle label="APF" on={apf} off={off} onClick={() => { setHold('apf', !apf, st.apf); call(() => SetTCIAPF(!apf)); }} />
|
||
)}
|
||
<Toggle label="SQL" on={sqlOn} off={off} onClick={() => { setHold('sql_on', !sqlOn, st.squelch_on); call(() => SetTCISquelch(!sqlOn)); }} />
|
||
<Toggle label={t('tcip.mute')} on={muted} off={off} onClick={() => { setHold('mute', !muted, st.mute); call(() => SetTCIMute(!muted)); }} />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{t('tcip.agc')}</span>
|
||
{/* LONG is gone. The protocol accepts it, but it is a hang time
|
||
nobody reaches for between overs, and a fifth button that has to
|
||
be explained is worse than four that do not. */}
|
||
<div className="grid grid-cols-4 gap-2">
|
||
{['off', 'slow', 'med', 'fast'].map((m) => (
|
||
<Toggle key={m} label={m.toUpperCase()} on={agc === m} off={off}
|
||
onClick={() => { setHold('agc', m, st.agc || ''); call(() => SetTCIAGC(m)); }} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<div className="flex items-baseline justify-between">
|
||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{t('tcip.filter')}</span>
|
||
<span className="text-xs font-mono tabular-nums">{st.filter_lo}–{st.filter_hi} Hz</span>
|
||
</div>
|
||
<div className="flex items-center gap-2 pb-1">
|
||
<LevelRow label="LO" unit=" Hz" min={-5000} max={5000} step={10}
|
||
value={st.filter_lo} disabled={off}
|
||
onSet={(v) => call(() => SetTCIFilter(v, st.filter_hi))} />
|
||
</div>
|
||
<div className="flex items-center gap-2 pb-1">
|
||
<LevelRow label="HI" unit=" Hz" min={-5000} max={5000} step={10}
|
||
value={st.filter_hi} disabled={off}
|
||
onSet={(v) => call(() => SetTCIFilter(st.filter_lo, v))} />
|
||
</div>
|
||
<div className="grid grid-cols-4 sm:grid-cols-7 gap-2">
|
||
{widthsFor(mode).map((w) => {
|
||
const e = edgesFor(w, mode);
|
||
// Lit by the WIDTH the radio is actually using, not by an exact
|
||
// pair of edges: the operator may have moved one edge on the
|
||
// radio, and a button that only lights on our own numbers would
|
||
// go dark for a filter that is plainly 500 Hz wide.
|
||
const on = Math.abs((st.filter_hi - st.filter_lo) - w) <= 50;
|
||
return (
|
||
<Toggle key={w} label={widthLabel(w)} on={on} off={off}
|
||
title={`${e.lo}–${e.hi} Hz`}
|
||
onClick={() => call(() => SetTCIFilter(e.lo, e.hi))} />
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
{/* RIT / XIT — the SAME control the Icom console uses, now shared
|
||
rather than reinvented: a chip, a signed offset you can type into,
|
||
scroll on, or step with ±, and a zero. Ctrl+←/→ shifts the RIT. */}
|
||
<Card icon={Mic} title="RIT / XIT">
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||
<ShiftRow label="RIT" on={rit} hz={ritHz} accent="#38bdf8" disabled={off}
|
||
onToggle={() => { setHold('rit', !rit, st.rit); call(() => SetTCIRIT(!rit)); }}
|
||
onSet={(v) => { setHold('rit_hz', v, st.rit_offset); call(() => SetTCIRITOffset(v)); }} />
|
||
<ShiftRow label="XIT" on={xit} hz={xitHz} accent="#f59e0b" disabled={off}
|
||
onToggle={() => { setHold('xit', !xit, st.xit); call(() => SetTCIXIT(!xit)); }}
|
||
onSet={(v) => { setHold('xit_hz', v, st.xit_offset); call(() => SetTCIXITOffset(v)); }} />
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|