diff --git a/docs/src/content/docs/guides/web-figures.md b/docs/src/content/docs/guides/web-figures.md
new file mode 100644
index 0000000..7db2373
--- /dev/null
+++ b/docs/src/content/docs/guides/web-figures.md
@@ -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';
+
+
+```
+
+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.
diff --git a/src/maxplotlib/canvas/canvas.py b/src/maxplotlib/canvas/canvas.py
index c75ae08..0a86b62 100644
--- a/src/maxplotlib/canvas/canvas.py
+++ b/src/maxplotlib/canvas/canvas.py
@@ -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:
@@ -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:
@@ -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,
diff --git a/src/maxplotlib/tests/test_web.py b/src/maxplotlib/tests/test_web.py
new file mode 100644
index 0000000..269fba7
--- /dev/null
+++ b/src/maxplotlib/tests/test_web.py
@@ -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)
diff --git a/src/maxplotlib/web.py b/src/maxplotlib/web.py
new file mode 100644
index 0000000..e4a8c68
--- /dev/null
+++ b/src/maxplotlib/web.py
@@ -0,0 +1,287 @@
+"""Web figures: Plotly figures as theme-neutral JSON for the max-models docs sites.
+
+A web figure is a Plotly figure saved as JSON (``data``, ``layout``, ``frames``) without
+plotly.js and without anything that fixes its look to one page: no template, no fixed width or
+height, transparent backgrounds. The ``PlotlyFigure`` component of the max-models docs theme
+(starlight-theme) embeds it on a page, loads plotly.js only when the figure scrolls into view,
+and gives it the site's colours in light and dark mode:
+
+- text, grid lines and backgrounds from the theme;
+- the theme's data palette for every trace in a default colour. Traces without a colour take it
+ from ``layout.colorway``, which the page sets to the palette; traces with an explicit colour
+ from a default cycle (Matplotlib's ``C0``..``C9``, Plotly's default colorway, the Okabe-Ito
+ palette) carry the index of that colour as ``trace.meta["color_slot"]``, and the page gives
+ them the palette colour of that index. Black traces take the text colour. Other explicit
+ colours and colormaps stay as they are.
+
+Hints for the page are in ``layout.meta["web"]``:
+
+``version``
+ The format version, :data:`FORMAT_VERSION`.
+``title``
+ The figure's title, also the default caption.
+``alt``
+ A text alternative for readers who cannot see the figure.
+``aspect``
+ Width over height, so the page reserves the right space before the figure loads.
+``white_plot``
+ Keep a white plot area in dark mode too, for colours chosen for a white background
+ (e.g. a figure converted from Matplotlib with white overlays).
+
+Examples
+--------
+>>> import plotly.graph_objects as go
+>>> from maxplotlib.web import write_web_figure
+>>> fig = go.Figure(go.Scatter(x=[0, 1, 2], y=[1, 3, 2]))
+>>> write_web_figure(fig, "public/figures/line.plotly.json", alt="A rising line")
+"""
+
+from __future__ import annotations
+
+import json
+import re
+from pathlib import Path
+from typing import Any
+
+FORMAT_VERSION = 1
+
+# The default colour cycles whose colours become palette slots, each in its own order.
+MATPLOTLIB_CYCLE = (
+ "#1f77b4",
+ "#ff7f0e",
+ "#2ca02c",
+ "#d62728",
+ "#9467bd",
+ "#8c564b",
+ "#e377c2",
+ "#7f7f7f",
+ "#bcbd22",
+ "#17becf",
+)
+PLOTLY_COLORWAY = (
+ "#636efa",
+ "#ef553b",
+ "#00cc96",
+ "#ab63fa",
+ "#ffa15a",
+ "#19d3f3",
+ "#ff6692",
+ "#b6e880",
+ "#ff97ff",
+ "#fecb52",
+)
+OKABE_ITO = (
+ "#0072b2",
+ "#e69f00",
+ "#009e73",
+ "#d55e00",
+ "#cc79a7",
+ "#56b4e9",
+ "#f0e442",
+ "#000000",
+)
+DEFAULT_CYCLES = (MATPLOTLIB_CYCLE, PLOTLY_COLORWAY, OKABE_ITO)
+
+_RGB = re.compile(r"rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)")
+_CARTESIAN_AXIS = re.compile(r"[xy]axis\d*")
+
+
+def _hex(color: Any) -> str | None:
+ """A colour as ``#rrggbb``, or ``None`` if it is not a single colour string."""
+ if not isinstance(color, str):
+ return None
+ text = color.strip().lower()
+ if re.fullmatch(r"#[0-9a-f]{6}", text):
+ return text
+ if re.fullmatch(r"#[0-9a-f]{3}", text):
+ return "#" + "".join(c * 2 for c in text[1:])
+ match = _RGB.match(text)
+ if match:
+ r, g, b = (round(float(v)) for v in match.groups())
+ return f"#{r:02x}{g:02x}{b:02x}"
+ return None
+
+
+def color_slot(color: Any, cycles=DEFAULT_CYCLES) -> int | None:
+ """Return the position of a colour in one of the default colour cycles.
+
+ Parameters
+ ----------
+ color : str
+ A colour, as ``#rrggbb``, ``#rgb``, ``rgb(...)`` or ``rgba(...)``.
+ cycles : sequence of sequence of str, optional
+ The cycles to look in, in order. Default: :data:`DEFAULT_CYCLES`.
+
+ Returns
+ -------
+ int or None
+ The position, or ``None`` if the colour is in none of the cycles.
+
+ Examples
+ --------
+ >>> color_slot("#ff7f0e") # Matplotlib's C1
+ 1
+ >>> color_slot("black") is None
+ True
+ """
+ code = _hex(color)
+ if code is None:
+ return None
+ for cycle in cycles:
+ if code in cycle:
+ return cycle.index(code)
+ return None
+
+
+def _trace_slot(trace: dict) -> int | None:
+ """The palette slot of a trace: of its line colour, else of its marker or fill colour."""
+ for path in (("line", "color"), ("marker", "color"), ("fillcolor",)):
+ value: Any = trace
+ for key in path:
+ value = value.get(key) if isinstance(value, dict) else None
+ slot = color_slot(value)
+ if slot is not None:
+ return slot
+ return None
+
+
+def _json_default(value: Any) -> Any:
+ tolist = getattr(value, "tolist", None)
+ if tolist is not None:
+ return tolist()
+ raise TypeError(f"cannot write {type(value).__name__} as JSON")
+
+
+def _figure_dict(figure: Any) -> dict:
+ """The figure as plain JSON data, with Plotly's own encoding of arrays."""
+ if isinstance(figure, dict):
+ return json.loads(json.dumps(figure, default=_json_default))
+ import plotly.io as pio
+
+ return json.loads(pio.to_json(figure, validate=False))
+
+
+def _title_text(layout: dict) -> str | None:
+ title = layout.get("title")
+ if isinstance(title, dict):
+ title = title.get("text")
+ return title if isinstance(title, str) and title else None
+
+
+def _with_slot(trace: dict) -> dict:
+ slot = _trace_slot(trace)
+ if slot is None:
+ return trace
+ meta = trace.get("meta")
+ if meta is not None and not isinstance(meta, dict):
+ meta = {"value": meta}
+ return {**trace, "meta": {**(meta or {}), "color_slot": slot}}
+
+
+def web_figure(
+ figure: Any,
+ *,
+ title: str | None = None,
+ alt: str | None = None,
+ aspect: float | None = None,
+ white_plot: bool | None = None,
+) -> dict:
+ """Make a Plotly figure theme-neutral, as a dict ready to write as JSON.
+
+ Parameters
+ ----------
+ figure : plotly.graph_objects.Figure or dict
+ The figure, or its JSON as a dict (e.g. read from a file).
+ title : str, optional
+ The title and default caption. Default: the figure's own title.
+ alt : str, optional
+ A text alternative for the figure. Default: none.
+ aspect : float, optional
+ Width over height. Default: from the figure's width and height when both are set.
+ white_plot : bool, optional
+ Keep a white plot area in dark mode. Default: ``True`` for a figure converted from
+ Matplotlib (``layout.meta["converted_from"] == "matplotlib"``), else ``False``.
+
+ Returns
+ -------
+ dict
+ ``data``, ``layout`` and ``frames`` (when the figure has frames) of the web figure.
+
+ Examples
+ --------
+ >>> web = web_figure(fig, alt="Field energy over time")
+ >>> web["layout"]["meta"]["web"]["version"]
+ 1
+ """
+ source = _figure_dict(figure)
+ layout = dict(source.get("layout") or {})
+ meta = layout.get("meta")
+ meta = (
+ dict(meta)
+ if isinstance(meta, dict)
+ else ({} if meta is None else {"value": meta})
+ )
+
+ width, height = layout.pop("width", None), layout.pop("height", None)
+ if aspect is None and width and height:
+ aspect = float(width) / float(height)
+ if white_plot is None:
+ white_plot = meta.get("converted_from") == "matplotlib"
+ title = title if title is not None else _title_text(layout)
+
+ layout.pop("template", None)
+ layout["autosize"] = True
+ # the page sets the size, so make room for tick labels and axis titles at any size
+ for key, axis in layout.items():
+ if _CARTESIAN_AXIS.fullmatch(key) and isinstance(axis, dict):
+ axis.setdefault("automargin", True)
+ layout["paper_bgcolor"] = "rgba(0,0,0,0)"
+ layout["plot_bgcolor"] = "white" if white_plot else "rgba(0,0,0,0)"
+ hints: dict[str, Any] = {"version": FORMAT_VERSION, "white_plot": bool(white_plot)}
+ if title:
+ hints["title"] = title
+ if alt:
+ hints["alt"] = alt
+ if aspect:
+ hints["aspect"] = round(float(aspect), 4)
+ meta["web"] = hints
+ layout["meta"] = meta
+
+ web = {
+ "data": [_with_slot(trace) for trace in source.get("data") or []],
+ "layout": layout,
+ }
+ frames = source.get("frames")
+ if frames:
+ web["frames"] = [
+ {**frame, "data": [_with_slot(trace) for trace in frame.get("data") or []]}
+ for frame in frames
+ ]
+ return web
+
+
+def write_web_figure(figure: Any, path: str | Path, **hints: Any) -> Path:
+ """Write a Plotly figure as a web figure, ``.plotly.json``.
+
+ Parameters
+ ----------
+ figure : plotly.graph_objects.Figure or dict
+ The figure.
+ path : str or pathlib.Path
+ The file; its folder is created when missing.
+ **hints
+ ``title``, ``alt``, ``aspect`` and ``white_plot``, as for :func:`web_figure`.
+
+ Returns
+ -------
+ pathlib.Path
+ The file written.
+
+ Examples
+ --------
+ >>> write_web_figure(fig, "docs/public/figures/energy.plotly.json", alt="Energy over time")
+ """
+ path = Path(path)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(web_figure(figure, **hints), separators=(",", ":")))
+ return path