From 3312b6686908a4b906f498bcf8b212f1a7412673 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Thu, 17 Sep 2026 11:22:23 -0500 Subject: [PATCH 1/3] Keep a decoded raster out of the state a zoom writes back A raster marker cached its decoded image on the marker set itself, and the marker set is part of the panel state the wheel and pan handlers serialise back through the model. A decoded canvas serialises to `{}`, so after the echo the panel held a truthy "cached image" under a matching key, skipped the decode, and drawImage threw. The model swallows listener errors, so the draw stopped after the grid: the raster and every marker drawn after it vanished until the next push from Python. The decoded image now lives in a per-panel map keyed by the marker set's id, pruned to the sets still present. Found in SpyDE's orientation-mapping refine window, where zooming an IPF heatmap blanked it until the crosshair moved. --- anyplotlib/figure_esm.js | 46 ++++++++++----- .../tests/test_plotxy/test_raster_zoom.py | 58 +++++++++++++++++++ upcoming_changes/+raster-zoom.bugfix.rst | 1 + 3 files changed, 90 insertions(+), 15 deletions(-) create mode 100644 anyplotlib/tests/test_plotxy/test_raster_zoom.py create mode 100644 upcoming_changes/+raster-zoom.bugfix.rst diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 760f0f0df..42900ecfc 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -7412,6 +7412,25 @@ 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 + // change, 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.key===b64) 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 +7590,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 { + let count = 0 + 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 && data[i] > 150 && data[i + 1] < 80 && data[i + 2] < 80) count++ + } + } + return count +}""" + +_TWO_FRAMES = "() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))" + + +def _red_raster_figure(): + fig, ax = apl.subplots(1, 1, figsize=(300, 300)) + coordinates = ax.axes2d(xlim=(0.0, 1.0), ylim=(0.0, 1.0), aspect="equal") + red = np.zeros((8, 8, 4), dtype=np.uint8) + red[..., 0] = 255 + red[..., 3] = 255 + coordinates.add_raster(red, extent=(0.0, 1.0, 0.0, 1.0)) + return fig + + +class TestRasterSurvivesZoom: + def test_a_wheel_zoom_keeps_the_raster(self, interact_page): + page = interact_page(_red_raster_figure()) + before = page.evaluate(_RED_PIXELS) + assert before > 1000, f"the raster was not drawn to begin with ({before} red pixels)" + + 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) + + after = page.evaluate(_RED_PIXELS) + assert after > 1000, f"zooming blanked the raster ({before} red pixels before, {after} after)" diff --git a/upcoming_changes/+raster-zoom.bugfix.rst b/upcoming_changes/+raster-zoom.bugfix.rst new file mode 100644 index 000000000..99a40d146 --- /dev/null +++ b/upcoming_changes/+raster-zoom.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. From e0adf821d5b11bef8d4b17f345586bdf275d3ae9 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Thu, 17 Sep 2026 11:22:45 -0500 Subject: [PATCH 2/3] chore: name the changelog fragment for PR 76 --- upcoming_changes/{+raster-zoom.bugfix.rst => 76.bugfix.rst} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename upcoming_changes/{+raster-zoom.bugfix.rst => 76.bugfix.rst} (100%) diff --git a/upcoming_changes/+raster-zoom.bugfix.rst b/upcoming_changes/76.bugfix.rst similarity index 100% rename from upcoming_changes/+raster-zoom.bugfix.rst rename to upcoming_changes/76.bugfix.rst From 9658b5c47184eac77ad3333261d4b786e6bf581d Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Thu, 17 Sep 2026 12:15:02 -0500 Subject: [PATCH 3/3] Re-decode a raster when its shape changes; test the markers after it The per-panel raster cache matched on the bytes alone, but the same bytes are a different image at a different shape (1x4 against 2x2), so a replaced raster of the same bytes drew from the stale decode. The cache now matches on the bytes and both dimensions. The zoom test also draws a marker after the raster: a raster that throws takes every later marker down with it, which is what made the original bug look like a blank panel. A second test swaps a raster's shape under the same bytes and checks the redraw. FIGURE_ESM.md: the _rasterBitmap insertion moved 62 anchors; they are shifted by the diff hunks and every function anchor checked against its declaration. The raster note now describes the per-panel cache. --- AGENTS.md | 2 +- anyplotlib/FIGURE_ESM.md | 92 ++++++++-------- anyplotlib/figure_esm.js | 10 +- .../tests/test_plotxy/test_raster_zoom.py | 101 +++++++++++++----- 4 files changed, 130 insertions(+), 75 deletions(-) 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 42900ecfc..e8e1e8700 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -7413,12 +7413,14 @@ fn fs(in : VsOut) -> @location(0) vec4 { } // The decoded image of raster marker set `id`, re-decoded only when its bytes - // change, or null if they cannot be decoded. Kept in a per-panel map rather - // than on the marker set itself, which is serialised state. + // 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.key===b64) return cached.bitmap; + if(cached && cached.b64===b64 && cached.width===width && cached.height===height) + return cached.bitmap; let bitmap=null; try{ const bin=atob(b64); @@ -7427,7 +7429,7 @@ fn fs(in : VsOut) -> @location(0) vec4 { bitmap=new OffscreenCanvas(width,height); bitmap.getContext('2d').putImageData(new ImageData(bytes, width, height),0,0); }catch(_){ bitmap=null; } - p._rasterCache.set(id, {key:b64, bitmap}); + p._rasterCache.set(id, {b64, width, height, bitmap}); return bitmap; } diff --git a/anyplotlib/tests/test_plotxy/test_raster_zoom.py b/anyplotlib/tests/test_plotxy/test_raster_zoom.py index 84ceacc65..ef55c735d 100644 --- a/anyplotlib/tests/test_plotxy/test_raster_zoom.py +++ b/anyplotlib/tests/test_plotxy/test_raster_zoom.py @@ -1,11 +1,13 @@ """ -A raster on a coordinate axis survives the view write-back a zoom makes. +A raster on a coordinate axis survives the view write-back a zoom makes, and +its decoded image is rebuilt whenever its bytes or its shape change. The wheel handler writes the panel's view state back through the model, and the model's change listener redraws the panel from that state after a round trip through JSON. The decoded raster must not live in that state: a decoded canvas serialises to ``{}``, which then passed for a cached image, made -``drawImage`` throw, and left the panel blank until the next push from Python. +``drawImage`` throw, and left the raster — and every marker drawn after it — +blank until the next push from Python. """ from __future__ import annotations @@ -15,44 +17,93 @@ GRID_PAD = 8 # gridDiv padding: the canvas's offset from the page origin -_RED_PIXELS = """() => { - let count = 0 +RED = (255, 0, 0, 255) +GREEN = (0, 255, 0, 255) + +# Where the red and the green pixels are, across every 2-D canvas. +_COLOUR_EXTENTS = """() => { + 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 && data[i] > 150 && data[i + 1] < 80 && data[i + 2] < 80) count++ + 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 count + return found }""" _TWO_FRAMES = "() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))" -def _red_raster_figure(): +def _coordinate_axis(): fig, ax = apl.subplots(1, 1, figsize=(300, 300)) - coordinates = ax.axes2d(xlim=(0.0, 1.0), ylim=(0.0, 1.0), aspect="equal") - red = np.zeros((8, 8, 4), dtype=np.uint8) - red[..., 0] = 255 - red[..., 3] = 255 - coordinates.add_raster(red, extent=(0.0, 1.0, 0.0, 1.0)) - return fig + 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(self, interact_page): - page = interact_page(_red_raster_figure()) - before = page.evaluate(_RED_PIXELS) - assert before > 1000, f"the raster was not drawn to begin with ({before} red pixels)" - - page.mouse.move(GRID_PAD + 150, GRID_PAD + 130) - for _ in range(3): - page.mouse.wheel(0, -240) - page.wait_for_timeout(50) + 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) - page.wait_for_timeout(100) - after = page.evaluate(_RED_PIXELS) - assert after > 1000, f"zooming blanked the raster ({before} red pixels before, {after} after)" + 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}"