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:
@@ -1,4 +1,14 @@
|
||||
[
|
||||
{
|
||||
"version": "0.24.1",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Band map: the width can be dragged. Both the map docked beside the tables and the per-band cards in the Band map tab were locked at a fixed width, so an operator watching a busy band could not give the map more room — nor take it back for the log. Grab the edge to resize, double-click it to go back to the default. The width is remembered and travels with your data folder, like the other layout settings. In the tab, one width applies to every card: they sit side by side, and columns of different widths read as a mistake."
|
||||
],
|
||||
"fr": [
|
||||
"Band map : la largeur se règle à la souris. La carte ancrée à côté des tableaux et les cartes par bande de l'onglet Band map étaient figées à une largeur fixe : impossible de donner plus de place à la carte sur une bande chargée, ni de la reprendre pour le journal. Attrape le bord pour redimensionner, double-clic pour revenir au défaut. La largeur est mémorisée et voyage avec ton dossier de données, comme les autres réglages de disposition. Dans l'onglet, une seule largeur vaut pour toutes les cartes : elles sont côte à côte, et des colonnes de largeurs différentes se lisent comme une erreur."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.24.0",
|
||||
"date": "",
|
||||
|
||||
+85
-5
@@ -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}
|
||||
|
||||
@@ -342,7 +342,7 @@ const en: Dict = {
|
||||
'bmp.statusNew': 'NEW DXCC (entity never worked)', 'bmp.statusNewBand': 'NEW BAND (entity not worked on this band)', 'bmp.statusNewSlot': 'NEW SLOT (mode not worked on this band)',
|
||||
'bmp.statusWorked': 'Worked (this band + mode already in log)', 'bmp.statusUnresolved': 'Entity not resolved', 'bmp.bandMap': 'Band map', 'bmp.notConfigured': 'Not configured for {band}.',
|
||||
'bmp.map': 'Map', 'bmp.zoomOut': 'Zoom out', 'bmp.zoomIn': 'Zoom in', 'bmp.scrollToRig': 'Scroll to current rig frequency', 'bmp.moveLeft': 'Move band map to the left', 'bmp.moveRight': 'Move band map to the right', 'bmp.hide': 'Hide band map',
|
||||
'bmp.bandsLabel': 'Bands:', 'bmp.fit': 'FIT', 'bmp.hideFt': 'Hide FTx', 'bmp.hideFtTitle': 'Hide all digital (FT8/FT4/JS8/…) spots on every band map', 'bmp.fitBand': 'Fit to band', 'bmp.fitTitle': 'Size each band map to show the whole band edge-to-edge',
|
||||
'bmp.bandsLabel': 'Bands:', 'bmp.fit': 'FIT', 'bmp.hideFt': 'Hide FTx', 'bmp.hideFtTitle': 'Hide all digital (FT8/FT4/JS8/…) spots on every band map', 'bmp.fitBand': 'Fit to band', 'bmp.widthTip': 'Drag to resize — double-click to reset', 'bmp.fitTitle': 'Size each band map to show the whole band edge-to-edge',
|
||||
'bmp.legendNewDxcc': 'New DXCC', 'bmp.legendNewBand': 'New band', 'bmp.legendNewSlot': 'New slot (mode)', 'bmp.legendWorked': 'Worked', 'bmp.legendCW': 'CW', 'bmp.legendData': 'Data', 'bmp.legendPhone': 'Phone', 'bmp.footerHint': 'scroll · ctrl+wheel = zoom · ◎ = jump to rig', 'bmp.spotsHidden': '{n} FT8/FT4 spots hidden — top {max} kept (CW/SSB all shown)',
|
||||
'frm.welcome': 'Welcome to OpsLog', 'frm.intro': 'Set up your station to start logging. These fields stamp every QSO and can be changed later in Preferences → Station Information (and per profile).',
|
||||
'frm.callsign': 'Callsign', 'frm.locator': 'Locator', 'frm.operator': 'Operator', 'frm.operatorPh': 'same as callsign', 'frm.owner': 'Owner', 'frm.ownerPh': 'station owner callsign', 'frm.name': 'Name', 'frm.namePh': 'your first name',
|
||||
@@ -750,7 +750,7 @@ const fr: Dict = {
|
||||
'bmp.statusNew': 'NOUVEAU DXCC (entité jamais contactée)', 'bmp.statusNewBand': 'NOUVELLE BANDE (entité non contactée sur cette bande)', 'bmp.statusNewSlot': 'NOUVEAU MODE (mode non contacté sur cette bande)',
|
||||
'bmp.statusWorked': 'Contacté (cette bande + mode déjà au log)', 'bmp.statusUnresolved': 'Entité non résolue', 'bmp.bandMap': 'Carte de bande', 'bmp.notConfigured': 'Non configurée pour {band}.',
|
||||
'bmp.map': 'Carte', 'bmp.zoomOut': 'Dézoomer', 'bmp.zoomIn': 'Zoomer', 'bmp.scrollToRig': 'Aller à la fréquence actuelle du poste', 'bmp.moveLeft': 'Déplacer la carte de bande à gauche', 'bmp.moveRight': 'Déplacer la carte de bande à droite', 'bmp.hide': 'Masquer la carte de bande',
|
||||
'bmp.bandsLabel': 'Bandes :', 'bmp.fit': 'FIT', 'bmp.hideFt': 'Masquer FTx', 'bmp.hideFtTitle': 'Masquer tous les spots numériques (FT8/FT4/JS8/…) sur toutes les cartes', 'bmp.fitBand': 'Ajuster à la bande', 'bmp.fitTitle': 'Dimensionner chaque carte pour afficher toute la bande',
|
||||
'bmp.bandsLabel': 'Bandes :', 'bmp.fit': 'FIT', 'bmp.hideFt': 'Masquer FTx', 'bmp.hideFtTitle': 'Masquer tous les spots numériques (FT8/FT4/JS8/…) sur toutes les cartes', 'bmp.fitBand': 'Ajuster à la bande', 'bmp.widthTip': 'Glisser pour redimensionner — double-clic pour réinitialiser', 'bmp.fitTitle': 'Dimensionner chaque carte pour afficher toute la bande',
|
||||
'bmp.legendNewDxcc': 'Nouveau DXCC', 'bmp.legendNewBand': 'Nouvelle bande', 'bmp.legendNewSlot': 'Nouveau mode', 'bmp.legendWorked': 'Contacté', 'bmp.legendCW': 'CW', 'bmp.legendData': 'Numérique', 'bmp.legendPhone': 'Phonie', 'bmp.footerHint': 'défiler · ctrl+molette = zoom · ◎ = aller au poste', 'bmp.spotsHidden': '{n} spots FT8/FT4 masqués — {max} meilleurs conservés (CW/SSB tous affichés)',
|
||||
'frm.welcome': 'Bienvenue dans OpsLog', 'frm.intro': 'Configure ta station pour commencer à logger. Ces champs sont inscrits sur chaque QSO et peuvent être modifiés plus tard dans Préférences → Informations station (et par profil).',
|
||||
'frm.callsign': 'Indicatif', 'frm.locator': 'Locator', 'frm.operator': 'Opérateur', 'frm.operatorPh': "identique à l'indicatif", 'frm.owner': 'Propriétaire', 'frm.ownerPh': 'indicatif du propriétaire de la station', 'frm.name': 'Nom', 'frm.namePh': 'ton prénom',
|
||||
|
||||
@@ -39,6 +39,8 @@ const PORTABLE_KEYS = [
|
||||
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
|
||||
'opslog.activeTab', // last selected tab
|
||||
'opslog.mainSplit', // Main tab: width share of the left pane (percent)
|
||||
'opslog.bandMapWidth', // docked band map: column width (px)
|
||||
'opslog.bandMapTabWidth', // Band map tab: shared card width (px)
|
||||
// NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
|
||||
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER
|
||||
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
|
||||
|
||||
Reference in New Issue
Block a user