feat(cluster): hold the list still while it is being read, and stop truncating filters
Two things reported together, both about a list that fights back. The GRID froze nothing: every spot landed at the top and pushed the rest down, so a few rows in, the callsign under the pointer had moved by the time the click arrived. It now stops redrawing as soon as it is scrolled away from the top and says how many spots are waiting; reaching the top again, or clicking the notice, releases it. The arrivals are counted by finding the frozen top row in the live list rather than by comparing lengths — the list is a ring buffer, so once it is full a length comparison would report nothing new for the rest of the evening. The COMMAND BUTTONS were capped at 120 characters. A DXSpider filter naming the prefixes an operator wants runs well past that, and the field just stopped accepting keystrokes: the command was saved truncated with nothing to say why. 500 now, with the full text in the tooltip since the box cannot show it.
This commit is contained in:
@@ -588,6 +588,44 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick, onSpotSelect }: Pro
|
||||
if (state) saveState(COL_STATE_KEY, state);
|
||||
}, []);
|
||||
|
||||
// HOLDING THE LIST STILL WHILE IT IS BEING READ.
|
||||
//
|
||||
// A busy evening puts a spot on the list every second or two, each landing at
|
||||
// the top and pushing everything below it down. At the top of the list that is
|
||||
// exactly right — it is what makes the panel live. A few rows down it makes
|
||||
// the panel unusable: the callsign under the pointer has moved by the time the
|
||||
// click lands, and the operator ends up chasing the row they wanted.
|
||||
//
|
||||
// So the grid freezes as soon as it is scrolled away from the top, and says
|
||||
// how many spots are waiting. Scrolling back to the top releases it, as does
|
||||
// clicking the notice. Nothing is lost — the spots keep arriving in the shared
|
||||
// list; this only decides when the GRID is allowed to redraw with them.
|
||||
const [held, setHeld] = useState<ClusterSpot[] | null>(null);
|
||||
const shown = held ?? rows;
|
||||
|
||||
// How many arrived since the freeze. Counted by finding the frozen top row in
|
||||
// the live list rather than by comparing lengths: the list is a ring buffer,
|
||||
// so once it is full the length stops growing and a length comparison would
|
||||
// report nothing new for the rest of the evening.
|
||||
const spotID = (r: ClusterSpot) => `${(r as any).received_at}-${r.dx_call}-${(r as any).source_id}`;
|
||||
const waiting = useMemo(() => {
|
||||
if (!held || held.length === 0) return 0;
|
||||
const top = spotID(held[0]);
|
||||
const i = rows.findIndex((r) => spotID(r) === top);
|
||||
return i < 0 ? rows.length : i; // fell out of the buffer: everything is new
|
||||
}, [held, rows]);
|
||||
|
||||
const onBodyScroll = (e: { top: number }) => {
|
||||
const down = e.top > 4; // a pixel or two of overscroll is not "scrolled down"
|
||||
if (!down && held) setHeld(null);
|
||||
if (down && !held) setHeld(rows);
|
||||
};
|
||||
|
||||
const release = () => {
|
||||
setHeld(null);
|
||||
gridRef.current?.api?.ensureIndexVisible(0, 'top');
|
||||
};
|
||||
|
||||
function handleRowClicked(e: RowClickedEvent<ClusterSpot>) {
|
||||
if (e.data && onSpotSelect) onSpotSelect(e.data);
|
||||
}
|
||||
@@ -646,7 +684,7 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick, onSpotSelect }: Pro
|
||||
<AgGridReact<ClusterSpot>
|
||||
ref={gridRef}
|
||||
theme={hamlogTheme}
|
||||
rowData={rows}
|
||||
rowData={shown}
|
||||
columnDefs={columnDefs}
|
||||
defaultColDef={defaultColDef}
|
||||
context={context}
|
||||
@@ -658,11 +696,20 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick, onSpotSelect }: Pro
|
||||
onSortChanged={saveColumnState}
|
||||
onRowClicked={handleRowClicked}
|
||||
onRowDoubleClicked={handleRowDoubleClicked}
|
||||
onBodyScroll={onBodyScroll}
|
||||
animateRows={false}
|
||||
suppressCellFocus
|
||||
getRowId={(p) => `${(p.data as any).received_at}-${(p.data as any).dx_call}-${(p.data as any).source_id}`}
|
||||
/>
|
||||
</div>
|
||||
{/* Only when something is actually waiting: a frozen list with nothing
|
||||
new to show needs no announcement. */}
|
||||
{waiting > 0 && (
|
||||
<button type="button" onClick={release}
|
||||
className="absolute left-1/2 -translate-x-1/2 top-1 z-10 rounded-full border border-primary bg-primary px-3 py-1 text-[11px] font-semibold text-primary-foreground shadow-lg hover:opacity-90">
|
||||
{t('clg2.newSpots', { n: waiting })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
|
||||
@@ -4918,11 +4918,18 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
maxLength={24}
|
||||
onChange={(e) => setMacro(i, { label: e.target.value })}
|
||||
/>
|
||||
{/* 500, not 120. A DXSpider filter is a list of prefixes and
|
||||
an operator's own list of wanted countries runs past a
|
||||
hundred characters easily — the field simply stopped
|
||||
accepting keystrokes, with nothing to say why, and the
|
||||
command was saved truncated. The title shows the whole
|
||||
thing, since the box cannot. */}
|
||||
<Input
|
||||
className="h-8 flex-1 min-w-0 font-mono text-xs"
|
||||
placeholder={t('clu.macroCmd')}
|
||||
value={m.cmd}
|
||||
maxLength={120}
|
||||
title={m.cmd}
|
||||
maxLength={500}
|
||||
onChange={(e) => setMacro(i, { cmd: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user