feat(dvk): twelve slots; perf: Settings pauses the heavy streams

The voice keyer grows to F1-F12 — the UI was already data-driven, so the
count constant, the F-key range and the labels are the whole change.

And the preferences stop stuttering on a busy station: every spot batch,
decode batch and CAT snapshot re-rendered the entire App tree behind the
dialog — cluster grid, decodes panel, thousands of nodes — and the pointer
janked over the very panel the operator was trying to use. While Settings is
open the flushes park their batches in the pending refs (bounded) and CAT
repaints at most every two seconds; closing the dialog drains everything.
This commit is contained in:
2026-08-30 20:09:44 +02:00
parent 1bd3896ca7
commit 0c0e8b06ba
5 changed files with 48 additions and 9 deletions
+36 -1
View File
@@ -2228,6 +2228,15 @@ export default function App() {
const [bulkEditIds, setBulkEditIds] = useState<number[]>([]);
const [bulkEditOpen, setBulkEditOpen] = useState(false);
const [showSettings, setShowSettings] = useState(false);
// While the Settings dialog is open, the spot/decode flushes and the CAT
// snapshot stream are PAUSED (data keeps accumulating in the pending refs).
// Every flush re-renders the whole App tree behind the dialog — cluster
// grid, decodes panel, thousands of nodes — and with a busy cluster plus
// two decoders the pointer visibly stuttered over the preferences.
const showSettingsRef = useRef(false);
useEffect(() => { showSettingsRef.current = showSettings; }, [showSettings]);
const flushSpotsRef = useRef<() => void>(() => {});
const flushDecodesRef = useRef<() => void>(() => {});
// Re-read the "beam on map" toggle when Preferences closes (it's edited there).
useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]);
useEffect(() => { if (!showSettings) setRotorCompact(localStorage.getItem('opslog.rotorCompact') === '1'); }, [showSettings]);
@@ -3435,7 +3444,15 @@ export default function App() {
// Apply a CAT snapshot to the entry strip (freq/band/mode), unless the user
// just typed something (freeze window) or locked a field. Shared by the live
// cat:state event and the startup poll below.
const lastCatWhileSettingsRef = useRef(0);
function applyCatState(s: CATState) {
// Behind the Settings dialog nobody reads a frequency four times a second;
// each snapshot re-renders the whole App tree under the pointer.
if (showSettingsRef.current) {
const now = Date.now();
if (now - lastCatWhileSettingsRef.current < 2000) return;
lastCatWhileSettingsRef.current = now;
}
setCatState(s);
if (!s?.connected) return;
// A snapshot arriving during the freeze used to be DROPPED, and that lost the
@@ -3580,11 +3597,19 @@ export default function App() {
// Commit the staged spots: resolve the status for any slot we don't know yet
// FIRST, then insert the rows — so they appear with the right badge already
// painted instead of flashing plain text then flipping to a pill.
// eslint-disable-next-line prefer-const
const flushPendingSpots = async () => {
pendingSpotTimer.current = undefined;
// Settings open: leave everything queued (bounded) and repaint nothing.
if (showSettingsRef.current) {
const cap = spotsCapRef.current;
if (pendingSpotsRef.current.length > cap) pendingSpotsRef.current = pendingSpotsRef.current.slice(-cap);
return;
}
const batch = pendingSpotsRef.current;
pendingSpotsRef.current = [];
if (batch.length === 0) return;
// (registered below so closing Settings can drain the queue)
// Resolve unknown statuses before the rows go in.
try {
const known = spotStatusRef.current;
@@ -3640,6 +3665,7 @@ export default function App() {
return next.length > cap ? next.slice(0, cap) : next;
});
};
flushSpotsRef.current = () => { void flushPendingSpots(); };
const unsubSpot = EventsOn('cluster:spot', (sp: ClusterSpot) => {
// Stage the spot; a short timer resolves its status then commits it.
pendingSpotsRef.current.push(sp);
@@ -3687,6 +3713,10 @@ export default function App() {
// decodes panel and plain worked in the cluster list two seconds later.
const flushDecodes = async () => {
pendingDecodeTimer.current = undefined;
if (showSettingsRef.current) {
if (pendingDecodesRef.current.length > 3000) pendingDecodesRef.current = pendingDecodesRef.current.slice(-3000);
return;
}
const batch = pendingDecodesRef.current;
pendingDecodesRef.current = [];
if (batch.length === 0) return;
@@ -3729,6 +3759,7 @@ export default function App() {
return next;
});
};
flushDecodesRef.current = () => { void flushDecodes(); };
const unsubDecode = EventsOn('udp:decode', (d: DecodeRow) => {
pendingDecodesRef.current.push(d);
@@ -4375,6 +4406,10 @@ export default function App() {
// The stable wrapper the dialog actually receives.
const openQSOFromSettings = useCallback((id: number) => openEditRef.current(id), []);
const closeSettings = useCallback(() => {
// Synchronously: the ref effect runs after the next render, and flushing
// through a still-true ref would hit the pause gate again.
showSettingsRef.current = false;
window.setTimeout(() => { flushSpotsRef.current(); flushDecodesRef.current(); }, 50);
setShowSettings(false);
setSettingsSection(undefined);
refreshChaseNew();
@@ -5203,7 +5238,7 @@ export default function App() {
if (dvkActiveRef.current) {
// Voice keyer: plain F1..F6 transmit the message; Ctrl+F1..F5 → tabs.
if (mod && n <= 5) { e.preventDefault(); setDetailTab(TABS[n - 1]); return; }
if (plain && n <= 6) { e.preventDefault(); dvkPlayRef.current(n); return; }
if (plain && n <= 12) { e.preventDefault(); dvkPlayRef.current(n); return; }
return;
}
// No keyer: plain F1..F5 switch the detail tab (labels read "F1…").