Load this skill when the analysis involves a waterfall, bridge, or walk chart — for financial attribution, variance decomposition, or any visualization where sequential deltas explain the change between two totals. Contains the required data shape (label, value, bar_type, sort_order), floating bar computation, Chart.js rendering pattern, closure validation, and common pitfalls.
Waterfall charts are one of the most powerful charts for financial and attribution analysis and also one of the most technically tricky to build correctly. This file covers everything: when to use it, how to structure the pipeline data, how to compute the floating bar values, and how to render it in Chart.js.
Use a waterfall whenever the story is "here's where we started, here's what moved it, here's where we ended up."
Strong signals:
Prefer waterfall over bar chart when:
Understanding which type you're building determines the pipeline computation.
Starts at zero. Each bar is a delta (positive or negative). Final bar is the net total.
0 → [+Price Effect] → [−Volume Effect] → [+Mix Effect] → Net Total
Use for: PVM bridge, attribution analysis, driver decomposition.
Starts at a real value (e.g., prior year revenue = $10M). Each bar is a delta. Final bar lands at the new absolute level.
Prior Year ($10M) → [+New Customers] → [−Churn] → [+Upsell] → Current Year ($12M)
Use for: YoY or period-over-period revenue walks. The first and last bars are anchored totals; middle bars are deltas.
Every bar is an absolute component that stacks or subtracts to reach a final result. No "running total" — each bar contributes directly.
Revenue ($20M) → [−COGS ($8M)] → [−OpEx ($5M)] → [−Tax ($2M)] → Net Income ($5M)
Use for: P&L waterfalls, margin bridges.
The pipeline must produce a flat table with exactly these columns:
| Column | Type | Description |
|---|---|---|
label | string | Bar label shown on x-axis |
value | float | The delta amount (positive or negative). For total/subtotal bars in Type 2/3, this is the absolute level. |
bar_type | string | increase, decrease, total, subtotal |
sort_order | int | Explicit ordering (never rely on row order) |
The running_start and running_end values are computed from this table, not stored in it. Compute them in Python before embedding in the dashboard.
label,value,bar_type,sort_order
Budget Revenue,1000000,total,1
Price Effect,50000,increase,2
Volume Effect,-80000,decrease,3
Mix Effect,30000,increase,4
FX Effect,-20000,decrease,5
Actual Revenue,980000,total,6
label,value,bar_type,sort_order
Prior Year Revenue,10000000,total,1
New Customers,800000,increase,2
Expansion Revenue,400000,increase,3
Churn,-600000,decrease,4
Price Changes,200000,increase,5
Current Year Revenue,10800000,total,6
This is the most critical and non-obvious step. Chart.js floating bars require [low, high] for each bar. You must compute these before embedding.
import pandas as pd
def compute_waterfall_bars(df: pd.DataFrame) -> pd.DataFrame:
"""
Input: df with columns [label, value, bar_type, sort_order]
Output: df with added columns [bar_low, bar_high, color]
bar_type must be one of: 'increase', 'decrease', 'total', 'subtotal'
- total/subtotal bars are anchored at 0 and span [0, value]
- increase/decrease bars float: [running_start, running_start + value]
"""
df = df.sort_values('sort_order').reset_index(drop=True)
running = 0.0
bar_lows = []
bar_highs = []
colors = []
COLORS = {
'increase': '#55A868CC', # green
'decrease': '#C44E52CC', # red
'total': '#4C72B0CC', # blue
'subtotal': '#8172B3CC', # purple
}
for _, row in df.iterrows():
btype = row['bar_type']
val = row['value']
if btype in ('total', 'subtotal'):
# Anchored at 0 — reset the running total for what follows a subtotal
low, high = (min(0, val), max(0, val))
if btype == 'subtotal':
running = val # resume running total from subtotal level
else:
# Floating bar
low = running if val >= 0 else running + val
high = running + val if val >= 0 else running
running += val
bar_lows.append(round(low, 4))
bar_highs.append(round(high, 4))
colors.append(COLORS[btype])
df['bar_low'] = bar_lows
df['bar_high'] = bar_highs
df['color'] = colors
return df
Critical edge cases:
low = value, high = 0. The code above handles this with min(0, val) / max(0, val).running = subtotal_value after placing the subtotal bar so subsequent deltas float from the right base.Use the floating bar type: y: [low, high] per data point. This is native in Chart.js 4.x and is cleaner than the stacked-transparent-bar hack.
function renderWaterfall(canvasId, data, valueFormat) {
// data = output of compute_waterfall_bars(), embedded as JS array
const ctx = document.getElementById(canvasId).getContext('2d');
const chart = new Chart(ctx, {
type: 'bar',
data: {
labels: data.map(d => d.label),
datasets: [{
data: data.map(d => ({ x: d.label, y: [d.bar_low, d.bar_high] })),
backgroundColor: data.map(d => d.color),
borderColor: data.map(d => d.color.replace('CC', 'FF')),
borderWidth: 1,
borderRadius: 3,
borderSkipped: false,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (ctx) => {
const d = data[ctx.dataIndex];
const val = d.bar_type === 'total' || d.bar_type === 'subtotal'
? d.value
: d.value; // always show the delta, not the float bounds
const sign = d.value > 0 ? '+' : '';
return `${sign}${fmt(val, valueFormat)}`;
}
}
}
},
scales: {
x: { grid: { display: false } },
y: {
ticks: { callback: v => fmt(v, valueFormat) },
grid: { color: 'rgba(0,0,0,0.05)' }
}
}
},
plugins: [{
// Connector lines: draw a horizontal dotted line from bar_high of bar N to bar_low of bar N+1
id: 'waterfall-connectors',
afterDraw(chart) {
const ctx = chart.ctx;
const meta = chart.getDatasetMeta(0);
ctx.save();
ctx.setLineDash([4, 4]);
ctx.strokeStyle = 'rgba(0,0,0,0.25)';
ctx.lineWidth = 1;
for (let i = 0; i < meta.data.length - 1; i++) {
const curr = meta.data[i];
const next = meta.data[i + 1];
const currData = data[i];
const nextData = data[i + 1];
// Connect end of current bar to start of next bar
// For increases: connect from bar_high; for decreases: connect from bar_low
// For totals: no connector going out (or into the next delta)
const yConnect = currData.bar_type === 'total' || currData.bar_type === 'subtotal'
? chart.scales.y.getPixelForValue(currData.value)
: chart.scales.y.getPixelForValue(currData.bar_high);
const xStart = curr.x + curr.width / 2;
const xEnd = next.x - next.width / 2;
ctx.beginPath();
ctx.moveTo(xStart, yConnect);
ctx.lineTo(xEnd, yConnect);
ctx.stroke();
}
ctx.restore();
}
}]
});
return chart;
}
Add delta values directly on each bar. Positive deltas label above; negative deltas label below.