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
Forecast.jsx Perspective pivot, selection handling, operation dispatch
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
OperationPanel.jsx The adjustment workbench — ledger + scale/recode/clone forms
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
`restore()`.
- **Rows**`group_by_depth`, driven by the `EXPAND 0 1 2 3` buttons via
`applyDepth()`
- **Columns**`split_by_depth`, driven by the `COLUMNS` buttons via
- **Rows**`group_by_depth`, driven by the `Rows` buttons via `applyDepth()`
- **Columns**`split_by_depth`, driven by the `Columns` buttons via
`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
`view.set_depth()` counts the boundary below them. `server.cpp` does
`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
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 useTheme from '../theme.jsx'
import LayoutMenu from '../components/LayoutMenu.jsx'
import DepthButtons from '../components/DepthButtons.jsx'
import useAuth from '../auth.jsx'
import OperationPanel from '../components/OperationPanel.jsx'
import BridgeView from '../components/BridgeView.jsx'
@ -115,6 +116,9 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
// re-renders when either changes.
const [splitFull, setSplitFull] = useState([])
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 [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
adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by,
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
// toolbar. expand_depth is the legacy key, from when depth was imperative
// 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)
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
// dirty dot has something to compare against. Read back rather than
// 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()
adoptSplit(live.split_by || [],
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
hideSplitTotal()
const cfg = await captureConfig()
@ -1515,6 +1522,7 @@ 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_depth != null ? cfg.split_by_depth - 1 : null)
setGroupFull(cfg.group_by || [])
if (cfg.group_by_depth != null) setExpandDepth(cfg.group_by_depth - 1)
else if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth)
@ -1899,6 +1907,16 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
{/* 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">
{/* View: everything here changes how the loaded rows are displayed.
Nothing in this zone talks to the server or alters the forecast,
which is the whole reason it is fenced off from the zone on the
right the two used to sit in one undifferentiated row of
buttons separated by identical rules, so "Fit" and "Bridge"
read as peers when one is a column width and the other opens
the version's history. */}
<div className="flex items-center gap-2.5 flex-wrap bg-gray-50 border border-gray-100
rounded px-2 py-1">
<LayoutMenu
layouts={layouts}
activeLayoutId={activeLayoutId}
@ -1914,58 +1932,43 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
onDelete={deleteLayout}
onReset={resetLayout} />
{/* Rows and Columns are the same control on the two axes a depth in
ViewConfig so they are built from one renderer and read alike.
The buttons name the level they reveal: "2" meant nothing without
already knowing what group_by held, and the old fixed 03 range
offered levels that did not exist and hid ones that did. */}
{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" />
</>
)}
{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" />
</>
)}
<div className="w-px h-4 bg-gray-200 shrink-0" />
<button onClick={fitColumns}
title="Size every column to its contents, releasing any widths pinned by dragging or carried in a saved layout"
className="border border-gray-200 rounded px-1.5 py-0.5 text-gray-500 hover:border-gray-400
transition-colors whitespace-nowrap">
Fit
Fit widths
</button>
<div className="w-px h-4 bg-gray-200 shrink-0" />
{/* Expand group */}
<div className="flex items-center gap-1.5">
<span className="text-gray-400 uppercase tracking-wide" style={{fontSize:'10px'}}>Expand</span>
{[0, 1, 2, 3].map(d => (
<button key={d} onClick={() => applyDepth(d)}
className={`border rounded px-1.5 py-0.5 transition-colors
${expandDepth === d ? 'border-blue-300 text-blue-600 bg-blue-50' : 'border-gray-200 text-gray-500 hover:border-gray-400'}`}>
{d}
</button>
))}
</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" />
{/* Data group */}
<div className="flex items-center gap-1.5">
<button onClick={() => initViewer(versionId, sourceId)} disabled={loading || !versionId}
{/* Forecast: reaches the server, or opens something over the pivot.
Refresh discards the current view's state and re-runs the load;
Operations is where the version is actually written to. */}
<div className="flex items-center gap-1.5 flex-wrap ml-auto">
<button onClick={() => initViewer(versionId, sourceId, layouts)} disabled={loading || !versionId}
title="Re-run the load from the database"
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'}
</button>