diff --git a/docs/src/content/docs/guides/web-figures.mdx b/docs/src/content/docs/guides/web-figures.mdx index 915dd24..378e590 100644 --- a/docs/src/content/docs/guides/web-figures.mdx +++ b/docs/src/content/docs/guides/web-figures.mdx @@ -21,7 +21,13 @@ any Plotly figure. The four waves have no colour of their own, so they take the site's data palette in order. The black reference line takes the text colour, so it stays visible in dark mode. - + ```python x = np.linspace(0, 4 * np.pi, 400) @@ -40,7 +46,12 @@ canvas.savefig("docs/public/figures/waves.plotly.json", backend="plotly") `write_web_figure` takes any Plotly figure. A colormap stays as it is; the contour drawn in Matplotlib's `C1` (`#ff7f0e`) carries that position and takes the palette's second colour. - + ```python import plotly.graph_objects as go @@ -49,7 +60,8 @@ from maxplotlib.web import write_web_figure fig = go.Figure( [ - go.Heatmap(x=x, y=y, z=z, colorscale="Viridis", colorbar={"title": "φ"}), + # zsmooth: a smooth field instead of visible cells (keep the cells for grid data) + go.Heatmap(x=x, y=y, z=z, colorscale="Viridis", colorbar={"title": "φ"}, zsmooth="best"), go.Contour( x=x, y=y, z=z, contours={"coloring": "none", "start": 0.5, "end": 0.5}, @@ -69,7 +81,12 @@ write_web_figure( Frames, a slider and a Play button come along: the file is the whole animation, and the page plays it with the plotly.js it loads. - + ```python frames = [go.Frame(name=f"{t:.1f}", data=[go.Scatter(y=packet(t))]) for t in times] @@ -85,6 +102,13 @@ The full code of the three figures is [`scripts/web_figure_examples.py`](https://github.com/max-models/maxplotlib/blob/devel/scripts/web_figure_examples.py), which the docs build runs before building this site. +### Captions and posters + +`caption` adds a caption below a figure. `poster` and `posterDark` are pictures of the figure for +light and dark mode, shown until plotly.js has drawn it and in its place without JavaScript, e.g. +for search engines and link previews. `maxplotlib.web.write_posters(fig, "docs/public/figures/waves")` +writes both (`waves.png`, `waves.dark.png`; needs kaleido). + ## Saving a canvas ```python diff --git a/pyproject.toml b/pyproject.toml index f238cd0..ec62a26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ xarray = [ "xarray", ] docs = [ + "kaleido", "xarray", "griffe", "ipykernel", diff --git a/scripts/web_figure_examples.py b/scripts/web_figure_examples.py index 2661c4f..62a4768 100644 --- a/scripts/web_figure_examples.py +++ b/scripts/web_figure_examples.py @@ -9,13 +9,14 @@ keep the two the same. """ +import json from pathlib import Path import numpy as np import plotly.graph_objects as go from maxplotlib import Canvas -from maxplotlib.web import write_web_figure +from maxplotlib.web import write_posters, write_web_figure OUT = Path(__file__).resolve().parents[1] / "docs" / "public" / "figures" @@ -45,7 +46,15 @@ def heatmap(): ) fig = go.Figure( [ - go.Heatmap(x=x, y=y, z=z, colorscale="Viridis", colorbar={"title": "φ"}), + # zsmooth: a smooth field instead of visible cells (keep the cells for grid data) + go.Heatmap( + x=x, + y=y, + z=z, + colorscale="Viridis", + colorbar={"title": "φ"}, + zsmooth="best", + ), go.Contour( x=x, y=y, @@ -126,8 +135,21 @@ def packet(t): ) +def posters(): + """Pictures of the figures for light and dark mode, shown until plotly.js has drawn them.""" + for name in ("waves", "heatmap", "packet"): + web = json.loads((OUT / f"{name}.plotly.json").read_text()) + try: + write_posters(web, OUT / name) + except Exception as error: # no kaleido or no browser for it: no posters + print(f"skipped the posters of {name}: {error}") + return + print(f"wrote the posters of {name}") + + if __name__ == "__main__": OUT.mkdir(parents=True, exist_ok=True) for example in (waves, heatmap, animation): example() print(f"wrote the {example.__name__} example") + posters() diff --git a/src/maxplotlib/tests/test_web.py b/src/maxplotlib/tests/test_web.py index 167ab53..e642eb9 100644 --- a/src/maxplotlib/tests/test_web.py +++ b/src/maxplotlib/tests/test_web.py @@ -153,3 +153,36 @@ def test_plotly_canvas_has_room_for_axis_titles_and_no_placeholder_title(): layout = json.loads(canvas.to_json())["layout"] assert layout["margin"]["b"] == 60 and layout["margin"]["l"] == 70 assert layout["xaxis"]["title"]["standoff"] == 8 + + +def test_write_posters(tmp_path, monkeypatch): + from maxplotlib.web import write_posters + + written = [] + monkeypatch.setattr( + go.Figure, + "write_image", + lambda self, path, **kw: written.append( + (path, kw, self.layout.font.color, len(self.layout.sliders), self) + ), + ) + fig = go.Figure( + [ + go.Scatter(y=[1, 2], line={"color": "#ff7f0e"}), # C1 + go.Scatter(y=[2, 1], line={"color": "black"}), + ], + layout={"width": 800, "height": 400, "sliders": [{"steps": []}]}, + ) + paths = write_posters(fig, tmp_path / "sub" / "energy") + assert paths == [ + str(tmp_path / "sub" / "energy.png"), + str(tmp_path / "sub" / "energy.dark.png"), + ] + assert written[0][1] == {"width": 800, "height": 400, "scale": 1} + assert (written[0][2], written[1][2]) == ("#10222d", "#eeeadd") + assert written[0][3] == 0 # no slider on a picture + light, dark = written[0][4], written[1][4] + assert light.data[0].line.color == "#c98a00" # C1 in the light palette + assert dark.data[0].line.color == "#f0b030" # and in the dark one + assert dark.data[1].line.color == "#eeeadd" # black as the text colour + assert list(dark.layout.colorway)[0] == "#4fa8e0" diff --git a/src/maxplotlib/web.py b/src/maxplotlib/web.py index e4a8c68..9aecb2b 100644 --- a/src/maxplotlib/web.py +++ b/src/maxplotlib/web.py @@ -285,3 +285,102 @@ def write_web_figure(figure: Any, path: str | Path, **hints: Any) -> Path: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(web_figure(figure, **hints), separators=(",", ":"))) return path + + +#: The colours of the posters for light (``""``) and dark (``".dark"``) pages, those of the +#: max-models docs theme: text, grid lines and the data palette (``--mm-data-1`` ... ``-8``). +POSTER_COLORS = { + "": { + "text": "#10222d", + "grid": "rgba(16, 34, 45, 0.12)", + "palette": ("#0072b2", "#c98a00", "#009e73", "#d55e00") + + ("#b55d92", "#3d9fd6", "#a69500", "#5f6b73"), + }, + ".dark": { + "text": "#eeeadd", + "grid": "rgba(238, 234, 221, 0.14)", + "palette": ("#4fa8e0", "#f0b030", "#2fc79a", "#f07a3a") + + ("#e08fc0", "#8fd0f5", "#e8dc4a", "#a8b0b6"), + }, +} + + +def _poster_trace(trace: dict, palette, text: str) -> dict: + """A trace in a poster's colours, as the theme draws it: its palette slot, black as text.""" + slot = ( + (trace.get("meta") or {}).get("color_slot") + if isinstance(trace.get("meta"), dict) + else None + ) + out = dict(trace) + for key in ("line", "marker"): + part = trace.get(key) + if isinstance(part, dict) and isinstance(part.get("color"), str): + if slot is not None: + out[key] = {**part, "color": palette[slot % len(palette)]} + elif ( + part["color"].strip().lower() == "black" + or _hex(part["color"]) == "#000000" + ): + out[key] = {**part, "color": text} + return out + + +def write_posters( + figure: Any, + stem: str | Path, + *, + width: int = 800, + colors: dict | None = None, + **hints: Any, +) -> list[str]: + """Write pictures of a web figure for light and dark pages: ``.png``, ``.dark.png``. + + The docs theme's ``PlotlyFigure`` shows them (``poster``, ``posterDark``) until plotly.js has + drawn the figure, and in its place without JavaScript, e.g. for search engines and link + previews. Both have a transparent background, the theme's text colour and no buttons or + slider; an animation shows its first frame. Needs kaleido. + + Parameters + ---------- + figure : plotly.graph_objects.Figure or dict + The figure. + stem : str or pathlib.Path + The files' path without the extension; the folder is created when missing. + width : int, optional + Width in pixels; the height follows the figure's aspect ratio. Default: 800. + colors : dict, optional + ``{suffix: {"text": ..., "grid": ..., "palette": (...)}}``. Default: :data:`POSTER_COLORS`. + **hints + Passed to :func:`web_figure`, e.g. ``white_plot=False``. + + Returns + ------- + list of str + The paths written. + + Examples + -------- + >>> write_posters(fig, "docs/public/figures/energy") + """ + import plotly.graph_objects as go + + web = web_figure(figure, **hints) + aspect = web["layout"]["meta"]["web"].get("aspect") or 1.6 + Path(stem).parent.mkdir(parents=True, exist_ok=True) + written = [] + for suffix, scheme in (colors or POSTER_COLORS).items(): + text, grid, palette = scheme["text"], scheme["grid"], list(scheme["palette"]) + data = [_poster_trace(trace, palette, text) for trace in web["data"]] + poster = go.Figure({"data": data, "layout": web["layout"]}) + poster.update_layout( + font_color=text, legend_bgcolor="rgba(0,0,0,0)", colorway=palette + ) + poster.layout.updatemenus, poster.layout.sliders = (), () # nothing to press + axes = dict(gridcolor=grid, linecolor=grid, zerolinecolor=grid, mirror=False) + poster.update_xaxes(**axes) + poster.update_yaxes(**axes) + path = f"{stem}{suffix}.png" + poster.write_image(path, width=width, height=round(width / aspect), scale=1) + written.append(path) + return written