From bd524aff8c38344b22f7b63fce76f754f49fd3f7 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Wed, 23 Sep 2026 00:44:51 -0400 Subject: [PATCH] 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 --- ui/src/views/Forecast.jsx | 48 ++++++++++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index 71a02d6..91e2318 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -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. 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() { - const grid = (await viewerRef.current?.getPlugin())?.regular_table + const viewer = viewerRef.current + const grid = (await viewer?.getPlugin())?.regular_table if (!grid?.saveColumnSizes) return const cells = [...grid.querySelectorAll('tbody th')] @@ -1293,6 +1315,15 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio for (const idx of idxs) { 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 grid.restoreColumnSizes(sizes) 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. // // Set by column *index* through regular-table's own saveColumnSizes / - // restoreColumnSizes, not as a plugin_config column_size_override. The override - // path maps the key "__ROW_PATH__" to index tree_header_offset - 1, which with - // two group_by levels is index 2 — the first data column, not a row label. - // Pinning that was doing nothing visible because that column is the - // grand-total one, which the stylesheet hides. + // restoreColumnSizes, not as a plugin_config column_size_override, which maps + // the single key "__ROW_PATH__" onto one index and so cannot address a + // multi-level row header at all. + // + // 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) { const cells = [...grid.querySelectorAll('tbody th')] if (cells.length === 0) return