Skip to content

Navigated embeds: a page that owns its data, and a binary image setter on the mount handle - #69

Merged
CSSFrancis merged 22 commits into
mainfrom
feat/embed-runtime
Sep 11, 2026
Merged

Navigated embeds: a page that owns its data, and a binary image setter on the mount handle#69
CSSFrancis merged 22 commits into
mainfrom
feat/embed-runtime

Conversation

@CSSFrancis

@CSSFrancis CSSFrancis commented Sep 11, 2026

Copy link
Copy Markdown
Owner

What this is

  • A way to export a figure as one HTML file that carries its own data and navigates it with no Python behind it. Move the crosshair on the navigator panel and the signal panel shows that position's frame; the overlays follow; drag a detector on the signal panel and the navigator re-maps.
  • The page does not compute anything scientific. It looks things up: a frame is a slice of a dense array, the spots at a position are the rows between two offsets. The only arithmetic is summing under a mask, which is what a virtual image is.

Why

  • SpyDE exports report figures as standalone HTML. The interactive ones (diffraction vectors, orientation maps, tinted overlays) are each a hand-written JavaScript program inside a Python string, with their own overlay canvas, levels code, base64 decode and widget wiring. Three pages, 2,800 lines, and a strain map or a fit still could not be embedded live.
  • The library already had everything those pages do, except a fast way to push a frame from JS and a page that owns its data. This adds those.

How a virtual image flows through it

  • The export packs the 4D stack as one dense block (leading axes are the scan, trailing axes are the detector) and builds a two-panel figure: the navigator with a crosshair, the diffraction pattern with a circle (or annulus, or rectangle) widget on it.
  • The diffraction panel's binding says frame: {block: "scan"} (its image at the crosshair position is scan[iy, ix]) and reduce: {block: "scan", navigator_panel: <the navigator>}.
  • When the circle moves, the page turns the widget geometry into a mask over the detector grid, sums every scan position's frame under that mask, and pushes the resulting scan-shaped image to the navigator with setImage. That is einsum("...ij,ij->...", scan, mask), the same thing SpyDE's virtual image action does per chunk, done once in the browser on the data that shipped.
  • For a vectors dataset the same binding names a ragged block instead: the sum is over the intensity of every spot whose (x, y) falls inside the mask, per position. No stack needed.

How a strain map flows through it

  • The committed result's maps (εxx, εyy, εxy, ω) are four dense blocks. The panel's binding lists them as views, a segmented control that swaps which block the panel reads; the colorbar label comes from the figure state as it does today.
  • If the reference diffraction pattern is composed into the figure too, the arrows overlay is a ragged block with (x, y, u, v) rows per scan position, evaluated once in Python at export, and drawn as ordinary arrow markers at the crosshair position.

What is in the diff

  • handle.setImage(panelId, bytes, width, height, opts): raw pixel bytes straight into the draw path, through the same side table the standalone page already used for the Electron binary transport. Colormap codes, or RGBA with opts.rgb.
  • handle.patchPanel(panelId, partial), handle.panelIds(), handle.flushImages().
  • mountNavigated(el, page) and the readers under it: dense / ragged (at, gather, reduce), maskFromWidget, rasterDisks, robustLevels, toU8.
  • Python: embed.Ragged, embed.pack_blocks (one byte string, one manifest, one ArrayBuffer on the page), embed.navigated_html. A binding that names a panel or block the page does not carry raises at build time.
  • One PNG-harvest listener shared by the standalone page and the navigated one.
  • docs/embedding.rst "Navigated pages" with a complete example. FIGURE_ESM.md anchors regenerated (they were already 37 to 100 lines stale).

Numbers

  • Pushing a frame through the panel state as base64 costs 6.7 ms at 512² and 129 to 136 ms at 2048² of main-thread time (0.8.0, headless Chromium, 40 frames).
  • setImage is a fraction of a millisecond at either size and paints on the next animation frame. The test prints the medians.

Things to know before reading the diff

  • RGB frames are RGBA, four bytes per pixel; the renderer writes straight into an ImageData.
  • setImage also writes a fresh \0bin:<n> token into the panel's geom cache, because the blit cache keys on image_b64 first and only falls back to the arrival sequence.
  • The repaint is deferred to one animation frame per panel; exportPNG and exportCanvas call flushImages themselves.
  • mountNavigated is async: the block decode is a fetch of a data: URL.

