From b268f068b6cb68f6e03caa11329739f4697d048d Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 3 Apr 2026 09:24:55 -0500 Subject: [PATCH 01/13] Add support for pyodide and responsive plots. --- _test_scraper.py | 39 ++++ docs/_sg_html_scraper.py | 79 +++++-- docs/_static/pyodide_bridge.js | 386 +++++++++++++++++++++++++++++++++ docs/conf.py | 61 ++++++ 4 files changed, 542 insertions(+), 23 deletions(-) create mode 100644 _test_scraper.py create mode 100644 docs/_static/pyodide_bridge.js diff --git a/_test_scraper.py b/_test_scraper.py new file mode 100644 index 0000000..a22d4a8 --- /dev/null +++ b/_test_scraper.py @@ -0,0 +1,39 @@ +"""Quick end-to-end test for the Playwright-based scraper thumbnail.""" +import sys +sys.path.insert(0, 'docs') +sys.path.insert(0, 'tests') + +import numpy as np +import anyplotlib as apl +from _sg_html_scraper import _make_thumbnail_png +from _png_utils import decode_png + +tests = [] + +# 1D line plot +fig, ax = apl.subplots(1, 1, figsize=(400, 250)) +ax.plot(np.sin(np.linspace(0, 2 * np.pi, 128)), color='#4fc3f7') +tests.append(("1D line", fig)) + +# 2D image +fig2, ax2 = apl.subplots(1, 1, figsize=(320, 320)) +data = np.linspace(0, 1, 64 * 64, dtype=np.float32).reshape(64, 64) +ax2.imshow(data) +tests.append(("2D imshow", fig2)) + +# multi-panel +fig3, 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)) +tests.append(("multi-panel", fig3)) + +for name, widget in tests: + png = _make_thumbnail_png(widget) + assert png[:4] == b'\x89PNG', f"[{name}] result is not a PNG!" + arr = decode_png(png) + r, g, b = arr[0, 0, 0], arr[0, 0, 1], arr[0, 0, 2] + dark_ok = (b > r) and (b > 30) + print(f"[{name}] shape={arr.shape} top-left RGB=({r},{g},{b}) {'DARK OK' if dark_ok else 'THEME CHECK NEEDED'}") + +print("\nAll tests passed.") + diff --git a/docs/_sg_html_scraper.py b/docs/_sg_html_scraper.py index 956fba6..0cf4585 100644 --- a/docs/_sg_html_scraper.py +++ b/docs/_sg_html_scraper.py @@ -17,7 +17,9 @@ from __future__ import annotations +import json as _json import tempfile +from html import escape as _html_escape from pathlib import Path from uuid import uuid4 @@ -108,7 +110,7 @@ def _make_thumbnail_png(widget) -> bytes: return png_bytes -def _iframe_html(src: str, w: int, h: int) -> str: +def _iframe_html(src: str, w: int, h: int, fig_id: str | None = None) -> str: """Return a single-line HTML snippet that embeds *src* responsively. The iframe is always rendered at its native resolution (``w × h`` px) so @@ -120,7 +122,7 @@ def _iframe_html(src: str, w: int, h: int) -> str: A tiny inline script re-runs the scale calculation on every ``resize`` event so the embed reflows without a page reload. """ - uid = f"f{uuid4().hex[:8]}" + uid = fig_id or f"f{uuid4().hex[:8]}" # Static initial scale so the page renders correctly before JS runs init_scale = min(1.0, MAX_DOC_WIDTH / w) @@ -161,7 +163,7 @@ def _iframe_html(src: str, w: int, h: int) -> str: f'
' f'
' - f'' + 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..18e2b5e --- /dev/null +++ b/anyplotlib/sphinx_anywidget/_scraper.py @@ -0,0 +1,327 @@ +""" +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. + + Parameters + ---------- + static_icon : str + Unicode character shown as the "static snapshot" badge. + Default ``"📷"``. Override via the ``anywidget_static_icon`` Sphinx + config value when using the ``sphinx_anywidget`` extension. + """ + + def __init__(self, static_icon: str = "\U0001f4f7"): + self.static_icon = static_icon + # 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, + static_icon=self.static_icon, + ) + + 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) + 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..f3847a2 --- /dev/null +++ b/anyplotlib/sphinx_anywidget/_wheel_builder.py @@ -0,0 +1,74 @@ +""" +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 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) + + for old in wheels_dir.glob(f"{package_name}*.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"{package_name}*.whl")) + if not wheels: + print(f"\n[sphinx_anywidget] WARNING: no wheel found for {package_name!r}") + return None + + stable = wheels_dir / f"{package_name}-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..49d6dfc --- /dev/null +++ b/anyplotlib/sphinx_anywidget/static/anywidget_bridge.js @@ -0,0 +1,411 @@ +/** + * 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 __init__(self): - # Maps src_file path → list of fig_ids emitted so far for that example. - # Used to assign a stable fig_index (creation order) so pyodide_bridge.js - # can run the example source once and tag figures in the right order. - self._example_figs: dict = {} - - 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 "" - - src_file = str(block_vars.get("src_file", "")) - - # ── assign a stable fig_id and fig_index for this widget ────────── - 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 (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)) - - # fig_id is derived from the PNG stem so it is stable across rebuilds - # and unique within the built docs (Sphinx Gallery guarantees unique stems). - fig_id = png_path.stem # e.g. "sphx_glr_plot_image2d_001" - self._example_figs[src_file].append(fig_id) - - # ── 2. Write the standalone HTML into docs/_static/viewer_widgets/ ─ - 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, fig_id=fig_id) - 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}" - - iframe_block = _iframe_html(src, w, h, fig_id=fig_id) - - # Embed the full example Python source alongside the iframe so - # pyodide_bridge.js can run it in Pyodide and wire live callbacks. - python_src = "" - try: - python_src = Path(src_file).read_text(encoding="utf-8") - except Exception: - pass - - if python_src: - # The Python source is JSON-encoded and HTML-escaped into a - # data-src attribute so the ' - ) - else: - python_block = "" +continues to work without changes. +""" - rst = "\n\n.. raw:: html\n\n " + iframe_block + "\n\n" - if python_block: - rst += "\n\n.. raw:: html\n\n " + python_block + "\n\n" - return rst - 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, +) diff --git a/test_sphinx_anywidget.py b/test_sphinx_anywidget.py new file mode 100644 index 0000000..3e1efed --- /dev/null +++ b/test_sphinx_anywidget.py @@ -0,0 +1,44 @@ +"""Quick smoke test for sphinx_anywidget extension.""" +from anyplotlib.sphinx_anywidget import AnywidgetScraper, ViewerScraper, setup +from anyplotlib.sphinx_anywidget._scraper import _find_widget, _iframe_html +from anyplotlib.sphinx_anywidget._repr_utils import build_standalone_html, _widget_px +from anyplotlib.sphinx_anywidget._wheel_builder import build_wheel +from anyplotlib.sphinx_anywidget._directive import AnywidgetFigureDirective +print('imports OK') + +import numpy as np +import anyplotlib as apl + +fig, ax = apl.subplots(1, 1, figsize=(400, 300)) +ax.plot(np.sin(np.linspace(0, 6.28, 64))) + +html = build_standalone_html(fig, resizable=False, fig_id='tf') +assert 'awi_state' in html, 'Missing awi_state listener' +assert '"tf"' in html, 'Missing fig_id in HTML' + +w, h = _widget_px(fig) +assert w == 416, f'Expected 416 got {w}' + +b = _iframe_html('t.html', 400, 300, fig_id='a', interactive=True, static_icon='P') +assert 'awi-activate-btn' in b, 'Missing activate button' +assert 'awi-static-icon' in b, 'Missing static icon' + +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' + +import anyplotlib.figure as _af +assert not hasattr(_af, '_pyodide_push_hook'), '_pyodide_push_hook should be gone' + +# Test _find_widget +found = _find_widget({'fig': fig, 'x': 42}) +assert found is fig, 'Should find Figure' +assert _find_widget({'x': 42}) is None + +# Test # Interactive detection +from anyplotlib.sphinx_anywidget._scraper import _INTERACTIVE_RE +assert _INTERACTIVE_RE.search('fig # Interactive\n'), 'Should match' +assert _INTERACTIVE_RE.search('fig # interactive'), 'Should match lowercase' +assert not _INTERACTIVE_RE.search('fig # not a match'), 'Should not match' + +print('ALL SMOKE TESTS PASSED') + diff --git a/tests/test_pyodide_e2e.py b/tests/test_pyodide_e2e.py new file mode 100644 index 0000000..4bb519b --- /dev/null +++ b/tests/test_pyodide_e2e.py @@ -0,0 +1,946 @@ +""" +tests/test_pyodide_e2e.py +========================= + +End-to-end Playwright tests for the Pyodide live documentation bridge. + +Three test tiers, in increasing scope: + +1. **Python push-hook unit tests** — verify ``_pyodide_push_hook`` intercepts + ``_push()`` / ``_push_layout()`` correctly, and that panel IDs are + deterministic (no-browser, fast). + +2. **iframe postMessage tests** — reuse the existing ``interact_page`` fixture + to open a standalone figure in headless Chromium, fire ``awi_state`` + messages directly, and assert the model updates correctly (no Pyodide, no + HTTP server). + +3. **Full bridge mock-boot tests** — build a ``parent.html`` page that + includes the real ``anywidget_bridge.js`` but defines ``window.loadPyodide`` + as a lightweight mock *before* the bridge evaluates it. The mock exercises + the complete JS boot sequence — button click → all ``runPythonAsync`` / + ``loadPackage`` calls → push-hook installation → state push into the iframe + → awi_event forwarding — without downloading the ~10 MB Pyodide WASM + runtime. Pages are served over a local stdlib HTTP server so the + ``file://`` guard in ``anywidget_bridge.js`` is bypassed. + +Run:: + + uv run pytest tests/test_pyodide_e2e.py -v +""" +from __future__ import annotations + +import json +import pathlib +import socket +import tempfile +import threading +from http.server import HTTPServer, SimpleHTTPRequestHandler +from html import escape as _html_escape +from typing import Generator + +import numpy as np +import pytest + +import anyplotlib as apl +import anyplotlib.figure as _af +from anyplotlib._repr_utils import build_standalone_html + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- + +_BRIDGE_JS = ( + pathlib.Path(__file__).parent.parent + / "anyplotlib" / "sphinx_anywidget" / "static" / "anywidget_bridge.js" +) + + +# --------------------------------------------------------------------------- +# Helpers used by multiple tiers +# --------------------------------------------------------------------------- + +def _capture_fig_state(fig) -> dict[str, str]: + """Return ``{trait_name: json_string}`` for layout + every panel trait. + + Reads traitlet values directly after calling the push methods. This + works even when the value hasn't changed (traitlets suppresses duplicate + change events, so an observe-based approach would return nothing on a + second call with the same state). + """ + # Ensure state is up to date + fig._push_layout() + for pid in list(fig._plots_map): + fig._push(pid) + + captured: dict[str, str] = {} + captured["layout_json"] = fig.layout_json + for tname in fig.trait_names(): + if tname.startswith("panel_") and tname.endswith("_json"): + captured[tname] = getattr(fig, tname) + return captured + + +def _patched_iframe_html(fig, fig_id: str) -> str: + """Return standalone figure HTML instrumented for Playwright. + + Patches applied on top of ``build_standalone_html``: + * ``window._aplModel = model`` — exposes the model to parent-frame JS. + * ``window._aplReady = true`` — sentinel polled by ``wait_for_function``. + """ + html = build_standalone_html(fig, resizable=False, fig_id=fig_id) + html = html.replace( + "const model = makeModel(STATE);", + "const model = makeModel(STATE);\nwindow._aplModel = model;", + ) + html = html.replace( + "renderFn({ model, el });", + "renderFn({ model, el }); window._aplReady = true;", + ) + return html + + +# --------------------------------------------------------------------------- +# HTTP-server fixture (module-scoped — one server per test module) +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="module") +def http_server(tmp_path_factory) -> Generator[tuple[str, pathlib.Path], None, None]: + """Serve a temp directory over HTTP; yield ``(base_url, base_dir)``. + + Uses a randomly-chosen free port so tests run safely alongside other + sessions. The server is shut down after the last test in the module. + """ + base_dir = tmp_path_factory.mktemp("bridge_server") + + class _SilentHandler(SimpleHTTPRequestHandler): + def __init__(self, *a, **kw): + super().__init__(*a, directory=str(base_dir), **kw) + + def log_message(self, *_): + pass # suppress request noise in test output + + # Pick a free port + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + + srv = HTTPServer(("127.0.0.1", port), _SilentHandler) + t = threading.Thread(target=srv.serve_forever, daemon=True) + t.start() + + yield f"http://127.0.0.1:{port}", base_dir + + srv.shutdown() + + +# --------------------------------------------------------------------------- +# Parent-page builder +# --------------------------------------------------------------------------- + +def _build_parent_page( + fig, + fig_id: str, + *, + base_dir: pathlib.Path, + python_src: str = "", +) -> pathlib.Path: + """Write a complete mock-Pyodide parent page to *base_dir*. + + Files written + ------------- + ``{fig_id}.html`` — standalone figure iframe + ``anywidget_bridge.js`` — the real bridge script (copied from docs/) + ``{fig_id}_parent.html`` — parent page with mock loadPyodide + + The mock ``window.loadPyodide`` is defined **before** the bridge script + so the bridge's ``typeof loadPyodide !== 'undefined'`` guard skips the CDN + download entirely. Each ``runPythonAsync`` call is dispatched by string + pattern to simulate the five significant Pyodide boot steps: + + 1. ``micropip.install`` — no-op. + 2. ``sys.modules['anywidget']`` stub — no-op. + 3. ``_pyodide_push_hook`` install — sets real ``window._anywidgetPush``. + 4. ``_fig_ids`` example-run — calls ``window._anywidgetPush`` with captured state. + + Pre-collected figure state (``layout_json`` + ``panel_*_json``) is baked + into the page as ``window._MOCK_LAYOUT`` / ``window._MOCK_PANELS`` so the + mock can push real data without running any Python. + """ + # ── 1. Iframe HTML (with Playwright instrumentation patches) ───────── + iframe_html = _patched_iframe_html(fig, fig_id) + (base_dir / f"{fig_id}.html").write_text(iframe_html, encoding="utf-8") + + # ── 2. Real bridge script ───────────────────────────────────────────── + (base_dir / "anywidget_bridge.js").write_text( + _BRIDGE_JS.read_text(encoding="utf-8"), encoding="utf-8" + ) + + # ── 3. Capture real figure state via the push-hook ──────────────────── + fig_state = _capture_fig_state(fig) + layout_value = fig_state.get("layout_json", "{}") + panel_entries = [ + {"key": k, "value": v} + for k, v in fig_state.items() + if k.startswith("panel_") + ] + + fig_w, fig_h = int(fig.fig_width), int(fig.fig_height) + + # ── 4. Python source block (or a minimal comment stub) ──────────────── + if not python_src: + python_src = "# mock example — state injected by test harness\n" + data_src_attr = _html_escape(json.dumps(python_src), quote=True) + + # ── 5. Mock loadPyodide script ──────────────────────────────────────── + # + # Intercepts every runPythonAsync call by pattern so the full JS boot + # path (button → loading → active) is exercised in milliseconds. + # + # Step (3): install push-hook → sets window._anywidgetPush which delivers + # postMessage awi_state updates into the correct iframe. + # Step (4): run example → calls window._anywidgetPush with pre-baked + # state so the iframe model receives real figure data. + mock_js = f"""""" + + # ── 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() + + + From 64c75e1dad7860af853b809a16e8ce135152a489 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 3 May 2026 13:40:46 -0500 Subject: [PATCH 03/13] refactor: remove static icon support and update badge behavior for interactive figures --- Examples/Interactive/plot_interactive_fft.py | 5 +- Examples/Interactive/plot_key_bindings.py | 3 +- Examples/Interactive/plot_point_widget.py | 2 +- anyplotlib/figure_plots.py | 54 +++++++----- anyplotlib/sphinx_anywidget/__init__.py | 21 +++-- anyplotlib/sphinx_anywidget/_directive.py | 9 -- anyplotlib/sphinx_anywidget/_scraper.py | 36 +++----- .../static/anywidget_bridge.js | 3 +- .../static/anywidget_overlay.css | 39 ++++----- docs/_static/pyodide_bridge.js | 82 +++++++++++-------- test_sphinx_anywidget.py | 3 +- tests/test_pyodide_e2e.py | 1 - 12 files changed, 130 insertions(+), 128 deletions(-) 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_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/anyplotlib/figure_plots.py b/anyplotlib/figure_plots.py index d3a67fa..da4e269 100644 --- a/anyplotlib/figure_plots.py +++ b/anyplotlib/figure_plots.py @@ -592,26 +592,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 index 75b8ea8..7f3cd0c 100644 --- a/anyplotlib/sphinx_anywidget/__init__.py +++ b/anyplotlib/sphinx_anywidget/__init__.py @@ -18,8 +18,6 @@ # Package whose wheel is built and served to Pyodide at runtime. anywidget_pyodide_package = "mypackage" - # Optional icon on static (non-interactive) figure snapshots. - anywidget_static_icon = "📷" # default The extension: * builds a pure-Python wheel at docs-build time; @@ -48,8 +46,7 @@ def setup(app): """Register sphinx_anywidget with Sphinx.""" - app.add_config_value("anywidget_pyodide_package", default=None, rebuild="html") - app.add_config_value("anywidget_static_icon", default="\U0001f4f7", rebuild="html") + app.add_config_value("anywidget_pyodide_package", default=None, rebuild="html") from anyplotlib.sphinx_anywidget._directive import AnywidgetFigureDirective app.add_directive("anywidget-figure", AnywidgetFigureDirective) @@ -57,7 +54,10 @@ def setup(app): app.connect("builder-inited", _copy_static_assets) app.connect("builder-inited", _build_pyodide_wheel) - app.add_js_file("anywidget_bridge.js", loading_method="defer") + # 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 { @@ -87,11 +87,18 @@ def _build_pyodide_wheel(app): ) return - from anyplotlib.sphinx_anywidget._wheel_builder import build_wheel conf_dir = Path(app.confdir) - project_root = conf_dir.parent static_dir = conf_dir / "_static" static_dir.mkdir(parents=True, exist_ok=True) + + # Write a tiny config script so anywidget_bridge.js can find the package + # name without fragile heuristics. Loaded before anywidget_bridge.js. + import json as _json + 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 = conf_dir.parent build_wheel(static_dir, pkg, project_root) diff --git a/anyplotlib/sphinx_anywidget/_directive.py b/anyplotlib/sphinx_anywidget/_directive.py index 4be69c4..2be636c 100644 --- a/anyplotlib/sphinx_anywidget/_directive.py +++ b/anyplotlib/sphinx_anywidget/_directive.py @@ -24,9 +24,6 @@ source is embedded for live re-execution by the Pyodide bridge. ``:width:`` (int, default 684) Maximum display width in pixels. -``:static-icon:`` (str, default "📷") - Unicode character for the static snapshot badge. Falls back to the - ``anywidget_static_icon`` Sphinx config value, then ``"📷"``. """ from __future__ import annotations @@ -50,7 +47,6 @@ class AnywidgetFigureDirective(Directive): option_spec = { "interactive": directives.flag, "width": directives.nonnegative_int, - "static-icon": directives.unchanged, } def run(self): @@ -73,10 +69,6 @@ def run(self): # ── options ────────────────────────────────────────────────────── is_interactive = "interactive" in self.options max_width = self.options.get("width", 684) - static_icon = self.options.get( - "static-icon", - getattr(config, "anywidget_static_icon", "\U0001f4f7"), - ) # ── execute the script to get the widget ───────────────────────── try: @@ -133,7 +125,6 @@ def run(self): src_url, w, h, fig_id=fig_id, interactive=is_interactive, - static_icon=static_icon, ) raw_html = "\n" + iframe_block + "\n" diff --git a/anyplotlib/sphinx_anywidget/_scraper.py b/anyplotlib/sphinx_anywidget/_scraper.py index 18e2b5e..807be2a 100644 --- a/anyplotlib/sphinx_anywidget/_scraper.py +++ b/anyplotlib/sphinx_anywidget/_scraper.py @@ -24,7 +24,7 @@ fig # Interactive Without the comment the figure is rendered as a plain static iframe with -only the snapshot (📷) badge — no Pyodide wiring at all. +no Pyodide wiring. Usage in ``conf.py``:: @@ -128,7 +128,6 @@ def _iframe_html( h: int, fig_id: str | None = None, interactive: bool = False, - static_icon: str = "\U0001f4f7", # 📷 ) -> str: """Return a single-line HTML snippet embedding *src* responsively. @@ -142,9 +141,6 @@ def _iframe_html( Stable identifier; used as the ``data-awi-fig`` attribute. interactive : bool When True, renders the ⚡ activation badge. - static_icon : str - Unicode character (or HTML entity) displayed as the "static snapshot" - badge icon. Configurable via ``anywidget_static_icon`` in ``conf.py``. """ uid = fig_id or f"f{uuid4().hex[:8]}" @@ -171,21 +167,22 @@ def _iframe_html( f"}})()" ) - # Badge HTML — always includes static icon; ⚡ only when interactive. - badge_parts = [ - f'{static_icon}' - ] + # Badge HTML — only the ⚡ button when interactive; nothing otherwise. + badge_parts = [] if interactive: badge_parts.append( f'' ) - badge_html = ( - f'
' - + "".join(badge_parts) - + "
" - ) + if not badge_parts: + badge_html = "" + else: + badge_html = ( + f'
' + + "".join(badge_parts) + + "
" + ) return ( f'
' @@ -210,17 +207,9 @@ def _iframe_html( class AnywidgetScraper: """Sphinx Gallery image scraper for any ``anywidget.AnyWidget`` subclass. - - Parameters - ---------- - static_icon : str - Unicode character shown as the "static snapshot" badge. - Default ``"📷"``. Override via the ``anywidget_static_icon`` Sphinx - config value when using the ``sphinx_anywidget`` extension. """ - def __init__(self, static_icon: str = "\U0001f4f7"): - self.static_icon = static_icon + def __init__(self): # Maps src_file → list of fig_ids emitted so far (creation order). self._example_figs: dict = {} @@ -289,7 +278,6 @@ def __call__(self, block, block_vars, gallery_conf): src, w, h, fig_id=fig_id, interactive=is_interactive, - static_icon=self.static_icon, ) rst = "\n\n.. raw:: html\n\n " + iframe_block + "\n\n" diff --git a/anyplotlib/sphinx_anywidget/static/anywidget_bridge.js b/anyplotlib/sphinx_anywidget/static/anywidget_bridge.js index 49d6dfc..110e16c 100644 --- a/anyplotlib/sphinx_anywidget/static/anywidget_bridge.js +++ b/anyplotlib/sphinx_anywidget/static/anywidget_bridge.js @@ -89,7 +89,6 @@ btn.textContent = '✓'; btn.dataset.state = 'active'; btn.title = 'Python active'; - // Mark the wrapper so CSS can hide the static icon const wrap = btn.closest('.awi-fig-wrap'); if (wrap) wrap.dataset.awiLive = 'true'; } @@ -353,6 +352,8 @@ elif _widget is None: // ── package name inference ──────────────────────────────────────────────── function _inferPackageName() { + // 0. Authoritative: set by anywidget_config.js (written at build time) + if (window._anywidgetPackage) return window._anywidgetPackage; // 1. Check for a tag (set by the extension) const meta = document.querySelector('meta[name="anywidget:package"]'); if (meta) return meta.getAttribute('content'); diff --git a/anyplotlib/sphinx_anywidget/static/anywidget_overlay.css b/anyplotlib/sphinx_anywidget/static/anywidget_overlay.css index cbed923..87f96a1 100644 --- a/anyplotlib/sphinx_anywidget/static/anywidget_overlay.css +++ b/anyplotlib/sphinx_anywidget/static/anywidget_overlay.css @@ -9,7 +9,6 @@ *
* *
- * 📷 * *
*
@@ -23,38 +22,34 @@ /* ── badge container ─────────────────────────────────────────────────────── */ .awi-badge { position: absolute; - top: 6px; - right: 6px; + top: 8px; + right: 8px; display: flex; align-items: center; - gap: 4px; + gap: 8px; z-index: 10; - pointer-events: none; /* icons are pointer-none by default; button overrides */ + pointer-events: none; } +/* ── after activation: no change needed — stays top-right ───────────────── */ + /* ── shared icon style ───────────────────────────────────────────────────── */ .awi-badge-icon { display: flex; align-items: center; justify-content: center; - width: 28px; - height: 28px; + width: 38px; + height: 38px; border-radius: 50%; - font-size: 14px; + font-size: 18px; line-height: 1; - background: rgba(30, 30, 46, 0.72); - backdrop-filter: blur(4px); - border: 1px solid rgba(255, 255, 255, 0.18); + background: rgba(30, 30, 46, 0.82); + backdrop-filter: blur(6px); + border: 1px solid rgba(255, 255, 255, 0.25); color: #fff; - box-shadow: 0 1px 4px rgba(0,0,0,0.4); + box-shadow: 0 2px 8px rgba(0,0,0,0.5); transition: opacity 0.25s, background 0.2s, transform 0.15s; - opacity: 0.80; -} - -/* ── static snapshot icon (📷 or custom) ────────────────────────────────── */ -.awi-static-icon { - opacity: 0.55; - cursor: default; + opacity: 0.90; } /* ── ⚡ activation button ────────────────────────────────────────────────── */ @@ -101,9 +96,5 @@ pointer-events: none; } -/* ── hide static icon once active (cleans up the badge) ─────────────────── */ -.awi-fig-wrap[data-awi-live="true"] .awi-static-icon { - opacity: 0; - pointer-events: none; -} + diff --git a/docs/_static/pyodide_bridge.js b/docs/_static/pyodide_bridge.js index 49b16f1..d0ea5b6 100644 --- a/docs/_static/pyodide_bridge.js +++ b/docs/_static/pyodide_bridge.js @@ -202,40 +202,27 @@ document.head.appendChild(s); })); } + console.info('[anyplotlib] pyodide.js ready'); // 2. Initialise Pyodide const pyodide = await _step('init pyodide', loadPyodide({ indexURL: PYODIDE_CDN })); + console.info('[anyplotlib] Pyodide initialised'); // 3. Install packages - // - // numpy — pre-built binary in Pyodide's package index. - // traitlets, colorcet — pure-Python; fetched from PyPI via micropip. - // anyplotlib — wheel from _static/wheels/ (same source tree as the docs). - // - // anyplotlib declares anywidget as a dep; anywidget → psygnal may have - // no Pyodide-compatible wheel. Safe strategy: - // a) load numpy + micropip from Pyodide's bundled index (fast), - // b) install traitlets + colorcet via micropip in Python (avoids - // JS-Array → Python-list coercion when calling the PyProxy), - // c) pre-populate sys.modules['anywidget'] with a HasTraits stub so - // micropip never tries to fetch it, - // d) install the anyplotlib wheel with deps=False. - - // a) Pyodide-bundled packages await _step('load numpy', pyodide.loadPackage(['micropip', 'numpy'])); + console.info('[anyplotlib] numpy + micropip loaded'); - // b) Pure-Python deps — run as Python to avoid JS array coercion issues + // b) Pure-Python deps await _step('install traitlets/colorcet', pyodide.runPythonAsync(` import micropip await micropip.install(['traitlets', 'colorcet']) `)); + console.info('[anyplotlib] traitlets + colorcet installed'); - // 4. Stub anywidget BEFORE installing the anyplotlib wheel. - // anyplotlib/figure.py imports anywidget inside a try/except and will - // pick up this stub as _AnyWidgetBase automatically. + // 4. Stub anywidget await _step('stub anywidget', pyodide.runPythonAsync(` import sys, traitlets as _tr @@ -252,23 +239,19 @@ class _AnyWidgetMod: sys.modules['anywidget'] = _AnyWidgetMod() _APL_REGISTRY = {} `)); + console.info('[anyplotlib] anywidget stub installed'); - // c) Install anyplotlib wheel directly via pyodide.loadPackage(url). - // loadPackage accepts wheel URLs and installs them without dependency - // resolution, which sidesteps the micropip "Attempted to install wheel - // before downloading it" bug that is triggered by deps=False on URL - // installs in Pyodide 0.27.x. anywidget is already in sys.modules so - // importing anyplotlib will use our stub. + // c) Install anyplotlib wheel const wheelUrl = _DOCS_ROOT + '_static/wheels/anyplotlib-0.0.0-py3-none-any.whl'; + console.info('[anyplotlib] installing anyplotlib wheel from', wheelUrl); await _step('install anyplotlib wheel', pyodide.loadPackage(wheelUrl)); + console.info('[anyplotlib] anyplotlib installed'); - // 5. Expose window._aplPush so Python can push state into iframes + // 5. Expose window._aplPush window._aplPush = (figId, key, value) => _postToIframe(String(figId), String(key), String(value)); - // 6. Install the push hook — from now on every Figure._push() / - // _push_layout() / _push_widget() call routes through _aplPush - // instead of writing to a Jupyter traitlet. + // 6. Install the push hook await _step('install push hook', pyodide.runPythonAsync(` import anyplotlib.figure as _af @@ -281,6 +264,7 @@ def _push_hook(fig, key, value_str): _af._pyodide_push_hook = _push_hook `)); + console.info('[anyplotlib] push hook installed'); // 7. Collect text/x-python script blocks, group by src-file so each // example source runs exactly once even if it creates multiple figures. @@ -305,12 +289,18 @@ _af._pyodide_push_hook = _push_hook // 8. Run each example source once, tag created figures in creation order, // then push the current Python state into the matching iframes. + const _execErrors = []; for (const [srcFile, { src, pairs }] of srcGroups) { const figIdList = JSON.stringify(pairs.map(p => p.figId)); + console.info(`[anyplotlib] running example: ${srcFile} (${pairs.length} figure(s))`); + // Pre-assign the srcFile name to a Python variable to avoid embedding + // JSON.stringify output (which uses double quotes) inside Python f-strings. + const _srcFileRepr = JSON.stringify(srcFile); try { await pyodide.runPythonAsync(` import anyplotlib.figure as _af +_SRC_FILE = ${_srcFileRepr} _CREATED_FIGS = [] _SEEN_IDS = set() _orig_init = _af.Figure.__init__ @@ -322,14 +312,18 @@ def _tracked_init(self, *a, **kw): _CREATED_FIGS.append(self) _af.Figure.__init__ = _tracked_init +_exec_error = None try: - exec(${JSON.stringify(src)}, {}) + exec(${JSON.stringify(src)}, {"__name__": "__main__"}) except Exception as _e: - print(f"[anyplotlib] exec error in ${JSON.stringify(srcFile)}: {_e}") + import traceback as _tb + _exec_error = _tb.format_exc() + print("[anyplotlib] exec error in " + _SRC_FILE + ":\\n" + _exec_error) finally: _af.Figure.__init__ = _orig_init _fig_ids = ${figIdList} +_wired = 0 for _i, _fid in enumerate(_fig_ids): if _i < len(_CREATED_FIGS): _fig = _CREATED_FIGS[_i] @@ -338,21 +332,41 @@ for _i, _fid in enumerate(_fig_ids): _fig._push_layout() for _pid in list(_fig._plots_map): _fig._push(_pid) + _wired += 1 + +print("[anyplotlib] wired " + str(_wired) + "/" + str(len(_fig_ids)) + " figures for " + _SRC_FILE) +if _exec_error: + raise RuntimeError("exec failed: " + _exec_error) `); } catch (err) { - console.warn(`[anyplotlib] Pyodide failed for ${srcFile}:`, err); + const msg = String(err.message || err); + console.warn(`[anyplotlib] Pyodide failed for ${srcFile}:`, msg); + _execErrors.push(`${srcFile}: ${msg.split('\n').filter(Boolean).pop()}`); } } + // Surface exec errors in the button tooltip so the user can open DevTools + if (_execErrors.length > 0) { + btn.title = 'Python active (some examples failed — open DevTools console for details)\n\n' + + _execErrors.join('\n'); + } + // 9. Route interaction events from iframes → Pyodide callbacks window.addEventListener('message', async (e) => { if (!e.data || e.data.type !== 'apl_event') return; const { figId, data } = e.data; + console.debug('[anyplotlib] apl_event received', figId, + JSON.parse(data || '{}').event_type); + const _figIdRepr = JSON.stringify(figId); + const _dataRepr = JSON.stringify(data); try { await pyodide.runPythonAsync(` -_fig = _APL_REGISTRY.get(${JSON.stringify(figId)}) +_FIG_ID = ${_figIdRepr} +_fig = _APL_REGISTRY.get(_FIG_ID) if _fig is not None: - _fig._dispatch_event(${JSON.stringify(data)}) + _fig._dispatch_event(${_dataRepr}) +else: + print("[anyplotlib] no figure registered for figId=" + repr(_FIG_ID) + "; registry keys: " + str(list(_APL_REGISTRY.keys()))) `); } catch (err) { console.warn('[anyplotlib] event dispatch error:', err); diff --git a/test_sphinx_anywidget.py b/test_sphinx_anywidget.py index 3e1efed..4e4e869 100644 --- a/test_sphinx_anywidget.py +++ b/test_sphinx_anywidget.py @@ -19,9 +19,8 @@ w, h = _widget_px(fig) assert w == 416, f'Expected 416 got {w}' -b = _iframe_html('t.html', 400, 300, fig_id='a', interactive=True, static_icon='P') +b = _iframe_html('t.html', 400, 300, fig_id='a', interactive=True) assert 'awi-activate-btn' in b, 'Missing activate button' -assert 'awi-static-icon' in b, 'Missing static icon' 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' diff --git a/tests/test_pyodide_e2e.py b/tests/test_pyodide_e2e.py index 4bb519b..2378ac1 100644 --- a/tests/test_pyodide_e2e.py +++ b/tests/test_pyodide_e2e.py @@ -298,7 +298,6 @@ def _build_parent_page( overflow:hidden;display:block;">
- 📷 From 69e83207d8f5c110960d820df57f04db55e708ae Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 3 May 2026 14:20:23 -0500 Subject: [PATCH 04/13] feat: enhance Pyodide bridge with event handling and deterministic panel IDs --- .../Interactive/plot_interactive_fitting.py | 2 +- .../Interactive/plot_segment_by_contrast.py | 2 +- anyplotlib/_repr_utils.py | 56 ++++++++++++++++--- anyplotlib/figure.py | 17 +++++- anyplotlib/figure_plots.py | 18 +++++- tests/test_pyodide_e2e.py | 34 +++++------ 6 files changed, 102 insertions(+), 27 deletions(-) diff --git a/Examples/Interactive/plot_interactive_fitting.py b/Examples/Interactive/plot_interactive_fitting.py index 06edc41..ce1988e 100644 --- a/Examples/Interactive/plot_interactive_fitting.py +++ b/Examples/Interactive/plot_interactive_fitting.py @@ -289,4 +289,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_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/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 da4e269..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 diff --git a/tests/test_pyodide_e2e.py b/tests/test_pyodide_e2e.py index 2378ac1..17d1078 100644 --- a/tests/test_pyodide_e2e.py +++ b/tests/test_pyodide_e2e.py @@ -236,26 +236,12 @@ def _build_parent_page( return; }} - // ── install generic anywidget monkey-patch ───────────────────── - // Identified by the '_patched_init' marker in the monkey-patch code. - // Installs window._anywidgetPush so postMessage reaches the iframe. - if (src.includes('_patched_init') || src.includes('_anywidget_fig_id')) {{ - window._APL_BOOT_STEPS.push('install_monkey_patch'); - // Install the real _anywidgetPush — delivers awi_state postMessages. - window._anywidgetPush = function(figId, key, value) {{ - const iframe = document.querySelector('iframe[data-awi-fig="' + figId + '"]'); - if (iframe && iframe.contentWindow) {{ - iframe.contentWindow.postMessage( - {{type: 'awi_state', key: key, value: value}}, '*'); - }} - }}; - return; - }} - // ── exec(example_src) + _push_layout() + _push(panel_id) ──────── // Triggered by the `_fig_ids = …` line that anywidget_bridge.js // wraps around every example exec call. We skip the actual Python // exec and instead push pre-collected state directly. + // NOTE: checked BEFORE the monkey-patch pattern because step 9 in + // anywidget_bridge.js contains both _fig_ids and _anywidget_fig_id. if (src.includes('_fig_ids')) {{ window._APL_BOOT_STEPS.push('run_example'); const fid = window._MOCK_FIG_ID; @@ -269,6 +255,22 @@ def _build_parent_page( return; }} + // ── install generic anywidget monkey-patch ───────────────────── + // Identified by the '_patched_init' marker in the monkey-patch code. + // Installs window._anywidgetPush so postMessage reaches the iframe. + if (src.includes('_patched_init') || src.includes('_anywidget_fig_id')) {{ + window._APL_BOOT_STEPS.push('install_monkey_patch'); + // Install the real _anywidgetPush — delivers awi_state postMessages. + window._anywidgetPush = function(figId, key, value) {{ + const iframe = document.querySelector('iframe[data-awi-fig="' + figId + '"]'); + if (iframe && iframe.contentWindow) {{ + iframe.contentWindow.postMessage( + {{type: 'awi_state', key: key, value: value}}, '*'); + }} + }}; + return; + }} + // Catch-all for any other runPythonAsync call window._APL_BOOT_STEPS.push('runPythonAsync:other'); }} From a03575915d83b0fb8414b44a68f09125611fda21 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 3 May 2026 14:20:34 -0500 Subject: [PATCH 05/13] refactor: remove stale pyodide_bridge.js reference and add anywidget_bridge.js for anyplotlib extension --- docs/conf.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 6cbf57c..71279cc 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -34,6 +34,7 @@ "sphinx.ext.intersphinx", "sphinx_gallery.gen_gallery", "sphinx_design", + "anyplotlib.sphinx_anywidget", ] autosummary_generate = True @@ -82,10 +83,8 @@ html_theme = "pydata_sphinx_theme" html_static_path = ["_static"] html_css_files = ["custom.css"] - -# pyodide_bridge.js adds the "⚡" activation button to gallery pages and boots -# a single shared Pyodide instance for the whole page on click. -html_js_files = ["pyodide_bridge.js"] +# anywidget_bridge.js is injected by the anyplotlib.sphinx_anywidget extension; +# the stale pyodide_bridge.js reference has been removed. html_theme_options = { "github_url": "https://github.com/CSSFrancis/anyplotlib", From 019aa82bc67d06b1887183f9bc76b7b5c2b3fd3b Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 3 May 2026 14:35:18 -0500 Subject: [PATCH 06/13] feat: add support for extracting and loading Pyodide packages in interactive examples --- .../Interactive/plot_interactive_fitting.py | 3 +++ anyplotlib/sphinx_anywidget/_scraper.py | 22 +++++++++++++++++ .../static/anywidget_bridge.js | 24 +++++++++++++++---- 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/Examples/Interactive/plot_interactive_fitting.py b/Examples/Interactive/plot_interactive_fitting.py index ce1988e..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 diff --git a/anyplotlib/sphinx_anywidget/_scraper.py b/anyplotlib/sphinx_anywidget/_scraper.py index 807be2a..71dbf21 100644 --- a/anyplotlib/sphinx_anywidget/_scraper.py +++ b/anyplotlib/sphinx_anywidget/_scraper.py @@ -51,6 +51,11 @@ # Sentinel that marks a code block as interactive. _INTERACTIVE_RE = re.compile(r"#\s*interactive\s*$", re.IGNORECASE | re.MULTILINE) +# Pattern that extracts _PYODIDE_PACKAGES = [...] declarations from source. +_PYODIDE_PACKAGES_RE = re.compile( + r"^_PYODIDE_PACKAGES\s*=\s*(\[[^\]]*\])", re.MULTILINE +) + # --------------------------------------------------------------------------- # Helpers @@ -293,11 +298,28 @@ def __call__(self, block, block_vars, gallery_conf): 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" diff --git a/anyplotlib/sphinx_anywidget/static/anywidget_bridge.js b/anyplotlib/sphinx_anywidget/static/anywidget_bridge.js index 110e16c..51305fe 100644 --- a/anyplotlib/sphinx_anywidget/static/anywidget_bridge.js +++ b/anyplotlib/sphinx_anywidget/static/anywidget_bridge.js @@ -237,7 +237,7 @@ print('[sphinx_anywidget] anywidget monkey-patch installed') // 8. Collect text/x-python script blocks, group by src-file so each // example source runs exactly once even with multiple figures. - const srcGroups = new Map(); // srcFile → { src, pairs: [{figId, figIndex}] } + const srcGroups = new Map(); // srcFile → { src, pairs: [{figId, figIndex}], packages: [] } for (const script of document.querySelectorAll( 'script[type="text/x-python"][data-fig-id]')) { @@ -246,9 +246,14 @@ print('[sphinx_anywidget] anywidget monkey-patch installed') const figIndex = parseInt(script.dataset.figIndex || '0', 10); let src = ''; try { src = JSON.parse(script.dataset.src || 'null') || ''; } catch (_) {} - - if (!srcGroups.has(srcFile)) srcGroups.set(srcFile, { src, pairs: [] }); - srcGroups.get(srcFile).pairs.push({ figId, figIndex }); + let packages = []; + try { packages = JSON.parse(script.dataset.pyodidePackages || 'null') || []; } catch (_) {} + + if (!srcGroups.has(srcFile)) srcGroups.set(srcFile, { src, pairs: [], packages }); + const grp = srcGroups.get(srcFile); + grp.pairs.push({ figId, figIndex }); + // Merge any packages declared by any script tag for this source file. + for (const p of packages) if (!grp.packages.includes(p)) grp.packages.push(p); } for (const g of srcGroups.values()) @@ -257,10 +262,19 @@ print('[sphinx_anywidget] anywidget monkey-patch installed') // 9. Run each example source once, assign _anywidget_fig_id in creation // order, then push current state into the matching iframes. const _execErrors = []; - for (const [srcFile, { src, pairs }] of srcGroups) { + for (const [srcFile, { src, pairs, packages }] of srcGroups) { const figIdList = JSON.stringify(pairs.map(p => p.figId)); console.info(`[sphinx_anywidget] running: ${srcFile} (${pairs.length} figure(s))`); const _srcFileRepr = JSON.stringify(srcFile); + + // Load any extra packages declared by this example (e.g. scipy). + if (packages.length > 0) { + console.info(`[sphinx_anywidget] loading extra packages for ${srcFile}:`, packages); + await _step(`load packages for ${srcFile}`, + pyodide.loadPackage(packages)); + console.info(`[sphinx_anywidget] extra packages loaded for ${srcFile}`); + } + try { await pyodide.runPythonAsync(` import anywidget as _aw From e997da271ef6a2f566de2a9a34264a295bd7eb74 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 3 May 2026 21:02:07 -0500 Subject: [PATCH 07/13] feat: add .gitignore and update conf.py for wheel build optimization --- .github/workflows/docs.yml | 24 ++++++++++++++++++++ .gitignore | 45 ++++++++++++++++++++++++++++++++++++++ docs/conf.py | 26 ++++++++++++++++------ 3 files changed, 88 insertions(+), 7 deletions(-) create mode 100644 .gitignore 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/docs/conf.py b/docs/conf.py index 71279cc..cbfa2bb 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -123,7 +123,13 @@ # pyodide_bridge.js derives the wheel URL from its own script src, which # already carries the version prefix. def setup(app): - """Build the anyplotlib wheel for the in-browser Pyodide bridge.""" + """Build the anyplotlib wheel for the in-browser Pyodide bridge. + + In CI the workflow pre-builds the wheel before sphinx-build runs, so we + skip the build step if ``anyplotlib-0.0.0-py3-none-any.whl`` is already + present. This avoids a redundant ``pip wheel`` invocation and keeps the + local ``make html`` path working without needing ``uv build``. + """ import subprocess import sys from pathlib import Path @@ -131,6 +137,14 @@ def setup(app): wheels_dir = Path(__file__).parent / "_static" / "wheels" wheels_dir.mkdir(parents=True, exist_ok=True) + stable = wheels_dir / "anyplotlib-0.0.0-py3-none-any.whl" + + # If the wheel was already built by the workflow step (or a previous local + # build), reuse it rather than rebuilding. + if stable.exists(): + print(f"[pyodide_bridge] wheel already present → {stable}") + return + # Remove stale wheels from previous builds. for old in wheels_dir.glob("anyplotlib*.whl"): old.unlink(missing_ok=True) @@ -153,13 +167,11 @@ def setup(app): print(f"\n[pyodide_bridge] WARNING: wheel build failed:\n{result.stderr}") return - # Rename to a stable, version-agnostic filename so pyodide_bridge.js can - # reference it without knowing the current version string. - # NOTE: "latest" is NOT a valid PEP 440 version; micropip rejects it. - # "0.0.0" is the simplest valid sentinel that micropip accepts when the - # wheel is installed via URL (no PyPI version-check happens for URL installs). + # Rename to the stable sentinel name micropip accepts for URL installs. + # "0.0.0" is a valid PEP 440 version; micropip skips PyPI checks for + # wheels installed by URL so the actual version number doesn't matter. wheels = sorted(wheels_dir.glob("anyplotlib*.whl")) if wheels: - stable = wheels_dir / "anyplotlib-0.0.0-py3-none-any.whl" stable.unlink(missing_ok=True) wheels[-1].rename(stable) + print(f"[pyodide_bridge] wheel → {stable}") From 4b1e48aba84a38ea7a1970e4a64602ca5086d739 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 3 May 2026 21:35:32 -0500 Subject: [PATCH 08/13] chore: remove committed __pycache__ .pyc files and add them to .gitignore --- .../__pycache__/__init__.cpython-313.pyc | Bin 5337 -> 0 bytes .../__pycache__/_directive.cpython-313.pyc | Bin 6660 -> 0 bytes .../__pycache__/_repr_utils.cpython-313.pyc | Bin 10925 -> 0 bytes .../__pycache__/_scraper.cpython-313.pyc | Bin 12628 -> 0 bytes .../__pycache__/_wheel_builder.cpython-313.pyc | Bin 2659 -> 0 bytes 5 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 anyplotlib/sphinx_anywidget/__pycache__/__init__.cpython-313.pyc delete mode 100644 anyplotlib/sphinx_anywidget/__pycache__/_directive.cpython-313.pyc delete mode 100644 anyplotlib/sphinx_anywidget/__pycache__/_repr_utils.cpython-313.pyc delete mode 100644 anyplotlib/sphinx_anywidget/__pycache__/_scraper.cpython-313.pyc delete mode 100644 anyplotlib/sphinx_anywidget/__pycache__/_wheel_builder.cpython-313.pyc diff --git a/anyplotlib/sphinx_anywidget/__pycache__/__init__.cpython-313.pyc b/anyplotlib/sphinx_anywidget/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index a0cb230ef18f6d8a88ac5895a954a132dcf31ed6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5337 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 DR*7&C diff --git a/anyplotlib/sphinx_anywidget/__pycache__/_directive.cpython-313.pyc b/anyplotlib/sphinx_anywidget/__pycache__/_directive.cpython-313.pyc deleted file mode 100644 index b6836ae5015958abdd903a00d076a9e8ae105acb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6660 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 IC{ywO1?8fSb^rhX diff --git a/anyplotlib/sphinx_anywidget/__pycache__/_repr_utils.cpython-313.pyc b/anyplotlib/sphinx_anywidget/__pycache__/_repr_utils.cpython-313.pyc deleted file mode 100644 index c96fef2deb4bcf4bf3a11856be9fb18032371856..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10925 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 diff --git a/anyplotlib/sphinx_anywidget/__pycache__/_scraper.cpython-313.pyc b/anyplotlib/sphinx_anywidget/__pycache__/_scraper.cpython-313.pyc deleted file mode 100644 index e44f132fc317246125ed6dab7b26e6a9c7829221..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12628 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 diff --git a/anyplotlib/sphinx_anywidget/__pycache__/_wheel_builder.cpython-313.pyc b/anyplotlib/sphinx_anywidget/__pycache__/_wheel_builder.cpython-313.pyc deleted file mode 100644 index e4d7ca526d4f4943b5a8addcfcb8fd5728ee8104..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2659 zcma)8-ESLJ7Qgn4$Ikd8A5KXM{kTd?!P(j_q;%C#c9p7>B_*k`Yq3%UFgNQ2GY9h;!G(rXHLC<6@e#Hiz%0CoDY1yo z2=ssROiHRb^+(~uOvsPxw(W480}@+=xm?hB&5(q&-;&V7JoNA^`2Ay42LEQ`2-#9u zDuxD|_WDv7e$sh}1IYK|^tY5lDO8eY!q4<|)95c_fF_saVq_0xFaoT|`4C16;|RqO zMoY>-QUq2UY*T0{V2xNWCJsswf@nuevC>dUEyb6H2lBEsuw~!cu6_BT;6{n_k{u~W ziX#K9y}p#3T|-DeiqKMeuv$u$Ll?qu(_zp7+Yh!Bnvn;mVzfH4G%?um`@A;LwUx5+ zWM8!kw(YZEv}eJdr^PvceF|(En+XRIjFzLn<@2&qN{f&b4-ahH>&wxB1rU?HTaTBc zaI;zEr1@Z(<)?G1dJTBr z-^GBwwq6$;cW|ZhrmGv*^;M#YwYRfpa`4N#jhU?N*dzQ=0yZ3*>!xkmtDx#O z`g?QSse{LG9u^tlo(rW;I8ZIZgxb5820!o`2KOw2r-K^@3lJ4irPip)IqAptwAeN+ z_Dy4m_lj;>5dNI%D?JBX%Y^-?t3zNp^J8S)WL$HuHzU&`cuv3P^WSvv-el*XjO9u=6 ztN4|<;^K>oFU7Lo+<9E;i5nrYh9jzywh6?7rH>|DorPze4k%a-jcV zOb_MT_ddj#q#vnYhb#H=s^u*Eif3D3tvwev*n6Hv`Vt4^ZV1ZL# zr9u1*r%L>YOCjBSg>iVwDD=Q4QuUQyYGq=8xtoW6Vm}XIwvST28komKzWS151rW@$ zI29FI;0um2QKUsmh~~eVFEhxUyrILHbcO1+VX}OoVbpY5FZ94!evioehX-Tz-;c5% z55m=Ztm#QOSk!8E4&Ip!)IEd7jM#UQYd-uCrJW;7#bP>KAFBf|H;)4 zu5P}yY5(i__Q~0u@n?6E7d9?`6CVYP_Q>47PjAn?+)fqm#7dpv^!?;SYq32&*M9Wk zcJkuJ<&Lbrzwqut>)OV`wmb#18&AJGe^)-VEg!nQaz{RTPabL~GFzv%Cfn-McjTE) zd~{>sA-E4^V)O`fR2F}NsPqx>cWkgGm?MfqGx9%G5Z41$A$QW5XUamovLS%KhZzHRD3Y7IrszbghCI( zN=W{B1cgp~hep3b>epzptDFywbw Date: Mon, 4 May 2026 07:54:54 -0500 Subject: [PATCH 09/13] feat: enhance anywidget integration with improved config handling and dynamic package detection --- Examples/plot_3d.py | 74 ----- Examples/plot_bar.py | 254 ------------------ Examples/plot_image2d.py | 139 ---------- Examples/plot_line_styles.py | 159 ----------- Examples/plot_pcolormesh.py | 65 ----- Examples/plot_spectra1d.py | 112 -------- anyplotlib/sphinx_anywidget/__init__.py | 38 ++- anyplotlib/sphinx_anywidget/_directive.py | 43 ++- anyplotlib/sphinx_anywidget/_scraper.py | 6 +- anyplotlib/sphinx_anywidget/_wheel_builder.py | 10 +- .../static/anywidget_bridge.js | 22 +- docs/_sg_html_scraper.py | 5 +- 12 files changed, 102 insertions(+), 825 deletions(-) delete mode 100644 Examples/plot_3d.py delete mode 100644 Examples/plot_bar.py delete mode 100644 Examples/plot_image2d.py delete mode 100644 Examples/plot_line_styles.py delete mode 100644 Examples/plot_pcolormesh.py delete mode 100644 Examples/plot_spectra1d.py 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/sphinx_anywidget/__init__.py b/anyplotlib/sphinx_anywidget/__init__.py index 7f3cd0c..eea5ea5 100644 --- a/anyplotlib/sphinx_anywidget/__init__.py +++ b/anyplotlib/sphinx_anywidget/__init__.py @@ -80,28 +80,54 @@ def _build_pyodide_wheel(app): 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 - conf_dir = Path(app.confdir) - static_dir = conf_dir / "_static" - static_dir.mkdir(parents=True, exist_ok=True) - # Write a tiny config script so anywidget_bridge.js can find the package # name without fragile heuristics. Loaded before anywidget_bridge.js. - import json as _json 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 = conf_dir.parent + 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: diff --git a/anyplotlib/sphinx_anywidget/_directive.py b/anyplotlib/sphinx_anywidget/_directive.py index 2be636c..e8a5ed4 100644 --- a/anyplotlib/sphinx_anywidget/_directive.py +++ b/anyplotlib/sphinx_anywidget/_directive.py @@ -28,12 +28,17 @@ 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 @@ -68,7 +73,7 @@ def run(self): # ── options ────────────────────────────────────────────────────── is_interactive = "interactive" in self.options - max_width = self.options.get("width", 684) + max_width = self.options.get("width", None) # None → use _iframe_html default # ── execute the script to get the widget ───────────────────────── try: @@ -95,11 +100,15 @@ def run(self): ) from anyplotlib.sphinx_anywidget._scraper import _iframe_html - # Use a stable ID based on the source file name - stem = src_path.stem - fig_id = f"rst_{stem}" + # 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}" - docs_static = Path(env.app.outdir).parent / "_static" / "viewer_widgets" + # 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 @@ -112,19 +121,21 @@ def run(self): # Compute relative path from the current RST file's output dir # to _static/viewer_widgets/ try: - out_dir = Path(env.app.outdir) doc_name = env.docname # e.g. "getting_started" - page_out = out_dir / (doc_name + ".html") 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" @@ -138,11 +149,29 @@ def run(self): 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" diff --git a/anyplotlib/sphinx_anywidget/_scraper.py b/anyplotlib/sphinx_anywidget/_scraper.py index 71dbf21..c17fa7e 100644 --- a/anyplotlib/sphinx_anywidget/_scraper.py +++ b/anyplotlib/sphinx_anywidget/_scraper.py @@ -133,6 +133,7 @@ def _iframe_html( h: int, fig_id: str | None = None, interactive: bool = False, + max_width: int | None = None, ) -> str: """Return a single-line HTML snippet embedding *src* responsively. @@ -146,10 +147,13 @@ def _iframe_html( Stable identifier; used as the ``data-awi-fig`` attribute. interactive : bool When True, renders the ⚡ activation badge. + max_width : int or None + Override the default ``MAX_DOC_WIDTH`` cap (pixels). """ uid = fig_id or f"f{uuid4().hex[:8]}" + cap = max_width if max_width is not None else MAX_DOC_WIDTH - init_scale = min(1.0, MAX_DOC_WIDTH / w) + init_scale = min(1.0, cap / w) init_w = round(w * init_scale) init_h = round(h * init_scale) scale_css = f"{init_scale:.6f}".rstrip("0").rstrip(".") diff --git a/anyplotlib/sphinx_anywidget/_wheel_builder.py b/anyplotlib/sphinx_anywidget/_wheel_builder.py index f3847a2..bc22bce 100644 --- a/anyplotlib/sphinx_anywidget/_wheel_builder.py +++ b/anyplotlib/sphinx_anywidget/_wheel_builder.py @@ -8,6 +8,7 @@ from __future__ import annotations +import re import subprocess import sys from pathlib import Path @@ -40,7 +41,10 @@ def build_wheel( wheels_dir = static_dir / "wheels" wheels_dir.mkdir(parents=True, exist_ok=True) - for old in wheels_dir.glob(f"{package_name}*.whl"): + # 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( @@ -61,12 +65,12 @@ def build_wheel( ) return None - wheels = sorted(wheels_dir.glob(f"{package_name}*.whl")) + 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"{package_name}-0.0.0-py3-none-any.whl" + 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}") diff --git a/anyplotlib/sphinx_anywidget/static/anywidget_bridge.js b/anyplotlib/sphinx_anywidget/static/anywidget_bridge.js index 51305fe..4ebfb6e 100644 --- a/anyplotlib/sphinx_anywidget/static/anywidget_bridge.js +++ b/anyplotlib/sphinx_anywidget/static/anywidget_bridge.js @@ -62,10 +62,20 @@ return document.querySelector('button.awi-activate-btn') !== null; } - /** Deliver a state-update message into the iframe for figId. */ - function _postToIframe(figId, key, value) { + /** Deliver a state-update message into the iframe for figId. + * + * rawValue is always a JSON-encoded string (Python serialises every trait + * with json.dumps so numeric/boolean/object traits are not type-erased when + * the iframe receives them). + */ + function _postToIframe(figId, key, rawValue) { const iframe = document.querySelector(`iframe[data-awi-fig="${figId}"]`); if (iframe && iframe.contentWindow) { + // JSON.parse recovers the real JS type (number, bool, array, object …). + // Plain Python strings are also JSON-encoded (quoted), so they round-trip + // correctly too. + let value = rawValue; + try { value = JSON.parse(rawValue); } catch (_) {} iframe.contentWindow.postMessage({ type: 'awi_state', key, value }, '*'); } } @@ -225,7 +235,9 @@ def _patched_init(self, *args, **kw): return import json as _j val = change['new'] - val_str = val if isinstance(val, str) else _j.dumps(val, default=str) + # Always JSON-encode so the JS bridge can JSON.parse to recover the + # correct type — strings, numbers, bools and objects all round-trip. + val_str = _j.dumps(val, default=str) js.window._anywidgetPush(fid, tname, val_str) self.observe(_push_cb, names=_tr.All) @@ -317,7 +329,9 @@ for _i, _fid in enumerate(_fig_ids): _val = getattr(_w, _tname, None) if _val is None: continue - _vs = _val if isinstance(_val, str) else _jj.dumps(_val, default=str) + # Always JSON-encode (matching the live observer above) so the + # JS bridge can JSON.parse to recover the correct type. + _vs = _jj.dumps(_val, default=str) import js as _js _js.window._anywidgetPush(_fid, _tname, _vs) _wired += 1 diff --git a/docs/_sg_html_scraper.py b/docs/_sg_html_scraper.py index fc7dd64..fe9c43e 100644 --- a/docs/_sg_html_scraper.py +++ b/docs/_sg_html_scraper.py @@ -9,10 +9,13 @@ from _sg_html_scraper import ViewerScraper -continues to work without changes. +continues to work without changes. All public helpers that existed in the +original module are re-exported here so downstream imports keep working. """ from anyplotlib.sphinx_anywidget._scraper import ( # noqa: F401 AnywidgetScraper, AnywidgetScraper as ViewerScraper, + _make_thumbnail_png, + _iframe_html, ) From 25921c6b7419d3243fa1ceac777c46c467b100be Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 13:09:30 +0000 Subject: [PATCH 10/13] test: move root-level test scripts into tests/ as proper pytest functions Agent-Logs-Url: https://github.com/CSSFrancis/anyplotlib/sessions/5e87efc9-9236-4c2f-abda-66cedd64e096 Co-authored-by: CSSFrancis <41125831+CSSFrancis@users.noreply.github.com> --- _test_scraper.py | 39 -------------- test_sphinx_anywidget.py | 43 ---------------- tests/test_scraper.py | 66 ++++++++++++++++++++++++ tests/test_sphinx_anywidget.py | 92 ++++++++++++++++++++++++++++++++++ 4 files changed, 158 insertions(+), 82 deletions(-) delete mode 100644 _test_scraper.py delete mode 100644 test_sphinx_anywidget.py create mode 100644 tests/test_scraper.py create mode 100644 tests/test_sphinx_anywidget.py diff --git a/_test_scraper.py b/_test_scraper.py deleted file mode 100644 index a22d4a8..0000000 --- a/_test_scraper.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Quick end-to-end test for the Playwright-based scraper thumbnail.""" -import sys -sys.path.insert(0, 'docs') -sys.path.insert(0, 'tests') - -import numpy as np -import anyplotlib as apl -from _sg_html_scraper import _make_thumbnail_png -from _png_utils import decode_png - -tests = [] - -# 1D line plot -fig, ax = apl.subplots(1, 1, figsize=(400, 250)) -ax.plot(np.sin(np.linspace(0, 2 * np.pi, 128)), color='#4fc3f7') -tests.append(("1D line", fig)) - -# 2D image -fig2, ax2 = apl.subplots(1, 1, figsize=(320, 320)) -data = np.linspace(0, 1, 64 * 64, dtype=np.float32).reshape(64, 64) -ax2.imshow(data) -tests.append(("2D imshow", fig2)) - -# multi-panel -fig3, 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)) -tests.append(("multi-panel", fig3)) - -for name, widget in tests: - png = _make_thumbnail_png(widget) - assert png[:4] == b'\x89PNG', f"[{name}] result is not a PNG!" - arr = decode_png(png) - r, g, b = arr[0, 0, 0], arr[0, 0, 1], arr[0, 0, 2] - dark_ok = (b > r) and (b > 30) - print(f"[{name}] shape={arr.shape} top-left RGB=({r},{g},{b}) {'DARK OK' if dark_ok else 'THEME CHECK NEEDED'}") - -print("\nAll tests passed.") - diff --git a/test_sphinx_anywidget.py b/test_sphinx_anywidget.py deleted file mode 100644 index 4e4e869..0000000 --- a/test_sphinx_anywidget.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Quick smoke test for sphinx_anywidget extension.""" -from anyplotlib.sphinx_anywidget import AnywidgetScraper, ViewerScraper, setup -from anyplotlib.sphinx_anywidget._scraper import _find_widget, _iframe_html -from anyplotlib.sphinx_anywidget._repr_utils import build_standalone_html, _widget_px -from anyplotlib.sphinx_anywidget._wheel_builder import build_wheel -from anyplotlib.sphinx_anywidget._directive import AnywidgetFigureDirective -print('imports OK') - -import numpy as np -import anyplotlib as apl - -fig, ax = apl.subplots(1, 1, figsize=(400, 300)) -ax.plot(np.sin(np.linspace(0, 6.28, 64))) - -html = build_standalone_html(fig, resizable=False, fig_id='tf') -assert 'awi_state' in html, 'Missing awi_state listener' -assert '"tf"' in html, 'Missing fig_id in HTML' - -w, h = _widget_px(fig) -assert w == 416, f'Expected 416 got {w}' - -b = _iframe_html('t.html', 400, 300, fig_id='a', interactive=True) -assert 'awi-activate-btn' in b, 'Missing activate button' - -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' - -import anyplotlib.figure as _af -assert not hasattr(_af, '_pyodide_push_hook'), '_pyodide_push_hook should be gone' - -# Test _find_widget -found = _find_widget({'fig': fig, 'x': 42}) -assert found is fig, 'Should find Figure' -assert _find_widget({'x': 42}) is None - -# Test # Interactive detection -from anyplotlib.sphinx_anywidget._scraper import _INTERACTIVE_RE -assert _INTERACTIVE_RE.search('fig # Interactive\n'), 'Should match' -assert _INTERACTIVE_RE.search('fig # interactive'), 'Should match lowercase' -assert not _INTERACTIVE_RE.search('fig # not a match'), 'Should not match' - -print('ALL SMOKE TESTS PASSED') - 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" From ec1cba731500bd21ba284e49503acf4d84b2cc26 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 4 May 2026 08:15:28 -0500 Subject: [PATCH 11/13] Documentation: Added Changelog entry. --- upcoming_changes/9.new_feature.rst | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 upcoming_changes/9.new_feature.rst 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. From 1f00dc1a06f260a0f35754bdaccc4b05b568369c Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 4 May 2026 08:39:55 -0500 Subject: [PATCH 12/13] Testing: add docutils and sphinx as dependencies for documentation support --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index e7c21a0..4c8c3cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,10 +36,12 @@ jupyter = [ [dependency-groups] dev = [ + "docutils>=0.19", "playwright>=1.58.0", "pytest>=9.0.2", "pytest-cov>=5.0.0", "scipy>=1.15.3", + "sphinx>=8.0", "towncrier>=24.0.0", ] From 5670be19abcf2e77593a10b401d4bfb78f63b595 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Tue, 5 May 2026 10:55:13 -0500 Subject: [PATCH 13/13] feat: enhance interactive 3D spectral viewer with draggable crosshair and energy-span filter --- .../Interactive/plot_3d_spectral_viewer.py | 225 ++++++++++++ anyplotlib/sphinx_anywidget/_directive.py | 48 ++- anyplotlib/sphinx_anywidget/_scraper.py | 31 +- docs/dev/index.rst | 332 ++++++++++++++++++ docs/index.rst | 37 ++ 5 files changed, 664 insertions(+), 9 deletions(-) create mode 100644 Examples/Interactive/plot_3d_spectral_viewer.py create mode 100644 docs/dev/index.rst diff --git a/Examples/Interactive/plot_3d_spectral_viewer.py b/Examples/Interactive/plot_3d_spectral_viewer.py new file mode 100644 index 0000000..82a17e0 --- /dev/null +++ b/Examples/Interactive/plot_3d_spectral_viewer.py @@ -0,0 +1,225 @@ +""" +Interactive 3D Spectral Viewer +============================== + +A side-by-side viewer for a 3-D ``(y, x, energy)`` dataset. + +* **Left panel** — 2-D projection image (sum over the energy axis). + A draggable crosshair ROI selects the pixel whose spectrum appears on + the right. Press **i** to switch to an 8 × 8-pixel rectangle ROI + that integrates the enclosed area; press **i** again to revert. +* **Right panel** — 1-D spectrum extracted at the current ROI. Press + **s** to overlay an energy-span widget; on release the 2-D image + recomputes as the sum over the selected energy window. Press **s** + again to remove the span and restore the full-sum image. + +**Key bindings** + +.. list-table:: + :header-rows: 1 + :widths: 10 10 80 + + * - Panel + - Key + - Action + * - Image + - ``i`` + - Toggle crosshair / 8x8-px rectangle ROI. + Rectangle snaps to the pixel grid and integrates the spectrum live. + Press again to revert. + * - Spectrum + - ``s`` + - Add/remove an energy-span filter. + The 2-D image updates on release to show the sum over the selected + energy window. Press again to restore the full-sum image. + * - Both + - ``r`` + - Reset zoom / pan. +""" + +import numpy as np +import anyplotlib as vw + +# ── Synthetic (NY, NX, NE) dataset ───────────────────────────────────────── +rng = np.random.default_rng(7) + +NY, NX, NE = 64, 64, 256 +energy = np.linspace(100, 900, NE) # physical energy axis (eV) + +yy, xx = np.mgrid[0:NY, 0:NX] # spatial index grids + + +def _gauss2d(cx, cy, sigma): + return np.exp(-((xx - cx) ** 2 + (yy - cy) ** 2) / (2 * sigma ** 2)) + + +def _gauss1d(e, mu, sigma): + return np.exp(-0.5 * ((e - mu) / sigma) ** 2) + + +# Three Gaussian peaks with spatially-varying amplitudes +_peaks = [ + dict(e_mu=280.0, e_sig=18.0, cx=18, cy=18, sig2d=14), + dict(e_mu=500.0, e_sig=22.0, cx=46, cy=20, sig2d=13), + dict(e_mu=710.0, e_sig=28.0, cx=32, cy=48, sig2d=16), +] + +data = np.zeros((NY, NX, NE), dtype=np.float32) +for _p in _peaks: + _amp = _gauss2d(_p["cx"], _p["cy"], _p["sig2d"]) # (NY, NX) + _sp = _gauss1d(energy, _p["e_mu"], _p["e_sig"]) # (NE,) + data += (_amp[:, :, np.newaxis] * _sp[np.newaxis, np.newaxis, :]).astype(np.float32) + +data += rng.normal(scale=0.02, size=data.shape).astype(np.float32) + +img_full = data.sum(axis=-1).astype(float) # full-energy projection (NY, NX) + +# Initial ROI centre +CX0, CY0 = NX // 2, NY // 2 + +# ── Figure layout ─────────────────────────────────────────────────────────── +fig, (ax_img, ax_spec) = vw.subplots( + 1, 2, + figsize=(950, 460), + help=( + "Image — drag crosshair to pick a spectrum\n" + " — press i: toggle crosshair / 8×8 rectangle ROI\n" + "Spectrum — press s: add/remove energy-span filter" + ), +) + +# ── Left: 2-D projection image ────────────────────────────────────────────── +v_img = ax_img.imshow(img_full) +v_img.set_colormap("viridis") + +# ── Right: 1-D spectrum at initial position ───────────────────────────────── +v_spec = ax_spec.plot( + data[CY0, CX0, :].astype(float), + axes=[energy], + units="eV", + y_units="Intensity (a.u.)", + color="#4fc3f7", + linewidth=1.5, +) + +# ── Shared state (lists so closures can mutate them) ──────────────────────── +wid = [None] # active 2-D ROI widget +mode = ["crosshair"] # "crosshair" or "rectangle" +span_wid = [None] # active energy-span widget (or None) +_syncing = [False] # echo-loop guard for rectangle snap + +ROI_PX = 8 # rectangle ROI fixed size (pixels) + + +# ── Helpers ───────────────────────────────────────────────────────────────── + +def _snap_rect(x_raw, y_raw): + """Snap top-left corner to the nearest integer pixel, clamped to bounds.""" + x0 = int(np.clip(round(float(x_raw)), 0, NX - ROI_PX)) + y0 = int(np.clip(round(float(y_raw)), 0, NY - ROI_PX)) + return x0, y0 + + +def _wire_crosshair(w): + """Register on_changed: update spectrum on every drag frame.""" + @w.on_changed + def _ch_moved(event): + cx = int(np.clip(round(event.data.get("cx", CX0)), 0, NX - 1)) + cy = int(np.clip(round(event.data.get("cy", CY0)), 0, NY - 1)) + v_spec.set_data(data[cy, cx, :].astype(float), x_axis=energy) + + +def _wire_rectangle(w): + """Register on_changed: snap widget to grid, integrate 8×8 region live.""" + @w.on_changed + def _rect_moved(event): + if _syncing[0]: + return + _syncing[0] = True + try: + x0, y0 = _snap_rect( + event.data.get("x", CX0 - ROI_PX // 2), + event.data.get("y", CY0 - ROI_PX // 2), + ) + # Push snapped, fixed-size position back so the widget visually + # snaps to the pixel grid and stays exactly 8×8. + w.set(x=float(x0), y=float(y0), w=float(ROI_PX), h=float(ROI_PX)) + spec = data[y0:y0 + ROI_PX, x0:x0 + ROI_PX, :].mean(axis=(0, 1)) + v_spec.set_data(spec.astype(float), x_axis=energy) + finally: + _syncing[0] = False + + +# ── Install initial crosshair ──────────────────────────────────────────────── +wid[0] = v_img.add_widget( + "crosshair", + cx=float(CX0), cy=float(CY0), + color="#69f0ae", +) +_wire_crosshair(wid[0]) + + +# ── "i" — toggle crosshair ↔ 8×8 rectangle ───────────────────────────────── +@v_img.on_key('i') +def _toggle_roi(event): + cur = wid[0] + v_img.remove_widget(cur) # remove old widget (Python ref still valid) + + if mode[0] == "crosshair": + # Preserve crosshair centre as rectangle anchor + cx_cur = float(cur.get("cx", CX0)) + cy_cur = float(cur.get("cy", CY0)) + x0, y0 = _snap_rect(cx_cur - ROI_PX / 2, cy_cur - ROI_PX / 2) + new_w = v_img.add_widget( + "rectangle", + x=float(x0), y=float(y0), + w=float(ROI_PX), h=float(ROI_PX), + color="#ffeb3b", + ) + _wire_rectangle(new_w) + wid[0] = new_w + mode[0] = "rectangle" + else: + # Restore crosshair at centre of old rectangle + rx = float(cur.get("x", CX0 - ROI_PX // 2)) + ry = float(cur.get("y", CY0 - ROI_PX // 2)) + cx_cur = rx + ROI_PX / 2 + cy_cur = ry + ROI_PX / 2 + new_w = v_img.add_widget( + "crosshair", + cx=float(np.clip(cx_cur, 0, NX - 1)), + cy=float(np.clip(cy_cur, 0, NY - 1)), + color="#69f0ae", + ) + _wire_crosshair(new_w) + wid[0] = new_w + mode[0] = "crosshair" + + +# ── "s" (spectrum panel) — add / remove energy-span filter ────────────────── +@v_spec.on_key('s') +def _toggle_span(event): + if span_wid[0] is None: + # Place span at 35 %–65 % of the energy range by default + e0 = float(energy[int(NE * 0.35)]) + e1 = float(energy[int(NE * 0.65)]) + sw = v_spec.add_range_widget(x0=e0, x1=e1, color="#ff7043") + span_wid[0] = sw + + @sw.on_release + def _span_released(ev): + x0_e = ev.data.get("x0", float(energy[0])) + x1_e = ev.data.get("x1", float(energy[-1])) + if x0_e > x1_e: + x0_e, x1_e = x1_e, x0_e + mask = (energy >= x0_e) & (energy <= x1_e) + new_img = data[..., mask].sum(axis=-1).astype(float) if mask.any() else img_full + v_img.set_data(new_img) + else: + v_spec.remove_widget(span_wid[0]) + span_wid[0] = None + v_img.set_data(img_full) # restore full-energy projection + + +fig # Interactive + diff --git a/anyplotlib/sphinx_anywidget/_directive.py b/anyplotlib/sphinx_anywidget/_directive.py index e8a5ed4..b0c34c4 100644 --- a/anyplotlib/sphinx_anywidget/_directive.py +++ b/anyplotlib/sphinx_anywidget/_directive.py @@ -51,7 +51,8 @@ class AnywidgetFigureDirective(Directive): has_content = False option_spec = { "interactive": directives.flag, - "width": directives.nonnegative_int, + "width": directives.unchanged, # e.g. "684", "100%", "80%" + "height": directives.unchanged, # e.g. "400px", "400" } def run(self): @@ -60,7 +61,13 @@ def run(self): # ── resolve the source file path ───────────────────────────────── src_arg = self.arguments[0] - conf_dir = Path(env.confdir) + # env.confdir was removed in Sphinx 9; fall back to env.app.confdir + # then env.srcdir so the directive works on all supported versions. + conf_dir = Path( + getattr(env, "confdir", None) + or getattr(env.app, "confdir", None) + or env.srcdir + ) src_path = (conf_dir / src_arg).resolve() if not src_path.exists(): @@ -73,7 +80,30 @@ def run(self): # ── options ────────────────────────────────────────────────────── is_interactive = "interactive" in self.options - max_width = self.options.get("width", None) # None → use _iframe_html default + + # :width: accepts "684" (pixels) or "100%" / "80%" (percentage). + # A percentage means "use the full container width" — we pass + # max_width=None so _iframe_html uses its default MAX_DOC_WIDTH cap + # and the CSS wrapper fills the container via its inline-block rule. + max_width = None + raw_width = self.options.get("width", None) + if raw_width is not None: + raw_width = str(raw_width).strip() + if not raw_width.endswith("%"): + try: + max_width = int(raw_width.replace("px", "").strip()) + except (ValueError, TypeError): + max_width = None + # else: percentage → leave max_width=None (full-width default) + + # :height: accepts "400px" or "400". + max_height = None + raw_height = self.options.get("height", None) + if raw_height is not None: + try: + max_height = int(str(raw_height).lower().replace("px", "").strip()) + except (ValueError, TypeError): + max_height = None # ── execute the script to get the widget ───────────────────────── try: @@ -119,10 +149,14 @@ def run(self): w, h = _widget_px(widget) # Compute relative path from the current RST file's output dir - # to _static/viewer_widgets/ + # to _static/viewer_widgets/. + # doc_name is the docname without extension, e.g. "index" or "dev/index". + # The output HTML sits at {out_dir}/{doc_name}.html, so the number of + # "../" hops needed to reach {out_dir}/_static/ equals the depth of the + # *directory* part of the docname (not the filename itself). try: - doc_name = env.docname # e.g. "getting_started" - rel_depth = len(Path(doc_name).parts) # depth from out root + doc_name = env.docname # e.g. "index" or "dev/index" + rel_depth = len(Path(doc_name).parent.parts) # parent dirs only prefix = "../" * rel_depth except Exception: prefix = "" @@ -131,6 +165,8 @@ def run(self): iframe_kw = {} if max_width is not None: iframe_kw["max_width"] = max_width + if max_height is not None: + iframe_kw["max_height"] = max_height iframe_block = _iframe_html( src_url, w, h, fig_id=fig_id, diff --git a/anyplotlib/sphinx_anywidget/_scraper.py b/anyplotlib/sphinx_anywidget/_scraper.py index c17fa7e..ce75db6 100644 --- a/anyplotlib/sphinx_anywidget/_scraper.py +++ b/anyplotlib/sphinx_anywidget/_scraper.py @@ -85,7 +85,6 @@ def _find_widget(globals_dict: dict): def _make_thumbnail_png(widget) -> bytes: """Render *widget* in headless Chromium and return a dark-theme PNG screenshot.""" - from playwright.sync_api import sync_playwright from anyplotlib.sphinx_anywidget._repr_utils import build_standalone_html html = build_standalone_html(widget, resizable=False) @@ -101,7 +100,9 @@ def _make_thumbnail_png(widget) -> bytes: fh.write(html) tmp_path = Path(fh.name) - try: + def _run_playwright(tmp_path: Path) -> bytes: + from playwright.sync_api import sync_playwright + with sync_playwright() as pw: browser = pw.chromium.launch( headless=True, args=["--no-sandbox", "--disable-setuid-sandbox"] @@ -117,10 +118,28 @@ def _make_thumbnail_png(widget) -> bytes: "() => new Promise(r =>" " requestAnimationFrame(() => requestAnimationFrame(r)))" ) - png_bytes = page.locator("#widget-root").screenshot() + return page.locator("#widget-root").screenshot() finally: page.close() browser.close() + + try: + import asyncio + import concurrent.futures + + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + + if loop is not None and loop.is_running(): + # Playwright sync API cannot be used inside a running asyncio loop. + # Run it in a separate thread where there is no event loop. + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + future = pool.submit(_run_playwright, tmp_path) + png_bytes = future.result() + else: + png_bytes = _run_playwright(tmp_path) finally: tmp_path.unlink(missing_ok=True) @@ -134,6 +153,7 @@ def _iframe_html( fig_id: str | None = None, interactive: bool = False, max_width: int | None = None, + max_height: int | None = None, ) -> str: """Return a single-line HTML snippet embedding *src* responsively. @@ -149,11 +169,16 @@ def _iframe_html( When True, renders the ⚡ activation badge. max_width : int or None Override the default ``MAX_DOC_WIDTH`` cap (pixels). + max_height : int or None + Maximum display height in pixels. When provided, the scale factor is + also constrained so the rendered iframe never exceeds this height. """ uid = fig_id or f"f{uuid4().hex[:8]}" cap = max_width if max_width is not None else MAX_DOC_WIDTH init_scale = min(1.0, cap / w) + if max_height is not None and h > 0: + init_scale = min(init_scale, max_height / h) init_w = round(w * init_scale) init_h = round(h * init_scale) scale_css = f"{init_scale:.6f}".rstrip("0").rstrip(".") diff --git a/docs/dev/index.rst b/docs/dev/index.rst new file mode 100644 index 0000000..ebfb987 --- /dev/null +++ b/docs/dev/index.rst @@ -0,0 +1,332 @@ +======================= +Developer Documentation +======================= + +This guide covers everything you need to contribute to anyplotlib — +from setting up your environment to writing documentation and interactive +gallery examples. + +.. contents:: On this page + :local: + :depth: 2 + +---- + +Environment Setup +================= + +anyplotlib uses `uv `_ for dependency +management. + +.. code-block:: bash + + # Clone and install all dev dependencies + git clone https://github.com/CSSFrancis/anyplotlib.git + cd anyplotlib + uv sync + + # Run the full test suite + uv run pytest tests/ + + # Quick smoke tests (no pytest overhead) + uv run python test_figure.py + uv run python test_pcolormesh.py + +The ``dev`` dependency group (declared in ``pyproject.toml``) pulls in +``pytest``, ``playwright``, ``sphinx``, ``docutils``, and other tools +needed for both tests and docs builds. + +---- + +Architecture Overview +===================== + +The library is split into a small number of focused modules. + +.. list-table:: + :header-rows: 1 + :widths: 25 75 + + * - File + - Purpose + * - ``figure.py`` + - ``Figure`` — the only ``anywidget.AnyWidget`` subclass. + Owns all traitlets and is the Python ↔ JS bridge. + * - ``figure_plots.py`` + - ``Plot2D``, ``Plot1D``, ``PlotMesh``, ``Plot3D``, ``Axes``, + ``GridSpec``, ``subplots()``. Plain Python classes — *no* traitlets. + * - ``figure_esm.js`` + - Pure-JS canvas renderer (≈ 4 000 lines). + * - ``markers.py`` + - Static visual overlays (circles, arrows, lines, etc.). + * - ``widgets.py`` + - Interactive draggable overlays (``RectangleWidget``, + ``CrosshairWidget``, etc.). + * - ``callbacks.py`` + - Multi-tier event system (``on_change`` / ``on_release``). + * - ``sphinx_anywidget/`` + - Sphinx extension for interactive docs via Pyodide. + +**Python → JS flow:** ``plot._push()`` → ``figure._push(panel_id)`` → +serialises ``_state`` to JSON → writes to the dynamic traitlet +``panel_{id}_json`` (``sync=True``) → JS observer re-renders. + +**JS → Python flow:** JS writes back to ``panel_{id}_json`` after a drag → +Python observer calls ``Widget._update_from_js()`` and fires callbacks. + +---- + +Running & Writing Tests +======================= + +Tests live in ``tests/`` + +Run the full suite:: + + uv run pytest tests/ + +Run a specific module:: + + uv run pytest tests/test_sphinx_anywidget.py -v + +The Playwright end-to-end tests (``test_pyodide_e2e.py``) require the +Playwright browsers. Install them once with:: + + uv run playwright install chromium + +---- + +Writing Sphinx Documentation +============================= + +The docs are built with `Sphinx `_ using the +`pydata-sphinx-theme `_. + +.. code-block:: bash + + # Build HTML docs (outputs to build/html/) + make html + + # Wipe build artefacts and rebuild from scratch + make clean && make html + +The conf.py lives at ``docs/conf.py`` and already registers these +extensions: + +* ``sphinx.ext.autodoc`` / ``autosummary`` — API reference from docstrings. +* ``sphinx_gallery.gen_gallery`` — auto-generates the Examples gallery. +* ``anyplotlib.sphinx_anywidget`` — interactive Pyodide figures. +* ``sphinx_design`` — grid cards used on the index page. + +Adding a new RST page +--------------------- + +1. Create ``docs/my_page.rst``. +2. Add it to the ``toctree`` in ``docs/index.rst``:: + + .. toctree:: + :hidden: + :maxdepth: 2 + + my_page + +Embedding a static figure in RST +--------------------------------- + +Use the ``.. anywidget-figure::`` directive to embed an anyplotlib figure +directly from a Python script, without Sphinx Gallery:: + + .. anywidget-figure:: ../Examples/PlotTypes/plot_image2d.py + +The directive executes the script, captures the widget, renders it as a +self-contained iframe, and embeds it in the page. + +Embedding an interactive figure in RST +---------------------------------------- + +Add the ``:interactive:`` flag to enable the ⚡ Pyodide activation badge:: + + .. anywidget-figure:: ../Examples/PlotTypes/plot_image2d.py + :interactive: + +When a reader clicks the badge, Pyodide boots in the browser, installs the +anyplotlib wheel that was built at docs-build time, re-executes the script, +and re-wires all live callbacks — no server required. + +You can also control the display width:: + + .. anywidget-figure:: ../Examples/PlotTypes/plot_image2d.py + :interactive: + :width: 500 + +Declaring extra Pyodide packages +--------------------------------- + +If your example script needs additional pure-Python packages available in +Pyodide, declare them at the top of the file:: + + _PYODIDE_PACKAGES = ["scipy", "scikit-image"] + +The ``sphinx_anywidget`` extension (and the Sphinx Gallery scraper) detect +this list automatically and pass it to ``micropip`` before executing the +example. + +---- + +Writing Sphinx Gallery Examples +================================ + +All gallery examples live under ``Examples/`` and are picked up by +Sphinx Gallery. Sub-directories become gallery sections. + +.. code-block:: text + + Examples/ + README.rst ← gallery landing-page text + PlotTypes/ ← "Plot Types" section + README.rst + plot_image2d.py + plot_spectra1d.py + ... + Interactive/ + Markers/ + Widgets/ + Benchmarks/ + +Naming rules +------------ + +* Files **must** be named ``plot_*.py`` — Sphinx Gallery ignores anything + else (controlled by ``filename_pattern = r"/plot_"`` in ``conf.py``). +* Each sub-directory needs a ``README.rst`` for the section heading. + +Docstring structure +------------------- + +Every example file must start with a module-level docstring. Sphinx +Gallery uses the first heading as the gallery card title:: + + """ + My Example Title + ================ + + A short description shown in the gallery card. Can span multiple + paragraphs and use any RST. + """ + + import numpy as np + import anyplotlib as apl + + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.plot(np.sin(np.linspace(0, 6, 200))) + fig + +Sectioning code with ``# %%`` +------------------------------ + +Split an example into multiple narrative sections using ``# %%`` comments. +Everything after ``# %%`` up to the next ``# %%`` (or end of file) is a +separate code block with its own prose cell:: + + # %% + # Adjusting the colour map + # ------------------------- + # :meth:`~anyplotlib.figure_plots.Plot2D.set_colormap` switches the palette. + + v.set_colormap("viridis") + fig + +Making a gallery figure interactive +------------------------------------ + +To enable the ⚡ Pyodide activation badge on a gallery figure, end the +code block that produces the widget with a ``# Interactive`` comment +(case-insensitive):: + + fig, ax = apl.subplots(1, 1, figsize=(640, 400)) + ax.imshow(data, cmap="inferno") + fig # Interactive + +The ``AnywidgetScraper`` (registered in ``conf.py`` as an +``image_scrapers`` entry) detects the comment and: + +1. Embeds the full example source in a ``