Compare commits

..

2 Commits

Author SHA1 Message Date
42d9d51f63 SPIKE fix: stop corrupting group header colspans
Reported symptom: with split_rollup_mode on, columns looked shuffled and
some month columns had no year header above them. That was this spike, not
Perspective.

Two mistakes, either enough on its own:

- The original span was stashed on the cell as `_pfFullSpan` the first time
  it was seen. regular-table recycles header cells between draws, so the
  stashed value reappeared on an unrelated group and truncated it.
- The work was guarded on `colSpan > 1`, so once a cell had been set to 1 it
  could never grow back.

Both fired even with nothing collapsed, which is why merely turning rollup
on was enough to break the header.

Now the span is derived from the column paths every draw, and colSpan is
only touched while something is actually collapsed — with nothing hidden,
regular-table's own merge of adjacent equal header values is the right
answer and is left alone.

Measured on Reason x (Year, Month), 18 leaf columns, across first draw,
sort, sort-cleared and horizontal scroll:

  before fix   2025 spans 3,  group row covers  8/18  (10 orphaned)
  after fix    2025 spans 13, group row covers 18/18
  no spike     2025 spans 13, group row covers 18/18

Collapse behaviour is unchanged: 8 -> 6 -> 3 -> 5 cells across collapse
2025, collapse 2026, expand 2025, with header and body width-aligned
throughout and the state surviving a sort.

Not fixed, because it is not a bug: rollup emits totals BEFORE their
members, so the grand total sits far left and each year's subtotal precedes
its own months. SplitRollupMode documents this as "totals before" order and
offers only flat|rollup, so it cannot be moved to Excel's convention.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoxNi8cFsQLPUSw3obb5NH
2026-09-12 12:11:25 -04:00
d2e706b483 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
2026-09-12 09:40:20 -04:00
2 changed files with 188 additions and 0 deletions

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

@ -0,0 +1,178 @@
// 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
}
// How many consecutive columns from x share this cell's first chy+1 levels —
// the span the group header would have if nothing were hidden.
//
// This has to be derived from the column paths on every draw. The obvious
// shortcut, stashing the original span on the cell the first time we see it, is
// wrong: regular-table recycles header cells between draws, so the stashed value
// reappears on an unrelated group and silently truncates it.
function fullSpan(columnPaths, x, chy) {
const levels = levelsOf(columnPaths[x])
if (levels.length < chy + 1) return 1 // a rollup placeholder covers only itself
const prefix = levels.slice(0, chy + 1)
let n = 0
for (let i = x; i < columnPaths.length; i++) {
const l = levelsOf(columnPaths[i])
if (l.length < prefix.length || !prefix.every((v, k) => l[k] === v)) break
n++
}
return n
}
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)
const columnPaths = paths()
for (const cell of rt.querySelectorAll('tbody td, tbody th, thead th')) {
const meta = rt.getMeta(cell)
if (!meta) continue
// A group header stays visible but must shrink to the columns still
// showing under it, or the header row and the body stop lining up.
//
// Only while something is actually collapsed. With nothing hidden this
// must not touch colSpan at all — regular-table computes it each draw by
// merging adjacent equal header values, and that answer is the right one.
if (hidden.size && meta.type === 'column_header' && meta.x != null) {
const chy = meta.column_header_y ?? 0
if (chy < len) {
const full = fullSpan(columnPaths, meta.x, chy)
let visible = 0
for (let i = meta.x; i < meta.x + full; i++) if (!hidden.has(i)) visible++
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(columnPaths, 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 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) {