diff --git a/ui/src/branchCollapse.js b/ui/src/branchCollapse.js new file mode 100644 index 0000000..02159af --- /dev/null +++ b/ui/src/branchCollapse.js @@ -0,0 +1,153 @@ +// SPIKE — per-branch column collapse without forking the datagrid plugin. +// +// The question this answers: Excel can collapse 2025 while 2026 stays expanded. +// Perspective cannot, because the column axis has no expand/collapse of any kind +// (see the "Column hierarchy" section of CLAUDE.md). But under +// `split_rollup_mode: 'rollup'` the engine already emits the whole column tree in +// pre-order — grand total, then each branch's subtotal immediately followed by its +// own leaves — so every number Excel would show is already on the client. What is +// missing is only the ability to *not show* some of them. +// +// So this hides the leaves of a collapsed branch in the DOM, from a style listener, +// and shrinks the spanning header cells to match. No fork, no engine change. +// +// What it cannot fix, and what the spike is really measuring: the plugin still +// fetches and lays out the hidden columns. Its horizontal virtualisation asks the +// view for a contiguous [start_col, end_col) window and indexes everything by that +// x, so a hidden column still costs a fetch and still occupies an index. At small +// column counts that is invisible; the open question is where it stops being so. + +const ZERO_WIDTH = /^\u200b*$/ + +// The split_by levels a column actually belongs to, dropping the measure name. +// 'Amount' -> [], '2025|Amount' -> ['2025'], '2025|AI|Amount' -> ['2025','AI'] +export function levelsOf(columnPath) { + if (!columnPath) return [] + return columnPath.split('|').slice(0, -1) +} + +// Same, from a cell's column_header, where rolled-up levels arrive as runs of +// zero-width spaces rather than being absent. +export function levelsOfHeader(columnHeader, splitLen) { + const out = [] + for (const level of (columnHeader || []).slice(0, splitLen)) { + if (level == null || ZERO_WIDTH.test(level)) break + out.push(level) + } + return out +} + +function isDescendant(levels, prefix) { + return levels.length > prefix.length && prefix.every((v, i) => levels[i] === v) +} + +// Engine column indices to hide: the strict descendants of any collapsed branch. +// The branch's own subtotal column survives — that is what you are collapsing to. +export function hiddenColumns(columnPaths, collapsed) { + const hidden = new Set() + if (!collapsed.size) return hidden + const prefixes = [...collapsed].map(c => c.split('|')) + columnPaths.forEach((path, x) => { + if (!path) return + const levels = levelsOf(path) + if (prefixes.some(p => isDescendant(levels, p))) hidden.add(x) + }) + return hidden +} + +// The branch a header cell at column x, header row `chy`, would collapse: the +// first chy+1 levels of its column path. Null when that is not a collapsible +// group — the grand total, or the deepest level, which has no leaves to hide. +export function branchAt(columnPaths, x, chy, splitLen) { + const levels = levelsOf(columnPaths[x]) + if (levels.length < chy + 1) return null + if (chy + 1 >= splitLen) return null + return levels.slice(0, chy + 1).join('|') +} + +/** + * Attach per-branch collapse to a datagrid plugin element. + * Returns { toggle, collapsed, clear, detach } — `onChange` fires after a toggle + * so the host can persist or re-render. + */ +export function attachBranchCollapse(grid, { onChange } = {}) { + const collapsed = new Set() + const rt = grid.regular_table + + const splitLen = () => grid.model?._config?.split_by?.length || 0 + const paths = () => grid.model?._column_paths || [] + + function styleListener() { + const len = splitLen() + if (!len) return + const hidden = hiddenColumns(paths(), collapsed) + + for (const cell of rt.querySelectorAll('tbody td, tbody th, thead th')) { + const meta = rt.getMeta(cell) + if (!meta) continue + + // A spanning group header covers [x, x + colSpan); it stays visible but + // must shrink to the columns still showing under it, or the header row + // and the body stop lining up. + const span = cell.colSpan || 1 + if (meta.type === 'column_header' && span > 1 && meta.x != null) { + let visible = 0 + for (let i = meta.x; i < meta.x + (cell._pfFullSpan || span); i++) { + if (!hidden.has(i)) visible++ + } + cell._pfFullSpan = cell._pfFullSpan || span + cell.colSpan = Math.max(1, visible) + } + + const hide = meta.x != null && hidden.has(meta.x) + cell.style.display = hide ? 'none' : '' + + // mark what can be clicked to collapse, and which way it would go + if (meta.type === 'column_header' && meta.x != null && !hide) { + const branch = branchAt(paths(), meta.x, meta.column_header_y ?? 0, len) + if (branch) { + cell.dataset.pfBranch = branch + cell.style.cursor = 'pointer' + cell.title = collapsed.has(branch) + ? `Expand ${branch}` + : `Collapse ${branch} to its subtotal` + } else { + delete cell.dataset.pfBranch + cell.style.cursor = '' + } + } + } + } + + function onClick(event) { + const target = (event.composedPath?.()[0]) || event.target + const th = target?.closest?.('th[data-pf-branch]') + if (!th) return + event.stopPropagation() // the plugin would otherwise sort or open the menu + event.preventDefault() + toggle(th.dataset.pfBranch) + } + + function toggle(branch) { + if (collapsed.has(branch)) collapsed.delete(branch) + else collapsed.add(branch) + rt.draw({ preserve_width: true })?.catch?.(() => {}) + onChange?.(new Set(collapsed)) + } + + function clear() { + collapsed.clear() + rt.draw({ preserve_width: true })?.catch?.(() => {}) + onChange?.(new Set(collapsed)) + } + + rt.addStyleListener(styleListener) + rt.addEventListener('click', onClick, true) + + return { + toggle, + clear, + collapsed: () => new Set(collapsed), + detach() { rt.removeEventListener('click', onClick, true) }, + } +} diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index 46fe05a..af1708b 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef } from 'react' import useTheme from '../theme.jsx' +import { attachBranchCollapse } from '../branchCollapse.js' // SPIKE import OperationPanel from '../components/OperationPanel.jsx' import BridgeView from '../components/BridgeView.jsx' @@ -181,6 +182,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio // set while our own restore is in flight, so the config-update listener can tell // a collapse from the user rearranging split_by themselves const collapsingRef = useRef(false) + const branchRef = useRef(null) // SPIKE: per-branch column collapse const initIdRef = useRef(0) const modifierRef = useRef(false) // the datagrid plugin element, for reading cell coordinates and driving its @@ -705,6 +707,14 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio viewer.addEventListener('perspective-select', viewer._pspSelect) gridRef.current = await viewer.getPlugin() + + // SPIKE: per-branch column collapse. Exposed on window so the spike can be + // driven without building UI for it yet. + try { + branchRef.current?.detach() + branchRef.current = attachBranchCollapse(gridRef.current) + window.__pfBranch = branchRef.current + } catch (err) { console.error('[branchCollapse]', err) } setLargeDataset(false) } catch (err) {