The walk read from tag and note, and hardcoded the word "Baseline" for the baseline load -- so a segment called 03 - New Orders in the pivot, in the bridge and on the Baseline page read as "Baseline" in the one place you go to check a number before changing it. label comes first now, the same precedence pf_segment uses, in the ledger and the bridge alike. logMeta did not carry label at all, which is why neither could reach it. The immovable rows split one line per segment. Combined, "01 - YTD Sales · 02 - Open Orders" said 1.6m was untouchable without saying how much of it was billed and how much was booked -- different things a forecaster treats differently. The FINAL badge also gains the space it was missing, having rendered as "02 - Open Ordersfinal". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
501 lines
23 KiB
JavaScript
501 lines
23 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 = '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 (
|
||
<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'}`
|
||
: scope === 'filtered' ? "pivot's filters" : 'whole version'}
|
||
</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'],
|
||
['all', 'Whole version', 'Every row in the version, filters ignored'],
|
||
].map(([v, l, title]) => (
|
||
<button key={v} onClick={() => setScope(v)} 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
|
||
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}>
|
||
{s.label.length > 12 ? `${s.label.slice(0, 11)}…` : s.label}
|
||
</text>
|
||
{!isAnchor && s.entries > 1 && (
|
||
<text x={x + barW / 2} y={PAD.t + plotH + 29} textAnchor="middle" fontSize="9" fill={INK_DIM}>
|
||
×{s.entries}
|
||
</text>
|
||
)}
|
||
{!s.tagged && !isAnchor && (
|
||
<text x={x + barW / 2} y={PAD.t + plotH + 29} textAnchor="middle" fontSize="9" fill={INK_DIM}>
|
||
untagged
|
||
</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}{!s.tagged && s.kind === 'step' && <span className="text-gray-600"> · untagged</span>}
|
||
</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>
|
||
)
|
||
}
|