Skip to content

feat: HyperSpy parity — 16 upstream items#44

Merged
CSSFrancis merged 12 commits into
mainfrom
feat/hyperspy-parity
Jul 26, 2026
Merged

feat: HyperSpy parity — 16 upstream items#44
CSSFrancis merged 12 commits into
mainfrom
feat/hyperspy-parity

Conversation

@CSSFrancis

Copy link
Copy Markdown
Owner

Implements 16 of the 17 upstream items on HyperSpy's wishlist
(docs/anyplotlib_improvements.md in the hyperspy alternate-backend branch),
found while running HyperSpy's full documentation gallery against the
anyplotlib backend. Each was re-verified against 0.4.1 before being worked on —
two entries on that list turned out to be already resolved and are not here.

One commit per item so they can be split or cherry-picked if review gets
unwieldy.

Parity blockers

What was missing Commit
linestyle="none" matplotlib's markers-only scatter idiom was rejected by _norm_linestyle, and _drawLine always stroked the path — st.line_linewidth || 1.5 turned an explicit 0 back into the default 64ff4e46
Positional pointer_down on 1-D Fired only when a click landed on a line, so pointer_down meant different things per panel kind and click-position features had nothing to listen to 29ff0adb
Per-marker colours edgecolors=[...] was honoured only by points/polygons on 1-D panels; elsewhere an array reached ctx.strokeStyle, which the canvas silently ignores 4eceecec
line / vline / hline widgets No bare segment (arrow draws a head, polygon closes the path) and no single-axis rule on 2-D panels ce7c2244

Fidelity

  • Colorbar gap (ce6016a0) — the strip sat 2px from the image and most plots set no colorbar_label, so it visually touched. Now a real 6px gap plus set_colorbar_pad(). ⚠️ This moves every colorbar plot by 4px; the imshow_labels baseline was regenerated and wants a human look.
  • size_units="px" (f51391f6) — marker sizes that don't swell on zoom. The hit-tester reads the same flag, or hover targets would drift from what's drawn.
  • y-label clearance (7d41de98) — the label's x was fixed at PAD_L*0.28 with a comment claiming it cleared the ticks "regardless of how wide those numbers are". It didn't; maxTW was already measured a few lines above and unused.

Removing workarounds

  • set(_notify=False) and Widget.remove() (0859a59d)
  • orientation="vertical" and snap_values (04515e67) — snapping has to live in the JS drag; correcting in Python afterwards moves an edge the user is still holding
  • plot_box() / data_to_display() / display_to_data() (2510e83c) — callers were copying PAD_* and the letterbox maths out of figure_esm.js
  • Stale widget state in exports (2510e83c) — save_html/to_html/figure_state captured widgets at their creation positions
  • Crosshair grabbable along its rules (ce7c2244), scale-bar colours (ce6016a0)

Not done

Aspect-equal letterbox gutters. Both routes are bigger than they look: auto-fitting panel height to the image aspect contradicts Rule 1 of the sizing contract ("grid tracks are always pure ratio math. No exceptions"), and an aspect="auto" fill mode needs a non-uniform x/y scale threaded through all 15 _imgFitRect call sites — image draw, markers, widgets, hit-testing, detail tiles and the WebGPU path. Adopting it partially would leave call sites disagreeing about where a data point is. It wants a decision from you on which way to go.

Residual on the y-label: an 8-character tick string (-5.6e-17) fills the 58px gutter on its own. The label goes as far left as it can and clips the leading characters — better than striking through the middle of every number, but not a fix. Eliminating it needs a wider gutter, and PAD_L is shared across panels to keep their plot areas aligned. There's a test that says exactly this.

Verification

uv run pytest1940 passed, 58 skipped, coverage 90%.

The ~90 new tests assert against rendered pixels in headless Chromium, not just Python state, because most of these bugs were in the renderer:

  • per-marker colours: 12 of 16 new tests fail on the old renderer, and the 4 that pass are exactly the cases that already worked
  • export sync: 5 of 8 fail without the fix
  • linestyle="none": a markers-only series with no marker renders exactly 0 coloured pixels
  • widget drags use real page.mouse input aimed at geometry the draw path publishes, rather than positions guessed from figure padding
  • data_to_display is checked against where the browser actually drew a marker — a first cut using get_xlim() spans was self-consistent and wrong by 26px, and that test is what caught it

