diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 1442bb8..17a97da 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -53,6 +53,20 @@ jobs: - name: Install Playwright browser run: uv run playwright install chromium --with-deps + # ── Build Pyodide wheel ─────────────────────────────────────────────── + # Produces docs/_static/wheels/anyplotlib-0.0.0-py3-none-any.whl so the + # in-browser Pyodide bridge can install the exact source tree that built + # these docs — no PyPI release required. + - name: Build Pyodide wheel + run: | + mkdir -p docs/_static/wheels + uv build --wheel --out-dir docs/_static/wheels/ + # Rename to the stable sentinel name micropip expects for URL installs. + cd docs/_static/wheels + for f in anyplotlib-*.whl; do + [ "$f" != "anyplotlib-0.0.0-py3-none-any.whl" ] && mv "$f" anyplotlib-0.0.0-py3-none-any.whl + done + # ── Sphinx build ───────────────────────────────────────────────────── # -W turns warnings into errors; --keep-going collects all of them. - name: Build HTML documentation @@ -110,6 +124,16 @@ jobs: echo "dest_dir=dev" >> "$GITHUB_OUTPUT" fi + # ── Build Pyodide wheel ─────────────────────────────────────────────── + - name: Build Pyodide wheel + run: | + mkdir -p docs/_static/wheels + uv build --wheel --out-dir docs/_static/wheels/ + cd docs/_static/wheels + for f in anyplotlib-*.whl; do + [ "$f" != "anyplotlib-0.0.0-py3-none-any.whl" ] && mv "$f" anyplotlib-0.0.0-py3-none-any.whl + done + # ── Sphinx build ───────────────────────────────────────────────────── - name: Build HTML documentation env: diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c0a1cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Python bytecode / caches +__pycache__/ +*.py[cod] +*$py.class +*.pyo + +# Distribution / packaging +dist/ +build/ +*.egg-info/ +*.egg +.eggs/ + +# Virtual environments +.venv/ +venv/ +env/ + +# Test / coverage artefacts +.pytest_cache/ +.coverage +coverage.xml +htmlcov/ + +# Jupyter notebooks checkpoints +.ipynb_checkpoints/ + +# Sphinx build output +docs/_build/ +build/html/ +build/doctrees/ + +# Generated Pyodide wheel (built by workflow / make html — never commit) +docs/_static/wheels/ + +# Editor / IDE +.idea/ +.vscode/ +*.swp +*.swo + +# macOS +.DS_Store + + diff --git a/Examples/Interactive/plot_interactive_fft.py b/Examples/Interactive/plot_interactive_fft.py index 20e2773..f274cc4 100644 --- a/Examples/Interactive/plot_interactive_fft.py +++ b/Examples/Interactive/plot_interactive_fft.py @@ -178,7 +178,4 @@ def _roi_released(event): v_fft.set_data(log_mag, x_axis=freq_x, y_axis=freq_y, units="1/\u00c5") -fig - - - +fig # Interactive diff --git a/Examples/Interactive/plot_interactive_fitting.py b/Examples/Interactive/plot_interactive_fitting.py index 06edc41..8acd7b6 100644 --- a/Examples/Interactive/plot_interactive_fitting.py +++ b/Examples/Interactive/plot_interactive_fitting.py @@ -19,6 +19,9 @@ and the sum curve will jump to the optimal fit. Click a component line again to hide its widgets. """ +# Packages required when running interactively in Pyodide (docs live mode). +_PYODIDE_PACKAGES = ["scipy"] + import numpy as np from scipy.optimize import curve_fit import anyplotlib as apl @@ -289,4 +292,4 @@ def _on_fit(event): def _clicked(event, c=comp): c.toggle() -fig \ No newline at end of file +fig # Interactive \ No newline at end of file diff --git a/Examples/Interactive/plot_key_bindings.py b/Examples/Interactive/plot_key_bindings.py index d3c1f22..3ec8505 100644 --- a/Examples/Interactive/plot_key_bindings.py +++ b/Examples/Interactive/plot_key_bindings.py @@ -118,5 +118,4 @@ def log_key(event): print(f"[on_key] key={event.key!r} img={pos}" f" last_widget={event.last_widget_id!r}") -fig - +fig # Interactive diff --git a/Examples/Interactive/plot_point_widget.py b/Examples/Interactive/plot_point_widget.py index 7f86f8a..c614b90 100644 --- a/Examples/Interactive/plot_point_widget.py +++ b/Examples/Interactive/plot_point_widget.py @@ -105,5 +105,5 @@ def _settled(event): _draw_tangent(event.x) -fig +fig # Interactive diff --git a/Examples/Interactive/plot_segment_by_contrast.py b/Examples/Interactive/plot_segment_by_contrast.py index fe559c3..e195dcc 100644 --- a/Examples/Interactive/plot_segment_by_contrast.py +++ b/Examples/Interactive/plot_segment_by_contrast.py @@ -232,6 +232,6 @@ def _delete_nearest(event): _refresh() -fig +fig # Interactive diff --git a/Examples/plot_3d.py b/Examples/plot_3d.py deleted file mode 100644 index fb52745..0000000 --- a/Examples/plot_3d.py +++ /dev/null @@ -1,74 +0,0 @@ -""" -3D Plotting -=========== - -Demonstrate the three 3-D geometry types supported by -:meth:`~anyplotlib.figure_plots.Axes.plot_surface`, -:meth:`~anyplotlib.figure_plots.Axes.scatter3d`, and -:meth:`~anyplotlib.figure_plots.Axes.plot3d`. -Drag to rotate, scroll to zoom, press **R** to reset the view. -""" -import numpy as np -import anyplotlib as vw - -# ── Surface ─────────────────────────────────────────────────────────────────── -x = np.linspace(-3, 3, 60) -y = np.linspace(-3, 3, 60) -XX, YY = np.meshgrid(x, y) -ZZ = np.sin(np.sqrt(XX ** 2 + YY ** 2)) - -fig, ax = vw.subplots(1, 1, figsize=(520, 480)) -surf = ax.plot_surface(XX, YY, ZZ, - colormap="viridis", - x_label="x", y_label="y", z_label="sin(r)") - -fig - -# %% -# Scatter plot -# ------------ - -rng = np.random.default_rng(1) -n = 300 -theta = rng.uniform(0, 2 * np.pi, n) -phi = rng.uniform(0, np.pi, n) -r = rng.uniform(0.6, 1.0, n) -xs = r * np.sin(phi) * np.cos(theta) -ys = r * np.sin(phi) * np.sin(theta) -zs = r * np.cos(phi) - -fig2, ax2 = vw.subplots(1, 1, figsize=(480, 480)) -sc = ax2.scatter3d(xs, ys, zs, - color="#4fc3f7", point_size=3, - x_label="x", y_label="y", z_label="z") - -fig2 - -# %% -# 3-D line — parametric helix -# ---------------------------- - -t = np.linspace(0, 4 * np.pi, 300) -hx = np.cos(t) -hy = np.sin(t) -hz = t / (4 * np.pi) - -fig3, ax3 = vw.subplots(1, 1, figsize=(480, 480)) -ln = ax3.plot3d(hx, hy, hz, - color="#ff7043", linewidth=2, - x_label="cos t", y_label="sin t", z_label="t") - -fig3 - -# %% -# Update the surface data live -# ---------------------------- -# Call :meth:`~anyplotlib.figure_plots.Plot3D.set_data` to replace the geometry -# without recreating the panel. - -ZZ2 = np.cos(np.sqrt(XX ** 2 + YY ** 2)) -surf.set_data(XX, YY, ZZ2) -surf.set_colormap("plasma") -surf.set_view(azimuth=30, elevation=40) - -fig diff --git a/Examples/plot_bar.py b/Examples/plot_bar.py deleted file mode 100644 index 05772d0..0000000 --- a/Examples/plot_bar.py +++ /dev/null @@ -1,254 +0,0 @@ -""" -Bar Chart -========= - -Demonstrate :meth:`~anyplotlib.figure_plots.Axes.bar` with: - -* **Matplotlib-aligned API** — ``ax.bar(x, height, width, bottom, …)`` -* Vertical and horizontal orientations, per-bar colours, category labels -* **Grouped bars** — pass a 2-D *height* array ``(N, G)`` -* **Log-scale value axis** — ``log_scale=True`` -* Live data updates via :meth:`~anyplotlib.figure_plots.PlotBar.set_data` -""" -import numpy as np -import anyplotlib as vw - -rng = np.random.default_rng(7) - -# ── 1. Vertical bar chart — monthly sales ──────────────────────────────────── -# The first positional argument is now *x* (positions or labels), matching -# ``matplotlib.pyplot.bar(x, height, width=0.8, bottom=0.0, ...)``. -months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] -sales = np.array([42, 55, 48, 63, 71, 68, 74, 81, 66, 59, 52, 78], - dtype=float) - -fig1, ax1 = vw.subplots(1, 1, figsize=(640, 340)) -bar1 = ax1.bar( - months, # x — category strings become x_labels automatically - sales, # height - width=0.6, - color="#4fc3f7", - show_values=True, - units="Month", - y_units="Units sold", -) -fig1 - -# %% -# Horizontal bar chart — ranked items -# ------------------------------------- -# Set ``orient="h"`` for a horizontal layout. Pass a list of CSS colours -# to ``colors`` to give each bar its own colour. - -categories = ["NumPy", "SciPy", "Matplotlib", "Pandas", "Scikit-learn", - "PyTorch", "TensorFlow", "JAX", "Polars", "Dask"] -scores = np.array([95, 88, 91, 87, 83, 79, 76, 72, 68, 65], dtype=float) - -palette = [ - "#ef5350", "#ec407a", "#ab47bc", "#7e57c2", "#42a5f5", - "#26c6da", "#26a69a", "#66bb6a", "#d4e157", "#ffa726", -] - -fig2, ax2 = vw.subplots(1, 1, figsize=(540, 400)) -bar2 = ax2.bar( - categories, - scores, - orient="h", - colors=palette, - width=0.65, - show_values=True, - y_units="Popularity score", -) -fig2 - -# %% -# Grouped bar chart — quarterly comparison -# ----------------------------------------- -# Pass a 2-D *height* array of shape ``(N, G)`` to draw *G* bars side by -# side for each category. Provide ``group_labels`` to show a legend and -# ``group_colors`` to customise each group's colour. - -quarters = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"] -q_data = np.array([ - [42, 58, 51], # Jan — Q1, Q2, Q3 - [55, 61, 59], # Feb - [48, 70, 65], # Mar - [63, 75, 71], # Apr - [71, 69, 80], # May - [68, 83, 77], # Jun -], dtype=float) # shape (6, 3) → 6 categories, 3 groups - -fig3, ax3 = vw.subplots(1, 1, figsize=(680, 340)) -bar3 = ax3.bar( - quarters, - q_data, - width=0.8, - group_labels=["Q1", "Q2", "Q3"], - group_colors=["#4fc3f7", "#ff7043", "#66bb6a"], - show_values=False, - y_units="Sales", -) -fig3 - -# %% -# Log-scale value axis -# --------------------- -# Set ``log_scale=True`` for a logarithmic value axis. Non-positive values -# are clamped to ``1e-10`` — no error is raised. Tick marks are placed at -# each decade (10⁰, 10¹, 10², …) with faint minor gridlines at 2×, 3×, 5× -# multiples. - -log_labels = ["A", "B", "C", "D", "E"] -log_vals = np.array([1, 10, 100, 1_000, 10_000], dtype=float) - -fig4, ax4 = vw.subplots(1, 1, figsize=(500, 340)) -bar4 = ax4.bar( - log_labels, - log_vals, - log_scale=True, - color="#ab47bc", - show_values=True, - y_units="Count (log scale)", -) -fig4 - -# %% -# Side-by-side comparison — update data live -# ------------------------------------------- -# Place two :class:`~anyplotlib.figure_plots.PlotBar` panels in one figure. -# Call :meth:`~anyplotlib.figure_plots.PlotBar.set_data` to swap in Q2 data — -# the value-axis range recalculates automatically. - -q1 = np.array([42, 55, 48, 63, 71, 68, 74, 81, 66, 59, 52, 78], dtype=float) -q2 = np.array([58, 61, 70, 75, 69, 83, 90, 88, 77, 64, 71, 95], dtype=float) -all_months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] - -fig5, (ax_left, ax_right) = vw.subplots(1, 2, figsize=(820, 320)) -bar_left = ax_left.bar( - all_months, q1, width=0.6, - color="#4fc3f7", show_values=False, y_units="Q1 sales", -) -bar_right = ax_right.bar( - all_months, q1, width=0.6, - color="#ff7043", show_values=False, y_units="Q2 sales", -) -bar_right.set_data(q2) # swap in Q2 — axis range recalculates automatically - -fig5 - -# %% -# Mutate colours, annotations, and scale at runtime -# -------------------------------------------------- -# :meth:`~anyplotlib.figure_plots.PlotBar.set_color` repaints all bars, -# :meth:`~anyplotlib.figure_plots.PlotBar.set_show_values` toggles labels, -# :meth:`~anyplotlib.figure_plots.PlotBar.set_log_scale` switches the -# value-axis between linear and logarithmic. - -bar1.set_color("#ff7043") -bar1.set_show_values(False) -fig1 - -import numpy as np -import anyplotlib as vw - -rng = np.random.default_rng(7) - -# ── 1. Vertical bar chart — monthly sales ──────────────────────────────────── -months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] -sales = np.array([42, 55, 48, 63, 71, 68, 74, 81, 66, 59, 52, 78], - dtype=float) - -fig1, ax1 = vw.subplots(1, 1, figsize=(640, 340)) -bar1 = ax1.bar( - sales, - x_labels=months, - color="#4fc3f7", - bar_width=0.6, - show_values=True, - units="Month", - y_units="Units sold", -) -fig1 - -# %% -# Horizontal bar chart — ranked items -# ------------------------------------- -# Set ``orient="h"`` for a horizontal layout. Pass a list of CSS colours to -# ``colors`` to give each bar its own colour, and use ``show_values=True`` to -# annotate each bar with its numeric value. - -categories = ["NumPy", "SciPy", "Matplotlib", "Pandas", "Scikit-learn", - "PyTorch", "TensorFlow", "JAX", "Polars", "Dask"] -scores = np.array([95, 88, 91, 87, 83, 79, 76, 72, 68, 65], dtype=float) - -palette = [ - "#ef5350", "#ec407a", "#ab47bc", "#7e57c2", "#42a5f5", - "#26c6da", "#26a69a", "#66bb6a", "#d4e157", "#ffa726", -] - -fig2, ax2 = vw.subplots(1, 1, figsize=(540, 400)) -bar2 = ax2.bar( - scores, - x_labels=categories, - orient="h", - colors=palette, - bar_width=0.65, - show_values=True, - y_units="Popularity score", -) -fig2 - -# %% -# Side-by-side comparison — update data live -# ------------------------------------------- -# Place two :class:`~anyplotlib.figure_plots.PlotBar` panels in one -# :func:`~anyplotlib.figure_plots.subplots` figure. Call -# :meth:`~anyplotlib.figure_plots.PlotBar.set_data` to swap in Q2 data for the -# right panel, demonstrating how the axis range re-calculates automatically. - -quarters = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] - -q1 = np.array([42, 55, 48, 63, 71, 68, 74, 81, 66, 59, 52, 78], dtype=float) -q2 = np.array([58, 61, 70, 75, 69, 83, 90, 88, 77, 64, 71, 95], dtype=float) - -fig3, (ax_left, ax_right) = vw.subplots(1, 2, figsize=(820, 320)) - -bar_left = ax_left.bar( - q1, - x_labels=quarters, - color="#4fc3f7", - bar_width=0.6, - show_values=False, - y_units="Q1 sales", -) - -bar_right = ax_right.bar( - q1, # start with Q1 … - x_labels=quarters, - color="#ff7043", - bar_width=0.6, - show_values=False, - y_units="Q2 sales", -) - -# Swap in Q2 data — range is recalculated automatically -bar_right.set_data(q2) - -fig3 - -# %% -# Mutate colours and annotations at runtime -# ------------------------------------------ -# :meth:`~anyplotlib.figure_plots.PlotBar.set_color` repaints all bars with a -# single CSS colour. -# :meth:`~anyplotlib.figure_plots.PlotBar.set_show_values` toggles the -# in-bar value annotations. - -bar1.set_color("#ff7043") -bar1.set_show_values(False) -fig1 - diff --git a/Examples/plot_image2d.py b/Examples/plot_image2d.py deleted file mode 100644 index ed0e168..0000000 --- a/Examples/plot_image2d.py +++ /dev/null @@ -1,139 +0,0 @@ -""" -2D Image with Histogram -======================= - -Display a 2-D image with physical axes, a colourmap, and an interactive -histogram below — all wired together with draggable threshold widgets. - -Layout ------- -A :class:`~anyplotlib.figure_plots.GridSpec` with two rows puts the image -on top and a bar-chart histogram below. Two -:class:`~anyplotlib.widgets.VLineWidget` handles on the histogram mark the -``display_min`` / ``display_max`` thresholds; dragging them updates the -image colour scale in real time. - -Key bindings on the image panel: **R** reset view · **C** toggle colorbar · -**L** / **S** cycle colour-scale modes. - -New ``imshow`` parameters -------------------------- -``cmap`` - Colormap name passed directly to :meth:`~anyplotlib.figure_plots.Axes.imshow` - (e.g. ``"viridis"``, ``"inferno"``). Defaults to ``"gray"``. -``vmin`` / ``vmax`` - Colormap clipping limits in data units. Values outside the range are - clamped to the colormap endpoints. Defaults to the data min/max. -``origin`` - ``"upper"`` (default) places row 0 at the top (image convention). - ``"lower"`` places row 0 at the bottom (scientific / matrix convention) - and automatically reverses the y-axis so tick values increase upward. -""" -import numpy as np -import anyplotlib as apl - - -rng = np.random.default_rng(1) - -# ── Synthetic diffraction pattern ───────────────────────────────────────────── -N = 256 -x = np.linspace(-5, 5, N) # physical axis in nm -y = np.linspace(-5, 5, N) -XX, YY = np.meshgrid(x, y) -R = np.sqrt(XX ** 2 + YY ** 2) - - -def _ring(r, r0, width, amp): - return amp * np.exp(-0.5 * ((r - r0) / width) ** 2) - - -image = ( - _ring(R, 0.0, 0.30, 1.00) # central spot - + _ring(R, 2.1, 0.15, 0.55) # first-order ring - + _ring(R, 4.2, 0.15, 0.25) # second-order ring - + rng.normal(scale=0.04, size=(N, N)) -) - -# ── Layout: image (top, 3×) + histogram bar chart (bottom, 1×) ──────────────── -gs = apl.GridSpec(2, 1, height_ratios=[3, 1]) -fig = apl.Figure(figsize=(500, 640)) -ax_img = fig.add_subplot(gs[0, 0]) -ax_hist = fig.add_subplot(gs[1, 0]) - -# ── Image panel — cmap, vmin, vmax supplied directly to imshow ──────────────── -vmin_init = float(image.min()) -vmax_init = float(image.max()) - -# Pass cmap, vmin, and vmax directly — no separate set_colormap / set_clim call -# needed for the initial display. -v = ax_img.imshow(image, axes=[x, y], units="nm", - cmap="inferno", vmin=vmin_init, vmax=vmax_init) - -# First-order spot markers -dx = x[1] - x[0] - - -def phys_to_px(val): - return (np.asarray(val) - x[0]) / dx - - -spot_nm = np.array([[ 2.1, 0.0], [-2.1, 0.0], - [ 0.0, 2.1], [ 0.0, -2.1]]) -spot_px = np.column_stack([phys_to_px(spot_nm[:, 0]), - phys_to_px(spot_nm[:, 1])]) -v.add_circles(spot_px, name="spots", radius=7, - edgecolors="#00e5ff", facecolors="#00e5ff22", - labels=["g1", "g1_bar", "g2", "g2_bar"]) - -# ── Histogram bar chart ──────────────────────────────────────────────────────── -counts, edges = np.histogram(image.ravel(), bins=64) -bin_centers = 0.5 * (edges[:-1] + edges[1:]) - -h = ax_hist.bar(counts, x_centers=bin_centers, orient="v", - color="#4fc3f7", y_units="count") - -# ── Draggable threshold handles on the histogram ────────────────────────────── -wlo = h.add_vline_widget(vmin_init, color="#ff6e40") # low-threshold handle -whi = h.add_vline_widget(vmax_init, color="#ffffff") # high-threshold handle - - -@wlo.on_release -def _apply_low(event): - """Update image display_min when the low handle is released.""" - v.set_clim(vmin=event.x) - - -@whi.on_release -def _apply_high(event): - """Update image display_max when the high handle is released.""" - v.set_clim(vmax=event.x) - - -fig - -# %% -# Adjust colour map and display range -# ------------------------------------ -# :meth:`~anyplotlib.figure_plots.Plot2D.set_colormap` switches the palette; -# :meth:`~anyplotlib.figure_plots.Plot2D.set_clim` adjusts the display range. -# Both are equivalent to passing ``cmap`` / ``vmin`` / ``vmax`` at construction. - -v.set_colormap("viridis") -v.set_clim(vmin=0.0, vmax=0.8) - -fig - -# %% -# origin='lower' — scientific / matrix convention -# ------------------------------------------------ -# Passing ``origin='lower'`` places row 0 of the data at the *bottom* of the -# image, matching the matplotlib / scientific convention. The y-axis is -# automatically reversed so tick values still increase upward. - -mat = np.arange(64, dtype=float).reshape(8, 8) # row 0 = small values - -fig2, ax2 = apl.subplots() -v2 = ax2.imshow(mat, cmap="plasma", origin="lower") - -fig2 - diff --git a/Examples/plot_line_styles.py b/Examples/plot_line_styles.py deleted file mode 100644 index dafef5a..0000000 --- a/Examples/plot_line_styles.py +++ /dev/null @@ -1,159 +0,0 @@ -""" -1D Line Styles -============== - -Demonstrates the line-style, opacity, and per-point marker parameters -available on :meth:`~anyplotlib.figure_plots.Axes.plot` and -:meth:`~anyplotlib.figure_plots.Plot1D.add_line`. - -Four separate figures are shown: - -1. **Linestyles** – all four dash patterns on one panel with a legend. -2. **Alpha (transparency)** – two overlapping sine waves, each at 40 % opacity. -3. **Marker symbols** – all seven supported symbols, each on its own offset - curve. -4. **Combined** – dashed + semi-transparent + circle-marker overlay on a solid - primary line; demonstrates post-construction setters. -""" -import numpy as np -import anyplotlib as vw - -t256 = np.linspace(0.0, 2.0 * np.pi, 256) # dense — good for dashes / alpha -t24 = np.linspace(0.0, 2.0 * np.pi, 24) # sparse — makes markers visible - -# ── 1. Linestyles ───────────────────────────────────────────────────────────── -fig1, ax1 = vw.subplots(1, 1, figsize=(580, 300)) - -plot1 = ax1.plot(np.sin(t256), color="#4fc3f7", linewidth=2, - linestyle="solid", label="solid") -plot1.add_line(np.sin(t256) + 0.6, color="#ff7043", linewidth=2, - linestyle="dashed", label="dashed (\"--\")") -plot1.add_line(np.sin(t256) + 1.2, color="#aed581", linewidth=2, - linestyle="dotted", label="dotted (\":\")") -plot1.add_line(np.sin(t256) + 1.8, color="#ce93d8", linewidth=2, - linestyle="dashdot", label="dashdot (\"-.\")") - -fig1 - -# %% -# The ``ls`` shorthand -# -------------------- -# Each linestyle has a single-character (or two-character) shorthand that -# matches the matplotlib convention: -# -# * ``"-"`` → ``"solid"`` -# * ``"--"`` → ``"dashed"`` -# * ``":"`` → ``"dotted"`` -# * ``"-."`` → ``"dashdot"`` -# -# The shorthands work on both :meth:`~anyplotlib.figure_plots.Axes.plot` -# and :meth:`~anyplotlib.figure_plots.Plot1D.add_line`: - -fig2a, ax2a = vw.subplots(1, 1, figsize=(440, 220)) -p = ax2a.plot(np.sin(t256), ls="-", color="#4fc3f7", label='ls="-"') -p.add_line(np.sin(t256) + 0.8, ls="--", color="#ff7043", label='ls="--"') -p.add_line(np.sin(t256) + 1.6, ls=":", color="#aed581", label='ls=":"') -fig2a - -# %% -# Alpha (opacity) -# --------------- -# ``alpha`` controls line opacity on a 0–1 scale. Values below 1 let -# overlapping curves show through each other — useful for comparing signals -# that share the same amplitude range. - -fig2, ax2 = vw.subplots(1, 1, figsize=(580, 300)) - -plot2 = ax2.plot(np.sin(t256), color="#4fc3f7", alpha=0.4, linewidth=3, - label="sin α=0.4") -plot2.add_line(np.cos(t256), color="#ff7043", alpha=0.4, linewidth=3, - label="cos α=0.4") - -fig2 - -# %% -# Marker symbols -# -------------- -# Set ``marker`` to place a symbol at every data point. Use a **sparse** -# x-axis (few points) so the individual markers are legible. -# ``markersize`` is the radius (circles / diamonds) or half-side-length -# (squares, triangles) in canvas pixels. -# -# Supported symbols: -# -# * ``"o"`` — circle -# * ``"s"`` — square -# * ``"^"`` — triangle-up -# * ``"v"`` — triangle-down -# * ``"D"`` — diamond -# * ``"+"`` — plus (stroke-only) -# * ``"x"`` — cross (stroke-only) -# * ``"none"`` — no marker (default) - -SYMBOLS = [ - ("o", "#4fc3f7"), - ("s", "#ff7043"), - ("^", "#aed581"), - ("v", "#ce93d8"), - ("D", "#ffcc02"), - ("+", "#80cbc4"), - ("x", "#ef9a9a"), -] - -fig3, ax3 = vw.subplots(1, 1, figsize=(580, 380)) - -plot3 = ax3.plot( - np.sin(t24) + (0 - 3) * 0.9, - color=SYMBOLS[0][1], linewidth=1.5, - marker=SYMBOLS[0][0], markersize=5, - label=f'marker="{SYMBOLS[0][0]}"', -) -for i, (sym, col) in enumerate(SYMBOLS[1:], 1): - plot3.add_line( - np.sin(t24) + (i - 3) * 0.9, - color=col, linewidth=1.5, - marker=sym, markersize=5, - label=f'marker="{sym}"', - ) - -fig3 - -# %% -# Combined — linestyle + alpha + marker -# -------------------------------------- -# All three style parameters can be combined freely on the same line or on -# separate overlay lines. - -fig4, ax4 = vw.subplots(1, 1, figsize=(580, 300)) - -# Dense solid primary line -plot4 = ax4.plot(np.sin(t256), color="#4fc3f7", linewidth=2, - label="sin (solid)") - -# Sparse dashed overlay with circle markers and reduced opacity -plot4.add_line(np.cos(t24), color="#ff7043", linewidth=2, - linestyle="dashed", alpha=0.75, - marker="o", markersize=5, - label="cos (dashed, α=0.75, marker='o')") - -fig4 - -# %% -# Post-construction setters -# ------------------------- -# Every primary-line style property has a matching setter method. These -# mutate ``_state`` and push the change to the canvas immediately — no -# need to recreate the panel. - -fig5, ax5 = vw.subplots(1, 1, figsize=(440, 220)) -plot5 = ax5.plot(np.sin(t256), color="#4fc3f7", linewidth=1.5) - -# Change style via setters -plot5.set_color("#ff7043") -plot5.set_linewidth(2.5) -plot5.set_linestyle("dashdot") # equivalent: plot5.set_linestyle("-.") -plot5.set_alpha(0.8) -plot5.set_marker("o", markersize=5) - -fig5 - diff --git a/Examples/plot_pcolormesh.py b/Examples/plot_pcolormesh.py deleted file mode 100644 index 41147bd..0000000 --- a/Examples/plot_pcolormesh.py +++ /dev/null @@ -1,65 +0,0 @@ -""" -pcolormesh — non-linear axes -============================ - -Demonstrate :meth:`~anyplotlib.figure_plots.Axes.pcolormesh` with non-uniform -(log-spaced) x-edges and irregularly-spaced y-edges, mirroring -``matplotlib.axes.Axes.pcolormesh``. - -The key difference from :meth:`~anyplotlib.figure_plots.Axes.imshow` is that -``pcolormesh`` takes **edge** arrays (length N+1 and M+1 for an (M, N) data -array) rather than center arrays. This enables fully non-linear axes where -each cell can have a different width/height in data coordinates. -""" -import numpy as np -import anyplotlib as vw - -rng = np.random.default_rng(42) - -# ── Data: 32 rows × 48 columns ─────────────────────────────────────────────── -M, N = 32, 48 -data = np.sin(np.linspace(0, 3 * np.pi, N)) + np.cos(np.linspace(0, 2 * np.pi, M))[:, None] -data += rng.normal(scale=0.15, size=(M, N)) - -# ── Non-uniform edges ───────────────────────────────────────────────────────── -# x: log-spaced between 0.1 and 100 (N+1 edges) -x_edges = np.logspace(-1, 2, N + 1) - -# y: irregular spacing — dense in the middle, coarse at the ends (M+1 edges) -y_centres = np.concatenate([ - np.linspace(0, 40, M // 4, endpoint=False), - np.linspace(40, 60, M // 2, endpoint=False), - np.linspace(60, 100, M // 4), -]) -y_edges = np.concatenate([[y_centres[0] - (y_centres[1] - y_centres[0]) / 2], - (y_centres[:-1] + y_centres[1:]) / 2, - [y_centres[-1] + (y_centres[-1] - y_centres[-2]) / 2]]) - -# ── Plot ────────────────────────────────────────────────────────────────────── -fig, ax = vw.subplots(1, 1, figsize=(560, 460)) -mesh = ax.pcolormesh(data, x_edges=x_edges, y_edges=y_edges, units="arb.") -mesh.set_colormap("viridis") -fig - -# %% -# Add point markers in physical coordinates -# ----------------------------------------- -# Marker coordinates are in the same physical (data) space as the edges. -# Only ``add_circles`` and ``add_lines`` are available on a pcolormesh panel. - -pts = np.array([[1.0, 20.0], [10.0, 50.0], [50.0, 80.0], [90.0, 45.0]]) -mesh.add_circles(pts, name="peaks", radius=3, - edgecolors="#ff1744", facecolors="#ff174433", - labels=["A", "B", "C", "D"]) -fig - -# %% -# Add line-segment markers -# ------------------------ -segs = [ - [[1.0, 20.0], [10.0, 50.0]], - [[10.0, 50.0], [50.0, 80.0]], -] -mesh.add_lines(segs, name="path", edgecolors="#00e5ff", linewidths=2.0) -fig - diff --git a/Examples/plot_spectra1d.py b/Examples/plot_spectra1d.py deleted file mode 100644 index 320ad22..0000000 --- a/Examples/plot_spectra1d.py +++ /dev/null @@ -1,112 +0,0 @@ -""" -1D Spectra -========== - -Plot a 1-D spectrum with a physical x-axis (energy in eV) using -:meth:`~anyplotlib.figure_plots.Axes.plot`. - -The spectrum contains a broad background and three Gaussian peaks. -Circle markers highlight the peak positions using -:meth:`~anyplotlib.figure_plots.Plot1D.add_points`, and a range widget -selects a region of interest. A model fit is overlaid with a dashed line, -and the background component is shown as a semi-transparent dotted curve with -diamond markers. - -Pan and zoom with the mouse; press **R** to reset the view. -""" -import numpy as np -import anyplotlib as vw - -rng = np.random.default_rng(0) - -# ── Synthetic XPS-style spectrum ────────────────────────────────────────────── -energy = np.linspace(280, 295, 512) # binding energy axis (eV) - -def gaussian(x, mu, sigma, amp): - return amp * np.exp(-0.5 * ((x - mu) / sigma) ** 2) - -background = 0.4 * np.exp(-0.08 * (energy - 280)) - -# Background + three peaks (C 1s region) -spectrum = ( - background - + gaussian(energy, 284.8, 0.4, 1.0) # C–C / C–H - + gaussian(energy, 286.2, 0.4, 0.35) # C–O - + gaussian(energy, 288.0, 0.4, 0.18) # C=O - + rng.normal(scale=0.015, size=len(energy)) -) - -# ── Plot ────────────────────────────────────────────────────────────────────── -fig, ax = vw.subplots(1, 1, figsize=(620, 340)) -v = ax.plot(spectrum, axes=[energy], units="eV", y_units="Intensity (a.u.)", - color="#4fc3f7", linewidth=1.5) - -# ── Peak markers (add_points collection) ────────────────────────────────────── -peak_energies = np.array([284.8, 286.2, 288.0]) -peak_offsets = np.column_stack([ - peak_energies, - np.interp(peak_energies, energy, spectrum), -]) -v.add_points(peak_offsets, name="peaks", - sizes=7, color="#ff1744", facecolors="#ff174433", - labels=["C\u2013C", "C\u2013O", "C=O"]) - -# ── Region-of-interest widget ───────────────────────────────────────────────── -v.add_range_widget(x0=285.8, x1=288.8, color="#00e5ff") - -fig - -# %% -# Overlay a model fit — linestyle and alpha -# ----------------------------------------- -# Use :meth:`~anyplotlib.figure_plots.Plot1D.add_line` to overlay additional -# curves. Here the noiseless model fit is drawn as a **dashed** line so it -# is visually distinct from the noisy measured spectrum. The ``alpha`` -# parameter makes the fit semi-transparent so the data underneath remains -# readable. -# -# The y-axis range is expanded automatically to accommodate any overlay line -# whose values fall outside the current bounds. - -fit = ( - background - + gaussian(energy, 284.8, 0.4, 1.0) - + gaussian(energy, 286.2, 0.4, 0.35) - + gaussian(energy, 288.0, 0.4, 0.18) -) -v.add_line(fit, x_axis=energy, - color="#ffcc00", linewidth=2.0, - linestyle="dashed", alpha=0.85, - label="fit") - -fig - -# %% -# Background component — dotted line with markers -# ------------------------------------------------ -# Draw the exponential background component as a **dotted** curve. Passing -# ``marker="D"`` places a diamond at every data point (useful when the line -# is sparse or when you want to emphasise individual sample positions). -# ``markersize`` controls the half-size of the symbol in pixels. - -# Sub-sample to keep the marker plot readable -step = 32 -v.add_line(background[::step], x_axis=energy[::step], - color="#ce93d8", linewidth=1.2, - linestyle="dotted", alpha=0.9, - marker="D", markersize=3, - label="background") - -fig - -# %% -# Post-construction setters -# ------------------------- -# All primary-line style properties can be changed after the panel is created -# without rebuilding it. This is useful in interactive notebooks where you -# want to tweak the appearance of the main trace. - -v.set_alpha(0.9) # slightly reduce primary-line opacity -v.set_linewidth(2.0) # thicker stroke for the main spectrum - -fig diff --git a/anyplotlib/_repr_utils.py b/anyplotlib/_repr_utils.py index c65063b..f8e5762 100644 --- a/anyplotlib/_repr_utils.py +++ b/anyplotlib/_repr_utils.py @@ -129,23 +129,51 @@ def _widget_px(widget) -> tuple[int, int]:
""" -def build_standalone_html(widget, *, resizable: bool = True) -> str: +def build_standalone_html(widget, *, resizable: bool = True, + fig_id: str | None = None) -> str: """Return a self-contained HTML page that renders *widget* interactively. Parameters @@ -193,6 +231,9 @@ def build_standalone_html(widget, *, resizable: bool = True) -> str: When ``True`` (default) the widget's built-in resize handle is preserved. When ``False`` the handle is hidden via CSS and the page is sized exactly to the widget's natural dimensions. + fig_id : str or None + When provided, embedded as ``FIG_ID`` so the parent-page bridge + can route ``postMessage`` state updates to this iframe. """ state = _widget_state(widget) @@ -210,6 +251,7 @@ def build_standalone_html(widget, *, resizable: bool = True) -> str: extra_css=extra_css, state_json=json.dumps(state, default=str), esm_json=json.dumps(esm), + fig_id_json=json.dumps(fig_id), ) diff --git a/anyplotlib/figure.py b/anyplotlib/figure.py index b044d3b..7161721 100644 --- a/anyplotlib/figure.py +++ b/anyplotlib/figure.py @@ -351,7 +351,22 @@ def _on_resize(self, change) -> None: @traitlets.observe("event_json") def _on_event(self, change) -> None: """Dispatch a JS interaction event to the relevant plot and widget callbacks.""" - raw = change["new"] + self._dispatch_event(change["new"]) + + def _dispatch_event(self, raw: str) -> None: + """Process a raw JSON event string from the JS side. + + Called by ``_on_event`` (traitlets observer) and also directly by the + Pyodide bridge (``anywidget_bridge.js``) when forwarding user interaction + events from the iframe back to Python callbacks. + + Parameters + ---------- + raw : str + JSON-encoded event message. Expected keys: ``event_type``, + ``panel_id``, and optionally ``source``, ``widget_id``, plus + any event-specific payload fields. + """ if not raw or raw == "{}": return try: diff --git a/anyplotlib/figure_plots.py b/anyplotlib/figure_plots.py index d3a67fa..f0539a7 100644 --- a/anyplotlib/figure_plots.py +++ b/anyplotlib/figure_plots.py @@ -531,13 +531,29 @@ def bar(self, x, height=None, width: float = 0.8, bottom: float = 0.0, *, self._attach(plot) return plot + def _panel_id_from_spec(self) -> str: + """Derive a deterministic, position-based panel ID from the SubplotSpec. + + The ID is ``"p"`` followed by the first 7 hex characters of a SHA-256 + hash of the row/col bounds, e.g. ``"p6a2f3b1"``. This is: + + * **Deterministic** – the same SubplotSpec always produces the same ID + across Python processes and after code edits. + * **Starts with "p"** – satisfies the JS naming convention and makes it + easy to grep for panel traits (``panel_{id}_json``). + * **Short** – 8 characters total; safe to embed in CSS selectors. + """ + import hashlib as _hl + key = f"{self._spec.row_start},{self._spec.row_stop},{self._spec.col_start},{self._spec.col_stop}" + return "p" + _hl.sha256(key.encode()).hexdigest()[:7] + def _attach(self, plot: "Plot1D | Plot2D | PlotMesh | Plot3D | PlotBar") -> None: """Register a plot on this axes (replace any previous plot).""" # Allocate a panel id if needed; reuse if replacing if self._plot is not None: panel_id = self._plot._id else: - panel_id = str(_uuid.uuid4())[:8] + panel_id = self._panel_id_from_spec() plot._id = panel_id plot._fig = self._fig self._plot = plot @@ -592,26 +608,42 @@ def _normalize_image(data: np.ndarray): def _build_colormap_lut(name: str) -> list: """Return a 256-entry ``[[r, g, b], ...]`` LUT for the named colormap. - Uses **colorcet** exclusively. Common matplotlib colormap names are - transparently remapped via :data:`_CMAP_ALIASES` so callers can keep - using names like ``"viridis"`` or ``"hot"`` without any matplotlib - dependency. Falls back to a plain gray ramp for unknown names. - """ - import colorcet as cc - - resolved = _CMAP_ALIASES.get(name, name) - palette = cc.palette.get(resolved) + Priority order: - if palette is None: - # Unknown name → linear gray ramp - return [[v, v, v] for v in range(256)] - - n = len(palette) - lut: list = [] - for i in range(256): - h = palette[int(round(i * (n - 1) / 255))].lstrip("#") - lut.append([int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)]) - return lut + 1. **colorcet** — preferred; common matplotlib names are remapped via + :data:`_CMAP_ALIASES` so callers can use ``"viridis"`` etc. + 2. **matplotlib** — fallback when colorcet is not installed (e.g. in + Pyodide before micropip finishes, or in minimal test environments). + 3. **Built-in gray ramp** — final fallback for unknown names. + """ + # ── 1. Try colorcet ─────────────────────────────────────────────────── + try: + import colorcet as cc + resolved = _CMAP_ALIASES.get(name, name) + palette = cc.palette.get(resolved) + if palette is not None: + n = len(palette) + lut: list = [] + for i in range(256): + h = palette[int(round(i * (n - 1) / 255))].lstrip("#") + lut.append([int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)]) + return lut + except Exception: + pass + + # ── 2. Try matplotlib ───────────────────────────────────────────────── + try: + import matplotlib.cm as _cm + import numpy as _np + cmap = _cm.get_cmap(name, 256) + rgba = cmap(_np.linspace(0, 1, 256)) + return [[int(r * 255), int(g * 255), int(b * 255)] + for r, g, b, _ in rgba] + except Exception: + pass + + # ── 3. Gray ramp fallback ───────────────────────────────────────────── + return [[v, v, v] for v in range(256)] def _resample_mesh(data: np.ndarray, x_edges, y_edges) -> np.ndarray: diff --git a/anyplotlib/sphinx_anywidget/__init__.py b/anyplotlib/sphinx_anywidget/__init__.py new file mode 100644 index 0000000..eea5ea5 --- /dev/null +++ b/anyplotlib/sphinx_anywidget/__init__.py @@ -0,0 +1,152 @@ +""" +sphinx_anywidget +================ + +A generic Sphinx extension that makes any ``anywidget.AnyWidget``-based +figure interactive in documentation pages — powered by Pyodide, with no +server or Jupyter kernel required. + +Quick start (any project) +------------------------- + +In your ``conf.py``:: + + extensions = [ + "anyplotlib.sphinx_anywidget", + ] + + # Package whose wheel is built and served to Pyodide at runtime. + anywidget_pyodide_package = "mypackage" + + +The extension: +* builds a pure-Python wheel at docs-build time; +* injects ``anywidget_bridge.js`` (per-figure ⚡ badges + Pyodide boot); +* provides ``AnywidgetScraper`` for Sphinx Gallery (``# Interactive`` tag); +* registers ``.. anywidget-figure::`` RST directive. + +Monkey-patch approach +--------------------- +``anywidget_bridge.js`` patches ``AnyWidget.__init__`` in Pyodide to add a +``traitlets.observe(names=All)`` observer. When any ``sync=True`` trait +changes and the widget has ``_anywidget_fig_id`` set, the observer calls +``window._anywidgetPush(fig_id, name, value_str)`` which postMessages the +new state into the matching iframe — no library-side Pyodide code needed. +""" + +from __future__ import annotations + +from pathlib import Path + +from anyplotlib.sphinx_anywidget._scraper import AnywidgetScraper, ViewerScraper # noqa: F401 + +_HERE = Path(__file__).parent +_STATIC_SRC = _HERE / "static" + + +def setup(app): + """Register sphinx_anywidget with Sphinx.""" + app.add_config_value("anywidget_pyodide_package", default=None, rebuild="html") + + from anyplotlib.sphinx_anywidget._directive import AnywidgetFigureDirective + app.add_directive("anywidget-figure", AnywidgetFigureDirective) + + app.connect("builder-inited", _copy_static_assets) + app.connect("builder-inited", _build_pyodide_wheel) + + # anywidget_config.js is written dynamically by _build_pyodide_wheel; + # it must load BEFORE anywidget_bridge.js so _inferPackageName sees the name. + app.add_js_file("anywidget_config.js", loading_method="defer", priority=490) + app.add_js_file("anywidget_bridge.js", loading_method="defer", priority=500) + app.add_css_file("anywidget_overlay.css") + + return { + "version": "0.1.0", + "parallel_read_safe": True, + "parallel_write_safe": True, + } + + +def _copy_static_assets(app): + """Add the extension's static/ dir to html_static_path.""" + src_str = str(_STATIC_SRC) + if hasattr(app.config, "html_static_path"): + if src_str not in app.config.html_static_path: + app.config.html_static_path.append(src_str) + + +def _build_pyodide_wheel(app): + """Build the configured package wheel for the Pyodide bridge.""" + pkg = getattr(app.config, "anywidget_pyodide_package", None) + if not pkg: + pkg = _infer_package_name(app) + + conf_dir = Path(app.confdir) + static_dir = conf_dir / "_static" + static_dir.mkdir(parents=True, exist_ok=True) + + import json as _json + + if not pkg: + print( + "[sphinx_anywidget] WARNING: anywidget_pyodide_package not set; " + "Pyodide interactive mode disabled." + ) + # Write a config that explicitly disables interactive mode so the + # bridge's heuristic package detection never runs. + config_js = ( + "window._anywidgetPackage = null;\n" + "window._anywidgetInteractiveDisabled = true;\n" + ) + (static_dir / "anywidget_config.js").write_text(config_js, encoding="utf-8") + return + + # Write a tiny config script so anywidget_bridge.js can find the package + # name without fragile heuristics. Loaded before anywidget_bridge.js. + config_js = f"window._anywidgetPackage = {_json.dumps(pkg)};\n" + (static_dir / "anywidget_config.js").write_text(config_js, encoding="utf-8") + + from anyplotlib.sphinx_anywidget._wheel_builder import build_wheel + project_root = _find_project_root(conf_dir) + build_wheel(static_dir, pkg, project_root) + + +def _find_project_root(start: Path) -> Path: + """Walk up from *start* to find the directory containing pyproject.toml or setup.py. + + Falls back to ``start.parent`` for backwards compatibility so behaviour is + unchanged for the common ``docs/conf.py`` layout where the project root is + directly above the docs directory. + """ + markers = ("pyproject.toml", "setup.py", "setup.cfg") + current = start.resolve() + # Check start itself and each parent up to the filesystem root. + for directory in [current, *current.parents]: + if any((directory / m).exists() for m in markers): + return directory + # No marker found — fall back to conf_dir's parent (original behaviour). + return start.parent + + +def _infer_package_name(app) -> str | None: + """Infer package name from pyproject.toml near conf.py.""" + try: + import tomllib + except ImportError: + try: + import tomli as tomllib # type: ignore[no-redef] + except ImportError: + return None + conf_dir = Path(app.confdir) + for candidate in [conf_dir / "pyproject.toml", conf_dir.parent / "pyproject.toml"]: + if candidate.exists(): + with open(candidate, "rb") as fh: + data = tomllib.load(fh) + name = ( + data.get("project", {}).get("name") + or data.get("tool", {}).get("poetry", {}).get("name") + ) + if name: + return name + return None + diff --git a/anyplotlib/sphinx_anywidget/_directive.py b/anyplotlib/sphinx_anywidget/_directive.py new file mode 100644 index 0000000..e8a5ed4 --- /dev/null +++ b/anyplotlib/sphinx_anywidget/_directive.py @@ -0,0 +1,190 @@ +""" +sphinx_anywidget/_directive.py +================================ + +RST directive for embedding interactive anywidget figures directly in ``.rst`` +pages — no Sphinx Gallery required. + +Usage +----- + +Static snapshot only:: + + .. anywidget-figure:: ../Examples/plot_image2d.py + +Interactive (Pyodide-activatable):: + + .. anywidget-figure:: ../Examples/plot_image2d.py + :interactive: + +Options +------- +``:interactive:`` + Flag. When present the ⚡ activation badge is shown and the example + source is embedded for live re-execution by the Pyodide bridge. +``:width:`` (int, default 684) + Maximum display width in pixels. +""" + +from __future__ import annotations + +import hashlib +import json as _json +import re +import runpy +import tempfile +from html import escape as _html_escape +from pathlib import Path + +# Reuse the _PYODIDE_PACKAGES_RE parser from the scraper. +from anyplotlib.sphinx_anywidget._scraper import _PYODIDE_PACKAGES_RE + +from docutils import nodes +from docutils.parsers.rst import Directive, directives + + +class AnywidgetFigureDirective(Directive): + """Directive: ``.. anywidget-figure:: path/to/script.py``.""" + + required_arguments = 1 # path to the Python source file + optional_arguments = 0 + has_content = False + option_spec = { + "interactive": directives.flag, + "width": directives.nonnegative_int, + } + + def run(self): + env = self.state.document.settings.env + config = env.config + + # ── resolve the source file path ───────────────────────────────── + src_arg = self.arguments[0] + conf_dir = Path(env.confdir) + src_path = (conf_dir / src_arg).resolve() + + if not src_path.exists(): + error = self.reporter.error( + f"anywidget-figure: source file not found: {src_path}", + nodes.literal_block(src_arg, src_arg), + line=self.lineno, + ) + return [error] + + # ── options ────────────────────────────────────────────────────── + is_interactive = "interactive" in self.options + max_width = self.options.get("width", None) # None → use _iframe_html default + + # ── execute the script to get the widget ───────────────────────── + try: + g = runpy.run_path(str(src_path), run_name="__main__") + except Exception as exc: + error = self.reporter.error( + f"anywidget-figure: failed to execute {src_path.name}: {exc}", + nodes.literal_block(str(exc), str(exc)), + line=self.lineno, + ) + return [error] + + widget = _find_widget(g) + if widget is None: + error = self.reporter.error( + f"anywidget-figure: no anywidget found in {src_path.name}", + line=self.lineno, + ) + return [error] + + # ── write the standalone HTML file ─────────────────────────────── + from anyplotlib.sphinx_anywidget._repr_utils import ( + build_standalone_html, _widget_px, + ) + from anyplotlib.sphinx_anywidget._scraper import _iframe_html + + # Use a stable ID derived from the *full* resolved path so two files + # with the same basename don't overwrite each other or share a fig_id. + path_hash = hashlib.md5(str(src_path).encode()).hexdigest()[:12] + fig_id = f"rst_{path_hash}" + + # Write the standalone HTML directly into the Sphinx output _static dir + # so the relative URL we embed in the RST resolves correctly. + out_dir = Path(env.app.outdir) + docs_static = out_dir / "_static" / "viewer_widgets" + docs_static.mkdir(parents=True, exist_ok=True) + html_name = f"{fig_id}.html" + html_path = docs_static / html_name + + inner_html = build_standalone_html(widget, resizable=False, fig_id=fig_id) + html_path.write_text(inner_html, encoding="utf-8") + + w, h = _widget_px(widget) + + # Compute relative path from the current RST file's output dir + # to _static/viewer_widgets/ + try: + doc_name = env.docname # e.g. "getting_started" + rel_depth = len(Path(doc_name).parts) # depth from out root + prefix = "../" * rel_depth + except Exception: + prefix = "" + + src_url = f"{prefix}_static/viewer_widgets/{html_name}" + iframe_kw = {} + if max_width is not None: + iframe_kw["max_width"] = max_width + iframe_block = _iframe_html( + src_url, w, h, + fig_id=fig_id, + interactive=is_interactive, + **iframe_kw, + ) + + raw_html = "\n" + iframe_block + "\n" + + if is_interactive: + python_src = "" + try: + python_src = src_path.read_text(encoding="utf-8") + except Exception: + pass + + if python_src: + data_src = _html_escape(_json.dumps(python_src), quote=True) + + # Detect _PYODIDE_PACKAGES = [...] in the source. + _pkg_attr = "" + m = _PYODIDE_PACKAGES_RE.search(python_src) + if m: + try: + import ast as _ast + pkgs = _ast.literal_eval(m.group(1)) + if pkgs: + _pkg_attr = ( + f' data-pyodide-packages=' + f'"{_html_escape(_json.dumps(pkgs), quote=True)}"' + ) + except Exception: + pass + + stem = src_path.stem + script_tag = ( + f'' + ) + raw_html += "\n" + script_tag + "\n" + + return [nodes.raw("", raw_html, format="html")] + + +def _find_widget(globals_dict: dict): + """Locate the most-recently created anywidget in *globals_dict*.""" + for val in reversed(list(globals_dict.values())): + if not callable(getattr(val, "_repr_html_", None)): + continue + if hasattr(val, "_esm") and hasattr(val, "traits"): + return val + return None + diff --git a/anyplotlib/sphinx_anywidget/_repr_utils.py b/anyplotlib/sphinx_anywidget/_repr_utils.py new file mode 100644 index 0000000..4754d7d --- /dev/null +++ b/anyplotlib/sphinx_anywidget/_repr_utils.py @@ -0,0 +1,298 @@ +""" +sphinx_anywidget/_repr_utils.py +================================ + +Self-contained HTML builder for any ``anywidget.AnyWidget`` subclass. +No runtime dependency on anyplotlib or any specific widget library. + +Strategy +-------- +1. Serialise every ``sync=True`` traitlet to a plain JSON dict. +2. Embed that dict and the widget's ``_esm`` source directly in the page. +3. Provide a minimal model shim (get/set/on/save_changes) so the ESM's + render() function works without any Jupyter comm infrastructure. +4. Import the ESM as a Blob URL and call ``render({ model, el })``. +""" + +from __future__ import annotations + +import json +from html import escape +from uuid import uuid4 + +# Maximum display width (px) for the non-resizable notebook embed. +MAX_NOTEBOOK_WIDTH = 860 + + +# --------------------------------------------------------------------------- +# Trait serialisation +# --------------------------------------------------------------------------- + +def _widget_state(widget) -> dict: + """Return a {name: value} dict of every synced traitlet.""" + state: dict = {} + for name, trait in widget.traits(sync=True).items(): + if name.startswith("_"): + continue + raw = getattr(widget, name) + if isinstance(raw, (bytes, bytearray)): + import base64 + raw = {"buffer": base64.b64encode(raw).decode("ascii")} + state[name] = raw + return state + + +def _widget_px(widget) -> tuple[int, int]: + """Return ``(width_px, height_px)`` for any anywidget subclass. + + Tries common trait names in priority order before falling back to a + sensible default. Widget authors can override by adding + ``_display_width`` and ``_display_height`` *non-synced* attributes. + """ + # Explicit override + if hasattr(widget, "_display_width") and hasattr(widget, "_display_height"): + return int(widget._display_width), int(widget._display_height) + + kind = type(widget).__name__ + + # anyplotlib Figure — gridDiv adds 16 px padding on each side + if kind == "Figure": + try: + return int(widget.fig_width) + 16, int(widget.fig_height) + 16 + except Exception: + pass + + # Common viewer patterns: viewer_width / viewer_height traits + if hasattr(widget, "viewer_width") and hasattr(widget, "viewer_height"): + return int(widget.viewer_width) + 20, int(widget.viewer_height) + 20 + + # width / height traits + if hasattr(widget, "width") and hasattr(widget, "height"): + try: + return int(widget.width), int(widget.height) + except Exception: + pass + + return 560, 340 + + +# --------------------------------------------------------------------------- +# HTML builder +# --------------------------------------------------------------------------- + +_NO_RESIZE_CSS = """\ + /* ── resizable=False overrides ─────────────────────────────── */ + div[style*="nwse-resize"], + div[title="Drag to resize"], + div[title="Drag to resize figure"] {{ + display: none !important; + }} + #widget-root > div {{ + padding-bottom: 0 !important; + padding-right: 0 !important; + }} +""" + +_PAGE_TEMPLATE = """\ + + + + + + + +
+ + + +""" + + +def build_standalone_html(widget, *, resizable: bool = True, + fig_id: str | None = None) -> str: + """Return a self-contained HTML page that renders *widget* interactively. + + Parameters + ---------- + widget : + Any ``anywidget.AnyWidget`` subclass with ``_esm`` defined. + resizable : bool + When ``True`` (default) the widget's built-in resize handle is + preserved. When ``False`` the handle is hidden and the page is + sized exactly to the widget's natural dimensions. + fig_id : str or None + When provided, embedded as ``FIG_ID`` so the parent-page bridge + can route ``postMessage`` state updates to this iframe. + """ + state = _widget_state(widget) + + esm = getattr(widget, "_esm", "") or "" + if hasattr(esm, "read_text"): + esm = esm.read_text(encoding="utf-8") + esm = str(esm) + + w, h = _widget_px(widget) + extra_css = _NO_RESIZE_CSS.format() if not resizable else "" + + return _PAGE_TEMPLATE.format( + width=w, + height=h, + extra_css=extra_css, + state_json=json.dumps(state, default=str), + esm_json=json.dumps(esm), + fig_id_json=json.dumps(fig_id), + ) + + +def repr_html_iframe(widget, *, resizable: bool = False, + max_width: int = MAX_NOTEBOOK_WIDTH, + max_height: int = 800) -> str: + """Return a centred, responsive ``' + f'' + f'' + f'' + ) + else: + return ( + f'' + ) + diff --git a/anyplotlib/sphinx_anywidget/_scraper.py b/anyplotlib/sphinx_anywidget/_scraper.py new file mode 100644 index 0000000..c17fa7e --- /dev/null +++ b/anyplotlib/sphinx_anywidget/_scraper.py @@ -0,0 +1,341 @@ +""" +sphinx_anywidget/_scraper.py +============================= + +Generic Sphinx Gallery image scraper for any ``anywidget.AnyWidget`` subclass. + +Drop-in replacement for the anyplotlib-specific ``_sg_html_scraper.ViewerScraper``. +Works with **any** library built on anywidget — just add the scraper to your +``sphinx_gallery_conf["image_scrapers"]``. + +Interactive tagging +------------------- +If a code block's last expression line contains a ``# Interactive`` comment +(case-insensitive), the scraper: + +* embeds the full example Python source in a ``' + f'' + ) + + +# --------------------------------------------------------------------------- +# Scraper +# --------------------------------------------------------------------------- + +class AnywidgetScraper: + """Sphinx Gallery image scraper for any ``anywidget.AnyWidget`` subclass. + """ + + def __init__(self): + # Maps src_file → list of fig_ids emitted so far (creation order). + self._example_figs: dict = {} + + def __repr__(self) -> str: + return "AnywidgetScraper()" + + def __call__(self, block, block_vars, gallery_conf): + globals_dict = block_vars.get("example_globals", {}) + widget = _find_widget(globals_dict) + if widget is None: + return "" + + src_file = str(block_vars.get("src_file", "")) + + # ── detect # Interactive tag ────────────────────────────────────── + block_source = block[1] if isinstance(block, (list, tuple)) else "" + is_interactive = bool(_INTERACTIVE_RE.search(block_source)) + + # ── assign a stable fig_id and fig_index ───────────────────────── + if src_file not in self._example_figs: + self._example_figs[src_file] = [] + fig_index = len(self._example_figs[src_file]) + + # ── 1. Write the thumbnail PNG ──────────────────────────────────── + image_path_iterator = block_vars["image_path_iterator"] + png_path = Path(next(image_path_iterator)) + png_path.parent.mkdir(parents=True, exist_ok=True) + png_path.write_bytes(_make_thumbnail_png(widget)) + + fig_id = png_path.stem # stable, unique stem from Sphinx Gallery + self._example_figs[src_file].append(fig_id) + + # ── 2. Write the standalone HTML ────────────────────────────────── + try: + from anyplotlib.sphinx_anywidget._repr_utils import ( + build_standalone_html, _widget_px, + ) + docs_dir = Path(gallery_conf["src_dir"]) + widgets_dir = docs_dir / "_static" / "viewer_widgets" + widgets_dir.mkdir(parents=True, exist_ok=True) + + html_name = png_path.stem + ".html" + html_path = widgets_dir / html_name + + inner_html = build_standalone_html(widget, resizable=False, fig_id=fig_id) + html_path.write_text(inner_html, encoding="utf-8") + w, h = _widget_px(widget) + have_html = True + except Exception as exc: + print(f"[sphinx_anywidget] WARNING: could not write iframe HTML: {exc}") + have_html = False + + # ── 3. Return rST ───────────────────────────────────────────────── + if have_html: + try: + src_dir = Path(gallery_conf["src_dir"]) + page_dir = png_path.parent.parent # strip /images + rel_parts = page_dir.relative_to(src_dir).parts + depth = len(rel_parts) + except Exception: + depth = 1 + prefix = "../" * depth + src = f"{prefix}_static/viewer_widgets/{html_name}" + + iframe_block = _iframe_html( + src, w, h, + fig_id=fig_id, + interactive=is_interactive, + ) + + rst = "\n\n.. raw:: html\n\n " + iframe_block + "\n\n" + + if is_interactive: + # Embed the example Python source so the Pyodide bridge can + # re-execute it and wire live callbacks. + python_src = "" + try: + python_src = Path(src_file).read_text(encoding="utf-8") + except Exception: + pass + + if python_src: + data_src = _html_escape(_json.dumps(python_src), quote=True) + + # Detect _PYODIDE_PACKAGES = [...] in the source. + _pkg_attr = "" + m = _PYODIDE_PACKAGES_RE.search(python_src) + if m: + try: + import ast as _ast + pkgs = _ast.literal_eval(m.group(1)) + if pkgs: + _pkg_attr = ( + f' data-pyodide-packages=' + f'"{_html_escape(_json.dumps(pkgs), quote=True)}"' + ) + except Exception: + pass + + python_block = ( + f'' + ) + rst += "\n\n.. raw:: html\n\n " + python_block + "\n\n" + + return rst + else: + return ( + f"\n\n.. image:: {png_path.name}\n" + f" :width: 100%\n\n" + ) + + +# Back-compat alias used by the existing anyplotlib docs. +ViewerScraper = AnywidgetScraper + diff --git a/anyplotlib/sphinx_anywidget/_wheel_builder.py b/anyplotlib/sphinx_anywidget/_wheel_builder.py new file mode 100644 index 0000000..bc22bce --- /dev/null +++ b/anyplotlib/sphinx_anywidget/_wheel_builder.py @@ -0,0 +1,78 @@ +""" +sphinx_anywidget/_wheel_builder.py +==================================== + +Builds a project wheel at docs-build time so the Pyodide bridge can install +the exact library version that generated the docs — no PyPI release required. +""" + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + + +def build_wheel( + static_dir: Path, + package_name: str, + project_root: Path, +) -> "Path | None": + """Build a pure-Python wheel into *static_dir/wheels/*. + + The wheel is renamed to ``{package_name}-0.0.0-py3-none-any.whl``. + ``0.0.0`` is a valid PEP 440 sentinel micropip accepts for URL installs. + + Parameters + ---------- + static_dir : + The docs ``_static`` directory; a ``wheels/`` sub-dir is created. + package_name : + PyPI / importable name (e.g. ``"anyplotlib"``). + project_root : + Directory containing ``pyproject.toml`` / ``setup.py``. + + Returns + ------- + Path or None + Path to the written wheel, or *None* on failure. + """ + wheels_dir = static_dir / "wheels" + wheels_dir.mkdir(parents=True, exist_ok=True) + + # PEP 427 normalises distribution names: hyphens and dots → underscores. + normalised = re.sub(r"[-.]", "_", package_name) + + for old in wheels_dir.glob(f"{normalised}*.whl"): + old.unlink(missing_ok=True) + + result = subprocess.run( + [ + sys.executable, "-m", "pip", "wheel", + "--no-deps", "--quiet", + "--wheel-dir", str(wheels_dir), + str(project_root), + ], + capture_output=True, + text=True, + ) + + if result.returncode != 0: + print( + f"\n[sphinx_anywidget] WARNING: wheel build failed " + f"for {package_name!r}:\n{result.stderr}" + ) + return None + + wheels = sorted(wheels_dir.glob(f"{normalised}*.whl")) + if not wheels: + print(f"\n[sphinx_anywidget] WARNING: no wheel found for {package_name!r}") + return None + + stable = wheels_dir / f"{normalised}-0.0.0-py3-none-any.whl" + stable.unlink(missing_ok=True) + wheels[-1].rename(stable) + print(f"[sphinx_anywidget] wheel → {stable}") + return stable + diff --git a/anyplotlib/sphinx_anywidget/static/anywidget_bridge.js b/anyplotlib/sphinx_anywidget/static/anywidget_bridge.js new file mode 100644 index 0000000..4ebfb6e --- /dev/null +++ b/anyplotlib/sphinx_anywidget/static/anywidget_bridge.js @@ -0,0 +1,440 @@ +/** + * anywidget_bridge.js + * + * Generic Pyodide bridge for anywidget-based interactive documentation. + * + * Architecture + * ──────────── + * Parent page (this script) + * ├─ Per-figure ⚡ badge (in .awi-badge div, rendered by _scraper.py) + * ├─ Pyodide WASM runtime (loaded once from CDN on first ⚡ click) + * ├─ Package wheel at _static/wheels/{pkg}-0.0.0-py3-none-any.whl + * ├─ ' - f'' - ) - - -# --------------------------------------------------------------------------- -# Scraper -# --------------------------------------------------------------------------- - -class ViewerScraper: - """Sphinx Gallery image scraper that embeds anyplotlib Widgets as live iframes.""" - - def __repr__(self) -> str: - return "ViewerScraper()" - - def __call__(self, block, block_vars, gallery_conf): - globals_dict = block_vars.get("example_globals", {}) - widget = _find_viewer(globals_dict) - if widget is None: - return "" - - # ── 1. Write the thumbnail PNG (Sphinx Gallery requires this) ────── - image_path_iterator = block_vars["image_path_iterator"] - png_path = Path(next(image_path_iterator)) - png_path.parent.mkdir(parents=True, exist_ok=True) - png_path.write_bytes(_make_thumbnail_png(widget)) - - # ── 2. Write the standalone HTML into docs/_static/viewer_widgets/ ─ - # - # WHY NOT srcdoc=: - # The srcdoc= attribute value is thousands of lines. Docutils parses - # the content of a ``.. raw:: html`` block as indented text, so a - # multi-line attribute value confuses the RST parser and the block is - # silently dropped from the output. - # - # WHY NOT src= into auto_examples/images/: - # Sphinx only copies *.png files from that directory to _build/html/. - # Any .html file referenced via src= would be a 404 in the built docs. - # - # SOLUTION: - # Write to docs/_static/viewer_widgets/ which is in html_static_path - # and is copied verbatim by Sphinx. The src= path is a single line, - # which is safe for docutils. - try: - from anyplotlib._repr_utils import build_standalone_html, _widget_px - docs_dir = Path(gallery_conf["src_dir"]) - widgets_dir = docs_dir / "_static" / "viewer_widgets" - widgets_dir.mkdir(parents=True, exist_ok=True) - - html_name = png_path.stem + ".html" # sphx_glr_plot_..._001.html - html_path = widgets_dir / html_name - - inner_html = build_standalone_html(widget, resizable=False) - html_path.write_text(inner_html, encoding="utf-8") - w, h = _widget_px(widget) - interactive = True - except Exception: - interactive = False - - # ── 3. Return rST ────────────────────────────────────────────────── - if interactive: - try: - src_dir = Path(gallery_conf["src_dir"]) - page_dir = png_path.parent.parent # strip /images - rel_parts = page_dir.relative_to(src_dir).parts - depth = len(rel_parts) - except Exception: - depth = 1 - prefix = "../" * depth - src = f"{prefix}_static/viewer_widgets/{html_name}" - return ( - "\n\n.. raw:: html\n\n" - " " + _iframe_html(src, w, h) + "\n\n" - ) - else: - rel_png = png_path.name - return ( - f"\n\n.. image:: {rel_png}\n" - f" :width: 100%\n\n" - ) +from anyplotlib.sphinx_anywidget._scraper import ( # noqa: F401 + AnywidgetScraper, + AnywidgetScraper as ViewerScraper, + _make_thumbnail_png, + _iframe_html, +) diff --git a/docs/_static/pyodide_bridge.js b/docs/_static/pyodide_bridge.js new file mode 100644 index 0000000..d0ea5b6 --- /dev/null +++ b/docs/_static/pyodide_bridge.js @@ -0,0 +1,400 @@ +/** + * pyodide_bridge.js + * + * Adds a single floating "⚡" button to any docs page that contains + * anyplotlib figure iframes. Clicking it boots ONE shared Pyodide instance + * for the entire page, runs each example's Python source exactly once, then + * wires Python ↔ JS via postMessage so on_change / on_release callbacks fire + * live in the browser — no server, no Jupyter kernel. + * + * Architecture + * ──────────── + * Parent page (this script) + * ├─ Pyodide WASM runtime (loaded once from CDN on button click) + * ├─ anyplotlib wheel built at docs-build time → _static/wheels/ + * ├─ """ + + # ── 6. Assemble the parent HTML ─────────────────────────────────────── + parent_html = f""" + + + +anywidget bridge test — {fig_id} +{mock_js} + + + +
+
+ +
+ +
+
+
+ + +""" + + parent_path = base_dir / f"{fig_id}_parent.html" + parent_path.write_text(parent_html, encoding="utf-8") + return parent_path + + +# --------------------------------------------------------------------------- +# Browser helpers +# --------------------------------------------------------------------------- + +def _rafter(page) -> None: + page.evaluate("() => new Promise(r => requestAnimationFrame(r))") + + +def _open_page(browser, url: str, timeout: int = 15_000): + page = browser.new_page() + page.goto(url, wait_until="domcontentloaded", timeout=timeout) + return page + + +def _click_and_wait_boot(page, timeout: int = 15_000) -> None: + """Click the ⚡ badge button and wait until it reaches the 'active' state.""" + page.wait_for_function( + "() => !!document.querySelector('button.awi-activate-btn')", + timeout=timeout, + ) + page.click("button.awi-activate-btn") + page.wait_for_function( + """() => { + const btn = document.querySelector('button.awi-activate-btn'); + return btn && btn.dataset.state === 'active'; + }""", + timeout=timeout, + ) + + +def _wait_for_iframe_model(page, fig_id: str, panel_id: str, + timeout: int = 10_000) -> None: + """Block until the iframe's model has a non-empty panel JSON.""" + page.wait_for_function( + f"""() => {{ + const iframe = document.querySelector('iframe[data-awi-fig="{fig_id}"]'); + if (!iframe || !iframe.contentWindow) return false; + const mdl = iframe.contentWindow._aplModel; + if (!mdl) return false; + const raw = mdl.get('panel_{panel_id}_json'); + return typeof raw === 'string' && raw.length > 10; + }}""", + timeout=timeout, + ) + + +# ============================================================================= +# Tier 1 — Traitlet push unit tests (no browser required) +# ============================================================================= + +class TestPushHook: + """Verify _push() / _push_layout() write to sync=True traitlets. + + The old tests checked ``_pyodide_push_hook``; now we observe the traitlets + directly — the same path that the generic anywidget monkey-patch uses in + Pyodide. + """ + + def test_push_does_not_crash(self): + """Normal mode: _push() succeeds without error.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.plot(np.zeros(16)) # must not raise + + def test_layout_json_written_on_create(self): + """layout_json traitlet is set when a figure is created.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + import json + parsed = json.loads(fig.layout_json) + assert "panel_specs" in parsed, ( + f"layout_json missing 'panel_specs': {list(parsed.keys())}" + ) + + def test_panel_json_written_after_plot(self): + """panel_*_json traitlet is set when a plot is added.""" + import json + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.plot(np.sin(np.linspace(0, 2 * np.pi, 64))) + + panel_keys = [k for k in fig.trait_names() if k.startswith("panel_") and k.endswith("_json")] + assert len(panel_keys) >= 1, "Expected at least one panel_*_json trait" + for k in panel_keys: + parsed = json.loads(getattr(fig, k)) + assert "kind" in parsed, f"panel JSON missing 'kind': {list(parsed.keys())}" + + def test_observe_fires_on_push(self): + """traitlets.observe() fires when _push() writes a panel trait.""" + seen: list[str] = [] + + def _watch(change): + seen.append(change["name"]) + + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + fig.observe(_watch) + ax.plot(np.zeros(8)) + fig.unobserve(_watch) + + assert any(k.startswith("panel_") for k in seen), ( + f"Expected a panel_* trait change; got: {seen}" + ) + + def test_panel_id_deterministic(self): + """Panel IDs derived from SubplotSpec must be identical across rebuilds.""" + ids: list[str] = [] + for _ in range(3): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.plot(np.zeros(8)) + ids.append(list(fig._plots_map.keys())[0]) + assert ids[0] == ids[1] == ids[2], ( + f"Panel ID must be deterministic; got {ids}" + ) + + def test_panel_ids_unique_in_multiplot(self): + """Each panel in a multi-panel figure has a unique ID.""" + fig, axes = apl.subplots(1, 3, figsize=(900, 300)) + for ax in axes: + ax.plot(np.zeros(8)) + ids = list(fig._plots_map.keys()) + assert len(ids) == len(set(ids)), f"Panel IDs not unique: {ids}" + + def test_panel_id_matches_grid_position(self): + """Panel IDs encode the SubplotSpec row/col bounds.""" + fig, axes = apl.subplots(2, 2, figsize=(600, 400)) + for ax in np.asarray(axes).flat: + ax.plot(np.zeros(4)) + ids = set(fig._plots_map.keys()) + for pid in ids: + assert pid.startswith("p"), f"Unexpected panel ID format: {pid!r}" + + def test_dispatch_event_callable_without_kernel(self): + """_dispatch_event() can be called directly as the Pyodide bridge does.""" + import json + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.plot(np.zeros(16)) + raw = json.dumps({ + "event_type": "on_zoom", + "panel_id": list(fig._plots_map.keys())[0], + "source": "js", + }) + fig._dispatch_event(raw) # must not raise + + def test_capture_fig_state_helper(self): + """_capture_fig_state returns both layout_json and panel JSON(s).""" + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.plot(np.zeros(32)) + state = _capture_fig_state(fig) + assert "layout_json" in state, f"Expected layout_json; got {list(state.keys())}" + panel_keys = [k for k in state if k.startswith("panel_")] + assert len(panel_keys) >= 1, "Expected at least one panel_ key" + + def test_no_pyodide_push_hook_attribute(self): + """figure module no longer exposes _pyodide_push_hook.""" + assert not hasattr(_af, "_pyodide_push_hook"), ( + "_pyodide_push_hook should not exist on figure module in this branch" + ) + + +# ============================================================================= +# Tier 2 — iframe postMessage tests (browser, no Pyodide, no HTTP server) +# ============================================================================= + +class TestIframeMessaging: + """Test the awi_state postMessage protocol via the standalone iframe. + + The ``interact_page`` fixture opens the figure HTML as a top-level page + (not as an iframe), so ``window.parent === window`` and the outbound + awi_event forwarding is naturally disabled. These tests focus on the + *inbound* direction: an ``awi_state`` message updates the model. + """ + + def _open_fig(self, interact_page): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.plot(np.sin(np.linspace(0, 2 * np.pi, 64)), color="#4fc3f7") + plot = list(fig._plots_map.values())[0] + panel_id = list(fig._plots_map.keys())[0] + page = interact_page(fig) + return fig, plot, panel_id, page + + def test_awi_state_message_updates_model_key(self, interact_page): + """Posting {type:'awi_state', key, value} into the page updates the model.""" + fig, plot, panel_id, page = self._open_fig(interact_page) + + # Read the current panel JSON and add a sentinel key + raw = page.evaluate(f"() => window._aplModel.get('panel_{panel_id}_json')") + assert raw is not None, "Model should have an initial panel JSON" + curr = json.loads(raw) + curr["__apl_e2e_sentinel__"] = "hello_from_postMessage" + new_json = json.dumps(curr) + + page.evaluate(f"""() => {{ + window.postMessage({{ + type: 'awi_state', + key: 'panel_{panel_id}_json', + value: {json.dumps(new_json)} + }}, '*'); + }}""") + _rafter(page) + + updated = json.loads( + page.evaluate(f"() => window._aplModel.get('panel_{panel_id}_json')") + ) + assert updated.get("__apl_e2e_sentinel__") == "hello_from_postMessage", ( + "awi_state postMessage did not update the model key" + ) + + def test_awi_state_message_sets_from_parent_flag(self, interact_page): + """_fromParent is True while the awi_state handler runs. + + We can't read the flag mid-handler, but we can verify that a + save_changes() triggered by awi_state does NOT set _eventJsonDirty + (since event_json was not written in that transaction). A by-product + check: calling model.set on a non-event_json key never marks the + dirty flag. + """ + fig, plot, panel_id, page = self._open_fig(interact_page) + + raw = json.loads( + page.evaluate(f"() => window._aplModel.get('panel_{panel_id}_json')") + ) + raw["__flag_test__"] = 42 + new_json = json.dumps(raw) + + # Expose _eventJsonDirty so we can read it after the handler runs. + # We monkey-patch model.save_changes to record whether _eventJsonDirty + # was True at the time of the call triggered by the awi_state message. + page.evaluate("""() => { + window._dirtyAtSaveChanges = null; + // We can't access module-scoped _eventJsonDirty from outside, but + // we can observe whether an awi_event postMessage is fired: it only + // fires when (!_fromParent && FIG_ID && parent!==window && dirty). + // Since FIG_ID is null (standalone page), no awi_event fires in any + // case. So we check absence of awi_event messages instead. + window._aplEventsSeen = 0; + window.addEventListener('message', (e) => { + if (e.data && e.data.type === 'awi_event') window._aplEventsSeen++; + }); + }""") + + page.evaluate(f"""() => {{ + window.postMessage({{ + type: 'awi_state', + key: 'panel_{panel_id}_json', + value: {json.dumps(new_json)} + }}, '*'); + }}""") + _rafter(page) + + # In standalone mode FIG_ID is null → no awi_event is ever forwarded + events_seen = page.evaluate("() => window._aplEventsSeen") + assert events_seen == 0, ( + "_fromParent guard or FIG_ID=null should prevent awi_event echo; " + f"got {events_seen} awi_event(s)" + ) + + def test_awi_state_fires_change_listeners(self, interact_page): + """Posting awi_state triggers on('change:…') listeners in the model.""" + fig, plot, panel_id, page = self._open_fig(interact_page) + + page.evaluate(f"""() => {{ + window._aplChangeCount = 0; + window._aplModel.on('change:panel_{panel_id}_json', () => {{ + window._aplChangeCount++; + }}); + }}""") + + raw = json.loads( + page.evaluate(f"() => window._aplModel.get('panel_{panel_id}_json')") + ) + raw["__change_test__"] = 1 + new_json = json.dumps(raw) + + page.evaluate(f"""() => {{ + window.postMessage({{ + type: 'awi_state', + key: 'panel_{panel_id}_json', + value: {json.dumps(new_json)} + }}, '*'); + }}""") + _rafter(page) + + count = page.evaluate("() => window._aplChangeCount") + assert count >= 1, ( + "awi_state postMessage should fire change listeners; " + f"got {count} invocations" + ) + + def test_layout_json_push_updates_model(self, interact_page): + """layout_json can be updated via awi_state, not only panel_*_json.""" + fig, plot, panel_id, page = self._open_fig(interact_page) + + layout = json.loads( + page.evaluate("() => window._aplModel.get('layout_json') || '{}'") + ) + layout["__layout_sentinel__"] = "bridge_test" + new_json = json.dumps(layout) + + page.evaluate(f"""() => {{ + window.postMessage({{ + type: 'awi_state', + key: 'layout_json', + value: {json.dumps(new_json)} + }}, '*'); + }}""") + _rafter(page) + + updated = json.loads( + page.evaluate("() => window._aplModel.get('layout_json') || '{}'") + ) + assert updated.get("__layout_sentinel__") == "bridge_test", ( + "layout_json postMessage did not update the model" + ) + + +# ============================================================================= +# Tier 3 — Full bridge mock-boot tests (HTTP server + mock Pyodide) +# ============================================================================= + +class TestFullBridgeBoot: + """Boot anywidget_bridge.js end-to-end via a mock loadPyodide. + + Each test builds a parent HTML page using ``_build_parent_page`` and + serves it from the shared ``http_server`` fixture. All Pyodide network + I/O is replaced by the JS mock so tests run in milliseconds. + """ + + # ------------------------------------------------------------------ + # helpers + + def _open(self, browser, base_url: str, parent_path: pathlib.Path, + timeout: int = 15_000): + url = f"{base_url}/{parent_path.name}" + page = browser.new_page() + page.goto(url, wait_until="domcontentloaded", timeout=timeout) + return page + + def _basic_fig(self) -> tuple: + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.plot(np.sin(np.linspace(0, 2 * np.pi, 64)), color="#50fa7b") + panel_id = list(fig._plots_map.keys())[0] + return fig, panel_id + + # ------------------------------------------------------------------ + # tests + + def test_button_appears_when_iframe_present( + self, http_server, _pw_browser + ): + """The ⚡ button is injected on any page that has a data-awi-fig iframe.""" + base_url, base_dir = http_server + fig, panel_id = self._basic_fig() + parent = _build_parent_page(fig, "btn_test_001", base_dir=base_dir) + page = self._open(_pw_browser, base_url, parent) + + page.wait_for_function( + "() => !!document.querySelector('button.awi-activate-btn')", + timeout=5_000, + ) + tooltip = page.evaluate( + "() => document.querySelector('button.awi-activate-btn').title" + ) + assert "interactive" in tooltip.lower(), ( + f"Button tooltip should mention 'interactive'; got {tooltip!r}" + ) + page.close() + + def test_boot_completes_all_mock_steps( + self, http_server, _pw_browser + ): + """Clicking ⚡ runs through all expected mock Pyodide boot steps.""" + base_url, base_dir = http_server + fig, panel_id = self._basic_fig() + parent = _build_parent_page(fig, "boot_test_001", base_dir=base_dir) + page = self._open(_pw_browser, base_url, parent) + + _click_and_wait_boot(page) + + steps = page.evaluate("() => window._APL_BOOT_STEPS") + + assert "loadPyodide" in steps, ( + f"loadPyodide() was never called; steps={steps}" + ) + assert "micropip_install" in steps, ( + f"micropip install step missing; steps={steps}" + ) + assert "stub_anywidget" in steps, ( + f"anywidget stub step missing; steps={steps}" + ) + assert "install_monkey_patch" in steps, ( + f"monkey-patch install step missing; steps={steps!r}\n" + "This means anywidget_bridge.js never called runPythonAsync with " + "the _patched_init monkey-patch source — the JS↔Python bridge is broken." + ) + assert "run_example" in steps, ( + f"Example-run step missing; steps={steps!r}\n" + "This means anywidget_bridge.js never called runPythonAsync with " + "the _fig_ids / _push_layout block that seeds the iframes." + ) + page.close() + + def test_anywidgetPush_is_function_after_boot( + self, http_server, _pw_browser + ): + """window._anywidgetPush must be a function after the push-hook step runs.""" + base_url, base_dir = http_server + fig, panel_id = self._basic_fig() + parent = _build_parent_page(fig, "apush_test_001", base_dir=base_dir) + page = self._open(_pw_browser, base_url, parent) + + _click_and_wait_boot(page) + + is_fn = page.evaluate("() => typeof window._anywidgetPush === 'function'") + assert is_fn, ( + "window._anywidgetPush should be a function after the push-hook step; " + "if it is missing the hook was never installed by anywidget_bridge.js" + ) + page.close() + + def test_state_pushed_into_iframe_model( + self, http_server, _pw_browser + ): + """After boot the iframe's model contains the figure's panel JSON. + + This is the core Pyodide bridge assertion: Python figure state must + reach the iframe model via _anywidgetPush → postMessage → awi_state listener + → model.set(key, value). + """ + base_url, base_dir = http_server + fig, panel_id = self._basic_fig() + expected = fig._plots_map[panel_id].to_state_dict() + + parent = _build_parent_page(fig, "state_push_001", base_dir=base_dir) + page = self._open(_pw_browser, base_url, parent) + + _click_and_wait_boot(page) + _wait_for_iframe_model(page, "state_push_001", panel_id) + + raw = page.evaluate(f"""() => {{ + const iframe = document.querySelector('iframe[data-awi-fig="state_push_001"]'); + return iframe && iframe.contentWindow + ? iframe.contentWindow._aplModel.get('panel_{panel_id}_json') + : null; + }}""") + + assert raw is not None, ( + "panel JSON was never delivered to the iframe model after boot.\n" + "Check: (a) _anywidgetPush was installed, (b) postMessage reached the " + "iframe's awi_state listener, (c) model.set() was called." + ) + state = json.loads(raw) + assert state.get("kind") == expected.get("kind"), ( + f"kind mismatch: iframe has {state.get('kind')!r}, " + f"Python produced {expected.get('kind')!r}" + ) + page.close() + + def test_layout_json_pushed_into_iframe( + self, http_server, _pw_browser + ): + """layout_json (panel geometry) is delivered to the iframe model.""" + base_url, base_dir = http_server + fig, panel_id = self._basic_fig() + parent = _build_parent_page(fig, "layout_push_001", base_dir=base_dir) + page = self._open(_pw_browser, base_url, parent) + + _click_and_wait_boot(page) + + # Wait for layout_json to propagate + page.wait_for_function( + """() => { + const iframe = document.querySelector('iframe[data-awi-fig="layout_push_001"]'); + if (!iframe || !iframe.contentWindow) return false; + const mdl = iframe.contentWindow._aplModel; + if (!mdl) return false; + const raw = mdl.get('layout_json'); + return typeof raw === 'string' && raw.length > 10; + }""", + timeout=8_000, + ) + + raw = page.evaluate("""() => { + const iframe = document.querySelector('iframe[data-awi-fig="layout_push_001"]'); + return iframe.contentWindow._aplModel.get('layout_json'); + }""") + assert raw is not None, "layout_json was not delivered to the iframe" + layout = json.loads(raw) + assert "panel_specs" in layout, ( + f"layout_json is missing 'panel_specs'; got keys: {list(layout.keys())}" + ) + page.close() + + def test_event_message_forwarded_to_parent( + self, http_server, _pw_browser + ): + """awi_event messages sent from the iframe arrive at the parent window. + + This tests the reverse direction of the bridge: user interaction in + the iframe → awi_event postMessage → parent window.message listener + → _fig._dispatch_event(). Here we only test the JS forwarding step; + the Python dispatch is covered by TestPushHook.test_dispatch_event_*. + """ + base_url, base_dir = http_server + fig, panel_id = self._basic_fig() + parent = _build_parent_page(fig, "event_fwd_001", base_dir=base_dir) + page = self._open(_pw_browser, base_url, parent) + + _click_and_wait_boot(page) + + # Install a parent-side listener that records received awi_events + page.evaluate("""() => { + window._aplReceivedEvents = []; + window.addEventListener('message', (e) => { + if (e.data && e.data.type === 'awi_event') { + window._aplReceivedEvents.push(e.data); + } + }); + }""") + + # Synthesise an awi_event from the iframe (mirrors what the iframe + # does when a widget drag ends: window.parent.postMessage({...}, '*')) + fake_event = json.dumps({ + "event_type": "on_release", + "panel_id": panel_id, + "widget_id": "w_e2e_fake", + "x": 42.0, + }) + page.evaluate(f"""() => {{ + // Simulate the iframe posting the event to its parent. + // In the actual docs the iframe does: + // window.parent.postMessage({{type:'awi_event', figId, data}}, '*') + // Here the iframe IS the top-level page so we post to window itself. + window.postMessage({{ + type: 'awi_event', + figId: 'event_fwd_001', + data: {json.dumps(fake_event)} + }}, '*'); + }}""") + _rafter(page) + + events = page.evaluate("() => window._aplReceivedEvents") + assert len(events) >= 1, ( + "No awi_event reached the parent message bus.\n" + "The parent window.message listener in anywidget_bridge.js " + "may not be installed, or the figId routing is broken." + ) + assert events[0]["figId"] == "event_fwd_001", ( + f"figId mismatch: {events[0]['figId']!r} vs 'event_fwd_001'" + ) + page.close() + + def test_multiple_panels_all_receive_state( + self, http_server, _pw_browser + ): + """All panels in a multi-panel figure have their state pushed.""" + base_url, base_dir = http_server + + fig, axes = apl.subplots(1, 2, figsize=(700, 300)) + axes[0].plot(np.zeros(32)) + axes[1].plot(np.ones(32) * 0.5) + panel_ids = list(fig._plots_map.keys()) + assert len(panel_ids) == 2, "Expected exactly 2 panels" + + parent = _build_parent_page(fig, "multi_panel_001", base_dir=base_dir) + page = self._open(_pw_browser, base_url, parent) + _click_and_wait_boot(page) + + # Wait for both panels to arrive + for pid in panel_ids: + _wait_for_iframe_model(page, "multi_panel_001", pid) + + for pid in panel_ids: + raw = page.evaluate(f"""() => {{ + const iframe = document.querySelector( + 'iframe[data-awi-fig="multi_panel_001"]'); + return iframe && iframe.contentWindow + ? iframe.contentWindow._aplModel.get('panel_{pid}_json') + : null; + }}""") + assert raw is not None, ( + f"Panel {pid!r} state was not pushed into the iframe model.\n" + "If only the first panel arrives, _anywidgetPush may be iterating " + "panels incorrectly in the mock (or in the real bridge)." + ) + page.close() + + def test_button_shows_error_on_boot_failure( + self, http_server, _pw_browser + ): + """If Pyodide boot fails the button switches to the error state (❌).""" + base_url, base_dir = http_server + fig, panel_id = self._basic_fig() + + # Build the parent page, then patch the mock to throw on loadPyodide + parent = _build_parent_page(fig, "error_test_001", base_dir=base_dir) + html = (base_dir / "error_test_001_parent.html").read_text(encoding="utf-8") + # Inject a rejection AFTER the mock definition so it overrides it + html = html.replace( + "window.loadPyodide = async function({indexURL}) {", + "window.loadPyodide = async function({indexURL}) { throw new Error('mock boot failure'); //", + ) + (base_dir / "error_test_001_parent.html").write_text(html, encoding="utf-8") + + page = self._open(_pw_browser, base_url, parent) + page.wait_for_function( + "() => !!document.querySelector('button.awi-activate-btn')", + timeout=5_000 + ) + page.click("button.awi-activate-btn") + + # Wait for button to enter error state + page.wait_for_function( + """() => { + const btn = document.querySelector('button.awi-activate-btn'); + return btn && btn.dataset.state === 'error'; + }""", + timeout=10_000, + ) + label = page.evaluate( + "() => document.querySelector('button.awi-activate-btn').title" + ) + assert "mock boot failure" in label, ( + f"Error button title should contain the exception message; got {label!r}" + ) + page.close() + + + diff --git a/tests/test_scraper.py b/tests/test_scraper.py new file mode 100644 index 0000000..a296743 --- /dev/null +++ b/tests/test_scraper.py @@ -0,0 +1,66 @@ +""" +tests/test_scraper.py +===================== + +Pytest tests for the Playwright-based scraper thumbnail functionality. +""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.sphinx_anywidget._scraper import _make_thumbnail_png +from tests._png_utils import decode_png + + +# ── fixtures ────────────────────────────────────────────────────────────────── + +@pytest.fixture +def line_fig(): + fig, ax = apl.subplots(1, 1, figsize=(400, 250)) + ax.plot(np.sin(np.linspace(0, 2 * np.pi, 128)), color="#4fc3f7") + return fig + + +@pytest.fixture +def imshow_fig(): + fig, ax = apl.subplots(1, 1, figsize=(320, 320)) + data = np.linspace(0, 1, 64 * 64, dtype=np.float32).reshape(64, 64) + ax.imshow(data) + return fig + + +@pytest.fixture +def multi_panel_fig(): + fig, axes = apl.subplots(1, 2, figsize=(640, 300)) + axes[0].plot(np.cos(np.linspace(0, 2 * np.pi, 64))) + axes[1].imshow( + np.random.default_rng(0).uniform(0, 1, (32, 32)).astype(np.float32) + ) + return fig + + +# ── thumbnail PNG validation ────────────────────────────────────────────────── + +def _assert_thumbnail_is_png(widget, label: str): + png = _make_thumbnail_png(widget) + assert png[:4] == b"\x89PNG", f"[{label}] result is not a PNG" + arr = decode_png(png) + assert arr.ndim == 3, f"[{label}] expected H×W×C array, got shape {arr.shape}" + assert arr.shape[2] in (3, 4), f"[{label}] expected RGB/RGBA, got {arr.shape[2]} channels" + + +def test_thumbnail_1d_line(line_fig): + _assert_thumbnail_is_png(line_fig, "1D line") + + +def test_thumbnail_2d_imshow(imshow_fig): + _assert_thumbnail_is_png(imshow_fig, "2D imshow") + + +def test_thumbnail_multi_panel(multi_panel_fig): + _assert_thumbnail_is_png(multi_panel_fig, "multi-panel") diff --git a/tests/test_sphinx_anywidget.py b/tests/test_sphinx_anywidget.py new file mode 100644 index 0000000..67f5921 --- /dev/null +++ b/tests/test_sphinx_anywidget.py @@ -0,0 +1,92 @@ +""" +tests/test_sphinx_anywidget.py +================================ + +Smoke tests for the ``anyplotlib.sphinx_anywidget`` extension. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl +import anyplotlib.figure as _af +from anyplotlib.sphinx_anywidget import AnywidgetScraper, ViewerScraper, setup # noqa: F401 +from anyplotlib.sphinx_anywidget._directive import AnywidgetFigureDirective # noqa: F401 +from anyplotlib.sphinx_anywidget._repr_utils import build_standalone_html, _widget_px +from anyplotlib.sphinx_anywidget._scraper import ( + _INTERACTIVE_RE, + _find_widget, + _iframe_html, +) +from anyplotlib.sphinx_anywidget._wheel_builder import build_wheel # noqa: F401 + + +# ── fixtures ────────────────────────────────────────────────────────────────── + +@pytest.fixture +def simple_fig(): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.plot(np.sin(np.linspace(0, 6.28, 64))) + return fig + + +# ── standalone HTML builder ─────────────────────────────────────────────────── + +def test_standalone_html_contains_awi_state(simple_fig): + html = build_standalone_html(simple_fig, resizable=False, fig_id="tf") + assert "awi_state" in html, "Missing awi_state listener" + + +def test_standalone_html_contains_fig_id(simple_fig): + html = build_standalone_html(simple_fig, resizable=False, fig_id="tf") + assert '"tf"' in html, "Missing fig_id in HTML" + + +def test_widget_px(simple_fig): + w, h = _widget_px(simple_fig) + assert w == 416, f"Expected 416 got {w}" + + +# ── iframe HTML helper ──────────────────────────────────────────────────────── + +def test_iframe_html_interactive_has_activate_btn(): + b = _iframe_html("t.html", 400, 300, fig_id="a", interactive=True) + assert "awi-activate-btn" in b, "Missing activate button" + + +def test_iframe_html_static_no_activate_btn(): + s = _iframe_html("t.html", 400, 300, fig_id="a", interactive=False) + assert "awi-activate-btn" not in s, "Should not have activate btn on static" + + +# ── no stale push hook ──────────────────────────────────────────────────────── + +def test_no_pyodide_push_hook(): + assert not hasattr(_af, "_pyodide_push_hook"), "_pyodide_push_hook should be gone" + + +# ── _find_widget ────────────────────────────────────────────────────────────── + +def test_find_widget_finds_figure(simple_fig): + found = _find_widget({"fig": simple_fig, "x": 42}) + assert found is simple_fig, "Should find Figure" + + +def test_find_widget_returns_none_for_non_widget(): + assert _find_widget({"x": 42}) is None + + +# ── # Interactive detection ─────────────────────────────────────────────────── + +def test_interactive_re_matches_inline_comment(): + assert _INTERACTIVE_RE.search("fig # Interactive\n"), "Should match" + + +def test_interactive_re_matches_lowercase(): + assert _INTERACTIVE_RE.search("fig # interactive"), "Should match lowercase" + + +def test_interactive_re_no_false_positives(): + assert not _INTERACTIVE_RE.search("fig # not a match"), "Should not match" diff --git a/upcoming_changes/9.new_feature.rst b/upcoming_changes/9.new_feature.rst new file mode 100644 index 0000000..42e401b --- /dev/null +++ b/upcoming_changes/9.new_feature.rst @@ -0,0 +1,7 @@ +* Added ``anyplotlib.sphinx_anywidget`` Sphinx extension for interactive, + Pyodide-powered figures in documentation (``.. anywidget-figure::`` directive, + automatic wheel building, Sphinx Gallery integration). +* Improved widget–parent page postMessage communication bridge. +* Made colormap LUT construction more robust against unknown colormap names. +* Subplot panels now use deterministic IDs. +* Added end-to-end test for the Playwright thumbnail scraper.