Files
OpsLog/frontend/src/components/IcomPanel.tsx
T

846 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, 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;
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,
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'];
// 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 }: { onReportRST?: (rst: string) => void } = {}) {
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 || '';
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. Manual by design — the app never wakes the rig
on connect; ON sends the wake preamble then the rig boots ~15 s. */}
<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>
<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>
{/* Speech processor / monitor / VOX. */}
<div className="pt-2 mt-1 border-t border-border/60 space-y-3">
<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="MON" on={st.monitor} value={st.mon_level}
onToggle={() => set({ monitor: !st.monitor }, () => IcomSetMonitor(!st.monitor))}
onLevel={(v) => set({ mon_level: v }, () => IcomSetMonLevel(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={[{ v: '0', l: 'OFF' }, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }]}
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>
</Card>
</div>
</div>
</div>
);
}