Source code for glyphx.violin_plot
"""
GlyphX ViolinPlotSeries — replaces scipy gaussian_kde with a pure-numpy implementation.
"""
import numpy as np
def _numpy_kde(data, bandwidth=None):
"""
Gaussian KDE using NumPy only (no scipy required).
Args:
data (np.ndarray): 1-D input array.
bandwidth (float | None): Scott's rule applied if None.
Returns:
callable: f(y_vals) → density array.
"""
n = len(data)
h = bandwidth or (n ** -0.2) * data.std(ddof=1)
if h == 0:
h = 1e-6
def kde(y_vals):
y = np.asarray(y_vals)
z = (y[:, None] - data[None, :]) / h
return np.exp(-0.5 * z ** 2).mean(axis=1) / (h * np.sqrt(2 * np.pi))
return kde
[docs]
class ViolinPlotSeries:
"""
Violin plot: a KDE-smoothed distribution mirrored on both sides of a centre line.
Requires no external dependencies beyond NumPy.
Args:
data (list of array-like): One array per category.
positions (list | None): X-axis positions for each violin.
color (str): Fill/stroke color.
width (int): Maximum pixel half-width of the violin body.
show_median (bool): Draw a horizontal median marker.
show_box (bool): Overlay a thin IQR box inside the violin.
label (str | None): Legend label.
"""
def __init__(self, data, positions=None, color="#1f77b4",
width=50, show_median=True, show_box=True, label=None):
self.data = data
self.positions = positions or list(range(len(data)))
self.color = color
self.width = width
self.show_median = show_median
self.show_box = show_box
self.label = label
self.css_class = f"series-{id(self) % 100000}"
# Expose x/y for Axes domain computation
self.x = self.positions
all_vals = np.concatenate([np.asarray(d) for d in data])
self.y = [float(all_vals.min()), float(all_vals.max())]
[docs]
def to_svg(self, ax, use_y2=False):
scale_y = ax.scale_y2 if use_y2 else ax.scale_y
elements = []
for i, values in enumerate(self.data):
arr = np.asarray(values, dtype=float)
if len(arr) < 2:
continue
kde = _numpy_kde(arr)
y_vals = np.linspace(arr.min(), arr.max(), 100)
dens = kde(y_vals)
max_d = dens.max() or 1
dens = dens / max_d * (self.width / 2)
cx = ax.scale_x(self.positions[i])
# Build mirrored violin path
right_pts = [(cx + d, scale_y(y)) for y, d in zip(y_vals, dens)]
left_pts = [(cx - d, scale_y(y)) for y, d in reversed(list(zip(y_vals, dens)))]
all_pts = right_pts + left_pts
path = "M " + " L ".join(f"{px:.1f},{py:.1f}" for px, py in all_pts) + " Z"
elements.append(
f'<path d="{path}" fill="{self.color}" fill-opacity="0.4" '
f'stroke="{self.color}" stroke-width="1" class="{self.css_class}"/>'
)
if self.show_box:
q1 = float(np.percentile(arr, 25))
q3 = float(np.percentile(arr, 75))
top = min(scale_y(q1), scale_y(q3))
h = abs(scale_y(q3) - scale_y(q1))
elements.append(
f'<rect x="{cx - 5}" y="{top}" width="10" height="{h}" '
f'fill="{self.color}" fill-opacity="0.5"/>'
)
if self.show_median:
med = float(np.median(arr))
elements.append(
f'<line x1="{cx - 7}" x2="{cx + 7}" '
f'y1="{scale_y(med)}" y2="{scale_y(med)}" '
f'stroke="{self.color}" stroke-width="2.5"/>'
)
return "\n".join(elements)