Stop fitting row labels on restore; raise the floor instead

A level's indent is the previous column's width, so fitting a
row-header column to its content deepens the inset of everything below
it. Which column got fitted depended on how deep the tree happened to
be drawn when the layout loaded, so one level per layout came back
deeply inset while the rest stepped evenly -- deterministic, and
unreadable as a pattern from outside. fitRowLabels is unwired again and
carries a note saying why, so the next attempt starts from the reason
rather than the idea.

The squashed labels it was meant to fix are addressed by raising
ensureRowLabelWidth's floor from 3 characters to 12. A floor is safe
where a fit is not: it goes under every row-header column equally, so
the levels still step by a constant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-23 00:05:34 -04:00
parent ad5e61d4d2
commit 818aa5e4fc

View File

@ -973,7 +973,6 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
// throw here would abort the rest of initViewer silently.
try {
await ensureRowLabelWidth()
await fitRowLabelsNow()
} catch (err) {
console.error('[pf-layout] threw', err)
}
@ -1255,9 +1254,16 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
// 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.
// Three characters, measured in the grid's own font rather than guessed in
// pixels, so it holds up if the theme or zoom changes.
const MIN_ROW_LABEL_CHARS = 3
// Measured in the grid's own font rather than guessed in pixels, so it holds
// up if the theme or zoom changes.
//
// The same floor goes under every row-header column, which is what makes it
// safe for the indentation: a level's indent is the previous column's width,
// so a uniform floor steps evenly, where a per-column fit does not. Three
// characters was too mean to read a label through; this is the one number to
// turn if the labels are still squished, and the cost of turning it is that
// every level indents by that much more.
const MIN_ROW_LABEL_CHARS = 12
async function ensureRowLabelWidth() {
const grid = (await viewerRef.current?.getPlugin())?.regular_table
@ -1293,6 +1299,18 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
// Every level is measured, because which column is the outermost depends on
// the depth on show, but only that one is resized — see below.
//
// UNUSED, and parked for the second time. It was called from initViewer and
// applyLayout for a while, and the trouble is that a level's indent *is* the
// previous column's width: widening any row-header column to fit its content
// deepens the inset of everything below it. Which column got widened depended
// on how deep the tree happened to be drawn when the layout loaded, so one
// level per layout came back deeply inset while the rest stepped evenly —
// deterministic, but unreadable as a pattern from the outside. Fitting and
// even Excel-style increments cannot both come from column widths. Doing it
// properly means pinning every level but the last to a fixed increment and
// letting a long label overflow rightwards into the last column, which is a
// change to the grid CSS as well as to this.
//
// Measured with a canvas rather than the DOM: the cell is clipped, so reading
// its box back gives the width it was allotted, not the width of its text —
// the same circularity that stops headers sizing themselves.
@ -1354,20 +1372,6 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
await grid.draw({ invalid_columns: true })
}
// fitRowLabels takes the grid; this finds it, so the restore paths can call it
// the same way they call ensureRowLabelWidth.
//
// Deliberately not routed through the Fit widths button: that one goes via
// regular-table's own auto-size, which measures the leaf header's rendered box
// over the rows currently in the viewport. fitRowLabels measures the label
// text on a canvas instead, so it is not blinded by a clipped cell and does
// not depend on what happens to be scrolled into view.
async function fitRowLabelsNow() {
const grid = (await viewerRef.current?.getPlugin())?.regular_table
if (!grid?.saveColumnSizes) return
await fitRowLabels(grid)
}
const GRID_CSS = `
/* Let headers and row labels size their own column.
*
@ -1580,7 +1584,6 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
else if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth)
await ensureRowLabelWidth()
await fitRowLabelsNow()
// The persisted copy is taken after the restore, so it carries whatever
// cleanLayout dropped on the way in rather than the stale original -- and