diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 4ddc704..0c27cd3 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -26,6 +26,8 @@ jobs: uses: max-models/starlight-theme/.github/workflows/docs.yml@v0 with: apt-packages: "texlive-latex-base texlive-latex-extra texlive-pictures texlive-fonts-recommended lmodern" + # the example web figures of guides/web-figures, from scripts/web_figure_examples.py + pre-build: '"$DOCS_PYTHON" scripts/web_figure_examples.py' # stable-branch: main # deploy the latest release at // and devel at //dev/ # a notebook's outputs are numbered in order: in tutorial 07, 1.html is the Plotly # preview and 2.png the first compiled TikZ figure @@ -33,6 +35,10 @@ jobs: getting-started/installation/index.html getting-started/quickstart/index.html guides/matplotlib-import/index.html + guides/web-figures/index.html + figures/waves.plotly.json + figures/heatmap.plotly.json + figures/packet.plotly.json tutorials/index.html tutorials/tutorial-01/index.html tutorials/tutorial-07-tikz/index.html diff --git a/docs/src/content/docs/guides/web-figures.md b/docs/src/content/docs/guides/web-figures.mdx similarity index 55% rename from docs/src/content/docs/guides/web-figures.md rename to docs/src/content/docs/guides/web-figures.mdx index 5b8a5ec..915dd24 100644 --- a/docs/src/content/docs/guides/web-figures.md +++ b/docs/src/content/docs/guides/web-figures.mdx @@ -3,11 +3,88 @@ 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. --- +import PlotlyFigure from '@max-models/starlight-theme/components/PlotlyFigure.astro'; + 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. +## Examples + +The figures below are web figures, embedded on this page with `PlotlyFigure`. Switch the site +between light and dark mode (top right) to see them follow it; hover, zoom and pan work as in +any Plotly figure. + +### Lines in the site's palette + +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) +canvas = Canvas(width="12cm", ratio=0.5) # the aspect ratio of the embedded figure: 2 +for k in range(4): + canvas.plot(x, np.exp(-0.1 * x) * np.sin(x - k * np.pi / 4), label=f"phase {k}/4 π") +canvas.plot(x, 0 * x, color="black", linewidth=0.8) +canvas.set_xlabel("t") +canvas.set_ylabel("amplitude") +canvas.set_legend(True) +canvas.savefig("docs/public/figures/waves.plotly.json", backend="plotly") +``` + +### Any Plotly figure: a colormap and a contour + +`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 + +from maxplotlib.web import write_web_figure + +fig = go.Figure( + [ + go.Heatmap(x=x, y=y, z=z, colorscale="Viridis", colorbar={"title": "φ"}), + go.Contour( + x=x, y=y, z=z, + contours={"coloring": "none", "start": 0.5, "end": 0.5}, + line={"color": "#ff7f0e", "width": 2}, + showscale=False, + ), + ], + layout={"xaxis_title": "x", "yaxis_title": "y", "width": 640, "height": 420}, +) +write_web_figure( + fig, "docs/public/figures/heatmap.plotly.json", alt="A potential φ(x, y) with its 0.5 contour" +) +``` + +### An animation + +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] +fig = go.Figure( + data=[go.Scatter(x=x, y=packet(0), name="u(x, t)")], + frames=frames, + layout={"sliders": [...], "updatemenus": [...], "width": 700, "height": 380}, +) +write_web_figure(fig, "docs/public/figures/packet.plotly.json", alt="A wave packet travelling to the right") +``` + +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. + ## Saving a canvas ```python diff --git a/scripts/web_figure_examples.py b/scripts/web_figure_examples.py new file mode 100644 index 0000000..2661c4f --- /dev/null +++ b/scripts/web_figure_examples.py @@ -0,0 +1,133 @@ +"""Write the example web figures of the docs' "Web figures" guide to docs/public/figures/. + +The docs build runs this before ``astro build`` (``pre-build`` in .github/workflows/docs.yml); +run it by hand for ``astro dev``: + + python scripts/web_figure_examples.py + +The code of each figure is also shown on the page, in docs/src/content/docs/guides/web-figures.mdx; +keep the two the same. +""" + +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 + +OUT = Path(__file__).resolve().parents[1] / "docs" / "public" / "figures" + + +def waves(): + """Lines without colours of their own, and a black reference line.""" + x = np.linspace(0, 4 * np.pi, 400) + canvas = Canvas(width="12cm", ratio=0.5) + for k in range(4): + canvas.plot( + x, np.exp(-0.1 * x) * np.sin(x - k * np.pi / 4), label=f"phase {k}/4 π" + ) + canvas.plot(x, 0 * x, color="black", linewidth=0.8) + canvas.set_xlabel("t") + canvas.set_ylabel("amplitude") + canvas.set_legend(True) + canvas.savefig(OUT / "waves.plotly.json", backend="plotly") + + +def heatmap(): + """A colormap, which stays as it is, and a contour in Matplotlib's C1.""" + x = np.linspace(-2, 2, 80) + y = np.linspace(-1.5, 1.5, 60) + X, Y = np.meshgrid(x, y) + z = np.exp(-(X**2 + Y**2)) * np.cos(3 * X) + 0.3 * np.exp( + -((X - 1) ** 2 + Y**2) * 4 + ) + fig = go.Figure( + [ + go.Heatmap(x=x, y=y, z=z, colorscale="Viridis", colorbar={"title": "φ"}), + go.Contour( + x=x, + y=y, + z=z, + contours={"coloring": "none", "start": 0.5, "end": 0.5}, + line={"color": "#ff7f0e", "width": 2}, + showscale=False, + name="φ = 0.5", + ), + ], + layout={ + "xaxis_title": "x", + "yaxis_title": "y", + "width": 640, + "height": 420, + "margin": {"t": 30}, + }, + ) + write_web_figure( + fig, OUT / "heatmap.plotly.json", alt="A potential φ(x, y) with its 0.5 contour" + ) + + +def animation(): + """An animation with a slider and Play button: a wave packet travelling to the right.""" + x = np.linspace(0, 10, 200) + times = np.linspace(0, 6, 31) + + def packet(t): + return np.exp(-((x - 2 - t) ** 2)) * np.cos(6 * (x - 2 - t)) + + frames = [ + # a frame names only what changes: the y values + go.Frame(name=f"{t:.1f}", data=[go.Scatter(y=packet(t))]) + for t in times + ] + steps = [ + { + "label": frame.name, + "method": "animate", + "args": [[frame.name], {"mode": "immediate", "frame": {"duration": 0}}], + } + for frame in frames + ] + fig = go.Figure( + data=[go.Scatter(x=x, y=packet(0), name="u(x, t)")], + frames=frames, + layout={ + "xaxis": {"title": {"text": "x", "standoff": 8}, "range": [0, 10]}, + "yaxis": {"title": "u", "range": [-1.1, 1.1]}, + "width": 700, + "height": 380, + "sliders": [ + {"steps": steps, "currentvalue": {"prefix": "t = "}, "pad": {"t": 50}} + ], + "updatemenus": [ + { + "type": "buttons", + "x": 0, + "y": 1.15, + "xanchor": "left", + "buttons": [ + { + "label": "Play", + "method": "animate", + "args": [ + None, + {"frame": {"duration": 60}, "fromcurrent": True}, + ], + } + ], + } + ], + }, + ) + write_web_figure( + fig, OUT / "packet.plotly.json", alt="A wave packet travelling to the right" + ) + + +if __name__ == "__main__": + OUT.mkdir(parents=True, exist_ok=True) + for example in (waves, heatmap, animation): + example() + print(f"wrote the {example.__name__} example") diff --git a/src/maxplotlib/canvas/canvas.py b/src/maxplotlib/canvas/canvas.py index 0a86b62..2afde69 100644 --- a/src/maxplotlib/canvas/canvas.py +++ b/src/maxplotlib/canvas/canvas.py @@ -2941,7 +2941,7 @@ def plot_plotly( subplot_titles = [""] * (self.nrows * self.ncols) for (row, col), sp in self._subplot_dict.items(): index = row * self.ncols + col - subplot_titles[index] = sp._title or f"({row}, {col})" + subplot_titles[index] = sp._title or "" specs = [ [{"secondary_y": (r, c) in self._twinx_subplots} for c in range(self.ncols)] @@ -3194,11 +3194,20 @@ def plot_plotly( col=col + 1, ) - # Update layout settings + # Update layout settings: tight margins, with room for the axis titles. A title needs a + # standoff from its tick labels: with automargin (web figures) and no standoff, Plotly + # pins it to the edge of the figure, half outside. + subplots = self._subplot_dict.values() + has_xlabel = any(getattr(sp, "_xlabel", None) for sp in subplots) + has_ylabel = any(getattr(sp, "_ylabel", None) for sp in subplots) fig.update_layout( font=dict(size=self.fontsize), - margin=dict(l=10, r=10, t=40, b=10), + margin=dict( + l=70 if has_ylabel else 10, r=10, t=40, b=60 if has_xlabel else 10 + ), ) + fig.update_xaxes(title_standoff=8) + fig.update_yaxes(title_standoff=8) barmode = self._barmode if barmode is None: barmode = next( diff --git a/src/maxplotlib/tests/test_web.py b/src/maxplotlib/tests/test_web.py index 269fba7..167ab53 100644 --- a/src/maxplotlib/tests/test_web.py +++ b/src/maxplotlib/tests/test_web.py @@ -138,3 +138,18 @@ def test_canvas_to_json_and_savefig(tmp_path): 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) + + +def test_plotly_canvas_has_room_for_axis_titles_and_no_placeholder_title(): + canvas = Canvas(width="10cm", ratio=0.5) + canvas.plot([0, 1], [0, 1]) + layout = json.loads(canvas.to_json())["layout"] + texts = [a.get("text") for a in layout.get("annotations", [])] + assert "(0, 0)" not in texts # an untitled subplot has no title + assert layout["margin"]["b"] == 10 and layout["margin"]["l"] == 10 + + canvas.set_xlabel("t") + canvas.set_ylabel("amplitude") + layout = json.loads(canvas.to_json())["layout"] + assert layout["margin"]["b"] == 60 and layout["margin"]["l"] == 70 + assert layout["xaxis"]["title"]["standoff"] == 8