Source code for glyphx.stat_annotation

"""
GlyphX Statistical Annotation layer.

Draw significance brackets between groups on bar, box, or violin charts::

    from glyphx import Figure
    from glyphx.series import BarSeries
    from glyphx.stat_annotation import StatAnnotation

    fig = Figure(auto_display=False)
    fig.add(BarSeries(["Control","Drug A","Drug B"], [45, 72, 68]))
    fig.add_stat_annotation("Control", "Drug A", p_value=0.002)
    fig.add_stat_annotation("Control", "Drug B", p_value=0.04)
    fig.show()

Works with both numeric and categorical X axes.
"""
from __future__ import annotations

from typing import Any

from .utils import svg_escape


# ---------------------------------------------------------------------------
# p-value → significance label
# ---------------------------------------------------------------------------

[docs] def pvalue_to_label(p: float, style: str = "stars") -> str: """ Convert a p-value to a display label. Args: p: The p-value (0–1). style: ``"stars"`` (default) or ``"numeric"``. Returns: ``"***"``, ``"**"``, ``"*"``, ``"ns"`` — or the formatted number. """ if style == "numeric": if p < 0.001: return f"p={p:.2e}" return f"p={p:.3f}" if p < 0.001: return "***" if p < 0.01: return "**" if p < 0.05: return "*" return "ns"
# --------------------------------------------------------------------------- # Annotation class # ---------------------------------------------------------------------------
[docs] class StatAnnotation: """ Significance bracket drawn between two groups on a chart. Attributes: x1: First group label or numeric X value. x2: Second group label or numeric X value. p_value: The p-value for the test between the two groups. label: Override text (defaults to significance stars). style: ``"stars"`` or ``"numeric"``. color: Line and text color. line_width: Bracket stroke width. tip_len: Vertical tick length at each bracket end. y_offset: Extra upward pixel shift (stacks multiple brackets). """ def __init__( self, x1: Any, x2: Any, p_value: float = 0.05, label: str | None = None, style: str = "stars", color: str = "#222", line_width: float = 1.5, tip_len: float = 8.0, y_offset: float = 0.0, ) -> None: self.x1 = x1 self.x2 = x2 self.p_value = p_value self.label = label or pvalue_to_label(p_value, style) self.color = color self.line_width = line_width self.tip_len = tip_len self.y_offset = y_offset
[docs] def to_svg(self, ax: Any) -> str: """ Render the bracket into SVG. Args: ax: A finalised :class:`~glyphx.layout.Axes` instance. Returns: SVG markup string, or empty string if the axes have no scale. """ if ax.scale_x is None or ax.scale_y is None: return "" # ── Resolve x positions ─────────────────────────────────────────── def resolve_x(val: Any) -> float: # Categorical lookup if isinstance(val, str): for s in ax.series: cats = getattr(s, "_x_categories", None) num = getattr(s, "_numeric_x", None) if cats and num: for cat, nx in zip(cats, num): if str(cat) == str(val): return ax.scale_x(nx) return ax.scale_x(float(val)) try: px1 = resolve_x(self.x1) px2 = resolve_x(self.x2) except (TypeError, ValueError): return "" # ── Bracket y position: just above the tallest bar / data point ─── y_top_data = ax._y_domain[1] if ax._y_domain else 0 base_py = ax.scale_y(y_top_data) - 16 - self.y_offset tip_py = base_py + self.tip_len mid_x = (px1 + px2) / 2 lbl = svg_escape(self.label) c = self.color lw = self.line_width # ── Font size: larger for stars, smaller for numeric ───────────── font_size = 16 if len(self.label) <= 3 else 11 return "\n".join([ # Left vertical tick f'<line x1="{px1}" x2="{px1}" y1="{tip_py}" y2="{base_py}" ' f'stroke="{c}" stroke-width="{lw}"/>', # Horizontal bar f'<line x1="{px1}" x2="{px2}" y1="{base_py}" y2="{base_py}" ' f'stroke="{c}" stroke-width="{lw}"/>', # Right vertical tick f'<line x1="{px2}" x2="{px2}" y1="{base_py}" y2="{tip_py}" ' f'stroke="{c}" stroke-width="{lw}"/>', # Label f'<text x="{mid_x}" y="{base_py - 4}" text-anchor="middle" ' f'font-size="{font_size}" fill="{c}">{lbl}</text>', ])