musrview: make the Fit parameters, Theory and Run info panels resizable

Add drag handles between the plot and the side column, between the Fit
parameters/Theory panels, and above the Run info panel, so their width/height
can be adjusted interactively. Sizes are clamped to the viewport and persisted
in localStorage, reusing the pattern already used for panel visibility.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjACu9ZD3FGfp1Db32DoiQ
This commit is contained in:
suter_aandClaude Sonnet 5 committed 2026-09-12 20:07:14 +02:00
1 parent 174d58ea6a
commit 16febf62e2
1 file changed
+146 -29
+146 -29
View File
@@ -30,24 +30,40 @@
}
/* ---- overall layout ------------------------------------------------ */
/* row/column sizes are the defaults for the very first paint; from then
on updateLayout() (below, in the script) drives them via inline
styles, so that the side column width and the run-info height can be
dragged by the user and the title/params/theory/info rows/columns
collapse to 0 when their panel is hidden. */
#app {
display: grid;
grid-template-rows: auto auto 1fr auto auto;
grid-template-columns: 1fr 340px;
grid-template-rows: auto auto 1fr 6px auto auto;
grid-template-columns: 1fr 6px 340px;
grid-template-areas:
"toolbar toolbar"
"title title"
"plot side"
"info info"
"status status";
"toolbar toolbar toolbar"
"title title title"
"plot vsplit side"
"hsplit hsplit hsplit"
"info info info"
"status status status";
height: 100%;
width: 100%;
}
/* the side column disappears completely when both side panels are off */
#app.no-side { grid-template-columns: 1fr 0px; }
#app.no-title { grid-template-rows: auto 0px 1fr auto auto; }
#app.no-info { grid-template-rows: auto auto 1fr 0px auto; }
#app.no-title.no-info { grid-template-rows: auto 0px 1fr 0px auto; }
/* ---- drag handles ---------------------------------------------------
thin bars between the plot and the side column (width) and between
the plot row and the run-info panel (height); the params/theory
split handle lives inside #side below. */
#vsplit, #hsplit, #side-split {
background: var(--border);
transition: background .15s;
touch-action: none;
}
#vsplit { grid-area: vsplit; cursor: col-resize; }
#hsplit { grid-area: hsplit; cursor: row-resize; }
#vsplit:hover, #vsplit.dragging,
#hsplit:hover, #hsplit.dragging,
#side-split:hover, #side-split.dragging { background: var(--accent); }
/* ---- toolbar ------------------------------------------------------- */
#toolbar {
@@ -112,10 +128,11 @@
#side {
grid-area: side;
display: flex;
flex-direction: column;
display: grid;
grid-template-rows: 1fr 6px 1fr;
border-left: 1px solid var(--border);
min-height: 0;
min-width: 0;
overflow: hidden;
}
@@ -123,10 +140,9 @@
display: flex;
flex-direction: column;
min-height: 0;
flex: 1 1 50%;
border-bottom: 1px solid var(--border);
min-width: 0;
overflow: hidden;
}
.panel:last-child { border-bottom: none; }
.panel > h2 {
margin: 0;
padding: 4px 8px;
@@ -179,11 +195,11 @@
grid-area: info;
display: flex;
flex-direction: column;
max-height: 28vh;
min-height: 0;
border-top: 1px solid var(--border);
overflow: hidden;
}
#infopanel > #info { max-height: 22vh; }
#infopanel > #info { flex: 1 1 auto; min-height: 0; }
#statusbar {
grid-area: status;
@@ -279,17 +295,22 @@
<div id="plot"></div>
<div id="vsplit" title="drag to resize the side panels"></div>
<div id="side">
<div class="panel" id="panel-params">
<h2>Fit parameters</h2>
<pre id="params"></pre>
</div>
<div id="side-split" title="drag to resize"></div>
<div class="panel" id="panel-theory">
<h2>Theory</h2>
<pre id="theory"></pre>
</div>
</div>
<div id="hsplit" title="drag to resize the run info panel"></div>
<div id="infopanel">
<h2 style="margin:0;padding:4px 8px;font-size:11px;font-weight:700;letter-spacing:.06em;
text-transform:uppercase;color:var(--muted);background:var(--panel-bg);
@@ -365,29 +386,25 @@ function isTyping() {
// shown and hidden individually)
//--------------------------------------------------------------------------
const panels = {
title: { btn: 'pan-title', el: 'titlebar', cls: 'no-title', visible: true },
params: { btn: 'pan-params', el: 'panel-params', cls: null, visible: true },
theory: { btn: 'pan-theory', el: 'panel-theory', cls: null, visible: true },
info: { btn: 'pan-info', el: 'infopanel', cls: 'no-info', visible: true }
title: { btn: 'pan-title', el: 'titlebar', visible: true },
params: { btn: 'pan-params', el: 'panel-params', visible: true },
theory: { btn: 'pan-theory', el: 'panel-theory', visible: true },
info: { btn: 'pan-info', el: 'infopanel', visible: true }
};
function applyPanels() {
const app = $('app');
for (const key of Object.keys(panels)) {
const p = panels[key];
$(p.el).classList.toggle('hidden', !p.visible);
$(p.btn).classList.toggle('active', p.visible);
if (p.cls) app.classList.toggle(p.cls, !p.visible);
}
// collapse the whole side column when neither side panel is shown
app.classList.toggle('no-side', !panels.params.visible && !panels.theory.visible);
try {
localStorage.setItem('musrview.panels',
JSON.stringify(Object.fromEntries(Object.entries(panels).map(([k, v]) => [k, v.visible]))));
} catch (e) { /* private mode, ignore */ }
resizePlot();
updateLayout();
}
function togglePanel(key) {
@@ -404,6 +421,105 @@ function restorePanels() {
applyPanels();
}
//--------------------------------------------------------------------------
// layout: the side column width, the fit-parameters/theory split and the
// run-info height are all user-draggable (via #vsplit / #side-split /
// #hsplit) and persisted the same way panel visibility is.
//--------------------------------------------------------------------------
const clamp = (v, lo, hi) => Math.min(Math.max(v, lo), hi);
let sideWidth = 340; // px, width of the params/theory column
let infoHeight = null; // px, height of the run-info panel; defaulted lazily
// from the viewport once it is known
let paramsFrac = 0.5; // fraction of #side's height given to 'Fit parameters'
function updateLayout() {
const app = $('app');
const sideVisible = panels.params.visible || panels.theory.visible;
const infoVisible = panels.info.visible;
if (infoHeight === null) infoHeight = Math.round(window.innerHeight * 0.28);
infoHeight = clamp(infoHeight, 60, Math.max(60, Math.round(window.innerHeight * 0.75)));
sideWidth = clamp(sideWidth, 200, Math.max(200, Math.round(window.innerWidth * 0.75)));
app.style.gridTemplateColumns =
`1fr ${sideVisible ? '6px' : '0px'} ${sideVisible ? sideWidth + 'px' : '0px'}`;
app.style.gridTemplateRows =
`auto ${panels.title.visible ? 'auto' : '0px'} 1fr ` +
`${infoVisible ? '6px' : '0px'} ${infoVisible ? infoHeight + 'px' : '0px'} auto`;
$('vsplit').classList.toggle('hidden', !sideVisible);
$('hsplit').classList.toggle('hidden', !infoVisible);
updateSideLayout();
resizePlot();
}
function updateSideLayout() {
const side = $('side');
const pVis = panels.params.visible, tVis = panels.theory.visible;
if (pVis && tVis) {
side.style.gridTemplateRows = `${paramsFrac}fr 6px ${1 - paramsFrac}fr`;
$('side-split').classList.remove('hidden');
} else {
side.style.gridTemplateRows = pVis ? '1fr 0px 0px' : (tVis ? '0px 0px 1fr' : '0px 0px 0px');
$('side-split').classList.add('hidden');
}
}
function saveLayout() {
try {
localStorage.setItem('musrview.layout', JSON.stringify({ sideWidth, infoHeight, paramsFrac }));
} catch (e) { /* private mode, ignore */ }
}
function restoreLayout() {
try {
const saved = JSON.parse(localStorage.getItem('musrview.layout') || '{}');
if (typeof saved.sideWidth === 'number') sideWidth = saved.sideWidth;
if (typeof saved.infoHeight === 'number') infoHeight = saved.infoHeight;
if (typeof saved.paramsFrac === 'number') paramsFrac = saved.paramsFrac;
} catch (e) { /* ignore */ }
}
/** Wires up a splitter bar: while it is dragged, onMove(pointerEvent) is
* called to update the layout; on release the layout is persisted. */
function makeSplitter(handleEl, onMove) {
handleEl.addEventListener('pointerdown', ev => {
ev.preventDefault();
handleEl.setPointerCapture(ev.pointerId);
handleEl.classList.add('dragging');
const onPointerMove = mv => onMove(mv);
const onPointerUp = () => {
handleEl.classList.remove('dragging');
handleEl.removeEventListener('pointermove', onPointerMove);
handleEl.removeEventListener('pointerup', onPointerUp);
saveLayout();
};
handleEl.addEventListener('pointermove', onPointerMove);
handleEl.addEventListener('pointerup', onPointerUp);
});
}
makeSplitter($('vsplit'), ev => {
sideWidth = window.innerWidth - ev.clientX - 3;
updateLayout();
});
makeSplitter($('hsplit'), ev => {
const statusH = $('statusbar').getBoundingClientRect().height;
infoHeight = window.innerHeight - statusH - ev.clientY - 3;
updateLayout();
});
makeSplitter($('side-split'), ev => {
const rect = $('side').getBoundingClientRect();
paramsFrac = clamp((ev.clientY - rect.top) / rect.height, 0.1, 0.9);
updateSideLayout();
});
//--------------------------------------------------------------------------
// plotting
//--------------------------------------------------------------------------
@@ -680,7 +796,7 @@ $('pan-params').onclick = () => togglePanel('params');
$('pan-theory').onclick = () => togglePanel('theory');
$('pan-info').onclick = () => togglePanel('info');
window.addEventListener('resize', resizePlot);
window.addEventListener('resize', updateLayout);
//--------------------------------------------------------------------------
// websocket towards the C++ side
@@ -724,6 +840,7 @@ const receiver = {
}
};
restoreLayout();
restorePanels();
connectWebWindow({