Writing a Plotting Backend#
A backend is an object that knows how to draw ChartBook’s chart types with one plotting library. Two ship with ChartBook — plotly and matplotlib — and a third-party package can add more: Altair, Bokeh, a house library, anything that can turn a dataframe and a description of a chart into a file.
This page is the whole interface. It is written so that you can build and ship a backend without reading ChartBook’s source.
What a third-party backend is used for today
A backend you register is discoverable and callable: get_backend("yours") returns it, and you (or your users) call its create_* methods.
It is not yet selectable from the chart functions. chartbook.plotting.line() and its siblings always build the interactive figure with the built-in plotly backend and the static PNG/PDF files with the built-in matplotlib backend; there is no backend= argument. So today a third-party backend adds a way to render the same chart description with another library — it does not replace what .save() writes. See Using a backend.
The contract#
A backend is any object with a name and six methods. That is the PlottingBackend protocol in chartbook.plotting.backends:
Member |
Required |
What it is |
|---|---|---|
|
yes |
A property returning the backend’s name as a string, e.g. |
|
yes |
Draw a line chart |
|
yes |
Draw a bar chart |
|
yes |
Draw a scatter plot |
|
yes |
Draw a pie chart |
|
yes |
Draw an area chart |
|
no |
Draw a chart with two y-axes. Raise |
Every method has the same five parameters and returns None. Its job is to write the chart to output_path.
Parameter |
Type |
Meaning |
|---|---|---|
|
|
The data. Column names are referred to by |
|
|
What to draw — see The configuration objects |
|
|
Reference lines, shaded regions and bands to draw over the chart |
|
|
Where to write. The directory may not exist yet; create it. The suffix says which format the caller wants — the built-in |
|
|
Figure size as |
A backend should honour what it can from config and overlay and quietly ignore what its library cannot express. Nothing validates the output.
Start from BaseBackend#
Subclass chartbook.plotting.backends.BaseBackend rather than implementing the protocol from scratch. It is the intended starting point, and it gives you three things:
the five chart methods and
nameas abstract members, so a backend that forgets one cannot be instantiated;a default
create_dual_axisthat raisesNotImplementedErrornaming your backend;create_chart(df, config, overlay, output_path, size), which readsconfig.chart_typeand calls the matchingcreate_*method. Callers that hold aChartConfiguse this instead of choosing a method themselves.
A complete example#
A backend that writes each chart as a Vega-Lite specification — the format Altair produces — in one file, with no dependency beyond ChartBook itself:
"""A ChartBook plotting backend that writes Vega-Lite specifications.
This is the worked example from the "Writing a Plotting Backend" guide: a
complete backend in one file, using nothing outside the standard library.
Each chart is written as a Vega-Lite JSON specification, which is the format
Altair produces and which any Vega-Lite renderer can draw.
"""
import json
from pathlib import Path
from chartbook.plotting.backends import BaseBackend
SCHEMA = "https://vega.github.io/schema/vega-lite/v5.json"
PIXELS_PER_INCH = 96
class VegaLiteBackend(BaseBackend):
"""Write each chart as a Vega-Lite JSON specification."""
@property
def name(self):
return "vegalite"
# The five methods every backend must provide. Each receives the data, the
# chart's configuration, its overlays, where to write, and a figure size.
def create_line(self, df, config, overlay, output_path, size):
self._write(self._xy_spec("line", df, config), config, output_path, size)
def create_bar(self, df, config, overlay, output_path, size):
self._write(self._xy_spec("bar", df, config), config, output_path, size)
def create_scatter(self, df, config, overlay, output_path, size):
self._write(self._xy_spec("point", df, config), config, output_path, size)
def create_area(self, df, config, overlay, output_path, size):
self._write(self._xy_spec("area", df, config), config, output_path, size)
def create_pie(self, df, config, overlay, output_path, size):
# A pie is described by config.names and config.values, not x and y
spec = {
"mark": "arc",
"data": {"values": _records(df)},
"encoding": {
"theta": {"field": config.values, "type": "quantitative"},
"color": {"field": config.names, "type": "nominal"},
},
}
self._write(spec, config, output_path, size)
# create_dual_axis is optional. BaseBackend's version raises
# NotImplementedError, which is the right answer for a backend without one.
def _xy_spec(self, mark, df, config):
return {
"mark": mark,
"data": {"values": _records(df)},
# config.y is always a list. Fold it into one row per
# (x, series, value) so each y column becomes its own colour.
"transform": [{"fold": config.y, "as": ["series", "value"]}],
"encoding": {
"x": {"field": config.x, "title": config.x_title or config.x},
"y": {
"field": "value",
"type": "quantitative",
"title": config.y_title,
},
"color": {"field": "series", "type": "nominal"},
},
}
def _write(self, spec, config, output_path, size):
width, height = size # inches
spec = {
"$schema": SCHEMA,
"title": config.title,
"width": int(width * PIXELS_PER_INCH),
"height": int(height * PIXELS_PER_INCH),
**spec,
}
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json.dumps(spec, indent=2))
def _records(df):
"""The dataframe as JSON-ready records, with dates in ISO format."""
return json.loads(df.to_json(orient="records", date_format="iso"))
#: What the entry point names: an instance, not the class.
backend = VegaLiteBackend()
This file is exercised by ChartBook’s own test suite, registered through a real entry point, so it is known to work against the version of ChartBook these docs describe.
Registering it#
Backends are found through the chartbook_plotting entry-point group. In your package’s pyproject.toml:
[project.entry-points.chartbook_plotting]
vegalite = "chartbook_vegalite:backend"
The entry point may name a backend instance, as here, or the class itself (chartbook_vegalite:VegaLiteBackend), which is instantiated with no arguments. An entry point that resolves to something that is not a backend is skipped with a warning, and the built-in backends keep working.
Once the package is installed, nothing else is needed — the first call to get_backend() loads every installed entry point in the group:
from chartbook.plotting.backends import get_backend, list_backends
sorted(list_backends()) # ['matplotlib', 'plotly', 'vegalite']
backend = get_backend("vegalite")
get_backend() matches on the backend’s name property first, then on the entry point’s own name. Keep the two the same.
To register a backend without packaging it — in a script, a notebook or a test — pass an instance to register_backend:
from chartbook.plotting.backends import register_backend
register_backend(VegaLiteBackend()) # or the class: register_backend(VegaLiteBackend)
Using a backend#
Call it with a chart description. ChartConfig, OverlayConfig and DualAxisConfig are exported from chartbook.plotting:
from pathlib import Path
from chartbook.plotting import ChartConfig, OverlayConfig
from chartbook.plotting.backends import get_backend
config = ChartConfig(chart_type="line", x="date", y=["gdp"], title="GDP")
get_backend("vegalite").create_chart(
df, config, OverlayConfig(), Path("_output/gdp.vl.json"), size=(8, 6)
)
create_chart picks the method from config.chart_type. For a dual-axis chart, build a DualAxisConfig and call create_dual_axis directly.
The configuration objects#
These are plain dataclasses. A field left at its default was not set by the caller.
ChartConfig#
Field |
Type |
Meaning |
|---|---|---|
|
|
Which chart. |
|
|
Column for the x-axis |
|
|
Columns to plot. Always a list, even for one series |
|
|
Annotations. The built-ins draw the title above the chart and the others beneath it |
|
|
One colour for every series, or one per |
|
|
Display name for each |
|
|
Axis titles |
|
|
Axis limits |
|
|
Tick format strings in Plotly’s syntax (d3-format; strftime-style for dates). The built-in |
|
|
Stack bars or areas |
|
|
Scatter only: columns for marker size and for colour grouping |
|
|
Pie only: the label column and the value column. ( |
|
|
Set when the chart is saved; not needed to draw |
|
|
Extra keyword arguments the caller passed, for the backend to forward to its library or ignore |
OverlayConfig#
Field |
Type |
Meaning |
|---|---|---|
|
|
Shade U.S. recessions. The dates come from |
|
|
Horizontal lines: |
|
|
Vertical lines: |
|
|
Shaded x-ranges: |
|
|
Fill between two columns: |
|
|
Add a fitted line through the first |
dash is one of "solid", "dash", "dot", "dashdot".
DualAxisConfig#
Field |
Type |
Meaning |
|---|---|---|
|
|
Column for the shared x-axis |
|
|
Columns for each axis |
|
|
How each axis’s series are drawn |
|
|
Axis titles |
|
|
Axis limits |
|
|
Tick formats |
|
|
Series colours per axis |
|
As in |
What is not part of the interface#
One thing in the built-in backends looks like part of the interface and is not.
build_line,build_bar, … on theplotlybackend, andbuild_figureon thematplotlibbackend. These return a figure object without saving it.ChartResultuses them to give you.figure,.mpl_figureand.mpl_axes. They are internal to the two built-ins and no other backend is asked for them.