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
6 changes: 6 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,19 @@ 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 /<repo>/ and devel at /<repo>/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
smoke-paths: |
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<PlotlyFigure src="/figures/waves.plotly.json" alt="Four damped waves with a reference line at zero" />

```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.

<PlotlyFigure src="/figures/heatmap.plotly.json" />

```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.

<PlotlyFigure src="/figures/packet.plotly.json" />

```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
Expand Down
133 changes: 133 additions & 0 deletions scripts/web_figure_examples.py
Original file line number Diff line number Diff line change
@@ -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")
15 changes: 12 additions & 3 deletions src/maxplotlib/canvas/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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(
Expand Down
15 changes: 15 additions & 0 deletions src/maxplotlib/tests/test_web.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading