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.