Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions docs/src/content/docs/guides/web-figures.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
---
title: Web figures
description: Save a canvas as theme-neutral Plotly JSON and embed it, interactive and in the site's colours, on a page of a max-models docs site.
---

A web figure is a Plotly figure saved as JSON, without plotly.js and without anything that
fixes its look to one page. A page of a max-models docs site embeds it with the theme's
`PlotlyFigure` component: the figure is interactive, plotly.js is loaded only when the figure
scrolls into view, and the figure takes the site's colours in light and dark mode.

## Saving a canvas

```python
import numpy as np

from maxplotlib import Canvas

x = np.linspace(0, 2 * np.pi, 200)
canvas = Canvas(width="10cm", ratio=0.5)
canvas.plot(x, np.sin(x), label="sin")
canvas.plot(x, np.cos(x), label="cos")
canvas.set_title("Two waves")

canvas.savefig("docs/public/figures/waves.plotly.json", backend="plotly")
# or as a string, with a text alternative for screen readers
text = canvas.to_json(alt="A sine and a cosine wave over one period")
```

Any Plotly figure, from maxplotlib or not, is written the same way with
`maxplotlib.web.write_web_figure(fig, path, title=..., alt=..., aspect=..., white_plot=...)`;
`maxplotlib.web.web_figure(fig)` returns the dict instead.

## Embedding it

In an MDX page, with the file under `docs/public/`:

```mdx
import PlotlyFigure from '@max-models/starlight-theme/components/PlotlyFigure.astro';

<PlotlyFigure src="/figures/waves.plotly.json" />
```

The path is relative to the site root; the component adds the site's base path.

:::note
The theme's `PlotlyFigure` already loads plotly.js on demand and recolours text, grid and
backgrounds. The data palette and the hints below take effect with the next release of
starlight-theme.
:::

## The format

The file is Plotly figure JSON: `data`, `layout` and, for an animation, `frames`. Compared with
the figure it was made from:

- **no look of its own**: no template, no fixed `width` or `height` (`autosize` instead, with
`automargin` on the axes so the tick labels fit at any size), and transparent `paper_bgcolor`
and `plot_bgcolor`. The page sets text, grid and background colours from the theme.
- **the data palette**: a trace without a colour takes it from `layout.colorway`, which the page
sets to the theme's data palette. A trace whose colour comes from a default colour cycle
(Matplotlib's `C0` to `C9`, Plotly's default colorway, the Okabe–Ito palette) carries its
position in that cycle as `trace.meta.color_slot`, and the page gives it the theme's colour
of that position. A black trace, such as a reference curve, takes the text colour, so it
stays visible in dark mode. Other explicit colours and colormaps stay as they are.
- **hints for the page** in `layout.meta.web`:

| Key | Meaning |
| --- | --- |
| `version` | the format version, `1` |
| `title` | the title, also the default caption |
| `alt` | a text alternative for the figure |
| `aspect` | width over height, so the page reserves the space before the figure loads |
| `white_plot` | keep a white plot area in dark mode, for colours chosen for a white background |

