diff --git a/CLAUDE.md b/CLAUDE.md index 8df147c..3644750 100644 --- a/CLAUDE.md +++ b/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 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. diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index 648586b..46fe05a 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -23,6 +23,8 @@ function cleanLayout(cfg, validCols) { 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.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.filter) c.filter = c.filter.filter(([col]) => ok(col)) return c @@ -42,6 +44,12 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio const [saveAsName, setSaveAsName] = useState('') // 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 [applyMode, setApplyMode] = useState('prorate') // 'prorate' | 'each' const [activeOp, setActiveOp] = useState('scale') @@ -167,6 +175,12 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio const tableRef = useRef(null) const colMetaRef = useRef([]) 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 modifierRef = useRef(false) // 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) setSlices([]) expandDepthRef.current = null + adoptSplit([], 0) try { const [dataResult, meta] = await Promise.all([ 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 cfg = { ...rest, plugin_config: { ...(rest.plugin_config || {}), edit_mode: 'SELECT_REGION' } } 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) } else { 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) + 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 if (viewer._pspUpdate) viewer.removeEventListener('perspective-config-update', viewer._pspUpdate) viewer._pspUpdate = async () => { 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() if (cfg) await persistLayout(vid, cfg) } 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) { const viewer = viewerRef.current if (!viewer) return @@ -704,7 +777,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio const viewer = viewerRef.current if (!viewer) return null 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) { @@ -762,6 +835,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio const cfg = cleanLayout(layout.config, validCols) cfg.plugin_config = { ...(cfg.plugin_config || {}), edit_mode: 'SELECT_REGION' } 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) setActiveLayoutId(layout.id) await persistLayout(versionId, cfg) @@ -1087,6 +1161,30 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio ))} + {splitFull.length > 0 && ( + <> +
+ + {/* Column hierarchy group — the split_by equivalent of Expand */} +