Also refreshed FIGURE_ESM.md, whose anchor table had drifted ~1,200 lines before this branch (_imgFitRect listed at 1176, actually at 2372).

🤖 Generated with Claude Code

https://claude.ai/code/session_012TTbBeDrPJBTpruuszC3mD

matplotlib's markers-only idiom (linestyle="None", marker="o") had no
equivalent: _norm_linestyle rejected the value, and _drawLine always
stroked the connecting path because `st.line_linewidth || 1.5` turned an
explicit 0 back into the default.

- _LINESTYLE_ALIASES accepts "none"/"None". The matplotlib spellings ""
  and " " stay errors — test_invalid_empty_raises pins an empty style
  string as a typo, and that guard is worth more than the alias.
- _drawLine skips the stroke when linestyle is "none" or lw <= 0, and
  still draws markers. Marker edge width keeps deriving from lw.
- Call sites use ?? instead of || on linewidth so 0 survives.
- The legend swatch omits its rule for a "none" series.

Verified in headless Chromium: a markers-only series with no marker
renders exactly 0 coloured pixels, and switching solid -> none drops a
full-plot-width stroke worth of ink.

Assisted-by: Claude Opus 5 (1M context)
2-D panels emit pointer_down with the clicked position in data coords.
1-D panels emitted it only when the click landed within the hit-test
radius of a line, so pointer_down meant two different things depending on
panel kind and click-position features had nothing to listen to.

A genuine click (same small-movement threshold as before) inside the plot
rect now always emits exactly one pointer_down carrying xdata/ydata.
line_id and the snapped on-line x/y still ride along when a line was hit,
so the existing line-click contract is unchanged. Clicks in the axis
gutters emit nothing — they are not a data position.

Note line_id is null for a hit on the *primary* line; only add_line
overlays carry an id. That is pre-existing behaviour, now covered.

Verified with real Playwright clicks: empty-area click emits one event
with xdata/ydata and no line_id, gutter clicks emit none, drags emit
none, and an overlay hit still reports its id.

Assisted-by: Claude Opus 5 (1M context)
edgecolors/facecolors could already be a sequence parallel to the markers,
but only the 1-D points and polygons draw loops read it. Everywhere else
the group was painted with a single colour — and on 2-D panels an array
reached ctx.strokeStyle directly, which the canvas silently ignores.

Both draw loops now resolve colours per marker through the same _ecAt/
_fcAt helpers, covering circles, ellipses, rectangles, squares, arrows,
lines, polygons and texts on 2-D panels and points, vlines, hlines,
lines, ellipses, rectangles, squares and polygons on 1-D panels. Short
sequences cycle, matching the idiom already used by points/polygons.

No Python change was needed — to_wire already passed sequences through
untouched; the gap was entirely in the renderer.

Verified against headless Chromium by counting pixels of each requested
colour: 12 of the 16 new tests fail on the previous renderer, and the 4
that pass are exactly the cases that already worked (1-D points, scalar
colour, and the two wire-format checks).

Assisted-by: Claude Opus 5 (1M context)
Widget.set() fired pointer_move unconditionally, so a Python-initiated
geometry update looked exactly like a user drag: handlers ran, and one
that wrote back to its own widget recursed. The only defence was
pause_events(), which suppresses every event type for the duration.

- set() takes _notify (default True) to skip the callback while still
  pushing the render update. Spelled with a leading underscore, like the
  existing _push, so it can never collide with a widget property in
  **kwargs.
- Widget.remove() removes the widget from its owning plot. The back-link
  is set in _make_widget_push_fn, which every add_*_widget routes
  through, so there is one place that has to know. No-op when the widget
  was never attached or is already gone.

