diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index 382a3db..2da34f5 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -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. *