Source code for glyphx.ecdf
"""
GlyphX Empirical Cumulative Distribution Function (ECDF) plot.
The ECDF is the step-function version of a CDF — for each value x,
it shows the proportion of observations ≤ x. Unlike histograms it
requires no bin-width choice and reveals the full distribution.
from glyphx import Figure
from glyphx.ecdf import ECDFSeries
fig = Figure(auto_display=False)
fig.add(ECDFSeries(control_data, label="Control", color="#1f77b4"))
fig.add(ECDFSeries(treatment_data, label="Treatment", color="#ff7f0e"))
fig.axes.xlabel = "Measurement"
fig.axes.ylabel = "Cumulative proportion"
fig.show()
"""
from __future__ import annotations
import numpy as np
from .series import BaseSeries
from .utils import svg_escape
[docs]
class ECDFSeries(BaseSeries):
"""
Empirical CDF rendered as a step function.
Args:
data: Raw observations (1-D array-like).
color: Line color.
label: Legend label.
show_points: Draw a circle at each step.
point_radius: Radius of step-point circles.
line_width: Stroke width of the step line.
complementary: If True, plot 1 − ECDF (survival function).
"""
def __init__(
self,
data: list | np.ndarray,
color: str | None = None,
label: str | None = None,
show_points: bool = False,
point_radius: float = 3.0,
line_width: float = 2.0,
complementary: bool = False,
) -> None:
arr = np.sort(np.asarray(data, dtype=float))
n = len(arr)
ys = np.arange(1, n + 1) / n
if complementary:
ys = 1 - ys
super().__init__(
x=arr.tolist(),
y=ys.tolist(),
color=color or "#1f77b4",
label=label,
)
self.show_points = show_points
self.point_radius = point_radius
self.line_width = line_width
self._raw = arr
[docs]
def to_svg(self, ax: object, use_y2: bool = False) -> str:
"""Render the ECDF step function as SVG."""
scale_y = ax.scale_y2 if use_y2 else ax.scale_y # type: ignore[union-attr]
elements: list[str] = []
# Build step-function path
# For each (x_i, y_i), draw:
# horizontal segment from previous x to x_i at previous y
# vertical jump from previous y to y_i at x_i
path_d: list[str] = []
prev_px: float | None = None
prev_py: float | None = None
for x_val, y_val in zip(self.x, self.y):
px = ax.scale_x(x_val) # type: ignore[union-attr]
py = scale_y(y_val)
if prev_px is None:
# Horizontal lead-in from left edge to first point
path_d.append(f"M {ax.padding},{py}") # type: ignore[union-attr]
path_d.append(f"L {px},{py}")
else:
# Horizontal at previous y
path_d.append(f"L {px},{prev_py}")
# Vertical jump
path_d.append(f"L {px},{py}")
if self.show_points:
elements.append(
f'<circle class="glyphx-point {self.css_class}" '
f'cx="{px}" cy="{py}" r="{self.point_radius}" '
f'fill="{self.color}" '
f'data-x="{svg_escape(str(x_val))}" '
f'data-y="{svg_escape(f"{y_val:.4f}")}" '
f'data-label="{svg_escape(self.label or "")}"/>'
)
prev_px, prev_py = px, py
# Horizontal tail to right edge
if prev_px is not None:
path_d.append(
f"L {ax.width - ax.padding},{prev_py}" # type: ignore[union-attr]
)
if path_d:
elements.insert(
0,
f'<path d="{" ".join(path_d)}" fill="none" '
f'stroke="{self.color}" stroke-width="{self.line_width}" '
f'class="{self.css_class}"/>',
)
return "\n".join(elements)