Assisted-by: Claude Opus 5 (1M context)
Plot2D could draw a circle, rectangle, annulus, polygon, crosshair, label
or arrow. Three shapes that callers actually reach for were missing:

- 'line': a bare two-endpoint segment. An arrow draws a head and a polygon
  needs >=3 vertices and closes the path, so a line profile or
  cross-section cut had no faithful widget. Endpoint handles move one end;
  the shaft translates both. LineWidget.length reports the length.
- 'vline'/'hline': full-height/full-width rules. They existed for 1-D
  panels only, so a 2-D single-axis pointer had to be a crosshair pinned
  to one coordinate, which leaves a stray perpendicular rule. No handle
  dot — the whole rule is the grab target, and the drag is constrained to
  the one axis.

Also: a crosshair is now grabbable along either rule rather than only at
the centre hotspot, which was a one-pixel target on a line-style pointer.
Grabbing a rule constrains the drag to that rule's axis (move_x/move_y).

The line branch publishes its canvas geometry to window._aplWidgetGeom,
as the rectangle branch already does, so Playwright tests can aim at a
real endpoint instead of guessing from figure padding.

Verified with real browser drags: endpoint vs shaft drags, rules grabbed
far from centre, a vertical drag on a vline being ignored, and the
crosshair rule-grab keeping the other axis fixed.

Assisted-by: Claude Opus 5 (1M context)
Two gaps that forced callers to reimplement widget behaviour in Python:

- add_range_widget(orientation="vertical") selects a range of values with
  a band spanning the plot width. x0/x1 stay the field names because they
  are the extents along the selection axis — matplotlib reads
  SpanSelector.extents the same way for either direction. style="fwhm" is
  horizontal-only and now raises rather than drawing something undefined.
- snap_values on range/vline/hline/point restricts a drag to a set of
  allowed positions. It has to live in the JS drag: snapping in Python
  after the event moves an edge the user is still holding, which reads as
  the selection fighting back. numpy arrays are normalised to lists —
  an ndarray in widget state is not JSON-serialisable and breaks the push.

The vertical band reuses the horizontal one's one-third hit-test rule, so
a thin band keeps a grabbable middle instead of being all edge, and the
same max_extent cap semantics.

Verified with real browser drags: a vertical band translating on a
vertical drag and ignoring a horizontal one, width preserved across a
translation, and snapped edges landing only on allowed values (with a
contrast case proving continuous dragging is still the default).

Assisted-by: Claude Opus 5 (1M context)
Two fixed visual choices that callers could not get out of.

- The colorbar strip was placed 2 px from the image edge while _cbWidth
  reserved 16 + labelW. Most plots set no colorbar_label, so labelW is 0
  and the strip visually touched the image. There is now a real 6 px gap
  (CB_GAP), applied consistently at both the reserve and the placement
  sites so the strip can never overflow the panel, and overridable with
  set_colorbar_pad(). The gap comes out of the image width, so widening
  it shrinks the image rather than pushing the strip off the edge.

- drawScaleBar2d hardcoded white on rgba(0,0,0,0.60). set_scalebar_style
  (color, bgcolor) recolours it; bgcolor="none" drops the pill, which is
  the case that matters over light images.

VISUAL CHANGE: the colorbar gap moves every colorbar plot by 4 px. The
imshow_labels baseline was regenerated with --update-baselines and should
be eyeballed rather than taken on trust.

Rendering tests measure the gap on the canvas (segmenting the middle row
into image / gap / strip) and the pill against a deliberately white
image — the pill is translucent, so over dark data it is
indistinguishable from the data underneath and a pixel count there would
measure nothing.

Assisted-by: Claude Opus 5 (1M context)
Two things that made callers reimplement anyplotlib internals:

- No geometry API. Anything working in display space — pixel-sized
  handles, hit-test tolerances, screenshot-driven tests — had to copy the
  PAD_* constants and the _imgFitRect letterbox math out of
  figure_esm.js and keep them in step by hand. plot_box(),
  data_to_display() and display_to_data() now come from the library.

  Mirroring the renderer exactly turned out to matter: a 2-D panel with
  no physical axes drops its left/right/bottom gutters entirely, the
  title strip grows with the title, the colorbar and its gap come out of
  the image width, and image coordinates are pixel CENTRES (the +0.5 in
  _imgToCanvas2d) over a zoom/pan window — not axis values. A first cut
  using get_xlim() spans was self-consistent and wrong by 26 px.