Tests

  • 14 Playwright tests for the navigated page and 6 for setImage, all checked to fail against the old renderer. Whole suite 2283 passed, 58 skipped.

Review fixes, landed

  • A cold review found four correctness gaps, now fixed here: a 1-D navigator read the wrong axis key and never moved; the range widget was ignored; overlays replaced a figure's own markers instead of merging by id; a ragged block read wrong rows when the index rank mismatched (it now throws).
  • Also from the review: chips became views (the block switch above); a frame's display window defaults to the panel's own instead of a per-frame percentile pass; a size-changing setImage commits geometry and bytes in the same frame (first push 110 ms to 0.2 ms); the pixel sequence is global so two identical figures in one document no longer collide; the readers are exported under one embed namespace instead of as generic top-level names.
  • New tests for each of those plus a circle detector on a 4D block against numpy's einsum. Whole suite after the fixes: 2301 passed, 58 skipped.

The standalone page's postMessage export handler moves out of the page
template into PNG_HARVEST_LISTENER, and reads its export entry point from
globalThis.__aplExportPNG instead of the template's own render() api, so a
second kind of page can install the same listener without copying it.
setImage hands a 2-D panel raw pixel bytes instead of routing a frame
through the geom trait: measured on 0.8.0, base64 costs 6.7 ms at 512 square
and 129-136 ms at 2048 square of main-thread time, against a fraction of a
millisecond here. Two details are load-bearing. The panel's geom cache gets a
fresh image_b64 token per push, because the blit cache keys on that string
and a mount() page's geom carries real base64 that never changes. And the
repaint is coalesced onto the next animation frame, so a task that pushes
several frames pays one blit; exportPNG flushes first. patchPanel and
panelIds round the handle out.

Above that sits mountNavigated: a page hands over blocks of data plus
bindings saying which block feeds which panel, and a navigator widget's
position dispatches through readers (dense, ragged, maskFromWidget,
rasterDisks, robustLevels, toU8) onto every driven panel. A detector widget
on a driven panel runs the other way, reducing the block back onto the
navigator's image.

FIGURE_ESM.md gains a section for it, and every line anchor in both numbered
tables is regenerated from the file (they had drifted by 37 to 100 lines
before this change).
pack_blocks packs dense arrays and Ragged row-pointer blocks into one
little-endian byte string plus a manifest, aligned so every typed-array view
is legal; the page decodes it once and takes views, rather than paying base64
per block. navigated_html inlines the renderer, the figure state, that blob
and the bindings into a self-contained page, and refuses a binding that names
a panel or a block the page does not carry.
test_embed_navigated.py opens real navigated_html output from file:// and
drags: the dragged index has to paint that frame (bytes and canvas pixels
alike), a ragged circles overlay has to land on its rows, a detector
rectangle has to reduce to numpy's einsum, and a rectangle navigator
selection has to paint the gathered mean. rasterDisks, maskFromWidget and
robustLevels are checked against numpy references.

test_embed_set_image.py pins the push cost at 512 and 2048 square and prints
the medians, and covers painting, the geometry patch, both throws, and the
absence of an onSync echo. The shared mount page now records onSync writes so
that last one can see them.
docs/embedding.rst gains a "Navigated pages" section with a complete example
(numpy block to pack_blocks to navigated_html to a file), the binding table,
and the JS entry points; the handle reference lists patchPanel, setImage,
flushImages and panelIds.
A drag fires an event per pointer move and each reduce is a pass over the
whole block, so the detector path gets the same newest-wins animation-frame
gate the navigator dispatch already had. Also drops the em-dashes from the
new comments and refreshes the recorded file length.
decodeBlocks takes an empty ArrayBuffer rather than fetching an empty data
URL, so a page with no blocks mounts; embed.py imports numpy at module scope
for the Ragged annotation.
Only membership was ever read, so it is a set rather than a map of sizes.
performance.now() is clamped to 100 microseconds outside a
cross-origin-isolated page, so a single push reads 0.0 or 0.1 ms and the
median says nothing. Timing a thousand pushes back to back gives the real
steady-state number, and that is what the budget now asserts; the per-frame
median and worst are still printed.
It no longer sits under the state-update handler it used to point back at.
@codecov-commenter