`white_plot` is set by default for a figure converted from Matplotlib
(`layout.meta.converted_from == "matplotlib"`), whose overlays were drawn for a white axes.
53 changes: 53 additions & 0 deletions src/maxplotlib/canvas/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -2123,6 +2123,17 @@ def _render(
else:
raise ValueError(f"Invalid backend: {backend}")

def _web_aspect(self) -> float | None:
"""Width over height of the canvas, when its size says so."""
if self._figsize is not None:
width, height = self._figsize
return float(width) / float(height)
if self._ratio == "golden":
return 2 / (5**0.5 - 1)
if isinstance(self._ratio, (int, float)) and self._ratio > 0:
return 1 / float(self._ratio)
return None

def _save_plotly(
self, fig, filename: str, include_plotlyjs: bool | str = True
) -> None:
Expand All @@ -2131,6 +2142,12 @@ def _save_plotly(
if extension in {".html", ".htm"}:
fig.write_html(filename, include_plotlyjs=include_plotlyjs)
return
if extension == ".json":
# a web figure for the docs sites, see maxplotlib.web
from maxplotlib.web import write_web_figure

write_web_figure(fig, filename, aspect=self._web_aspect())
return
try:
fig.write_image(filename)
except Exception as exc:
Expand Down Expand Up @@ -3220,6 +3237,42 @@ def plot_plotly(
self._plotly_fig = fig
return fig

def to_json(
self,
layers: list | None = None,
title: str | None = None,
alt: str | None = None,
white_plot: bool | None = None,
verbose: bool = False,
allow_unsupported: bool = False,
) -> str:
"""Render the canvas with the Plotly backend and return it as a web figure.

A web figure is theme-neutral Plotly JSON without plotly.js, which the max-models
docs theme embeds on a page with the site's colours (see :mod:`maxplotlib.web`).
``canvas.savefig("figure.plotly.json", backend="plotly")`` writes the same to a file.

Parameters:
layers (list, optional): The layers to draw.
title (str, optional): The title and default caption. Default: the canvas title.
alt (str, optional): A text alternative for the figure.
white_plot (bool, optional): Keep a white plot area in dark mode.
"""
import json

from maxplotlib.web import web_figure

fig = self.plot_plotly(
show=False,
layers=layers,
verbose=verbose,
allow_unsupported=allow_unsupported,
)
web = web_figure(
fig, title=title, alt=alt, aspect=self._web_aspect(), white_plot=white_plot
)
return json.dumps(web, separators=(",", ":"))

def to_html(
self,
layers: list | None = None,
Expand Down
140 changes: 140 additions & 0 deletions src/maxplotlib/tests/test_web.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"""Web figures: theme-neutral Plotly JSON for the docs sites."""

import json

import numpy as np
import pytest

go = pytest.importorskip("plotly.graph_objects")

from maxplotlib import Canvas # noqa: E402
from maxplotlib.web import ( # noqa: E402
FORMAT_VERSION,
color_slot,
web_figure,
write_web_figure,
)


def test_color_slot_finds_the_default_cycles():
assert color_slot("#1f77b4") == 0 # Matplotlib C0
assert color_slot("#FF7F0E") == 1 # upper case
assert color_slot("rgb(44, 160, 44)") == 2 # C2 as rgb()
assert color_slot("rgba(214, 39, 40, 0.5)") == 3 # C3 with alpha
assert color_slot("#EF553B") == 1 # Plotly's colorway
assert color_slot("#e69f00") == 1 # Okabe-Ito
assert color_slot("#000") == 7 # Okabe-Ito black, short form
assert color_slot("black") is None
assert color_slot("#123456") is None
assert color_slot(["#1f77b4"]) is None # one colour per point is not a slot


def test_web_figure_strips_the_look_and_keeps_the_data():
fig = go.Figure(go.Scatter(x=[0, 1, 2], y=[1, 3, 2], name="a"))
fig.update_layout(
width=800,
height=400,
title="Energy",
paper_bgcolor="white",
template="plotly_dark",
)
web = web_figure(fig, alt="A rising line")
layout = web["layout"]
assert "template" not in layout
assert "width" not in layout and "height" not in layout
assert layout["paper_bgcolor"] == "rgba(0,0,0,0)"
assert layout["plot_bgcolor"] == "rgba(0,0,0,0)"
assert layout["meta"]["web"] == {
"version": FORMAT_VERSION,
"white_plot": False,
"title": "Energy",
"alt": "A rising line",
"aspect": 2.0,
}
assert web["data"][0]["name"] == "a"
assert "frames" not in web


def test_default_colours_get_slots_and_others_stay():
fig = go.Figure(
[
go.Scatter(y=[1, 2], line={"color": "#ff7f0e"}), # C1
go.Scatter(y=[2, 1], line={"color": "black"}, meta="reference"),
go.Scatter(
y=[1, 1], mode="markers", marker={"color": "#2ca02c"}, meta="points"
),
go.Scatter(y=[0, 1]), # no colour: the page's colorway
]
)
data = web_figure(fig)["data"]
assert data[0]["meta"] == {"color_slot": 1}
assert data[1]["meta"] == "reference"
assert data[2]["meta"] == {"value": "points", "color_slot": 2}
assert "meta" not in data[3]


def test_a_matplotlib_conversion_keeps_a_white_plot_area_and_its_meta():
fig = go.Figure(
go.Scatter(y=[1, 2]), layout={"meta": {"converted_from": "matplotlib"}}
)
layout = web_figure(fig)["layout"]
assert layout["plot_bgcolor"] == "white"
assert layout["meta"]["converted_from"] == "matplotlib"
assert layout["meta"]["web"]["white_plot"] is True
assert (
web_figure(fig, white_plot=False)["layout"]["plot_bgcolor"] == "rgba(0,0,0,0)"
)


def test_frames_keep_their_slots():
fig = go.Figure(
go.Scatter(y=[0, 1], line={"color": "#1f77b4"}),
frames=[
go.Frame(data=[go.Scatter(y=[1, 0], line={"color": "#1f77b4"})], name="1")
],
)
web = web_figure(fig)
assert web["frames"][0]["data"][0]["meta"] == {"color_slot": 0}
assert web["frames"][0]["name"] == "1"


def test_a_dict_with_arrays_is_accepted():
figure = {
"data": [{"type": "scatter", "y": np.arange(3)}],
"layout": {"title": {"text": "t"}},
}
web = web_figure(figure)
assert web["data"][0]["y"] == [0, 1, 2]
assert web["layout"]["meta"]["web"]["title"] == "t"


def test_write_web_figure(tmp_path):
path = write_web_figure(
go.Figure(go.Bar(y=[1, 2])), tmp_path / "sub" / "bars.plotly.json", aspect=1.5
)
web = json.loads(path.read_text())
assert web["layout"]["meta"]["web"]["aspect"] == 1.5
assert web["data"][0]["type"] == "bar"


def test_canvas_to_json_and_savefig(tmp_path):
canvas = Canvas(width="10cm", ratio=0.5)
x = np.linspace(0, 1, 5)
canvas.plot(x, x, label="line")
canvas.set_title("A canvas")
web = json.loads(canvas.to_json(alt="A straight line"))
hints = web["layout"]["meta"]["web"]
assert hints["aspect"] == 2.0
assert hints["alt"] == "A straight line"
assert "template" not in web["layout"]
assert web["layout"]["yaxis"]["automargin"] is True # room for the tick labels

canvas.savefig(tmp_path / "canvas.plotly.json", backend="plotly")
saved = json.loads((tmp_path / "canvas.plotly.json").read_text())
assert saved["layout"]["meta"]["web"]["version"] == FORMAT_VERSION
assert saved["data"][0]["name"] == "line"


def test_canvas_aspect_from_figsize_and_the_golden_ratio():
assert Canvas(figsize=(6, 3))._web_aspect() == 2.0
assert Canvas(width="10cm")._web_aspect() == pytest.approx(1.618, abs=1e-3)
Loading
Loading