- Snapshots captured stale widget geometry. _push_widget writes only
  event_json, so panel_<id>_json kept the creation-time positions and
  save_html/to_html/figure_state exported widgets where they used to be.
  Figure._sync_for_export() reconciles the traits, reached through
  _repr_utils._widget_state — the one chokepoint every export goes
  through. The live path still uses targeted pushes.

The conversion tests place a marker at a known coordinate and check the
prediction against where the browser actually drew it, so the Python
mirror cannot drift from the JS without failing. 5 of the 8 export tests
fail without the fix.

Assisted-by: Claude Opus 5 (1M context)
Marker radii and widths were always data units, so a marker standing in
for a point swelled as the user zoomed in. That is right for a shape
drawn on the data and wrong for a glyph marking a position — which is
why matplotlib sizes scatter markers in display points.

size_units is a size space independent of the position transform:
positions stay in data coordinates while sizes are pinned to pixels.
Absent means "data", so existing wire dicts are unchanged.

The hit-tester reads the same flag as the draw loop. Skipping that would
have left hover targets at the data-scaled size while the marker was
drawn at the pixel size — the two would silently drift apart on zoom.

Assisted-by: Claude Opus 5 (1M context)
The label's x was a fixed round(PAD_L*0.28), with a comment asserting it
cleared the tick numbers "regardless of how wide those numbers are". It
does not. maxTW — the widest tick string — was already measured a few
lines above and simply not used for the label.

The fixed position is now a preference: the label shifts left when the
ticks need the room, clamped to half the rotated glyph height so it
cannot run off the canvas. maxTW is hoisted so the log branch measures
its ticks too (a plain-text measure of "10^e" over-estimates the
TeX-rendered width, which errs in the safe direction).

Measured on rendered screenshots: ticks up to 6 characters ("100000",
which collided before) now have a clear label column. 8-character strings
("-5.6e-17") span almost the whole 58 px gutter and leave nowhere clear —
the label goes to the left edge and clips the leading characters, which
is better than striking through the middle of every number but is not a
fix. Eliminating it needs a wider gutter, and PAD_L is shared across
panels to keep their plot areas aligned, so that is a layout policy call
rather than a placement one. Covered by a test that says so.

Assisted-by: Claude Opus 5 (1M context)
Changelog fragments use the orphan +{slug}.{type}.rst form the
upcoming_changes README prescribes for work batched on a branch with no
PR number yet, and only the types towncrier is configured for (an
earlier draft used "improvement", which is not one of them). Verified
with `towncrier build --draft`.

FIGURE_ESM.md's anchor table was stale by ~1,200 lines before this branch
— _imgFitRect was listed at 1176 and lives at 2372 — and these changes
moved sections further. Regenerated every line number against the current
file, and added anchors for _cbGap, _drawLine and _snapVal. The header's
line-count estimate goes from ~6,000 to ~9,000.

Assisted-by: Claude Opus 5 (1M context)
It was untracked in the working copy and is not part of this change;
picked up by a git add -A. Keeping the PR to its own files.

Assisted-by: Claude Opus 5 (1M context)
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.13300% with 18 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.32%. Comparing base (d4755d4) to head (5a2cb86).
⚠️ Report is 4 commits behind head on main.

Files with missing lines Patch % Lines
anyplotlib/_base_plot.py 82.35% 18 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #44      +/-   ##
==========================================
- Coverage   90.41%   90.32%   -0.10%     
==========================================
  Files          39       39              
  Lines        4008     4185     +177     
==========================================
+ Hits         3624     3780     +156     
- Misses        384      405      +21     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@CSSFrancis
CSSFrancis merged commit e7fc2bf into main Jul 26, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants