Collapse the column hierarchy from the toolbar
The row axis has had Expand 0/1/2/3 since the pivot landed; the column axis had nothing. With a year over month split there was no way to step back to whole years short of dragging split_by apart in the settings panel and putting it back afterwards. Perspective gives the two axes nothing in common here. Rows collapse because the GROUP BY ROLLUP view holds every level at once and view.set_depth() hides the deeper ones. For columns there is no equivalent: expand()/collapse() take a row index, ViewConfig has group_by_depth but no split_by_depth, and split_rollup_mode only chooses whether subtotal column groups are emitted — a view shape, not an interaction. So applySplitDepth() collapses by restoring a truncated split_by, which rebuilds the view. Three consequences of that rebuild, each handled: - Once collapsed, viewer.save() only reports the short split_by, so the full hierarchy is held separately (splitFullRef) and persisted into the layout as split_full. Without it, collapsing would be a one-way door: reload while collapsed and the deeper levels are gone. adoptSplit() is the single place it is set. - perspective-config-update fires for our own restore as well as for the user rearranging the pivot, and the two mean opposite things — one must adopt the new hierarchy, the other must not. collapsingRef separates them. - Row depth lives on the discarded view, so it is re-applied afterwards. The selection is cleared on each change: slices name the split_by dimensions they were cut from, and the highlight is keyed on grid coordinates. Neither survives a column axis that just changed shape. Buttons are named for the level they show — Total, then one per split_by column — rather than numbered like Expand, since the levels are named and a number would say nothing about what you are collapsing to. Whole-axis, not per-branch: Excel can collapse 2025 while 2026 stays expanded, and this cannot. `columns` selects which measures appear, not individual split combinations, so there is no way to hide one branch's leaves while keeping another's. Verified in the browser against cash/test with split_by Year x Reason: Reason -> Total -> Year -> Reason all render the expected column sets and the right button highlights; and a reload while collapsed to Year comes back collapsed with Reason still offered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoxNi8cFsQLPUSw3obb5NH
This commit is contained in:
parent
a0d39d44c0
commit
4b9296abc1
37
CLAUDE.md
37
CLAUDE.md
@ -95,6 +95,43 @@ Turning a region back into slices re-derives, per cell, the same filters Perspec
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Column hierarchy (collapse / expand)
|
||||||
|
|
||||||
|
The two pivot axes collapse by completely different mechanisms, and the asymmetry is a
|
||||||
|
Perspective constraint, not a choice:
|
||||||
|
|
||||||
|
- **Rows.** The `GROUP BY ROLLUP` view holds every level at once; `view.set_depth()` — which
|
||||||
|
lives on the view, not the config — hides the deeper ones. That is what the `EXPAND 0 1 2 3`
|
||||||
|
buttons drive, via `applyDepth()`.
|
||||||
|
- **Columns.** There is no equivalent. `expand()` / `collapse()` take a **row index**,
|
||||||
|
`ViewConfig` has `group_by_depth` but no `split_by_depth`, and `split_rollup_mode`
|
||||||
|
(`'flat' | 'rollup'`) only chooses whether subtotal column groups are *emitted* — it is a
|
||||||
|
view shape, not an interaction. So `applySplitDepth(n)` collapses by restoring a
|
||||||
|
**truncated `split_by`**, which rebuilds the view.
|
||||||
|
|
||||||
|
Three things follow from the rebuild, and each is handled:
|
||||||
|
|
||||||
|
1. The full hierarchy has to be remembered separately — once collapsed, `viewer.save()`
|
||||||
|
only reports the short `split_by`. `splitFullRef` / `splitFull` hold it, and it is
|
||||||
|
persisted into the saved layout as `split_full` so a reload while collapsed can still
|
||||||
|
expand back. `adoptSplit()` is the single place it is set.
|
||||||
|
2. `perspective-config-update` fires for our own restore as well as the user rearranging
|
||||||
|
the pivot. `collapsingRef` distinguishes them — without it, a collapse would overwrite
|
||||||
|
the full hierarchy with the truncated one and the deeper levels would be unreachable.
|
||||||
|
3. Row depth lives on the discarded view, so `applyDepth(expandDepthRef.current)` is
|
||||||
|
re-applied afterwards — the same wart as the refocus re-apply.
|
||||||
|
|
||||||
|
The selection is cleared on every change: slices name the split_by dimensions they were
|
||||||
|
cut from, and the highlight is keyed on grid coordinates. Neither survives a column axis
|
||||||
|
that just changed shape.
|
||||||
|
|
||||||
|
**Limitation:** this is whole-axis, not per-branch. Excel can collapse 2025 while 2026
|
||||||
|
stays expanded; truncating `split_by` collapses every column group at that level together.
|
||||||
|
Per-branch is not reachable — `columns` selects which *measures* appear, not individual
|
||||||
|
split combinations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Operation SQL patterns
|
## Operation SQL patterns
|
||||||
|
|
||||||
All three operations follow the same structure: insert a `pf.log` row in a CTE, then insert forecast rows referencing its id. `{{where_clause}}` is built from the slice; `{{exclude_clause}}` blocks `exclude_iters` rows.
|
All three operations follow the same structure: insert a `pf.log` row in a CTE, then insert forecast rows referencing its id. `{{where_clause}}` is built from the slice; `{{exclude_clause}}` blocks `exclude_iters` rows.
|
||||||
|
|||||||
@ -23,6 +23,8 @@ function cleanLayout(cfg, validCols) {
|
|||||||
if (c.columns) c.columns = c.columns.filter(col => col == null || ok(col))
|
if (c.columns) c.columns = c.columns.filter(col => col == null || ok(col))
|
||||||
if (c.group_by) c.group_by = c.group_by.filter(ok)
|
if (c.group_by) c.group_by = c.group_by.filter(ok)
|
||||||
if (c.split_by) c.split_by = c.split_by.filter(ok)
|
if (c.split_by) c.split_by = c.split_by.filter(ok)
|
||||||
|
// the uncollapsed column hierarchy travels with the layout (see applySplitDepth)
|
||||||
|
if (c.split_full) c.split_full = c.split_full.filter(ok)
|
||||||
if (c.sort) c.sort = c.sort.filter(([col]) => ok(col))
|
if (c.sort) c.sort = c.sort.filter(([col]) => ok(col))
|
||||||
if (c.filter) c.filter = c.filter.filter(([col]) => ok(col))
|
if (c.filter) c.filter = c.filter.filter(([col]) => ok(col))
|
||||||
return c
|
return c
|
||||||
@ -42,6 +44,12 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
const [saveAsName, setSaveAsName] = useState('')
|
const [saveAsName, setSaveAsName] = useState('')
|
||||||
|
|
||||||
// operation panel — a selection is a LIST of slices; one entry is the common case
|
// operation panel — a selection is a LIST of slices; one entry is the common case
|
||||||
|
// The column hierarchy and how many of its levels are showing. Mirrored into
|
||||||
|
// splitFullRef for handlers registered once; held as state so the toolbar
|
||||||
|
// re-renders when either changes.
|
||||||
|
const [splitFull, setSplitFull] = useState([])
|
||||||
|
const [splitDepth, setSplitDepth] = useState(null)
|
||||||
|
|
||||||
const [slices, setSlices] = useState([])
|
const [slices, setSlices] = useState([])
|
||||||
const [applyMode, setApplyMode] = useState('prorate') // 'prorate' | 'each'
|
const [applyMode, setApplyMode] = useState('prorate') // 'prorate' | 'each'
|
||||||
const [activeOp, setActiveOp] = useState('scale')
|
const [activeOp, setActiveOp] = useState('scale')
|
||||||
@ -167,6 +175,12 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
const tableRef = useRef(null)
|
const tableRef = useRef(null)
|
||||||
const colMetaRef = useRef([])
|
const colMetaRef = useRef([])
|
||||||
const expandDepthRef = useRef(null)
|
const expandDepthRef = useRef(null)
|
||||||
|
// The column axis has no set_depth() — collapsing it means restoring a shorter
|
||||||
|
// split_by, so the full hierarchy has to be remembered separately to expand again.
|
||||||
|
const splitFullRef = useRef([])
|
||||||
|
// 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 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
|
||||||
@ -536,6 +550,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
setLoadProgress(null)
|
setLoadProgress(null)
|
||||||
setSlices([])
|
setSlices([])
|
||||||
expandDepthRef.current = null
|
expandDepthRef.current = null
|
||||||
|
adoptSplit([], 0)
|
||||||
try {
|
try {
|
||||||
const [dataResult, meta] = await Promise.all([
|
const [dataResult, meta] = await Promise.all([
|
||||||
fetch(`/api/versions/${vid}/data`).then(async r => {
|
fetch(`/api/versions/${vid}/data`).then(async r => {
|
||||||
@ -616,6 +631,9 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
const { table: _t, ...rest } = cleanLayout(JSON.parse(saved), validCols)
|
const { table: _t, ...rest } = cleanLayout(JSON.parse(saved), validCols)
|
||||||
const cfg = { ...rest, plugin_config: { ...(rest.plugin_config || {}), edit_mode: 'SELECT_REGION' } }
|
const cfg = { ...rest, plugin_config: { ...(rest.plugin_config || {}), edit_mode: 'SELECT_REGION' } }
|
||||||
await viewer.restore(cfg)
|
await viewer.restore(cfg)
|
||||||
|
// split_full outlives split_by: a layout saved while collapsed still knows
|
||||||
|
// the levels it was collapsed from
|
||||||
|
adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by, (cfg.split_by || []).length)
|
||||||
if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth)
|
if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth)
|
||||||
} else {
|
} else {
|
||||||
const sourceDefault = sources.find(s => String(s.id) === String(sid))?.default_layout
|
const sourceDefault = sources.find(s => String(s.id) === String(sid))?.default_layout
|
||||||
@ -633,12 +651,19 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
await viewer.restore(cfg)
|
await viewer.restore(cfg)
|
||||||
|
adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by, (cfg.split_by || []).length)
|
||||||
}
|
}
|
||||||
|
|
||||||
// auto-persist viewer state (formatting, columns, etc.) to the last-used cache
|
// auto-persist viewer state (formatting, columns, etc.) to the last-used cache
|
||||||
if (viewer._pspUpdate) viewer.removeEventListener('perspective-config-update', viewer._pspUpdate)
|
if (viewer._pspUpdate) viewer.removeEventListener('perspective-config-update', viewer._pspUpdate)
|
||||||
viewer._pspUpdate = async () => {
|
viewer._pspUpdate = async () => {
|
||||||
try {
|
try {
|
||||||
|
// A split_by change that is not ours is the user rearranging the pivot, and
|
||||||
|
// it redefines the hierarchy. Ours is a collapse, and must not overwrite it.
|
||||||
|
if (!collapsingRef.current) {
|
||||||
|
const live = await viewer.save()
|
||||||
|
adoptSplit(live.split_by || [], (live.split_by || []).length)
|
||||||
|
}
|
||||||
const cfg = await captureConfig()
|
const cfg = await captureConfig()
|
||||||
if (cfg) await persistLayout(vid, cfg)
|
if (cfg) await persistLayout(vid, cfg)
|
||||||
} catch {}
|
} catch {}
|
||||||
@ -690,6 +715,54 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record the column hierarchy as the uncollapsed truth. Called whenever a layout
|
||||||
|
// arrives or the user rearranges split_by themselves — but never for our own
|
||||||
|
// collapse, which would otherwise overwrite the full list with the short one.
|
||||||
|
function adoptSplit(full, depth) {
|
||||||
|
const list = Array.isArray(full) ? full : []
|
||||||
|
splitFullRef.current = list
|
||||||
|
setSplitFull(list)
|
||||||
|
setSplitDepth(depth == null ? list.length : Math.min(depth, list.length))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapse or expand the column axis to `n` split_by levels.
|
||||||
|
//
|
||||||
|
// The row axis gets this for free: its GROUP BY ROLLUP view holds every level at
|
||||||
|
// once and view.set_depth() hides the deeper ones. The column axis has no
|
||||||
|
// equivalent — there is no split_by_depth in ViewConfig and expand()/collapse()
|
||||||
|
// take a row index — so collapsing means restoring a truncated split_by, which
|
||||||
|
// rebuilds the view. Two consequences fall out of that: the row depth has to be
|
||||||
|
// re-applied afterwards (it lives on the discarded view), and it is whole-axis,
|
||||||
|
// not per-branch — every column group collapses to the same level together.
|
||||||
|
async function applySplitDepth(n) {
|
||||||
|
const viewer = viewerRef.current
|
||||||
|
const full = splitFullRef.current
|
||||||
|
if (!viewer || !full.length) return
|
||||||
|
const depth = Math.max(0, Math.min(n, full.length))
|
||||||
|
collapsingRef.current = true
|
||||||
|
try {
|
||||||
|
await viewer.restore({ split_by: full.slice(0, depth) })
|
||||||
|
setSplitDepth(depth)
|
||||||
|
// restore() rebuilt the view, so the row depth that lived on the old one is gone
|
||||||
|
if (expandDepthRef.current != null) await applyDepth(expandDepthRef.current)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[applySplitDepth]', err)
|
||||||
|
flash(err.message || String(err), 'error')
|
||||||
|
return
|
||||||
|
} finally {
|
||||||
|
collapsingRef.current = false
|
||||||
|
}
|
||||||
|
// a slice names the split_by dimensions it was cut from, and the highlight is
|
||||||
|
// keyed on grid coordinates — neither survives a column axis that just changed
|
||||||
|
setSlices([])
|
||||||
|
try {
|
||||||
|
const cfg = await captureConfig()
|
||||||
|
if (cfg) await persistLayout(versionId, cfg)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[applySplitDepth persist]', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function applyDepth(d) {
|
async function applyDepth(d) {
|
||||||
const viewer = viewerRef.current
|
const viewer = viewerRef.current
|
||||||
if (!viewer) return
|
if (!viewer) return
|
||||||
@ -704,7 +777,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
const viewer = viewerRef.current
|
const viewer = viewerRef.current
|
||||||
if (!viewer) return null
|
if (!viewer) return null
|
||||||
const cfg = await viewer.save()
|
const cfg = await viewer.save()
|
||||||
return { ...cfg, expand_depth: expandDepthRef.current }
|
return { ...cfg, expand_depth: expandDepthRef.current, split_full: splitFullRef.current }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function persistLayout(vid, cfg) {
|
async function persistLayout(vid, cfg) {
|
||||||
@ -762,6 +835,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
const cfg = cleanLayout(layout.config, validCols)
|
const cfg = cleanLayout(layout.config, validCols)
|
||||||
cfg.plugin_config = { ...(cfg.plugin_config || {}), edit_mode: 'SELECT_REGION' }
|
cfg.plugin_config = { ...(cfg.plugin_config || {}), edit_mode: 'SELECT_REGION' }
|
||||||
await viewer.restore(cfg)
|
await viewer.restore(cfg)
|
||||||
|
adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by, (cfg.split_by || []).length)
|
||||||
if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth)
|
if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth)
|
||||||
setActiveLayoutId(layout.id)
|
setActiveLayoutId(layout.id)
|
||||||
await persistLayout(versionId, cfg)
|
await persistLayout(versionId, cfg)
|
||||||
@ -1087,6 +1161,30 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{splitFull.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="w-px h-4 bg-gray-200 shrink-0" />
|
||||||
|
|
||||||
|
{/* Column hierarchy group — the split_by equivalent of Expand */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-gray-400 uppercase tracking-wide" style={{fontSize:'10px'}}>Columns</span>
|
||||||
|
{Array.from({ length: splitFull.length + 1 }, (_, n) => {
|
||||||
|
const label = n === 0 ? 'Total' : splitFull[n - 1]
|
||||||
|
return (
|
||||||
|
<button key={n} onClick={() => applySplitDepth(n)}
|
||||||
|
title={n === 0
|
||||||
|
? 'Collapse the columns to a single total'
|
||||||
|
: `Show columns down to ${splitFull.slice(0, n).join(' › ')}`}
|
||||||
|
className={`border rounded px-1.5 py-0.5 transition-colors max-w-[9rem] truncate
|
||||||
|
${splitDepth === n ? 'border-blue-300 text-blue-600 bg-blue-50' : 'border-gray-200 text-gray-500 hover:border-gray-400'}`}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="w-px h-4 bg-gray-200 shrink-0" />
|
<div className="w-px h-4 bg-gray-200 shrink-0" />
|
||||||
|
|
||||||
{/* Data group */}
|
{/* Data group */}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user