codecov-commenter commented Sep 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.56098% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.08%. Comparing base (3870516) to head (e70177d).

Files with missing lines Patch % Lines
anyplotlib/embed.py 97.46% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #69      +/-   ##
==========================================
+ Coverage   90.97%   91.08%   +0.10%     
==========================================
  Files          41       41              
  Lines        4709     4789      +80     
==========================================
+ Hits         4284     4362      +78     
- Misses        425      427       +2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved security and renderer/API correctness issues remain.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds self-contained navigated HTML embeds and efficient binary image updates for interactive plots.

Changes:

  • Adds packed data blocks and mountNavigated.
  • Adds image, panel patching, flushing, and navigation APIs.
  • Updates documentation, tests, changelog, and renderer references.
File summaries
File Reviewed changes
upcoming_changes/69.new_feature.rst Changelog entry for navigated embeds and binary image updates
docs/embedding.rst Navigated embedding and API documentation
anyplotlib/tests/test_embed/test_embed_set_image.py Binary image setter tests
anyplotlib/tests/test_embed/test_embed_navigated.py End-to-end navigated embed tests
anyplotlib/tests/test_embed/test_embed_api.py Packing and HTML API tests
anyplotlib/tests/test_embed/_export_utils.py Mount synchronization test helpers
anyplotlib/FIGURE_ESM.md Updated renderer section references
anyplotlib/figure_esm.js Mount APIs, image updates, and navigation runtime
anyplotlib/embed.py Data block packing and navigated HTML generation
anyplotlib/_repr_utils.py Shared PNG harvest listener
AGENTS.md Updated renderer size references
Review details

Suppressed comments (3)

anyplotlib/figure_esm.js:11733

  • Plot1D serializes its x axis as x_axis_b64 and the raw state.x_axis is absent (see anyplotlib/plot1d/_plot1d.py:381-387). Consequently state.x_axis is undefined here and every 1-D navigator vline/point resolves to index 0, regardless of the widget's x position; use the panel's decoded _1dXArr (or decode x_axis_b64) for nearest-axis lookup.
    if (widget.type === 'vline') return [nearestAxisIndex(state.x_axis, widget.x)];
    if (widget.type === 'point') return [nearestAxisIndex(state.x_axis, widget.x)];

anyplotlib/figure_esm.js:11215

  • When display limits, dimensions, or RGB mode change, patchPanel runs synchronously and its panel listener immediately calls _redrawPanel; only the byte-slot commit is deferred. mountNavigated computes robust levels per frame, so normal scrubbing can synchronously blit the previous frame before every queued raw-byte paint, defeating the low-latency setImage path. Defer the metadata patch/redraw together with the pending image and apply both once in commitImages.
      if (Object.keys(patch).length) this.patchPanel(panelId, patch);

