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
+2 -2
View File
@@ -4,7 +4,7 @@
"date": "2026-07-24",
"en": [
"New: 4O3A Tuner Genius XL control. Enable it in Settings → Tuner Genius (IP only; port is fixed at 9010). Live SWR and forward power with Tune, Bypass and Operate/Standby, the two channels A/B (source, frequency and antenna) shown and click-selectable. Available as a docked widget, a card in the FlexRadio panel (like the PowerGenius) and a card in Station Control. Controlled directly over TCP, so it uses just one of the box's connection slots.",
"The FlexRadio, amplifier and tuner cards can now be collapsed with the chevron in their header, and the amplifier meters are the same size as the others for a tidier layout.",
"FlexRadio panel tidy-up: cards collapse from the chevron in their header (Transmit and Receive fold together), all meters are the same size, the MIC and COMP meters only show in phone modes, and the S-meter dBm now sits next to the S-value instead of on a second line.",
"Fixed the colour theme sometimes reverting to the default when reopening OpsLog — it's now restored reliably.",
"ADIF export field picker: the per-group All / None buttons work again.",
"Station Control: cards now pack tightly into balanced columns instead of leaving big gaps under shorter panels — a cleaner, more even dashboard.",
@@ -14,7 +14,7 @@
],
"fr": [
"Nouveau : contrôle du 4O3A Tuner Genius XL. Active-le dans Réglages → Tuner Genius (IP seulement ; port fixé à 9010). ROS et puissance directe en direct avec Accord, Bypass et Operate/Standby, et les deux canaux A/B (source, fréquence et antenne) affichés et sélectionnables d'un clic. Disponible en widget ancré, en carte dans le panneau FlexRadio (comme le PowerGenius) et en carte dans Station Control. Piloté directement en TCP, il n'utilise qu'une des connexions de la boîte.",
"Les cartes FlexRadio, amplificateur et tuner peuvent maintenant être repliées via le chevron de leur en-tête, et les meters de l'amplificateur ont la même taille que les autres pour une mise en page plus nette.",
"Nettoyage du panneau FlexRadio : les cartes se replient via le chevron de leur en-tête (Transmit et Receive se replient ensemble), tous les meters ont la même taille, les meters MIC et COMP ne s'affichent qu'en phonie, et le dBm du S-mètre est maintenant à côté de la valeur S plutôt que sur une deuxième ligne.",
"Correction du thème qui revenait parfois au défaut à la réouverture d'OpsLog — il est maintenant restauré de façon fiable.",
"Sélecteur de champs à l'export ADIF : les boutons Tout / Aucun par groupe refonctionnent.",
"Station Control : les cartes se rangent en colonnes équilibrées et se tassent au lieu de laisser de gros trous sous les panneaux plus courts — tableau de bord plus propre et régulier.",
+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')} />