feat(matrix): the DIG row cycles through your own digital modes

One row per digital mode would be the honest layout, and there is no
height for it: the matrix sits in a fixed panel beside a dozen widgets.
So the row keeps its place and changes what it answers — DIG, then each
digital mode the operator's own list holds, in the order they put them
in, then back to DIG.

It costs no round trip. The query behind the matrix already grouped by
band AND mode; only the collapse to a class threw that away, so the same
cell is now published under the raw mode name too. Digital only: PH and
CW have nothing to cycle through.

On a specific mode the you-are-here mark follows THAT mode, or every FT4
entry would light whichever digital row the rotation happened to rest
on. A four-letter mode drops to 9px rather than widen a column sized for
three characters and push the whole matrix sideways. Opens 0.27.8.
This commit is contained in:
2026-09-01 23:47:03 +02:00
parent 6442325926
commit 74dfc3a725
6 changed files with 82 additions and 17 deletions
+42 -5
View File
@@ -15,7 +15,10 @@ interface Props {
busy: boolean;
currentBand: string;
currentMode: string;
bands?: string[]; // operator's configured bands; falls back to DEFAULT_BANDS
bands?: string[];
// The operator's configured mode list, in THEIR order: the digital row
// rotates through it.
modes?: string[]; // operator's configured bands; falls back to DEFAULT_BANDS
hasCall?: boolean; // a callsign is being entered — only then highlight the "current entry" cell
// DX station coordinates, for its sunrise/sunset. Optional: many spots resolve
// to an entity with no position at all, and the block simply does not appear.
@@ -121,10 +124,31 @@ function cellTitle(t: (k: string) => string, band: string, cls: string, status:
return `${band} ${cls}: ${desc}${mine ? ' — ' + mine : ''}${current ? ' — ' + t('mx.current') : ''}`;
}
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon, forCall, onEditQso }: Props) {
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, modes, hasCall = true, lat, lon, forCall, onEditQso }: Props) {
const { t } = useI18n();
// Cell drill-down: which band+class the operator clicked, or null.
const [slot, setSlot] = useState<{ band: string; cls: string } | null>(null);
// The DIGITAL row is a rotation, not a fixed row.
//
// One row for every digital mode would be the honest layout and there is no
// height for it — the matrix sits in a fixed panel beside a dozen widgets.
// So the row keeps its place and changes what it answers: DIG (all of them),
// then each digital mode the operator actually uses, in the order their mode
// list gives, then back to DIG. The backend publishes the same cells under
// both the class name and the raw mode, so a rotation costs no round trip.
const digModes = useMemo(
() => (modes ?? [])
.map((m) => (m || '').toUpperCase().trim())
.filter((m) => m !== '' && m !== 'CW' && !PHONE_MODES.has(m)),
[modes],
);
const [digIdx, setDigIdx] = useState(0); // 0 = the DIG group itself
// A shorter mode list (the operator edited it) must not strand the rotation
// on a row that no longer exists.
const digPos = digModes.length ? digIdx % (digModes.length + 1) : 0;
const digRow = digPos === 0 ? 'DIG' : digModes[digPos - 1];
const cycleDig = () => setDigIdx((i) => (digModes.length ? (i + 1) % (digModes.length + 1) : 0));
// Columns from the operator's configured bands (so the matrix shows only the
// bands they actually use), falling back to the built-in default set.
const cols = useMemo(
@@ -325,13 +349,26 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
</tr>
</thead>
<tbody>
{CLASSES.map((cls) => {
const classCurrent = classMatchesMode(cls, currentMode);
{CLASSES.map((clsBase) => {
const cls = clsBase === 'DIG' ? digRow : clsBase;
// On a specific digital mode the "you are here" mark has to be that
// mode, not any digital one — otherwise every FT4 entry lights the
// FT8 row it happens to be cycled to.
const classCurrent = cls === clsBase
? classMatchesMode(cls, currentMode)
: (currentMode || '').toUpperCase() === cls;
return (
<tr key={cls}>
<th
onClick={clsBase === 'DIG' && digModes.length ? cycleDig : undefined}
title={clsBase === 'DIG' && digModes.length ? t('bsg.digCycle') : undefined}
className={cn(
'font-mono text-[11px] font-semibold pr-1.5 text-right w-[26px]',
'font-mono font-semibold pr-1.5 text-right w-[26px]',
// RTTY and PSK31 do not fit at 11px in a column sized for
// three characters, and widening it would push the whole
// matrix sideways.
cls.length > 3 ? 'text-[9px]' : 'text-[11px]',
clsBase === 'DIG' && digModes.length ? 'cursor-pointer hover:text-foreground' : '',
classCurrent ? 'text-primary font-extrabold' : 'text-muted-foreground',
)}
>