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
32 changes: 28 additions & 4 deletions docs/src/content/docs/guides/web-figures.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<PlotlyFigure src="/figures/waves.plotly.json" alt="Four damped waves with a reference line at zero" />
<PlotlyFigure
src="/figures/waves.plotly.json"
alt="Four damped waves with a reference line at zero"
caption="Four damped waves, in the site's data palette."
poster="/figures/waves.png"
posterDark="/figures/waves.dark.png"
/>

```python
x = np.linspace(0, 4 * np.pi, 400)
Expand All @@ -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.

<PlotlyFigure src="/figures/heatmap.plotly.json" />
<PlotlyFigure
src="/figures/heatmap.plotly.json"
caption="A potential φ(x, y) and its 0.5 contour; the colormap stays, the contour takes the palette."
poster="/figures/heatmap.png"
posterDark="/figures/heatmap.dark.png"
/>

```python
import plotly.graph_objects as go
Expand All @@ -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},
Expand All @@ -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.

<PlotlyFigure src="/figures/packet.plotly.json" />
<PlotlyFigure
src="/figures/packet.plotly.json"
caption="A wave packet travelling to the right: press Play or drag the slider."
poster="/figures/packet.png"
posterDark="/figures/packet.dark.png"
/>

```python
frames = [go.Frame(name=f"{t:.1f}", data=[go.Scatter(y=packet(t))]) for t in times]
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ xarray = [
"xarray",
]
docs = [
"kaleido",
"xarray",
"griffe",
"ipykernel",
Expand Down
26 changes: 24 additions & 2 deletions scripts/web_figure_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()
33 changes: 33 additions & 0 deletions src/maxplotlib/tests/test_web.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
99 changes: 99 additions & 0 deletions src/maxplotlib/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: ``<stem>.png``, ``<stem>.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
Loading