// 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 = 'Forecast', }) { const hasBuckets = rows.some(r => r.pf_bucket != null) const excl = new Set(excludeIters) 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 !== forecastBucket) { 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() const label = tag || (meta.note || '').trim() || `${(meta.operation || r.pf_iter || 'adj')}${r.pf_logid != null ? ` #${r.pf_logid}` : ''}` const key = tag ? `tag:${tag}` : `log:${r.pf_logid}` const g = byTag.get(key) || { key, label, tagged: !!tag, value: 0, units: 0, rows: 0, logIds: new Set(), first: r.pf_logid } 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) } byTag.set(key, g) } const mid = [...byTag.values()].sort((a, b) => (a.first ?? 0) - (b.first ?? 0)) 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({ key: 'loads-vs-basis', label: `Loads vs ${basis}`, kind: 'step', 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 ? forecastBucket + ' 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 ? forecastBucket : '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. export function layoutSteps(steps, width, H = 340, PAD = { t: 24, r: 16, b: 64, 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, }) { const hasSelection = slices.length > 0 // 'selection' | 'filtered' | 'all' const [scope, setScope] = useState(hasSelection ? 'selection' : 'filtered') // 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. 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 seen = new Set() rows = [] for (const sl of slices) { const f = [ ...Object.entries(sl).filter(([c]) => dimNames.has(c)).map(([c, v]) => [c, '==', v]), ...Object.entries(sl).filter(([c]) => dateNames.has(c)).map(([c, v]) => [c, '==', Number(v)]), ] if (!f.length) continue // No expressions needed here: these filters are built from col_meta // names, so they only ever reference real columns. const view = await tableRef.current.view({ filter: f }) 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 (scope === 'filtered' && 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 })) } catch (err) { setError(err.message || String(err)) setSteps(null) } finally { setLoading(false) } }, [tableRef, viewerRef, scope, logMeta, valueCol, unitsCol, excludeIters, slices, colMeta, basis]) useEffect(() => { if (!hasSelection && scope === 'selection') setScope('filtered') }, [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 (
{error}
} {!error && !steps &&Computing…
} {!error && steps && steps.length <= 2 && (No adjustments in scope — the bridge shows the walk from baseline to current, and this selection has only a baseline.
)} {!error && steps && steps.length > 2 && !asTable && (| Step | {valueCol} | {unitsCol &&{unitsCol} | }Running | Adjustments | Rows |
|---|---|---|---|---|---|
| {s.label}{!s.tagged && s.kind === 'step' && · untagged} | {s.kind === 'anchor' ? fmt(s.end) : fmtSigned(s.delta)} | {unitsCol && ({s.kind === 'anchor' ? fmt(s.units) : fmtSigned(s.units)} | )}{fmt(s.end)} | {s.kind === 'step' ? s.entries : '—'} | {s.rows} |