feat(bandmap): drag the width, and remember it

Both band maps were pinned to a hardcoded width — 300px docked beside the
tables, 260px per card in the Band map tab. On a busy band the map could not be
given more room, and on a quiet one the log could not take it back.

The docked map becomes a resizable grid column with the grip in the gap between
the panes, so the handle costs no space; the tab cards share one width with the
grip on their right edge. Side-by-side columns of different widths read as a
mistake rather than a choice, which is why the tab has one width and not one
per card. Double-click either grip to return to the default.

The drag measures from the pointer's START position rather than the container,
so the same helper serves both edges — the docked map sits on the left or the
right depending on the operator's setting, and the grip is on its inner edge
either way. Pointer capture, like the main splitter: without it the map or the
grid under the cursor swallows the moves.

Both widths are persisted through writeUiPref and registered as portable, so
they travel with the data folder like the main splitter and the rest of the
layout.
This commit is contained in:
2026-08-09 13:12:43 +02:00
parent 2bf98a9801
commit a8fac52400
4 changed files with 99 additions and 7 deletions
+85 -5
View File
@@ -907,6 +907,49 @@ export default function App() {
return Number.isFinite(n) && n >= 15 && n <= 85 ? n : 50;
});
useEffect(() => { writeUiPref('opslog.mainSplit', String(Math.round(mainSplit))); }, [mainSplit]);
// Band-map widths. Two of them, because they are two different things: the
// docked map sits beside the tables and competes with them for room, while
// the cards in the Band map tab share a scrolling row and want to be uniform.
// Both were hardcoded — 300px and 260px — so an operator watching a busy band
// could not give the map the space it needed, nor claw it back for the log.
const BANDMAP_W_DEFAULT = 300, BANDMAP_W_MIN = 200, BANDMAP_W_MAX = 900;
const BANDMAP_TAB_W_DEFAULT = 260, BANDMAP_TAB_W_MIN = 160, BANDMAP_TAB_W_MAX = 700;
const readWidth = (key: string, def: number, min: number, max: number) => {
const n = parseFloat(localStorage.getItem(key) || '');
return Number.isFinite(n) && n >= min && n <= max ? n : def;
};
const [bandMapWidth, setBandMapWidth] = useState<number>(
() => readWidth('opslog.bandMapWidth', BANDMAP_W_DEFAULT, BANDMAP_W_MIN, BANDMAP_W_MAX));
const [bandMapTabWidth, setBandMapTabWidth] = useState<number>(
() => readWidth('opslog.bandMapTabWidth', BANDMAP_TAB_W_DEFAULT, BANDMAP_TAB_W_MIN, BANDMAP_TAB_W_MAX));
useEffect(() => { writeUiPref('opslog.bandMapWidth', String(Math.round(bandMapWidth))); }, [bandMapWidth]);
useEffect(() => { writeUiPref('opslog.bandMapTabWidth', String(Math.round(bandMapTabWidth))); }, [bandMapTabWidth]);
// Drag one edge of a fixed-width column. Measures from the pointer's START
// position rather than the container, so it behaves the same whether the grip
// is on the left or the right edge — the docked map is docked on either side.
const startWidthDrag = (
e: React.PointerEvent, current: number, edge: 'left' | 'right',
min: number, max: number, apply: (w: number) => void,
) => {
e.preventDefault();
// Pointer capture, for the same reason as the main splitter: without it a
// map or a grid under the cursor swallows the moves.
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
const x0 = e.clientX;
const onMove = (ev: PointerEvent) => {
const delta = edge === 'right' ? ev.clientX - x0 : x0 - ev.clientX;
apply(Math.min(max, Math.max(min, Math.round(current + delta))));
};
const onUp = () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
};
const mainSplitRef = useRef<HTMLDivElement | null>(null);
const startMainSplitDrag = (e: React.PointerEvent) => {
e.preventDefault();
@@ -5948,9 +5991,15 @@ export default function App() {
{/* ===== LOWER: tabbed table / cluster / band map ===== */}
{compact ? null : <>
<div className={cn('grid gap-2.5 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)]',
showBandMap ? (bandMapSide === 'left' ? 'grid-cols-[300px_1fr]' : 'grid-cols-[1fr_300px]') : 'grid-cols-[1fr]')}>
<section className="bg-card border border-border rounded-lg shadow-sm flex flex-col min-h-0 overflow-hidden">
{/* The band map is a fixed-width column with a draggable grip on its inner
edge the gap between the two panes doubles as the handle, so no room
is spent on it. Same idiom as the Main tab's splitter. */}
<div className={cn('grid gap-0 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)]', !showBandMap && 'grid-cols-[1fr]')}
style={showBandMap
? { gridTemplateColumns: bandMapSide === 'left' ? `${bandMapWidth}px 10px 1fr` : `1fr 10px ${bandMapWidth}px` }
: undefined}>
<section className={cn('bg-card border border-border rounded-lg shadow-sm flex flex-col min-h-0 overflow-hidden',
showBandMap && (bandMapSide === 'left' ? 'order-3' : 'order-1'))}>
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col min-h-0 flex-1">
<TabsList className="px-3 shrink-0">
<TabsTrigger value="main">{t('tab.main')}</TabsTrigger>
@@ -6522,7 +6571,23 @@ export default function App() {
Pick one or more bands above to show their band maps side by side.
</div>
) : bandMapBands.map((b) => (
<div key={b} className="w-[260px] shrink-0 min-h-0 border border-border rounded-lg overflow-hidden flex flex-col">
<div key={b} className="relative shrink-0 min-h-0 border border-border rounded-lg overflow-hidden flex flex-col"
style={{ width: bandMapTabWidth }}>
{/* One width for every card: they sit side by side in a
scrolling row, and columns of different widths read as a
mistake rather than a choice. Grip on the right edge. */}
<div
role="separator"
aria-orientation="vertical"
title={t('bmp.widthTip')}
onPointerDown={(e) => startWidthDrag(
e, bandMapTabWidth, 'right',
BANDMAP_TAB_W_MIN, BANDMAP_TAB_W_MAX, setBandMapTabWidth)}
onDoubleClick={() => setBandMapTabWidth(BANDMAP_TAB_W_DEFAULT)}
className="group absolute inset-y-0 right-0 z-10 w-2 cursor-col-resize flex items-center justify-center"
>
<span className="h-10 w-[3px] rounded-full bg-transparent group-hover:bg-primary transition-colors" />
</div>
<BandMap
band={b}
spots={spots.filter((s) => s.band === b)}
@@ -6541,7 +6606,22 @@ export default function App() {
</section>
{showBandMap && (
<div className={cn('bg-card border border-border rounded-lg shadow-sm flex flex-col min-h-0 overflow-hidden', bandMapSide === 'left' && 'order-first')}>
<div
role="separator"
aria-orientation="vertical"
title={t('bmp.widthTip')}
onPointerDown={(e) => startWidthDrag(
e, bandMapWidth, bandMapSide === 'left' ? 'right' : 'left',
BANDMAP_W_MIN, BANDMAP_W_MAX, setBandMapWidth)}
onDoubleClick={() => setBandMapWidth(BANDMAP_W_DEFAULT)}
className="group relative order-2 cursor-col-resize flex items-center justify-center"
>
<span className="h-10 w-[3px] rounded-full bg-border group-hover:bg-primary transition-colors" />
</div>
)}
{showBandMap && (
<div className={cn('bg-card border border-border rounded-lg shadow-sm flex flex-col min-h-0 overflow-hidden',
bandMapSide === 'left' ? 'order-1' : 'order-3')}>
<BandMap
side={bandMapSide}
onToggleSide={toggleBandMapSide}