Collapse the column axis with split_by_depth, like the row axis

Column collapse did not survive flipping away from the browser and back,
because it was the one piece of state still held outside the config:
applySplitDepth restored a *truncated* split_by, and a rebuilt view took its
split_by from the config while the levels it had dropped lived only in
splitFull.

split_by_depth is the config-level equivalent, and it works now that
apply_update applies it -- server.cpp does
ctx2->set_depth(HEADER_COLUMN, column_pivot_depth - 1), so it is 1-based
exactly like group_by_depth.

Everything that existed to support truncation goes with it:

- splitFull no longer has to outlive split_by, because split_by keeps every
  level. It is just the live axis, for rendering the buttons.
- split_full is no longer persisted beside the config. Still read on load,
  for layouts saved by the old scheme.
- collapsingRef and the prefix test are gone. They existed to tell our own
  collapse from the user rearranging the pivot, which only mattered because
  a collapse looked like a shorter split_by.
- The selection survives a collapse. It was cleared because slices name the
  split_by dimensions they were cut from and the axis was changing shape;
  with a depth those dimensions are all still there.

CLAUDE.md's §Column hierarchy describes the old mechanism throughout, and is
now wrong; updating it separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-17 12:13:51 -04:00
parent 251692a3f9
commit 607dac221d

View File

