diff --git a/AGENTS.md b/AGENTS.md
index f58d7a773..09b3688d4 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,349 lines.
+the 2-D function table). Both were last verified at 12,386 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 1c8802e50..8f39a328a 100644
--- a/anyplotlib/FIGURE_ESM.md
+++ b/anyplotlib/FIGURE_ESM.md
@@ -50,31 +50,31 @@ Rule 5 – Text never clips. Optional gutters earn real layout space:
| Shared plot-area padding (`PAD_*`) | 9 |
| Theme (dark/light detection) | 26 |
| Shared math helpers | 64 |
-| b64 array decode helpers | 109 |
-| **Rich-text (mini-TeX) engine**: `_texRuns` / `_texLayout` / `_drawTex` | 175 / 242 / 264 |
-| **2D gutter geometry**: `_cbLabelW` / `_cbValueChars` / `_cbTickW` / `_cbWidth` / `_cbGap` / `_padT` / `_titlePx` | 313 / 324 / 342 / 354 / 364 / 374 / 384 |
-| **Layout engine** `applyLayout` | 875 |
-| `_buildCanvasStack` | 958 |
-| `_createPanelDOM` | 1100 |
-| `_createInsetDOM` / `_applyAllInsetStates` | 1241 / 1635 |
-| `_resizePanelDOM` | 2348 |
-| **2D drawing**: `_imgFitRect` | 2519 |
-| `draw2d` | 2863 |
-| `drawScaleBar2d` / `drawColorbar2d` | 3058 / 3338 |
-| **Floating keys**: `_keyEnsure` / `_keyRect` / `drawKeys` | 3157 / 3180 / 3193 |
-| `_drawAxes2d` (ticks, labels, title) | 3450 |
-| `drawOverlay2d` / `drawMarkers2d` | 3603 / 3767 |
-| **Image layers**: `_layerBytes` / `_layerBitmap` / `_drawLayers2d` | 2683 / 2707 / 2768 |
-| Binary-bytes splice: `_spliceBinaryBytes` / `_registerBinaryPixelListeners` | 831 / 862 |
-| **Hover readout**: `_pixelValue2d` / `_readoutInfo2d` | 4611 / 4693 |
-| `_notifyReadout` / `_updateStatus2d` / `_armValueProbe` | 4733 / 4748 / 4769 |
-| **3D drawing**: `draw3d` | 5745 |
-| Event emission `_emitEvent` | 6582 |
-| 3D event handlers `_attachEvents3d` | 6639 |
-| **1D drawing**: `draw1d` | 6863 |
-| `_drawLine` (1D series + markers) | 7016 |
-| `drawOverlay1d` / `drawMarkers1d` | 7309 / 7393 |
-| Marker hit-test `_markerHitTest2d` | 7661 |
+| b64 array decode helpers | 141 |
+| **Rich-text (mini-TeX) engine**: `_texRuns` / `_texLayout` / `_drawTex` | 193 / 260 / 282 |
+| **2D gutter geometry**: `_cbLabelW` / `_cbValueChars` / `_cbTickW` / `_cbWidth` / `_cbGap` / `_padT` / `_titlePx` | 331 / 342 / 360 / 372 / 382 / 392 / 402 |
+| **Layout engine** `applyLayout` | 893 |
+| `_buildCanvasStack` | 976 |
+| `_createPanelDOM` | 1118 |
+| `_createInsetDOM` / `_applyAllInsetStates` | 1259 / 1653 |
+| `_resizePanelDOM` | 2366 |
+| **2D drawing**: `_imgFitRect` | 2537 |
+| `draw2d` | 2881 |
+| `drawScaleBar2d` / `drawColorbar2d` | 3076 / 3356 |
+| **Floating keys**: `_keyEnsure` / `_keyRect` / `drawKeys` | 3175 / 3198 / 3211 |
+| `_drawAxes2d` (ticks, labels, title) | 3468 |
+| `drawOverlay2d` / `drawMarkers2d` | 3621 / 3785 |
+| **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 |
> **`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` | 7918 |
-| 2D events `_attachEvents2d` | 7960 |
-| 1D events `_attachEvents1d` | 8353 |
-| 2D widget drag `_ovHitTest2d` / `_doDrag2d` | 8628 / 8907 |
-| **Brush strokes**: `_brushLiveBegin` / `_brushCommit` / `_brushErase` / `_brushPaintAt` | 8820 / 8834 / 8863 / 8898 |
-| 1D widget drag `_canvasXToFrac1d` … / snapping `_snapVal` | 9032 / 9105 |
-| Shared-axis propagation `_getShareGroups` | 9176 |
-| Figure resize `_applyFigResizeDOM` | 9240 |
-| **Bar chart**: `_barGeom` / `drawBar` / `_attachEventsBar` | 9434 / 9497 / 9873 |
-| Generic redraw `_redrawPanel` | 10063 |
-| **PNG export**: `_compositeCanvas` / `exportCanvas` / `exportPNG` | 10226 / 10422 / 10481 |
-| Native-resolution render `_withNativeSize` | 10202 |
-| **Export UI**: `_toast` / `_downloadCanvas` / `_openMenu` | 10515 / 10624 / 10803 |
-| Export registry `registerExportAction` | 10680 |
-| **Embedding API**: `createLocalModel` / `mount` | 11194 / 11250 |
-| **Navigated embed**: `decodeBlocks` / `mountNavigated` | 11505 / 11892 |
+| 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 |
> **`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`
@@ -282,17 +282,28 @@ st.colorbar_label_size (label font sizes; optional)
| Function | Line | Purpose |
|----------|------|---------|
-| **`_imgFitRect(iw,ih,cw,ch)`** / `_cbFitRect(st,imgW,imgH)` | **2519 / 2528** | Largest rect of aspect `iw:ih` centred in `cw×ch`; all 2-D coordinate functions derive from this. `_cbFitRect` is its vertical extent in whole px — the colorbar strip spans the letterboxed IMAGE, not the whole image area (`p._cbH`) |
-| `draw2d(p)` | 2863 | Main render: `_resizePanelDOM` → decode → LUT → ImageBitmap → blit; then mask, axes, scale bar, colorbar, overlay, markers |
-| `drawScaleBar2d(p)` | 3058 | Physical scale bar |
-| `_rawBand(st)` / `_displayFrac(st, val)` / `_buildLut32(st)` | 2578 / 2588 / 2596 | 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)` | 3338 | 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)` | 3450 | Ticks (edge labels nudged inward both axes), axis labels + title via `_drawTex` |
-| `drawOverlay2d(p)` / `drawMarkers2d(p)` | 3603 / 3767 | Widgets / marker groups |
+| **`_imgFitRect(iw,ih,cw,ch)`** / `_cbFitRect(st,imgW,imgH)` | **2537 / 2546** | Largest rect of aspect `iw:ih` centred in `cw×ch`; all 2-D coordinate functions derive from this. `_cbFitRect` is its vertical extent in whole px — the colorbar strip spans the letterboxed IMAGE, not the whole image area (`p._cbH`) |
+| `draw2d(p)` | 2881 | Main render: `_resizePanelDOM` → decode → LUT → ImageBitmap → blit; then mask, axes, scale bar, colorbar, overlay, markers |
+| `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 |
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
-inverses of the blit geometry.
+inverses of the blit geometry — and of each other. Both use the pixel-CENTRE
+convention (integer *i* is the centre of pixel *i*, which spans
+`[i − 0.5, i + 0.5)`), the same as marker offsets, widget positions and Python's
+`data_to_display` / `display_to_data`; `_imgPix2d` / `_inImgAxis2d` name the
+pixel under a coordinate and bound it. Pointer events and the readout map a
+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.
+
+`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.
### Hover readout
@@ -641,13 +652,13 @@ exportCanvas(same opts) → {canvas, width, height} // synchronous, throws
| Function | Line | Purpose |
|----------|------|---------|
-| `_cssScale` | 10098 | inverse of `_applyScale`'s `transform:scale()` |
-| `_panelBox` | 10109 | the element whose rect bounds one panel |
-| `_neutralizeView` / `_restoreView` | 10118 / 10143 | transient whole-extent view |
-| `_nativeGeom` / `_nativeGuard` | 10158 / 10177 | native size + why-not message |
-| `_withNativeSize` | 10202 | resize → redraw → run → restore |
-| `_compositeCanvas` | 10226 | the compositor (`_drawEl` / `_drawPanel` …) |
-| `exportCanvas` / `exportPNG` | 10422 / 10481 | orchestrator / data-URL wrapper |
+| `_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 |
**The whole pipeline is ONE synchronous task** — theme swap, view reset, native
resize, composite, restore — so the browser never paints an intermediate state
@@ -743,13 +754,13 @@ leaders that cross into the panel included. Pinned by
| Function | Line | Purpose |
|----------|------|---------|
-| `_toast` | 10515 | transient bottom-centre message |
-| `_copyCanvas` | 10550 | clipboard write + feature detection |
-| `_showPngPreview` | 10574 | framed-document download fallback |
-| `_downloadCanvas` | 10624 | `` or the preview |
-| `registerExportAction` | 10680 | downstream extension point |
-| `_menuRows` / `_openMenu` | 10734 / 10803 | menu model / DOM |
-| `_panelAtPoint` | 10915 | hit test (insets first — they sit on top) |
+| `_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) |
- **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
@@ -833,16 +844,16 @@ bindings, let it dispatch", rather than a hand-written program per result kind.
| Function | Line | Purpose |
|----------|------|---------|
-| `decodeBlocks` | 11505 | one base64 `fetch` → one ArrayBuffer → a typed-array view per manifest entry |
-| `dense` | 11531 | `at` / `gather` / `reduce` over a block whose leading axes are the nav axes |
-| `ragged` | 11596 | the same three, over a row-pointer block (`offsets` + one array per column) |
-| `maskFromWidget` | 11679 | rectangle / circle / annulus widget dict → `Uint8Array` (carries `width`/`height`) |
-| `rasterDisks` | 11719 | splat `{x, y, intensity}` rows as filled disks — the base image of a vectors panel |
-| `robustLevels` / `toU8` | 11748 / 11789 | the percentile window and the 8-bit code map, one implementation |
-| `panelAxis` | 11867 | a 1-D panel's decoded x axis (`_1dXArr`, else `x_axis_b64`) |
-| `installTouchShim` / `reportEmbedHeight` | 11804 / 11823 | page chrome: touch → mouse, `postMessage({aplEmbedHeight})` |
-| `encodeBase64` / `typedArrayBytes` | 11843 / 11851 | a 3-D cloud's geometry channel is base64, not the binary side table |
-| `mountNavigated` | 11892 | mount + bind + dispatch; resolves to the mount handle plus `dispatch`/`index`/`blocks` |
+| `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` |
`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/_base_plot.py b/anyplotlib/_base_plot.py
index 43e200c09..80eb3c34f 100644
--- a/anyplotlib/_base_plot.py
+++ b/anyplotlib/_base_plot.py
@@ -719,7 +719,8 @@ def add_text(self, x, y, s, name=None, *, color="#ff0000",
transform : str, optional
Coordinate system for ``(x, y)``. Default ``"data"``.
**kwargs : dict
- Forwarded to :meth:`add_texts` (e.g. ``clip_display``).
+ Forwarded to :meth:`add_texts` (e.g. ``clip_display``,
+ ``fontweight``, ``outline_color``).
Returns
-------
diff --git a/anyplotlib/callbacks.py b/anyplotlib/callbacks.py
index bebcb30c4..7976db781 100644
--- a/anyplotlib/callbacks.py
+++ b/anyplotlib/callbacks.py
@@ -43,7 +43,7 @@ class Event:
button — 0=left 1=middle 2=right; None on move/enter/leave/settled
buttons — bitmask of currently held buttons
xdata, ydata — data-space coordinates (None for Plot3D)
- img_x, img_y — Plot2D/PlotMesh: position in IMAGE PIXELS (column, row; row 0 at the top, origin already applied), so a handler can index the source array directly. None on other plot types.
+ img_x, img_y — Plot2D/PlotMesh: position in IMAGE PIXELS (column, row; row 0 at the top, origin already applied), so a handler can index the source array directly. Integer i is the CENTRE of pixel i (as for markers and widgets), so round() names the pixel. None on other plot types.
ray — Plot3D only: {"origin": [...], "direction": [...]}
line_id — Plot1D only: set when pointer is over a line
dwell_ms — pointer_settled only: actual dwell time
diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js
index 39f41220e..e834de5c3 100644
--- a/anyplotlib/figure_esm.js
+++ b/anyplotlib/figure_esm.js
@@ -120,6 +120,23 @@ function render({ model, el, onResize, onReadout }) {
r=Math.min(255,Math.round(r+(255-r)*amt));g=Math.min(255,Math.round(g+(255-g)*amt));b=Math.min(255,Math.round(b+(255-b)*amt));
return `#${r.toString(16).padStart(2,'0')}${g.toString(16).padStart(2,'0')}${b.toString(16).padStart(2,'0')}`;
}
+ // Font and outline for a `texts` marker group — shared by the 1-D and 2-D
+ // marker draws. Sets the fill font; when the group has an outline_color it
+ // also arms the stroke and returns true, and _drawMarkerText lays the stroke
+ // UNDER the fill: a halo that keeps a label legible on light and dark ground.
+ function _markerTextStyle(ctx, ms) {
+ ctx.font=`${ms.fontweight||'normal'} ${ms.fontsize||12}px sans-serif`;
+ ctx.textAlign='left'; ctx.textBaseline='top';
+ const ow=ms.outline_color ? (ms.outline_width!=null ? ms.outline_width : 3) : 0;
+ if(!(ow>0)) return false;
+ // Round joins: a thick stroke on a mitred glyph corner spikes.
+ ctx.strokeStyle=ms.outline_color; ctx.lineWidth=ow; ctx.lineJoin='round';
+ return true;
+ }
+ function _drawMarkerText(ctx, s, x, y, outlined) {
+ if(outlined) ctx.strokeText(s, x, y);
+ ctx.fillText(s, x, y);
+ }
// ── b64 array decode helpers ─────────────────────────────────────────────
// Convert a base-64 string (little-endian raw bytes) to a JS TypedArray.
@@ -3897,12 +3914,11 @@ function render({ model, el, onResize, onReadout }) {
mkCtx.stroke();
}
} else if(type==='texts'){
- const fs=ms.fontsize||12;
- mkCtx.font=`${fs}px sans-serif`;mkCtx.textAlign='left';mkCtx.textBaseline='top';
+ const outlined=_markerTextStyle(mkCtx,ms);
for(let i=0;i @location(0) vec4 {
// tile carries true native pixels for the visible region. Image LAYERS
// (_drawLayers2d) are never probed — the base image is the primary data.
//
+ // (ix, iy) is a centre-convention image coordinate (see _canvasToImg2d).
// Returns {value, step} for a scalar image, {rgba:[r,g,b,a]} for a true-colour
// one, or null when the bytes / the band are unavailable.
- function _pixelValue2d(p, st, ix, iy) {
+ function _pixelValue2d(p, st, cix, ciy) {
const iw = st.image_width, ih = st.image_height;
if (!(iw > 0) || !(ih > 0)) return null;
- if (!(ix >= 0 && iy >= 0 && ix < iw && iy < ih)) return null;
+ if (!(_inImgAxis2d(cix, iw) && _inImgAxis2d(ciy, ih))) return null;
+ // Edge space from here on: texels and the detail region are both addressed
+ // by pixel edges, where pixel i spans [i, i + 1).
+ const ix = cix + 0.5, iy = ciy + 0.5;
// Logical px → base-texture texel (the same kx/ky scale _blit2d draws with,
// so the readout names the texel the cursor is actually over).
const bw = st.base_width || iw, bh = st.base_height || ih;
@@ -4693,15 +4713,14 @@ fn fs(in : VsOut) -> @location(0) vec4 {
// Returns null when the cursor is off-image.
function _readoutInfo2d(p, st, ix, iy) {
const iw = st.image_width, ih = st.image_height;
- if (!(ix >= 0 && iy >= 0 && ix < iw && iy < ih)) return null;
+ if (!(_inImgAxis2d(ix, iw) && _inImgAxis2d(iy, ih))) return null;
const xArr = st.x_axis || [], yArr = st.y_axis || [];
const showPhys = xArr.length >= 2 || yArr.length >= 2;
- // For both imshow (centre arrays) and pcolormesh (edge arrays), ix/iw maps the
- // pixel fraction into the axis array via binary search.
- const physX = showPhys && xArr.length >= 2 ? _axisFracToVal(xArr, ix / iw) : ix;
- const physY = showPhys && yArr.length >= 2 ? _axisFracToVal(yArr, iy / ih) : iy;
+ // The same position → axis mapping the pointer events carry as xdata/ydata.
+ const physX = _imgToAxisVal2d(st, xArr, ix, iw);
+ const physY = _imgToAxisVal2d(st, yArr, iy, ih);
const units = st.units || 'px';
- const px = Math.floor(ix), py = Math.floor(iy);
+ const px = _imgPix2d(ix), py = _imgPix2d(iy);
const pv = _pixelValue2d(p, st, ix, iy);
const probe = _probeValueFor(st, px, py);
@@ -4773,8 +4792,8 @@ fn fs(in : VsOut) -> @location(0) vec4 {
if (!st) return;
const ms = st.probe_ms || 0;
if (ms <= 0 || st.is_rgb) return; // RGB channels are already exact
- if (!(ix >= 0 && iy >= 0 && ix < st.image_width && iy < st.image_height)) return;
- const px = Math.floor(ix), py = Math.floor(iy);
+ if (!(_inImgAxis2d(ix, st.image_width) && _inImgAxis2d(iy, st.image_height))) return;
+ const px = _imgPix2d(ix), py = _imgPix2d(iy);
if (st.probe_x === px && st.probe_y === py) return; // already answered
const key = `${px},${py}`;
if (p._probeSent === key) return; // already asked, no answer yet
@@ -7594,11 +7613,10 @@ fn fs(in : VsOut) -> @location(0) vec4 {
mkCtx.closePath();mkCtx.fill();
}
} else if(type==='texts'){
- const fs=ms.fontsize||12;
- mkCtx.font=`${fs}px sans-serif`;mkCtx.textAlign='left';mkCtx.textBaseline='top';
+ const outlined=_markerTextStyle(mkCtx,ms);
for(let i=0;i @location(0) vec4 {
}
}
+ // The exact inverse of _imgToCanvas2d, in the same CENTRE convention: image
+ // coordinate i is the centre of pixel i, so pixel i spans [i - 0.5, i + 0.5)
+ // and _imgPix2d (floor(v + 0.5)) names the pixel under the cursor. Pointer
+ // events, the readout and the brush all read positions through this, so a
+ // click lands on the same coordinate a marker or widget drawn there holds
+ // (GH #72: without the -0.5 every event read half a pixel right and down).
function _canvasToImg2d(px, py, st, pw, ph) {
const { x, y, w, h } = _imgFitRect(st.image_width, st.image_height, pw, ph);
const zoom = st.zoom, cx = st.center_x, cy = st.center_y;
@@ -7950,12 +7974,31 @@ fn fs(in : VsOut) -> @location(0) vec4 {
// Zoom-out path: inverse of the centred-shrink in _blit2d.
const dstW = w * zoom, dstH = h * zoom;
const dstX = x + (w - dstW) / 2, dstY = y + (h - dstH) / 2;
- return [(px - dstX) / dstW * iw, (py - dstY) / dstH * ih];
+ return [(px - dstX) / dstW * iw - 0.5, (py - dstY) / dstH * ih - 0.5];
}
const visW = iw / zoom, visH = ih / zoom;
const srcX = Math.max(0, Math.min(iw - visW, cx * iw - visW / 2));
const srcY = Math.max(0, Math.min(ih - visH, cy * ih - visH / 2));
- return [srcX + (px - x) / w * visW, srcY + (py - y) / h * visH];
+ return [srcX + (px - x) / w * visW - 0.5, srcY + (py - y) / h * visH - 0.5];
+ }
+
+ // The pixel index a centre-convention coordinate falls in, and whether that
+ // pixel is inside an n-pixel axis.
+ function _imgPix2d(v) { return Math.floor(v + 0.5); }
+ function _inImgAxis2d(v, n) { return v >= -0.5 && v < n - 0.5; }
+
+ // A centre-convention image coordinate → axis value, for pointer events and the
+ // readout. imshow's x_axis/y_axis hold one value per pixel CENTRE, so pixel i
+ // reads exactly arr[i] (continued linearly past either end, over the outer half
+ // of the edge pixels). pcolormesh's hold the n + 1 cell EDGES, so the pixel's
+ // edge-space fraction (i + 0.5) / n indexes them, as the tick gutters do.
+ function _imgToAxisVal2d(st, arr, i, n) {
+ if (!arr || arr.length < 2) return i;
+ if (st.is_mesh) return _axisFracToVal(arr, (i + 0.5) / n);
+ const m = arr.length - 1;
+ const pos = n > 1 ? i * m / (n - 1) : 0;
+ const lo = Math.max(0, Math.min(m - 1, Math.floor(pos)));
+ return arr[lo] + (pos - lo) * (arr[lo + 1] - arr[lo]);
}
function _attachEvents2d(p) {
@@ -7981,8 +8024,9 @@ fn fs(in : VsOut) -> @location(0) vec4 {
const iw=st.image_width, ih=st.image_height;
const fr=_imgFitRect(iw,ih,imgW,imgH);
const newVisW=iw/newZ, newVisH=ih/newZ;
- const newSrcX=anchorX-(mx-fr.x)/fr.w*newVisW;
- const newSrcY=anchorY-(my-fr.y)/fr.h*newVisH;
+ // +0.5: the anchor is a pixel-centre coordinate, srcX a pixel edge.
+ const newSrcX=anchorX+0.5-(mx-fr.x)/fr.w*newVisW;
+ const newSrcY=anchorY+0.5-(my-fr.y)/fr.h*newVisH;
st.center_x=Math.max(0,Math.min(1,(newSrcX+newVisW/2)/iw));
st.center_y=Math.max(0,Math.min(1,(newSrcY+newVisH/2)/ih));
} else {
@@ -8111,10 +8155,8 @@ fn fs(in : VsOut) -> @location(0) vec4 {
if(_dist2<=25&&_dt<=350){
// Genuine click — skip pan-settle, emit pointer_down with image coords.
const [imgX,imgY]=_canvasToImg2d(_cc.mx,_cc.my,st,imgW,imgH);
- const xArr=st.x_axis||[], yArr=st.y_axis||[];
- const _iw=st.image_width||1, _ih=st.image_height||1;
- const physX=xArr.length>=2?_axisFracToVal(xArr,imgX/_iw):imgX;
- const physY=yArr.length>=2?_axisFracToVal(yArr,imgY/_ih):imgY;
+ const physX=_imgToAxisVal2d(st,st.x_axis,imgX,st.image_width||1);
+ const physY=_imgToAxisVal2d(st,st.y_axis,imgY,st.image_height||1);
_emitEvent(p.id,'pointer_down',null,{
img_x:imgX, img_y:imgY,
xdata:physX, ydata:physY,
@@ -8184,12 +8226,10 @@ fn fs(in : VsOut) -> @location(0) vec4 {
const imgW2 = p.imgW || Math.max(1, p.pw - PAD_L - PAD_R);
const imgH2 = p.imgH || Math.max(1, p.ph - PAD_T - PAD_B);
const [sImgX, sImgY] = _canvasToImg2d(p.mouseX, p.mouseY, st2, imgW2, imgH2);
- const sXArr = st2.x_axis || [], sYArr = st2.y_axis || [];
- const _siw = st2.image_width || 1, _sih = st2.image_height || 1;
return {
img_x: sImgX, img_y: sImgY,
- xdata: sXArr.length >= 2 ? _axisFracToVal(sXArr, sImgX / _siw) : sImgX,
- ydata: sYArr.length >= 2 ? _axisFracToVal(sYArr, sImgY / _sih) : sImgY,
+ xdata: _imgToAxisVal2d(st2, st2.x_axis, sImgX, st2.image_width || 1),
+ ydata: _imgToAxisVal2d(st2, st2.y_axis, sImgY, st2.image_height || 1),
};
});
});
@@ -8207,10 +8247,8 @@ fn fs(in : VsOut) -> @location(0) vec4 {
const imgW=p.imgW||Math.max(1,p.pw-PAD_L-PAD_R), imgH=p.imgH||Math.max(1,p.ph-PAD_T-PAD_B);
const {mx,my}=_clientPos(e,overlayCanvas,imgW,imgH);
const [imgX,imgY]=_canvasToImg2d(mx,my,st,imgW,imgH);
- const xArr=st.x_axis||[], yArr=st.y_axis||[];
- const _iw=st.image_width||1, _ih=st.image_height||1;
- const physX=xArr.length>=2?_axisFracToVal(xArr,imgX/_iw):imgX;
- const physY=yArr.length>=2?_axisFracToVal(yArr,imgY/_ih):imgY;
+ const physX=_imgToAxisVal2d(st,st.x_axis,imgX,st.image_width||1);
+ const physY=_imgToAxisVal2d(st,st.y_axis,imgY,st.image_height||1);
_emitEvent(p.id,'double_click',null,{..._pointerFields(e),button:e.button,x:mx,y:my,img_x:imgX,img_y:imgY,xdata:physX,ydata:physY});
});
@@ -8291,10 +8329,8 @@ fn fs(in : VsOut) -> @location(0) vec4 {
const st=p.state; if(!st) return;
const imgW=p.imgW||Math.max(1,p.pw-PAD_L-PAD_R), imgH=p.imgH||Math.max(1,p.ph-PAD_T-PAD_B);
const [imgX,imgY]=_canvasToImg2d(p.mouseX,p.mouseY,st,imgW,imgH);
- const xArr=st.x_axis||[], yArr=st.y_axis||[];
- const iw=st.image_width||1, ih=st.image_height||1;
- const physX=xArr.length>=2?_axisFracToVal(xArr,imgX/iw):imgX;
- const physY=yArr.length>=2?_axisFracToVal(yArr,imgY/ih):imgY;
+ const physX=_imgToAxisVal2d(st,st.x_axis,imgX,st.image_width||1);
+ const physY=_imgToAxisVal2d(st,st.y_axis,imgY,st.image_height||1);
_emitEvent(p.id,'key_down',null,{
time_stamp:performance.now()/1000,
modifiers:_modifiers(e),
@@ -8885,7 +8921,7 @@ fn fs(in : VsOut) -> @location(0) vec4 {
// stroke exactly where painting would drop every point.
function _inImage2d(mx, my, st, imgW, imgH) {
const [ix, iy] = _canvasToImg2d(mx, my, st, imgW, imgH);
- return ix >= 0 && ix < st.image_width && iy >= 0 && iy < st.image_height;
+ return _inImgAxis2d(ix, st.image_width) && _inImgAxis2d(iy, st.image_height);
}
// Extend (or start) the brush's stroke at image point (ix,iy). Shared by
@@ -8897,7 +8933,7 @@ fn fs(in : VsOut) -> @location(0) vec4 {
// per-drag "next in-bounds point starts a stroke" flag — the hit-test seeds
// it true, which is what makes mousedown open a fresh stroke.
function _brushPaintAt(L, st, ix, iy, d) {
- if (!(ix >= 0 && ix < st.image_width && iy >= 0 && iy < st.image_height)) {
+ if (!(_inImgAxis2d(ix, st.image_width) && _inImgAxis2d(iy, st.image_height))) {
d._gap = true; return;
}
if (d.mode === 'erase') { _brushErase(L, ix, iy); return; }
diff --git a/anyplotlib/markers.py b/anyplotlib/markers.py
index b7dc724d6..4b1a85fd3 100644
--- a/anyplotlib/markers.py
+++ b/anyplotlib/markers.py
@@ -73,6 +73,33 @@ def _offsets_2d(offsets) -> list:
_VALID_TRANSFORMS = frozenset({"data", "axes", "display"})
+def _check_text_style(kwargs: dict) -> None:
+ """Validate the ``texts`` styling kwargs present in *kwargs*.
+
+ Checked when a group is created and on every :meth:`MarkerGroup.set`, so a
+ bad value is refused before it lands in the group's data rather than on
+ the next push.
+ """
+ if "fontweight" in kwargs:
+ fw = kwargs["fontweight"]
+ # CSS font-weight: a keyword, or a number in [1, 1000].
+ ok = (fw in ("normal", "bold") if isinstance(fw, str)
+ else isinstance(fw, (int, float)) and not isinstance(fw, bool)
+ and 1 <= fw <= 1000)
+ if not ok:
+ raise ValueError(
+ "fontweight must be 'normal', 'bold' or a number in "
+ f"[1, 1000], got {fw!r}")
+ oc = kwargs.get("outline_color")
+ if oc is not None and not isinstance(oc, str):
+ raise ValueError(f"outline_color must be a CSS colour string or None, got {oc!r}")
+ if "outline_width" in kwargs:
+ ow = kwargs["outline_width"]
+ if (isinstance(ow, bool) or not isinstance(ow, (int, float))
+ or not ow >= 0):
+ raise ValueError(f"outline_width must be a number >= 0, got {ow!r}")
+
+
def _apply_fill_color(wire: dict, d: dict) -> None:
"""Apply facecolors/alpha fill fields to a wire dict if facecolors is set."""
fc = d.get("facecolors")
@@ -124,6 +151,7 @@ def __init__(self, marker_type: str, name: str, kwargs: dict, push_fn,
)
if "clip_display" in kwargs and not isinstance(kwargs["clip_display"], bool):
raise ValueError("clip_display must be a bool")
+ _check_text_style(kwargs)
self._data: dict = dict(kwargs)
self._push_fn = push_fn
self._parent: "MarkerTypeDict | None" = parent
@@ -145,6 +173,7 @@ def set(self, **kwargs) -> None:
)
if "clip_display" in kwargs and not isinstance(kwargs["clip_display"], bool):
raise ValueError("clip_display must be a bool")
+ _check_text_style(kwargs)
self._data.update(kwargs)
self._push_fn()
@@ -314,7 +343,12 @@ def to_wire(self, group_id: str) -> dict:
"texts": texts,
"color": d.get("color", d.get("edgecolors", "#ff0000")),
"fontsize": int(d.get("fontsize", 12)),
+ "fontweight": d.get("fontweight", "normal"),
}
+ # Outline (a halo stroked under the fill): only on the wire when set.
+ if d.get("outline_color") is not None:
+ wire["outline_color"] = d["outline_color"]
+ wire["outline_width"] = float(d.get("outline_width", 3.0))
# ── 1D-only types ───────────────────────────────────────────────────
elif t == "points":
diff --git a/anyplotlib/plot1d/_plot1d.py b/anyplotlib/plot1d/_plot1d.py
index 650245cd9..1cc21e586 100644
--- a/anyplotlib/plot1d/_plot1d.py
+++ b/anyplotlib/plot1d/_plot1d.py
@@ -1581,7 +1581,8 @@ def add_raster(self, rgba, *, extent, name=None, clip_path=None,
clip_display=clip_display)
def add_texts(self, offsets, texts, name=None, *,
- color="#ff0000", fontsize=12,
+ color="#ff0000", fontsize=12, fontweight="normal",
+ outline_color=None, outline_width=3.0,
hover_edgecolors=None,
labels=None, label=None,
transform: str = "data",
@@ -1600,6 +1601,17 @@ def add_texts(self, offsets, texts, name=None, *,
Text colour. Default ``"#ff0000"``.
fontsize : int, optional
Font size in pixels. Default ``12``.
+ fontweight : {"normal", "bold"} or float, optional
+ Font weight — a keyword or a CSS numeric weight in ``[1, 1000]``
+ (400 is normal, 700 bold). Default ``"normal"``.
+ outline_color : str, optional
+ Colour of a halo stroked under the text, which keeps a label
+ legible over both light and dark backgrounds. ``None`` (default)
+ draws no outline.
+ outline_width : float, optional
+ Stroke width of the outline in pixels; about half of it shows
+ outside the glyphs. Default ``3.0``. Ignored without
+ ``outline_color``.
hover_edgecolors : str, optional
Colour override applied on mouse-hover.
labels : list of str, optional
@@ -1613,6 +1625,9 @@ def add_texts(self, offsets, texts, name=None, *,
"""
return self._add_marker("texts", name, offsets=offsets, texts=texts,
color=color, fontsize=fontsize,
+ fontweight=fontweight,
+ outline_color=outline_color,
+ outline_width=outline_width,
hover_edgecolors=hover_edgecolors,
labels=labels, label=label,
transform=transform,
diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py
index fc47053ec..28f235c13 100644
--- a/anyplotlib/plot2d/_plot2d.py
+++ b/anyplotlib/plot2d/_plot2d.py
@@ -2536,13 +2536,65 @@ def add_polygons(self, vertices_list, name=None, *,
clip_display=clip_display)
def add_texts(self, offsets, texts, name=None, *,
- color="#ff0000", fontsize=12,
+ color="#ff0000", fontsize=12, fontweight="normal",
+ outline_color=None, outline_width=3.0,
hover_edgecolors=None,
labels=None, label=None,
transform: str = "data",
clip_display: bool = True) -> "MarkerGroup": # noqa: F821
+ """Add text annotations at image-pixel positions.
+
+ Parameters
+ ----------
+ offsets : array-like, shape (N, 2)
+ Anchor (top-left) positions, in the coordinate system named by
+ ``transform`` — image pixels for ``"data"``.
+ texts : list of str
+ One string per position.
+ name : str, optional
+ Registry key. Auto-generated if omitted.
+ color : str or list of str, optional
+ Text colour, or one per text. Default ``"#ff0000"``.
+ fontsize : int, optional
+ Font size in pixels. Default ``12``.
+ fontweight : {"normal", "bold"} or float, optional
+ Font weight — a keyword or a CSS numeric weight in ``[1, 1000]``
+ (400 is normal, 700 bold). Default ``"normal"``.
+ outline_color : str, optional
+ Colour of a halo stroked under the text, which keeps a label
+ legible over both light and dark regions of the image. ``None``
+ (default) draws no outline.
+ outline_width : float, optional
+ Stroke width of the outline in pixels; about half of it shows
+ outside the glyphs. Default ``3.0``. Ignored without
+ ``outline_color``.
+ hover_edgecolors : str, optional
+ Colour override applied on mouse-hover.
+ labels : list of str, optional
+ Per-annotation tooltip labels.
+ label : str, optional
+ Collection-level tooltip label.
+ transform : {"data", "axes", "display"}, optional
+ Coordinate system for ``offsets``. Default ``"data"``.
+ clip_display : bool, optional
+ Clip ``"display"``-transform texts to the image. Default ``True``.
+
+ Returns
+ -------
+ MarkerGroup
+
+ Examples
+ --------
+ A bold white label with a dark halo, readable over any image:
+
+ >>> plot.add_texts([[40, 12]], ["2 Å"], fontsize=16, fontweight="bold",
+ ... color="#ffffff", outline_color="#000000") # doctest: +SKIP
+ """
return self._add_marker("texts", name, offsets=offsets, texts=texts,
color=color, fontsize=fontsize,
+ fontweight=fontweight,
+ outline_color=outline_color,
+ outline_width=outline_width,
hover_edgecolors=hover_edgecolors,
labels=labels, label=label,
transform=transform,
diff --git a/anyplotlib/tests/test_markers/test_text_style.py b/anyplotlib/tests/test_markers/test_text_style.py
new file mode 100644
index 000000000..91fc26afd
--- /dev/null
+++ b/anyplotlib/tests/test_markers/test_text_style.py
@@ -0,0 +1,146 @@
+"""
+Font weight and outline (halo) for ``texts`` markers (GH #66).
+
+``add_texts`` / ``add_text`` drew every label in a fixed ``{fs}px sans-serif``
+with no way to make it bold or give it an outline, so a label over a busy image
+was only as legible as its size allowed. ``fontweight`` threads through to the
+canvas font the way ``fontsize`` does; ``outline_color`` / ``outline_width``
+stroke a halo under the fill.
+"""
+from __future__ import annotations
+
+import numpy as np
+import pytest
+
+import anyplotlib as apl
+
+RED, BLUE = (255, 0, 0), (0, 0, 255)
+
+
+def _count(img: np.ndarray, rgb, tol: int = 60) -> int:
+ a = img[..., :3].astype(int)
+ return int((np.abs(a - np.array(rgb)).sum(axis=-1) < tol).sum())
+
+
+def _plot2d():
+ fig, ax = apl.subplots(1, 1, figsize=(400, 400))
+ return fig, ax.imshow(np.zeros((40, 40), dtype=np.float32))
+
+
+def _plot1d():
+ fig, ax = apl.subplots(1, 1, figsize=(400, 400))
+ return fig, ax.plot(np.zeros(40))
+
+
+# Where a label lands well inside the plot area on each kind of panel.
+_PLACE = {_plot2d: dict(offsets=[[4, 20]]),
+ _plot1d: dict(offsets=[[0.1, 0.6]], transform="axes")}
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# API + wire format
+# ══════════════════════════════════════════════════════════════════════════════
+
+class TestWire:
+ @pytest.mark.parametrize("make", [_plot2d, _plot1d])
+ def test_defaults_are_normal_weight_and_no_outline(self, make):
+ _, plot = make()
+ w = plot.add_texts([[5, 5]], ["a"]).to_wire("gid")
+ assert w["fontweight"] == "normal"
+ assert "outline_color" not in w and "outline_width" not in w
+
+ @pytest.mark.parametrize("make", [_plot2d, _plot1d])
+ def test_weight_and_outline_reach_the_wire(self, make):
+ _, plot = make()
+ g = plot.add_texts([[5, 5]], ["a"], fontweight="bold",
+ outline_color="#000000", outline_width=4)
+ w = g.to_wire("gid")
+ assert w["fontweight"] == "bold"
+ assert w["outline_color"] == "#000000"
+ assert w["outline_width"] == 4.0
+
+ def test_outline_width_defaults_when_only_the_colour_is_given(self):
+ _, plot = _plot2d()
+ w = plot.add_texts([[5, 5]], ["a"], outline_color="#fff").to_wire("gid")
+ assert w["outline_width"] == 3.0
+
+ def test_numeric_weight(self):
+ _, plot = _plot2d()
+ assert plot.add_texts([[5, 5]], ["a"], fontweight=600).to_wire("gid")[
+ "fontweight"] == 600
+
+ def test_add_text_forwards_the_style(self):
+ _, plot = _plot2d()
+ h = plot.add_text(5, 5, "a", fontweight="bold", outline_color="#000")
+ w = h._group.to_wire("gid")
+ assert (w["fontweight"], w["outline_color"]) == ("bold", "#000")
+
+ def test_set_updates_live(self):
+ _, plot = _plot2d()
+ g = plot.add_texts([[5, 5]], ["a"])
+ g.set(fontweight="bold", outline_color="#123456")
+ w = plot._state["markers"][0]
+ assert (w["fontweight"], w["outline_color"]) == ("bold", "#123456")
+ g.set(outline_color=None)
+ assert "outline_color" not in plot._state["markers"][0]
+
+
+class TestValidation:
+ @pytest.mark.parametrize("bad", ["heavy", "", 0, 1001, True, None])
+ def test_bad_weight_is_refused(self, bad):
+ _, plot = _plot2d()
+ with pytest.raises(ValueError, match="fontweight"):
+ plot.add_texts([[5, 5]], ["a"], fontweight=bad)
+
+ @pytest.mark.parametrize("bad", [-1, "3", float("nan")])
+ def test_bad_outline_width_is_refused(self, bad):
+ _, plot = _plot2d()
+ with pytest.raises(ValueError, match="outline_width"):
+ plot.add_texts([[5, 5]], ["a"], outline_color="#000",
+ outline_width=bad)
+
+ def test_bad_outline_colour_is_refused(self):
+ _, plot = _plot2d()
+ with pytest.raises(ValueError, match="outline_color"):
+ plot.add_texts([[5, 5]], ["a"], outline_color=(0, 0, 0))
+
+ def test_set_refuses_before_touching_the_group(self):
+ _, plot = _plot2d()
+ g = plot.add_texts([[5, 5]], ["a"])
+ with pytest.raises(ValueError):
+ g.set(fontweight="extra-bold")
+ assert g._data["fontweight"] == "normal"
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# Rendering
+# ══════════════════════════════════════════════════════════════════════════════
+
+class TestRendering:
+ @pytest.mark.parametrize("make", [_plot2d, _plot1d])
+ def test_bold_puts_more_ink_down(self, take_screenshot, make):
+ ink = {}
+ for weight in ("normal", "bold"):
+ fig, plot = make()
+ plot.add_texts(texts=["WWWW"], color="#ff0000", fontsize=28,
+ fontweight=weight, **_PLACE[make])
+ ink[weight] = _count(take_screenshot(fig), RED)
+ assert ink["normal"] > 0, "the label did not render"
+ assert ink["bold"] > ink["normal"] * 1.15, ink
+
+ @pytest.mark.parametrize("make", [_plot2d, _plot1d])
+ def test_outline_draws_a_halo_in_its_own_colour(self, take_screenshot, make):
+ fig, plot = make()
+ plot.add_texts(texts=["HALO"], color="#0000ff", fontsize=28,
+ **_PLACE[make])
+ base = take_screenshot(fig)
+
+ fig, plot = make()
+ plot.add_texts(texts=["HALO"], color="#0000ff", fontsize=28,
+ outline_color="#ff0000", outline_width=4, **_PLACE[make])
+ halo = take_screenshot(fig)
+
+ assert _count(base, RED) == 0
+ assert _count(halo, RED) > 100, "no outline pixels on the canvas"
+ # The fill sits on top of the stroke: the glyph body keeps its colour.
+ assert _count(halo, BLUE) > 0.5 * _count(base, BLUE)
diff --git a/anyplotlib/tests/test_plot2d/test_pointer_pixel_centre.py b/anyplotlib/tests/test_plot2d/test_pointer_pixel_centre.py
new file mode 100644
index 000000000..2f20333e2
--- /dev/null
+++ b/anyplotlib/tests/test_plot2d/test_pointer_pixel_centre.py
@@ -0,0 +1,168 @@
+"""
+2-D pointer coordinates use the pixel-CENTRE convention (GH #72).
+
+``_imgToCanvas2d`` places image coordinate *i* at the centre of pixel *i*, as
+marker offsets, widget positions and :meth:`Plot2D.display_to_data` all do.
+``_canvasToImg2d`` used to skip the matching ``-0.5``, so every pointer event
+read half a pixel right and down of anything drawn at the same spot, and a
+handler doing ``round(event.img_x)`` got pixel ``i + 1`` for the right half of
+pixel ``i``.
+"""
+from __future__ import annotations
+
+import pathlib
+
+import numpy as np
+import pytest
+
+import anyplotlib as apl
+from anyplotlib.tests.test_interactive._event_test_utils import (
+ GRID_PAD, _collect_events, _get_events,
+)
+
+_ESM = pathlib.Path(apl.__file__).parent / "figure_esm.js"
+
+
+def _grab(src: str, name: str) -> str:
+ """The source of top-level ``function name(...) {...}`` in figure_esm.js."""
+ start = src.index(f"function {name}(")
+ depth, i = 0, src.index("{", start)
+ while True:
+ if src[i] == "{":
+ depth += 1
+ elif src[i] == "}":
+ depth -= 1
+ if depth == 0:
+ return src[start:i + 1]
+ i += 1
+
+
+@pytest.fixture(scope="module")
+def coord_fns(_pw_browser):
+ """A blank page exposing the renderer's pure 2-D coordinate helpers."""
+ src = _ESM.read_text(encoding="utf-8")
+ names = ["_imgFitRect", "_imgToCanvas2d", "_canvasToImg2d", "_axisFracToVal",
+ "_imgPix2d", "_inImgAxis2d", "_imgToAxisVal2d"]
+ 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()
+
+
+class TestRoundTrip:
+ @pytest.mark.parametrize("zoom", [0.5, 1.0, 2.0, 4.0, 37.0])
+ @pytest.mark.parametrize("center", [0.5, 0.1, 0.93])
+ def test_canvas_to_img_inverts_img_to_canvas(self, coord_fns, zoom, center):
+ worst = coord_fns.evaluate("""([zoom, c]) => {
+ const st = {image_width: 512, image_height: 384, zoom,
+ center_x: c, center_y: 1 - c};
+ let worst = 0;
+ for (const i of [0, 0.25, 100, 383, 511]) {
+ const [cx, cy] = _apl._imgToCanvas2d(i, i, st, 1024, 768);
+ const [bx, by] = _apl._canvasToImg2d(cx, cy, st, 1024, 768);
+ worst = Math.max(worst, Math.abs(bx - i), Math.abs(by - i));
+ }
+ return worst;
+ }""", [zoom, center])
+ assert worst < 1e-9, f"round trip is off by {worst} image px"
+
+ def test_pixel_spans_half_a_pixel_either_side_of_its_centre(self, coord_fns):
+ got = coord_fns.evaluate("""() => [
+ _apl._imgPix2d(7.51), _apl._imgPix2d(8.0), _apl._imgPix2d(8.49),
+ _apl._imgPix2d(-0.5), _apl._inImgAxis2d(-0.5, 16),
+ _apl._inImgAxis2d(-0.51, 16), _apl._inImgAxis2d(15.49, 16),
+ _apl._inImgAxis2d(15.5, 16)]""")
+ assert got == [8, 8, 8, 0, True, False, True, False]
+
+
+class TestAxisValue:
+ def test_imshow_pixel_centre_reads_its_axis_value(self, coord_fns):
+ """imshow axes hold one value per pixel centre: pixel i is exactly
+ x_axis[i], and the outer half of an edge pixel continues the spacing
+ instead of clamping."""
+ got = coord_fns.evaluate("""() => {
+ const ax = Array.from({length: 16}, (_, k) => 10 + 0.5 * k);
+ const st = {};
+ return [0, 8, 15, -0.5, 15.5].map(i => _apl._imgToAxisVal2d(st, ax, i, 16));
+ }""")
+ assert got == pytest.approx([10.0, 14.0, 17.5, 9.75, 17.75])
+
+ def test_mesh_reads_its_edges_as_before(self, coord_fns):
+ """pcolormesh axes are the n + 1 cell edges: the centre of cell i is
+ the midpoint of edges i and i + 1 — the value the old edge-convention
+ formula already produced, so mesh xdata does not move."""
+ got = coord_fns.evaluate("""() => {
+ const edges = [0, 1, 3, 6, 10];
+ const st = {is_mesh: true};
+ return [0, 1, 3].map(i => _apl._imgToAxisVal2d(st, edges, i, 4));
+ }""")
+ assert got == pytest.approx([0.5, 2.0, 8.0])
+
+ def test_no_axis_falls_back_to_the_image_coordinate(self, coord_fns):
+ assert coord_fns.evaluate(
+ "() => _apl._imgToAxisVal2d({}, [], 3.25, 16)") == pytest.approx(3.25)
+
+
+# ── in the real renderer ──────────────────────────────────────────────────────
+
+# 16×16 image at 16 canvas px per image px, no axis gutters (see
+# TestMarkerPixelCenterAlignment in test_events_regression.py).
+_PAD_T = 12
+_FIG_W, _FIG_H = 16 * 16, 16 * 16 + _PAD_T
+
+
+def _click(page, plot, ix, iy):
+ """Click the page point where image coordinate (ix, iy) is drawn.
+
+ ``data_to_display`` is the centre-convention Python mirror of
+ ``_imgToCanvas2d`` (checked against screenshots in test_coord_conversion),
+ so the event must hand back the coordinate it was given.
+ """
+ x, y = plot.data_to_display([ix, iy])
+ page.mouse.click(GRID_PAD + x, GRID_PAD + y)
+ page.wait_for_timeout(120)
+ events = _get_events(page, "pointer_down")
+ assert events, "a click on the image must emit pointer_down"
+ return events[-1]
+
+
+class TestPointerEvents:
+ def test_click_on_a_pixel_centre_reports_that_coordinate(self, interact_page):
+ fig, ax = apl.subplots(1, 1, figsize=(_FIG_W, _FIG_H))
+ plot = ax.imshow(np.zeros((16, 16)))
+ page = interact_page(fig)
+ _collect_events(page)
+
+ e = _click(page, plot, 8.0, 5.0)
+ # One canvas px is 1/16 image px; allow for the integer mouse position.
+ assert e["img_x"] == pytest.approx(8.0, abs=0.1), e
+ assert e["img_y"] == pytest.approx(5.0, abs=0.1), e
+
+ def test_right_half_of_a_pixel_rounds_to_that_pixel(self, interact_page):
+ """The failure the issue describes: +0.375 px into pixel 8 used to read
+ 8.875, which rounds to 9."""
+ fig, ax = apl.subplots(1, 1, figsize=(_FIG_W, _FIG_H))
+ plot = ax.imshow(np.zeros((16, 16)))
+ page = interact_page(fig)
+ _collect_events(page)
+
+ e = _click(page, plot, 8.375, 8.375)
+ assert (round(e["img_x"]), round(e["img_y"])) == (8, 8), e
+
+ def test_imshow_xdata_is_the_axis_value_of_the_clicked_pixel(self, interact_page):
+ fig, ax = apl.subplots(1, 1, figsize=(400, 400))
+ plot = ax.imshow(np.zeros((16, 16)),
+ axes=[np.arange(16) * 0.5, np.arange(16) * 2.0])
+ page = interact_page(fig)
+ _collect_events(page)
+
+ e = _click(page, plot, 4.0, 11.0)
+ scale = plot.plot_box()["width"] / 16 # canvas px per image px
+ tol = 1.0 / scale # one canvas px, in image px
+ assert e["img_x"] == pytest.approx(4.0, abs=tol), e
+ assert e["xdata"] == pytest.approx(0.5 * e["img_x"], abs=1e-9), e
+ assert e["ydata"] == pytest.approx(2.0 * e["img_y"], abs=1e-9), e
diff --git a/docs/embedding.rst b/docs/embedding.rst
index 62d66379d..48fc41116 100644
--- a/docs/embedding.rst
+++ b/docs/embedding.rst
@@ -408,8 +408,9 @@ other than the ``mount()`` call site::
====================== ======================================================
``panel_id`` Which panel the cursor is over.
-``img_x``, ``img_y`` Fractional position in image pixels.
-``col``, ``row`` Integer pixel index (``img_x``/``img_y`` floored).
+``img_x``, ``img_y`` Fractional position in image pixels; integer *i* is the
+ centre of pixel *i*.
+``col``, ``row`` Integer pixel index (``img_x``/``img_y`` rounded).
``xdata``, ``ydata`` Physical position in ``units``.
``units`` Axis units string (``"px"`` when unset).
``value`` Pixel value, or ``null`` for a true-colour image.
diff --git a/docs/events.rst b/docs/events.rst
index 8693e03c2..148441b52 100644
--- a/docs/events.rst
+++ b/docs/events.rst
@@ -173,8 +173,11 @@ Present on ``pointer_down``, ``pointer_up``, ``pointer_move``,
- ``float | None``
- Plot2D / PlotMesh only: position in **image pixels** (column, row —
row 0 at the top, ``origin`` already applied), so a handler can index the
- source array directly without mapping axis units back. ``None`` on other
- plot types.
+ source array directly without mapping axis units back. Integer *i* is the
+ *centre* of pixel *i* — the same convention marker ``offsets``, widget
+ positions and :meth:`~anyplotlib.Plot2D.display_to_data` use — so
+ ``round()`` gives the pixel under the cursor. ``None`` on other plot
+ types.
* - ``ray``
- ``dict | None``
- Plot3D only: ``{"origin": [x,y,z], "direction": [dx,dy,dz]}``.
@@ -192,13 +195,14 @@ Present on ``pointer_down``, ``pointer_up``, ``pointer_move``,
2-D panels (:class:`~anyplotlib.Plot2D`, :class:`~anyplotlib.PlotMesh`)
already have a built-in readout — see :ref:`hover-readout` — so you rarely
need a handler just to show a value. When you do want one, ``img_x`` /
- ``img_y`` index the source array directly:
+ ``img_y`` index the source array directly once rounded to the nearest
+ pixel centre:
.. code-block:: python
@plot.add_event_handler("pointer_settled", ms=200)
def probe(event):
- row, col = int(event.img_y), int(event.img_x)
+ row, col = round(event.img_y), round(event.img_x)
label.value = f"{data[row, col]:.6g}"
PlotBar additional fields on ``pointer_down``
diff --git a/upcoming_changes/73.api_change.rst b/upcoming_changes/73.api_change.rst
new file mode 100644
index 000000000..4ca82b1ff
--- /dev/null
+++ b/upcoming_changes/73.api_change.rst
@@ -0,0 +1 @@
+2-D pointer events (``pointer_down``, ``double_click``, ``pointer_settled``, ``key_down``) and the hover readout now report ``img_x``/``img_y`` in the pixel-centre convention that markers, widgets and :meth:`~anyplotlib.Plot2D.display_to_data` already used — they read half a pixel right and down before — so ``round(event.img_x)`` names the clicked pixel (``int()`` no longer does), brush strokes land under the cursor, and an ``imshow`` event's ``xdata``/``ydata`` is exactly the axis value of the pixel centre it hits (``pcolormesh`` values are unchanged).
diff --git a/upcoming_changes/73.new_feature.rst b/upcoming_changes/73.new_feature.rst
new file mode 100644
index 000000000..7b323d0f4
--- /dev/null
+++ b/upcoming_changes/73.new_feature.rst
@@ -0,0 +1 @@
+Text markers (:meth:`~anyplotlib.Plot2D.add_texts`, :meth:`~anyplotlib.Plot1D.add_texts` and ``add_text``) gained ``fontweight`` for bold labels and ``outline_color`` / ``outline_width`` for a halo stroked under the text, which keeps a label legible over both light and dark parts of an image.