From 6c93609ff325feeab4ff56c967e377b3a88d737e Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 14 Sep 2026 07:45:34 -0500 Subject: [PATCH] Pointer events in the pixel-centre convention; bold and outlined text markers GH #72: _canvasToImg2d was not the inverse of _imgToCanvas2d. The forward map puts image coordinate i at the centre of pixel i -- the convention marker offsets, widget positions and Python's display_to_data share -- but the inverse skipped the matching -0.5, so every 2-D pointer event read half a pixel right and down of anything drawn at the same spot and round(event.img_x) named pixel i+1 over the right half of pixel i. It now subtracts the 0.5; _imgPix2d / _inImgAxis2d carry the index and bounds rule to the readout, value probe and brush, and _pixelValue2d adds the 0.5 back where texels and the detail tile are addressed by edge. The wheel-zoom anchor compensates so the point under the cursor still stays put, and brush strokes now land under the cursor. xdata/ydata go through _imgToAxisVal2d: imshow axes hold one value per pixel centre, so pixel i reads exactly x_axis[i] (continued linearly over the outer half of the edge pixels rather than clamped); pcolormesh axes are cell edges and keep the (i + 0.5) / n mapping, i.e. the values they already reported. GH #66: add_texts / add_text take fontweight ('normal' | 'bold' | CSS number) and outline_color / outline_width, a halo stroked under the fill so a label stays legible on light and dark ground. Validated on creation and on MarkerGroup.set; the 1-D and 2-D renderers share _markerTextStyle / _drawMarkerText. FIGURE_ESM.md anchors are set to the current line numbers (they were one line off throughout on main). Claude-Session: https://claude.ai/code/session_01EUrvzeXdzNjPKBTk5jCtp1 --- AGENTS.md | 2 +- anyplotlib/FIGURE_ESM.md | 157 ++++++++-------- anyplotlib/_base_plot.py | 3 +- anyplotlib/callbacks.py | 2 +- anyplotlib/figure_esm.js | 112 ++++++++---- anyplotlib/markers.py | 34 ++++ anyplotlib/plot1d/_plot1d.py | 17 +- anyplotlib/plot2d/_plot2d.py | 54 +++++- .../tests/test_markers/test_text_style.py | 146 +++++++++++++++ .../test_plot2d/test_pointer_pixel_centre.py | 168 ++++++++++++++++++ docs/embedding.rst | 5 +- docs/events.rst | 12 +- upcoming_changes/73.api_change.rst | 1 + upcoming_changes/73.new_feature.rst | 1 + 14 files changed, 592 insertions(+), 122 deletions(-) create mode 100644 anyplotlib/tests/test_markers/test_text_style.py create mode 100644 anyplotlib/tests/test_plot2d/test_pointer_pixel_centre.py create mode 100644 upcoming_changes/73.api_change.rst create mode 100644 upcoming_changes/73.new_feature.rst 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.