Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
48c2719
Reuse one PNG-harvest listener across embedded pages
CSSFrancis Sep 11, 2026
2d2caca
Add the navigated-embed runtime and a binary image setter
CSSFrancis Sep 11, 2026
bf14b52
Build navigated pages from Python
CSSFrancis Sep 11, 2026
5fb2b60
Test the navigated page end to end
CSSFrancis Sep 11, 2026
212feec
Document navigated pages
CSSFrancis Sep 11, 2026
e89f7a2
Coalesce a detector drag onto one animation frame
CSSFrancis Sep 11, 2026
3024d37
Tidy the navigated-page seams
CSSFrancis Sep 11, 2026
8b19e54
Make the block dtype table say what it is
CSSFrancis Sep 11, 2026
62fc9bd
Measure the push cost with a clock that can resolve it
CSSFrancis Sep 11, 2026
120ff42
Reword the harvest listener's opening comment
CSSFrancis Sep 11, 2026
ad6a5c7
Fix the changelog fragment's grammar
CSSFrancis Sep 11, 2026
953bfd2
Name the changelog fragment for PR 69
CSSFrancis Sep 11, 2026
2584b93
Fix the renderer side of the review
CSSFrancis Sep 11, 2026
92a8a4c
Build the views control and expose the page's own inputs
CSSFrancis Sep 11, 2026
cf489db
Test every behaviour the review named
CSSFrancis Sep 11, 2026
dc17dba
Say how a virtual image flows through a navigated page
CSSFrancis Sep 11, 2026
f01f19f
Stop an exported page's own data from closing its script block
CSSFrancis Sep 11, 2026
1a6e8bf
Aim setImage at a panel that can draw, and keep an overlay's whole style
CSSFrancis Sep 11, 2026
837ad12
Test the escaping, the setImage target and the overlay style
CSSFrancis Sep 11, 2026
9fe07f4
Drive a 3-D panel from the navigator
CSSFrancis Sep 11, 2026
d93fd7b
Validate the 3-D blocks and name a view by position
CSSFrancis Sep 11, 2026
e70177d
Test the sphere as a driven panel
CSSFrancis Sep 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
- **`Figure`** (`anyplotlib/figure/_figure.py`) — the only `anywidget.AnyWidget` subclass. Owns all traitlets and is the Python↔JS bridge.
- **Plot objects** (`plot1d/`, `plot2d/`, `plot3d/`) — `Plot1D`, `PlotBar`, `Plot2D`, `PlotMesh`, `Plot3D` are **plain Python classes**, not widgets. They hold state in `_state` dicts and push to the Figure. Shared behaviour lives in `_base_plot.py` (`_BasePlot`, `_PanelMixin`, `_MarkerMixin`).
- **`Axes`** (`axes/_axes.py`) — grid-cell container; factory methods (`imshow`, `plot`, `bar`, `pcolormesh`, `plot_surface`, …) create plot objects and attach them.
- **`figure_esm.js`** — pure-JS canvas renderer (~9,300 lines); all rendering logic lives here. **Read `anyplotlib/FIGURE_ESM.md` first** — it is the section map.
- **`figure_esm.js`** — pure-JS canvas renderer (~12,210 lines); all rendering logic lives here. **Read `anyplotlib/FIGURE_ESM.md` first** — it is the section map.
- **`markers.py`** — static visual overlays (circles, arrows, lines, etc.) with a two-level dict registry: `plot.markers[type][name]`.
- **`widgets/`** — interactive draggable overlays (`RectangleWidget`, `CrosshairWidget`, etc.) that receive JS position updates.
- **`callbacks.py`** — event system: `Event` dataclass, `CallbackRegistry` (priority ordering, wildcard, pause/hold), `_EventMixin` (`add_event_handler`).
Expand Down Expand Up @@ -115,7 +115,7 @@ grep -nE '^\s*(function|const|let) [A-Za-z_]' anyplotlib/figure_esm.js
```

and reconcile against the two numbered tables (the section map near the top and
the 2-D function table). Both were last verified at 10,119 lines.
the 2-D function table). Both were last verified at 12,211 lines.

Changelog entries: add a fragment file to `upcoming_changes/` (e.g.
`123.new_feature.rst`) — towncrier assembles `CHANGELOG.rst` at release time.
Expand All @@ -129,7 +129,7 @@ Use `api_change` when existing behaviour changes, even if the change is a fix.
| `anyplotlib/figure/_gridspec.py` | `GridSpec`, `SubplotSpec` |
| `anyplotlib/figure/_subplots.py` | `subplots()` factory |
| `anyplotlib/axes/_axes.py` | `Axes` — plot factory methods |
| `anyplotlib/figure_esm.js` | All JS canvas rendering (~9,300 lines) |
| `anyplotlib/figure_esm.js` | All JS canvas rendering (~12,210 lines) |
| `anyplotlib/FIGURE_ESM.md` | Section map for `figure_esm.js` — read this before editing the JS |
| `anyplotlib/markers.py` | Static marker collections; `to_wire()` translation |
| `anyplotlib/widgets/` | Interactive overlay widgets |
Expand Down
272 changes: 215 additions & 57 deletions anyplotlib/FIGURE_ESM.md

Large diffs are not rendered by default.

95 changes: 62 additions & 33 deletions anyplotlib/_repr_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,23 @@
# Trait serialisation
# ---------------------------------------------------------------------------

def script_json(obj) -> str:
"""Return *obj* as a JSON literal that is safe inside a ``<script>`` block.

An HTML parser ends a script element at the first ``</`` in its text and
treats ``<!--`` as a comment opener, and it does not care that the sequence
is inside a JavaScript string. So a figure title, an axis label read from
file metadata, or any other value carrying ``</script>`` would close the
block early and run whatever followed it as markup. Both sequences are
escaped through the ``<``, which JSON spells ``\u003c``, so ``JSON.parse``
and a script literal read back exactly the character that went in.
"""
return (json.dumps(obj, default=str)
.replace("</", "\\u003c/")
.replace("<!--", "\\u003c!--"))



def _widget_state(widget) -> dict:
"""Return a {name: value} dict of every synced traitlet.

Expand Down Expand Up @@ -249,6 +266,7 @@ def _widget_px(widget) -> tuple[int, int]:
// host page script cannot reach exportPNG (only the postMessage protocol
// below can). anyplotlib.savefig() drives the export through this handle.
window._aplRenderApi = _aplRenderApi;
globalThis.__aplExportPNG = (o) => _aplRenderApi.exportPNG(o);
}} else {{
el.textContent = "ESM has no render() export";
}}
Expand Down Expand Up @@ -304,45 +322,55 @@ def _widget_px(widget) -> tuple[int, int]:
}}
}});

{png_harvest}
</script>
</body>
</html>
"""


# A host page (or the SpyDE report harvester) asks an embedded figure for a
# composite PNG over postMessage. Both the standalone page and the navigated
# embed install this listener; each assigns ``globalThis.__aplExportPNG`` once
# its figure is mounted, which is also what makes "not ready yet" answerable.
PNG_HARVEST_LISTENER = '''\
// ── PNG export protocol ──────────────────────────────────────────────────────
// Rides the same postMessage channel as the state updates above. A parent page
// (or the SpyDE report harvester) requests a composite PNG of the whole figure:
// → {{ type: 'anyplotlib_export_png', requestId, opts }}
// A parent page (or the SpyDE report harvester) asks this frame over
// postMessage for a composite PNG of the whole figure:
// → { type: 'anyplotlib_export_png', requestId, opts }
// and receives back, on event.source (targetOrigin '*'):
// ← {{ type: 'anyplotlib_export_png_result', requestId, dataUrl, width, height }}
// ← {{ type: 'anyplotlib_export_png_result', requestId, error }} (on failure)
// ← { type: 'anyplotlib_export_png_result', requestId, dataUrl, width, height }
// ← { type: 'anyplotlib_export_png_result', requestId, error } (on failure)
// `opts` is forwarded verbatim to exportPNG:
// {{ scale?, includeWidgets?, panelId?, source?, theme? }}
// { scale?, includeWidgets?, panelId?, source?, theme? }
// source: 'view' | 'full' | 'native' theme: 'current' | 'light' | 'dark'
window.addEventListener('message', (e) => {{
window.addEventListener('message', (e) => {
if (!e.data || e.data.type !== 'anyplotlib_export_png') return;
const requestId = e.data.requestId;
const source = e.source;
const reply = (msg) => {{
try {{
if (source && typeof source.postMessage === 'function') {{
const reply = (msg) => {
try {
if (source && typeof source.postMessage === 'function') {
source.postMessage(Object.assign(
{{ type: 'anyplotlib_export_png_result', requestId }}, msg), '*');
}}
}} catch (_) {{}}
}};
try {{
if (!_aplRenderApi || typeof _aplRenderApi.exportPNG !== 'function') {{
reply({{ error: 'figure not ready (exportPNG unavailable)' }});
{ type: 'anyplotlib_export_png_result', requestId }, msg), '*');
}
} catch (_) {}
};
try {
const exportPNG = globalThis.__aplExportPNG;
if (typeof exportPNG !== 'function') {
reply({ error: 'figure not ready (exportPNG unavailable)' });
return;
}}
Promise.resolve(_aplRenderApi.exportPNG(e.data.opts || {{}}))
.then((res) => reply({{
dataUrl: res.dataUrl, width: res.width, height: res.height }}))
.catch((err) => reply({{ error: String(err && err.message || err) }}));
}} catch (err) {{
reply({{ error: String(err && err.message || err) }});
}}
}});
</script>
</body>
</html>
"""
}
Promise.resolve(exportPNG(e.data.opts || {}))
.then((res) => reply({
dataUrl: res.dataUrl, width: res.width, height: res.height }))
.catch((err) => reply({ error: String(err && err.message || err) }));
} catch (err) {
reply({ error: String(err && err.message || err) });
}
});
'''


def build_standalone_html(widget, *, resizable: bool = True,
Expand Down Expand Up @@ -375,9 +403,10 @@ def build_standalone_html(widget, *, resizable: bool = True,
width=w,
height=h,
extra_css=extra_css,
state_json=json.dumps(state, default=str),
esm_json=json.dumps(esm),
fig_id_json=json.dumps(fig_id),
state_json=script_json(state),
esm_json=script_json(esm),
fig_id_json=script_json(fig_id),
png_harvest=PNG_HARVEST_LISTENER,
)


Expand Down
Loading
Loading