Say what each toolbar control actually does

The toolbar was one row of buttons separated by identical hairlines, so a
column width and the version's change history read as peers. Split it: a
view zone on its own ground -- layout, the two depth controls, fit widths,
none of which reach the server -- and the forecast zone that re-loads,
opens a window, or writes.

The row depth buttons were a fixed 0 1 2 3. A number means nothing without
already knowing what group_by holds, and a fixed range offers levels a
two-field pivot does not have while hiding the fifth of one that does.
They are now built from the live group_by and named after the level they
reveal, which is what the column axis already did -- so both are drawn by
one DepthButtons component, since both are the same thing: a depth in
ViewConfig.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-19 15:17:13 -04:00
parent 9e9782419a
commit cc5de46b9e
3 changed files with 114 additions and 64 deletions

View File

@ -46,6 +46,7 @@ ui/src/
Baseline.jsx Version management, baseline workbench, reference load Baseline.jsx Version management, baseline workbench, reference load
Forecast.jsx Perspective pivot, selection handling, operation dispatch Forecast.jsx Perspective pivot, selection handling, operation dispatch
components/ components/
DepthButtons.jsx One axis's collapse control; both axes are drawn from it
LayoutMenu.jsx The Layout ▾ control — Published / Mine, with the write actions LayoutMenu.jsx The Layout ▾ control — Published / Mine, with the write actions
OperationPanel.jsx The adjustment workbench — ledger + scale/recode/clone forms OperationPanel.jsx The adjustment workbench — ledger + scale/recode/clone forms
BridgeView.jsx Baseline → current waterfall by tag (exports buildSteps/layoutSteps) BridgeView.jsx Baseline → current waterfall by tag (exports buildSteps/layoutSteps)
@ -231,11 +232,15 @@ gets written.
Both axes collapse the same way: a **depth in `ViewConfig`**, set through Both axes collapse the same way: a **depth in `ViewConfig`**, set through
`restore()`. `restore()`.
- **Rows**`group_by_depth`, driven by the `EXPAND 0 1 2 3` buttons via - **Rows**`group_by_depth`, driven by the `Rows` buttons via `applyDepth()`
`applyDepth()` - **Columns**`split_by_depth`, driven by the `Columns` buttons via
- **Columns**`split_by_depth`, driven by the `COLUMNS` buttons via
`applySplitDepth()` `applySplitDepth()`
Both sets are drawn by `components/DepthButtons.jsx` from the axis's own field
list (`groupFull` / `splitFull`), so each button is named after the level it
reveals. The row axis used to be a fixed `0 1 2 3`, which named nothing and
offered levels a two-field `group_by` does not have.
Both are **1-based**: they count the levels to show, where the imperative Both are **1-based**: they count the levels to show, where the imperative
`view.set_depth()` counts the boundary below them. `server.cpp` does `view.set_depth()` counts the boundary below them. `server.cpp` does
`ctx1->set_depth(row_pivot_depth - 1)` and `ctx1->set_depth(row_pivot_depth - 1)` and
@ -367,6 +372,22 @@ layout, since `restore()` is all-or-nothing.
--- ---
## The Forecast toolbar is two zones
Left, on its own tinted ground: **view**`Layout ▾`, the `Rows` and `Columns`
depth buttons, `Fit widths`. Nothing there reaches the server or changes the
forecast; it is all `ViewConfig` and `plugin_config` against rows already loaded.
Right: **forecast**`Refresh data`, `Change log`, `Bridge`, `Operations`. These
re-run the load, open something over the pivot, or write to the version.
They were one undifferentiated row separated by identical hairlines, which made
`Fit` and `Bridge` read as peers when one is a column width and the other is the
version's history. The split is the point; the tint is just what makes it
visible.
---
## Slice mechanics ## Slice mechanics
When the user clicks a pivot cell, `perspective-click` fires. The handler in `Forecast.jsx` extracts `[col, '==', value]` filters from `detail.config.filter` — only `role = dimension` and `role = date` columns are kept as the slice. A plain click replaces the selection; ctrl/⌘/shift-click toggles a slice in or out of it, so the panel holds a **list** of slices sent as `slices` in operation POST bodies (the single `slice` object is still accepted server-side). When the user clicks a pivot cell, `perspective-click` fires. The handler in `Forecast.jsx` extracts `[col, '==', value]` filters from `detail.config.filter` — only `role = dimension` and `role = date` columns are kept as the slice. A plain click replaces the selection; ctrl/⌘/shift-click toggles a slice in or out of it, so the panel holds a **list** of slices sent as `slices` in operation POST bodies (the single `slice` object is still accepted server-side).

View File

