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.