SPIKE: per-branch column collapse without forking the plugin

Answers whether Excel-style column collapse — 2025 collapsed while 2026
stays expanded — can be had from our own code, given that the engine is
the one thing @perspective-dev ships as a binary.

It can, and the engine needs no change. 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. Every
number Excel would show is already on the client. What is missing is only
the ability to not show some of them, which is a DOM concern.

So branchCollapse.js hides the leaves of a collapsed branch from a style
listener on the regular-table, shrinks the spanning header cells to the
columns still showing under them, and makes the group headers clickable.
No fork, no engine change, ~150 lines.

Verified on an 8-column grid: collapse 2025 (8 -> 6 cells), also collapse
2026 (-> 3), expand 2025 while 2026 stays collapsed (-> 5), header row and
body stay width-aligned throughout, and the state survives a redraw the
plugin initiates itself (a sort change). Clicking the real header works,
not just the API.

Where it stops working, which is the point of the spike:

The plugin virtualises the column axis by fetching a CONTIGUOUS
[start_col, end_col) window from the view and indexing everything by that
x. Hiding cells inside that window does not make it fetch more. On the
same pivot with no filter — 101 engine columns, 20 rendered — collapsing
2025 leaves 2 of 21 rendered cells visible and the rest of the viewport
empty, because 2026's columns are outside the fetched window and nothing
tells the grid to widen it. Scroll right and it looks correct again.

So this is not a rendering bug to patch; collapse punches holes in the
axis rather than compacting it. It holds while the whole axis fits in one
fetched window (~20 columns at 1500px), and degrades past that. Fixing it
properly means the visible-index -> engine-index indirection a fork would
have to add anyway.

Not merged, and not suitable to ship as-is. Kept as the evidence for
choosing between forking viewer-datagrid and raising it upstream, where
nothing is currently in flight: zero open PRs, no branch, and no issue
asking for it, though split_rollup_mode itself landed only in PR #3211 on
2026-08-10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoxNi8cFsQLPUSw3obb5NH
This commit is contained in:
Paul Trowbridge 2026-09-12 09:40:20 -04:00
parent 4b9296abc1
commit d2e706b483
2 changed files with 163 additions and 0 deletions

153
ui/src/branchCollapse.js Normal file
View File

@ -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) },
}
}

View File

@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import useTheme from '../theme.jsx' import useTheme from '../theme.jsx'
import { attachBranchCollapse } from '../branchCollapse.js' // SPIKE
import OperationPanel from '../components/OperationPanel.jsx' import OperationPanel from '../components/OperationPanel.jsx'
import BridgeView from '../components/BridgeView.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 // set while our own restore is in flight, so the config-update listener can tell
// a collapse from the user rearranging split_by themselves // a collapse from the user rearranging split_by themselves
const collapsingRef = useRef(false) const collapsingRef = useRef(false)
const branchRef = useRef(null) // SPIKE: per-branch column collapse
const initIdRef = useRef(0) const initIdRef = useRef(0)
const modifierRef = useRef(false) const modifierRef = useRef(false)
// the datagrid plugin element, for reading cell coordinates and driving its // 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) viewer.addEventListener('perspective-select', viewer._pspSelect)
gridRef.current = await viewer.getPlugin() 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) setLargeDataset(false)
} catch (err) { } catch (err) {