diff --git a/AGENTS.md b/AGENTS.md index 09b3688d..a2fc0079 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,386 lines. +the 2-D function table). Both were last verified at 12,407 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 8f39a328..6a3ef0f2 100644 --- a/anyplotlib/FIGURE_ESM.md +++ b/anyplotlib/FIGURE_ESM.md @@ -63,18 +63,18 @@ Rule 5 – Text never clips. Optional gutters earn real layout space: | `drawScaleBar2d` / `drawColorbar2d` | 3076 / 3356 | | **Floating keys**: `_keyEnsure` / `_keyRect` / `drawKeys` | 3175 / 3198 / 3211 | | `_drawAxes2d` (ticks, labels, title) | 3468 | -| `drawOverlay2d` / `drawMarkers2d` | 3621 / 3785 | +| `drawOverlay2d` / `drawMarkers2d` | 3623 / 3787 | | **Image layers**: `_layerBytes` / `_layerBitmap` / `_drawLayers2d` | 2701 / 2725 / 2786 | | Binary-bytes splice: `_spliceBinaryBytes` / `_registerBinaryPixelListeners` | 849 / 880 | -| **Hover readout**: `_pixelValue2d` / `_readoutInfo2d` | 4629 / 4714 | -| `_notifyReadout` / `_updateStatus2d` / `_armValueProbe` | 4753 / 4768 / 4789 | -| **3D drawing**: `draw3d` | 5765 | -| Event emission `_emitEvent` | 6602 | -| 3D event handlers `_attachEvents3d` | 6659 | -| **1D drawing**: `draw1d` | 6883 | -| `_drawLine` (1D series + markers) | 7036 | -| `drawOverlay1d` / `drawMarkers1d` | 7329 / 7413 | -| Marker hit-test `_markerHitTest2d` | 7680 | +| **Hover readout**: `_pixelValue2d` / `_readoutInfo2d` | 4631 / 4716 | +| `_notifyReadout` / `_updateStatus2d` / `_armValueProbe` | 4755 / 4770 / 4791 | +| **3D drawing**: `draw3d` | 5767 | +| Event emission `_emitEvent` | 6604 | +| 3D event handlers `_attachEvents3d` | 6661 | +| **1D drawing**: `draw1d` | 6885 | +| `_drawLine` (1D series + markers) | 7038 | +| `drawOverlay1d` / `drawMarkers1d` | 7331 / 7415 | +| Marker hit-test `_markerHitTest2d` | 7682 | > **`raster` marker (1D/PlotXY)** — `drawMarkers1d` has a `type==='raster'` > branch that blits a single RGBA image across data-coord `extent` (the fast @@ -83,22 +83,22 @@ Rule 5 – Text never clips. Optional gutters earn real layout space: > 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` | 7937 | -| 2D events `_attachEvents2d` | 8004 | -| 1D events `_attachEvents1d` | 8390 | -| 2D widget drag `_ovHitTest2d` / `_doDrag2d` | 8665 / 8944 | -| **Brush strokes**: `_brushLiveBegin` / `_brushCommit` / `_brushErase` / `_brushPaintAt` | 8857 / 8871 / 8900 / 8935 | -| 1D widget drag `_canvasXToFrac1d` … / snapping `_snapVal` | 9069 / 9142 | -| Shared-axis propagation `_getShareGroups` | 9213 | -| Figure resize `_applyFigResizeDOM` | 9277 | -| **Bar chart**: `_barGeom` / `drawBar` / `_attachEventsBar` | 9471 / 9534 / 9910 | -| Generic redraw `_redrawPanel` | 10100 | -| **PNG export**: `_compositeCanvas` / `exportCanvas` / `exportPNG` | 10263 / 10459 / 10518 | -| Native-resolution render `_withNativeSize` | 10239 | -| **Export UI**: `_toast` / `_downloadCanvas` / `_openMenu` | 10552 / 10661 / 10840 | -| Export registry `registerExportAction` | 10717 | -| **Embedding API**: `createLocalModel` / `mount` | 11231 / 11287 | -| **Navigated embed**: `decodeBlocks` / `mountNavigated` | 11542 / 11929 | +| 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 | > **`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` @@ -287,8 +287,8 @@ st.colorbar_label_size (label font sizes; optional) | `drawScaleBar2d(p)` | 3076 | Physical scale bar | | `_rawBand(st)` / `_displayFrac(st, val)` / `_buildLut32(st)` | 2596 / 2606 / 2614 | The quantisation band the u8 bytes were encoded over, the ONE rule mapping a value through the display window and scale mode to a colormap fraction (shared by the LUT and the colorbar strip, so the strip's colours are the image's), then the 256-entry LUT built from both. `_rawBand` mirrors Python `_tile_quant_clim`: a DEGENERATE band (`raw_max <= raw_min`) is UNSET and falls back to `display_min/max`. Both render paths and the colorbar go through it — honouring a `(0, 0)` band paints solid black | | `drawColorbar2d(p)` | 3356 | Gradient strip coloured through the display window (`_displayFrac` — saturated beyond it, like the image) + min/max marks (band-relative, via `_rawBand`) + the two values written beside them (`fmtRange`; kept inside the strip by the glyphs' measured extent, held apart on a tiny range, the maximum alone at its own place on a strip too short for both; omitted when the layout dropped the gutter, `p._cbTickW == 0`) + rotated label centred right after the values' measured width (never past the reserved gutter) | -| `_drawAxes2d(p)` | 3468 | Ticks (edge labels nudged inward both axes), axis labels + title via `_drawTex` | -| `drawOverlay2d(p)` / `drawMarkers2d(p)` | 3621 / 3785 | Widgets / marker groups | +| `_drawAxes2d(p)` | 3468 | Ticks placed through `_imgToCanvas2d` (pixel-centre axes, letterbox-aware; edge labels nudged inward both axes), axis labels + title via `_drawTex` | +| `drawOverlay2d(p)` / `drawMarkers2d(p)` | 3623 / 3787 | Widgets / marker groups | Zoom model: at `zoom=1` the whole image fills the fit-rect; at `zoom=Z>1` a `1/Z` region fills it. `_imgToCanvas2d` / `_canvasToImg2d` must stay exact @@ -301,6 +301,14 @@ coordinate to `xdata`/`ydata` through `_imgToAxisVal2d` (imshow axes are per-pixel centres, pcolormesh axes cell edges). Code that addresses texels or the detail region by pixel EDGE (`_pixelValue2d`) adds the `0.5` back itself. +The tick gutters (`_drawAxes2d`) use the same chain in reverse: tick value → +`_axisValToImg2d` (imshow; pcolormesh goes through `_axisValToFrac` × n − ½) → +`_imgToCanvas2d`, so ticks honour the letterboxed fit rect, zoom < 1, pan +clamping and descending (`origin='lower'`) axes for free. The visible value +range is the gutter's two ends through `_canvasToImg2d`, clamped to the outer +pixel edges. Python's `Plot2D._axis_extent_frac` (behind `set_view` / +`set_xlim`) mirrors `_axisValToImg2d` — keep the two in step. + `texts` markers (1-D and 2-D) set their font and optional outline through `_markerTextStyle(ctx, ms)` (`fontweight`, `outline_color`, `outline_width`) and draw via `_drawMarkerText`, which strokes the halo under the fill. @@ -652,13 +660,13 @@ exportCanvas(same opts) → {canvas, width, height} // synchronous, throws | Function | Line | Purpose | |----------|------|---------| -| `_cssScale` | 10135 | inverse of `_applyScale`'s `transform:scale()` | -| `_panelBox` | 10146 | the element whose rect bounds one panel | -| `_neutralizeView` / `_restoreView` | 10155 / 10180 | transient whole-extent view | -| `_nativeGeom` / `_nativeGuard` | 10195 / 10214 | native size + why-not message | -| `_withNativeSize` | 10239 | resize → redraw → run → restore | -| `_compositeCanvas` | 10263 | the compositor (`_drawEl` / `_drawPanel` …) | -| `exportCanvas` / `exportPNG` | 10459 / 10518 | orchestrator / data-URL wrapper | +| `_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 | **The whole pipeline is ONE synchronous task** — theme swap, view reset, native resize, composite, restore — so the browser never paints an intermediate state @@ -754,13 +762,13 @@ leaders that cross into the panel included. Pinned by | Function | Line | Purpose | |----------|------|---------| -| `_toast` | 10552 | transient bottom-centre message | -| `_copyCanvas` | 10587 | clipboard write + feature detection | -| `_showPngPreview` | 10611 | framed-document download fallback | -| `_downloadCanvas` | 10661 | `` or the preview | -| `registerExportAction` | 10717 | downstream extension point | -| `_menuRows` / `_openMenu` | 10771 / 10840 | menu model / DOM | -| `_panelAtPoint` | 10952 | hit test (insets first — they sit on top) | +| `_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) | - **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 @@ -844,16 +852,16 @@ bindings, let it dispatch", rather than a hand-written program per result kind. | Function | Line | Purpose | |----------|------|---------| -| `decodeBlocks` | 11542 | one base64 `fetch` → one ArrayBuffer → a typed-array view per manifest entry | -| `dense` | 11568 | `at` / `gather` / `reduce` over a block whose leading axes are the nav axes | -| `ragged` | 11633 | the same three, over a row-pointer block (`offsets` + one array per column) | -| `maskFromWidget` | 11716 | rectangle / circle / annulus widget dict → `Uint8Array` (carries `width`/`height`) | -| `rasterDisks` | 11756 | splat `{x, y, intensity}` rows as filled disks — the base image of a vectors panel | -| `robustLevels` / `toU8` | 11785 / 11826 | the percentile window and the 8-bit code map, one implementation | -| `panelAxis` | 11904 | a 1-D panel's decoded x axis (`_1dXArr`, else `x_axis_b64`) | -| `installTouchShim` / `reportEmbedHeight` | 11841 / 11860 | page chrome: touch → mouse, `postMessage({aplEmbedHeight})` | -| `encodeBase64` / `typedArrayBytes` | 11880 / 11888 | a 3-D cloud's geometry channel is base64, not the binary side table | -| `mountNavigated` | 11929 | mount + bind + dispatch; resolves to the mount handle plus `dispatch`/`index`/`blocks` | +| `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` | `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 e834de5c..760f0f0d 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -3472,7 +3472,6 @@ function render({ model, el, onResize, onReadout }) { const imgH = p.imgH||Math.max(1, ph - PAD_T - PAD_B); const xArr=st.x_axis||[], yArr=st.y_axis||[]; const TICK=6; - const zoom=st.zoom, cx=st.center_x, cy=st.center_y; const units=st.units||'px'; const hasPhysAxis = (st.is_mesh || st.has_axes) && xArr.length>=2 && yArr.length>=2; if(st.axis_visible===false){ @@ -3485,16 +3484,22 @@ function render({ model, el, onResize, onReadout }) { const hasX=hasPhysAxis&&st.axis_visible!==false&&st.x_ticks_visible!==false&&p.xCtx&&p.xAxisCanvas&&p.xAxisCanvas.style.display!=='none'; const hasY=hasPhysAxis&&st.axis_visible!==false&&st.y_ticks_visible!==false&&p.yCtx&&p.yAxisCanvas&&p.yAxisCanvas.style.display!=='none'; - function _visFrac(z,c){ - if(z>=1.0){const h=0.5/z;const cc=Math.max(h,Math.min(1-h,c));return[cc-h,cc+h];} - return[0,1]; - } - function _fracToPx(frac, z, center, span) { - if(z>=1.0){ - const h=0.5/z, cc=Math.max(h,Math.min(1-h,center)); - return (frac-(cc-h))/(2*h)*span; - } - return (span-span*z)/2+frac*span*z; + // Ticks go through the SAME transform markers, widgets and pointer events + // use — axis value → centre-convention image coordinate → _imgToCanvas2d — + // so a tick sits on the pixels it labels at any zoom and pan, and on the + // letterboxed fit rect rather than the whole gutter. (They used to take the + // value's fraction of the axis array as a fraction of the gutter: imshow's + // first and last pixel CENTRES landed on the gutter's ends, stretching every + // label outward, and a pillarboxed image's ticks ignored its offset.) + const iw=st.image_width||1, ih=st.image_height||1; + const _valToImg=(arr,v,n)=>st.is_mesh ? _axisValToFrac(arr,v)*n-0.5 : _axisValToImg2d(arr,v,n); + // [lo, hi] axis values over the visible stretch of the image along axis `ax` + // (0 = x, 1 = y): the gutter's two ends, clamped to the outer pixel edges. + function _visRange(arr, n, ax){ + const a=_canvasToImg2d(0,0,st,imgW,imgH)[ax], b=_canvasToImg2d(imgW,imgH,st,imgW,imgH)[ax]; + const va=_imgToAxisVal2d(st,arr,Math.max(-0.5,Math.min(a,b)),n); + const vb=_imgToAxisVal2d(st,arr,Math.min(n-0.5,Math.max(a,b)),n); + return [Math.min(va,vb), Math.max(va,vb)]; } // ── X axis canvas: imgW × PAD_B, origin at top-left ───────────────── @@ -3505,8 +3510,7 @@ function render({ model, el, onResize, onReadout }) { p.xCtx.fillStyle=theme.axisBg; p.xCtx.fillRect(0,0,aw,ah); p.xCtx.strokeStyle=theme.axisStroke; p.xCtx.lineWidth=1; p.xCtx.beginPath(); p.xCtx.moveTo(0,0); p.xCtx.lineTo(aw,0); p.xCtx.stroke(); - const [xF0,xF1]=_visFrac(zoom,cx); - const xVMin=_axisFracToVal(xArr,xF0), xVMax=_axisFracToVal(xArr,xF1); + const [xVMin,xVMax]=_visRange(xArr,iw,0); const step=findNice((xVMax-xVMin)/Math.max(3,Math.floor(imgW/60))); p.xCtx.strokeStyle=theme.tickStroke; p.xCtx.fillStyle=theme.tickText; p.xCtx.font=(st.tick_size||10)+'px sans-serif'; @@ -3521,12 +3525,12 @@ function render({ model, el, onResize, onReadout }) { let lastPx=-Infinity; for(let ti=0;tiimgW) continue; p.xCtx.beginPath(); p.xCtx.moveTo(px2,0); p.xCtx.lineTo(px2,TICK); p.xCtx.stroke(); - // Skip label if too close to the previous one - if(px2-lastPx>=minLabelGap){ + // Skip label if too close to the previous one (either direction: a + // descending axis walks the gutter right to left) + if(Math.abs(px2-lastPx)>=minLabelGap){ const txt=fmtVal(v); // Nudge edge labels inward so they are never clipped by the canvas const hw=p.xCtx.measureText(txt).width/2; @@ -3549,8 +3553,7 @@ function render({ model, el, onResize, onReadout }) { p.yCtx.fillStyle=theme.axisBg; p.yCtx.fillRect(0,0,aw,ah); p.yCtx.strokeStyle=theme.axisStroke; p.yCtx.lineWidth=1; p.yCtx.beginPath(); p.yCtx.moveTo(aw,0); p.yCtx.lineTo(aw,ah); p.yCtx.stroke(); - const [yF0,yF1]=_visFrac(zoom,cy); - const yVMin=_axisFracToVal(yArr,yF0), yVMax=_axisFracToVal(yArr,yF1); + const [yVMin,yVMax]=_visRange(yArr,ih,1); const step=findNice((yVMax-yVMin)/Math.max(3,Math.floor(imgH/60))); p.yCtx.strokeStyle=theme.tickStroke; p.yCtx.fillStyle=theme.tickText; p.yCtx.font=(st.tick_size||10)+'px sans-serif'; @@ -3561,11 +3564,10 @@ function render({ model, el, onResize, onReadout }) { let lastPy=-Infinity; for(let ti=0;tiimgH) continue; p.yCtx.beginPath(); p.yCtx.moveTo(aw,py2); p.yCtx.lineTo(aw-TICK,py2); p.yCtx.stroke(); - if(py2-lastPy>=minLabelGapY){ + if(Math.abs(py2-lastPy)>=minLabelGapY){ // Nudge edge labels inward so digits are never cut by the canvas const vh=(st.tick_size||10)*0.5+1; p.yCtx.fillText(fmtVal(v), aw-TICK-2, Math.min(Math.max(py2,vh), ah-vh)); @@ -8001,6 +8003,25 @@ fn fs(in : VsOut) -> @location(0) vec4 { return arr[lo] + (pos - lo) * (arr[lo + 1] - arr[lo]); } + // The inverse for imshow's centre arrays: an axis value → the centre-convention + // image coordinate it sits at, continued linearly past either end (the tick + // gutters place labels in the outer half of the edge pixels through this). + // `arr` is monotonic, ascending or descending (origin='lower' reverses y). + function _axisValToImg2d(arr, v, n) { + const m = arr.length - 1; + if (m < 1) return 0; + const asc = arr[m] >= arr[0]; + // The last segment start at or before v — segment 0 below the range, m-1 above. + let lo = 0, hi = m - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (asc ? arr[mid] <= v : arr[mid] >= v) lo = mid; else hi = mid - 1; + } + const d = arr[lo + 1] - arr[lo]; + const pos = lo + (d ? (v - arr[lo]) / d : 0); + return n > 1 ? pos * (n - 1) / m : 0; + } + function _attachEvents2d(p) { const { overlayCanvas } = p; let localOnly = false; diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py index 28f235c1..72f703c1 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -2303,6 +2303,30 @@ def add_hline_widget(self, y: float | None = None, color: str = "#00e5ff", # ------------------------------------------------------------------ # View control # ------------------------------------------------------------------ + def _axis_extent_frac(self, arr, value: float, n: int) -> float: + """An axis value → its fraction of the image extent (0 = leading edge). + + Mirrors the tick gutters in ``figure_esm.js``: an ``imshow`` axis holds + one value per pixel centre (``_axisValToImg2d``, continued linearly past + either end), so its first value lies half a pixel in from the edge; a + ``pcolormesh`` axis holds the cell edges and spans the image exactly. + """ + arr = np.asarray(arr, dtype=float) + m = len(arr) - 1 + if self._state.get("is_mesh"): + return (value - arr[0]) / ((arr[-1] - arr[0]) or 1.0) + idx = np.arange(m + 1, dtype=float) + if arr[-1] < arr[0]: # descending (origin='lower' y) + arr, idx = arr[::-1], idx[::-1] + if value < arr[0] or value > arr[-1]: + k = 0 if value < arr[0] else m - 1 # extrapolate the end segment + d = (arr[k + 1] - arr[k]) or 1.0 + pos = idx[k] + (value - arr[k]) / d * (idx[k + 1] - idx[k]) + else: + pos = float(np.interp(value, arr, idx)) + centre = pos * (n - 1) / m if n > 1 else 0.0 + return (centre + 0.5) / n + def set_view(self, x0: float | None = None, x1: float | None = None, y0: float | None = None, y1: float | None = None) -> None: @@ -2319,29 +2343,34 @@ def set_view(self, Translates the requested rectangle into the ``zoom`` / ``center_x`` / ``center_y`` state values used by the 2-D JS renderer. + + On an ``imshow`` panel the axis values sit at pixel *centres*, so the + image extends half a pixel beyond the first and last value (as + matplotlib's ``imshow`` extent does): ``set_view(x0=-0.5, x1=n - 0.5)`` + on a default ``arange(n)`` axis is the whole image, and the tick + labelled ``x0`` sits at the view's left edge. """ xarr = np.asarray(self._state["x_axis"]) yarr = np.asarray(self._state["y_axis"]) if len(xarr) < 2 or len(yarr) < 2: return - xmin, xmax = float(xarr[0]), float(xarr[-1]) - ymin, ymax = float(yarr[0]), float(yarr[-1]) - x_span = xmax - xmin or 1.0 - y_span = ymax - ymin or 1.0 - zoom_candidates = [] if x0 is not None and x1 is not None: - fx0 = max(0.0, min(1.0, (float(x0) - xmin) / x_span)) - fx1 = max(0.0, min(1.0, (float(x1) - xmin) / x_span)) + n = int(self._state.get("image_width") or len(xarr)) + fx0 = max(0.0, min(1.0, self._axis_extent_frac(xarr, float(x0), n))) + fx1 = max(0.0, min(1.0, self._axis_extent_frac(xarr, float(x1), n))) + fx0, fx1 = min(fx0, fx1), max(fx0, fx1) if fx1 > fx0: self._state["center_x"] = (fx0 + fx1) / 2.0 zoom_candidates.append(1.0 / (fx1 - fx0)) if y0 is not None and y1 is not None: - fy0 = max(0.0, min(1.0, (float(y0) - ymin) / y_span)) - fy1 = max(0.0, min(1.0, (float(y1) - ymin) / y_span)) + n = int(self._state.get("image_height") or len(yarr)) + fy0 = max(0.0, min(1.0, self._axis_extent_frac(yarr, float(y0), n))) + fy1 = max(0.0, min(1.0, self._axis_extent_frac(yarr, float(y1), n))) + fy0, fy1 = min(fy0, fy1), max(fy0, fy1) if fy1 > fy0: self._state["center_y"] = (fy0 + fy1) / 2.0 zoom_candidates.append(1.0 / (fy1 - fy0)) diff --git a/anyplotlib/tests/test_plot2d/test_axis_ticks.py b/anyplotlib/tests/test_plot2d/test_axis_ticks.py new file mode 100644 index 00000000..40866e0c --- /dev/null +++ b/anyplotlib/tests/test_plot2d/test_axis_ticks.py @@ -0,0 +1,205 @@ +""" +2-D tick gutters label the pixels they sit next to. + +imshow axis arrays hold one value per pixel CENTRE. The gutters used to take a +value's fraction of the axis array as a fraction of the gutter, so the first and +last centres landed on the gutter's two ends — every label stretched outward by +up to half a pixel — and a letterboxed image's ticks ignored its offset inside +the gutter altogether. Ticks now go through ``_imgToCanvas2d``, the transform +markers and pointer events use, and ``set_view`` / ``set_xlim`` read the axis +the same way. + +The rendering tests find the tick marks in a real screenshot and compare them +with :meth:`Plot2D.data_to_display` (itself checked against drawn markers in +``test_layouts/test_coord_conversion.py``). +""" +from __future__ import annotations + +import pathlib + +import numpy as np +import pytest + +import anyplotlib as apl + +GRID_PAD = 8 +_ESM = pathlib.Path(apl.__file__).parent / "figure_esm.js" + + +def _dark_runs(line: np.ndarray, thresh: int = 40) -> list[float]: + """Centres of runs of pixels clearly darker than the line's background.""" + lum = line[..., :3].astype(int).sum(axis=-1) / 3 + bg = np.median(lum) + hit = np.flatnonzero(bg - lum > thresh) + if not len(hit): + return [] + runs, start = [], hit[0] + for a, b in zip(hit[:-1], hit[1:]): + if b != a + 1: + runs.append((start + a) / 2) + start = b + runs.append((start + hit[-1]) / 2) + return runs + + +def _x_ticks(img, plot): + """Page x of every tick mark under the image (3 px into the x gutter).""" + box = plot.plot_box() + row = int(round(GRID_PAD + box["y"] + box["height"] + 3)) + return _dark_runs(img[row]) + + +def _y_ticks(img, plot): + """Page y of every tick mark left of the image (3 px into the y gutter).""" + box = plot.plot_box() + col = int(round(GRID_PAD + box["x"] - 3)) + return _dark_runs(img[:, col]) + + +def _near(ticks, want, tol=1.5): + return all(any(abs(t - w) <= tol for t in ticks) for w in want) + + +class TestTicksSitOnPixelCentres: + def test_pillarboxed_x_ticks(self, take_screenshot): + """A 6×4 image in a wide panel: the fit rect is offset inside the + gutter, and tick i must sit under the centre of column i.""" + fig, ax = apl.subplots(1, 1, figsize=(520, 330)) + plot = ax.imshow(np.zeros((4, 6)), axes=[np.arange(6.0), np.arange(4.0)]) + ticks = _x_ticks(take_screenshot(fig), plot) + want = [GRID_PAD + plot.data_to_display([i, 0])[0] for i in range(6)] + assert _near(ticks, want), f"ticks at {ticks}, pixel centres at {want}" + + def test_letterboxed_y_ticks(self, take_screenshot): + fig, ax = apl.subplots(1, 1, figsize=(420, 460)) + plot = ax.imshow(np.zeros((4, 6)), axes=[np.arange(6.0), np.arange(4.0)]) + ticks = _y_ticks(take_screenshot(fig), plot) + want = [GRID_PAD + plot.data_to_display([0, j])[1] for j in range(4)] + assert _near(ticks, want), f"ticks at {ticks}, pixel centres at {want}" + + def test_scaled_axis_ticks_follow_zoom_and_pan(self, take_screenshot): + """Physical units, zoomed into an off-centre window: tick v sits at the + pixel coordinate (v - x0) / dx.""" + fig, ax = apl.subplots(1, 1, figsize=(500, 360)) + dx = 0.25 + plot = ax.imshow(np.zeros((40, 50)), + axes=[10.0 + dx * np.arange(50), dx * np.arange(40)]) + plot.set_view(x0=12.0, x1=15.0, y0=2.0, y1=4.5) + img = take_screenshot(fig) + ticks = _x_ticks(img, plot) + want = [GRID_PAD + plot.data_to_display([(v - 10.0) / dx, 0])[0] + for v in (12.5, 13.0, 13.5, 14.0, 14.5)] + assert _near(ticks, want), f"ticks at {ticks}, values at {want}" + + def test_descending_y_axis_is_ticked(self, take_screenshot): + """origin='lower' reverses the y axis; its labels still land on their + rows.""" + fig, ax = apl.subplots(1, 1, figsize=(420, 460)) + plot = ax.imshow(np.zeros((4, 6)), axes=[np.arange(6.0), np.arange(4.0)], + origin="lower") + ticks = _y_ticks(take_screenshot(fig), plot) + # Row 0 of the displayed image is the TOP, which carries y = 3. + want = [GRID_PAD + plot.data_to_display([0, 3 - v])[1] for v in range(4)] + assert _near(ticks, want), f"ticks at {ticks}, rows at {want}" + + def test_mesh_ticks_still_span_the_cell_edges(self, take_screenshot): + """pcolormesh axes are the n + 1 edges: edge k sits at the leading edge + of cell k. The centre convention does not apply to them, but they + shared the letterbox bug — a pillarboxed mesh's ticks ran across the + whole gutter too.""" + fig, ax = apl.subplots(1, 1, figsize=(520, 330)) + plot = ax.pcolormesh(np.zeros((4, 6)), x_edges=np.arange(7.0), + y_edges=np.arange(5.0)) + ticks = _x_ticks(take_screenshot(fig), plot) + want = [GRID_PAD + plot.data_to_display([k - 0.5, 0])[0] for k in range(7)] + assert _near(ticks, want), f"ticks at {ticks}, cell edges at {want}" + + +class TestSetView: + def test_whole_extent_is_zoom_one(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((16, 32))) + plot.set_view(x0=-0.5, x1=31.5, y0=-0.5, y1=15.5) + assert plot._state["zoom"] == pytest.approx(1.0) + assert plot._state["center_x"] == pytest.approx(0.5) + assert plot._state["center_y"] == pytest.approx(0.5) + + def test_window_between_pixel_centres(self): + """x0 and x1 are where the view's edges land: [7.5, 23.5] on a 32 px + arange axis is columns 8..23 — half the image, centred.""" + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((32, 32))) + plot.set_xlim(7.5, 23.5) + assert plot._state["zoom"] == pytest.approx(2.0) + assert plot._state["center_x"] == pytest.approx(0.5) + + def test_physical_units(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((10, 40)), + axes=[100.0 + 0.5 * np.arange(40), None]) + plot.set_xlim(104.75, 109.75) # pixel coords 9.5 .. 19.5 + assert plot._state["zoom"] == pytest.approx(4.0) + assert plot._state["center_x"] == pytest.approx(15.0 / 40) + + def test_descending_axis(self): + """origin='lower' stores a descending y axis; set_ylim(lo, hi) must + still zoom (both orders of the ends are accepted).""" + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((32, 32)), origin="lower") + plot.set_ylim(7.5, 23.5) + assert plot._state["zoom"] == pytest.approx(2.0) + assert plot._state["center_y"] == pytest.approx(0.5) + + def test_mesh_keeps_edge_mapping(self): + fig, ax = apl.subplots(1, 1) + plot = ax.pcolormesh(np.zeros((8, 8)), x_edges=np.arange(9.0), + y_edges=np.arange(9.0)) + plot.set_xlim(2.0, 6.0) + assert plot._state["zoom"] == pytest.approx(2.0) + assert plot._state["center_x"] == pytest.approx(0.5) + + +def _grab(src: str, name: str) -> str: + start = src.index(f"function {name}(") + depth, i = 0, src.index("{", start) + while True: + depth += {"{": 1, "}": -1}.get(src[i], 0) + if depth == 0: + return src[start:i + 1] + i += 1 + + +class TestJsPythonParity: + """``_axisValToImg2d`` (JS ticks) and ``Plot2D._axis_extent_frac`` (Python + set_view) must agree, and the JS pair must invert each other.""" + + @pytest.fixture(scope="class") + def js(self, _pw_browser): + src = _ESM.read_text(encoding="utf-8") + names = ["_axisFracToVal", "_imgToAxisVal2d", "_axisValToImg2d"] + page = _pw_browser.new_page() + page.evaluate("src => { window._apl = new Function(src)(); }", + "\n".join(_grab(src, n) for n in names) + + "\nreturn {" + ", ".join(names) + "};") + yield page + page.close() + + @pytest.mark.parametrize("arr", [ + list(np.arange(12.0)), + list(3.0 - 0.4 * np.arange(12)), # descending + list(np.cumsum(np.linspace(0.5, 2.0, 12))), # non-uniform + ]) + def test_round_trip_and_parity(self, js, arr): + n = 12 + coords = [-0.5, -0.2, 0.0, 3.3, 7.0, 11.0, 11.5] + back = js.evaluate("""([arr, n, cs]) => cs.map(c => + _apl._axisValToImg2d(arr, _apl._imgToAxisVal2d({}, arr, c, n), n))""", + [arr, n, coords]) + assert back == pytest.approx(coords, abs=1e-9) + + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((4, n)), axes=[np.asarray(arr), None]) + vals = js.evaluate("""([arr, n, cs]) => cs.map(c => + _apl._imgToAxisVal2d({}, arr, c, n))""", [arr, n, coords]) + fracs = [plot._axis_extent_frac(arr, v, n) for v in vals] + assert fracs == pytest.approx([(c + 0.5) / n for c in coords], abs=1e-9) diff --git a/anyplotlib/tests/test_plot2d/test_imshow.py b/anyplotlib/tests/test_plot2d/test_imshow.py index d7b35e5b..b4937883 100644 --- a/anyplotlib/tests/test_plot2d/test_imshow.py +++ b/anyplotlib/tests/test_plot2d/test_imshow.py @@ -481,19 +481,22 @@ def test_add_points_uses_circles_type(self): class TestImshowView: + # Axis values sit at pixel CENTRES, so on an arange axis the image spans + # [-0.5, n - 0.5] and the window [7.5, 23.5] is columns 8..23 — exactly the + # middle half of a 32 px image (see test_axis_ticks.py). + def _make_with_x_axis(self, shape=(32, 32)): data = np.zeros(shape) - x_axis = np.linspace(0.0, float(shape[1]), shape[1]) + x_axis = np.arange(shape[1], dtype=float) fig, ax = apl.subplots(1, 1) return ax.imshow(data, axes=[x_axis, None]) def test_set_view_x_only(self): """set_view(x0, x1) must update center_x and zoom, not view_x0/view_x1.""" plot = self._make_with_x_axis() - plot.set_view(x0=8.0, x1=24.0) - # center_x should be midpoint fraction: (8+24)/2 / 32 = 0.5 + plot.set_view(x0=7.5, x1=23.5) + # edge-space window [8, 24] of 32 px: centred, half the width assert abs(plot._state["center_x"] - 0.5) < 1e-6 - # zoom_x = 32 / (24-8) = 2.0 assert abs(plot._state["zoom"] - 2.0) < 1e-6 assert "view_x0" not in plot._state assert "view_x1" not in plot._state @@ -501,23 +504,23 @@ def test_set_view_x_only(self): def test_set_view_y_only(self): """set_view(y0=..., y1=...) must update center_y and zoom.""" data = np.zeros((32, 32)) - y_axis = np.linspace(0.0, 32.0, 32) + y_axis = np.arange(32, dtype=float) fig, ax = apl.subplots(1, 1) plot = ax.imshow(data, axes=[None, y_axis]) - plot.set_view(y0=8.0, y1=24.0) + plot.set_view(y0=7.5, y1=23.5) assert abs(plot._state["center_y"] - 0.5) < 1e-6 assert abs(plot._state["zoom"] - 2.0) < 1e-6 def test_set_view_xy(self): """set_view(x0, x1, y0, y1) uses minimum zoom when both axes given.""" data = np.zeros((32, 64)) - x_axis = np.linspace(0.0, 64.0, 64) - y_axis = np.linspace(0.0, 32.0, 32) + x_axis = np.arange(64, dtype=float) + y_axis = np.arange(32, dtype=float) fig, ax = apl.subplots(1, 1) plot = ax.imshow(data, axes=[x_axis, y_axis]) - plot.set_view(x0=0, x1=32, y0=0, y1=16) + plot.set_view(x0=-0.5, x1=31.5, y0=-0.5, y1=23.5) zoom_x = 64.0 / 32.0 # = 2.0 - zoom_y = 32.0 / 16.0 # = 2.0 + zoom_y = 32.0 / 24.0 # ≈ 1.33 expected_zoom = min(zoom_x, zoom_y) assert abs(plot._state["zoom"] - expected_zoom) < 1e-6 diff --git a/upcoming_changes/74.api_change.rst b/upcoming_changes/74.api_change.rst new file mode 100644 index 00000000..a75a989b --- /dev/null +++ b/upcoming_changes/74.api_change.rst @@ -0,0 +1 @@ +2-D tick labels now sit on the pixels they name: ``imshow`` axis values are placed at pixel centres (the image extends half a pixel past the first and last value, as matplotlib's ``imshow`` extent does) through the same transform markers and pointer events use, so ticks also follow a letterboxed image instead of spanning the whole gutter, and an ``origin='lower'`` image gets y ticks at all; :meth:`~anyplotlib.Plot2D.set_view` / ``set_xlim`` / ``set_ylim`` read the axis the same way — ``set_xlim(-0.5, n - 0.5)`` is the whole image, ``set_xlim(0, n - 1)`` now crops the outer half pixels — and accept a descending ``origin='lower'`` y axis.