From a867d96b1ea97b25cdb085106571b6798744d31f Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 6 Nov 2026 16:11:16 +0000 Subject: [PATCH 1/2] Add web figures: theme-neutral Plotly JSON for the docs sites. --- docs/src/content/docs/guides/web-figures.md | 76 ++++++ src/maxplotlib/canvas/canvas.py | 53 ++++ src/maxplotlib/tests/test_web.py | 139 ++++++++++ src/maxplotlib/web.py | 281 ++++++++++++++++++++ 4 files changed, 549 insertions(+) create mode 100644 docs/src/content/docs/guides/web-figures.md create mode 100644 src/maxplotlib/tests/test_web.py create mode 100644 src/maxplotlib/web.py 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..9cd9f8b --- /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), 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. Other explicit colours, such as a black reference curve, 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..519f5c6 --- /dev/null +++ b/src/maxplotlib/tests/test_web.py @@ -0,0 +1,139 @@ +"""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"] + + 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..94c4235 --- /dev/null +++ b/src/maxplotlib/web.py @@ -0,0 +1,281 @@ +"""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. 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.]+)") + + +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 + 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 From a82d867fb298e37478a5c9e9fc3979f2d12265e6 Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 6 Nov 2026 16:21:02 +0000 Subject: [PATCH 2/2] Web figures: automargin on the axes and black traces in the text colour. --- docs/src/content/docs/guides/web-figures.md | 10 +++++----- src/maxplotlib/tests/test_web.py | 1 + src/maxplotlib/web.py | 8 +++++++- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/src/content/docs/guides/web-figures.md b/docs/src/content/docs/guides/web-figures.md index 9cd9f8b..7db2373 100644 --- a/docs/src/content/docs/guides/web-figures.md +++ b/docs/src/content/docs/guides/web-figures.md @@ -53,15 +53,15 @@ starlight-theme. 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), and - transparent `paper_bgcolor` and `plot_bgcolor`. The page sets text, grid and background - colours from the theme. +- **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. Other explicit colours, such as a black reference curve, and colormaps stay - as they are. + 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 | diff --git a/src/maxplotlib/tests/test_web.py b/src/maxplotlib/tests/test_web.py index 519f5c6..269fba7 100644 --- a/src/maxplotlib/tests/test_web.py +++ b/src/maxplotlib/tests/test_web.py @@ -127,6 +127,7 @@ def test_canvas_to_json_and_savefig(tmp_path): 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()) diff --git a/src/maxplotlib/web.py b/src/maxplotlib/web.py index 94c4235..e4a8c68 100644 --- a/src/maxplotlib/web.py +++ b/src/maxplotlib/web.py @@ -11,7 +11,8 @@ 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. Other explicit colours and colormaps stay as they are. + 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"]``: @@ -82,6 +83,7 @@ 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: @@ -229,6 +231,10 @@ def web_figure( 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)}