diff --git a/AGENTS.md b/AGENTS.md
index a2fc00791..5f6a240df 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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 12,407 lines.
+the 2-D function table). Both were last verified at 12,425 lines.
Changelog entries: add a fragment file to `upcoming_changes/` (e.g.
`123.new_feature.rst`) — towncrier assembles `CHANGELOG.rst` at release time.
diff --git a/anyplotlib/FIGURE_ESM.md b/anyplotlib/FIGURE_ESM.md
index 6a3ef0f28..cd729499b 100644
--- a/anyplotlib/FIGURE_ESM.md
+++ b/anyplotlib/FIGURE_ESM.md
@@ -73,32 +73,34 @@ Rule 5 – Text never clips. Optional gutters earn real layout space:
| 3D event handlers `_attachEvents3d` | 6661 |
| **1D drawing**: `draw1d` | 6885 |
| `_drawLine` (1D series + markers) | 7038 |
-| `drawOverlay1d` / `drawMarkers1d` | 7331 / 7415 |
-| Marker hit-test `_markerHitTest2d` | 7682 |
+| `drawOverlay1d` / `_rasterBitmap` / `drawMarkers1d` | 7331 / 7419 / 7436 |
+| Marker hit-test `_markerHitTest2d` | 7700 |
> **`raster` marker (1D/PlotXY)** — `drawMarkers1d` has a `type==='raster'`
> branch that blits a single RGBA image across data-coord `extent` (the fast
> path for dense `PlotXY.pcolormesh` heatmaps). The image bytes ride the geom
> channel as `st.raster_geom[id]` (Python `Plot1D._GEOM_KEYS`), so view-only
-> redraws never re-transmit them; the decoded `OffscreenCanvas` is cached on
-> the marker set (`ms._rasterBmp`/`_rasterKey`). The shared `clip_path` block
-> clips it to a curved sector.
-| Panel event dispatch `_attachPanelEvents` | 7939 |
-| 2D events `_attachEvents2d` | 8025 |
-| 1D events `_attachEvents1d` | 8411 |
-| 2D widget drag `_ovHitTest2d` / `_doDrag2d` | 8686 / 8965 |
-| **Brush strokes**: `_brushLiveBegin` / `_brushCommit` / `_brushErase` / `_brushPaintAt` | 8878 / 8892 / 8921 / 8956 |
-| 1D widget drag `_canvasXToFrac1d` … / snapping `_snapVal` | 9090 / 9163 |
-| Shared-axis propagation `_getShareGroups` | 9234 |
-| Figure resize `_applyFigResizeDOM` | 9298 |
-| **Bar chart**: `_barGeom` / `drawBar` / `_attachEventsBar` | 9492 / 9555 / 9931 |
-| Generic redraw `_redrawPanel` | 10121 |
-| **PNG export**: `_compositeCanvas` / `exportCanvas` / `exportPNG` | 10284 / 10480 / 10539 |
-| Native-resolution render `_withNativeSize` | 10260 |
-| **Export UI**: `_toast` / `_downloadCanvas` / `_openMenu` | 10573 / 10682 / 10861 |
-| Export registry `registerExportAction` | 10738 |
-| **Embedding API**: `createLocalModel` / `mount` | 11252 / 11308 |
-| **Navigated embed**: `decodeBlocks` / `mountNavigated` | 11563 / 11950 |
+> redraws never re-transmit them. The decoded `OffscreenCanvas` is cached per
+> panel by `_rasterBitmap` (keyed by the marker set's id, re-decoded when the
+> bytes or the shape change) — never on the marker set, which is state the
+> wheel and pan handlers serialise back. The shared `clip_path` block clips it
+> to a curved sector.
+| Panel event dispatch `_attachPanelEvents` | 7957 |
+| 2D events `_attachEvents2d` | 8043 |
+| 1D events `_attachEvents1d` | 8429 |
+| 2D widget drag `_ovHitTest2d` / `_doDrag2d` | 8704 / 8983 |
+| **Brush strokes**: `_brushLiveBegin` / `_brushCommit` / `_brushErase` / `_brushPaintAt` | 8896 / 8910 / 8939 / 8974 |
+| 1D widget drag `_canvasXToFrac1d` … / snapping `_snapVal` | 9108 / 9181 |
+| Shared-axis propagation `_getShareGroups` | 9252 |
+| Figure resize `_applyFigResizeDOM` | 9316 |
+| **Bar chart**: `_barGeom` / `drawBar` / `_attachEventsBar` | 9510 / 9573 / 9949 |
+| Generic redraw `_redrawPanel` | 10139 |
+| **PNG export**: `_compositeCanvas` / `exportCanvas` / `exportPNG` | 10302 / 10498 / 10557 |
+| Native-resolution render `_withNativeSize` | 10278 |
+| **Export UI**: `_toast` / `_downloadCanvas` / `_openMenu` | 10591 / 10700 / 10879 |
+| Export registry `registerExportAction` | 10756 |
+| **Embedding API**: `createLocalModel` / `mount` | 11270 / 11326 |
+| **Navigated embed**: `decodeBlocks` / `mountNavigated` | 11581 / 11968 |
> **`brush` widget (2-D)** — the one widget whose drag is *modal*, and the one
> that must NOT write the model per tick. `_ovHitTest2d` takes an extra `mods`
@@ -660,13 +662,13 @@ exportCanvas(same opts) → {canvas, width, height} // synchronous, throws
| Function | Line | Purpose |
|----------|------|---------|
-| `_cssScale` | 10156 | inverse of `_applyScale`'s `transform:scale()` |
-| `_panelBox` | 10167 | the element whose rect bounds one panel |
-| `_neutralizeView` / `_restoreView` | 10176 / 10201 | transient whole-extent view |
-| `_nativeGeom` / `_nativeGuard` | 10216 / 10235 | native size + why-not message |
-| `_withNativeSize` | 10260 | resize → redraw → run → restore |
-| `_compositeCanvas` | 10284 | the compositor (`_drawEl` / `_drawPanel` …) |
-| `exportCanvas` / `exportPNG` | 10480 / 10539 | orchestrator / data-URL wrapper |
+| `_cssScale` | 10174 | inverse of `_applyScale`'s `transform:scale()` |
+| `_panelBox` | 10185 | the element whose rect bounds one panel |
+| `_neutralizeView` / `_restoreView` | 10194 / 10219 | transient whole-extent view |
+| `_nativeGeom` / `_nativeGuard` | 10234 / 10253 | native size + why-not message |
+| `_withNativeSize` | 10278 | resize → redraw → run → restore |
+| `_compositeCanvas` | 10302 | the compositor (`_drawEl` / `_drawPanel` …) |
+| `exportCanvas` / `exportPNG` | 10498 / 10557 | orchestrator / data-URL wrapper |
**The whole pipeline is ONE synchronous task** — theme swap, view reset, native
resize, composite, restore — so the browser never paints an intermediate state
@@ -762,13 +764,13 @@ leaders that cross into the panel included. Pinned by
| Function | Line | Purpose |
|----------|------|---------|
-| `_toast` | 10573 | transient bottom-centre message |
-| `_copyCanvas` | 10608 | clipboard write + feature detection |
-| `_showPngPreview` | 10632 | framed-document download fallback |
-| `_downloadCanvas` | 10682 | `` or the preview |
-| `registerExportAction` | 10738 | downstream extension point |
-| `_menuRows` / `_openMenu` | 10792 / 10861 | menu model / DOM |
-| `_panelAtPoint` | 10973 | hit test (insets first — they sit on top) |
+| `_toast` | 10591 | transient bottom-centre message |
+| `_copyCanvas` | 10626 | clipboard write + feature detection |
+| `_showPngPreview` | 10650 | framed-document download fallback |
+| `_downloadCanvas` | 10700 | `` or the preview |
+| `registerExportAction` | 10756 | downstream extension point |
+| `_menuRows` / `_openMenu` | 10810 / 10879 | menu model / DOM |
+| `_panelAtPoint` | 10991 | hit test (insets first — they sit on top) |
- **An `exportBtn` badge (⤓, beside the help badge) opens the same menu on an
ordinary left click.** It is a `role="button"` with `tabIndex=0` and
@@ -852,16 +854,16 @@ bindings, let it dispatch", rather than a hand-written program per result kind.
| Function | Line | Purpose |
|----------|------|---------|
-| `decodeBlocks` | 11563 | one base64 `fetch` → one ArrayBuffer → a typed-array view per manifest entry |
-| `dense` | 11589 | `at` / `gather` / `reduce` over a block whose leading axes are the nav axes |
-| `ragged` | 11654 | the same three, over a row-pointer block (`offsets` + one array per column) |
-| `maskFromWidget` | 11737 | rectangle / circle / annulus widget dict → `Uint8Array` (carries `width`/`height`) |
-| `rasterDisks` | 11777 | splat `{x, y, intensity}` rows as filled disks — the base image of a vectors panel |
-| `robustLevels` / `toU8` | 11806 / 11847 | the percentile window and the 8-bit code map, one implementation |
-| `panelAxis` | 11925 | a 1-D panel's decoded x axis (`_1dXArr`, else `x_axis_b64`) |
-| `installTouchShim` / `reportEmbedHeight` | 11862 / 11881 | page chrome: touch → mouse, `postMessage({aplEmbedHeight})` |
-| `encodeBase64` / `typedArrayBytes` | 11901 / 11909 | a 3-D cloud's geometry channel is base64, not the binary side table |
-| `mountNavigated` | 11950 | mount + bind + dispatch; resolves to the mount handle plus `dispatch`/`index`/`blocks` |
+| `decodeBlocks` | 11581 | one base64 `fetch` → one ArrayBuffer → a typed-array view per manifest entry |
+| `dense` | 11607 | `at` / `gather` / `reduce` over a block whose leading axes are the nav axes |
+| `ragged` | 11672 | the same three, over a row-pointer block (`offsets` + one array per column) |
+| `maskFromWidget` | 11755 | rectangle / circle / annulus widget dict → `Uint8Array` (carries `width`/`height`) |
+| `rasterDisks` | 11795 | splat `{x, y, intensity}` rows as filled disks — the base image of a vectors panel |
+| `robustLevels` / `toU8` | 11824 / 11865 | the percentile window and the 8-bit code map, one implementation |
+| `panelAxis` | 11943 | a 1-D panel's decoded x axis (`_1dXArr`, else `x_axis_b64`) |
+| `installTouchShim` / `reportEmbedHeight` | 11880 / 11899 | page chrome: touch → mouse, `postMessage({aplEmbedHeight})` |
+| `encodeBase64` / `typedArrayBytes` | 11919 / 11927 | a 3-D cloud's geometry channel is base64, not the binary side table |
+| `mountNavigated` | 11968 | mount + bind + dispatch; resolves to the mount handle plus `dispatch`/`index`/`blocks` |
`mountNavigated(el, page, opts)` is **async** — the blob decode is a `fetch` of
a `data:` URL — so a host `await`s it. `page` is `{state, blocks, bindings,
diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js
index 760f0f0df..e8e1e8700 100644
--- a/anyplotlib/figure_esm.js
+++ b/anyplotlib/figure_esm.js
@@ -7412,6 +7412,27 @@ fn fs(in : VsOut) -> @location(0) vec4 {
ctx.beginPath();ctx.arc(x,y,5,0,Math.PI*2);ctx.fill();ctx.stroke();ctx.restore();
}
+ // The decoded image of raster marker set `id`, re-decoded only when its bytes
+ // or its shape change (the same bytes can be 1×4 or 2×2), or null if they
+ // cannot be decoded. Kept in a per-panel map rather than on the marker set
+ // itself, which is serialised state.
+ function _rasterBitmap(p, id, b64, width, height){
+ if(!p._rasterCache) p._rasterCache=new Map();
+ const cached=p._rasterCache.get(id);
+ if(cached && cached.b64===b64 && cached.width===width && cached.height===height)
+ return cached.bitmap;
+ let bitmap=null;
+ try{
+ const bin=atob(b64);
+ const bytes=new Uint8ClampedArray(bin.length);
+ for(let i=0;i @location(0) vec4 {
if (st.y_range && st.y_range.length === 2) { dMin = st.y_range[0]; dMax = st.y_range[1]; }
mkCtx.clearRect(0,0,pw,ph);
const sets=st.markers||[];
+ if(p._rasterCache){
+ const live=new Set(sets.map(set=>set.id));
+ for(const id of [...p._rasterCache.keys()]) if(!live.has(id)) p._rasterCache.delete(id);
+ }
if(!sets.length) return;
const hsi = hoverState ? hoverState.si : -1;
@@ -7567,33 +7592,26 @@ fn fs(in : VsOut) -> @location(0) vec4 {
} else if(type==='raster'){
// A single RGBA image stretched across data-coord `extent`. Heavy bytes
// ride the geom channel (st.raster_geom[id]); fall back to inline. The
- // decoded OffscreenCanvas is cached on the set so view-only redraws blit
- // without re-decoding. The clip block above already scoped any sector.
+ // clip block above already scoped any sector.
const rg = (st.raster_geom && st.raster_geom[ms.id]) || ms;
const b64 = rg.image_b64 || '';
const iw = rg.image_width|0, ih = rg.image_height|0;
if(b64 && iw>0 && ih>0){
- if(ms._rasterKey!==b64 || !ms._rasterBmp){
- try{
- const bin=atob(b64);
- const bytes=new Uint8ClampedArray(bin.length);
- for(let i=0;i {
+ const found = {red: {count: 0, minX: Infinity, maxX: -Infinity},
+ green: {count: 0, minX: Infinity, maxX: -Infinity}}
+ for (const canvas of document.querySelectorAll('canvas')) {
+ const context = canvas.getContext('2d')
+ if (!context || !canvas.width || !canvas.height) continue
+ const data = context.getImageData(0, 0, canvas.width, canvas.height).data
+ for (let i = 0; i < data.length; i += 4) {
+ if (data[i + 3] < 128) continue
+ const red = data[i], green = data[i + 1], blue = data[i + 2]
+ const colour = red > 150 && green < 80 && blue < 80 ? found.red
+ : green > 150 && red < 80 && blue < 80 ? found.green : null
+ if (!colour) continue
+ const x = (i / 4) % canvas.width
+ colour.count++
+ colour.minX = Math.min(colour.minX, x)
+ colour.maxX = Math.max(colour.maxX, x)
+ }
+ }
+ return found
+}"""
+
+_TWO_FRAMES = "() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))"
+
+
+def _coordinate_axis():
+ fig, ax = apl.subplots(1, 1, figsize=(300, 300))
+ return fig, ax.axes2d(xlim=(0.0, 1.0), ylim=(0.0, 1.0), aspect="equal")
+
+
+def _wheel_zoom(page):
+ page.mouse.move(GRID_PAD + 150, GRID_PAD + 130)
+ for _ in range(3):
+ page.mouse.wheel(0, -240)
+ page.wait_for_timeout(50)
+ page.evaluate(_TWO_FRAMES)
+ page.wait_for_timeout(100)
+
+
+class TestRasterSurvivesZoom:
+ def test_a_wheel_zoom_keeps_the_raster_and_the_markers_after_it(self, interact_page):
+ fig, coordinates = _coordinate_axis()
+ coordinates.add_raster(np.tile(np.array(RED, np.uint8), (8, 8, 1)),
+ extent=(0.0, 1.0, 0.0, 1.0))
+ # Drawn after the raster, so a raster that throws takes it down too.
+ coordinates.scatter([0.5], [0.5], s=10, c="#00ff00", edgecolors="#00ff00")
+ page = interact_page(fig)
+ before = page.evaluate(_COLOUR_EXTENTS)
+ assert before["red"]["count"] > 1000, f"the raster was not drawn to begin with: {before}"
+ assert before["green"]["count"] > 20, f"the marker was not drawn to begin with: {before}"
+
+ _wheel_zoom(page)
+
+ after = page.evaluate(_COLOUR_EXTENTS)
+ assert after["red"]["count"] > 1000, f"zooming blanked the raster: {before} -> {after}"
+ assert after["green"]["count"] > 20, f"zooming blanked the marker: {before} -> {after}"
+
+
+class TestRasterRedecodes:
+ def test_the_same_bytes_in_a_new_shape_are_decoded_again(self, interact_page):
+ # Two red pixels then two green ones: as 2×2 the red row sits above the
+ # green one; as 4×1 the red half sits left of the green half.
+ fig, coordinates = _coordinate_axis()
+ square = np.array([[RED, RED], [GREEN, GREEN]], dtype=np.uint8)
+ coordinates.add_raster(square, extent=(0.0, 1.0, 0.0, 1.0))
+ page = interact_page(fig)
+ stacked = page.evaluate(_COLOUR_EXTENTS)
+ assert stacked["red"]["maxX"] > stacked["green"]["minX"] + 50, \
+ f"the 2x2 raster should put red above green: {stacked}"
+
+ page.evaluate("""(panelId) => {
+ const name = 'panel_' + panelId + '_geom'
+ const geom = JSON.parse(window._aplModel.get(name))
+ for (const id in geom.raster_geom) {
+ geom.raster_geom[id].image_width = 4
+ geom.raster_geom[id].image_height = 1
+ }
+ window._aplModel.set(name, JSON.stringify(geom))
+ }""", coordinates._id)
+ page.evaluate(_TWO_FRAMES)
+
+ side_by_side = page.evaluate(_COLOUR_EXTENTS)
+ assert side_by_side["red"]["count"] > 1000 and side_by_side["green"]["count"] > 1000, \
+ f"the 4x1 raster lost a colour: {side_by_side}"
+ assert side_by_side["red"]["maxX"] <= side_by_side["green"]["minX"] + 2, \
+ f"the 4x1 raster was drawn from the stale 2x2 image: {side_by_side}"
diff --git a/upcoming_changes/76.bugfix.rst b/upcoming_changes/76.bugfix.rst
new file mode 100644
index 000000000..99a40d146
--- /dev/null
+++ b/upcoming_changes/76.bugfix.rst
@@ -0,0 +1 @@
+Zooming or panning a coordinate axis no longer blanks an :meth:`~anyplotlib.plotxy.PlotXY.add_raster` image, and every marker drawn after it, until the next update from Python.