Compare commits
2 Commits
4b9296abc1
...
42d9d51f63
| Author | SHA1 | Date | |
|---|---|---|---|
| 42d9d51f63 | |||
| d2e706b483 |
178
ui/src/branchCollapse.js
Normal file
178
ui/src/branchCollapse.js
Normal 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) },
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user