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

name

yes

A property returning the backend’s name as a string, e.g. "altair". This is what get_backend() looks up.

create_line(df, config, overlay, output_path, size)

yes

Draw a line chart

create_bar(…)

yes

Draw a bar chart

create_scatter(…)

yes

Draw a scatter plot

create_pie(…)

yes

Draw a pie chart

create_area(…)

yes

Draw an area chart

create_dual_axis(df, config, overlay, output_path, size)

no

Draw a chart with two y-axes. Raise NotImplementedError if unsupported

Every method has the same five parameters and returns None. Its job is to write the chart to output_path.

Parameter

Type

Meaning

df

pandas.DataFrame

The data. Column names are referred to by config.

config

ChartConfig (DualAxisConfig for create_dual_axis)

What to draw — see The configuration objects

overlay

OverlayConfig

Reference lines, shaded regions and bands to draw over the chart

output_path

pathlib.Path

Where to write. The directory may not exist yet; create it. The suffix says which format the caller wants — the built-in matplotlib backend is called once each for .png and .pdf

size

tuple[float, float]

Figure size as (width, height) in inches

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 name as abstract members, so a backend that forgets one cannot be instantiated;

  • a default create_dual_axis that raises NotImplementedError naming your backend;

  • create_chart(df, config, overlay, output_path, size), which reads config.chart_type and calls the matching create_* method. Callers that hold a ChartConfig use 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

chart_type

"line", "bar", "scatter", "pie", "area"

Which chart. create_chart dispatches on it

x

str

Column for the x-axis

y

list[str]

Columns to plot. Always a list, even for one series

title, caption, note, source

str or None

Annotations. The built-ins draw the title above the chart and the others beneath it

color

str, sequence of str, or None

One colour for every series, or one per y column in order

labels

dict[str, str] or None

Display name for each y column

x_title, y_title

str or None

Axis titles

x_range, y_range

(min, max) or None

Axis limits

x_tickformat, y_tickformat

str or None

Tick format strings in Plotly’s syntax (d3-format; strftime-style for dates). The built-in matplotlib backend ignores them — an example of skipping what a library cannot express

stacked

bool

Stack bars or areas

size, color_by

str or None

Scatter only: columns for marker size and for colour grouping

names, values

str or None

Pie only: the label column and the value column. (x and y are also set to them.)

chart_id

str or None

Set when the chart is saved; not needed to draw

extra_kwargs

dict

Extra keyword arguments the caller passed, for the backend to forward to its library or ignore

OverlayConfig#

Field

Type

Meaning

nber_recessions

bool

Shade U.S. recessions. The dates come from chartbook.plotting._overlays.get_nber_recessions(), a dataframe of start/end — a private helper today, so treat it as subject to change

hlines

list[dict]

Horizontal lines: y (required), color, dash, width, label

vlines

list[dict]

Vertical lines: x (required), color, dash, width, label

shaded_regions

list[dict]

Shaded x-ranges: x0, x1 (required), color, alpha, label

bands

list[dict]

Fill between two columns: y_upper, y_lower (required), color, alpha, label

regression_line

bool

Add a fitted line through the first y column (scatter)

dash is one of "solid", "dash", "dot", "dashdot".

DualAxisConfig#

Field

Type

Meaning

x

str

Column for the shared x-axis

left_y, right_y

list[str]

Columns for each axis

left_type, right_type

"line", "bar", "scatter", "area"

How each axis’s series are drawn

left_y_title, right_y_title, x_title

str or None

Axis titles

left_y_range, right_y_range

(min, max) or None

Axis limits

left_y_tickformat, right_y_tickformat

str or None

Tick formats

left_colors, right_colors

list[str] or None

Series colours per axis

title, caption, note, source, chart_id, extra_kwargs

As in ChartConfig

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 the plotly backend, and build_figure on the matplotlib backend. These return a figure object without saving it. ChartResult uses them to give you .figure, .mpl_figure and .mpl_axes. They are internal to the two built-ins and no other backend is asked for them.