Source code for glyphx.stacked_bar

"""
GlyphX StackedBarSeries — stacked and 100%-stacked bar charts.

Matplotlib requires manual ``bottom=`` accumulation across multiple
``ax.bar()`` calls.  Seaborn has no native stacked bar.  GlyphX handles
the entire stack computation internally.

    from glyphx import Figure
    from glyphx.stacked_bar import StackedBarSeries

    fig = Figure(auto_display=False)
    fig.add(StackedBarSeries(
        x=["Q1","Q2","Q3","Q4"],
        series={
            "Cloud":    [1.2, 1.5, 1.8, 2.1],
            "AI/ML":    [0.8, 1.0, 1.3, 1.6],
            "Mobile":   [0.5, 0.6, 0.7, 0.9],
        },
        normalize=False,   # True → 100% stacked
    ))
    fig.show()
"""
from __future__ import annotations

import numpy as np
from .series import BaseSeries
from .utils  import svg_escape, _format_tick
from .themes import themes as _themes


[docs] class StackedBarSeries(BaseSeries): """ Stacked bar chart — multiple series stacked vertically per category. Args: x: Category labels for the X-axis. series: ``{label: [values]}`` mapping. Order determines stack order (first key is at the bottom). colors: Per-series hex colors. Falls back to the active theme palette. normalize: If ``True``, bars are normalized to 100% (proportional stacking). bar_width: Fraction of the available slot width per bar (0–1). label: Legend label (not used; each sub-series has its own label). """ def __init__( self, x: list, series: dict[str, list[float]], colors: list[str] | None = None, normalize: bool = False, bar_width: float = 0.75, label: str | None = None, ) -> None: self.categories = list(x) self.stacks = series # OrderedDict-stable in 3.7+ self.normalize = normalize self.bar_width = float(bar_width) self.css_class = f"series-{id(self) % 100000}" palette = _themes["default"]["colors"] self.colors = colors or palette # Pre-compute per-category totals for normalization names = list(series.keys()) n_cats = len(x) n_stacks = len(names) self._mat = np.zeros((n_stacks, n_cats)) # [stack_i, cat_j] for i, name in enumerate(names): self._mat[i] = series[name] totals = self._mat.sum(axis=0) # total per category if normalize: # Avoid div-by-zero totals = np.where(totals == 0, 1, totals) self._mat = self._mat / totals * 100 # BaseSeries x/y for domain y_max = float(self._mat.sum(axis=0).max()) super().__init__( x=list(x), y=[0.0, y_max], color=self.colors[0], label=label, ) # Register categorical x mapping for render_grid self._x_categories = list(x) self._numeric_x = [i + 0.5 for i in range(n_cats)]
[docs] def to_svg(self, ax: object, use_y2: bool = False) -> str: # type: ignore scale_y = ax.scale_y2 if use_y2 else ax.scale_y # type: ignore y0 = scale_y(0) elements: list[str] = [] font = ax.theme.get("font", "sans-serif") # type: ignore tc = ax.theme.get("text_color", "#000") # type: ignore # Pixel slot width n_cats = len(self.categories) if n_cats > 1: px_slot = ax.scale_x(1.5) - ax.scale_x(0.5) # type: ignore else: px_slot = (ax.width - 2 * ax.padding) * 0.8 # type: ignore px_bar = px_slot * self.bar_width names = list(self.stacks.keys()) for cat_j, cat in enumerate(self.categories): cx = ax.scale_x(cat_j + 0.5) # type: ignore cumsum = 0.0 for stack_i, name in enumerate(names): val = float(self._mat[stack_i, cat_j]) top_v = cumsum + val py_top = scale_y(top_v) py_bot = scale_y(cumsum) h = abs(py_bot - py_top) color = self.colors[stack_i % len(self.colors)] if h < 0.5: # skip invisibly thin segments cumsum = top_v continue label_txt = f"{val:.1f}{'%' if self.normalize else ''}" tooltip = ( f'data-x="{svg_escape(str(cat))}" ' f'data-label="{svg_escape(name)}" ' f'data-value="{svg_escape(label_txt)}"' ) elements.append( f'<rect class="glyphx-point {self.css_class}" ' f'x="{cx - px_bar / 2:.1f}" y="{min(py_top, py_bot):.1f}" ' f'width="{px_bar:.1f}" height="{h:.1f}" ' f'fill="{color}" stroke="#fff" stroke-width="0.5" ' f'{tooltip}/>' ) cumsum = top_v # Inline legend (right gutter handled by Figure, but add per-stack colors) # The caller's draw_legend handles the actual gutter legend; # we expose each stack as a labelled sub-series by registering them. return "\n".join(elements)
# Expose stack names/colors so draw_legend can render them @property def _legend_entries(self) -> list[tuple[str, str]]: names = list(self.stacks.keys()) return [(n, self.colors[i % len(self.colors)]) for i, n in enumerate(names)]