diff --git a/app.go b/app.go index 064cd18..235d3d8 100644 --- a/app.go +++ b/app.go @@ -7842,6 +7842,30 @@ func (a *App) IcomSetScopeMode(fixed bool) error { return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetScopeMode(fixed) }) } +// IcomSetRIT sets the RIT/ΔTX offset in signed Hz. +func (a *App) IcomSetRIT(hz int) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetRIT(hz) }) +} + +// IcomSetRITOn toggles RIT. +func (a *App) IcomSetRITOn(on bool) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetRITOn(on) }) +} + +// IcomSetXITOn toggles ΔTX (XIT). +func (a *App) IcomSetXITOn(on bool) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetXITOn(on) }) +} + func (a *App) IcomSetPTT(on bool) error { if a.cat == nil { return fmt.Errorf("cat not initialized") diff --git a/frontend/src/components/IcomPanel.tsx b/frontend/src/components/IcomPanel.tsx index d1587a3..820af85 100644 --- a/frontend/src/components/IcomPanel.tsx +++ b/frontend/src/components/IcomPanel.tsx @@ -1,11 +1,12 @@ import { useEffect, useRef, useState } from 'react'; -import { Radio, AudioLines, RefreshCw, Mic, Zap, Activity } from 'lucide-react'; +import { Radio, AudioLines, RefreshCw, Mic, Activity, SlidersHorizontal } 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, GetCATState, SetCATFrequency, + IcomSetRIT, IcomSetRITOn, IcomSetXITOn, } from '../../wailsjs/go/main/App'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; @@ -19,6 +20,7 @@ type IcomState = { 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; }; const ZERO: IcomState = { @@ -27,6 +29,7 @@ const ZERO: IcomState = { 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, }; function Slider({ value, onChange, disabled, accent = '#2563eb', step = 1 }: { @@ -145,6 +148,58 @@ function Meter({ label, value, accent, scale, onClick, title }: { label: string; return
{body}
; } +// HdrMeter — a compact live meter for the model header band (S when receiving, +// Po/SWR when transmitting). Clickable variant sends the S reading to RST tx. +function HdrMeter({ 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 = ( + <> + {label} +
+
+
+ {scale} + + ); + if (onClick) return ; + return
{body}
; +} + +// 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(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 ( +
+ +
+ + + {hz > 0 ? '+' : hz < 0 ? '−' : ''}{Math.abs(hz)} Hz + + +
+ +
+ ); +} + // 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. @@ -157,15 +212,36 @@ function sParts(v: number): { s: number; over: number; label: string } { 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 on a canvas. The amplitude array is raw rig scale (~0-160); -// we normalise to the tallest recent peak so the trace fills the height. +// 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(null); + const wfRef = useRef(null); // waterfall const peakRef = useRef(160); // running amplitude ceiling for auto-scale + const holdRef = useRef([]); // 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 @@ -198,7 +274,7 @@ function ScopePanadapter() { 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, 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; @@ -236,7 +312,7 @@ function ScopePanadapter() { return () => el.removeEventListener('wheel', onWheel); }, [on]); - const draw = (amp: number[], lowHz: number, highHz: number, vfoHz: number) => { + const draw = (amp: number[], lowHz: number, highHz: number, vfoHz: number, fixedMode: boolean) => { const cv = canvasRef.current; if (!cv) return; const dpr = window.devicePixelRatio || 1; @@ -245,66 +321,133 @@ function ScopePanadapter() { const ctx = cv.getContext('2d'); if (!ctx) return; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - ctx.clearRect(0, 0, w, h); // 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,120,120,0.15)'; + 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(); } - // Spectrum trace, filled under the line. - const n = amp.length; + 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++) { - const x = (i / (n - 1)) * w; - const y = h - Math.min(1, amp[i] / scale) * h; - ctx.lineTo(x, y); - } - ctx.lineTo(w, h); - ctx.closePath(); + 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.55)'); - grad.addColorStop(1, 'rgba(56,189,248,0.05)'); - ctx.fillStyle = grad; - ctx.fill(); - ctx.strokeStyle = '#38bdf8'; - ctx.lineWidth = 1.25; - ctx.stroke(); + grad.addColorStop(0, 'rgba(56,189,248,0.40)'); + grad.addColorStop(1, 'rgba(56,189,248,0.02)'); + ctx.fillStyle = grad; ctx.fill(); - // VFO marker: a vertical line at the current frequency within the span. - if (vfoHz > 0 && lowHz > 0 && highHz > lowHz && vfoHz >= lowHz && vfoHz <= highHz) { - const x = ((vfoHz - lowHz) / (highHz - lowHz)) * w; - ctx.strokeStyle = 'rgba(239,68,68,0.95)'; - ctx.lineWidth = 1.5; + // 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: translucent band + crisp line + top marker triangle. In centre + // mode the span tracks the VFO, so fall back to the exact centre when the + // reported freq isn't inside the (just-updated) span — you always see where + // you are. + const inSpan = vfoHz > 0 && lowHz > 0 && highHz > lowHz && vfoHz >= lowHz && vfoHz <= highHz; + const markerX = inSpan ? ((vfoHz - lowHz) / (highHz - lowHz)) * w : (!fixedMode ? w / 2 : -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)'; + ctx.beginPath(); ctx.moveTo(x - 4, 0); ctx.lineTo(x + 4, 0); ctx.lineTo(x, 6); ctx.closePath(); ctx.fill(); } - // Frequency scale (edges + centre), only when the rig reported the span. - if (lowHz > 0 && highHz > lowHz) { - const mhz = (hz: number) => (hz / 1e6).toFixed(3); - ctx.fillStyle = 'rgba(226,232,240,0.75)'; - ctx.font = '10px ui-monospace, monospace'; - ctx.textBaseline = 'bottom'; - ctx.textAlign = 'left'; ctx.fillText(mhz(lowHz), 3, h - 2); - ctx.textAlign = 'center'; ctx.fillText(mhz((lowHz + highHz) / 2), w / 2, h - 2); - ctx.textAlign = 'right'; ctx.fillText(mhz(highHz), w - 3, h - 2); + // 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 ( - -
- - {on ? (fixed ? t('icmp.scopeFixed') : t('icmp.scopeCenter')) : t('icmp.scopeOff')} - -
+
+
+ + {t('icmp.spectrum')} +
{on && ( setMode(v === 'FIX')} /> @@ -312,11 +455,16 @@ function ScopePanadapter() {
-
- -
- + {on && ( +
+
+ + +
+
+ )} +
); } @@ -330,6 +478,7 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void const [busy, setBusy] = useState(false); const [tuning, setTuning] = useState(false); const txRef = useRef(false); + const stRef = useRef(ZERO); stRef.current = st; const load = () => GetIcomState().then((s) => setSt((s ?? ZERO) as IcomState)).catch(() => {}); const refresh = async () => { @@ -364,6 +513,27 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void 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 (
@@ -389,6 +559,16 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void {st.mode ? {st.mode} : null} {st.split ? Split : null}
+ {/* Live meter in the header band: S when receiving (click → RST tx), Po when transmitting. */} +
+ {tx ? ( + + ) : (() => { const sp = sParts(st.s_meter); return ( + onReportRST(sMeterRST(sp.s, sp.over, st.mode)) : undefined} /> + ); })()} +