"""
GlyphX Waterfall / Bridge chart.
Shows cumulative effect of sequentially introduced positive or negative values.
Essential for financial P&L analysis, budget variance, and change attribution.
from glyphx import Figure
from glyphx.waterfall import WaterfallSeries
fig = Figure(title="Q3 Revenue Bridge", auto_display=False)
fig.add(WaterfallSeries(
labels=["Start", "Product A", "Product B", "Returns", "Total"],
values=[1_000, +350, +210, -80, None], # None = auto-total bar
))
fig.show()
"""
from __future__ import annotations
from .series import BaseSeries
from .utils import svg_escape, _format_tick
[docs]
class WaterfallSeries(BaseSeries):
"""
Waterfall (bridge) chart.
Args:
labels: Category labels.
values: Deltas to add at each step. Pass ``None`` for the
last bar to auto-compute the running total.
up_color: Fill for positive bars.
down_color: Fill for negative bars.
total_color: Fill for the auto-total bar.
bar_width: Fraction of slot width used by each bar (0–1).
connector: Draw dashed connector lines between bars.
show_values: Print the delta value above each bar.
label: Legend label.
"""
def __init__(
self,
labels: list[str],
values: list[float | None],
up_color: str = "#2ca02c",
down_color: str = "#d62728",
total_color: str = "#1f77b4",
bar_width: float = 0.6,
connector: bool = True,
show_values: bool = True,
label: str | None = None,
) -> None:
self.labels = labels
self.raw_values = values
self.up_color = up_color
self.down_color = down_color
self.total_color = total_color
self.bar_width = bar_width
self.connector = connector
self.show_values = show_values
# Compute running totals and bar extents
self._bases: list[float] = []
self._tops: list[float] = []
self._colors: list[str] = []
self._deltas: list[float] = []
running = 0.0
for v in values:
if v is None:
# Total bar — spans from 0 to current running total
self._bases.append(0.0)
self._tops.append(running)
self._colors.append(total_color)
self._deltas.append(running)
else:
base = running
top = running + v
self._bases.append(min(base, top))
self._tops.append(max(base, top))
self._colors.append(up_color if v >= 0 else down_color)
self._deltas.append(v)
running += v
# x/y for domain
n = len(labels)
ymin = min(min(self._bases), 0)
ymax = max(self._tops)
super().__init__(
x=list(range(n)),
y=[ymin, ymax],
color=up_color,
label=label,
)
# Register as categorical so render_grid() draws x-axis labels
self._x_categories = list(labels)
self._numeric_x = [i + 0.5 for i in range(n)]
[docs]
def to_svg(self, ax: object, use_y2: bool = False) -> str:
scale_y = ax.scale_y2 if use_y2 else ax.scale_y # type: ignore[union-attr]
elements: list[str] = []
n = len(self.labels)
slot_px = (ax.width - 2 * ax.padding) / n # type: ignore[union-attr]
body_px = slot_px * self.bar_width
prev_top_py: float | None = None
for i, (lbl, base, top, color, delta) in enumerate(zip(
self.labels, self._bases, self._tops,
self._colors, self._deltas,
)):
cx = ax.scale_x(i + 0.5) # type: ignore[union-attr]
py_base = scale_y(base)
py_top = scale_y(top)
bar_h = abs(py_base - py_top)
bar_y = min(py_base, py_top)
tooltip = (
f'data-x="{svg_escape(lbl)}" '
f'data-value="{svg_escape(_format_tick(delta))}" '
f'data-label="{svg_escape(self.label or lbl)}"'
)
elements.append(
f'<rect class="glyphx-point {self.css_class}" '
f'x="{cx - body_px / 2}" y="{bar_y}" '
f'width="{body_px}" height="{max(bar_h, 1)}" '
f'fill="{color}" {tooltip}/>'
)
# Connector dashed line from previous bar's top to this bar's base
if self.connector and prev_top_py is not None:
prev_cx = ax.scale_x(i - 1 + 0.5) # type: ignore[union-attr]
elements.append(
f'<line '
f'x1="{prev_cx + body_px / 2}" '
f'x2="{cx - body_px / 2}" '
f'y1="{prev_top_py}" y2="{prev_top_py}" '
f'stroke="#999" stroke-width="1" stroke-dasharray="3,3"/>'
)
prev_top_py = py_top
# Delta label above bar
if self.show_values:
label_y = bar_y - 4
sign = "+" if delta > 0 else ""
elements.append(
f'<text x="{cx}" y="{label_y}" text-anchor="middle" '
f'font-size="10" fill="{color}" font-weight="600">'
f'{sign}{svg_escape(_format_tick(delta))}</text>'
)
return "\n".join(elements)