@ -192,9 +192,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
// The column axis has no set_depth() collapsing it means restoring a shorter // 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. // split_by, so the full hierarchy has to be remembered separately to expand again.
const splitFullRef = useRef([]) 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 // 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
@ -670,9 +668,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 // split_full is the legacy key, from when collapsing truncated split_by
// the levels it was collapsed from adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by,
adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by, (cfg.split_by || []).length) cfg.split_by_depth != null ? cfg.split_by_depth - 1 : null)
// restore() has already applied group_by_depth; this only syncs the // restore() has already applied group_by_depth; this only syncs the
// toolbar. expand_depth is the legacy key, from when depth was imperative // toolbar. expand_depth is the legacy key, from when depth was imperative
// and had to be stored beside the config rather than in it. // and had to be stored beside the config rather than in it.
@ -701,22 +699,12 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
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 // split_by is never truncated now, so the live value is always the real
// it redefines the hierarchy. Ours is a collapse, and must not overwrite it. // hierarchy and can be adopted unconditionally -- no need to tell our own
if (!collapsingRef.current) { // collapse apart from the user rearranging the pivot.
const live = await viewer.save() const live = await viewer.save()
const next = live.split_by || [] adoptSplit(live.split_by || [],
const full = splitFullRef.current || [] live.split_by_depth != null ? live.split_by_depth - 1 : null)
// This event fires for anything in the config -- opening the settings
// sidebar included -- not only for split_by. While collapsed, the live
// split_by is a prefix of the full hierarchy, and adopting it would
// record the truncation as the hierarchy and make the deeper levels
// unreachable. A genuine rearrangement is not a prefix of what we hold.
const isCollapsedPrefix =
next.length < full.length && next.every((c, i) => c === full[i])
if (!isCollapsedPrefix) adoptSplit(next, next.length)
// else: our own collapse, so the full hierarchy stands
}
const cfg = await captureConfig() const cfg = await captureConfig()
if (cfg) await persistLayout(vid, cfg) if (cfg) await persistLayout(vid, cfg)
} catch {} } catch {}
@ -771,46 +759,51 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
// Record the column hierarchy as the uncollapsed truth. Called whenever a layout // 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 // 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. // collapse, which would otherwise overwrite the full list with the short one.
function adoptSplit(full, depth) { // The column axis as it stands, for rendering the collapse buttons. split_by is
const list = Array.isArray(full) ? full : [] // no longer truncated to collapse, so it *is* the hierarchy -- nothing has to be
splitFullRef.current = list // remembered alongside it.
setSplitFull(list) function adoptSplit(list, depth) {
setSplitDepth(depth == null ? list.length : Math.min(depth, list.length)) const cols = Array.isArray(list) ? list : []
splitFullRef.current = cols
setSplitFull(cols)
setSplitDepth(depth == null ? cols.length : Math.min(depth, cols.length))
} }
// Collapse or expand the column axis to `n` split_by levels. // 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 // Both axes are now the same shape: a depth in ViewConfig. This used to restore
// once and view.set_depth() hides the deeper ones. The column axis has no // a *truncated* split_by instead, because split_by_depth was accepted and then
// equivalent there is no split_by_depth in ViewConfig and expand()/collapse() // dropped by ViewConfig::apply_update -- see ui/vendor's patch. Truncating had
// take a row index so collapsing means restoring a truncated split_by, which // to be undone to expand again, which is why the full hierarchy needed
// rebuilds the view. Two consequences fall out of that: the row depth has to be // remembering separately (splitFull, persisted as split_full), why our own
// re-applied afterwards (it lives on the discarded view), and it is whole-axis, // collapse had to be told apart from the user rearranging the pivot
// not per-branch every column group collapses to the same level together. // (collapsingRef and the prefix test), and why the selection was cleared each
// time the axis changed shape.
//
// None of that is needed for a depth. split_by keeps every level, so the
// hierarchy is simply `cfg.split_by`; the depth rides along in saved layouts and
// survives a view rebuild; and the dimensions a slice was cut from are still
// there, so the selection can stay.
async function applySplitDepth(n) { async function applySplitDepth(n) {
const viewer = viewerRef.current const viewer = viewerRef.current
const full = splitFullRef.current if (!viewer) return
if (!viewer || !full.length) return const { table: _t, ...cfg } = await viewer.save()
const depth = Math.max(0, Math.min(n, full.length)) const levels = (cfg.split_by || []).length
collapsingRef.current = true if (!levels) return
const depth = Math.max(0, Math.min(n, levels))
try { try {
await viewer.restore({ split_by: full.slice(0, depth) }) // 1-based, as with group_by_depth: server.cpp does
// ctx2->set_depth(HEADER_COLUMN, column_pivot_depth - 1)
await viewer.restore({ ...cfg, split_by_depth: depth + 1 })
setSplitDepth(depth) setSplitDepth(depth)
// No need to re-apply the row depth: group_by_depth is part of the config the
// view is rebuilt from, so it comes back with it.
} catch (err) { } catch (err) {
console.error('[applySplitDepth]', err) console.error('[applySplitDepth]', err)
flash(err.message || String(err), 'error') flash(err.message || String(err), 'error')
return 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 { try {
const cfg = await captureConfig() const cfg2 = await captureConfig()
if (cfg) await persistLayout(versionId, cfg) if (cfg2) await persistLayout(versionId, cfg2)
} catch (err) { } catch (err) {
console.error('[applySplitDepth persist]', err) console.error('[applySplitDepth persist]', err)
} }
@ -871,10 +864,10 @@ 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()
// group_by_depth is already in cfg, straight from save(). Only split_full is // Both depths are already in cfg, straight from save(). Nothing of ours needs
// ours: it outlives split_by, so a layout saved while collapsed still knows // to travel beside it any more -- split_full existed only because collapsing
// the levels it was collapsed from. // truncated split_by and the discarded levels had to be remembered.
return { ...cfg, split_full: splitFullRef.current } return cfg
} }
async function persistLayout(vid, cfg) { async function persistLayout(vid, cfg) {
@ -932,7 +925,8 @@ 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) adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by,
cfg.split_by_depth != null ? cfg.split_by_depth - 1 : null)
if (cfg.group_by_depth != null) setExpandDepth(cfg.group_by_depth - 1) if (cfg.group_by_depth != null) setExpandDepth(cfg.group_by_depth - 1)
else if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth) else if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth)
setActiveLayoutId(layout.id) setActiveLayoutId(layout.id)