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:
parent
4b9296abc1
commit
d2e706b483
153
ui/src/branchCollapse.js
Normal file
153
ui/src/branchCollapse.js
Normal 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) },
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user