The panel said what it was doing only on the button that did it. The operation tabs sat unlabelled in the middle column, the slice list held a third of the dock to repeat cells you had just clicked, and the ledger -- the thing being read and edited -- got whatever width was left over. - One strip across the top: operation, apply mode, slice summary, Post - The slice list is a peek; the summary keeps the two facts not visible elsewhere, the count and the pivot filter scoping the write - The body is two grid tracks, ledger first, label second. Both used to carry flex-1, so the ledger's table overflowed its shrunken box and painted over the label column on a narrow window; the tables scroll inside their own zone now - "Apply Scale" is "Post". It renamed itself whenever the operation changed, so the primary action never looked the same twice - Target on: "Forecast" / "Total" rather than "Adjustable" / "Selected total" -- what a rep is choosing is whether the number is the forecast alone or the whole year including what has shipped - The minor actions (clear, peek, unpin, request, collapse) were bare buttons in the surrounding grey; they share one text-button style now, and the segmented groups have rules between their segments Also drops the bridge's "Whole version" scope. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
629 lines
30 KiB
JavaScript
629 lines
30 KiB
JavaScript
// Bridge (waterfall): how a version got from its baseline to where it stands,
|
||
// one step per initiative tag.
|
||
//
|
||
// Data is computed from the Perspective table already in the browser rather than
|
||
// from the /bridge endpoint, so the figures always reconcile with what the pivot
|
||
// is showing — including when the view is scoped to the pivot's current filters.
|
||
//
|
||
// Colour is a POLARITY job, not a categorical one: increases and decreases are two
|
||
// poles of one scale, with baseline and current as neutral anchors. Blue/red is the
|
||
// validated diverging pair (CVD ΔE 21.6, normal-vision 32.3 against white);
|
||
// green/red is avoided precisely because it is the classic CVD failure.
|
||
|
||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||
|
||
const UP = '#2a78d6' // increase
|
||
const DOWN = '#e34948' // decrease
|
||
const ANCHOR = '#6b7280' // baseline / current — neutral, 4.83:1 on white
|
||
const GRID = '#e5e7eb'
|
||
const INK = '#374151'
|
||
const INK_DIM = '#6b7280'
|
||
|
||
const fmt = (n, dp = 2) =>
|
||
n == null || !isFinite(n) ? '—'
|
||
: n.toLocaleString(undefined, { minimumFractionDigits: dp, maximumFractionDigits: dp })
|
||
|
||
const fmtSigned = (n, dp = 2) =>
|
||
n == null || !isFinite(n) ? '—' : `${n > 0 ? '+' : n < 0 ? '−' : ''}${fmt(Math.abs(n), dp)}`
|
||
|
||
// compact axis ticks — full precision belongs on the marks and in the table
|
||
function fmtAxis(n) {
|
||
const a = Math.abs(n)
|
||
if (a >= 1e9) return `${(n / 1e9).toFixed(1)}B`
|
||
if (a >= 1e6) return `${(n / 1e6).toFixed(1)}M`
|
||
if (a >= 1e3) return `${(n / 1e3).toFixed(1)}k`
|
||
return String(Math.round(n))
|
||
}
|
||
|
||
function niceTicks(min, max, count = 5) {
|
||
if (!isFinite(min) || !isFinite(max) || min === max) return [min || 0]
|
||
const span = max - min
|
||
const raw = span / count
|
||
const mag = Math.pow(10, Math.floor(Math.log10(raw)))
|
||
const step = [1, 2, 2.5, 5, 10].map(m => m * mag).find(s => s >= raw) || mag * 10
|
||
const out = []
|
||
for (let t = Math.ceil(min / step) * step; t <= max + 1e-9; t += step) out.push(t)
|
||
return out
|
||
}
|
||
|
||
// Turn raw forecast rows into the walk: baseline anchor, one floating step per
|
||
// initiative tag, current anchor. Pure and exported so the arithmetic can be
|
||
// checked against real data without a browser.
|
||
// The walk from a basis to the current forecast.
|
||
//
|
||
// Without a basis this is the forecast's own composition: its loads as the
|
||
// opening anchor, then one step per adjustment tag.
|
||
//
|
||
// With one -- Plan when building a forecast, Prior Year when building the AOP --
|
||
// it starts there instead, and the identity that makes it exact is
|
||
//
|
||
// Forecast - Basis = (Forecast loads - Basis) + adjustments
|
||
//
|
||
// so the opening step is the difference between the forecast's own loads and the
|
||
// basis, and every tagged adjustment explains the rest. No residual, no plug: the
|
||
// bars sum to the endpoint by construction rather than by hoping the tags cover
|
||
// everything.
|
||
//
|
||
// Membership comes from pf_bucket, not pf_iter. Those answer different questions
|
||
// -- Open Orders is loaded as reference so nothing adjusts it, and is still part
|
||
// of the forecast -- so a bridge keyed on iter silently dropped it.
|
||
export function buildSteps(rows, {
|
||
valueCol, unitsCol, logMeta = {},
|
||
excludeIters = ['reference'], // kept for callers with no bucket data
|
||
basis = null, // a pf_bucket name, or null for composition
|
||
forecastBucket,
|
||
}) {
|
||
const hasBuckets = rows.some(r => r.pf_bucket != null)
|
||
const excl = new Set(excludeIters)
|
||
|
||
// Which bucket is the forecast. It used to be the literal 'Forecast', and the
|
||
// moment the buckets were renamed to carry their sort prefix -- '04 - Forecast'
|
||
// -- no row matched it: every row counted as a comparison, the walk had no
|
||
// steps, and the bridge showed the basis cancelling itself to zero.
|
||
//
|
||
// The caller passes the version's adjustment_bucket, which is the same value
|
||
// an unbucketed adjustment is labelled with, so the two agree by construction.
|
||
// Falling back to whichever bucket actually holds the adjustments keeps a
|
||
// renamed or unconfigured version working rather than silently empty.
|
||
const fcBucket = (() => {
|
||
if (forecastBucket && rows.some(r => r.pf_bucket === forecastBucket)) return forecastBucket
|
||
const adjusted = rows.find(r => r.pf_bucket && !['baseline', 'reference'].includes(r.pf_iter))
|
||
if (adjusted) return adjusted.pf_bucket
|
||
const baseline = rows.find(r => r.pf_bucket && r.pf_iter === 'baseline')
|
||
return baseline ? baseline.pf_bucket : (forecastBucket || 'Forecast')
|
||
})()
|
||
|
||
const num = (r, col) => (col ? (parseFloat(r[col]) || 0) : 0)
|
||
const blank = () => ({ value: 0, units: 0, rows: 0 })
|
||
|
||
const loads = blank() // the forecast's own segments
|
||
const basisT = blank() // the comparison bucket
|
||
const byTag = new Map()
|
||
const buckets = new Map() // every bucket, for the picker and the markers
|
||
|
||
for (const r of rows) {
|
||
const v = num(r, valueCol)
|
||
const u = num(r, unitsCol)
|
||
const bucket = hasBuckets ? (r.pf_bucket || '') : null
|
||
|
||
if (bucket != null) {
|
||
const b = buckets.get(bucket) || blank()
|
||
b.value += v; b.units += u; b.rows += 1
|
||
buckets.set(bucket, b)
|
||
}
|
||
|
||
// Anything outside the forecast is a comparison, never a step.
|
||
if (hasBuckets && bucket !== fcBucket) {
|
||
if (basis && bucket === basis) { basisT.value += v; basisT.units += u; basisT.rows += 1 }
|
||
continue
|
||
}
|
||
if (!hasBuckets && excl.has(r.pf_iter)) continue
|
||
|
||
const isLoad = r.pf_iter === 'baseline' || r.pf_iter === 'reference'
|
||
if (isLoad) { loads.value += v; loads.units += u; loads.rows += 1; continue }
|
||
|
||
const meta = logMeta[r.pf_logid] || {}
|
||
// label first, the same precedence pf_segment uses, so the bridge and the
|
||
// pivot call a step by the same name
|
||
const tag = (meta.label || meta.tag || '').trim()
|
||
// Untagged entries collapse into one step, the same as the ledger's
|
||
// "N untagged adjustments" line in OperationPanel.jsx -- one bar per
|
||
// untagged scale/recode would otherwise turn a routine cleanup pass into
|
||
// a wall of identical-looking bars. Tagged entries group case-
|
||
// insensitively for the same reason the ledger does: "Pull Forward" and
|
||
// "pull forward" are one initiative, not two, and the most-used spelling
|
||
// is what gets shown.
|
||
const key = tag ? `tag:${tag.toLowerCase()}` : 'untagged'
|
||
const g = byTag.get(key) || {
|
||
key, tagged: !!tag, value: 0, units: 0, rows: 0, logIds: new Set(), first: r.pf_logid,
|
||
spellings: tag ? new Map() : null,
|
||
}
|
||
g.value += v; g.units += u; g.rows += 1
|
||
if (r.pf_logid != null) { g.logIds.add(r.pf_logid); g.first = Math.min(g.first ?? r.pf_logid, r.pf_logid) }
|
||
if (tag) g.spellings.set(tag, (g.spellings.get(tag) || 0) + 1)
|
||
byTag.set(key, g)
|
||
}
|
||
|
||
const mid = [...byTag.values()]
|
||
.sort((a, b) => (a.first ?? 0) - (b.first ?? 0))
|
||
.map(g => ({
|
||
...g,
|
||
label: g.tagged
|
||
? [...g.spellings.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || ''
|
||
: `${g.logIds.size} untagged adjustment${g.logIds.size === 1 ? '' : 's'}`,
|
||
}))
|
||
const useBasis = !!basis && basisT.rows > 0
|
||
|
||
const out = []
|
||
if (useBasis) {
|
||
out.push({
|
||
key: 'basis', label: basis, kind: 'anchor',
|
||
delta: basisT.value, start: 0, end: basisT.value,
|
||
units: basisT.units, rows: basisT.rows, entries: 1,
|
||
})
|
||
const gap = loads.value - basisT.value
|
||
out.push({
|
||
// "Loads" is our word for a segment import and means nothing to anyone
|
||
// reading a waterfall. This step is simply where the forecast began
|
||
// relative to the comparison.
|
||
//
|
||
// tagged, though it has no tag: the flag drives an "· untagged" suffix
|
||
// meant for adjustments nobody grouped into an initiative, and this is
|
||
// not an adjustment at all.
|
||
key: 'loads-vs-basis', label: `Starting point vs ${basis}`, kind: 'step',
|
||
tagged: true,
|
||
delta: gap, start: basisT.value, end: loads.value,
|
||
units: loads.units - basisT.units, rows: loads.rows, entries: 1,
|
||
})
|
||
} else {
|
||
out.push({
|
||
key: 'baseline', label: hasBuckets ? fcBucket + ' loads' : 'Baseline', kind: 'anchor',
|
||
delta: loads.value, start: 0, end: loads.value,
|
||
units: loads.units, rows: loads.rows, entries: 1,
|
||
})
|
||
}
|
||
|
||
let running = loads.value
|
||
for (const g of mid) {
|
||
const start = running
|
||
running += g.value
|
||
out.push({ ...g, kind: 'step', delta: g.value, start, end: running, entries: g.logIds.size })
|
||
}
|
||
|
||
out.push({
|
||
key: 'current', label: hasBuckets ? fcBucket : 'Current', kind: 'anchor',
|
||
delta: running, start: 0, end: running,
|
||
units: loads.units + mid.reduce((a, g) => a + (g.units || 0), 0),
|
||
rows: loads.rows + mid.reduce((a, g) => a + g.rows, 0),
|
||
entries: mid.reduce((a, g) => a + g.logIds.size, 0) + 1,
|
||
})
|
||
|
||
// Every bucket present, so the view can offer them as bases and show the ones
|
||
// that are not the basis as comparison markers.
|
||
out.buckets = [...buckets.entries()]
|
||
.map(([name, t]) => ({ name, ...t }))
|
||
.sort((a, b) => b.value - a.value)
|
||
return out
|
||
}
|
||
|
||
// Plot geometry, also pure: given the steps and a canvas size, where does each
|
||
// bar and label land? Exported so collisions and overflow can be checked.
|
||
// Break a label to the bar's width, on word boundaries.
|
||
//
|
||
// SVG text does not wrap, so the labels were cut at twelve characters and
|
||
// "04 - Forecast" and "04 - Forecasting" read the same. Character width is
|
||
// estimated rather than measured -- measuring means a DOM round trip per label
|
||
// on every render, and at this font a digit is about 0.55em, which is close
|
||
// enough for a centred label with a bar's width to play with.
|
||
//
|
||
// Three lines maximum, and a word longer than the line is left to overflow
|
||
// rather than broken: a truncated word helps nobody, and the bars are wide
|
||
// enough that it only happens to things like a pasted part number.
|
||
function wrapLabel(label, barW, fontSize = 10, maxLines = 3) {
|
||
const perLine = Math.max(6, Math.floor(barW / (fontSize * 0.55)))
|
||
const words = String(label || '').split(/\s+/).filter(Boolean)
|
||
const lines = []
|
||
let line = ''
|
||
for (const w of words) {
|
||
const next = line ? `${line} ${w}` : w
|
||
if (next.length <= perLine) { line = next; continue }
|
||
if (line) lines.push(line)
|
||
line = w
|
||
if (lines.length === maxLines) break
|
||
}
|
||
if (line && lines.length < maxLines) lines.push(line)
|
||
if (!lines.length) return ['']
|
||
// anything that did not fit is marked on the last line rather than dropped
|
||
const used = lines.join(' ').length
|
||
if (used < String(label).replace(/\s+/g, ' ').length) {
|
||
lines[lines.length - 1] = `${lines[lines.length - 1]}…`
|
||
}
|
||
return lines
|
||
}
|
||
|
||
export function layoutSteps(steps, width, H = 340, PAD = { t: 24, r: 16, b: 84, l: 68 }) {
|
||
const plotW = Math.max(120, width - PAD.l - PAD.r)
|
||
const plotH = H - PAD.t - PAD.b
|
||
const values = steps.flatMap(s => [s.start, s.end])
|
||
const rawMin = Math.min(0, ...values)
|
||
const rawMax = Math.max(0, ...values)
|
||
const span = (rawMax - rawMin) || 1
|
||
const yMin = rawMin - span * 0.08
|
||
const yMax = rawMax + span * 0.12
|
||
const y = (v) => PAD.t + plotH - ((v - yMin) / (yMax - yMin)) * plotH
|
||
|
||
const n = steps.length || 1
|
||
const band = plotW / n
|
||
const barW = Math.max(10, Math.min(64, band - 14))
|
||
const bars = steps.map((s, i) => {
|
||
const x = PAD.l + band * i + (band - barW) / 2
|
||
const top = y(Math.max(s.start, s.end))
|
||
const bot = y(Math.min(s.start, s.end))
|
||
return { key: s.key, x, w: barW, top, h: Math.max(2, bot - top), labelY: top - 6 }
|
||
})
|
||
return { PAD, plotW, plotH, yMin, yMax, y, band, barW, bars, H, width }
|
||
}
|
||
|
||
export default function BridgeView({
|
||
open, onClose, tableRef, viewerRef, logMeta = {},
|
||
valueCol, unitsCol, colMeta = [], slices = [],
|
||
excludeIters = ['reference'], versionName, forecastBucket,
|
||
}) {
|
||
const hasSelection = slices.length > 0
|
||
// 'selection' | 'filtered'. There is no whole-version scope: the pivot's
|
||
// filter is part of what the figures mean, and dropping it moves one end of
|
||
// the walk and not the other. On the version this was built against the grid
|
||
// is scoped `sseas_e <= 2027`, and the rows outside it -- orders with a null
|
||
// ship date, plus a tail beyond 2027 -- sit entirely in the forecast loads,
|
||
// with no counterpart in Plan. Ignoring the filter therefore added $9.4M to
|
||
// the endpoint and nothing to the basis, and the bridge said nothing about it.
|
||
const [scope, setScope] = useState(hasSelection ? 'selection' : 'filtered')
|
||
// Whether the user has picked a scope button themselves. Bridge mounts once,
|
||
// up front, before anything has ever been selected on the pivot — so the
|
||
// useState initializer above always seeds 'filtered' and nothing follows a
|
||
// selection made afterward. Until the user overrides it, scope tracks the
|
||
// live selection instead.
|
||
const [scopeTouched, setScopeTouched] = useState(false)
|
||
// Which bucket the walk starts from. Plan when building a forecast, Prior Year
|
||
// when building the AOP; empty means show the forecast's own composition.
|
||
const [basis, setBasis] = useState(() => localStorage.getItem('pf_bridge_basis') || '')
|
||
const [asTable, setAsTable] = useState(false)
|
||
const [steps, setSteps] = useState(null)
|
||
const [loading, setLoading] = useState(false)
|
||
const [error, setError] = useState(null)
|
||
const [hover, setHover] = useState(null)
|
||
const [width, setWidth] = useState(880)
|
||
const boxRef = useRef(null)
|
||
|
||
// Build the steps: baseline anchor, one floating step per tag, current anchor.
|
||
const compute = useCallback(async () => {
|
||
if (!tableRef?.current || !valueCol) return
|
||
setLoading(true); setError(null)
|
||
try {
|
||
let rows
|
||
if (scope === 'selection') {
|
||
// The union of the selected slices — the same reach an operation would
|
||
// have. Perspective view filters are AND-only, so each slice needs its own
|
||
// view; rows matching more than one slice are counted once.
|
||
//
|
||
// pf_bucket/pf_segment are deliberately left out here, even though a
|
||
// click can carry one (e.g. the column axis split by bucket): the walk
|
||
// needs rows from every bucket, not just the one the slice landed on,
|
||
// or there is nothing left for "Compare to" to compare against and
|
||
// every basis collapses to the one bucket fetched. Which bucket the
|
||
// walk starts and ends on is decided inside buildSteps, from
|
||
// forecastBucket, not by filtering it out of the fetch.
|
||
const dimNames = new Set(colMeta.filter(c => c.role === 'dimension').map(c => c.cname))
|
||
const dateNames = new Set(colMeta.filter(c => c.role === 'date').map(c => c.cname))
|
||
const schema = await tableRef.current.schema()
|
||
const typed = (col, val) => {
|
||
switch (schema[col]) {
|
||
case 'integer': case 'float': return Number(val)
|
||
case 'boolean': return val === true || val === 'true'
|
||
case 'date': case 'datetime': return Number(val)
|
||
default: return String(val)
|
||
}
|
||
}
|
||
// The pivot's own filter (e.g. sseas_e <= 2027) is not part of a
|
||
// clicked slice — perspective-click reports only the cell's own
|
||
// dimensions — so it has to be read off the viewer and applied
|
||
// alongside every per-slice view, the same reason Forecast.jsx's
|
||
// ledger totals do this. Without it, a grid scoped narrower than the
|
||
// whole version bridges rows the grid is hiding.
|
||
let viewFilter = []
|
||
let expressions = {}
|
||
if (viewerRef?.current) {
|
||
const cfg = await viewerRef.current.save()
|
||
viewFilter = (cfg.filter || []).filter(f => Array.isArray(f) && f.length >= 2)
|
||
expressions = cfg.expressions || {}
|
||
}
|
||
const seen = new Set()
|
||
rows = []
|
||
for (const sl of slices) {
|
||
const sliceFilters = Object.entries(sl)
|
||
.filter(([c]) => (dimNames.has(c) || dateNames.has(c)) && !viewFilter.some(vf => vf[0] === c))
|
||
// A null is filtered with `is null`, not `== null`: the engine
|
||
// matches on type, and a null threshold against a string column is
|
||
// the same dropped predicate that widens a slice back toward its
|
||
// parent group — see totalsFor in Forecast.jsx for the same fix.
|
||
.map(([c, v]) => (v === null ? [c, 'is null'] : [c, '==', typed(c, v)]))
|
||
if (!sliceFilters.length) continue
|
||
const viewCfg = { filter: [...viewFilter, ...sliceFilters] }
|
||
if (Object.keys(expressions).length) viewCfg.expressions = expressions
|
||
const view = await tableRef.current.view(viewCfg)
|
||
const part = await view.to_json()
|
||
await view.delete()
|
||
for (const r of part) {
|
||
if (r.pf_id != null && seen.has(r.pf_id)) continue
|
||
if (r.pf_id != null) seen.add(r.pf_id)
|
||
rows.push(r)
|
||
}
|
||
}
|
||
} else {
|
||
let filter = []
|
||
// Expression columns have to come with the filter that uses them: a
|
||
// filter can name a column that exists only as an expression, and a view
|
||
// built without it cannot resolve the column and fails outright.
|
||
let expressions = {}
|
||
if (viewerRef?.current) {
|
||
const cfg = await viewerRef.current.save()
|
||
filter = (cfg.filter || []).filter(f => Array.isArray(f) && f.length >= 2)
|
||
expressions = cfg.expressions || {}
|
||
}
|
||
const viewCfg = {}
|
||
if (filter.length) viewCfg.filter = filter
|
||
if (Object.keys(expressions).length) viewCfg.expressions = expressions
|
||
const view = await tableRef.current.view(viewCfg)
|
||
rows = await view.to_json()
|
||
await view.delete()
|
||
}
|
||
|
||
setSteps(buildSteps(rows, {
|
||
valueCol, unitsCol, logMeta, excludeIters, basis: basis || null, forecastBucket,
|
||
}))
|
||
} catch (err) {
|
||
setError(err.message || String(err))
|
||
setSteps(null)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [tableRef, viewerRef, scope, logMeta, valueCol, unitsCol, excludeIters, slices, colMeta, basis, forecastBucket])
|
||
|
||
// Follow the pivot's selection until the user chooses a scope explicitly.
|
||
useEffect(() => {
|
||
if (scopeTouched) return
|
||
setScope(hasSelection ? 'selection' : 'filtered')
|
||
}, [hasSelection, scopeTouched])
|
||
|
||
// Once the user has picked "Selection" -- themselves or via the effect above
|
||
// -- and the selection then clears, there is nothing left to show under it;
|
||
// fall back regardless of whether the choice was manual.
|
||
useEffect(() => {
|
||
if (!hasSelection && scope === 'selection') { setScope('filtered'); setScopeTouched(false) }
|
||
}, [hasSelection, scope])
|
||
|
||
useEffect(() => { try { localStorage.setItem('pf_bridge_basis', basis) } catch {} }, [basis])
|
||
useEffect(() => { if (open) compute() }, [open, compute])
|
||
|
||
useEffect(() => {
|
||
if (!open || !boxRef.current) return
|
||
const ro = new ResizeObserver(([e]) => setWidth(Math.max(420, e.contentRect.width)))
|
||
ro.observe(boxRef.current)
|
||
return () => ro.disconnect()
|
||
}, [open])
|
||
|
||
if (!open) return null
|
||
|
||
const geom = layoutSteps(steps || [{ start: 0, end: 0 }], width)
|
||
const { PAD, plotW, plotH, yMin, yMax, y, barW, H, bars } = geom
|
||
const xOf = (i) => bars[i]?.x ?? PAD.l
|
||
|
||
const ticks = niceTicks(yMin, yMax, 5)
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
||
<div className="bg-white rounded-lg shadow-xl w-full max-w-5xl mx-4 flex flex-col max-h-[88vh]"
|
||
onClick={e => e.stopPropagation()}>
|
||
|
||
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 shrink-0">
|
||
<div className="flex items-baseline gap-2">
|
||
<span className="font-medium text-gray-700 text-sm">Bridge</span>
|
||
{versionName && <span className="text-gray-600 text-xs">{versionName}</span>}
|
||
<span className="text-gray-600 text-xs">
|
||
· {scope === 'selection'
|
||
? `${slices.length} selected slice${slices.length === 1 ? '' : 's'}`
|
||
: "pivot's filters"}
|
||
</span>
|
||
</div>
|
||
<button onClick={onClose} className="text-gray-600 hover:text-gray-800 text-lg leading-none">×</button>
|
||
</div>
|
||
|
||
{/* controls — one row above the chart */}
|
||
<div className="flex items-center gap-3 px-5 py-2 border-b border-gray-100 shrink-0 text-xs flex-wrap">
|
||
<span className="text-gray-600">Scope</span>
|
||
<div className="inline-flex rounded border border-gray-200 overflow-hidden">
|
||
{[
|
||
['selection', hasSelection ? `Selection (${slices.length})` : 'Selection',
|
||
hasSelection ? 'The slices selected in the operation panel'
|
||
: 'Select one or more pivot rows first'],
|
||
['filtered', "Pivot's filters", 'Everything the pivot currently shows'],
|
||
].map(([v, l, title]) => (
|
||
<button key={v} onClick={() => { setScope(v); setScopeTouched(true) }} title={title}
|
||
disabled={v === 'selection' && !hasSelection}
|
||
className={`px-3 py-1 disabled:opacity-40 disabled:cursor-not-allowed ${
|
||
scope === v ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50'}`}>
|
||
{l}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{/* Offered only once something carries a bucket, and only listing buckets
|
||
other than the forecast itself -- a walk from Forecast to Forecast is
|
||
the composition view, which is what the empty option gives. */}
|
||
{(steps?.buckets || []).some(b => b.name && b.name !== 'Forecast') && (
|
||
<>
|
||
<div className="w-px h-4 bg-gray-200" />
|
||
<span className="text-gray-600">Compare to</span>
|
||
<select value={basis} onChange={e => setBasis(e.target.value)}
|
||
className="border border-gray-200 rounded px-2 py-1 text-gray-700 bg-white">
|
||
<option value="">nothing — show composition</option>
|
||
{(steps?.buckets || [])
|
||
.filter(b => b.name && b.name !== 'Forecast')
|
||
.map(b => <option key={b.name} value={b.name}>{b.name}</option>)}
|
||
</select>
|
||
</>
|
||
)}
|
||
|
||
<div className="w-px h-4 bg-gray-200" />
|
||
<button onClick={() => setAsTable(t => !t)}
|
||
className="border border-gray-200 rounded px-2 py-1 text-gray-700 hover:bg-gray-50">
|
||
{asTable ? 'Show chart' : 'Show table'}
|
||
</button>
|
||
<button onClick={compute} disabled={loading}
|
||
className="border border-gray-200 rounded px-2 py-1 text-gray-700 hover:bg-gray-50 disabled:opacity-40">
|
||
{loading ? 'Computing…' : 'Refresh'}
|
||
</button>
|
||
|
||
{/* legend — identity is never colour alone, but say it anyway */}
|
||
<div className="ml-auto flex items-center gap-3 text-gray-700">
|
||
{[['Increase', UP], ['Decrease', DOWN], ['Total', ANCHOR]].map(([l, c]) => (
|
||
<span key={l} className="inline-flex items-center gap-1.5">
|
||
<span className="inline-block w-2.5 h-2.5 rounded-sm" style={{ background: c }} />
|
||
{l}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="overflow-auto p-5" ref={boxRef}>
|
||
{error && <p className="text-red-600">{error}</p>}
|
||
{!error && !steps && <p className="text-gray-600">Computing…</p>}
|
||
{!error && steps && steps.length <= 2 && (
|
||
<p className="text-gray-600">
|
||
No adjustments in scope — the bridge shows the walk from baseline to current,
|
||
and this selection has only a baseline.
|
||
</p>
|
||
)}
|
||
|
||
{!error && steps && steps.length > 2 && !asTable && (
|
||
<div className="relative">
|
||
<svg width={width} height={H} role="img"
|
||
aria-label={`Bridge from baseline ${fmt(steps[0].end)} to current ${fmt(steps[steps.length - 1].end)}`}>
|
||
{/* recessive grid */}
|
||
{ticks.map(t => (
|
||
<g key={t}>
|
||
<line x1={PAD.l} x2={PAD.l + plotW} y1={y(t)} y2={y(t)}
|
||
stroke={t === 0 ? '#d1d5db' : GRID} strokeWidth={t === 0 ? 1.5 : 1} />
|
||
<text x={PAD.l - 8} y={y(t) + 3} textAnchor="end" fontSize="10" fill={INK_DIM}>
|
||
{fmtAxis(t)}
|
||
</text>
|
||
</g>
|
||
))}
|
||
|
||
{steps.map((s, i) => {
|
||
const isAnchor = s.kind === 'anchor'
|
||
const up = s.delta >= 0
|
||
const fill = isAnchor ? ANCHOR : (up ? UP : DOWN)
|
||
const top = y(Math.max(s.start, s.end))
|
||
const bot = y(Math.min(s.start, s.end))
|
||
const h = Math.max(2, bot - top)
|
||
const x = xOf(i)
|
||
const on = hover?.key === s.key
|
||
const labelLines = wrapLabel(s.label, barW)
|
||
const subY = PAD.t + plotH + 16 + labelLines.length * 11 + 2
|
||
return (
|
||
<g key={s.key}
|
||
onMouseEnter={() => setHover({ ...s, x: x + barW / 2, y: top })}
|
||
onMouseLeave={() => setHover(null)}>
|
||
{/* connector to the next bar, drawn behind */}
|
||
{i < steps.length - 1 && (
|
||
<line x1={x + barW} x2={xOf(i + 1)} y1={y(s.end)} y2={y(s.end)}
|
||
stroke="#cbd5e1" strokeWidth="1" strokeDasharray="2 2" />
|
||
)}
|
||
{/* hit target larger than the mark */}
|
||
<rect x={x - 6} y={PAD.t} width={barW + 12} height={plotH} fill="transparent" />
|
||
<rect x={x} y={top} width={barW} height={h} rx="4" fill={fill}
|
||
opacity={on ? 1 : 0.92}
|
||
stroke="#ffffff" strokeWidth="2" />
|
||
{/* direct label: few bars, so every one is labelled */}
|
||
<text x={x + barW / 2} y={top - 6} textAnchor="middle" fontSize="10"
|
||
fill={INK} fontWeight="500">
|
||
{isAnchor ? fmt(s.end, 0) : fmtSigned(s.delta, 0)}
|
||
</text>
|
||
<text x={x + barW / 2} y={PAD.t + plotH + 16} textAnchor="middle" fontSize="10" fill={INK}>
|
||
{labelLines.map((ln, li) => (
|
||
<tspan key={li} x={x + barW / 2} dy={li === 0 ? 0 : 11}>{ln}</tspan>
|
||
))}
|
||
</text>
|
||
{/* below the label, however many lines it took. Skipped for the
|
||
untagged bucket -- its label already reads "N untagged
|
||
adjustments", so a ×N here would just repeat the count. */}
|
||
{!isAnchor && s.tagged && s.entries > 1 && (
|
||
<text x={x + barW / 2} y={subY} textAnchor="middle" fontSize="9" fill={INK_DIM}>
|
||
×{s.entries}
|
||
</text>
|
||
)}
|
||
</g>
|
||
)
|
||
})}
|
||
</svg>
|
||
|
||
{hover && (
|
||
<div className="absolute pointer-events-none bg-white border border-gray-300 rounded shadow-lg px-2.5 py-1.5 text-xs"
|
||
style={{ left: Math.min(hover.x + 10, width - 190), top: Math.max(0, hover.y - 10) }}>
|
||
<div className="font-medium text-gray-800">{hover.label}</div>
|
||
<div className="text-gray-700 font-mono tabular-nums">
|
||
{hover.kind === 'anchor' ? fmt(hover.end) : fmtSigned(hover.delta)}
|
||
</div>
|
||
{hover.kind === 'step' && (
|
||
<div className="text-gray-600">
|
||
running → <span className="font-mono tabular-nums">{fmt(hover.end)}</span>
|
||
</div>
|
||
)}
|
||
<div className="text-gray-600">
|
||
{hover.rows} row{hover.rows === 1 ? '' : 's'}
|
||
{hover.entries > 1 ? ` · ${hover.entries} adjustments` : ''}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* table view — the same numbers, at full precision */}
|
||
{!error && steps && steps.length > 2 && asTable && (
|
||
<table className="w-full text-xs">
|
||
<thead>
|
||
<tr className="text-gray-600 border-b border-gray-200">
|
||
<th className="text-left py-1.5 pr-3 font-medium">Step</th>
|
||
<th className="text-right py-1.5 px-2 font-medium">{valueCol}</th>
|
||
{unitsCol && <th className="text-right py-1.5 px-2 font-medium">{unitsCol}</th>}
|
||
<th className="text-right py-1.5 px-2 font-medium">Running</th>
|
||
<th className="text-right py-1.5 px-2 font-medium">Adjustments</th>
|
||
<th className="text-right py-1.5 pl-2 font-medium">Rows</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{steps.map(s => (
|
||
<tr key={s.key} className="border-b border-gray-100">
|
||
<td className="py-1.5 pr-3 text-gray-800">{s.label}</td>
|
||
<td className="py-1.5 px-2 text-right font-mono tabular-nums text-gray-800">
|
||
{s.kind === 'anchor' ? fmt(s.end) : fmtSigned(s.delta)}
|
||
</td>
|
||
{unitsCol && (
|
||
<td className="py-1.5 px-2 text-right font-mono tabular-nums text-gray-700">
|
||
{s.kind === 'anchor' ? fmt(s.units) : fmtSigned(s.units)}
|
||
</td>
|
||
)}
|
||
<td className="py-1.5 px-2 text-right font-mono tabular-nums text-gray-700">{fmt(s.end)}</td>
|
||
<td className="py-1.5 px-2 text-right text-gray-700">{s.kind === 'step' ? s.entries : '—'}</td>
|
||
<td className="py-1.5 pl-2 text-right text-gray-700">{s.rows}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|