Source code for glyphx.figure
"""
GlyphX Figure: top-level chart canvas with rendering, display, and export.
"""
from __future__ import annotations
import re
import webbrowser
from tempfile import NamedTemporaryFile
from typing import Any
from .layout import Axes
from .utils import (
wrap_svg_with_template,
write_svg_file,
wrap_svg_canvas,
draw_legend,
svg_escape,
)
[docs]
class Figure:
"""
Central class for creating and rendering GlyphX visualizations.
Every mutating method returns ``self`` so calls can be chained::
fig = (
Figure(width=900)
.set_theme("dark")
.set_title("Monthly Revenue")
.add(LineSeries(months, revenue, label="Revenue"))
.annotate("Peak", x="Oct", y=5400)
.share("report.html")
)
Args:
width: Canvas width in pixels.
height: Canvas height in pixels.
padding: Inner margin between canvas edge and plot area.
title: Optional title rendered above all plots.
theme: Theme name (str) or custom theme dict.
rows: Number of subplot rows.
cols: Number of subplot columns.
auto_display: Auto-render when ``plot()`` or ``__repr__`` is called.
legend: Legend position string, or ``False`` to suppress.
xscale: ``"linear"`` or ``"log"``.
yscale: ``"linear"`` or ``"log"``.
"""
def __init__(
self,
width: int = 640,
height: int = 480,
padding: int = 50,
title: str | None = None,
theme: str | dict[str, Any] | None = None,
rows: int = 1,
cols: int = 1,
auto_display: bool = True,
legend: str | bool | None = "outside-right",
xscale: str = "linear",
yscale: str = "linear",
) -> None:
self.width = width
self.height = height
self.padding = padding
self.title = title
self.rows = rows
self.cols = cols
self.auto_display = auto_display
self.xscale = xscale
self.yscale = yscale
from .themes import themes
self._theme_name: str = (
theme if isinstance(theme, str) and theme in themes
else ("custom" if isinstance(theme, dict) else "default")
)
self.theme: dict[str, Any] = (
themes.get(theme, themes["default"])
if isinstance(theme, str)
else (theme or themes["default"])
)
self.legend_pos: str | None = (
None if legend in (False, None) else str(legend)
)
self.grid: list[list[Axes | None]] = [
[None] * self.cols for _ in range(self.rows)
]
# When the legend sits to the right of the chart area, shrink the
# axes so the chart data never overlaps the legend. The legend
# occupies the right margin within the same total canvas width.
from .utils import LEGEND_GUTTER
# Reserve right gutter for legend regardless of position string.
# This prevents legends from ever overlapping chart data.
_axes_width = self.width - LEGEND_GUTTER if legend not in (False, None) else self.width
self.axes = Axes(
width=_axes_width,
height=self.height,
padding=self.padding,
theme=self.theme,
xscale=xscale,
yscale=yscale,
)
self.series: list[tuple[Any, bool]] = []
self._annotations: list[dict[str, Any]] = []
self._canvas_texts: list[dict[str, Any]] = []
self._supxlabel: dict | None = None
self._supylabel: dict | None = None
# ── Fluent setters ───────────────────────────────────────────────────
[docs]
def set_title(self, title: str) -> Figure:
"""Set the figure title and return ``self`` for chaining."""
self.title = title
return self
[docs]
def set_theme(self, theme: str | dict[str, Any]) -> Figure:
"""Apply a named theme or a custom dict and return ``self``."""
from .themes import themes
self._theme_name = (
theme if isinstance(theme, str) and theme in themes
else ("custom" if isinstance(theme, dict) else "default")
)
self.theme = (
themes.get(theme, themes["default"])
if isinstance(theme, str)
else theme
)
self.axes.theme = self.theme
return self
[docs]
def set_size(self, width: int, height: int) -> Figure:
"""Resize the canvas and return ``self``."""
self.width = width
self.height = height
self.axes.width = width
self.axes.height = height
return self
[docs]
def set_xlabel(self, label: str) -> Figure:
"""Set the X-axis label and return ``self``."""
self.axes.xlabel = label
return self
[docs]
def set_ylabel(self, label: str) -> Figure:
"""Set the Y-axis label and return ``self``."""
self.axes.ylabel = label
return self
[docs]
def set_legend(self, position: str | bool | None) -> Figure:
"""Set legend position (or ``False`` to hide) and return ``self``."""
self.legend_pos = None if position in (False, None) else str(position)
return self
# ── Subplot grid ─────────────────────────────────────────────────────
[docs]
def add_axes(self, row: int = 0, col: int = 0) -> Axes:
"""Create or retrieve the Axes at a grid position."""
if self.grid[row][col] is None:
self.grid[row][col] = Axes(
width=self.width // self.cols,
height=self.height // self.rows,
padding=self.padding,
theme=self.theme,
xscale=self.xscale,
yscale=self.yscale,
)
return self.grid[row][col] # type: ignore[return-value]
# ── Series management ─────────────────────────────────────────────────
[docs]
def add(self, series: Any, use_y2: bool = False) -> Figure:
"""
Add a series to the figure.
Returns ``self`` so calls can be chained::
fig.add(LineSeries(...)).add(BarSeries(...)).show()
"""
self.series.append((series, use_y2))
if hasattr(series, "x") and hasattr(series, "y"):
self.axes.add_series(series, use_y2)
return self
# ── Shorthand series methods ──────────────────────────────────────────
# These mirror the long-form Figure().add(XxxSeries(...)) API so users
# can write fig.line(x, y) instead of importing and constructing manually.
# Every method returns self for chaining and accepts the same kwargs as
# the underlying series class.
[docs]
def line(self, x, y, color=None, label=None, linestyle="solid",
width=2, yerr=None, xerr=None, use_y2=False, **kwargs) -> "Figure":
"""Add a :class:`~glyphx.series.LineSeries`. Returns ``self``."""
from .series import LineSeries
return self.add(LineSeries(x, y, color=color, label=label,
linestyle=linestyle, width=width,
yerr=yerr, xerr=xerr, **kwargs), use_y2)
[docs]
def bar(self, x, y, color=None, label=None, bar_width=0.8,
yerr=None, use_y2=False, **kwargs) -> "Figure":
"""Add a :class:`~glyphx.series.BarSeries`. Returns ``self``."""
from .series import BarSeries
return self.add(BarSeries(x, y, color=color, label=label,
bar_width=bar_width, yerr=yerr, **kwargs), use_y2)
[docs]
def scatter(self, x, y, color=None, label=None, size=5,
c=None, cmap="viridis", use_y2=False, **kwargs) -> "Figure":
"""Add a :class:`~glyphx.series.ScatterSeries`. Returns ``self``."""
from .series import ScatterSeries
return self.add(ScatterSeries(x, y, color=color, label=label,
size=size, c=c, cmap=cmap, **kwargs), use_y2)
[docs]
def hist(self, data, bins=20, color=None, label=None, **kwargs) -> "Figure":
"""Add a :class:`~glyphx.series.HistogramSeries`. Returns ``self``."""
from .series import HistogramSeries
return self.add(HistogramSeries(data, bins=bins,
color=color, label=label, **kwargs))
[docs]
def box(self, data, categories=None, color=None, box_width=20,
**kwargs) -> "Figure":
"""Add a :class:`~glyphx.series.BoxPlotSeries`. Returns ``self``."""
from .series import BoxPlotSeries
return self.add(BoxPlotSeries(data, categories=categories,
color=color or "#1f77b4",
box_width=box_width, **kwargs))
[docs]
def heatmap(self, matrix, row_labels=None, col_labels=None,
show_values=False, cmap=None, **kwargs) -> "Figure":
"""Add a :class:`~glyphx.series.HeatmapSeries`. Returns ``self``."""
from .series import HeatmapSeries
return self.add(HeatmapSeries(matrix, row_labels=row_labels,
col_labels=col_labels,
show_values=show_values,
cmap=cmap, **kwargs))
[docs]
def pie(self, values, labels=None, colors=None, **kwargs) -> "Figure":
"""Add a :class:`~glyphx.series.PieSeries`. Returns ``self``."""
from .series import PieSeries
return self.add(PieSeries(values=values, labels=labels,
colors=colors, **kwargs))
[docs]
def donut(self, values, labels=None, colors=None, **kwargs) -> "Figure":
"""Add a :class:`~glyphx.series.DonutSeries`. Returns ``self``."""
from .series import DonutSeries
return self.add(DonutSeries(values=values, labels=labels,
colors=colors, **kwargs))
[docs]
def area(self, x, y1, y2=0, color=None, alpha=0.25,
line_width=1, label=None, **kwargs) -> "Figure":
"""Add a :class:`~glyphx.fill_between.FillBetweenSeries`. Returns ``self``."""
from .fill_between import FillBetweenSeries
return self.add(FillBetweenSeries(x, y1, y2, color=color or "#1f77b4",
alpha=alpha, line_width=line_width,
label=label, **kwargs))
[docs]
def kde(self, data, filled=False, alpha=0.20, color=None,
width=2, label=None, **kwargs) -> "Figure":
"""Add a :class:`~glyphx.kde.KDESeries`. Returns ``self``."""
from .kde import KDESeries
return self.add(KDESeries(data, filled=filled, alpha=alpha,
color=color or "#1f77b4", width=width,
label=label, **kwargs))
[docs]
def ecdf(self, data, color=None, label=None,
complementary=False, **kwargs) -> "Figure":
"""Add a :class:`~glyphx.ecdf.ECDFSeries`. Returns ``self``."""
from .ecdf import ECDFSeries
return self.add(ECDFSeries(data, color=color, label=label,
complementary=complementary, **kwargs))
[docs]
def raincloud(self, data, categories=None, seed=42,
violin_width=40, **kwargs) -> "Figure":
"""Add a :class:`~glyphx.raincloud.RaincloudSeries`. Returns ``self``."""
from .raincloud import RaincloudSeries
series = RaincloudSeries(data, categories=categories,
seed=seed, violin_width=violin_width, **kwargs)
series._x_categories = series.categories
return self.add(series)
[docs]
def candlestick(self, dates, open, high, low, close,
label=None, **kwargs) -> "Figure":
"""Add a :class:`~glyphx.candlestick.CandlestickSeries`. Returns ``self``."""
from .candlestick import CandlestickSeries
return self.add(CandlestickSeries(dates, open, high, low, close,
label=label, **kwargs))
[docs]
def waterfall(self, labels, values, show_values=True, **kwargs) -> "Figure":
"""Add a :class:`~glyphx.waterfall.WaterfallSeries`. Returns ``self``."""
from .waterfall import WaterfallSeries
return self.add(WaterfallSeries(labels=labels, values=values,
show_values=show_values, **kwargs))
[docs]
def treemap(self, labels, values, cmap="viridis",
show_values=True, **kwargs) -> "Figure":
"""Add a :class:`~glyphx.treemap.TreemapSeries`. Returns ``self``."""
from .treemap import TreemapSeries
return self.add(TreemapSeries(labels=labels, values=values,
cmap=cmap, show_values=show_values,
**kwargs))
[docs]
def bubble(self, x, y, size, color=None, c=None, cmap="viridis",
alpha=0.65, min_radius=4, max_radius=40,
labels=None, label=None, use_y2=False, **kwargs) -> "Figure":
"""Add a :class:`~glyphx.bubble.BubbleSeries`. Returns ``self``."""
from .bubble import BubbleSeries
return self.add(BubbleSeries(x, y, size, color=color, c=c, cmap=cmap,
alpha=alpha, min_radius=min_radius,
max_radius=max_radius, labels=labels,
label=label, **kwargs), use_y2)
[docs]
def sunburst(self, labels, parents, values, cmap="viridis",
**kwargs) -> "Figure":
"""Add a :class:`~glyphx.sunburst.SunburstSeries`. Returns ``self``."""
from .sunburst import SunburstSeries
return self.add(SunburstSeries(labels=labels, parents=parents,
values=values, cmap=cmap, **kwargs))
[docs]
def parallel_coords(self, data, axes, hue=None, cmap="viridis",
alpha=0.45, **kwargs) -> "Figure":
"""Add a :class:`~glyphx.parallel_coords.ParallelCoordinatesSeries`.
Returns ``self``."""
from .parallel_coords import ParallelCoordinatesSeries
return self.add(ParallelCoordinatesSeries(data=data, axes=axes,
hue=hue, cmap=cmap,
alpha=alpha, **kwargs))
[docs]
def diverging_bar(self, categories, values, pos_color="#2563eb",
neg_color="#dc2626", show_values=True,
**kwargs) -> "Figure":
"""Add a :class:`~glyphx.diverging_bar.DivergingBarSeries`.
Returns ``self``."""
from .diverging_bar import DivergingBarSeries
return self.add(DivergingBarSeries(categories=categories, values=values,
pos_color=pos_color, neg_color=neg_color,
show_values=show_values, **kwargs))
[docs]
def stream(self, max_points=100, color=None, label=None,
**kwargs) -> "Figure":
"""Add a :class:`~glyphx.streaming.StreamingSeries` and return it.
Unlike other shorthand methods, this returns the *series* (not self)
so callers can push data to it::
stream = fig.stream(max_points=100, label="Sensor")
stream.push(42.0)
"""
from .streaming import StreamingSeries
s = StreamingSeries(max_points=max_points,
color=color or "#7c3aed",
label=label, **kwargs)
self.add(s)
return s
[docs]
def axhspan(self, ymin: float, ymax: float,
color: str = "#facc15", alpha: float = 0.20,
label: str | None = None) -> "Figure":
"""
Add a horizontal shaded band across the full chart width.
Equivalent to Matplotlib's ``ax.axhspan()``. Y values are in
data space; the band is clipped to the plot area.
Args:
ymin: Lower Y bound (data units).
ymax: Upper Y bound (data units).
color: Fill color.
alpha: Fill opacity 0–1.
label: Optional legend label.
Returns:
``self`` for chaining.
Example::
fig.axhspan(90, 110, color="#22c55e", alpha=0.15, label="Target range")
"""
self.axes.axhspan(ymin, ymax, color=color, alpha=alpha, label=label)
return self
[docs]
def axvspan(self, xmin, xmax,
color: str = "#a855f7", alpha: float = 0.20,
label: str | None = None) -> "Figure":
"""
Add a vertical shaded band across the full chart height.
Equivalent to Matplotlib's ``ax.axvspan()``. X values may be
numeric data values or category label strings.
Args:
xmin: Left X bound (data units or category label).
xmax: Right X bound.
color: Fill color.
alpha: Fill opacity 0–1.
label: Optional legend label.
Returns:
``self`` for chaining.
Example::
fig.axvspan("Jul", "Sep", color="#f59e0b", alpha=0.15, label="Q3")
"""
self.axes.axvspan(xmin, xmax, color=color, alpha=alpha, label=label)
return self
[docs]
def set_xticks(self, ticks: list,
labels: list | None = None) -> "Figure":
"""
Set explicit X-axis tick positions (and optionally their labels).
Equivalent to Matplotlib's ``ax.set_xticks()`` + ``ax.set_xticklabels()``.
Returns:
``self`` for chaining.
Example::
fig.set_xticks([0, 25, 50, 75, 100])
fig.set_xticks([1, 6, 12], labels=["Jan", "Jun", "Dec"])
"""
self.axes.set_xticks(ticks, labels)
return self
[docs]
def set_yticks(self, ticks: list,
labels: list | None = None) -> "Figure":
"""
Set explicit Y-axis tick positions (and optionally their labels).
Returns:
``self`` for chaining.
Example::
fig.set_yticks([0, 500_000, 1_000_000], labels=["$0", "$500k", "$1M"])
"""
self.axes.set_yticks(ticks, labels)
return self
[docs]
def set_tick_format(self, formatter) -> "Figure":
"""
Apply a callable formatter to all numeric tick labels on both axes.
Equivalent to Matplotlib's ``FuncFormatter``.
Args:
formatter: Callable ``(value: float) -> str``.
Returns:
``self`` for chaining.
Example::
fig.set_tick_format(lambda v: f"${v:,.0f}")
fig.set_tick_format(lambda v: f"{v:.1%}")
"""
self.axes.set_tick_format(formatter)
return self
[docs]
def set_minor_ticks(self, n: int = 4) -> "Figure":
"""
Draw minor tick subdivisions between each pair of major ticks.
Args:
n: Number of minor ticks between major ticks.
Returns:
``self`` for chaining.
Example::
fig.set_minor_ticks(4)
"""
self.axes.set_minor_ticks(n)
return self
[docs]
def set_spine_visible(self, left: bool = True, right: bool = True,
top: bool = False, bottom: bool = True) -> "Figure":
"""
Control axis spine (border line) visibility.
Equivalent to Matplotlib's ``ax.spines[...].set_visible()``.
Args:
left: Left (Y) spine.
right: Right (Y2) spine.
top: Top spine.
bottom: Bottom (X) spine.
Returns:
``self`` for chaining.
Example::
fig.set_spine_visible(top=False, right=False) # clean minimal look
"""
self.axes.set_spine_visible(left=left, right=right,
top=top, bottom=bottom)
return self
[docs]
def text(self, x: float, y: float, s: str,
color: str = "#333", font_size: int = 12,
anchor: str = "middle",
transform: str = "canvas") -> "Figure":
"""
Add free-form text anywhere on the canvas.
Equivalent to Matplotlib's ``fig.text()`` / ``ax.text()``.
Args:
x: X position. When ``transform='canvas'`` this is
a fraction of canvas width (0–1). When
``transform='data'`` it is a data-space value.
y: Y position. Canvas fraction (0 = top, 1 = bottom)
or data-space value depending on ``transform``.
s: Text string. Use ``$...$`` for math (rendered
via MathJax when the chart is opened in a browser).
color: Font color.
font_size: Font size in points.
anchor: SVG text-anchor: ``"start"``, ``"middle"``, ``"end"``.
transform: ``"canvas"`` (0–1 fractions) or ``"data"`` (data coords).
Returns:
``self`` for chaining.
Example::
fig.text(0.5, 0.02, "Source: World Bank", font_size=9, color="#888")
fig.text(0.5, 0.5, "$R^2 = 0.95$", font_size=14)
"""
self._canvas_texts.append(dict(
x=x, y=y, s=s, color=color,
font_size=font_size, anchor=anchor, transform=transform,
))
return self
[docs]
def supxlabel(self, label: str, font_size: int = 13,
color: str | None = None) -> "Figure":
"""
Set a shared X-axis label below all subplots.
Equivalent to Matplotlib's ``fig.supxlabel()``. Useful when all
subplots in a grid share the same X-axis meaning.
Args:
label: Label text.
font_size: Font size.
color: Text color (defaults to theme text color).
Returns:
``self`` for chaining.
"""
self._supxlabel = dict(label=label, font_size=font_size, color=color)
return self
[docs]
def supylabel(self, label: str, font_size: int = 13,
color: str | None = None) -> "Figure":
"""
Set a shared Y-axis label beside all subplots.
Equivalent to Matplotlib's ``fig.supylabel()``.
Args:
label: Label text.
font_size: Font size.
color: Text color (defaults to theme text color).
Returns:
``self`` for chaining.
"""
self._supylabel = dict(label=label, font_size=font_size, color=color)
return self
[docs]
def stacked_bar(self, x: list, series: dict,
normalize: bool = False, colors: list | None = None,
bar_width: float = 0.75, **kwargs) -> "Figure":
"""Add a :class:`~glyphx.stacked_bar.StackedBarSeries`. Returns ``self``."""
from .stacked_bar import StackedBarSeries
return self.add(StackedBarSeries(x=x, series=series,
normalize=normalize, colors=colors,
bar_width=bar_width, **kwargs))
[docs]
def bump(self, x: list, rankings: dict,
colors: list | None = None, show_labels: bool = True,
**kwargs) -> "Figure":
"""Add a :class:`~glyphx.bump_chart.BumpChartSeries`. Returns ``self``."""
from .bump_chart import BumpChartSeries
return self.add(BumpChartSeries(x=x, rankings=rankings,
colors=colors, show_labels=show_labels,
**kwargs))
[docs]
def sparkline(self, data: list, color: str = "#2563eb",
kind: str = "line", fill: bool = True,
label: str | None = None, **kwargs) -> "Figure":
"""Add a :class:`~glyphx.sparkline.SparklineSeries`. Returns ``self``."""
from .sparkline import SparklineSeries
return self.add(SparklineSeries(data=data, color=color,
kind=kind, fill=fill,
label=label, **kwargs))
[docs]
def vline(self, x, color="#888", width=1,
linestyle="dashed", label=None) -> "Figure":
"""Draw a vertical reference line at data coordinate ``x``. Returns ``self``."""
from .series import LineSeries
# Compute domain from existing series if not yet finalized
if self.axes._y_domain:
ymin, ymax = self.axes._y_domain
elif self.series:
all_y = [v for s, _ in self.series for v in (s.y or []) if v is not None]
ymin, ymax = (min(all_y), max(all_y)) if all_y else (0, 1)
else:
ymin, ymax = 0, 1
return self.add(LineSeries([x, x], [ymin, ymax],
color=color, width=width,
linestyle=linestyle, label=label))
[docs]
def hline(self, y, color="#888", width=1,
linestyle="dashed", label=None) -> "Figure":
"""Draw a horizontal reference line at data coordinate ``y``. Returns ``self``."""
from .series import LineSeries
if self.axes._x_domain:
xmin, xmax = self.axes._x_domain
elif self.series:
all_x = [v for s, _ in self.series for v in (getattr(s, "_numeric_x", None) or s.x or []) if v is not None]
xmin, xmax = (min(all_x), max(all_x)) if all_x else (0, 1)
else:
xmin, xmax = 0, 1
return self.add(LineSeries([xmin, xmax], [y, y],
color=color, width=width,
linestyle=linestyle, label=label))
# ── __repr__ ─────────────────────────────────────────────────────────
[docs]
def copy(self) -> "Figure":
"""
Return a deep copy of this figure.
Useful for creating small variations without re-building from scratch::
base = Figure().add(LineSeries(x, y))
dark = base.copy().set_theme("dark")
light = base.copy().set_theme("default")
Returns:
A new :class:`Figure` with all series, annotations, and settings
duplicated.
"""
import copy as _copy
return _copy.deepcopy(self)
def __eq__(self, other: object) -> bool:
"""
Compare two figures by their rendered SVG output.
Chart IDs (UUIDs) are stripped before comparison so two figures
produced from identical data are considered equal even though their
IDs differ. This enables snapshot / regression testing::
assert fig_a == fig_b # visual equality
assert fig_new != fig_baseline # regression detected
Returns:
``True`` if both figures render to visually identical SVG.
"""
if not isinstance(other, Figure):
return NotImplemented
import re as _re
# Pattern strips any XML attribute whose value contains a glyphx UUID
_UUID_ATTR = _re.compile(r'\s[\w:-]+="[^"]*glyphx-chart-[a-f0-9]{12}[^"]*"')
def _strip_ids(s: str) -> str:
return _UUID_ATTR.sub("", s)
return _strip_ids(self.render_svg()) == _strip_ids(other.render_svg())
def __hash__(self) -> int:
"""
Hash based on figure identity (not SVG content).
Required by Python when ``__eq__`` is defined. Figures remain
usable as dict keys and in sets even though equality is SVG-based.
"""
return id(self)
def __repr__(self) -> str:
series_desc = ", ".join(
f"{s.__class__.__name__}({repr(s.label)})"
if getattr(s, "label", None)
else s.__class__.__name__
for s, _ in self.series
)
theme_name = getattr(self, "_theme_name", "default")
return (
f"<glyphx.Figure {self.width}×{self.height}"
+ (f" [{series_desc}]" if self.series else " [empty]")
+ f" theme={theme_name!r}>"
)
# ── Annotations ──────────────────────────────────────────────────────
[docs]
def annotate(
self,
text: str,
x: float,
y: float,
color: str = "#333",
font_size: int = 12,
anchor: str = "start",
arrow: bool = False,
ax_x: float | None = None,
ax_y: float | None = None,
) -> Figure:
"""
Add a text annotation in data-space coordinates.
Returns ``self`` for chaining.
Args:
text: Label text.
x: Data-space X coordinate of the annotation point.
y: Data-space Y coordinate.
color: Text and arrow colour.
font_size: Label font size in points.
anchor: SVG text-anchor (``"start"``, ``"middle"``, ``"end"``).
arrow: Draw a small leader line from text to point.
ax_x: Arrow tail X pixel offset (default −8).
ax_y: Arrow tail Y pixel offset (default −8).
"""
self._annotations.append(dict(
text=text, x=x, y=y, color=color,
font_size=font_size, anchor=anchor,
arrow=arrow, ax_x=ax_x, ax_y=ax_y,
))
return self
def _render_annotations(
self,
scale_x: Any,
scale_y: Any,
font: str,
) -> str:
elements: list[str] = []
# Build a category→numeric map from all registered series
cat_map: dict = {}
for s, _ in self.series:
if hasattr(s, "_x_categories") and s._x_categories:
for i, cat in enumerate(s._x_categories):
cat_map[str(cat)] = i + 0.5
for ann in self._annotations:
ann_x = ann["x"]
ann_y = ann["y"]
# Resolve categorical x values to numeric
if isinstance(ann_x, str) and ann_x in cat_map:
ann_x = cat_map[ann_x]
try:
px = scale_x(float(ann_x))
except (TypeError, ValueError):
continue
try:
py = scale_y(float(ann_y))
except (TypeError, ValueError):
continue
ox = ann["ax_x"] if ann["ax_x"] is not None else -8
oy = ann["ax_y"] if ann["ax_y"] is not None else -8
if ann["arrow"]:
elements.append(
f'<line x1="{px + ox}" y1="{py + oy}" x2="{px}" y2="{py}" '
f'stroke="{ann["color"]}" stroke-width="1.5" '
f'marker-end="url(#arrow)"/>'
)
elements.append(
f'<text x="{px + ox}" y="{py + oy - 2}" '
f'text-anchor="{ann["anchor"]}" font-size="{ann["font_size"]}" '
f'font-family="{font}" fill="{ann["color"]}">'
f'{svg_escape(ann["text"])}</text>'
)
return "\n".join(elements)
@staticmethod
def _arrow_marker_def() -> str:
return (
'<defs><marker id="arrow" markerWidth="8" markerHeight="8" '
'refX="6" refY="3" orient="auto">'
'<path d="M0,0 L0,6 L8,3 z" fill="#333"/>'
'</marker></defs>'
)
# ── Accessibility ─────────────────────────────────────────────────────
[docs]
def to_alt_text(self) -> str:
"""
Generate a plain-English description of this figure for screen readers.
Returns:
A human-readable string suitable for ``aria-label`` or ``<desc>``.
"""
from .a11y import generate_alt_text
return generate_alt_text(self)
# ── Rendering ────────────────────────────────────────────────────────
[docs]
def render_svg(self, viewbox: bool = False) -> str:
"""
Render the complete figure and return an SVG string.
The SVG includes:
- ``role="img"`` and ``aria-labelledby`` on the root element
- ``<title>`` and ``<desc>`` ARIA landmark children
- ``tabindex="0"`` on every interactive data point
Returns:
Complete SVG document markup.
"""
svg_parts: list[str] = []
if any(a["arrow"] for a in self._annotations):
svg_parts.append(self._arrow_marker_def())
svg_parts.append(
f'<rect width="{self.width}" height="{self.height}" '
f'fill="{self.theme.get("background", "#ffffff")}"/>'
)
if self.title:
font = self.theme.get("font", "sans-serif")
color = self.theme.get("text_color", "#000")
svg_parts.append(
f'<text x="{self.width // 2}" y="28" text-anchor="middle" '
f'font-size="20" font-weight="bold" font-family="{font}" '
f'fill="{color}">{svg_escape(self.title)}</text>'
)
# sup[x|y]label — shared axis labels across subplot grids
_font = self.theme.get("font", "sans-serif")
_tc = self.theme.get("text_color", "#000")
if self._supxlabel:
sx = self._supxlabel
c = sx.get("color") or _tc
svg_parts.append(
f'<text x="{self.width // 2}" y="{self.height - 4}" '
f'text-anchor="middle" font-size="{sx["font_size"]}" '
f'font-family="{_font}" fill="{c}">{svg_escape(sx["label"])}</text>'
)
if self._supylabel:
sy = self._supylabel
c = sy.get("color") or _tc
cx = 14
cy = self.height // 2
svg_parts.append(
f'<text x="{cx}" y="{cy}" text-anchor="middle" '
f'font-size="{sy["font_size"]}" font-family="{_font}" fill="{c}" '
f'transform="rotate(-90, {cx}, {cy})">{svg_escape(sy["label"])}</text>'
)
# Free-form canvas text (fig.text())
for ct in getattr(self, "_canvas_texts", []):
if ct["transform"] == "canvas":
cx = ct["x"] * self.width
cy = ct["y"] * self.height
else:
# data coords — resolve if axes are finalized
try:
cx = self.axes.scale_x(ct["x"]) if self.axes.scale_x else ct["x"] * self.width
cy = self.axes.scale_y(ct["y"]) if self.axes.scale_y else ct["y"] * self.height
except Exception:
cx, cy = ct["x"] * self.width, ct["y"] * self.height
svg_parts.append(
f'<text x="{cx:.1f}" y="{cy:.1f}" '
f'text-anchor="{ct["anchor"]}" '
f'font-size="{ct["font_size"]}" font-family="{_font}" '
f'fill="{ct["color"]}">{svg_escape(ct["s"])}</text>'
)
# ── Subplot grid ──────────────────────────────────────────────────
if self.grid and any(any(cell for cell in row) for row in self.grid):
cell_w = self.width // self.cols
cell_h = self.height // self.rows
for r, row in enumerate(self.grid):
for c, ax in enumerate(row):
if not ax:
continue
ax.finalize()
group = f'<g transform="translate({c * cell_w},{r * cell_h})">'
group += ax.render_axes() + ax.render_grid()
for s in ax.series:
group += s.to_svg(ax)
if getattr(ax, "legend_pos", None):
group += draw_legend(
ax.series,
position=ax.legend_pos,
font=self.theme.get("font", "sans-serif"),
text_color=self.theme.get("text_color", "#000"),
fig_width=ax.width,
fig_height=ax.height,
)
group += "</g>"
svg_parts.append(group)
# ── Single-axes ───────────────────────────────────────────────────
elif self.series and any(
hasattr(s, "x") and hasattr(s, "y") and s.x and s.y
for s, _ in self.series
):
if not self.axes.series:
for s, use_y2 in self.series:
self.axes.add_series(s, use_y2)
self.axes.finalize()
svg_parts.append(self.axes.render_axes())
svg_parts.append(self.axes.render_grid())
for series, use_y2 in self.series:
svg_parts.append(series.to_svg(self.axes, use_y2=use_y2))
if self._annotations and self.axes.scale_x and self.axes.scale_y:
svg_parts.append(self._render_annotations(
self.axes.scale_x,
self.axes.scale_y,
self.theme.get("font", "sans-serif"),
))
if self.legend_pos:
# For outside-right legends, anchor x relative to the
# axes width (the shrunk plot area) not the full canvas.
# Always render legend in the right gutter (outside plot area)
_legend_ref_w = self.axes.width
svg_parts.append(draw_legend(
[s for s, _ in self.series],
position="outside-right",
font=self.theme.get("font", "sans-serif"),
text_color=self.theme.get("text_color", "#000"),
fig_width=_legend_ref_w,
fig_height=self.height,
))
# ── Statistical annotations ───────────────────────────────────
for ann in getattr(self, "_stat_annotations", []):
svg_parts.append(ann.to_svg(self.axes))
# ── Axis-free (pie, donut, etc.) ──────────────────────────────────
elif self.series:
for series, _ in self.series:
svg_parts.append(series.to_svg(self.axes))
# Detect math text ($...$) in the rendered SVG content for MathJax
_svg_content = "\n".join(svg_parts)
_has_math = "$" in _svg_content
raw_svg = wrap_svg_canvas(
_svg_content,
width=self.width,
height=self.height,
has_math=_has_math,
)
# ── Accessibility injection ───────────────────────────────────────
chart_id = re.search(r'id="(glyphx-chart-[^"]+)"', raw_svg)
cid = chart_id.group(1) if chart_id else "glyphx-chart-0"
from .a11y import inject_aria
return inject_aria(
svg=raw_svg,
title=self.title or "GlyphX Chart",
desc=self.to_alt_text(),
chart_id=cid,
)
# ── Display / export ──────────────────────────────────────────────────
def _display(self, svg_string: str) -> None:
try:
from IPython import get_ipython
ip = get_ipython()
if ip is not None and "IPKernelApp" in ip.config:
from IPython.display import SVG, display as jup_display
jup_display(SVG(svg_string))
return
except Exception:
pass
html = wrap_svg_with_template(svg_string)
tmp = NamedTemporaryFile(delete=False, suffix=".html", mode="w", encoding="utf-8")
tmp.write(html)
tmp.close()
webbrowser.open(f"file://{tmp.name}")
[docs]
def show(self) -> Figure:
"""Render and display the figure. Returns ``self`` for chaining."""
self._display(self.render_svg())
return self
[docs]
def save(self, filename: str = "glyphx_output.svg",
dpi: int = 96) -> "Figure":
"""
Save the rendered figure to disk.
Supported extensions: ``.svg``, ``.html``, ``.png``, ``.jpg``,
``.pptx``. PNG/JPG/PPTX require optional extras::
pip install "glyphx[export]" # PNG/JPG
pip install "glyphx[pptx]" # PowerPoint
Args:
filename: Output path. Extension determines the format.
dpi: Output resolution for raster formats (PNG/JPG).
Default 96; use 192 or 300 for high-DPI / print.
Has no effect on SVG or HTML output.
Returns:
``self`` for chaining.
Example::
fig.save("chart.png", dpi=192) # crisp on retina displays
fig.save("chart.png", dpi=300) # print-quality
"""
svg = self.render_svg()
if filename.lower().endswith(".pptx"):
_save_as_pptx(svg, filename, title=self.title)
else:
write_svg_file(svg, filename, dpi=dpi)
return self
[docs]
def tight_layout(self) -> "Figure":
"""
Auto-adjust padding to prevent label clipping and overlap.
For single-axis figures, delegates to
:meth:`~glyphx.layout.Axes.tight_layout`. For subplot grids,
applies :meth:`constrained_layout` which aligns the left edges of
all subplot cells so tick labels never overlap across rows.
Returns ``self`` for chaining.
"""
if self.grid and any(any(c for c in r) for r in self.grid):
return self.constrained_layout()
if not self.axes.series:
for s, use_y2 in self.series:
self.axes.add_series(s, use_y2)
self.axes.finalize()
self.axes.tight_layout()
return self
[docs]
def constrained_layout(self) -> "Figure":
"""
Align subplot cells so their plot areas share a common left edge.
Equivalent to Matplotlib's ``constrained_layout=True``. Computes
the widest Y-tick label across all cells in each column, then sets
a uniform left padding for all cells in that column so the actual
data area (to the right of the Y-axis) is flush.
Returns ``self`` for chaining.
Example::
fig = Figure(rows=2, cols=2, width=900, height=600)
# ... add_axes and add_series ...
fig.constrained_layout() # aligns all subplot left edges
"""
from .utils import _format_tick
# Finalize all axes first so _y_domain is populated
for row in self.grid:
for ax in row:
if ax is not None and ax.series:
ax.finalize()
# Per-column: compute the max Y-tick label width across all rows
n_cols = self.cols
n_rows = self.rows
col_max_label_px: list[int] = []
for c in range(n_cols):
max_label_len = 0
for r in range(n_rows):
ax = self.grid[r][c] if r < len(self.grid) and c < len(self.grid[r]) else None
if ax is None or ax._y_domain is None:
continue
for v in (ax._y_domain[0], ax._y_domain[1]):
lbl = _format_tick(v, is_log=(ax.yscale == "log"))
max_label_len = max(max_label_len, len(lbl))
# ~7px per char + 8px gap between label and axis
col_max_label_px.append(max_label_len * 7 + 8 + 8)
# Apply uniform padding per column
for c in range(n_cols):
uniform_pad = max(col_max_label_px[c], 50) # never below default 50
for r in range(n_rows):
ax = self.grid[r][c] if r < len(self.grid) and c < len(self.grid[r]) else None
if ax is not None:
ax.padding = uniform_pad
return self
[docs]
def add_stat_annotation(
self,
x1: Any,
x2: Any,
p_value: float = 0.05,
label: str | None = None,
style: str = "stars",
color: str = "#222",
y_offset: float = 0.0,
) -> Figure:
"""
Add a significance bracket between two groups.
Draws ``***`` / ``**`` / ``*`` / ``ns`` above the bracket
based on *p_value*. Works with both numeric and categorical X axes.
Args:
x1: First group (label or numeric X value).
x2: Second group.
p_value: Statistical p-value.
label: Override the auto-generated significance label.
style: ``"stars"`` or ``"numeric"``.
color: Bracket and text color.
y_offset: Extra upward shift in pixels (stack multiple brackets).
Returns:
``self`` for chaining.
"""
from .stat_annotation import StatAnnotation
self._stat_annotations = getattr(self, "_stat_annotations", [])
self._stat_annotations.append(StatAnnotation(
x1=x1, x2=x2, p_value=p_value,
label=label, style=style,
color=color, y_offset=y_offset,
))
return self
[docs]
def enable_crosshair(self) -> Figure:
"""
Enable the synchronized crosshair on the next ``share()`` / ``show()`` call.
The crosshair draws a vertical line across all GlyphX charts on the
same HTML page and highlights the nearest data point in each.
Returns ``self``.
"""
self._crosshair = True
return self
[docs]
def plot(self) -> Figure:
"""Shortcut for :meth:`show` respecting ``auto_display``. Returns ``self``."""
if self.auto_display:
self.show()
return self
# ---------------------------------------------------------------------------
# PPTX export helper
# ---------------------------------------------------------------------------
def _save_as_pptx(svg: str, filename: str, title: str | None = None) -> None:
"""
Save an SVG as a PNG-embedded PowerPoint slide.
Requires ``python-pptx`` and ``cairosvg``::
pip install "glyphx[pptx]"
The SVG is rasterised to PNG at 2× resolution, then inserted as a
full-slide picture in a blank 16:9 presentation.
"""
try:
import cairosvg
except (ImportError, OSError):
raise RuntimeError(
"PPTX export requires cairosvg and the system libcairo library. "
"Install with:\n"
" pip install \"glyphx[pptx]\"\n"
"On macOS: brew install cairo"
)
try:
from pptx import Presentation
from pptx.util import Inches, Pt
from pptx.enum.text import PP_ALIGN
except ImportError:
raise RuntimeError(
"PPTX export requires python-pptx. Install it with:\n"
" pip install \"glyphx[pptx]\""
)
import io
# ── SVG → PNG at 2× for crisp rendering ──────────────────────────────
png_bytes = cairosvg.svg2png(bytestring=svg.encode(), scale=2)
png_stream = io.BytesIO(png_bytes)
# ── Build presentation ────────────────────────────────────────────────
prs = Presentation()
blank = prs.slide_layouts[6] # completely blank layout
slide = prs.slides.add_slide(blank)
slide_w = prs.slide_width
slide_h = prs.slide_height
# ── Optional title text box ───────────────────────────────────────────
top_offset = Inches(0)
if title:
txBox = slide.shapes.add_textbox(
Inches(0.3), Inches(0.1), slide_w - Inches(0.6), Inches(0.55)
)
tf = txBox.text_frame
tf.text = title
tf.paragraphs[0].alignment = PP_ALIGN.CENTER
tf.paragraphs[0].runs[0].font.size = Pt(22)
tf.paragraphs[0].runs[0].font.bold = True
top_offset = Inches(0.65)
# ── Insert chart PNG ──────────────────────────────────────────────────
pic_h = slide_h - top_offset - Inches(0.1)
pic_w = min(slide_w - Inches(0.4), pic_h * (slide_w / slide_h))
left = (slide_w - pic_w) // 2
slide.shapes.add_picture(png_stream, left, top_offset, pic_w, pic_h)
prs.save(filename)
# ---------------------------------------------------------------------------
# SubplotGrid
# ---------------------------------------------------------------------------
[docs]
class SubplotGrid:
"""
Standalone 2-D grid for laying out existing Figure objects into one page.
Example::
sg = SubplotGrid(2, 2)
sg.add(fig_revenue, 0, 0)
sg.add(fig_costs, 0, 1)
html = sg.render()
open("dashboard.html", "w").write(html)
Args:
rows: Number of rows.
cols: Number of columns.
"""
def __init__(self, rows: int, cols: int) -> None:
self.rows = rows
self.cols = cols
self.grid: list[list[Figure | None]] = [
[None] * cols for _ in range(rows)
]
[docs]
def add(self, figure: Figure, row: int, col: int) -> SubplotGrid:
"""Place a Figure at a grid position. Returns ``self``."""
if not (0 <= row < self.rows and 0 <= col < self.cols):
raise IndexError(
f"Position ({row}, {col}) is out of range for a "
f"{self.rows}×{self.cols} grid."
)
self.grid[row][col] = figure
return self
[docs]
def add_axes(self, row: int, col: int, figure: Figure) -> SubplotGrid:
"""Alias for :meth:`add` kept for backward compatibility."""
return self.add(figure, row, col)
[docs]
def render(self, gap: int = 20) -> str:
"""
Render all figures into a self-contained HTML page.
Returns:
Full HTML document string.
"""
from .utils import wrap_svg_with_template
rows_html: list[str] = []
for r in range(self.rows):
cells: list[str] = []
for c in range(self.cols):
fig = self.grid[r][c]
svg = fig.render_svg() if fig is not None else ""
cells.append(f'<div style="margin:{gap}px">{svg}</div>')
rows_html.append(
'<div style="display:flex">' + "".join(cells) + "</div>"
)
html_body = "<div>" + "".join(rows_html) + "</div>"
return wrap_svg_with_template(html_body)