886 lines
44 KiB
TypeScript
886 lines
44 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
||
import { Radio, AudioLines, Mic, Activity, SlidersHorizontal, Antenna, Filter, Power } from 'lucide-react';
|
||
import {
|
||
GetIcomState, IcomRefresh,
|
||
IcomSetAFGain, IcomSetRFGain, IcomSetNB, IcomSetNBLevel, IcomSetNR, IcomSetNRLevel,
|
||
IcomSetANF, IcomSetAPF, IcomSetAGC, IcomSetPreamp, IcomSetAtt, IcomSetFilter,
|
||
IcomSetRFPower, IcomSetMicGain, IcomSetSplit, IcomTune, IcomSetPTT,
|
||
IcomSetScope, IcomScopeData, IcomSetScopeMode, IcomSetScopeEdges, GetCATState, SetCATFrequency, SetCATMode,
|
||
IcomSetRIT, IcomSetRITOn, IcomSetXITOn,
|
||
IcomSetAntenna, IcomSetPBTInner, IcomSetPBTOuter, IcomSetManualNotch, IcomSetNotchPos,
|
||
IcomSetSquelch, IcomSetComp, IcomSetCompLevel, IcomSetMonitor, IcomSetMonLevel,
|
||
IcomSetVOX, IcomSetVOXGain, IcomSetAntiVOX, IcomSetPower,
|
||
} from '../../wailsjs/go/main/App';
|
||
import { cn } from '@/lib/utils';
|
||
import { useI18n } from '@/lib/i18n';
|
||
import { sMeterRST } from '@/lib/rst';
|
||
|
||
type IcomState = {
|
||
available: boolean; model?: string; mode?: string;
|
||
transmitting: boolean; split: boolean;
|
||
s_meter: number; power_meter: number; swr_meter: number;
|
||
rf_power: number; mic_gain: number;
|
||
af_gain: number; rf_gain: number;
|
||
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; apf: boolean;
|
||
agc?: string; preamp: number; att: number; filter: number;
|
||
rit_hz: number; rit_on: boolean; xit_on: boolean;
|
||
antenna: number;
|
||
pbt_inner: number; pbt_outer: number; manual_notch: boolean; notch_pos: number;
|
||
squelch: number; comp: boolean; comp_level: number;
|
||
monitor: boolean; mon_level: number;
|
||
vox: boolean; vox_gain: number; anti_vox: number;
|
||
};
|
||
|
||
const ZERO: IcomState = {
|
||
available: false, transmitting: false, split: false,
|
||
s_meter: 0, power_meter: 0, swr_meter: 0, rf_power: 0, mic_gain: 0,
|
||
af_gain: 0, rf_gain: 0,
|
||
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, apf: false,
|
||
preamp: 0, att: 0, filter: 1,
|
||
rit_hz: 0, rit_on: false, xit_on: false,
|
||
antenna: 1,
|
||
pbt_inner: 50, pbt_outer: 50, manual_notch: false, notch_pos: 50,
|
||
squelch: 0, comp: false, comp_level: 0,
|
||
monitor: false, mon_level: 0,
|
||
vox: false, vox_gain: 0, anti_vox: 0,
|
||
};
|
||
|
||
// Band buttons jump the VFO to a sensible default frequency (SSB/CW mix) using
|
||
// the plain SetFrequency command — no band-stacking codes needed. Hz values.
|
||
const BANDS: { l: string; hz: number }[] = [
|
||
{ l: '160', hz: 1_840_000 }, { l: '80', hz: 3_750_000 }, { l: '40', hz: 7_100_000 },
|
||
{ l: '30', hz: 10_130_000 }, { l: '20', hz: 14_150_000 }, { l: '17', hz: 18_130_000 },
|
||
{ l: '15', hz: 21_250_000 }, { l: '12', hz: 24_950_000 }, { l: '10', hz: 28_400_000 },
|
||
{ l: '6', hz: 50_150_000 },
|
||
];
|
||
|
||
// Mode buttons for the console (like RS-BA1's row). SetCATMode picks USB/LSB for
|
||
// SSB by frequency and the rig's data variant for digital modes.
|
||
const MODES = ['SSB', 'CW', 'RTTY', 'PSK', 'AM', 'FM'];
|
||
|
||
// Attenuator steps are MODEL-dependent even though the CI-V command (0x11) is the
|
||
// same: the value byte is the dB. The IC-7610 (and 7700/7800/7851) have a 6/12/18
|
||
// dB stepped attenuator; the IC-7300/705/7100 have a single 20 dB attenuator; the
|
||
// IC-9700 a single 10 dB. Offering the wrong steps = a dead button (the rig NAKs
|
||
// e.g. 6 dB on a 7300). Default to the common single 20 dB for unknown models.
|
||
function attOptions(model?: string): { v: string; l: string }[] {
|
||
const m = (model ?? '').toUpperCase();
|
||
const OFF = { v: '0', l: 'OFF' };
|
||
if (/(7610|7700|7800|7850|7851)/.test(m)) {
|
||
return [OFF, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }];
|
||
}
|
||
if (m.includes('9700')) return [OFF, { v: '10', l: '10dB' }];
|
||
return [OFF, { v: '20', l: '20dB' }]; // IC-7300 / IC-705 / IC-7100 / default
|
||
}
|
||
|
||
// fmtVFO renders a Hz frequency the way an Icom front panel does:
|
||
// MHz "." 3-digit-kHz "." 2-digit-(10 Hz). 21032000 → "21.032.00".
|
||
function fmtVFO(hz?: number): string {
|
||
if (!hz || hz <= 0) return '––.–––.––';
|
||
const mhz = Math.floor(hz / 1_000_000);
|
||
const khz = Math.floor((hz % 1_000_000) / 1000);
|
||
const h2 = Math.floor((hz % 1000) / 10);
|
||
return `${mhz}.${String(khz).padStart(3, '0')}.${String(h2).padStart(2, '0')}`;
|
||
}
|
||
|
||
// modeMatches marks a mode button active, folding the rig's USB/LSB into SSB.
|
||
function modeMatches(btn: string, cur?: string): boolean {
|
||
if (!cur) return false;
|
||
if (btn === 'SSB') return cur === 'SSB' || cur === 'USB' || cur === 'LSB';
|
||
return btn === cur;
|
||
}
|
||
|
||
function Slider({ value, onChange, disabled, accent = '#2563eb', step = 1 }: {
|
||
value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string; step?: number;
|
||
}) {
|
||
const v = Math.max(0, Math.min(100, value));
|
||
const ref = useRef<HTMLInputElement>(null);
|
||
// Mouse-wheel adjusts the slider. React's onWheel is passive (preventDefault
|
||
// is ignored), so attach a non-passive native listener; read live values via
|
||
// refs to avoid stale closures.
|
||
const valRef = useRef(value); valRef.current = value;
|
||
const cbRef = useRef(onChange); cbRef.current = onChange;
|
||
const disRef = useRef(disabled); disRef.current = disabled;
|
||
const stepRef = useRef(step); stepRef.current = step;
|
||
useEffect(() => {
|
||
const el = ref.current;
|
||
if (!el) return;
|
||
const onWheel = (e: WheelEvent) => {
|
||
if (disRef.current) return;
|
||
e.preventDefault();
|
||
const d = e.deltaY < 0 ? stepRef.current : -stepRef.current;
|
||
const nv = Math.max(0, Math.min(100, valRef.current + d));
|
||
if (nv !== valRef.current) cbRef.current(nv);
|
||
};
|
||
el.addEventListener('wheel', onWheel, { passive: false });
|
||
return () => el.removeEventListener('wheel', onWheel);
|
||
}, []);
|
||
return (
|
||
<input
|
||
ref={ref}
|
||
type="range" min={0} max={100} value={v} disabled={disabled}
|
||
onChange={(e) => onChange(parseInt(e.target.value, 10))}
|
||
className={cn('flex-1 h-1.5 rounded-full appearance-none cursor-pointer disabled:opacity-30 disabled:cursor-default',
|
||
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:rounded-full',
|
||
'[&::-webkit-slider-thumb]:bg-card [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow-sm')}
|
||
style={{ background: `linear-gradient(to right, ${accent} ${v}%, #d8cfb8 ${v}%)`, borderColor: accent }}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function Segmented({ value, options, onChange }: {
|
||
value: string; options: { v: string; l: string }[]; onChange: (v: string) => void;
|
||
}) {
|
||
return (
|
||
<div className="inline-flex rounded-md border border-border overflow-hidden shrink-0">
|
||
{options.map((o) => (
|
||
<button key={o.v} type="button" onClick={() => onChange(o.v)}
|
||
className={cn('px-2 py-1 text-[11px] font-bold tracking-wide transition-colors border-l border-border first:border-l-0',
|
||
value === o.v ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||
{o.l}
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Chip({ on, onClick, label }: { on: boolean; onClick: () => void; label: string }) {
|
||
return (
|
||
<button type="button" onClick={onClick}
|
||
className={cn('w-14 shrink-0 px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
|
||
on ? 'bg-success border-success text-success-foreground' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||
{label}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function LevelRow({ label, on, onToggle, value, onLevel }: {
|
||
label: string; on: boolean; onToggle: () => void; value: number; onLevel: (v: number) => void;
|
||
}) {
|
||
return (
|
||
<div className="flex items-center gap-2">
|
||
<Chip on={on} onClick={onToggle} label={label} />
|
||
<Slider value={value} disabled={!on} onChange={onLevel} />
|
||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{value}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: 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" style={{ color: accent ?? 'var(--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>
|
||
);
|
||
}
|
||
|
||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||
return (
|
||
<div className="flex items-center gap-2">
|
||
<span className="w-16 shrink-0 text-[11px] font-bold uppercase tracking-wider text-muted-foreground">{label}</span>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Meter — a thin horizontal bar for a live 0-100 reading (S / Po / SWR).
|
||
// Optional onClick makes the row a button (used to send the S reading to RST tx).
|
||
function Meter({ label, value, accent, scale, onClick, title }: { label: string; value: number; accent: string; scale?: string; onClick?: () => void; title?: string }) {
|
||
const v = Math.max(0, Math.min(100, value));
|
||
const body = (
|
||
<>
|
||
<span className="w-9 shrink-0 text-[11px] font-bold uppercase tracking-wider text-muted-foreground">{label}</span>
|
||
<div className="flex-1 h-2.5 rounded-full bg-muted/60 overflow-hidden">
|
||
<div className="h-full rounded-full transition-[width] duration-150" style={{ width: `${v}%`, background: accent }} />
|
||
</div>
|
||
<span className="w-10 text-right text-[11px] font-mono tabular-nums text-muted-foreground">{scale ?? v}</span>
|
||
</>
|
||
);
|
||
if (onClick) {
|
||
return <button type="button" onClick={onClick} title={title} className="flex items-center gap-2 w-full rounded hover:bg-muted/50 -mx-1 px-1 py-0.5">{body}</button>;
|
||
}
|
||
return <div className="flex items-center gap-2">{body}</div>;
|
||
}
|
||
|
||
// ShiftRow — a RIT / ΔTX offset control: on/off chip + a wheel-adjustable signed
|
||
// offset (±10 Hz per notch or per ± button) + a clear (0) button.
|
||
function ShiftRow({ label, on, hz, accent, onToggle, onDelta, onClear }: {
|
||
label: string; on: boolean; hz: number; accent: string;
|
||
onToggle: () => void; onDelta: (d: number) => void; onClear: () => void;
|
||
}) {
|
||
const ref = useRef<HTMLDivElement>(null);
|
||
const cb = useRef(onDelta); cb.current = onDelta;
|
||
useEffect(() => {
|
||
const el = ref.current;
|
||
if (!el) return;
|
||
const onWheel = (e: WheelEvent) => { e.preventDefault(); cb.current(e.deltaY < 0 ? 10 : -10); };
|
||
el.addEventListener('wheel', onWheel, { passive: false });
|
||
return () => el.removeEventListener('wheel', onWheel);
|
||
}, []);
|
||
return (
|
||
<div className="flex items-center gap-2">
|
||
<Chip on={on} onClick={onToggle} label={label} />
|
||
<div ref={ref} title="Wheel / ± to shift"
|
||
className={cn('flex-1 flex items-center justify-between rounded-md border px-1 py-0.5 select-none cursor-ns-resize',
|
||
on ? 'border-border bg-muted/40' : 'border-border/60 bg-muted/20 opacity-60')}>
|
||
<button type="button" onClick={() => onDelta(-10)} className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground">−</button>
|
||
<span className="text-sm font-mono font-bold tabular-nums" style={{ color: on ? accent : undefined }}>
|
||
{hz > 0 ? '+' : hz < 0 ? '−' : ''}{Math.abs(hz)} Hz
|
||
</span>
|
||
<button type="button" onClick={() => onDelta(10)} className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground">+</button>
|
||
</div>
|
||
<button type="button" onClick={onClear}
|
||
className="w-8 shrink-0 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">0</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// sParts turns the raw 0-100 S-meter into S-unit + dB-over-S9 (S9 ≈ 47% on the
|
||
// CI-V 0-255 scale, +60 dB near full scale). Used for both the display label and
|
||
// the RST-tx value on click.
|
||
function sParts(v: number): { s: number; over: number; label: string } {
|
||
if (v >= 47) {
|
||
const over = Math.max(0, Math.round((v - 47) * 60 / 47));
|
||
return { s: 9, over, label: over > 0 ? `S9+${over}` : 'S9' };
|
||
}
|
||
const s = Math.max(0, Math.min(9, Math.round(v / 5.2)));
|
||
return { s, over: 0, label: `S${s}` };
|
||
}
|
||
|
||
// wfColor maps a 0-1 amplitude to a classic waterfall colour ramp
|
||
// (near-black → blue → cyan → green → amber → red).
|
||
const WF_STOPS: [number, [number, number, number]][] = [
|
||
[0.0, [8, 12, 28]], [0.22, [26, 58, 138]], [0.42, [0, 150, 190]],
|
||
[0.62, [46, 200, 120]], [0.80, [240, 210, 70]], [1.0, [244, 63, 60]],
|
||
];
|
||
function wfColor(v: number): [number, number, number] {
|
||
v = Math.max(0, Math.min(1, Math.pow(v, 0.7))); // gamma-lift so the noise floor still has hue
|
||
for (let i = 1; i < WF_STOPS.length; i++) {
|
||
if (v <= WF_STOPS[i][0]) {
|
||
const [a, ca] = WF_STOPS[i - 1], [b, cb] = WF_STOPS[i];
|
||
const f = (v - a) / (b - a || 1);
|
||
return [Math.round(ca[0] + (cb[0] - ca[0]) * f), Math.round(ca[1] + (cb[1] - ca[1]) * f), Math.round(ca[2] + (cb[2] - ca[2]) * f)];
|
||
}
|
||
}
|
||
return WF_STOPS[WF_STOPS.length - 1][1];
|
||
}
|
||
|
||
// ScopePanadapter — enables the rig's spectrum-scope stream and draws the
|
||
// reassembled sweep as a modern SDR panadapter: a glowing filled spectrum trace
|
||
// on top and a scrolling colour waterfall below. Amplitudes are raw rig scale
|
||
// (~0-160), normalised to the tallest recent peak so the trace fills the height.
|
||
function ScopePanadapter() {
|
||
const { t } = useI18n();
|
||
const [on, setOn] = useState(false);
|
||
const [fixed, setFixed] = useState(true);
|
||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||
const wfRef = useRef<HTMLCanvasElement>(null); // waterfall
|
||
const peakRef = useRef(160); // running amplitude ceiling for auto-scale
|
||
const holdRef = useRef<number[]>([]); // per-bin peak-hold line
|
||
const spanRef = useRef({ low: 0, high: 0 }); // latest sweep edges, for click-to-tune
|
||
const vfoRef = useRef(0); // latest VFO frequency, for wheel-tune
|
||
const centerRef = useRef(0); // scope centre we last set (for pan ◀/▶)
|
||
|
||
const toggle = () => {
|
||
const next = !on;
|
||
setOn(next);
|
||
IcomSetScope(next).catch(() => {});
|
||
};
|
||
|
||
// Centre/pan the FIXED scope: set the edges to centre ±50 kHz (a 100 kHz
|
||
// window). "Centre" uses the live VFO; ◀/▶ shift the window by 50 kHz. This
|
||
// just writes the rig's fixed edges — simple and independent of the waveform
|
||
// decode.
|
||
const SCOPE_HALF = 50_000;
|
||
const applyEdges = (center: number) => {
|
||
if (center <= 0) return;
|
||
centerRef.current = center;
|
||
setFixed(true);
|
||
IcomSetScopeEdges(center - SCOPE_HALF, center + SCOPE_HALF).catch(() => {});
|
||
};
|
||
const centerOnVfo = async () => {
|
||
let c = vfoRef.current;
|
||
if (c <= 0) { try { const cs = await GetCATState(); c = cs?.freq_hz || 0; } catch {} }
|
||
applyEdges(c);
|
||
};
|
||
const pan = (dir: number) => applyEdges((centerRef.current || vfoRef.current) + dir * SCOPE_HALF);
|
||
const setMode = (nextFixed: boolean) => {
|
||
setFixed(nextFixed);
|
||
IcomSetScopeMode(nextFixed).catch(() => {});
|
||
};
|
||
// Stop the stream when the panel unmounts.
|
||
useEffect(() => () => { IcomSetScope(false).catch(() => {}); }, []);
|
||
|
||
useEffect(() => {
|
||
if (!on) return;
|
||
let raf = 0, lastSeq = -1, alive = true;
|
||
const tick = async () => {
|
||
if (!alive) return;
|
||
try {
|
||
const sw = await IcomScopeData();
|
||
if (sw && sw.seq !== lastSeq && sw.amp && sw.amp.length) {
|
||
lastSeq = sw.seq;
|
||
setFixed(sw.fixed);
|
||
spanRef.current = { low: sw.low_hz, high: sw.high_hz };
|
||
let vfo = 0;
|
||
try {
|
||
const cs = await GetCATState();
|
||
vfo = cs?.split && cs.freq_rx_hz ? cs.freq_rx_hz : (cs?.freq_hz || 0);
|
||
} catch {}
|
||
if (vfo > 0) vfoRef.current = vfo;
|
||
draw(sw.amp, sw.low_hz, sw.high_hz, vfoRef.current, sw.fixed);
|
||
}
|
||
} catch {}
|
||
if (alive) raf = window.setTimeout(() => { raf = requestAnimationFrame(tick); }, 40) as unknown as number;
|
||
};
|
||
raf = requestAnimationFrame(tick);
|
||
return () => { alive = false; cancelAnimationFrame(raf); window.clearTimeout(raf); };
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [on]);
|
||
|
||
// Double-click tunes the rig to the clicked frequency.
|
||
const onDblClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||
const cv = canvasRef.current;
|
||
const { low, high } = spanRef.current;
|
||
if (!cv || !(low > 0 && high > low)) return;
|
||
const rect = cv.getBoundingClientRect();
|
||
const frac = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||
const hz = Math.round((low + frac * (high - low)) / 100) * 100; // nearest 100 Hz
|
||
SetCATFrequency(hz).catch(() => {});
|
||
};
|
||
|
||
// Mouse-wheel over the scope QSYs ±100 Hz. Non-passive listener so we can
|
||
// preventDefault (else the page scrolls); optimistic vfoRef so quick spins
|
||
// accumulate before the poll reconciles.
|
||
useEffect(() => {
|
||
const el = canvasRef.current;
|
||
if (!el || !on) return;
|
||
const onWheel = (e: WheelEvent) => {
|
||
if (!vfoRef.current) return;
|
||
e.preventDefault();
|
||
const next = vfoRef.current + (e.deltaY < 0 ? 100 : -100);
|
||
vfoRef.current = next;
|
||
SetCATFrequency(next).catch(() => {});
|
||
};
|
||
el.addEventListener('wheel', onWheel, { passive: false });
|
||
return () => el.removeEventListener('wheel', onWheel);
|
||
}, [on]);
|
||
|
||
const draw = (amp: number[], lowHz: number, highHz: number, vfoHz: number, fixedMode: boolean) => {
|
||
const cv = canvasRef.current;
|
||
if (!cv) return;
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const w = cv.clientWidth, h = cv.clientHeight;
|
||
if (cv.width !== w * dpr || cv.height !== h * dpr) { cv.width = w * dpr; cv.height = h * dpr; }
|
||
const ctx = cv.getContext('2d');
|
||
if (!ctx) return;
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
|
||
// Auto-scale: track the peak, decaying slowly so the floor doesn't jump.
|
||
const peak = Math.max(...amp);
|
||
peakRef.current = Math.max(peak, peakRef.current * 0.95, 40);
|
||
const scale = peakRef.current;
|
||
const n = amp.length;
|
||
|
||
// Background — deep navy vertical gradient.
|
||
const bg = ctx.createLinearGradient(0, 0, 0, h);
|
||
bg.addColorStop(0, '#0b1220'); bg.addColorStop(1, '#05070e');
|
||
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
|
||
|
||
// Grid.
|
||
ctx.strokeStyle = 'rgba(120,150,200,0.08)';
|
||
ctx.lineWidth = 1;
|
||
for (let i = 1; i < 4; i++) { const y = (h * i) / 4; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); }
|
||
for (let i = 1; i < 8; i++) { const x = (w * i) / 8; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); }
|
||
|
||
const xOf = (i: number) => (i / (n - 1)) * w;
|
||
const yOf = (v: number) => h - Math.min(1, v / scale) * h;
|
||
|
||
// Peak-hold line (slow decay) — a faint ghost of recent maxima.
|
||
const hold = holdRef.current;
|
||
if (hold.length !== n) hold.length = n, hold.fill(0);
|
||
for (let i = 0; i < n; i++) hold[i] = Math.max(amp[i], hold[i] * 0.92);
|
||
|
||
// Filled spectrum area.
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, h);
|
||
for (let i = 0; i < n; i++) ctx.lineTo(xOf(i), yOf(amp[i]));
|
||
ctx.lineTo(w, h); ctx.closePath();
|
||
const grad = ctx.createLinearGradient(0, 0, 0, h);
|
||
grad.addColorStop(0, 'rgba(56,189,248,0.40)');
|
||
grad.addColorStop(1, 'rgba(56,189,248,0.02)');
|
||
ctx.fillStyle = grad; ctx.fill();
|
||
|
||
// Peak-hold trace (thin, faint).
|
||
ctx.beginPath();
|
||
for (let i = 0; i < n; i++) { const x = xOf(i), y = yOf(hold[i]); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }
|
||
ctx.strokeStyle = 'rgba(148,197,255,0.35)'; ctx.lineWidth = 1; ctx.stroke();
|
||
|
||
// Live spectrum trace with a soft glow.
|
||
ctx.save();
|
||
ctx.shadowColor = 'rgba(56,189,248,0.7)'; ctx.shadowBlur = 6;
|
||
ctx.beginPath();
|
||
for (let i = 0; i < n; i++) { const x = xOf(i), y = yOf(amp[i]); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }
|
||
ctx.strokeStyle = '#7dd3fc'; ctx.lineWidth = 1.5; ctx.lineJoin = 'round'; ctx.stroke();
|
||
ctx.restore();
|
||
|
||
// VFO marker: you should ALWAYS see where you are. Exact position when the VFO
|
||
// is inside the span; the centre in CTR mode; clamped to the nearest edge with
|
||
// a sideways arrow in FIX mode when the fixed scope doesn't cover the VFO (so
|
||
// you can tell which way to tune to get it back on-screen).
|
||
const haveVfo = vfoHz > 0 && lowHz > 0 && highHz > lowHz;
|
||
const inSpan = haveVfo && vfoHz >= lowHz && vfoHz <= highHz;
|
||
let markerX = -1;
|
||
let offEdge = 0; // -1 = VFO off the left edge, +1 = off the right
|
||
if (inSpan) markerX = ((vfoHz - lowHz) / (highHz - lowHz)) * w;
|
||
else if (!fixedMode) markerX = w / 2;
|
||
else if (haveVfo) { offEdge = vfoHz < lowHz ? -1 : 1; markerX = offEdge < 0 ? 1 : w - 1; }
|
||
if (markerX >= 0) {
|
||
const x = markerX;
|
||
ctx.fillStyle = 'rgba(244,63,94,0.10)'; ctx.fillRect(x - 5, 0, 10, h);
|
||
ctx.strokeStyle = 'rgba(244,63,94,0.9)'; ctx.lineWidth = 1.25;
|
||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke();
|
||
ctx.fillStyle = 'rgba(244,63,94,0.95)';
|
||
if (offEdge === 0) {
|
||
ctx.beginPath(); ctx.moveTo(x - 4, 0); ctx.lineTo(x + 4, 0); ctx.lineTo(x, 6); ctx.closePath(); ctx.fill();
|
||
} else {
|
||
const yh = 8; ctx.beginPath(); ctx.moveTo(x, yh - 5); ctx.lineTo(x + offEdge * 7, yh); ctx.lineTo(x, yh + 5); ctx.closePath(); ctx.fill();
|
||
}
|
||
}
|
||
|
||
// Frequency scale. In fixed mode the rig reports usable edge frequencies, so
|
||
// we label low/centre/high from them. In centre mode the header frame's edge
|
||
// pair isn't a usable low..high range, but the scope is centred on the VFO —
|
||
// so we always label the centre with the live VFO frequency (which we fetch
|
||
// each sweep), and only add edge labels when the reported edges genuinely
|
||
// bracket the VFO. That guarantees you always see your frequency in CTR.
|
||
const mhz = (hz: number) => (hz / 1e6).toFixed(3);
|
||
ctx.font = '10px ui-monospace, monospace';
|
||
ctx.textBaseline = 'bottom';
|
||
ctx.shadowColor = 'rgba(0,0,0,0.8)'; ctx.shadowBlur = 3;
|
||
ctx.fillStyle = 'rgba(226,232,240,0.85)';
|
||
const label = (txt: string, x: number, align: CanvasTextAlign) => { ctx.textAlign = align; ctx.fillText(txt, x, h - 3); };
|
||
const validEdges = lowHz > 0 && highHz > lowHz;
|
||
if (fixedMode) {
|
||
if (validEdges) {
|
||
label(mhz(lowHz), 4, 'left');
|
||
label(mhz((lowHz + highHz) / 2), w / 2, 'center');
|
||
label(mhz(highHz), w - 4, 'right');
|
||
}
|
||
} else {
|
||
if (validEdges && vfoHz >= lowHz && vfoHz <= highHz) {
|
||
label(mhz(lowHz), 4, 'left');
|
||
label(mhz(highHz), w - 4, 'right');
|
||
}
|
||
if (vfoHz > 0) label(mhz(vfoHz), w / 2, 'center');
|
||
}
|
||
ctx.shadowBlur = 0;
|
||
|
||
drawWaterfall(amp, scale);
|
||
};
|
||
|
||
// drawWaterfall scrolls the history down one row and paints the newest sweep
|
||
// as a colour-mapped line at the top.
|
||
const drawWaterfall = (amp: number[], scale: number) => {
|
||
const cv = wfRef.current;
|
||
if (!cv) return;
|
||
const w = Math.max(1, cv.clientWidth), h = Math.max(1, cv.clientHeight);
|
||
if (cv.width !== w || cv.height !== h) { cv.width = w; cv.height = h; }
|
||
const ctx = cv.getContext('2d');
|
||
if (!ctx) return;
|
||
// Scroll everything down by one pixel row.
|
||
ctx.drawImage(cv, 0, 0, w, h - 1, 0, 1, w, h - 1);
|
||
// Paint the new top row.
|
||
const row = ctx.createImageData(w, 1);
|
||
const n = amp.length;
|
||
for (let x = 0; x < w; x++) {
|
||
const i = Math.min(n - 1, Math.round((x / (w - 1)) * (n - 1)));
|
||
const [r, g, b] = wfColor(amp[i] / scale);
|
||
const o = x * 4;
|
||
row.data[o] = r; row.data[o + 1] = g; row.data[o + 2] = b; row.data[o + 3] = 255;
|
||
}
|
||
ctx.putImageData(row, 0, 0);
|
||
};
|
||
|
||
// Collapsible card: when the scope is off, only the header band shows (the
|
||
// canvas is hidden entirely) so it doesn't waste vertical space. The CTR/FIX
|
||
// and ON/OFF controls live in the header itself.
|
||
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">
|
||
<Activity className="size-4" style={{ color: '#38bdf8' }} />
|
||
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{t('icmp.spectrum')}</span>
|
||
<div className="ml-auto flex items-center gap-2 shrink-0">
|
||
{on && (
|
||
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||
<button type="button" onClick={() => pan(-1)} title={t('icmp.scopePanDown')}
|
||
className="px-2 py-1 text-xs font-bold bg-card text-muted-foreground hover:bg-muted border-r border-border">◀</button>
|
||
<button type="button" onClick={centerOnVfo} title={t('icmp.scopeCenterVfo')}
|
||
className="px-2 py-1 text-[11px] font-bold bg-card text-muted-foreground hover:bg-muted border-r border-border">⊙</button>
|
||
<button type="button" onClick={() => pan(1)} title={t('icmp.scopePanUp')}
|
||
className="px-2 py-1 text-xs font-bold bg-card text-muted-foreground hover:bg-muted">▶</button>
|
||
</div>
|
||
)}
|
||
{on && (
|
||
<Segmented value={fixed ? 'FIX' : 'CTR'} options={[{ v: 'CTR', l: 'CTR' }, { v: 'FIX', l: 'FIX' }]}
|
||
onChange={(v) => setMode(v === 'FIX')} />
|
||
)}
|
||
<Chip label={on ? 'ON' : 'OFF'} on={on} onClick={toggle} />
|
||
</div>
|
||
</div>
|
||
{on && (
|
||
<div className="p-3">
|
||
<div className="rounded-xl overflow-hidden ring-1 ring-info/20 shadow-lg shadow-sky-500/5 bg-[#05070e]">
|
||
<canvas ref={canvasRef} onDoubleClick={onDblClick}
|
||
className="w-full block cursor-crosshair" style={{ height: 140 }} />
|
||
<canvas ref={wfRef} className="w-full block" style={{ height: 96 }} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// IcomPanel — full control surface (RX DSP + TX) for an Icom on the CI-V backend.
|
||
// Unlike the Flex (which pushes state), the Icom is polled: meters/TX state are
|
||
// read every cache cycle; DSP set-controls are optimistic and reconcile on the
|
||
// next poll. Front-panel knob changes for DSP show after ↻ Refresh.
|
||
export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (rst: string) => void; isNetwork?: boolean } = {}) {
|
||
const { t } = useI18n();
|
||
const [st, setSt] = useState<IcomState>(ZERO);
|
||
const [cat, setCat] = useState<any>(null); // RigState (freq/mode/split) for the VFO display
|
||
const [tuning, setTuning] = useState(false);
|
||
const txRef = useRef(false);
|
||
const stRef = useRef<IcomState>(ZERO); stRef.current = st;
|
||
|
||
const load = () => {
|
||
GetIcomState().then((s) => setSt((s ?? ZERO) as IcomState)).catch(() => {});
|
||
GetCATState().then((c) => setCat(c ?? null)).catch(() => {});
|
||
};
|
||
const setMode = (m: string) => { setCat((c: any) => (c ? { ...c, mode: m } : c)); SetCATMode(m).catch(() => {}); };
|
||
// Initial one-shot read of the rig's DSP snapshot on mount (the 500ms poll only
|
||
// re-reads the cache; the backend also loads DSP on the first responsive read).
|
||
const refresh = async () => {
|
||
try { await IcomRefresh(); } catch {}
|
||
await load();
|
||
};
|
||
|
||
useEffect(() => {
|
||
refresh();
|
||
const id = window.setInterval(load, 500); // fast poll so meters/TX feel live
|
||
return () => window.clearInterval(id);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
// Optimistic local update + fire the command; the cache poll reconciles.
|
||
const set = (patch: Partial<IcomState>, fn: () => Promise<void>) => {
|
||
setSt((s) => ({ ...s, ...patch }));
|
||
fn().catch(() => {});
|
||
};
|
||
|
||
const toggleMox = () => {
|
||
const next = !txRef.current;
|
||
txRef.current = next;
|
||
set({ transmitting: next }, () => IcomSetPTT(next));
|
||
};
|
||
|
||
const tune = async () => {
|
||
setTuning(true);
|
||
try { await IcomTune(); } catch {}
|
||
window.setTimeout(() => setTuning(false), 4000);
|
||
};
|
||
|
||
// RIT/ΔTX offset (signed Hz, clamped ±9999). Optimistic like the DSP controls.
|
||
const setRit = (hz: number) => {
|
||
const v = Math.max(-9999, Math.min(9999, hz));
|
||
set({ rit_hz: v }, () => IcomSetRIT(v));
|
||
};
|
||
|
||
// Ctrl+Left/Right shifts the RIT by ±10 Hz while RIT is active — a keyboard
|
||
// clarifier for zero-beating a caller without touching the mouse.
|
||
useEffect(() => {
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (!e.ctrlKey || (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight')) return;
|
||
const s = stRef.current;
|
||
if (!s.available || !s.rit_on) return;
|
||
e.preventDefault();
|
||
setRit(s.rit_hz + (e.key === 'ArrowRight' ? 10 : -10));
|
||
};
|
||
window.addEventListener('keydown', onKey);
|
||
return () => window.removeEventListener('keydown', onKey);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
if (!st.available) {
|
||
return (
|
||
<div className="h-full flex items-center justify-center text-sm text-muted-foreground p-6 text-center">
|
||
{t('icmp.notConnected')}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const tx = st.transmitting;
|
||
// VFO readout. In split the active/listening VFO is RX (freq_rx_hz) and the
|
||
// other is TX (freq_hz); otherwise there's a single VFO (freq_hz).
|
||
const split = !!cat?.split;
|
||
const mainHz: number = split ? (cat?.freq_rx_hz || 0) : (cat?.freq_hz || 0);
|
||
const subHz: number = split ? (cat?.freq_hz || 0) : 0;
|
||
const curMode: string = cat?.mode || st.mode || '';
|
||
// Mode-dependent controls: VOX / speech-comp / mic are voice-only (hidden on
|
||
// CW and data); APF (audio peak filter) is CW-only. Fold USB/LSB into phone.
|
||
const um = curMode.toUpperCase();
|
||
const isCW = um === 'CW' || um === 'CWR';
|
||
const isPhone = um === 'SSB' || um === 'USB' || um === 'LSB' || um === 'AM' || um === 'FM';
|
||
|
||
return (
|
||
<div className="h-full min-h-0 overflow-auto bg-background">
|
||
<div className="max-w-5xl mx-auto p-3 space-y-3">
|
||
{/* Header strip: model + mode + live RX/TX indicator + split badge. */}
|
||
<div className="flex items-center justify-between rounded-xl border border-border bg-card px-3 py-2 shadow-sm">
|
||
<div className="flex items-center gap-2">
|
||
<span className={cn('inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] font-bold uppercase tracking-wider',
|
||
tx ? 'bg-destructive text-destructive-foreground' : 'bg-success text-success-foreground')}>
|
||
<span className={cn('size-2 rounded-full', tx ? 'bg-card animate-pulse' : 'bg-card/90')} />
|
||
{tx ? 'TX' : 'RX'}
|
||
</span>
|
||
<span className="text-sm font-bold">{st.model || 'Icom'}</span>
|
||
{st.mode ? <span className="text-xs font-mono text-muted-foreground">{st.mode}</span> : null}
|
||
{st.split ? <span className="rounded-md bg-warning/20 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-warning">Split</span> : null}
|
||
</div>
|
||
<div className="flex items-center gap-1.5">
|
||
{/* Radio power ON / OFF — NETWORK only. Over USB the CI-V interface is
|
||
unpowered while the rig is off, so power-ON can't reach it (OFF works
|
||
but ON doesn't); hiding both avoids a dead button. On the network the
|
||
rig's LAN server stays alive in standby, so both work. */}
|
||
{isNetwork && (
|
||
<>
|
||
<button type="button" onClick={() => IcomSetPower(true).catch(() => {})} title={t('icmp.powerOnHint')}
|
||
className="inline-flex items-center gap-1 rounded-md border border-success/60 bg-success/10 px-2 py-1 text-xs font-bold text-success hover:bg-success/20">
|
||
<Power className="size-3.5" /> ON
|
||
</button>
|
||
<button type="button" onClick={() => IcomSetPower(false).catch(() => {})} title={t('icmp.powerOffHint')}
|
||
className="inline-flex items-center gap-1 rounded-md border border-destructive/60 bg-destructive/10 px-2 py-1 text-xs font-bold text-destructive hover:bg-destructive/20">
|
||
<Power className="size-3.5" /> OFF
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* VFO readout — the RS-BA1-style twin display: MAIN (active) + SUB, the big
|
||
tabular frequency, mode badge, band, and the RIT/ΔTX offset. */}
|
||
<div className="rounded-xl border border-border bg-muted/25 shadow-inner overflow-hidden">
|
||
<div className="grid grid-cols-2 divide-x divide-border/60">
|
||
{/* MAIN VFO */}
|
||
<div className="px-4 py-3">
|
||
<div className="flex items-center justify-between mb-1.5">
|
||
<span className={cn('text-[10px] font-bold uppercase tracking-widest', tx ? 'text-destructive' : 'text-success')}>{tx ? 'Main · TX' : 'Main'}</span>
|
||
{curMode ? <span className="rounded px-1.5 py-0.5 text-[10px] font-bold bg-primary/15 text-primary">{curMode}</span> : null}
|
||
</div>
|
||
<div className="font-mono font-bold tabular-nums leading-none text-foreground" style={{ fontSize: 'clamp(1.5rem, 4.5vw, 2.25rem)' }}>{fmtVFO(mainHz)}</div>
|
||
<div className="mt-1.5 flex items-center gap-2 text-[11px] font-mono text-muted-foreground">
|
||
<span>{cat?.band || (mainHz ? '' : '—')}</span>
|
||
{st.rit_on ? <span className="text-primary">RIT {st.rit_hz > 0 ? '+' : st.rit_hz < 0 ? '−' : ''}{Math.abs(st.rit_hz)}</span> : null}
|
||
{st.xit_on ? <span className="text-warning">ΔTX</span> : null}
|
||
</div>
|
||
</div>
|
||
{/* SUB VFO (populated in split; dimmed otherwise) */}
|
||
<div className={cn('px-4 py-3', !split && 'opacity-40')}>
|
||
<div className="flex items-center justify-between mb-1.5">
|
||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">Sub</span>
|
||
{split ? <span className="rounded px-1.5 py-0.5 text-[10px] font-bold bg-warning/15 text-warning">SPLIT</span> : null}
|
||
</div>
|
||
<div className="font-mono font-bold tabular-nums leading-none text-muted-foreground" style={{ fontSize: 'clamp(1.5rem, 4.5vw, 2.25rem)' }}>{fmtVFO(subHz)}</div>
|
||
<div className="mt-1.5 text-[11px] font-mono text-muted-foreground">{split ? 'TX' : ''}</div>
|
||
</div>
|
||
</div>
|
||
{/* Mode selector row (RS-BA1's SSB/CW/RTTY/PSK/AM/FM). */}
|
||
<div className="grid grid-cols-6 border-t border-border/60 divide-x divide-border/60">
|
||
{MODES.map((m) => {
|
||
const on = modeMatches(m, curMode);
|
||
return (
|
||
<button key={m} type="button" onClick={() => setMode(m)}
|
||
className={cn('py-1.5 text-[11px] font-bold tracking-wide transition-colors',
|
||
on ? 'bg-primary text-primary-foreground' : 'bg-card/40 text-muted-foreground hover:bg-muted')}>
|
||
{m}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Live meters — always visible: S (RX, click → RST), Po in watts, SWR. */}
|
||
<div className="rounded-xl border border-border bg-card px-3 py-2.5 shadow-sm grid grid-cols-1 sm:grid-cols-3 gap-x-5 gap-y-2">
|
||
{(() => { const sp = sParts(st.s_meter); return (
|
||
<Meter label="S" value={st.s_meter} accent="#22c55e" scale={sp.label}
|
||
title={onReportRST ? t('rst.clickToFill') : undefined}
|
||
onClick={onReportRST ? () => onReportRST(sMeterRST(sp.s, sp.over, st.mode)) : undefined} />
|
||
); })()}
|
||
<Meter label="Po" value={st.power_meter} accent="#ef4444" scale={`${st.power_meter} W`} />
|
||
<Meter label="SWR" value={st.swr_meter} accent="#f59e0b" scale={st.swr_meter > 0 ? `${(1 + st.swr_meter / 33.3).toFixed(1)}` : '1.0'} />
|
||
</div>
|
||
|
||
{/* Spectrum panadapter (full width). */}
|
||
<ScopePanadapter />
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||
{/* Band buttons + antenna selection. */}
|
||
<Card icon={Antenna} title={t('icmp.bandsAntenna')} accent="#0891b2">
|
||
<div className="grid grid-cols-5 gap-1.5">
|
||
{BANDS.map((b) => (
|
||
<button key={b.l} type="button" onClick={() => SetCATFrequency(b.hz).catch(() => {})}
|
||
className="px-1 py-1.5 rounded-md text-[11px] font-bold border border-border bg-card text-foreground hover:bg-muted transition-colors">
|
||
{b.l}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<Row label={t('icmp.antenna')}>
|
||
<Segmented value={String(st.antenna)} options={[{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }]}
|
||
onChange={(v) => set({ antenna: parseInt(v) }, () => IcomSetAntenna(parseInt(v)))} />
|
||
</Row>
|
||
</Card>
|
||
|
||
{/* Clarifiers: RIT & ΔTX (XIT) — wheel or ± to shift, Ctrl+←/→ shifts RIT. */}
|
||
<Card icon={SlidersHorizontal} title={t('icmp.clarifiers')} accent="#8b5cf6">
|
||
<ShiftRow label="RIT" accent="#8b5cf6" on={st.rit_on} hz={st.rit_hz}
|
||
onToggle={() => set({ rit_on: !st.rit_on }, () => IcomSetRITOn(!st.rit_on))}
|
||
onDelta={(d) => setRit(st.rit_hz + d)} onClear={() => setRit(0)} />
|
||
<ShiftRow label="ΔTX" accent="#f59e0b" on={st.xit_on} hz={st.rit_hz}
|
||
onToggle={() => set({ xit_on: !st.xit_on }, () => IcomSetXITOn(!st.xit_on))}
|
||
onDelta={(d) => setRit(st.rit_hz + d)} onClear={() => setRit(0)} />
|
||
<p className="text-[11px] text-muted-foreground">{t('icmp.ritHint')}</p>
|
||
</Card>
|
||
|
||
{/* Transmit controls. */}
|
||
<Card icon={Mic} title={t('icmp.transmit')} accent="#ef4444">
|
||
<Row label={t('icmp.power')}>
|
||
<Slider value={st.rf_power} accent="#ef4444" onChange={(v) => set({ rf_power: v }, () => IcomSetRFPower(v))} />
|
||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.rf_power}</span>
|
||
</Row>
|
||
{isPhone && (
|
||
<Row label={t('icmp.mic')}>
|
||
<Slider value={st.mic_gain} accent="#ef4444" onChange={(v) => set({ mic_gain: v }, () => IcomSetMicGain(v))} />
|
||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_gain}</span>
|
||
</Row>
|
||
)}
|
||
<div className="flex items-center gap-2 pt-1">
|
||
<button type="button" onClick={toggleMox}
|
||
className={cn('flex-1 px-3 py-1.5 rounded-md text-xs font-bold border transition-colors',
|
||
tx ? 'bg-destructive border-destructive text-destructive-foreground' : 'bg-card text-foreground border-border hover:bg-muted')}>
|
||
{tx ? 'TX ON' : 'MOX'}
|
||
</button>
|
||
<Chip label="SPLIT" on={st.split} onClick={() => set({ split: !st.split }, () => IcomSetSplit(!st.split))} />
|
||
<button type="button" onClick={tune} disabled={tuning}
|
||
className={cn('w-14 shrink-0 px-2 py-1.5 rounded-md text-[11px] font-bold border transition-colors',
|
||
tuning ? 'bg-warning border-warning text-warning-foreground animate-pulse' : 'bg-card text-foreground border-border hover:bg-muted')}>
|
||
TUNE
|
||
</button>
|
||
</div>
|
||
{/* Monitor (all modes) + speech processor / VOX (voice modes only —
|
||
they don't exist on CW or data). */}
|
||
<div className="pt-2 mt-1 border-t border-border/60 space-y-3">
|
||
<LevelRow label="MON" on={st.monitor} value={st.mon_level}
|
||
onToggle={() => set({ monitor: !st.monitor }, () => IcomSetMonitor(!st.monitor))}
|
||
onLevel={(v) => set({ mon_level: v }, () => IcomSetMonLevel(v))} />
|
||
{isPhone && (
|
||
<>
|
||
<LevelRow label="COMP" on={st.comp} value={st.comp_level}
|
||
onToggle={() => set({ comp: !st.comp }, () => IcomSetComp(!st.comp))}
|
||
onLevel={(v) => set({ comp_level: v }, () => IcomSetCompLevel(v))} />
|
||
<LevelRow label="VOX" on={st.vox} value={st.vox_gain}
|
||
onToggle={() => set({ vox: !st.vox }, () => IcomSetVOX(!st.vox))}
|
||
onLevel={(v) => set({ vox_gain: v }, () => IcomSetVOXGain(v))} />
|
||
<Row label="Anti-VOX">
|
||
<Slider value={st.anti_vox} disabled={!st.vox} onChange={(v) => set({ anti_vox: v }, () => IcomSetAntiVOX(v))} />
|
||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.anti_vox}</span>
|
||
</Row>
|
||
</>
|
||
)}
|
||
</div>
|
||
</Card>
|
||
|
||
<Card icon={Radio} title={t('icmp.receive')} accent="#2563eb">
|
||
<Row label="AF">
|
||
<Slider value={st.af_gain} onChange={(v) => set({ af_gain: v }, () => IcomSetAFGain(v))} />
|
||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.af_gain}</span>
|
||
</Row>
|
||
<Row label="RF">
|
||
<Slider value={st.rf_gain} onChange={(v) => set({ rf_gain: v }, () => IcomSetRFGain(v))} />
|
||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.rf_gain}</span>
|
||
</Row>
|
||
<Row label={t('icmp.squelch')}>
|
||
<Slider value={st.squelch} onChange={(v) => set({ squelch: v }, () => IcomSetSquelch(v))} />
|
||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.squelch}</span>
|
||
</Row>
|
||
<Row label="AGC">
|
||
<Segmented value={st.agc || ''} options={[{ v: 'FAST', l: 'FAST' }, { v: 'MID', l: 'MID' }, { v: 'SLOW', l: 'SLOW' }]}
|
||
onChange={(v) => set({ agc: v }, () => IcomSetAGC(v))} />
|
||
</Row>
|
||
<Row label={t('icmp.preamp')}>
|
||
<Segmented value={String(st.preamp)} options={[{ v: '0', l: 'OFF' }, { v: '1', l: 'P1' }, { v: '2', l: 'P2' }]}
|
||
onChange={(v) => set({ preamp: parseInt(v) }, () => IcomSetPreamp(parseInt(v)))} />
|
||
</Row>
|
||
<Row label="Att">
|
||
<Segmented value={String(st.att)} options={attOptions(cat?.rig)}
|
||
onChange={(v) => set({ att: parseInt(v) }, () => IcomSetAtt(parseInt(v)))} />
|
||
</Row>
|
||
<Row label={t('icmp.filter')}>
|
||
<Segmented value={String(st.filter)} options={[{ v: '1', l: 'FIL1' }, { v: '2', l: 'FIL2' }, { v: '3', l: 'FIL3' }]}
|
||
onChange={(v) => set({ filter: parseInt(v) }, () => IcomSetFilter(parseInt(v)))} />
|
||
</Row>
|
||
</Card>
|
||
|
||
{/* Twin PBT + manual notch. Sliders are 0-100 with 50 = centre. */}
|
||
<Card icon={Filter} title={t('icmp.passband')} accent="#7c3aed">
|
||
<Row label="PBT-IN">
|
||
<Slider value={st.pbt_inner} accent="#7c3aed" onChange={(v) => set({ pbt_inner: v }, () => IcomSetPBTInner(v))} />
|
||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.pbt_inner - 50 > 0 ? '+' : ''}{st.pbt_inner - 50}</span>
|
||
</Row>
|
||
<Row label="PBT-OUT">
|
||
<Slider value={st.pbt_outer} accent="#7c3aed" onChange={(v) => set({ pbt_outer: v }, () => IcomSetPBTOuter(v))} />
|
||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.pbt_outer - 50 > 0 ? '+' : ''}{st.pbt_outer - 50}</span>
|
||
</Row>
|
||
<button type="button"
|
||
onClick={() => { set({ pbt_inner: 50 }, () => IcomSetPBTInner(50)); set({ pbt_outer: 50 }, () => IcomSetPBTOuter(50)); }}
|
||
className="w-full py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">
|
||
{t('icmp.pbtCenter')}
|
||
</button>
|
||
<div className="pt-1 border-t border-border/60 space-y-3">
|
||
<div className="flex items-center gap-2">
|
||
<Chip label="MN" on={st.manual_notch} onClick={() => set({ manual_notch: !st.manual_notch }, () => IcomSetManualNotch(!st.manual_notch))} />
|
||
<Slider value={st.notch_pos} disabled={!st.manual_notch} accent="#7c3aed" onChange={(v) => set({ notch_pos: v }, () => IcomSetNotchPos(v))} />
|
||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.notch_pos}</span>
|
||
</div>
|
||
<p className="text-[11px] text-muted-foreground">{t('icmp.manualNotch')}</p>
|
||
</div>
|
||
</Card>
|
||
|
||
<Card icon={AudioLines} title={t('icmp.noiseNotch')} accent="#16a34a">
|
||
<LevelRow label="NB" on={st.nb} value={st.nb_level}
|
||
onToggle={() => set({ nb: !st.nb }, () => IcomSetNB(!st.nb))}
|
||
onLevel={(v) => set({ nb_level: v }, () => IcomSetNBLevel(v))} />
|
||
<LevelRow label="NR" on={st.nr} value={st.nr_level}
|
||
onToggle={() => set({ nr: !st.nr }, () => IcomSetNR(!st.nr))}
|
||
onLevel={(v) => set({ nr_level: v }, () => IcomSetNRLevel(v))} />
|
||
<div className="flex items-center gap-2">
|
||
<Chip label="ANF" on={st.anf} onClick={() => set({ anf: !st.anf }, () => IcomSetANF(!st.anf))} />
|
||
<span className="text-xs text-muted-foreground">{t('icmp.autoNotch')}</span>
|
||
</div>
|
||
{/* APF (audio peak filter) — CW only: peaks the CW tone. */}
|
||
{isCW && (
|
||
<div className="flex items-center gap-2">
|
||
<Chip label="APF" on={st.apf} onClick={() => set({ apf: !st.apf }, () => IcomSetAPF(!st.apf))} />
|
||
<span className="text-xs text-muted-foreground">{t('icmp.apf')}</span>
|
||
</div>
|
||
)}
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|