refactor(flex): single shared MeterBar so Flex/amp/tuner meters are identical
The FlexRadio panel, AmpCard and TunerCard each carried their own copy of the MeterBar (LED-bar) component. Even small drift between the copies made the tuner meters look slightly off in height/LED size vs the others. Extracted one shared components/MeterBar.tsx (segments, bar height, padding) and imported it in all three, so every meter renders at exactly the same size. No behaviour change.
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { Flame, ChevronDown } from 'lucide-react';
|
import { Flame, ChevronDown } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { MeterBar } from '@/components/MeterBar';
|
||||||
import { AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, FlexAmpOperate } from '../../wailsjs/go/main/App';
|
import { AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, FlexAmpOperate } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
// AmpCard renders the amplifier card exactly like the one in the FlexRadio panel,
|
// AmpCard renders the amplifier card exactly like the one in the FlexRadio panel,
|
||||||
@@ -12,43 +13,6 @@ import { AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, FlexAmpOperate } from
|
|||||||
// Controls use the multi-amp API (AmpOperate/AmpPower/… by amp id) so several amps
|
// Controls use the multi-amp API (AmpOperate/AmpPower/… by amp id) so several amps
|
||||||
// can each get their own card.
|
// can each get their own card.
|
||||||
|
|
||||||
const METER_SEGMENTS = 26;
|
|
||||||
|
|
||||||
function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', display, segColor, compact }: {
|
|
||||||
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; display?: string;
|
|
||||||
segColor?: (frac: number) => string; compact?: boolean;
|
|
||||||
}) {
|
|
||||||
const span = hi - lo;
|
|
||||||
const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0;
|
|
||||||
const lit = Math.round((pct / 100) * METER_SEGMENTS);
|
|
||||||
return (
|
|
||||||
<div className={cn('rounded-lg border border-border/70 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0',
|
|
||||||
compact ? 'px-2 py-1' : 'px-2.5 py-2')}>
|
|
||||||
<div className={cn('flex items-baseline justify-between gap-1', compact ? 'mb-1' : 'mb-1.5')}>
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground truncate">{label}</span>
|
|
||||||
<span className={cn('font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90', compact ? 'text-xs' : 'text-sm')}>
|
|
||||||
{display !== undefined ? display : (
|
|
||||||
<>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}<span className="text-muted-foreground text-[10px] ml-0.5">{unit}</span></>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className={cn('flex gap-[2px] items-stretch rounded-[3px] bg-black/10 p-[2px]', compact ? 'h-2' : 'h-3')}>
|
|
||||||
{Array.from({ length: METER_SEGMENTS }).map((_, i) => {
|
|
||||||
const on = i < lit;
|
|
||||||
const frac = i / METER_SEGMENTS;
|
|
||||||
const col = segColor ? segColor(frac) : (frac > 0.82 ? '#dc2626' : accent);
|
|
||||||
return (
|
|
||||||
<div key={i} className="flex-1 rounded-[2px] transition-colors duration-100"
|
|
||||||
style={on
|
|
||||||
? { background: `linear-gradient(to bottom, ${col}, ${col}cc)`, boxShadow: `0 0 4px ${col}88` }
|
|
||||||
: { background: '#cfc6ad', opacity: 0.35 }} />
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Card({ icon: Icon, title, accent, children, ckey }: { icon: any; title: string; accent?: string; children: React.ReactNode; ckey?: string }) {
|
function Card({ icon: Icon, title, accent, children, ckey }: { icon: any; title: string; accent?: string; children: React.ReactNode; ckey?: string }) {
|
||||||
// Collapsible with persisted state — same behaviour as the FlexRadio panel's Card.
|
// Collapsible with persisted state — same behaviour as the FlexRadio panel's Card.
|
||||||
const storeKey = 'opslog.cardOpen.' + (ckey || title);
|
const storeKey = 'opslog.cardOpen.' + (ckey || title);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { cn } from '@/lib/utils';
|
|||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { sMeterRST } from '@/lib/rst';
|
import { sMeterRST } from '@/lib/rst';
|
||||||
import { TunerCard } from '@/components/TunerCard';
|
import { TunerCard } from '@/components/TunerCard';
|
||||||
|
import { MeterBar } from '@/components/MeterBar';
|
||||||
import type { TGStatus } from '@/components/TunerGeniusPanel';
|
import type { TGStatus } from '@/components/TunerGeniusPanel';
|
||||||
|
|
||||||
type FlexState = {
|
type FlexState = {
|
||||||
@@ -222,46 +223,6 @@ function OffsetRow({ label, on, onToggle, hz, onHz, disabled, title }: {
|
|||||||
// MeterBar — a segmented "LED" instrument bar (radio look) scaled by lo/hi.
|
// MeterBar — a segmented "LED" instrument bar (radio look) scaled by lo/hi.
|
||||||
// `display` overrides the numeric readout; `segColor` colours segments by their
|
// `display` overrides the numeric readout; `segColor` colours segments by their
|
||||||
// 0..1 position (zones); the top ~18% light red by default (overload/peak).
|
// 0..1 position (zones); the top ~18% light red by default (overload/peak).
|
||||||
const METER_SEGMENTS = 26;
|
|
||||||
function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, display, segColor, onClick, title, compact }: {
|
|
||||||
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; extra?: string; display?: string;
|
|
||||||
segColor?: (frac: number) => string; onClick?: () => void; title?: string; compact?: boolean;
|
|
||||||
}) {
|
|
||||||
const span = hi - lo;
|
|
||||||
const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0;
|
|
||||||
const lit = Math.round((pct / 100) * METER_SEGMENTS);
|
|
||||||
return (
|
|
||||||
<div onClick={onClick} title={title}
|
|
||||||
className={cn('rounded-lg border border-border/70 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0',
|
|
||||||
compact ? 'px-2 py-1' : 'px-2.5 py-2',
|
|
||||||
onClick && 'cursor-pointer hover:border-primary/60 hover:from-muted/40')}>
|
|
||||||
<div className={cn('flex items-baseline justify-between gap-1', compact ? 'mb-1' : 'mb-1.5')}>
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground truncate">{label}</span>
|
|
||||||
<span className={cn('font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90', compact ? 'text-xs' : 'text-sm')}>
|
|
||||||
{display !== undefined ? display : (
|
|
||||||
<>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}<span className="text-muted-foreground text-[10px] ml-0.5">{unit}</span></>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/* LED bar — recessed track + gradient segments for a cleaner instrument look. */}
|
|
||||||
<div className={cn('flex gap-[2px] items-stretch rounded-[3px] bg-black/10 p-[2px]', compact ? 'h-2' : 'h-3')}>
|
|
||||||
{Array.from({ length: METER_SEGMENTS }).map((_, i) => {
|
|
||||||
const on = i < lit;
|
|
||||||
const frac = i / METER_SEGMENTS;
|
|
||||||
const col = segColor ? segColor(frac) : (frac > 0.82 ? '#dc2626' : accent);
|
|
||||||
return (
|
|
||||||
<div key={i} className="flex-1 rounded-[2px] transition-colors duration-100"
|
|
||||||
style={on
|
|
||||||
? { background: `linear-gradient(to bottom, ${col}, ${col}cc)`, boxShadow: `0 0 4px ${col}88` }
|
|
||||||
: { background: '#cfc6ad', opacity: 0.35 }} />
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{extra && !compact && <div className="text-[10px] text-muted-foreground/70 mt-1 text-right font-mono">{extra}</div>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Card({ icon: Icon, title, accent, children, ckey, open: openProp, onToggle }: {
|
function Card({ icon: Icon, title, accent, children, ckey, open: openProp, onToggle }: {
|
||||||
icon: any; title: string; accent?: string; children: React.ReactNode; ckey?: string;
|
icon: any; title: string; accent?: string; children: React.ReactNode; ckey?: string;
|
||||||
open?: boolean; onToggle?: () => void; // controlled mode — lets sibling cards share one collapse state
|
open?: boolean; onToggle?: () => void; // controlled mode — lets sibling cards share one collapse state
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// MeterBar is the ONE LED-bar meter used across the FlexRadio panel, the amplifier
|
||||||
|
// cards and the Tuner Genius card, so every meter renders at exactly the same size
|
||||||
|
// (segment count, bar height, padding). Keep it the single source of truth — don't
|
||||||
|
// re-declare a local copy in a panel, or the meters drift out of sync.
|
||||||
|
export const METER_SEGMENTS = 26;
|
||||||
|
|
||||||
|
export function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, display, segColor, onClick, title, compact }: {
|
||||||
|
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; extra?: string; display?: string;
|
||||||
|
segColor?: (frac: number) => string; onClick?: () => void; title?: string; compact?: boolean;
|
||||||
|
}) {
|
||||||
|
const span = hi - lo;
|
||||||
|
const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0;
|
||||||
|
const lit = Math.round((pct / 100) * METER_SEGMENTS);
|
||||||
|
return (
|
||||||
|
<div onClick={onClick} title={title}
|
||||||
|
className={cn('rounded-lg border border-border/70 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0',
|
||||||
|
compact ? 'px-2 py-1' : 'px-2.5 py-2',
|
||||||
|
onClick && 'cursor-pointer hover:border-primary/60 hover:from-muted/40')}>
|
||||||
|
<div className={cn('flex items-baseline justify-between gap-1', compact ? 'mb-1' : 'mb-1.5')}>
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground truncate">{label}</span>
|
||||||
|
<span className={cn('font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90', compact ? 'text-xs' : 'text-sm')}>
|
||||||
|
{display !== undefined ? display : (
|
||||||
|
<>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}<span className="text-muted-foreground text-[10px] ml-0.5">{unit}</span></>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* LED bar — recessed track + gradient segments for a cleaner instrument look. */}
|
||||||
|
<div className={cn('flex gap-[2px] items-stretch rounded-[3px] bg-black/10 p-[2px]', compact ? 'h-2' : 'h-3')}>
|
||||||
|
{Array.from({ length: METER_SEGMENTS }).map((_, i) => {
|
||||||
|
const on = i < lit;
|
||||||
|
const frac = i / METER_SEGMENTS;
|
||||||
|
const col = segColor ? segColor(frac) : (frac > 0.82 ? '#dc2626' : accent);
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex-1 rounded-[2px] transition-colors duration-100"
|
||||||
|
style={on
|
||||||
|
? { background: `linear-gradient(to bottom, ${col}, ${col}cc)`, boxShadow: `0 0 4px ${col}88` }
|
||||||
|
: { background: '#cfc6ad', opacity: 0.35 }} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{extra && !compact && <div className="text-[10px] text-muted-foreground/70 mt-1 text-right font-mono">{extra}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Gauge, Radio, ChevronDown } from 'lucide-react';
|
import { Gauge, Radio, ChevronDown } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { MeterBar } from '@/components/MeterBar';
|
||||||
import type { TGStatus, TGChannel } from '@/components/TunerGeniusPanel';
|
import type { TGStatus, TGChannel } from '@/components/TunerGeniusPanel';
|
||||||
import { TunerGeniusAutotune, TunerGeniusSetBypass, TunerGeniusSetOperate, TunerGeniusActivate } from '../../wailsjs/go/main/App';
|
import { TunerGeniusAutotune, TunerGeniusSetBypass, TunerGeniusSetOperate, TunerGeniusActivate } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
@@ -9,43 +10,8 @@ import { TunerGeniusAutotune, TunerGeniusSetBypass, TunerGeniusSetOperate, Tuner
|
|||||||
// mirrors the native app's two channels (A / B) with their source, frequency and
|
// mirrors the native app's two channels (A / B) with their source, frequency and
|
||||||
// antenna, a live PWR / SWR pair, and the Tune / Bypass / Operate actions. It
|
// antenna, a live PWR / SWR pair, and the Tune / Bypass / Operate actions. It
|
||||||
// drives the backend directly (no local state) — the caller's ~1.5s poll
|
// drives the backend directly (no local state) — the caller's ~1.5s poll
|
||||||
// reconciles the display, just like AmpCard.
|
// reconciles the display, just like AmpCard. Meters come from the shared MeterBar
|
||||||
|
// so they're the exact same size as the Flex/amp meters.
|
||||||
const METER_SEGMENTS = 26;
|
|
||||||
|
|
||||||
function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', display, segColor }: {
|
|
||||||
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; display?: string;
|
|
||||||
segColor?: (frac: number) => string;
|
|
||||||
}) {
|
|
||||||
const span = hi - lo;
|
|
||||||
const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0;
|
|
||||||
const lit = Math.round((pct / 100) * METER_SEGMENTS);
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-border/70 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0 px-2.5 py-2">
|
|
||||||
<div className="flex items-baseline justify-between gap-1 mb-1.5">
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground truncate">{label}</span>
|
|
||||||
<span className="font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90 text-sm">
|
|
||||||
{display !== undefined ? display : (
|
|
||||||
<>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}<span className="text-muted-foreground text-[10px] ml-0.5">{unit}</span></>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-[2px] items-stretch rounded-[3px] bg-black/10 p-[2px] h-3">
|
|
||||||
{Array.from({ length: METER_SEGMENTS }).map((_, i) => {
|
|
||||||
const on = i < lit;
|
|
||||||
const frac = i / METER_SEGMENTS;
|
|
||||||
const col = segColor ? segColor(frac) : (frac > 0.82 ? '#dc2626' : accent);
|
|
||||||
return (
|
|
||||||
<div key={i} className="flex-1 rounded-[2px] transition-colors duration-100"
|
|
||||||
style={on
|
|
||||||
? { background: `linear-gradient(to bottom, ${col}, ${col}cc)`, boxShadow: `0 0 4px ${col}88` }
|
|
||||||
: { background: '#cfc6ad', opacity: 0.35 }} />
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Card({ icon: Icon, title, accent, children, ckey }: { icon: any; title: string; accent?: string; children: React.ReactNode; ckey?: string }) {
|
function Card({ icon: Icon, title, accent, children, ckey }: { icon: any; title: string; accent?: string; children: React.ReactNode; ckey?: string }) {
|
||||||
// Collapsible with persisted state — same behaviour as the FlexRadio panel's Card.
|
// Collapsible with persisted state — same behaviour as the FlexRadio panel's Card.
|
||||||
|
|||||||
Reference in New Issue
Block a user