GlyphX
A next-generation Python visualization library — SVG-first, interactive, and built to replace Matplotlib, Seaborn, and Plotly.
GlyphX renders crisp, interactive SVG charts that work everywhere — Jupyter notebooks, CLI pipelines, FastAPI servers, and static HTML files — with zero configuration and no plt.show() required.
View the documentation page here for more detailed explanations of everything.
Why GlyphX?
| Feature | GlyphX | Matplotlib | Seaborn | Plotly |
|---|---|---|---|---|
Auto-display (no show()) |
Everywhere | Notebooks only | Notebooks only | Notebooks only |
| Method chaining API | Yes | No | No | Partial |
DataFrame accessor (df.glyphx.*) |
Yes | No | Partial | No |
| Self-contained HTML size | Tens of KB | n/a | n/a | ~3 MB (inlines plotly.js) |
| Cross-chart click-to-filter | Yes, no server | No | No | Needs Dash |
| Linked interactive brushing | Yes, no server | No | No | Needs Dash |
| Streaming / real-time series | Yes, no server | No | No | Needs a server |
| Statistical significance brackets | Yes | No | Third-party | No |
| Raincloud plot | Yes | No | Third-party | No |
| Sunburst chart | Yes | No | No | Yes |
| Diverging bar | Yes | No | No | Yes |
| Candlestick / OHLC | Yes | Third-party | No | Yes |
| Waterfall / bridge chart | Yes | No | No | Yes |
| Treemap (squarified) | Yes | Third-party | No | Yes |
| Auto large-data downsampling | M4, LTTB and voxel | Rasterises instead | No | No |
| PPTX export | Yes | No | No | No |
CLI tool (glyphx plot data.csv) |
Yes | No | No | No |
| Full ARIA / WCAG 2.1 AA accessibility | Yes | No | No | Partial |
tight_layout() |
Automatic | Manual | Automatic | Automatic |
Matplotlib and Plotly are both mature and broadly capable; the rows above are
where GlyphX differs, not a general scorecard. Notably, both have things
GlyphX does not: Matplotlib ships 3-D plotting (mpl_toolkits.mplot3d), an
ecdf method, and type hints for most public APIs since 3.8, and Plotly's
write_html already produces a fully self-contained offline file — GlyphX's
is simply two orders of magnitude smaller.
Installation
pip install glyphx
# Optional extras
pip install "glyphx[export]" # PNG/JPG raster export (resvg-py + pillow)
pip install "glyphx[cairo]" # alternative PDF backend (cairosvg; PDF needs nothing)
pip install "glyphx[pptx]" # PowerPoint export (python-pptx + cairosvg)
pip install "glyphx[all]" # Everything
Requirements: Python 3.10+ · NumPy ≥ 1.26 · pandas ≥ 2.1
Quick Start
from glyphx import plot
# One-liner — auto-displays in Jupyter, opens browser in CLI
plot([1, 2, 3], [4, 5, 6], kind="line", title="My First Chart")
from glyphx import Figure
from glyphx.series import LineSeries, BarSeries
months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"]
revenue = [120, 145, 132, 178, 159, 203]
costs = [ 80, 90, 85, 105, 98, 115]
fig = (
Figure(width=800, height=500)
.set_title("Revenue vs Costs — H1")
.set_theme("dark")
.set_xlabel("Month")
.set_ylabel("USD (thousands)")
.add(LineSeries(months, revenue, color="#60a5fa", label="Revenue"))
.add(LineSeries(months, costs, color="#f87171", label="Costs", linestyle="dashed"))
.add_stat_annotation("Jan", "Jun", p_value=0.004)
.set_legend("top-left")
.tight_layout()
)
fig.show() # Jupyter inline or browser tab
fig.save("chart.svg") # SVG vector
fig.save("chart.html") # Interactive HTML
fig.save("chart.png") # Raster PNG (requires cairosvg)
fig.save("chart.pptx") # PowerPoint (requires glyphx[pptx])
fig.share("report.html") # Zero-CDN self-contained HTML
Core APIs
plot() — One-liner charts
The fastest path to any chart type. Mirrors pandas' df.plot():
from glyphx import plot
plot([1,2,3], [4,5,6], kind="line", title="Line")
plot(["A","B","C"], [10,20,15], kind="bar", title="Bar")
plot([1,2,3], [4,5,6], kind="scatter", title="Scatter")
plot(data=[30, 40, 30], kind="pie", labels=["A","B","C"])
plot(data=[30, 40, 30], kind="donut", labels=["A","B","C"])
plot(data=raw_values, kind="hist", bins=20)
plot(data=raw_values, kind="box")
plot(data=matrix, kind="heatmap")
Method-Chaining API
Every method returns self. Build the entire chart in one expression:
fig = (
Figure(width=900, height=520, theme="warm")
.set_title("Q3 Performance")
.set_xlabel("Month").set_ylabel("Revenue ($M)")
.set_legend("bottom-right")
.add(LineSeries(x, revenue, label="Revenue"))
.add(BarSeries(x, costs, label="Costs"), use_y2=True)
.annotate("Record High", x=10, y=5.4, arrow=True, color="#dc2626")
.add_stat_annotation("Jan", "Jun", p_value=0.001)
.vline(x=6, color="#888", linestyle="dashed")
.hline(y=5.0, color="#888", linestyle="dotted")
.tight_layout()
.share("dashboard.html")
)
Math in labels
Any label — title, axis label, legend entry, annotation — can carry a $...$
span:
fig.set_ylabel(r"Rate $\frac{dN}{dt}$")
fig.set_xlabel(r"Inverse temperature $\frac{1}{T}$ (K$^{-1}$)")
What's covered:
| Greek | all lower and upper case, plus variants (\varepsilon, \varrho, \varpi) |
| Scripts | x^2, x_i, braced groups x^{n+1} |
| Fractions | \frac{a}{b}, stacked with a rule |
| Functions | \sin, \log, \lim, \max, \det, \arg and ~40 more, set upright |
| Accents | \hat, \bar, \vec, \tilde, \dot, \ddot, \check, \breve |
| Alphabets | \mathbb{R}, \mathcal{L}, \mathfrak{g}, \mathrm, \mathbf |
| Relations | \leq, \subseteq, \cong, \prec, \models, \asymp, … |
| Operators | \oplus, \otimes, \wedge, \setminus, \bigcup, \oint, … |
| Arrows | \to, \mapsto, \longrightarrow, \Leftrightarrow, \hookrightarrow, … |
| Delimiters | \langle, \lceil, \lfloor, and \left/\right (accepted, no sizing) |
| Spacing | \, \: \; \! \quad \qquad |
Around 300 symbols in total. It is a shorthand rather than a full typesetting engine — no matrices or alignment environments, and no delimiter sizing — and it needs no LaTeX installation.
Screen readers get the spoken form, so $\frac{dN}{dt}$ is announced as
dN/dt rather than as a pile of markup.
Heatmap colour ranges
cmap takes any of the named colormaps, and the range can be pinned rather
than taken from the data:
fig.add(HeatmapSeries(corr, cmap="coolwarm", center=0)) # 0 stays neutral
fig.add(HeatmapSeries(a, cmap="viridis", vmin=0, vmax=100)) # comparable panels
center= matters for diverging colormaps. By default a heatmap normalises
over its own min and max, so on a correlation matrix spanning −0.2 to 1.0
zero lands 17% up the ramp — mildly positive values render with the colour
that reads as negative. center=0 widens the narrower side so the neutral
colour sits exactly at zero.
vmin/vmax pin the range so two panels can be read side by side; values
outside it clamp.
Aggregation with confidence bands
Hand it raw repeated measurements — several y values per x, from several
subjects, trials or runs — and it draws the estimate per x with a
bootstrapped confidence band, with no manual groupby first:
fig.aggregate_line(df, x="week", y="score", hue="arm")
estimator= takes "mean" (default), "median", "sum", "min", "max",
"count", or any callable reducing an array to a scalar. ci= takes a
confidence level, "sd" for one standard deviation, "se" for standard
error, or None for the line alone.
The bootstrap is seeded, so redrawing the same data gives an identical band — a figure in a paper should not shift between renders. A group with a single observation gets no interval rather than a fabricated one, and each band takes its own line's colour.
Drop-in pandas backend
Existing df.plot() code becomes GlyphX with one line — no rewrite:
import pandas as pd
pd.options.plotting.backend = "glyphx"
df.plot(x="month", y="revenue") # returns a glyphx Figure
df.plot.bar(x="month", y=["revenue", "costs"])
df.plot.scatter(x="spend", y="revenue")
df.plot(x="month", y=["revenue", "costs"], subplots=True, sharex=True)
Supports line, bar, area, scatter, hist, kde/density, box and
pie, for both DataFrame and Series, plus the usual keyword arguments —
figsize, title, xlabel/ylabel, legend, grid, logx/logy,
colormap, subplots, sharex, stacked. Column resolution matches
matplotlib's: omit x and the index is used, omit y and every numeric
column is drawn.
Anything not supported — hexbin, barh, ax=, secondary_y= — raises
NotImplementedError naming exactly what is missing, rather than silently
dropping part of your call and handing back a chart that looks finished.
The one difference from matplotlib's backend is the return value: you get a
glyphx.Figure, not an Axes, so .show(), .share() and .save() are
available on it.
DataFrame Accessor
Import glyphx once — every pd.DataFrame gains .glyphx:
import pandas as pd
import glyphx # registers accessor automatically
df = pd.read_csv("sales.csv")
# One-liner charts from column names
df.glyphx.line(x="date", y="revenue", title="Daily Revenue")
df.glyphx.bar( x="product", y="sales", title="Sales by Product")
df.glyphx.scatter(x="spend", y="revenue")
df.glyphx.hist(col="response_time", bins=20)
df.glyphx.box(col="score", groupby="region")
df.glyphx.pie(labels="category", values="share")
df.glyphx.donut(labels="segment", values="revenue")
df.glyphx.heatmap(title="Correlation Matrix")
# Groupby aggregation
df.glyphx.bar(groupby="region", y="revenue", agg="sum",
title="Revenue by Region")
# Hue splitting — one BarSeries per unique region value, auto-colored
df.glyphx.bar(x="month", y="revenue", hue="region",
title="Revenue by Month and Region")
# Full chain from the accessor
(df.glyphx
.bar(x="month", y="revenue", auto_display=False)
.set_theme("dark")
.add_stat_annotation("Jan", "Jun", p_value=0.002)
.share("report.html"))
Chart Types
Core charts
from glyphx.series import (
LineSeries, BarSeries, ScatterSeries,
PieSeries, DonutSeries, HistogramSeries,
BoxPlotSeries, HeatmapSeries,
)
# Line — multiple linestyles, error bars
LineSeries(x, y,
color="#2563eb", label="Revenue",
linestyle="dashed", # solid | dashed | dotted | longdash | step
width=2,
yerr=error_values, # symmetric Y error bars with caps
xerr=x_error_values, # symmetric X error bars
)
# Bar — error bars, per-bar color list
BarSeries(x, y,
color="#7c3aed", label="Units",
bar_width=0.7,
yerr=std_errors,
)
# Scatter — continuous color encoding
ScatterSeries(x, y,
c=z_values, # per-point values → colormap
cmap="viridis", # any of 9 built-in colormaps
size=6,
marker="circle", # circle | square
)
# Histogram
HistogramSeries(data, bins=20, color="#0891b2")
# Box plot — single or multi-group
BoxPlotSeries([group_a, group_b, group_c],
categories=["Control", "Drug A", "Drug B"],
box_width=24,
)
# Heatmap — colorbar, row/col labels, value overlay
HeatmapSeries(matrix,
row_labels=row_names,
col_labels=col_names,
show_values=True,
cmap=["#1e40af", "#f0f0f0", "#b91c1c"], # custom diverging
)
Statistical
# ECDF — no bin-width choice needed, shows full distribution
from glyphx.ecdf import ECDFSeries
fig.add(ECDFSeries(data, label="Control", complementary=False))
fig.add(ECDFSeries(data2, label="Treatment"))
# KDE — smooth density curve (no scipy required)
from glyphx.kde import KDESeries
fig.add(KDESeries(data, filled=True, alpha=0.20, label="Density"))
# Area / fill-between
from glyphx.fill_between import FillBetweenSeries
fig.add(FillBetweenSeries(x, y_lower, y_upper, color="#2563eb", alpha=0.25,
label="95% CI"))
# Raincloud — jitter + half-violin + box in one plot
from glyphx.raincloud import RaincloudSeries
fig.add(RaincloudSeries(
data=[control, drug_a, drug_b],
categories=["Control", "Drug A", "Drug B"],
violin_width=35,
))
# Violin plot
from glyphx.violin_plot import ViolinPlotSeries
fig.add(ViolinPlotSeries([grp_a, grp_b], show_median=True, show_box=True))
# Statistical significance brackets (built-in, no extra package)
fig.add_stat_annotation("Control", "Drug A", p_value=0.001) # → ***
fig.add_stat_annotation("Control", "Drug B", p_value=0.031, y_offset=30) # stack
fig.add_stat_annotation("Drug A", "Drug B", p_value=0.18, style="numeric")
Financial
# Candlestick / OHLC
from glyphx.candlestick import CandlestickSeries
fig.add(CandlestickSeries(
dates=["Mon", "Tue", "Wed", "Thu", "Fri"],
open= [150, 153, 149, 155, 158],
high= [155, 157, 153, 160, 162],
low= [148, 151, 146, 154, 156],
close=[153, 149, 155, 158, 160],
))
# Waterfall / bridge chart
from glyphx.waterfall import WaterfallSeries
fig.add(WaterfallSeries(
labels=["Q2 Revenue", "New Sales", "Upsell", "Churn", "Q3 Revenue"],
values=[8_200_000, 2_100_000, 650_000, -420_000, None], # None = auto-total
show_values=True,
))
Hierarchical
# Treemap — squarified layout, area-proportional rectangles
from glyphx.treemap import TreemapSeries
fig.add(TreemapSeries(
labels=["Cloud", "AI", "Mobile", "Security", "Data"],
values=[4200, 3100, 2800, 2100, 1900],
cmap="viridis",
show_values=True,
))
3-D Charts
Use Figure3D for interactive Three.js WebGL output with an SVG fallback.
All 3-D series support per-series threshold= and expose
last_downsample_info after rendering.
from glyphx import Figure3D, plot3d
from glyphx.scatter3d import Scatter3DSeries
from glyphx.surface3d import Surface3DSeries
from glyphx.line3d import Line3DSeries
from glyphx.bar3d import Bar3DSeries
from glyphx.contour import ContourSeries
import numpy as np
# 3-D scatter with colormap
fig = Figure3D(title="Clusters", theme="dark", azimuth=45, elevation=30)
fig.add(Scatter3DSeries(xs, ys, zs, c=zs, cmap="plasma", size=4))
fig.show() # WebGL interactive; .save("chart.html") for sharing
# 3-D surface — auto-decimated for large grids
x = np.linspace(-3, 3, 200)
y = np.linspace(-3, 3, 200)
Z = np.sin(np.sqrt(x[None,:]**2 + y[:,None]**2))
Figure3D().add(Surface3DSeries(x, y, Z, cmap="viridis", wireframe=True)).show()
# Helix polyline
t = np.linspace(0, 4*np.pi, 2000)
Figure3D().add(Line3DSeries(np.cos(t), np.sin(t), t/(4*np.pi))).show()
# One-liner
plot3d(xs, ys, zs, kind="scatter", title="Quick 3D")
plot3d(x, y, Z, kind="surface", title="Quick Surface")
New Chart Types (v1.5+)
# Bubble — scatter with size encoding
from glyphx.bubble import BubbleSeries
fig.add(BubbleSeries(x, y, sizes=market_cap, c=growth, cmap="plasma"))
# Sunburst — multi-ring hierarchy
from glyphx.sunburst import SunburstSeries
fig.add(SunburstSeries(labels=[...], parents=[...], values=[...]))
# Parallel coordinates
from glyphx.parallel_coords import ParallelCoordinatesSeries
fig.add(ParallelCoordinatesSeries(data=df[cols], labels=df["species"]))
# Diverging bar
from glyphx.diverging_bar import DivergingBarSeries
fig.add(DivergingBarSeries(categories=[...], values=[12,-8,21,-3]))
Streaming / Real-Time
from glyphx.streaming import StreamingSeries
fig = Figure(title="Live Sensor Feed")
stream = StreamingSeries(max_points=100, color="#7c3aed", label="Sensor")
fig.add(stream)
# Manual push
stream.push(42.0)
stream.push_many([41.5, 42.3, 43.1])
# Jupyter live mode — re-renders at target FPS, no server needed
with stream.live(fig, fps=10) as s:
for reading in sensor_generator():
s.push(reading)
Large-Data Downsampling
GlyphX automatically keeps SVG files fast on large datasets. All algorithms are fully vectorised with NumPy.
| Series type | Algorithm | Threshold |
|---|---|---|
LineSeries |
Two-stage M4 → LTTB | M4 at 50k pts; LTTB at 5k pts |
ScatterSeries |
2-D voxel grid thinning | 5k pts |
Line3DSeries |
LTTB in screen space (camera-aware) | 5k pts |
Scatter3DSeries |
3-D voxel grid thinning | 5k pts |
Surface3DSeries |
Grid decimation + face culling | 5k faces |
# Per-series threshold override
from glyphx.series import LineSeries
ls = LineSeries(x, y, threshold=1_000) # keep at most 1 000 points
# Inspect what happened after render
ls_info = ls.last_downsample_info
# {'algorithm': 'M4+LTTB', 'original_n': 200000, 'thinned_n': 1000}
# Global kill-switch (thread-local — safe for multi-threaded renderers)
import glyphx.downsample as ds
ds.disable() # no downsampling on this thread
fig.render_svg()
ds.enable()
# Manual use of any algorithm
from glyphx.downsample import lttb, m4, voxel_thin_2d, lttb_3d, decimate_grid
x_down, y_down = lttb(x, y, threshold=2_000)
x_m4, y_m4 = m4(x, y, pixel_width=800)
xt, yt, ct = voxel_thin_2d(xs, ys, c=labels, max_points=5_000)
See the Downsampling docs for the full API, benchmark results, and the test suite.
Interactivity
All charts rendered to HTML include:
| Interaction | How |
|---|---|
| Tooltips | Hover any data point |
| Zoom | Mouse wheel |
| Pan | Click and drag |
| Reset zoom | Double-click |
| Linked brushing | Shift + drag — filters all charts on the page |
| Keyboard navigation | Tab / Arrow keys between data points |
| Legend toggle | Click a legend item to show/hide its series |
| Export | SVG / PNG buttons in the toolbar |
| Synchronized crosshair | fig.enable_crosshair() |
Linked Brushing
Hold Shift and drag a selection rectangle on any chart. All charts on the page with matching X values highlight together and dim non-matching points. Press Escape to clear.
from glyphx.layout import grid
f1 = Figure(auto_display=False).add(ScatterSeries(x, y1, label="Sales"))
f2 = Figure(auto_display=False).add(LineSeries(x, y2, label="Revenue"))
html = grid([f1, f2], rows=1, cols=2)
open("dashboard.html", "w").write(html)
Advanced Layout
# Dual Y-axis
fig.add(LineSeries(x, prices, label="Price (left)"))
fig.add(BarSeries(x, volume, label="Volume (right)"), use_y2=True)
fig.set_ylabel("Price ($)").set_y2label("Volume (units)")
# Log-scale axes
fig = Figure(yscale="log")
fig = Figure(xscale="log", yscale="log")
Right-hand tick rows are derived from the left axis, so the two sets of
gridlines always coincide — including when set_yticks() gives the left axis
a non-default tick count. set_tick_format() applies to all three axes.
# Subplot grid
fig = Figure(rows=2, cols=2, width=1000, height=700)
ax0 = fig.add_axes(0, 0); ax0.add_series(LineSeries(x, y))
ax1 = fig.add_axes(0, 1); ax1.add_series(BarSeries(cats, vals))
ax2 = fig.add_axes(1, 0); ax2.add_series(ScatterSeries(x, y2))
ax3 = fig.add_axes(1, 1); ax3.add_series(HistogramSeries(data))
# Reference lines
fig.vline(x=50, color="#e11d48", linestyle="dashed")
fig.hline(y=3.5, color="#0284c7", linestyle="dotted")
# Text annotations with optional arrows
fig.annotate("Peak", x=10, y=5.4, arrow=True, color="#dc2626", font_size=12)
fig.annotate("Baseline", x=0, y=2.0, anchor="start")
# Auto tight layout (adjusts padding, rotates crowded X labels)
fig.tight_layout()
# Wrap long X labels onto a second line instead of rotating them
fig.bar(["Product Engineering", "Sales & Marketing", "R&D"], [10, 20, 15])
fig.tight_layout().set_tick_wrap()
Shared X axis
Stacked panels that all plot against one X range — the standard layout for a price/volume/indicator stack, or any set of series measured over the same period:
fig = Figure(rows=3, cols=1, width=820, height=640, shared_x=True)
fig.add_axes(0, 0).add_series(LineSeries(t, price))
fig.add_axes(1, 0).add_series(LineSeries(t, volume))
fig.add_axes(2, 0).add_series(LineSeries(t, rsi))
fig.show()
Every cell gets the union of all cells' X domains, so panels line up vertically even when their series cover different spans. X tick labels are drawn only on the lowest occupied cell in each column; grid lines and tick marks stay on all of them, so the alignment is still readable. Sparse grids work — if a column's bottom row is empty, the lowest cell that exists keeps its labels.
Zoom and pan are already synchronised: a subplot grid renders as one <svg>
with the cells as translated groups, and zoom.js works on the SVG viewBox,
so dragging moves every panel together.
Inset axes
A small panel drawn on top of the main plot area, with its own independent scales — for a zoomed detail view, or an overview thumbnail beside a zoomed main chart:
from glyphx import Figure, LineSeries
fig = Figure(width=820, height=520).line(x, y, label="full range")
inset = fig.inset_axes(0.55, 0.14, 0.38, 0.34) # x, y, w, h as 0-1 fractions
inset.add_series(LineSeries(x[:45], y[:45]))
fig.show()
Position and size are fractions of the figure canvas, not the plot area, so
an inset stays where you put it regardless of how padding changes. The panel
inherits the parent's theme unless you pass theme=, gets a padding scaled to
its own size, and draws on an opaque background so the parent's grid lines
don't show through — pass background="none" for a transparent panel. Insets
render last and in the order added, so a later one overlaps an earlier one.
Wrapping long tick labels
set_tick_wrap() is an alternative to GlyphX's default auto-rotation. Rotation
is compact but harder to read; wrapping keeps labels horizontal by splitting
them across up to two lines. Labels that already fit are left alone, and the
two are mutually exclusive — enabling wrap suppresses rotation for that axes.
Colormaps
Nine perceptually-uniform colormaps:
| Name | Type | Best for |
|---|---|---|
viridis |
Sequential | Default continuous encoding |
plasma |
Sequential | High-contrast continuous |
inferno |
Sequential | Print-safe dark backgrounds |
magma |
Sequential | Heatmaps and density |
cividis |
Sequential | Deuteranopia-safe |
coolwarm |
Diverging | Correlation matrices |
rdbu |
Diverging | Positive / negative values |
spectral |
Multi-hue | Categorical ranges |
greys |
Sequential | Monochrome / print export |
from glyphx.colormaps import apply_colormap, colormap_colors, list_colormaps
apply_colormap(0.75, "plasma") # → "#eb5f34"
colormap_colors("viridis", 6) # → list of 6 hex colors
list_colormaps() # → ["cividis", "coolwarm", ...]
# Color-encode scatter by a third variable
ScatterSeries(x, y, c=z_values, cmap="inferno")
Themes
Seven built-in themes:
Figure(theme="default") # clean white background
Figure(theme="dark") # charcoal background
Figure(theme="colorblind") # Okabe-Ito palette — safe for all color vision types
Figure(theme="pastel") # soft, presentation-friendly
Figure(theme="warm") # earthy tones, Georgia serif font
Figure(theme="ocean") # blue palette, light blue background
Figure(theme="monochrome") # grayscale, print-safe
# Custom theme dict
Figure(theme={
"colors": ["#ff6b6b", "#4ecdc4", "#45b7d1"],
"background": "#1a1a2e",
"text_color": "#eeeeee",
"axis_color": "#555555",
"grid_color": "#333333",
"font": "Roboto, sans-serif",
})
# Mid-chain theme swap
fig.set_theme("dark")
Registering your own theme
A theme dict works with Figure(theme=...), but everything else — df.glyphx.*,
facet_plot, clustermap, Figure3D, the CLI — takes a theme name. Register
one and it works everywhere:
from glyphx import register_theme, list_themes
register_theme(
"acme",
base="dark", # inherit unspecified keys
colors=["#e6194b", "#3cb44b", "#4363d8"],
font="Inter, sans-serif",
)
Figure(theme="acme").line(x, y).show()
df.glyphx.scatter(x="a", y="b", theme="acme")
list_themes() # ['acme', 'colorblind', 'dark', 'default', ...]
register_theme validates as it goes: a misspelled key (colours) or a bad
colors value is rejected with a message naming the problem, and built-in
names are protected from being overwritten. Unknown theme names now raise
rather than silently falling back to default:
Figure(theme="darkk")
# ValueError: Unknown theme 'darkk'. Did you mean 'dark'? Available: ...
Series without an explicit color= cycle through the active theme's palette,
so a three-line chart on colorblind gets three distinguishable Okabe-Ito
colors rather than three identical blues. The same applies to the multi-color
types — pie, donut, grouped bar and stacked bar all take their slice and
segment colors from the active theme. Colormap-driven charts (treemap,
raincloud, bump) keep their cmap.
Accessibility note: The
colorblindtheme uses the Okabe-Ito palette — the scientific standard for color-vision-deficiency-safe visualization. It is safe for deuteranopia, protanopia, and tritanopia.
Export Options
fig.save("chart.svg") # SVG vector — scales to any size
fig.save("chart.html") # interactive HTML with tooltips, zoom, export buttons
fig.save("chart.pdf") # vector PDF — no extra packages needed
fig.save("chart.png") # raster PNG (requires: pip install "glyphx[export]")
fig.save("chart.jpg") # raster JPG (requires: pip install "glyphx[export]")
fig.save("chart.pptx") # PowerPoint slide (requires: pip install "glyphx[pptx]")
# PDF is written by a built-in pure-Python writer, so it works in a bare
# virtualenv or CI container with no system libraries and no browser. The
# output is true vector — paths stay paths and text stays selectable, so it
# scales without pixelation and can be searched and copied.
# Self-contained HTML — all JS inlined, works fully offline
html_str = fig.share() # returns string
html_str = fig.share("report.html") # also writes to disk
html_str = fig.share(title="Q3 Report") # custom <title> tag
fig.share() inlines all JavaScript so the output works in:
email clients · Confluence · Notion · GitHub Pages · air-gapped environments
Reacting to a clicked point
Clicking a point dispatches a glyphx:select event on document, so anything
else on the page can update itself — a detail panel, a table, an image, a
second chart, a request to your own endpoint. Clicking the same point again,
or pressing Escape, dispatches glyphx:deselect.
fig.add(ScatterSeries(
x, y,
meta=[{"customer": "Acme", "orders": 42},
{"customer": "Beta", "orders": 17}],
))
fig.share("chart.html")
<div id="detail">Click a point</div>
<script>
document.addEventListener('glyphx:select', (e) => {
const { x, y, label, series, meta } = e.detail;
detail.textContent = `${meta.customer} — ${meta.orders} orders`;
});
</script>
meta is whatever you passed in Python, parsed back from JSON, so the listener
receives the structure you wrote rather than a flattened string.
A detail panel without writing JavaScript
For the common case — click a point, show its record — add_detail_panel()
does the wiring for you:
fig.add(ScatterSeries(x, y, meta=records))
fig.add_detail_panel(["customer", "region", "tier"], title="Selected customer")
fig.share("chart.html")
The panel renders beside the chart, fills in on click, and returns to its
empty message on Escape or a second click. fields fixes the display order
and omits anything else; leave it out to show whatever each point carries.
It is an ordinary listener on the same glyphx:select event, so it composes
rather than competes — your own listeners still fire for the same click, and
cross-filtering still applies. Values render as text, never markup.
What each chart type reports
Every chart type answers a click with the values it is read for, so a pie gives you a share and a candlestick gives you OHLC:
| Chart | x |
y |
also in detail.data |
|---|---|---|---|
| line, scatter, bar | category or position | value | |
| pie, donut | slice label | value | percent |
| treemap | tile label | value | percent |
| sunburst | node label | value | |
| box plot | category | median | q1, q2, q3, median |
| candlestick | date | close | open, high, low, close |
| stacked, grouped bar | category | segment value | |
| waterfall, diverging bar | step label | delta | |
| histogram | bin | count | |
ECDF (show_points=True) |
value | cumulative probability |
detail.data carries every data- attribute the element has, so a listener
receives whatever that chart type knows about the thing that was clicked
rather than only the fields all types share. Any of those names can also go
straight into add_detail_panel(fields=[...]). The selected
point gets an outline rather than a colour change, since colour is data.
Events rather than a callback registry: any number of listeners can attach
without knowing about each other, and it composes with the rest — on a chart
with enable_crossfilter(), one click both filters the other charts and emits
the selection. Everything runs in the exported file; there is no server.
Zoom and pan
Scroll to zoom, drag to pan, double-click empty space to reset. Axis labels are redrawn for whatever region is visible, so a zoomed chart still tells you what you are looking at — they are not static text that scrolls away with the rest of the drawing.
Linear axes only; a log axis keeps its original ticks.
On a touch device, one finger pans and two pinch to zoom, anchored on the midpoint between them. The toolbar wraps rather than overflowing a phone screen, so a chart you share by email is usable on the device most people will open it on.
A Reset view button appears in the toolbar as soon as the view moves, and disappears once you are back to the default — so the way out is visible exactly when it is wanted, rather than relying on knowing the double-click gesture. It restores zoom, position and axis labels together, and resets every chart on the page.
Brushing
Shift and drag to select a region. Matching points stay lit, the rest fade, and a readout shows what you selected — count, mean, sum and range — updating live as the rectangle grows rather than only once you let go. Escape clears it.
Non-numeric values are skipped, so a categorical axis still reports a count. The readout is an ARIA live region, so the numbers are announced rather than being visual-only.
Filter controls
Checkboxes, radio buttons and a search box that filter the chart in the browser — no server, no callbacks:
fig.add(ScatterSeries(x, y, meta=records))
fig.add_controls(checkboxes="region", radio="tier", search="customer",
title="Filter")
You name a field; GlyphX reads the distinct values out of the data and
builds one control per value. A field is found wherever it lives — in a
point's meta, in its own data- attributes (percent on a pie, close on
a candlestick), or as the series label via "series".
Filters combine with AND, which is how a stack of controls reads: tick two regions and type a name and you get that name within those regions. A running "Showing 12 of 40" sits underneath and is announced to screen readers.
Checkboxes start ticked, and radio groups get an "All" option — a panel that
hides your data on load looks broken, and a radio group without "All" is a
one-way trip. labels= gives friendlier captions, reset=False drops the
"Show all" button.
Cross-chart filtering
Click a bar, point or slice and every other opted-in chart on the page dims everything that doesn't share that x value:
revenue = Figure().bar(months, revenue).enable_crossfilter()
costs = Figure().bar(months, costs).enable_crossfilter()
SubplotGrid(1, 2).add(revenue, 0, 0).add(costs, 0, 1).save("dashboard.html")
Click "Feb" in either chart and February stays lit in both while the other months recede. Click it again, or press Escape, to clear.
This runs entirely inside the exported HTML — no server, no callback round-trip, nothing leaving the page. Linked views in Plotly need Dash, and in Bokeh need a Bokeh server; here it's a static file you can email.
Charts opt in individually, so a page can mix filtered and independent charts.
The x value is the join key, since data-x is already on every drawn element.
Filter changes are announced through an ARIA live region, and elements are
keyboard-reachable, so Enter and Space filter as well as clicking.
Multi-figure export
SubplotGrid lays out several independent figures on one page, and now saves
directly:
from glyphx import Figure
from glyphx.figure import SubplotGrid
sg = SubplotGrid(2, 2)
sg.add(revenue_fig, 0, 0)
sg.add(costs_fig, 0, 1)
sg.save("dashboard.html") # composite page, all figures inline
sg.save("quarterly_review.pptx") # one slide per figure, row-major order
Each PPTX slide is titled from that figure's own title. Empty grid cells are
skipped. Single-image formats (.svg, .png, .pdf) are rejected with a
pointer to saving each Figure individually — the grid's cells are separate
<svg> documents, so there is no one image to rasterise.
CLI Tool
Plot any CSV, JSON, or Excel file from the terminal — no Python script needed:
# Basic
glyphx plot sales.csv --x month --y revenue --kind bar -o chart.html
# Full options
glyphx plot data.csv \
--x date --y revenue \
--kind line \
--groupby region \
--agg sum \
--theme dark \
--title "Monthly Revenue" \
--xlabel "Date" --ylabel "Revenue ($M)" \
--width 900 --height 500 \
--no-legend \
-o report.html \
--open # auto-open in browser after rendering
# Print version
glyphx version
Supported inputs: .csv .tsv .json .jsonl .xlsx .xls
Supported outputs: .svg .html .png .jpg .pptx
Accessibility
Every GlyphX chart meets WCAG 2.1 AA standards automatically:
role="img"andaria-labelledbyon every<svg>root<title>and<desc>landmark elements with auto-generated descriptionstabindex="0"androle="graphics-symbol"on every interactive data pointTab/Arrowkeys navigate between data pointsEnter/Spacetriggers tooltips from keyboardEscapedismisses and blursfocusable="false"prevents focus stealing
# Auto-generated plain-English description for screen readers
print(fig.to_alt_text())
# → 'Line chart titled "Monthly Revenue". X axis: Month. Y axis: USD.
# Series "Revenue": 12 data points. Ranges from 98 (Mar) to 203 (Dec).'
Type Annotations
GlyphX ships with a py.typed marker (PEP 561). All public APIs have complete type hints:
from glyphx import Figure
from glyphx.series import LineSeries
fig: Figure = Figure(width=640, height=480, theme="dark")
s: LineSeries = LineSeries([1, 2, 3], [4, 5, 6], label="Revenue")
fig.add(s).set_title("Typed Chart").tight_layout().show()
Works with mypy, pyright, and all major IDEs out of the box.
Comparison with Matplotlib
# Matplotlib — 12 lines, and the output is a static image
import matplotlib.pyplot as plt
fig, ax = plt.subplots()
ax.plot(months, revenue, color="blue", label="Revenue")
ax.plot(months, costs, color="red", label="Costs", linestyle="--")
ax.set_title("Revenue vs Costs")
ax.set_xlabel("Month")
ax.set_ylabel("USD")
ax.legend(loc="upper left")
plt.tight_layout()
plt.show()
# GlyphX — 7 lines, interactive, shareable
(Figure()
.set_title("Revenue vs Costs")
.set_xlabel("Month").set_ylabel("USD")
.add(LineSeries(months, revenue, color="#2563eb", label="Revenue"))
.add(LineSeries(months, costs, color="#dc2626", label="Costs", linestyle="dashed"))
.set_legend("top-left").tight_layout()
.share("report.html"))
Comparison with Seaborn
# Seaborn — significance brackets need a separate package
import seaborn as sns
from statannotations.Annotator import Annotator
ax = sns.barplot(data=df, x="group", y="score")
annotator = Annotator(ax, [("Control","Drug A")], data=df, x="group", y="score")
annotator.configure(test="t-test_ind", text_format="star")
annotator.apply_and_annotate()
# GlyphX — built-in, no extra package
(Figure()
.add(BarSeries(["Control","Drug A","Drug B"], means, yerr=errors))
.add_stat_annotation("Control", "Drug A", p_value=0.001)
.add_stat_annotation("Control", "Drug B", p_value=0.031, y_offset=30)
.show())
Comparison with Plotly
# Plotly — self-contained by default, but inlines ~3 MB of plotly.js
import plotly.express as px
fig = px.line(df, x="month", y="revenue")
fig.write_html("chart.html") # ~3 MB, works offline
fig.write_html("chart.html", include_plotlyjs="cdn") # ~40 KB, needs the CDN
# GlyphX — self-contained *and* small; there is no CDN variant to pick
fig.share("chart.html") # tens of KB, all JS inlined
Both produce a file you can open offline. The difference is that Plotly makes you trade size against a CDN dependency, and GlyphX does not — its inlined JavaScript is a few tens of KB rather than three megabytes.
Full API Reference
Figure
| Method | Returns | Description |
|---|---|---|
Figure(width, height, theme, rows, cols, legend, xscale, yscale) |
Figure |
Create figure |
.add(series, use_y2=False) |
Figure |
Add a series |
.line(x, y, ...) |
Figure |
Shorthand LineSeries |
.bar(x, y, ...) |
Figure |
Shorthand BarSeries |
.scatter(x, y, ...) |
Figure |
Shorthand ScatterSeries |
.hist(data, ...) |
Figure |
Shorthand HistogramSeries |
.box(data, ...) |
Figure |
Shorthand BoxPlotSeries |
.heatmap(matrix, ...) |
Figure |
Shorthand HeatmapSeries |
.pie(values, ...) |
Figure |
Shorthand PieSeries |
.donut(values, ...) |
Figure |
Shorthand DonutSeries |
.area(x, y1, y2, ...) |
Figure |
Shorthand FillBetweenSeries |
.kde(data, ...) |
Figure |
Shorthand KDESeries |
.ecdf(data, ...) |
Figure |
Shorthand ECDFSeries |
.raincloud(data, ...) |
Figure |
Shorthand RaincloudSeries |
.candlestick(dates, o, h, l, c) |
Figure |
Shorthand CandlestickSeries |
.waterfall(labels, values, ...) |
Figure |
Shorthand WaterfallSeries |
.treemap(labels, values, ...) |
Figure |
Shorthand TreemapSeries |
.stream(max_points, ...) |
StreamingSeries |
Add streaming series; returns stream |
.vline(x, ...) |
Figure |
Vertical reference line |
.hline(y, ...) |
Figure |
Horizontal reference line |
.set_title(text) |
Figure |
Chart title |
.set_theme(name_or_dict) |
Figure |
Apply theme |
.set_size(width, height) |
Figure |
Resize canvas |
.set_xlabel(text) |
Figure |
X-axis label |
.set_ylabel(text) |
Figure |
Y-axis label |
.set_y2label(text) |
Figure |
Right-hand Y-axis label (no-op without use_y2 series) |
.set_legend(position) |
Figure |
Legend position or False |
.set_tick_format(fn) |
Figure |
Tick label formatter, applied to X, Y1 and Y2 |
.set_minor_ticks(n) |
Figure |
Minor tick subdivisions |
.set_tick_wrap(enabled) |
Figure |
Wrap long X tick labels instead of rotating |
.add_axes(row, col) |
Axes |
Get / create subplot cell |
.inset_axes(x, y, w, h) |
Axes |
Panel drawn over the plot area, own scales |
.annotate(text, x, y, ...) |
Figure |
Text annotation with optional arrow |
.add_stat_annotation(x1, x2, p_value, ...) |
Figure |
Significance bracket |
.tight_layout() |
Figure |
Auto-adjust padding and rotate labels |
.enable_crosshair() |
Figure |
Synchronized crosshair |
.to_alt_text() |
str |
Screen-reader description |
.show() |
Figure |
Display in Jupyter or browser |
.save(filename) |
Figure |
Write SVG / HTML / PNG / JPG / PPTX |
.share(filename, title) |
str |
Generate self-contained HTML |
.render_svg() |
str |
Raw SVG string |
DataFrame Accessor (df.glyphx.*)
| Method | Description |
|---|---|
.line(x, y, yerr, ...) |
Line chart |
.bar(x, y, groupby, agg, yerr, ...) |
Bar chart with optional groupby |
.scatter(x, y, ...) |
Scatter plot |
.hist(col, bins, ...) |
Histogram of a column |
.box(col, groupby, ...) |
Box plot, optional multi-group |
.pie(labels, values, ...) |
Pie chart |
.donut(labels, values, ...) |
Donut chart |
.heatmap(...) |
Heatmap from numeric columns |
.plot(kind, x, y, ...) |
Unified dispatcher |
All accessor methods return Figure for chaining.
An unrecognised column name raises KeyError naming the closest match, rather
than being silently ignored:
df.glyphx.line(x="Month", y="revenue")
# KeyError: "Column 'Month' not found. Did you mean 'month'?
# Available columns: ['month', 'revenue', 'region']"
Applies to x, y, yerr, hue and groupby. Omitting x is still valid
and falls back to the row index — only a name that doesn't exist is an error.
CLI
| Command | Description |
|---|---|
glyphx plot <file> [options] |
Render a chart from a data file |
glyphx version |
Print version and exit |
Running the Examples
git clone https://github.com/kjkoeller/glyphx
cd glyphx
pip install -e ".[all]"
python examples.py # generates HTML files in ./glyphx_output/
OPEN=1 python examples.py # also auto-opens each chart in browser
Contributing
Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.
# Setup dev environment
git clone https://github.com/kjkoeller/glyphx
cd glyphx
pip install -e ".[all]"
pip install pytest pytest-cov
# Run the test suite
pytest tests/ -v
# Run with coverage
pytest tests/ --cov=glyphx --cov-report=term-missing
Please ensure all new chart types include:
- A series class with a
to_svg(ax)method - Tests in
tests/ - A
to_alt_text()compatible description - An entry in
__init__.pyand__all__
License
MIT License — © 2025 Kyle Koeller and GlyphX contributors.
See LICENSE for the full text.
Release files for glyphx 4.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| glyphx-4.0.0.tar.gz | 1.3 MB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| glyphx-4.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 1.6 MB
Release files / glyphx-4.0.0.tar.gz
| Download URL | glyphx-4.0.0.tar.gz |
|---|---|
| Size | 1.3 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
b703f7a9dcccc9d5404486fcd2a3258ee7773ec001f7f198c6e73a4cb481bb7a
|
|
BLAKE2b-256 checksum How to use checksums |
1b066d703ff5ded24b173a3fd6b6b7d99804a0c0265dc177d96f45bb73d9665b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 11, 2026.
Transparency logRelease files / glyphx-4.0.0-py3-none-any.whl
| Download URL | glyphx-4.0.0-py3-none-any.whl |
|---|---|
| Size | 284.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
db5c3349c403f594eb35c8f80082bade780e25073d938d165676d1e90e9f806b
|
|
BLAKE2b-256 checksum How to use checksums |
de47243710e1006d295ada768112cded3489d8f1032fb1d65800453c8d4ef982
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 11, 2026.
Transparency log