fix(flex): link TX/RX collapse, equal-size meters, phone-only MIC/COMP, inline S-meter dBm

FlexRadio panel refinements from feedback:
- TRANSMIT and RECEIVE now share one collapse state — folding either folds both
  (Card gained an optional controlled open/onToggle mode; the parent owns the
  shared txrx state, persisted).
- Meter sizes: the Tuner Genius PWR/SWR meters used a 2-column grid (wider than
  the Flex/amp meters). Switched to the same grid-cols-2 sm:grid-cols-3 so every
  meter across the METERS, AMPLIFIER and TUNER cards is the same width.
- MIC and COMP meters are hidden outside phone modes (shown for SSB/AM/FM only).
- S-meter dBm moved inline next to the S-value (was a second line under the bar),
  and the redundant dBm line under PWR removed — keeps only the watts. Saves height.
This commit is contained in:
2026-07-25 11:31:26 +02:00
parent 3a9dda13c4
commit f4bc55cd41
3 changed files with 33 additions and 17 deletions
+29 -13
View File
@@ -262,12 +262,20 @@ function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, displ
);
}
function Card({ icon: Icon, title, accent, children, ckey }: { icon: any; title: string; accent?: string; children: React.ReactNode; ckey?: string }) {
// Collapsible: a chevron in the header hides the body. State persists per card
// (keyed by ckey, falling back to the title) so a folded card stays folded.
function Card({ icon: Icon, title, accent, children, ckey, open: openProp, onToggle }: {
icon: any; title: string; accent?: string; children: React.ReactNode; ckey?: string;
open?: boolean; onToggle?: () => void; // controlled mode — lets sibling cards share one collapse state
}) {
// Collapsible: a chevron in the header hides the body. Uncontrolled by default,
// persisting per card (keyed by ckey, falling back to the title); when `open`/
// `onToggle` are supplied the parent owns the state (e.g. linked TX/RX cards).
const storeKey = 'opslog.cardOpen.' + (ckey || title);
const [open, setOpen] = useState(() => localStorage.getItem(storeKey) !== '0');
const toggle = () => setOpen((o) => { const n = !o; localStorage.setItem(storeKey, n ? '1' : '0'); return n; });
const [openState, setOpenState] = useState(() => localStorage.getItem(storeKey) !== '0');
const controlled = openProp !== undefined;
const open = controlled ? openProp : openState;
const toggle = controlled
? (onToggle ?? (() => {}))
: () => setOpenState((o) => { const n = !o; localStorage.setItem(storeKey, n ? '1' : '0'); return n; });
return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
<button type="button" onClick={toggle}
@@ -364,6 +372,11 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
return () => { alive = false; window.clearInterval(id); };
}, []);
// TRANSMIT + RECEIVE share ONE collapse state (they sit side by side, so folding
// one folds the other and keeps the row tidy). Persisted like the other cards.
const [txrxOpen, setTxrxOpen] = useState(() => localStorage.getItem('opslog.cardOpen.txrx') !== '0');
const toggleTxrx = () => setTxrxOpen((o) => { const n = !o; localStorage.setItem('opslog.cardOpen.txrx', n ? '1' : '0'); return n; });
// Tuner Genius XL direct connection — its own card in the Flex panel (like PGXL).
const [tg, setTg] = useState<TGStatus>({ connected: false });
const [tgEnabled, setTgEnabled] = useState(false);
@@ -446,6 +459,9 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
return () => off();
}, [st.rit, st.rit_freq]);
const isCW = (st.mode || '').toUpperCase().includes('CW');
// Phone (voice) modes — MIC / COMP meters only make sense here, so they're
// hidden in CW and digital.
const isPhone = /\b(SSB|USB|LSB|AM|FM|DFM|NFM)\b/i.test(st.mode || '');
const PROC = [{ v: '0', l: 'NOR' }, { v: '1', l: 'DX' }, { v: '2', l: 'DX+' }];
const AGC = [{ v: 'off', l: 'OFF' }, { v: 'slow', l: 'SLOW' }, { v: 'med', l: 'MED' }, { v: 'fast', l: 'FAST' }];
const CW_BW = [100, 200, 300, 400, 500];
@@ -574,22 +590,22 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
};
const cur = [
sig && (() => { const dbm = peakHold('s', sig.value); const s = sUnit(dbm); return (
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={s.display} extra={`${dbm.toFixed(1)} dBm`}
// dBm sits inline next to the S-value (no separate line below) to save height.
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={`${s.display} ${dbm.toFixed(0)} dBm`}
title={onReportRST ? t('rst.clickToFill') : undefined}
onClick={onReportRST ? () => onReportRST(sMeterRST(s.s, s.over, st.mode)) : undefined}
segColor={(fr) => { const sval = fr * 19; return sval < 9 ? '#16a34a' : sval < 12.33 ? '#f59e0b' : '#dc2626'; }} />
); })(),
fwd && (() => { const w = peakHold('p', isDbm(fwd) ? dbmToW(fwd.value) : fwd.value); return (
<MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626"
value={w} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} />
<MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626" value={w} />
); })(),
swr && <MeterBar key="w" label="SWR" value={peakHold('w', swr.value)} unit="" lo={1} hi={3} accent="#d97706" />,
// Mic input level in dBFS — SmartSDR's scale is -40…0 dB.
mic && <MeterBar key="mic" label="MIC" value={peakHold('mic', mic.value)} unit={mic.unit || 'dB'} lo={-40} hi={0} accent="#16a34a"
// Mic input level in dBFS — SmartSDR's scale is -40…0 dB. Phone modes only.
isPhone && mic && <MeterBar key="mic" label="MIC" value={peakHold('mic', mic.value)} unit={mic.unit || 'dB'} lo={-40} hi={0} accent="#16a34a"
segColor={(fr) => (fr >= 0.8 ? '#dc2626' : fr >= 0.7 ? '#f59e0b' : '#16a34a')} />,
// Speech compression — original working meter, only the top of the
// scale changed from the radio-reported 20 to 25 (SmartSDR's -25 max).
comp && <MeterBar key="comp" label="COMP" value={peakHold('comp', comp.value)} unit={comp.unit || 'dB'} lo={0} hi={25} accent="#0891b2" />,
isPhone && comp && <MeterBar key="comp" label="COMP" value={peakHold('comp', comp.value)} unit={comp.unit || 'dB'} lo={0} hi={25} accent="#0891b2" />,
].filter(Boolean);
return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">{cur}</div>
@@ -606,7 +622,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
{/* TX + RX columns */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{/* TRANSMIT */}
<Card icon={Zap} title={t('flxp.transmit')} accent="#dc2626">
<Card icon={Zap} title={t('flxp.transmit')} accent="#dc2626" open={txrxOpen} onToggle={toggleTxrx}>
<div className="flex items-center gap-3">
<span className="w-20 shrink-0 text-xs font-medium text-muted-foreground">{t('flxp.rfPower')}</span>
<Slider value={st.rf_power} disabled={off} accent="#dc2626" onChange={(v) => change('rf_power', v, () => FlexSetPower(v))} />
@@ -745,7 +761,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
</Card>
{/* RECEIVE */}
<Card icon={AudioLines} title={t('flxp.receiveActive')} accent="#0891b2">
<Card icon={AudioLines} title={t('flxp.receiveActive')} accent="#0891b2" open={txrxOpen} onToggle={toggleTxrx}>
{/* Antenna selection sits at the very top of the RX column. */}
{((st.ant_list?.length ?? 0) > 0 || (st.tx_ant_list?.length ?? 0) > 0) && (
<div className="flex items-center gap-2 pb-3 border-b border-border/60">
+2 -2
View File
@@ -149,8 +149,8 @@ export function TunerCard({ status, t }: { status: TGStatus; t: (k: string, v?:
onSelect={() => TunerGeniusActivate(2).catch(() => {})} t={t} />
</div>
{/* PWR + SWR meters. */}
<div className="grid grid-cols-2 gap-2">
{/* PWR + SWR meters — same grid as the Flex/amp meters so they match size. */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
<MeterBar label={t('tgp.power')} value={fwdW} unit="W" lo={0} hi={2000}
display={fwdW >= 1 ? `${Math.round(fwdW)} W` : '—'}
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />