Fit the row-label column to the labels it actually shows

Values fitted and row labels did not. The measurement pairs each column's
header with its body cell, and for the row-header columns the header is a
blank corner cell — so the column was being sized from an empty string,
never from the labels beneath it.

Measured with a canvas instead of the DOM, for the same reason the headers
cannot size themselves: the cell is clipped, so reading its box back returns
the width it was allotted rather than the width of its text. Tree
indentation is added, since it occupies real width. The result is pinned as
a column_size_override on __ROW_PATH__ — the key
restore_column_size_overrides special-cases for this column, and an override
is what survives subsequent draws, which is precisely why overrides were
defeating resetAutoSize earlier.

Group headers are deliberately left alone. pro.css is explicit:

    /* Header groups should overflow and not contribute to auto-sizing. */
    thead tr:not(.rt-autosize) th { overflow: hidden; max-width: 0px; }

Only the leaf header row participates, because a group label spans many
columns and letting it set width would widen all of them. With the ordinal
prefix in front, "01 - Prior…" still reads when truncated. Fixing it
properly means distributing a group's label width across its span, which is
a different job.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-17 14:17:22 -04:00
parent 194134ea5f
commit b75ce939b1

View File

@ -955,21 +955,25 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
// Size every column to its contents.
//
// The datagrid measures content on its own -- draw() calls
// regular_table.resetAutoSize() when _reset_column_size is set -- but the reset
// is immediately undone:
// Values fit on their own: draw() calls regular_table.resetAutoSize(), which
// clears the width caches so the next draw measures cells and sets each
// column's min-width from the result. The complication is that the plugin's own
// draw saves the *live* widths first and restores them immediately after the
// reset, so going through it preserves whatever the columns already are. Hence
// driving regular_table directly.
//
// const old_sizes = save_column_size_overrides.call(this); // live widths
// ... if (this._reset_column_size) { resetAutoSize() }
// restore_column_size_overrides.call(this, old_sizes); // straight back
// Two things that measurement will never fix:
//
// and old_sizes comes from regular_table.saveColumnSizes(), the *live* widths,
// not from plugin_config. So every draw preserves whatever the columns are
// currently at, and clearing the config releases nothing. Setting the flag and
// going through the plugin's draw cannot work for the same reason.
// - Group headers are excluded on purpose. pro.css says so:
// /* Header groups should overflow and not contribute to auto-sizing. */
// thead tr:not(.rt-autosize) th { overflow: hidden; max-width: 0px; }
// Only the leaf header row participates. Letting a group label set width
// would widen every column beneath it, so this is left alone -- with the
// ordinal prefix in front, "01 - Prior" still reads when truncated.
//
// So drive regular_table directly: drop the cached sizes, reset, and draw. It is
// the same draw the plugin calls, without the save/restore wrapped around it.
// - The row-label column measures its *header*, which for row headers is a
// blank corner cell, so it never reflects the labels underneath. That one
// is worth fixing, and is what fitRowLabels does below.
async function fitColumns() {
const viewer = viewerRef.current
if (!viewer) return
@ -987,6 +991,8 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
grid.resetAutoSize()
await grid.draw({ invalid_columns: true })
await fitRowLabels(viewer, grid)
// Widths are not part of ViewConfig, so persist the layout to keep the
// saved copy in step with what is on screen.
const cfg = await captureConfig()
@ -997,21 +1003,49 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
}
}
// In rollup mode the datagrid emits a grand-total column group as well as the
// subtotals. The subtotals are the point -- they are what per-branch collapse
// needs -- but the grand total sums across the split, and when the split is
// Prior Year / Plan / Forecast that sum is meaningless and reads as a real
// figure to anyone scanning the sheet.
// Pin the row-label column wide enough for the labels it shows.
//
// The engine cannot separate them: t_totals is { BEFORE, HIDDEN, AFTER }, and in
// a rollup the grand total *is* the root of the hierarchy that produces the
// subtotals. So it is hidden rather than suppressed -- the view still computes
// it, it is simply not painted.
// 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.
//
// psp-split-total marks it and psp-split-subtotal marks the subtotals, so this
// survives adding measures or rearranging the pivot, unlike hiding by position.
// The rule has to live inside the plugin's shadow root; a document stylesheet
// cannot reach it.
// __ROW_PATH__ is the key restore_column_size_overrides special-cases for this
// column, and an override is what survives every subsequent draw, which is
// exactly why overrides were defeating resetAutoSize in the first place.
async function fitRowLabels(viewer, grid) {
const cells = [...grid.querySelectorAll('tbody th')]
if (cells.length === 0) return
const probe = cells[0]
const style = getComputedStyle(probe)
const ctx = (fitRowLabels._ctx ||= document.createElement('canvas').getContext('2d'))
ctx.font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`
// Indentation is real width: a tree label sits inside a flex container with
// the expand control beside it, so measure from the cell's left edge.
const padding = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight)
let widest = 0
for (const cell of cells) {
const text = (cell.textContent || '').trim()
if (!text) continue
const indent = cell.querySelector('span.rt-tree-container')
? (parseFloat(getComputedStyle(cell.firstElementChild).paddingLeft) || 0)
: 0
widest = Math.max(widest, ctx.measureText(text).width + indent)
}
if (widest === 0) return
// A few px over, since canvas metrics and rendered text differ slightly with
// font fallback and letter-spacing.
const width = Math.ceil(widest + padding + 8)
const { table: _t, ...cfg } = await viewer.save()
const pc = { ...(cfg.plugin_config || {}) }
pc.columns = { ...(pc.columns || {}) }
pc.columns.__ROW_PATH__ = { ...(pc.columns.__ROW_PATH__ || {}), column_size_override: width }
await viewer.restore({ ...cfg, plugin_config: pc })
}
const GRID_CSS = `
/* Let headers and row labels size their own column.
*