// 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 (
{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.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} |