Put a floor under the row-label wall

The last row-label column is where the deepest level's labels land once
the tree is opened, and nothing was sizing it, so expanding squashed
them against the numbers.

It is column group_by.length. The geometry is now written down, because
the old comment had it wrong and that error steered several failed
attempts: tree_header_offset is group_by.length + 1 and is where the
DATA starts, so the row labels are columns 0..group_by.length -- column
0 the indent gutter, 1..N the N levels. tree_header_offset - 1 is the
last label column, not a data column, and the hidden grand-total column
is the data one at tree_header_offset itself.

A constant rather than a measurement, because only the levels currently
on show have cells: at group_by_depth 1 the deeper label columns are not
in the DOM at all, so the wall is sized before there is anything to
measure. 218px was measured by hand on a rep/customer/month/part tree.

A floor, like the neighbouring 3-char one, so a wall dragged wider
survives the next layout apply.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-23 00:44:51 -04:00
parent 3257ac2639
commit bd524aff8c

View File

@ -1268,8 +1268,30 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
// has to overflow its column, not the column grow to hold the text. // has to overflow its column, not the column grow to hold the text.
const MIN_ROW_LABEL_CHARS = 3 const MIN_ROW_LABEL_CHARS = 3
// The wall: the last row-label column, which is where the deepest level's
// labels land once the tree is expanded to full depth.
//
// Its index is group_by.length, and the geometry is worth writing down because
// the comment on fitRowLabels had it wrong and cost a day. With four group_by
// levels the grid lays out rt-col-0..4 for row labels and starts the data at
// rt-col-5 (= tree_header_offset, group_by.length + 1). Column 0 is the indent
// gutter, and columns 1..N are the N levels — so the last level is column N,
// not N - 1. The hidden grand-total column is the *data* one at
// tree_header_offset, which the psp-split-total rule zeroes out; it is not
// this one.
//
// Only the columns for levels currently on show are rendered, so at
// group_by_depth 1 the deeper ones have no cells at all. Sizing this one by
// measuring the DOM therefore cannot work — it is sized blind, ahead of being
// needed, which is exactly why it is a constant rather than a fit.
//
// 218px was measured by hand: the width a rep/customer/month/part tree wants
// once it is open. This is the number to turn.
const ROW_LABEL_WALL_W = 218
async function ensureRowLabelWidth() { async function ensureRowLabelWidth() {
const grid = (await viewerRef.current?.getPlugin())?.regular_table const viewer = viewerRef.current
const grid = (await viewer?.getPlugin())?.regular_table
if (!grid?.saveColumnSizes) return if (!grid?.saveColumnSizes) return
const cells = [...grid.querySelectorAll('tbody th')] const cells = [...grid.querySelectorAll('tbody th')]
@ -1293,6 +1315,15 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
for (const idx of idxs) { for (const idx of idxs) {
if ((sizes[idx] ?? 0) < min) { sizes[idx] = min; changed = true } if ((sizes[idx] ?? 0) < min) { sizes[idx] = min; changed = true }
} }
// A floor, like the rest: a wall someone has dragged wider is left alone.
const { group_by = [] } = (await viewer.save()) || {}
const wall = group_by.length
if (wall > 0 && (sizes[wall] ?? 0) < ROW_LABEL_WALL_W) {
sizes[wall] = ROW_LABEL_WALL_W
changed = true
}
if (!changed) return if (!changed) return
grid.restoreColumnSizes(sizes) grid.restoreColumnSizes(sizes)
await grid.draw({ invalid_columns: true }) await grid.draw({ invalid_columns: true })
@ -1319,11 +1350,16 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
// the same circularity that stops headers sizing themselves. // the same circularity that stops headers sizing themselves.
// //
// Set by column *index* through regular-table's own saveColumnSizes / // Set by column *index* through regular-table's own saveColumnSizes /
// restoreColumnSizes, not as a plugin_config column_size_override. The override // restoreColumnSizes, not as a plugin_config column_size_override, which maps
// path maps the key "__ROW_PATH__" to index tree_header_offset - 1, which with // the single key "__ROW_PATH__" onto one index and so cannot address a
// two group_by levels is index 2 — the first data column, not a row label. // multi-level row header at all.
// Pinning that was doing nothing visible because that column is the //
// grand-total one, which the stylesheet hides. // Note the index arithmetic, which an earlier version of this comment had
// wrong: tree_header_offset is group_by.length + 1 and is where the DATA
// starts. The row labels are columns 0..group_by.length, of which 0 is the
// indent gutter and 1..N are the N levels. tree_header_offset - 1 is the last
// *label* column, not a data column; the hidden grand-total column is the
// data one at tree_header_offset itself.
async function fitRowLabels(grid) { async function fitRowLabels(grid) {
const cells = [...grid.querySelectorAll('tbody th')] const cells = [...grid.querySelectorAll('tbody th')]
if (cells.length === 0) return if (cells.length === 0) return