@ -0,0 +1,26 @@
// One axis's collapse control. Rows and columns are the same thing a depth in
// ViewConfig, counting the levels to show so they are drawn by one component
// rather than two that happen to look alike.
//
// The buttons are named after the level they reveal. The row axis used to be a
// fixed 0 1 2 3: a number means nothing without already knowing what group_by
// holds, and a fixed range offers levels that do not exist while hiding ones
// that do.
export default function DepthButtons({ label, levels, depth, onPick, totalLabel = 'Total' }) {
return (
<div className="flex items-center gap-1.5">
<span className="text-gray-400 uppercase tracking-wide" style={{fontSize:'10px'}}>{label}</span>
{Array.from({ length: levels.length + 1 }, (_, n) => (
<button key={n} onClick={() => onPick(n)}
title={n === 0
? `Collapse ${label.toLowerCase()} to a single total`
: `Show ${label.toLowerCase()} down to ${levels.slice(0, n).join(' ')}`}
className={`border rounded px-1.5 py-0.5 transition-colors max-w-[9rem] truncate
${depth === n ? 'border-blue-300 text-blue-600 bg-blue-50'
: 'border-gray-200 text-gray-500 hover:border-gray-400'}`}>
{n === 0 ? totalLabel : levels[n - 1]}
</button>
))}
</div>
)
}

View File