anyplotlib/tests/test_embed/test_embed_set_image.py:111

  • This test suite covers only colormap-code frames; none of the tests calls setImage with opts.rgb: true. The RGBA path is a separate byte-count and renderer branch, so it can regress while all current tests remain green; add a mount-level test that pushes four-byte RGBA data and verifies the canvas pixel.
    def test_push_median_is_independent_of_frame_size(self, mount_page, size, capsys):
        fig, plot = _figure_with_panel()
        page = mount_page(fig)
        timings = page.evaluate(_PUSH_SCRIPT, [plot._id, size, FRAME_COUNT])
  • Files reviewed: 11/11 changed files
  • Comments generated: 6
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread anyplotlib/embed.py Outdated
caption_html=(f'<div class="apl-caption">{escape(caption)}</div>'
if caption else ""),
strips_html=strips,
page_json=_json.dumps(page, default=str),
Comment thread anyplotlib/figure_esm.js
Comment on lines +11185 to +11186
if (model.get(`panel_${panelId}_geom`) === undefined)
throw new Error(`setImage: panel ${panelId} has no image channel`);
Comment thread anyplotlib/figure_esm.js Outdated
if (current.base_width) patch.base_width = 0;
if (current.base_height) patch.base_height = 0;
if (current.tile_enabled) patch.tile_enabled = false;
if (current.detail_b64) patch.detail_b64 = '';
Comment thread anyplotlib/figure_esm.js Outdated
const wire = { id: `apl-overlay-${overlay.block}`, name: overlay.block,
type: overlay.kind, color: style.color || '#ff0000',
linewidth: style.linewidth === undefined ? 1.5 : style.linewidth };
if (style.fill_color) wire.fill_color = style.fill_color;
Comment thread anyplotlib/figure_esm.js Outdated
Comment on lines +11847 to +11852
function paintChips(binding, indices) {
const parts = binding.chips.map((chip) => {
const reader = readerFor(chip.block);
const values = reader.at(indices[0]);
const value = values && values.length ? values[0] : NaN;
return `${chip.label} ${Number.isFinite(value) ? value.toPrecision(4) : '-'}`;
Comment thread anyplotlib/figure_esm.js
Comment on lines +11188 to +11192
const rgb = !!settings.rgb;
const expected = width * height * (rgb ? 4 : 1);
if (!bytes || bytes.length !== expected)
throw new Error(`setImage: expected ${expected} bytes for ${width}x${height}` +
`${rgb ? ' RGBA' : ''}, got ${bytes ? bytes.length : 0}`);
A 1-D navigator never moved: Plot1D.to_state_dict pops x_axis and sends
x_axis_b64, so resolving a vline through state.x_axis read an empty array and
every position came out 0. panelAxis reads draw1d's decoded cache the way the
rest of the file does. The span selector, the 1-D analogue of the rectangle,
was ignored in silence; it now resolves both edges and selects the run.

A dispatch assigned markers and extra_lines wholesale, wiping the groups the
figure was built with on the first crosshair move; it now merges by the
apl-overlay- id prefix. A ragged index of the wrong arity silently read the
row number as the position, and dense.reduce trusted the mask's length; both
throw. views replace chips: a committed result's alternative frames, picked
from the page's segmented control, rather than per-position scalars readout
already covers.

setImage patched geometry at push time but committed bytes on the next
animation frame, so a size change painted once at the new dimensions over the
old pixels; both now land in commitImages. Its sequence counter is global,
because the pixel side table is and panel ids repeat across figures of one
layout. _loadGeom keeps a live binary token rather than letting a geom push
rename the key for bytes that are still what gets drawn.

The frame's colour window defaults to the panel's own instead of a percentile
recomputed per frame, which cost two passes and made the contrast jump between
neighbouring positions. A reduce binding with no detector on its panel is
refused at mount, and any other widget there is ignored at dispatch rather
than throwing inside an animation frame. The generic readers move under one
embed namespace so dense, ragged and toU8 stop sitting beside mount and
render.
A views binding renders a segmented control of its labels, which the runtime
wires to the block each button names. frame.block is optional when views is
given. The page also publishes the dict it was built from, so a host can
mount a second figure from it or read back what this one carries. json is
imported at module scope rather than aliased inside the builder.
A 1-D navigator drag lands on its time frame and a span gathers the run
between its edges; a figure's own marker group survives a dispatch; a
mismatched ragged index and a short reduce mask both throw; a views button
re-reads the frame from its block; a reduce binding with no detector is
refused at mount while a crosshair on that panel is ignored at dispatch; a
circle detector gives the navigator numpy's einsum; a geom push leaves a live
binary token alone but yields to one of its own; two figures in one document
keep their own pixels; the frame window stays the panel's; and a resize does
not reach the panel before the bytes it describes.

The region mean is compared with a tolerance of one code, since a float32 sum
scaled by 1/n and numpy's float64 mean can truncate either side of a
boundary, and the rasterDisks reference states the convention (a filled disk
per row, combined by max or sum) rather than transcribing the loop.
The detector widget becomes a mask over the signal grid, every position's
frame is summed under it, and the navigator-shaped result goes back with
setImage. The binding table documents views, initial_index and the disk
raster's width/height, and the changelog fragment loses its em dash.
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 sits inside
a JavaScript string. A figure title, an axis label read from file metadata, a
widget field or a binding's style value carrying "</script>" therefore closed
the block and ran whatever followed, in a file people open directly rather
than only inside a sandboxed frame.

script_json escapes both sequences through the "<", which JSON spells
\u003c, so JSON.parse and a script literal read back the character that went
in. Every JSON literal either page embeds now goes through it: the navigated
page's state, bindings, manifest and chrome, and the standalone page's state,
inlined renderer and figure id. Panel ids reaching element ids are escaped
like the title and caption already were.
setImage took any panel with a geometry trait, so a 3-D panel accepted bytes
that went nowhere; it now checks the panel kind. A detail tile is a crop of
the PREVIOUS frame at a zoom the viewer may still be at, and clearing only the
light field left the bytes and the region in the geom cache for _applyGeom to
splice straight back in, so a new frame now voids the tile everywhere it
lives.

The circle wire builder read the style key by key and dropped fill_alpha, so
every filled overlay got the renderer's 0.3 and a requested 0 or 1 was
ignored. The style IS the wire dict, so it is carried through whole.
A page whose panel title, axis label, page title, caption and binding style
all carry "</script><script>window.__injected = 1</script>" opens with
__injected undefined, the figure mounted and one script element; the title and
caption render as text. script_json is checked on its own for a literal with
no "</" that json.loads reads back. The standalone page gets the same
treatment.

Also: a 3-D panel refuses pixel bytes; a new frame clears a tile-enabled
panel's detail fields in the state, the geom cache and the side table; an
overlay's fill_alpha of 1 reaches the wire and paints opaque; and RGBA bytes
paint their own channels, with the wrong length refused.
@CSSFrancis

Copy link
Copy Markdown
Owner Author

Thanks — all six are addressed.

  • Script injection. This was real, and worse than it looks: an HTML parser ends a <script> block at the first </ in its text and doesn't care that it's inside a JS string, so a panel title, an axis label read from file metadata, a widget field or a binding style value containing </script> closed the block and ran what followed. navigated_html output is a top-level document people open by double-clicking, not only a sandboxed srcdoc, so it has to be safe on its own. There's now one function, _repr_utils.script_json, that escapes </ and <!-- through the < (as the JSON escape for <, so JSON.parse and a module-script literal read back the same character), and every JSON literal either page embeds goes through it — the navigated page's state, bindings, manifest and chrome, and the standalone page's state, inlined renderer and figure id, which had the same hole. Panel ids reaching element ids are HTML-escaped now too; the title and caption already were, and the base64 blob is base64-alphabet only.

    Tested three ways in test_embed_escaping.py: a page whose panel title, axis label, page title, caption and one binding style are all </script><script>window.__injected = 1</script> opens with window.__injected undefined, the figure mounted, exactly one <script> element in the document, and the title and caption rendered as literal text; the standalone page gets the same treatment; and script_json is checked on its own for output with no </ that json.loads reads back unchanged. All three page tests fail against the previous json.dumps — I ran them pinned to it to be sure the test can see the bug.

  • setImage on a non-image panel. Right — a 3-D panel has a geometry trait too, so the bytes were accepted and went nowhere. It checks the panel kind and throws naming it.

  • Stale detail tile. Also right, and the light-state patch was only half of it: the bytes and the region live in panel._geomCache, which _applyGeom splices straight back in. A new frame now clears detail_b64/detail_region/detail_width/detail_height/detail_min/detail_max/detail_is_int in the state, detail_b64 and detail_b64_bytes in the geom cache, the side-table slot and p._detailBlit. Tested on a tile-enabled panel with a real detail tile set before mount.

  • fill_alpha dropped. Fixed by deleting the allow-list rather than extending it: the style is the wire dict (the keys MarkerGroup.to_wire emits), so it's carried through whole. The || default idiom was the other half of the bug — fill_alpha: 0 read as unset. Test asserts the wire carries fill_alpha: 1 and that the marker canvas has fully opaque fill pixels.

  • Scalar chips. Already gone — that was an earlier round. The binding is views: [{label, block}] now, a segmented control that swaps which block the panel's frame is read from at the current navigator position; per-position scalars are readout's job. No chips or paintChips anywhere in the tree.

  • opts.rgb untested. Added: a push of RGBA bytes with distinct channel values reads back as those channels off the canvas, plus a case asserting three-byte rows are refused (the renderer's is_rgb path is RGBA, four bytes a pixel).

Whole suite green.

An orientation map's sphere is a driven panel: the picked orientation is a
highlight on it, the sphere turns to face that point, and a direction toggle
swaps the cloud and its per-point colours. setImage is still the wrong door
for that, so the 3-D path goes through patchPanel and the geometry trait.

A frame of kind points3d loads a dense (M, 3) float32 cloud with a (M, 3) or
(M, 4) uint8 colors block. It rides panel_<id>_geom as base64, because the
binary side table is registered for image pixel keys only and a cloud changes
on a view click rather than per navigator move; vertices_count and a bumped
_geom_rev go on the light trait in the same breath, or the renderer draws the
previous cloud's point count. The push is skipped while the block and colours
are the ones already shown, so a navigator drag costs nothing.

A highlight overlay writes the shape Plot3D.set_highlight writes, from a dense
(nav..., 3) or one-row-per-position ragged block. With face_camera it also
writes elevation = asin(z/r) and azimuth = atan2(x, -y) plus
_view_from_python, which is what tells _preserveView the camera is intended.
The flag is written either way: it persists on the trait, so a true left by an
earlier facing push would let the next highlight discard the orbit the reader
is holding.

A views entry is now identified by its position rather than its block, since
two views may read one cloud with different colours.
frame.colors and views[].colors are checked like every other block name, and
each view button carries its index rather than its block so a direction toggle
over one cloud can tell its entries apart.
A real scatter3d panel with a reference sphere: a crosshair drag puts the
block's row in the state's highlight and moves the white pixels; face_camera
lands the camera on asin(z)/atan2(x, -y) and without it an orbit the reader
made survives the next dispatch; a view click repaints the cloud in its own
colours while a navigator move does not re-push it; the cloud arrives on the
geometry trait with the right vertices and count; setImage still refuses the
panel; and dense.at on a (nav, nav, 3) block gives the 3-vector.

The docs gain rows for points3d, highlight and face_camera, and a paragraph on
how an orientation map flows through a navigated page.
@CSSFrancis

Copy link
Copy Markdown
Owner Author

One more capability on top of the Copilot round: a 3-D panel can now be a driven panel of a navigated page. Refusing setImage on a 3-D panel was right — it is an image setter — but the orientation embed drives an IPF sphere from the navigator, so the 3-D path goes through patchPanel and the geometry trait instead.

  • frame: {block, kind: "points3d", colors} loads a dense (M, 3) float32 cloud with a (M, 3) or (M, 4) uint8 colour block. It rides panel_<id>_geom as base64 — the binary side table is registered for image pixel keys only, and a cloud changes on a view click rather than per navigator move, so the encode is nowhere near a hot path. vertices_count and a bumped _geom_rev go on the light trait in the same breath, or the renderer keeps the previous cloud's point count. The push is skipped while the block and colours already shown are the ones asked for, so a navigator drag costs nothing.
  • views: [{label, block, colors}] is the direction toggle. An entry is identified by its position in the list now, not by its block name: two views may read one cloud with different colourings, which is exactly what a direction toggle over a single sphere looks like.
  • Overlay kind: "highlight" marks one point per navigation position, from a dense (nav…, 3) block or a one-row-per-position ragged one, writing the {x, y, z, color, size} shape Plot3D.set_highlight writes; style keys pass through. face_camera: true also turns the sphere to face it with the live view's rule, elevation = asin(z/r) and azimuth = atan2(x, -y), plus _view_from_python so the renderer takes the intended camera. That flag is written either way, because it persists on the trait — a true left by an earlier facing push would let the next highlight quietly discard the orbit the reader is holding.
  • Tests in test_embed_3d.py drive a real scatter3d panel with a reference sphere: the drag puts the block's row in state.highlight and moves the white pixels; face_camera lands the camera on the expected az/el and without it an orbit the reader made survives the next dispatch; a view click repaints the cloud in its own colours while a navigator move does not re-push it; the cloud arrives on the geometry trait with the right vertices and count; and setImage still throws on the panel. All nine fail against the previous renderer.

@CSSFrancis
CSSFrancis merged commit e2a9fc6 into main Sep 11, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants