feat: HyperSpy parity — 16 upstream items#44
Merged
Conversation
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 Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements 16 of the 17 upstream items on HyperSpy's wishlist
(
docs/anyplotlib_improvements.mdin the hyperspyalternate-backendbranch),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
linestyle="none"_norm_linestyle, and_drawLinealways stroked the path —st.line_linewidth || 1.5turned an explicit0back into the default64ff4e46pointer_downon 1-Dpointer_downmeant different things per panel kind and click-position features had nothing to listen to29ff0adbedgecolors=[...]was honoured only bypoints/polygonson 1-D panels; elsewhere an array reachedctx.strokeStyle, which the canvas silently ignores4eceececline/vline/hlinewidgetsce7c2244Fidelity
ce6016a0) — the strip sat 2px from the image and most plots set nocolorbar_label, so it visually touched. Now a real 6px gap plusset_colorbar_pad().imshow_labelsbaseline 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.7d41de98) — the label's x was fixed atPAD_L*0.28with a comment claiming it cleared the ticks "regardless of how wide those numbers are". It didn't;maxTWwas already measured a few lines above and unused.Removing workarounds
set(_notify=False)andWidget.remove()(0859a59d)orientation="vertical"andsnap_values(04515e67) — snapping has to live in the JS drag; correcting in Python afterwards moves an edge the user is still holdingplot_box()/data_to_display()/display_to_data()(2510e83c) — callers were copyingPAD_*and the letterbox maths out offigure_esm.js2510e83c) —save_html/to_html/figure_statecaptured widgets at their creation positionsce7c2244), 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_imgFitRectcall 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, andPAD_Lis shared across panels to keep their plot areas aligned. There's a test that says exactly this.Verification
uv run pytest— 1940 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:
linestyle="none": a markers-only series with no marker renders exactly 0 coloured pixelspage.mouseinput aimed at geometry the draw path publishes, rather than positions guessed from figure paddingdata_to_displayis checked against where the browser actually drew a marker — a first cut usingget_xlim()spans was self-consistent and wrong by 26px, and that test is what caught itAlso refreshed
FIGURE_ESM.md, whose anchor table had drifted ~1,200 lines before this branch (_imgFitRectlisted at 1176, actually at 2372).🤖 Generated with Claude Code
https://claude.ai/code/session_012TTbBeDrPJBTpruuszC3mD