@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import useTheme from '../theme.jsx' import useTheme from '../theme.jsx'
import LayoutMenu from '../components/LayoutMenu.jsx' import LayoutMenu from '../components/LayoutMenu.jsx'
import DepthButtons from '../components/DepthButtons.jsx'
import useAuth from '../auth.jsx' import useAuth from '../auth.jsx'
import OperationPanel from '../components/OperationPanel.jsx' import OperationPanel from '../components/OperationPanel.jsx'
import BridgeView from '../components/BridgeView.jsx' import BridgeView from '../components/BridgeView.jsx'
@ -115,6 +116,9 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
// re-renders when either changes. // re-renders when either changes.
const [splitFull, setSplitFull] = useState([]) const [splitFull, setSplitFull] = useState([])
const [splitDepth, setSplitDepth] = useState(null) const [splitDepth, setSplitDepth] = useState(null)
// The row hierarchy, for the same reason: the depth buttons name the level
// they show rather than counting to a number nobody can map back to a field.
const [groupFull, setGroupFull] = useState([])
const [slices, setSlices] = useState([]) const [slices, setSlices] = useState([])
const [applyMode, setApplyMode] = useState('prorate') // 'prorate' | 'each' const [applyMode, setApplyMode] = useState('prorate') // 'prorate' | 'each'
@ -920,6 +924,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
// split_full is the legacy key, from when collapsing truncated split_by // split_full is the legacy key, from when collapsing truncated split_by
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_depth != null ? cfg.split_by_depth - 1 : null) cfg.split_by_depth != null ? cfg.split_by_depth - 1 : null)
setGroupFull(cfg.group_by || [])
// 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.
@ -947,6 +952,7 @@ 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) adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by, (cfg.split_by || []).length)
setGroupFull(cfg.group_by || [])
// Name what we landed on, so the menu says which layout this is and the // Name what we landed on, so the menu says which layout this is and the
// dirty dot has something to compare against. Read back rather than // dirty dot has something to compare against. Read back rather than
// reusing cfg: restore() normalises, so the live config is what a later // reusing cfg: restore() normalises, so the live config is what a later
@ -971,6 +977,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
const live = await viewer.save() const live = await viewer.save()
adoptSplit(live.split_by || [], adoptSplit(live.split_by || [],
live.split_by_depth != null ? live.split_by_depth - 1 : null) live.split_by_depth != null ? live.split_by_depth - 1 : null)
setGroupFull(live.group_by || [])
// the plugin element is replaced when the plugin changes, so re-assert // the plugin element is replaced when the plugin changes, so re-assert
hideSplitTotal() hideSplitTotal()
const cfg = await captureConfig() const cfg = await captureConfig()
@ -1515,6 +1522,7 @@ 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, adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by,
cfg.split_by_depth != null ? cfg.split_by_depth - 1 : null) cfg.split_by_depth != null ? cfg.split_by_depth - 1 : null)
setGroupFull(cfg.group_by || [])
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)
@ -1899,73 +1907,68 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
{/* Toolbar */} {/* Toolbar */}
<div className="px-3 py-1.5 border-b border-gray-200 bg-white flex items-center gap-3 shrink-0 flex-wrap text-xs"> <div className="px-3 py-1.5 border-b border-gray-200 bg-white flex items-center gap-3 shrink-0 flex-wrap text-xs">
<LayoutMenu {/* View: everything here changes how the loaded rows are displayed.
layouts={layouts} Nothing in this zone talks to the server or alters the forecast,
activeLayoutId={activeLayoutId} which is the whole reason it is fenced off from the zone on the
dirty={layoutDirty} right the two used to sit in one undifferentiated row of
onApply={applyLayout} buttons separated by identical rules, so "Fit" and "Bridge"
onSave={handleSaveOver} read as peers when one is a column width and the other opens
onSaveAs={handleSaveAs} the version's history. */}
onSetVisibility={(l, v) => patchLayout(l, { visibility: v }, <div className="flex items-center gap-2.5 flex-wrap bg-gray-50 border border-gray-100
v === 'published' ? `Published “${l.name}` : `${l.name}” is private again`)} rounded px-2 py-1">
onSetDefault={l => patchLayout(l, { is_default: true },
`${l.name}” opens this forecast`)}
onRename={(l, name) => patchLayout(l, { name }, 'Renamed')}
onDelete={deleteLayout}
onReset={resetLayout} />
<div className="w-px h-4 bg-gray-200 shrink-0" /> <LayoutMenu
layouts={layouts}
activeLayoutId={activeLayoutId}
dirty={layoutDirty}
onApply={applyLayout}
onSave={handleSaveOver}
onSaveAs={handleSaveAs}
onSetVisibility={(l, v) => patchLayout(l, { visibility: v },
v === 'published' ? `Published “${l.name}` : `${l.name}” is private again`)}
onSetDefault={l => patchLayout(l, { is_default: true },
`${l.name}” opens this forecast`)}
onRename={(l, name) => patchLayout(l, { name }, 'Renamed')}
onDelete={deleteLayout}
onReset={resetLayout} />
<button onClick={fitColumns} {/* Rows and Columns are the same control on the two axes a depth in
title="Size every column to its contents, releasing any widths pinned by dragging or carried in a saved layout" ViewConfig so they are built from one renderer and read alike.
className="border border-gray-200 rounded px-1.5 py-0.5 text-gray-500 hover:border-gray-400 The buttons name the level they reveal: "2" meant nothing without
transition-colors whitespace-nowrap"> already knowing what group_by held, and the old fixed 03 range
Fit offered levels that did not exist and hid ones that did. */}
</button> {groupFull.length > 0 && (
<>
<div className="w-px h-4 bg-gray-200 shrink-0" />
<DepthButtons label="Rows" levels={groupFull} depth={expandDepth}
onPick={applyDepth} totalLabel="Total" />
</>
)}
<div className="w-px h-4 bg-gray-200 shrink-0" /> {splitFull.length > 0 && (
<>
<div className="w-px h-4 bg-gray-200 shrink-0" />
<DepthButtons label="Columns" levels={splitFull} depth={splitDepth}
onPick={applySplitDepth} totalLabel="Total" />
</>
)}
{/* Expand group */} <div className="w-px h-4 bg-gray-200 shrink-0" />
<div className="flex items-center gap-1.5">
<span className="text-gray-400 uppercase tracking-wide" style={{fontSize:'10px'}}>Expand</span> <button onClick={fitColumns}
{[0, 1, 2, 3].map(d => ( title="Size every column to its contents, releasing any widths pinned by dragging or carried in a saved layout"
<button key={d} onClick={() => applyDepth(d)} className="border border-gray-200 rounded px-1.5 py-0.5 text-gray-500 hover:border-gray-400
className={`border rounded px-1.5 py-0.5 transition-colors transition-colors whitespace-nowrap">
${expandDepth === d ? 'border-blue-300 text-blue-600 bg-blue-50' : 'border-gray-200 text-gray-500 hover:border-gray-400'}`}> Fit widths
{d} </button>
</button>
))}
</div> </div>
{splitFull.length > 0 && ( {/* Forecast: reaches the server, or opens something over the pivot.
<> Refresh discards the current view's state and re-runs the load;
<div className="w-px h-4 bg-gray-200 shrink-0" /> Operations is where the version is actually written to. */}
<div className="flex items-center gap-1.5 flex-wrap ml-auto">
{/* Column hierarchy group — the split_by equivalent of Expand */} <button onClick={() => initViewer(versionId, sourceId, layouts)} disabled={loading || !versionId}
<div className="flex items-center gap-1.5"> title="Re-run the load from the database"
<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" />
{/* Data group */}
<div className="flex items-center gap-1.5">
<button onClick={() => initViewer(versionId, sourceId)} disabled={loading || !versionId}
className="border border-gray-200 rounded px-2 py-0.5 text-gray-500 hover:bg-gray-50 disabled:opacity-40"> className="border border-gray-200 rounded px-2 py-0.5 text-gray-500 hover:bg-gray-50 disabled:opacity-40">
{loading ? 'Loading…' : 'Refresh data'} {loading ? 'Loading…' : 'Refresh data'}
</button> </button>