Put a floor under the row-label columns

Loading a saved layout brought them back a few pixels wide, needing to be
dragged open by hand. Restoring resets the widths, and the row-header
columns are then sized from their header — which for row headers is a blank
corner cell — so they measure as empty.

A minimum rather than a fit. Fitting to content is the other extreme: each
group_by level is its own column, so the first widens to its longest label
and shoves the second rightwards, which is the spacing that read worse than
the default. 130px leaves a reasonable default alone and only intervenes
where a column came back unusable; anything already wider, whether dragged
or recorded in a layout, is untouched.

Applied after the initial load and after a layout restore — the two places
that reset widths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-17 15:21:44 -04:00
parent a9062c398c
commit 4045d336c4

View File

@ -811,6 +811,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
// column list. // column list.
try { try {
await syncOrderExpressions() await syncOrderExpressions()
await ensureRowLabelWidth()
} catch (err) { } catch (err) {
// Its own try covers only restore(); save() and the builders sit outside // Its own try covers only restore(); save() and the builders sit outside
// it, and a throw there would abort the rest of initViewer silently. // it, and a throw there would abort the rest of initViewer silently.
@ -1085,6 +1086,40 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
} }
} }
// A floor under the row-label columns, not a fit.
//
// Restoring a layout resets the widths, and the row-header columns are then
// sized from their *header* which for row headers is a blank corner cell
// so they come back a few pixels wide and have to be dragged open by hand.
// Fitting them to content is the other extreme: each group_by level is its own
// column, so the first widens to its longest label and shoves the second
// rightwards.
//
// A minimum leaves the default spacing alone where it is already reasonable and
// only intervenes where a column came back unusably narrow. Anything already
// wider set by dragging, or recorded in a layout is untouched.
const MIN_ROW_LABEL_W = 130
async function ensureRowLabelWidth() {
const grid = (await viewerRef.current?.getPlugin())?.regular_table
if (!grid?.saveColumnSizes) return
const idxs = new Set()
for (const cell of grid.querySelectorAll('tbody th')) {
const m = [...cell.classList].map(c => /^rt-col-(\d+)$/.exec(c)).find(Boolean)
if (m) idxs.add(Number(m[1]))
}
if (idxs.size === 0) return
const sizes = { ...grid.saveColumnSizes() }
let changed = false
for (const idx of idxs) {
if ((sizes[idx] ?? 0) < MIN_ROW_LABEL_W) { sizes[idx] = MIN_ROW_LABEL_W; changed = true }
}
if (!changed) return
grid.restoreColumnSizes(sizes)
await grid.draw({ invalid_columns: true })
}
// Pin the row-label columns wide enough for the labels they show. // Pin the row-label columns wide enough for the labels they show.
// //
// Measured with a canvas rather than the DOM: the cell is clipped, so reading // Measured with a canvas rather than the DOM: the cell is clipped, so reading
@ -1336,6 +1371,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
// back. Re-applied after every restore that comes from a saved config, which // back. Re-applied after every restore that comes from a saved config, which
// is the general form of the fix initViewer already needed. // is the general form of the fix initViewer already needed.
await syncOrderExpressions() await syncOrderExpressions()
await ensureRowLabelWidth()
setActiveLayoutId(layout.id) setActiveLayoutId(layout.id)
// After the sync, so the persisted copy carries the expressions too. // After the sync, so the persisted copy carries the expressions too.