diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index 34305d5..901a60a 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -866,6 +866,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio viewer.addEventListener('perspective-select', viewer._pspSelect) gridRef.current = await viewer.getPlugin() + applyGroupRules() setLargeDataset(false) } catch (err) { @@ -929,6 +930,67 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio } } + // Rule off the column groups, and mark each group's subtotal. + // + // Scanning across "prior · plan · forecast, each with twelve months and a + // total" is twelve columns of identical-looking numbers with nothing to say + // where one domain ends and the next begins. The annual figures and the + // monthly ones read as one run. + // + // Done through regular_table's style listener rather than CSS: which column + // starts a group, and which one is a group's subtotal, are facts about the + // data that only the cell metadata knows. The listener runs on every draw, so + // it survives scrolling and virtualisation -- a stylesheet cannot, since the + // DOM cells are recycled across columns as you scroll. + function applyGroupRules() { + const grid = gridRef.current + const table = grid?.regular_table + if (!table || table._pfGroupRules) return + table._pfGroupRules = true + + // The grid lives in a shadow root, so a stylesheet on the page cannot reach + // these cells. Inject into whichever root actually contains the table. + // currentColor rather than a fixed grey, so the rule follows the theme + // instead of vanishing against Pro Dark. + const root = table.getRootNode() || document + if (!root.querySelector('#pf-group-rules')) { + const style = document.createElement('style') + style.id = 'pf-group-rules' + style.textContent = ` + td.pf-group-start { border-left: 2px solid currentColor; opacity: 1; } + td.pf-subtotal { font-weight: 600; background: color-mix(in srgb, currentColor 7%, transparent); } + ` + ;(root.head || root).appendChild(style) + } + + table.addStyleListener(() => { + const body = table.querySelectorAll('tbody td') + // The deepest column path is a leaf; anything shorter is an aggregate of + // the levels below it, which is what makes a subtotal a subtotal. + let depth = 0 + const metas = [] + for (const td of body) { + let meta + try { meta = table.getMeta(td) } catch { meta = null } + metas.push([td, meta]) + const path = meta?.column_header + if (Array.isArray(path)) depth = Math.max(depth, path.filter(v => v != null && v !== '').length) + } + + let prevGroup = null + for (const [td, meta] of metas) { + td.classList.remove('pf-group-start', 'pf-subtotal') + const path = meta?.column_header + if (!Array.isArray(path) || !path.length) { prevGroup = null; continue } + const named = path.filter(v => v != null && v !== '') + const group = named[0] + if (group !== prevGroup) { td.classList.add('pf-group-start'); prevGroup = group } + if (named.length < depth) td.classList.add('pf-subtotal') + } + }) + table.draw() + } + // Size every column to its contents. // // Values fit on their own: draw() calls regular_table.resetAutoSize(), which