Rule off the column groups and mark their subtotals

Prior, plan and forecast each carry twelve months and a total, so scanning
across is thirty-odd columns of identical-looking numbers with nothing to say
where one domain ends and the next begins -- annual figures read as just
another month.

Each group's first column now takes a left rule and each group's subtotal a
tint and a heavier weight. Both are derived from the cell's column path: the
deepest path is a leaf, so anything shorter is an aggregate of the levels
below it, which is what makes a subtotal a subtotal.

Through regular_table's style listener rather than CSS, because the DOM cells
are recycled across columns as you scroll -- a stylesheet would paint the
wrong ones the moment the grid virtualised. The styles go into the grid's own
shadow root, since a page stylesheet cannot reach it, and use currentColor so
the rule follows the theme instead of disappearing against Pro Dark.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-18 09:20:27 -04:00
parent cdb40e7368
commit 54d49ebc75

View File

@ -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