From 64ff4e46c3e6c5efe42ce7f7bea6c0dfe7ee9af4 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 26 Jul 2026 15:48:54 -0500 Subject: [PATCH 01/12] feat(plot1d): "none" linestyle and honoured linewidth=0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- anyplotlib/_utils.py | 14 ++- anyplotlib/axes/_axes.py | 4 +- anyplotlib/figure_esm.js | 64 ++++++---- anyplotlib/plot1d/_plot1d.py | 9 +- .../test_plot1d/test_line_none_rendering.py | 118 ++++++++++++++++++ anyplotlib/tests/test_plot1d/test_plot1d.py | 21 ++++ upcoming_changes/42.new_feature.rst | 6 + 7 files changed, 203 insertions(+), 33 deletions(-) create mode 100644 anyplotlib/tests/test_plot1d/test_line_none_rendering.py create mode 100644 upcoming_changes/42.new_feature.rst diff --git a/anyplotlib/_utils.py b/anyplotlib/_utils.py index 11bb2333..6e12d3ff 100644 --- a/anyplotlib/_utils.py +++ b/anyplotlib/_utils.py @@ -21,6 +21,12 @@ "dashdot": "dashdot", "step-mid": "step-mid", "steps-mid": "step-mid", + # "no connecting line" — draw the per-point markers only, so a matplotlib + # ``linestyle="None", marker="o"`` scatter ports over unchanged. + # matplotlib also spells this ``""`` and ``" "``; those stay errors here + # because an empty style string is far more often a typo than an intent. + "none": "none", + "None": "none", } @@ -42,7 +48,9 @@ def _norm_linestyle(ls: str) -> str: Accepted values --------------- ``"solid"`` / ``"-"``, ``"dashed"`` / ``"--"``, - ``"dotted"`` / ``":"``, ``"dashdot"`` / ``"-."``. + ``"dotted"`` / ``":"``, ``"dashdot"`` / ``"-."``, + ``"step-mid"`` / ``"steps-mid"``, and ``"none"`` / ``"None"`` / ``""`` / + ``" "`` for no connecting line (markers only). Raises ------ @@ -53,8 +61,8 @@ def _norm_linestyle(ls: str) -> str: if canonical is None: raise ValueError( f"Unknown linestyle {ls!r}. Expected one of: " - "'solid', 'dashed', 'dotted', 'dashdot', 'step-mid' (alias: 'steps-mid') " - "or shorthands '-', '--', ':', '-.'." + "'solid', 'dashed', 'dotted', 'dashdot', 'step-mid' (alias: 'steps-mid'), " + "'none' (markers only) or shorthands '-', '--', ':', '-.'." ) return canonical diff --git a/anyplotlib/axes/_axes.py b/anyplotlib/axes/_axes.py index e7ab23aa..99b38220 100644 --- a/anyplotlib/axes/_axes.py +++ b/anyplotlib/axes/_axes.py @@ -309,7 +309,9 @@ def plot(self, data: np.ndarray, linestyle : str, optional Dash pattern. Accepted values: ``"solid"`` (``"-"``), ``"dashed"`` (``"--"``), ``"dotted"`` (``":"``), - ``"dashdot"`` (``"-."``) . Default ``"solid"``. + ``"dashdot"`` (``"-."``), or ``"none"`` to draw no connecting + line at all — pair it with *marker* for a scatter. + Default ``"solid"``. ls : str, optional Short alias for *linestyle*. Takes precedence if both are given. alpha : float, optional diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index fe5ac617..f2f984e3 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -5626,6 +5626,9 @@ fn fs(in : VsOut) -> @location(0) vec4 { const eff_alpha = (alpha != null && alpha < 1.0) ? alpha : 1.0; const ms = Math.max(1, markersize || 4); const doMarker = marker && marker !== 'none'; + // linestyle 'none' (or a zero width) means "markers only" — matplotlib's + // scatter idiom. Skip the connecting stroke but keep drawing markers. + const doLine = linestyle !== 'none' && lw > 0; // Pre-compute pixel positions const allPx = new Array(n), allPy = new Array(n); @@ -5639,26 +5642,29 @@ fn fs(in : VsOut) -> @location(0) vec4 { ctx.save(); if (eff_alpha < 1.0) ctx.globalAlpha = eff_alpha; - ctx.setLineDash(dash); - ctx.beginPath(); - ctx.strokeStyle = color; ctx.lineWidth = lw; ctx.lineJoin = 'round'; - - if (isStepMid && n >= 2) { - ctx.moveTo(allPx[0], allPy[0]); - for (let i = 0; i < n - 1; i++) { - const midX = (allPx[i] + allPx[i + 1]) / 2; - ctx.lineTo(midX, allPy[i]); - ctx.lineTo(midX, allPy[i + 1]); - } - ctx.lineTo(allPx[n - 1], allPy[n - 1]); - } else { - for (let i = 0; i < n; i++) { - if (i === 0) ctx.moveTo(allPx[i], allPy[i]); - else ctx.lineTo(allPx[i], allPy[i]); + + if (doLine) { + ctx.setLineDash(dash); + ctx.beginPath(); + ctx.strokeStyle = color; ctx.lineWidth = lw; ctx.lineJoin = 'round'; + + if (isStepMid && n >= 2) { + ctx.moveTo(allPx[0], allPy[0]); + for (let i = 0; i < n - 1; i++) { + const midX = (allPx[i] + allPx[i + 1]) / 2; + ctx.lineTo(midX, allPy[i]); + ctx.lineTo(midX, allPy[i + 1]); + } + ctx.lineTo(allPx[n - 1], allPy[n - 1]); + } else { + for (let i = 0; i < n; i++) { + if (i === 0) ctx.moveTo(allPx[i], allPy[i]); + else ctx.lineTo(allPx[i], allPy[i]); + } } + ctx.stroke(); + ctx.setLineDash([]); } - ctx.stroke(); - ctx.setLineDash([]); const pts = doMarker ? allPx.map((px, i) => [px, allPy[i]]) : null; @@ -5679,8 +5685,10 @@ fn fs(in : VsOut) -> @location(0) vec4 { ctx.restore(); } + // ?? not || on linewidth: an explicit 0 means "no stroke" and must not + // fall back to the 1.5 default (see _drawLine's doLine). _drawLine(yData, xArr, - st.line_color || '#4fc3f7', st.line_linewidth || 1.5, + st.line_color || '#4fc3f7', st.line_linewidth ?? 1.5, st.line_linestyle || 'solid', st.line_alpha != null ? st.line_alpha : 1.0, st.line_marker || 'none', st.line_markersize || 4); @@ -5689,7 +5697,7 @@ fn fs(in : VsOut) -> @location(0) vec4 { const exX = ex.x_axis_b64 ? _decodeF64(ex.x_axis_b64) : (ex.x_axis ? ex.x_axis : xArr); const exMap = ex.axis === 'right' ? _toRightY : _toPlotY; _drawLine(exY, exX, - ex.color || (theme.dark ? '#fff' : '#333'), ex.linewidth || 1.5, + ex.color || (theme.dark ? '#fff' : '#333'), ex.linewidth ?? 1.5, ex.linestyle || 'solid', ex.alpha != null ? ex.alpha : 1.0, ex.marker || 'none', ex.markersize || 4, exMap); @@ -5699,7 +5707,7 @@ fn fs(in : VsOut) -> @location(0) vec4 { if (_hovId !== undefined && _hovId !== '__none__') { if (_hovId === null) { _drawLine(yData, xArr, - _brightenColor(st.line_color||'#4fc3f7'), (st.line_linewidth||1.5)+1, + _brightenColor(st.line_color||'#4fc3f7'), (st.line_linewidth??1.5)+1, st.line_linestyle||'solid', st.line_alpha!=null?st.line_alpha:1.0, st.line_marker||'none', st.line_markersize||4); } else { @@ -5709,7 +5717,7 @@ fn fs(in : VsOut) -> @location(0) vec4 { const exX = ex.x_axis_b64 ? _decodeF64(ex.x_axis_b64) : (ex.x_axis?ex.x_axis:xArr); const exMap = ex.axis === 'right' ? _toRightY : _toPlotY; _drawLine(exY, exX, - _brightenColor(ex.color||(theme.dark?'#fff':'#333')), (ex.linewidth||1.5)+1, + _brightenColor(ex.color||(theme.dark?'#fff':'#333')), (ex.linewidth??1.5)+1, ex.linestyle||'solid', ex.alpha!=null?ex.alpha:1.0, ex.marker||'none', ex.markersize||4, exMap); break; @@ -5847,10 +5855,14 @@ fn fs(in : VsOut) -> @location(0) vec4 { const legendTop = ly; for(const lb of labels){ const ldash=_LINESTYLE_DASH[lb.linestyle||'solid']||[]; - ctx.strokeStyle=lb.color; ctx.lineWidth=2; - ctx.setLineDash(ldash); - ctx.beginPath(); ctx.moveTo(swatchX0,ly+5); ctx.lineTo(swatchX1,ly+5); ctx.stroke(); - ctx.setLineDash([]); + // linestyle 'none' is a markers-only series: the swatch shows just the + // marker, with no connecting rule (matches how the series is drawn). + if(lb.linestyle!=='none'){ + ctx.strokeStyle=lb.color; ctx.lineWidth=2; + ctx.setLineDash(ldash); + ctx.beginPath(); ctx.moveTo(swatchX0,ly+5); ctx.lineTo(swatchX1,ly+5); ctx.stroke(); + ctx.setLineDash([]); + } if(lb.marker && lb.marker!=='none'){ ctx.strokeStyle=lb.color; ctx.fillStyle=lb.color; ctx.lineWidth=1.5; ctx.beginPath(); _drawMarkerSymbol(ctx,lb.marker,markerX,ly+5,Math.min(lb.ms||4,4)); ctx.fill(); ctx.stroke(); diff --git a/anyplotlib/plot1d/_plot1d.py b/anyplotlib/plot1d/_plot1d.py index e25d25cd..05d6a4d0 100644 --- a/anyplotlib/plot1d/_plot1d.py +++ b/anyplotlib/plot1d/_plot1d.py @@ -237,7 +237,8 @@ class Plot1D(_BasePlot, _PanelMixin, _MarkerMixin): - ``"solid"`` - Dash pattern: ``"solid"``, ``"dashed"``, ``"dotted"``, ``"dashdot"``. Shorthands ``"-"``, ``"--"``, ``":"``, - ``"-."`` also accepted. + ``"-."`` also accepted, as is ``"none"`` (markers only, no + connecting line). * - ``alpha`` - ``1.0`` - Line opacity (0 = transparent, 1 = fully opaque). @@ -550,7 +551,8 @@ def add_line(self, data: np.ndarray, x_axis=None, Stroke width in pixels. Default ``1.5``. linestyle : str, optional Dash pattern: ``"solid"``, ``"dashed"``, ``"dotted"``, - ``"dashdot"`` (or shorthands). Default ``"solid"``. + ``"dashdot"`` (or shorthands), or ``"none"`` for markers only + with no connecting line. Default ``"solid"``. ls : str, optional Short alias for *linestyle*. alpha : float, optional @@ -924,7 +926,8 @@ def set_linestyle(self, linestyle: str) -> None: ---------- linestyle : str ``"solid"`` (``"-"``), ``"dashed"`` (``"--"``), - ``"dotted"`` (``":"``), or ``"dashdot"`` (``"-."``) + ``"dotted"`` (``":"``), ``"dashdot"`` (``"-."``), or ``"none"`` + to suppress the connecting line and draw only the markers. """ self._state["line_linestyle"] = _norm_linestyle(linestyle) self._push() diff --git a/anyplotlib/tests/test_plot1d/test_line_none_rendering.py b/anyplotlib/tests/test_plot1d/test_line_none_rendering.py new file mode 100644 index 00000000..c1186e7d --- /dev/null +++ b/anyplotlib/tests/test_plot1d/test_line_none_rendering.py @@ -0,0 +1,118 @@ +""" +Playwright tests for ``linestyle="none"`` and ``linewidth=0`` on Plot1D. + +Strategy +-------- +Both mean "draw no connecting line". Canvas contents cannot be inspected as +geometry, so these tests assert on *ink*: a markers-only series must leave +far fewer coloured pixels than the same data drawn as a solid line, while +still leaving some (the markers themselves). + +The data is a ramp, so a connecting line would sweep across the whole plot +area — a markers-only version of the same series is unambiguously sparser. +""" +from __future__ import annotations + +import numpy as np + +import anyplotlib as apl + +PAD_L, PAD_R, PAD_T, PAD_B = 58, 12, 12, 42 + +# The series colour, chosen well away from both themes' backgrounds and grid. +LINE_COLOR = "#ff0000" + + +# ── helpers ─────────────────────────────────────────────────────────────────── + +def _plot_area(img: np.ndarray) -> np.ndarray: + """Return just the plot rectangle, excluding the axis gutters.""" + return img[PAD_T:-PAD_B, PAD_L:-PAD_R, :3].astype(int) + + +def _red_ink(img: np.ndarray) -> int: + """Count pixels in the plot area that are predominantly the line colour.""" + area = _plot_area(img) + r, g, b = area[..., 0], area[..., 1], area[..., 2] + return int(((r > 120) & (g < 100) & (b < 100)).sum()) + + +def _render(take_screenshot, **plot_kwargs) -> int: + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.plot(np.linspace(0.0, 1.0, 24), color=LINE_COLOR, **plot_kwargs) + return _red_ink(take_screenshot(fig)) + + +# ══════════════════════════════════════════════════════════════════════════════ + + +class TestLinestyleNone: + def test_none_draws_less_ink_than_solid(self, take_screenshot): + solid = _render(take_screenshot, linestyle="solid", marker="o") + none = _render(take_screenshot, linestyle="none", marker="o") + assert none < solid, ( + f"linestyle='none' must drop the connecting stroke " + f"(got {none} px vs {solid} px for solid)" + ) + + def test_ink_dropped_is_the_full_span(self, take_screenshot): + """The ink lost to ``none`` is a stroke crossing the whole plot area. + + The series is a monotonic ramp, so its connecting line spans the full + width of the plot rectangle. Anything less than that would mean part + of the stroke survived. + """ + solid = _render(take_screenshot, linestyle="solid", marker="o") + none = _render(take_screenshot, linestyle="none", marker="o") + plot_width = 400 - PAD_L - PAD_R + assert solid - none > plot_width * 0.5, ( + f"expected to lose a full-width stroke, lost only {solid - none} px " + f"across a {plot_width} px wide plot area" + ) + + def test_none_still_draws_markers(self, take_screenshot): + """Markers-only is not the same as invisible.""" + none = _render(take_screenshot, linestyle="none", marker="o", markersize=6) + assert none > 0, "markers must still be drawn when linestyle='none'" + + def test_none_without_markers_draws_nothing(self, take_screenshot): + blank = _render(take_screenshot, linestyle="none", marker="none") + assert blank == 0, ( + f"linestyle='none' with no marker must draw nothing, got {blank} px" + ) + + +class TestZeroLinewidth: + def test_zero_linewidth_suppresses_stroke(self, take_screenshot): + """A 0 width must not fall back to the 1.5 default in the renderer.""" + thin = _render(take_screenshot, linewidth=0, marker="none") + assert thin == 0, f"linewidth=0 must draw no stroke, got {thin} px" + + def test_zero_linewidth_keeps_markers(self, take_screenshot): + pts = _render(take_screenshot, linewidth=0, marker="o", markersize=6) + assert pts > 0, "markers must survive linewidth=0" + + +class TestOverlayLineNone: + """The same rules apply to add_line() overlays, not just the primary line.""" + + def _overlay_ink(self, take_screenshot, **line_kwargs) -> int: + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.plot(np.zeros(24), color="#222222", linewidth=0.5) + plot.add_line(np.linspace(0.0, 1.0, 24), color=LINE_COLOR, **line_kwargs) + return _red_ink(take_screenshot(fig)) + + def test_overlay_none_draws_less_than_solid(self, take_screenshot): + solid = self._overlay_ink(take_screenshot, linestyle="solid", marker="o") + none = self._overlay_ink(take_screenshot, linestyle="none", marker="o") + assert none < solid, ( + f"overlay linestyle='none' must drop the stroke " + f"(got {none} px vs {solid} px)" + ) + + def test_overlay_none_with_no_marker_draws_nothing(self, take_screenshot): + assert self._overlay_ink(take_screenshot, linestyle="none", + marker="none") == 0 + + def test_overlay_zero_linewidth_suppresses_stroke(self, take_screenshot): + assert self._overlay_ink(take_screenshot, linewidth=0, marker="none") == 0 diff --git a/anyplotlib/tests/test_plot1d/test_plot1d.py b/anyplotlib/tests/test_plot1d/test_plot1d.py index 4ba530f8..aff6224b 100644 --- a/anyplotlib/tests/test_plot1d/test_plot1d.py +++ b/anyplotlib/tests/test_plot1d/test_plot1d.py @@ -82,6 +82,27 @@ def test_invalid_empty_raises(self): with pytest.raises(ValueError): _norm_linestyle("") + @pytest.mark.parametrize("spelling", ["none", "None"]) + def test_none_means_no_connecting_line(self, spelling): + """matplotlib's markers-only idiom: linestyle="None", marker="o".""" + assert _norm_linestyle(spelling) == "none" + + def test_set_linestyle_none_accepted(self): + p = _plot_lin() + p.set_linestyle("None") + assert p._state["line_linestyle"] == "none" + + def test_plot_with_linestyle_none_and_marker(self): + p = _plot_lin(linestyle="None", marker="o", markersize=3) + assert p._state["line_linestyle"] == "none" + assert p._state["line_marker"] == "o" + + def test_zero_linewidth_is_preserved(self): + """0 must survive to the wire — the renderer treats it as no stroke.""" + p = _plot_lin(linewidth=0) + assert p._state["line_linewidth"] == 0.0 + assert p.to_state_dict()["line_linewidth"] == 0.0 + # =========================================================================== # Default state values diff --git a/upcoming_changes/42.new_feature.rst b/upcoming_changes/42.new_feature.rst new file mode 100644 index 00000000..e74ab01e --- /dev/null +++ b/upcoming_changes/42.new_feature.rst @@ -0,0 +1,6 @@ +``linestyle="none"`` (also spelled ``"None"``) draws a series' markers with no +connecting line — matplotlib's scatter idiom, ``ax.plot(y, linestyle="none", +marker="o")``. An explicit ``linewidth=0`` now means the same thing; it +previously fell back to the 1.5 default in the renderer. Both apply to the +primary line and to :meth:`~anyplotlib.Plot1D.add_line` overlays, and the +legend swatch drops its rule to match. From 29ff0adbdb5514981a6a59da1ab2e45a1c6e36bb Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 26 Jul 2026 15:54:06 -0500 Subject: [PATCH 02/12] feat(events): positional pointer_down on 1-D panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- anyplotlib/figure_esm.js | 28 +++- .../test_interactive/test_pointer_down_1d.py | 144 ++++++++++++++++++ upcoming_changes/.gitkeep | 3 + upcoming_changes/42.improvement.rst | 7 + 4 files changed, 179 insertions(+), 3 deletions(-) create mode 100644 anyplotlib/tests/test_interactive/test_pointer_down_1d.py create mode 100644 upcoming_changes/.gitkeep create mode 100644 upcoming_changes/42.improvement.rst diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index f2f984e3..6466d05e 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -6927,16 +6927,38 @@ fn fs(in : VsOut) -> @location(0) vec4 { const st=p.state; if(st) _emitEvent(p.id,'pointer_up',null,{view_x0:st.view_x0,view_x1:st.view_x1,..._pointerFields(e),button:e.button}); } - // Line click: fire when no widget was being dragged and mouse barely moved. + // Click: fire when no widget was being dragged and mouse barely moved. // NOTE: p.isPanning is always set true on mousedown (pan start), so we // deliberately only block on wasWidgetDragging here — the distance // threshold below already excludes real pan gestures. + // + // A click always emits exactly one pointer_down carrying the clicked + // position in data coords, mirroring 2-D panels. When the click also + // landed on a line, line_id (and the snapped on-line x/y) come along — + // that is the pre-existing line-click contract, kept intact. if(!wasWidgetDragging && p._mousedownX!=null){ const mdx=e.clientX-p._mousedownX, mdy=e.clientY-p._mousedownY; if(Math.hypot(mdx,mdy)<5){ const {mx,my}=_clientPos(e,overlayCanvas,p.pw,p.ph); - const lhit=_lineHitTest1d(mx,my,p); - if(lhit) _emitEvent(p.id,'pointer_down',null,{line_id:lhit.lineId,x:lhit.x,y:lhit.y,..._pointerFields(e),button:e.button}); + const st=p.state; + const r=_plotRect1d(p); + const inPlot = st && mx>=r.x && mx<=r.x+r.w && my>=r.y && my<=r.y+r.h; + if(inPlot){ + const lhit=_lineHitTest1d(mx,my,p); + const xArr=p._1dXArr||(st.x_axis_b64?_decodeF64(st.x_axis_b64):(st.x_axis||[])); + const frac=_canvasXToFrac1d(mx,st.view_x0||0,st.view_x1||1,r); + const physX=xArr.length>=2?_axisFracToVal(xArr,frac):frac; + const dMin=st.data_min, dMax=st.data_max; + const physY=dMin+(r.y+r.h-my)/(r.h||1)*(dMax-dMin); + _emitEvent(p.id,'pointer_down',null,{ + ...(lhit?{line_id:lhit.lineId}:{}), + // On-line coords when a line was hit, else the raw click point. + x:lhit?lhit.x:physX, y:lhit?lhit.y:physY, + xdata:physX, ydata:physY, + ..._pointerFields(e), + button:e.button, + }); + } } } p._mousedownX=null; diff --git a/anyplotlib/tests/test_interactive/test_pointer_down_1d.py b/anyplotlib/tests/test_interactive/test_pointer_down_1d.py new file mode 100644 index 00000000..c0a3e702 --- /dev/null +++ b/anyplotlib/tests/test_interactive/test_pointer_down_1d.py @@ -0,0 +1,144 @@ +""" +tests/test_interactive/test_pointer_down_1d.py +=============================================== + +Playwright tests for positional ``pointer_down`` on 1-D panels. + +2-D panels have always emitted ``pointer_down`` with the clicked position in +data coordinates. 1-D panels used to emit it *only* when the click landed +within the hit-test radius of a line, which made ``pointer_down`` mean two +different things depending on panel kind and left click-position features +(e.g. "jump the cursor where I clicked") with nothing to listen to. + +A click on a 1-D panel now always emits exactly one ``pointer_down`` carrying +``xdata``/``ydata``. ``line_id`` still rides along when a line was hit, so the +older line-click contract is unchanged. +""" +from __future__ import annotations + +import numpy as np + +import anyplotlib as apl +from anyplotlib.tests.test_interactive._event_test_utils import ( + _collect_events, + _get_events, + _plot_center_page, + GRID_PAD, + PAD_L, PAD_R, PAD_T, PAD_B, +) + +FIG_W, FIG_H = 400, 300 + + +def _make_flat_1d(interact_page, value=0.0): + """A flat line at *value* — its pixel row is easy to avoid or hit.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.plot(np.full(64, value), axes=[np.linspace(0.0, 10.0, 64)]) + page = interact_page(fig) + _collect_events(page) + return fig, plot, page + + +def _click(page, x, y): + page.mouse.click(x, y) + page.wait_for_timeout(100) + + +class TestPositionalPointerDown: + def test_click_away_from_any_line_emits_pointer_down(self, interact_page): + """The regression this fixes: an empty-area click used to emit nothing.""" + _, _, page = _make_flat_1d(interact_page) + cx, _ = _plot_center_page(FIG_W, FIG_H) + # A flat line at y=0 sits on the vertical mid-row; click well above it. + near_top = GRID_PAD + PAD_T + 20 + _click(page, cx, near_top) + events = _get_events(page, "pointer_down") + assert len(events) == 1, ( + f"a click in empty plot area must emit exactly one pointer_down, " + f"got {len(events)}" + ) + + def test_positional_event_carries_data_coords(self, interact_page): + _, _, page = _make_flat_1d(interact_page) + cx, _ = _plot_center_page(FIG_W, FIG_H) + _click(page, cx, GRID_PAD + PAD_T + 20) + ev = _get_events(page, "pointer_down")[0] + assert ev.get("xdata") is not None + assert ev.get("ydata") is not None + # Clicked the horizontal centre of a 0..10 x-axis. + assert 4.0 < ev["xdata"] < 6.0, ev["xdata"] + + def test_empty_area_click_has_no_line_id(self, interact_page): + _, _, page = _make_flat_1d(interact_page) + cx, _ = _plot_center_page(FIG_W, FIG_H) + _click(page, cx, GRID_PAD + PAD_T + 20) + ev = _get_events(page, "pointer_down")[0] + assert ev.get("line_id") is None + + def test_click_outside_plot_area_emits_nothing(self, interact_page): + """The axis gutters are not the plot; clicking there is not a position.""" + _, _, page = _make_flat_1d(interact_page) + cx, _ = _plot_center_page(FIG_W, FIG_H) + in_bottom_gutter = GRID_PAD + FIG_H - PAD_B + 15 + _click(page, cx, in_bottom_gutter) + assert _get_events(page, "pointer_down") == [] + + def test_xdata_tracks_click_x(self, interact_page): + """Two clicks at different x must report different, ordered xdata.""" + _, _, page = _make_flat_1d(interact_page) + y = GRID_PAD + PAD_T + 20 + left_x = GRID_PAD + PAD_L + 20 + right_x = GRID_PAD + FIG_W - PAD_R - 20 + _click(page, left_x, y) + _click(page, right_x, y) + events = _get_events(page, "pointer_down") + assert len(events) == 2 + assert events[0]["xdata"] < events[1]["xdata"] + + +class TestLineClickContractPreserved: + def test_click_on_overlay_still_reports_line_id(self, interact_page): + """The pre-existing line-click payload must keep working. + + Only ``add_line`` overlays carry an id — a hit on the primary line + reports ``line_id`` as ``None`` (``_lineHitTest1d`` passes ``null`` + for it), so the overlay is what pins this contract. + """ + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.plot(np.full(64, -1.0), axes=[np.linspace(0.0, 10.0, 64)]) + line = plot.add_line(np.full(64, 1.0)) + page = interact_page(fig) + _collect_events(page) + + # The overlay sits at the top of the -1..1 range and the primary at the + # bottom, but the exact pixel row depends on renderer padding — so scan + # down the upper half of the plot rather than hard-coding it. + cx, _ = _plot_center_page(FIG_W, FIG_H) + top = GRID_PAD + PAD_T + plot_h = FIG_H - PAD_T - PAD_B + for dy in range(0, plot_h // 2, 4): + _click(page, cx, top + dy) + + ids = {e.get("line_id") for e in _get_events(page, "pointer_down")} + assert line.id in ids, ( + f"clicking the overlay must report its id; saw line_ids {ids}" + ) + + def test_line_click_also_carries_data_coords(self, interact_page): + _, _, page = _make_flat_1d(interact_page) + cx, cy = _plot_center_page(FIG_W, FIG_H) + _click(page, cx, cy) + ev = _get_events(page, "pointer_down")[0] + assert ev.get("xdata") is not None + assert ev.get("ydata") is not None + + def test_drag_does_not_emit_pointer_down(self, interact_page): + """Panning is not a click.""" + _, _, page = _make_flat_1d(interact_page) + cx, cy = _plot_center_page(FIG_W, FIG_H) + page.mouse.move(cx, cy) + page.mouse.down() + page.mouse.move(cx + 60, cy, steps=10) + page.mouse.up() + page.wait_for_timeout(100) + assert _get_events(page, "pointer_down") == [] diff --git a/upcoming_changes/.gitkeep b/upcoming_changes/.gitkeep new file mode 100644 index 00000000..061573b3 --- /dev/null +++ b/upcoming_changes/.gitkeep @@ -0,0 +1,3 @@ +# This file keeps the upcoming_changes/ directory tracked by git. +# Replace it with real fragment files (e.g. 42.new_feature.rst) as PRs land. + diff --git a/upcoming_changes/42.improvement.rst b/upcoming_changes/42.improvement.rst new file mode 100644 index 00000000..2b3a17ae --- /dev/null +++ b/upcoming_changes/42.improvement.rst @@ -0,0 +1,7 @@ +Clicking a 1-D panel now always emits a ``pointer_down`` event carrying the +clicked position as ``xdata``/``ydata``, matching 2-D panels. Previously a +1-D panel emitted ``pointer_down`` only when the click landed on a line, so +click-position features had nothing to listen to and ``pointer_down`` meant +different things on different panel kinds. Clicks on a line still report +``line_id`` (and the snapped on-line ``x``/``y``), so existing line-click +handlers are unaffected — they can filter on ``event.line_id is not None``. From 4eceecec6759cefe2500706aaf4b9108f928cf9f Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 26 Jul 2026 16:01:08 -0500 Subject: [PATCH 03/12] feat(markers): per-marker edge/face colours for every marker type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- anyplotlib/figure_esm.js | 71 +++++-- anyplotlib/markers.py | 11 + .../test_markers/test_per_marker_colors.py | 188 ++++++++++++++++++ upcoming_changes/42.new_feature_2.rst | 11 + 4 files changed, 259 insertions(+), 22 deletions(-) create mode 100644 anyplotlib/tests/test_markers/test_per_marker_colors.py create mode 100644 upcoming_changes/42.new_feature_2.rst diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 6466d05e..e1d25d42 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -3266,6 +3266,13 @@ function render({ model, el, onResize }) { const fch = isHov && ms.hover_facecolor ? ms.hover_facecolor : fc; const dlw = isHov && (ms.hover_color || ms.hover_facecolor) ? lw+1 : lw; const type = ms.type || 'circles'; + // Per-marker edge/face colours: `color` and `fill_color` may be arrays + // parallel to the markers (matplotlib's edgecolors=[...] / c=[...]). + // Shorter arrays cycle, as matplotlib's colour cycle does. + const _ecArr = Array.isArray(ec) ? ec : null; + const _fcArr = Array.isArray(fch) ? fch : null; + const _ecAt = (i) => _ecArr ? _ecArr[i % _ecArr.length] : ec; + const _fcAt = (i) => _fcArr ? _fcArr[i % _fcArr.length] : fch; // Coordinate transform dispatch: "data" (default), "axes", "display". // For non-data transforms sizes are in pixels, not scaled by zoom. @@ -3286,14 +3293,17 @@ function render({ model, el, onResize }) { if(clipSet){ mkCtx.beginPath(); mkCtx.rect(vr.x, vr.y, vr.w, vr.h); mkCtx.clip(); } - mkCtx.strokeStyle=ec; mkCtx.fillStyle=ec; mkCtx.lineWidth=dlw; + // Group-level style; per-marker arrays override inside each loop below. + mkCtx.strokeStyle=_ecAt(0); mkCtx.fillStyle=_ecAt(0); mkCtx.lineWidth=dlw; if(type==='circles'){ for(let i=0;i @location(0) vec4 { const ec = isHov && ms.hover_color ? ms.hover_color : color; const fch = isHov && ms.hover_facecolor ? ms.hover_facecolor : fc; const dlw = isHov && (ms.hover_color || ms.hover_facecolor) ? lw+1 : lw; + // Per-marker edge/face colours — see the matching block in _drawMarkers2d. + const _ecArr = Array.isArray(ec) ? ec : null; + const _fcArr = Array.isArray(fch) ? fch : null; + const _ecAt = (i) => _ecArr ? _ecArr[i % _ecArr.length] : ec; + const _fcAt = (i) => _fcArr ? _fcArr[i % _fcArr.length] : fch; // Coordinate transform: "axes" and "display" map 2-D offsets to panel // space independently of data values; vlines/hlines stay in data coords. @@ -6012,7 +6038,7 @@ fn fs(in : VsOut) -> @location(0) vec4 { _tc2d=(off0,off1)=>_offToCanvas([off0,off1]); } - mkCtx.save();mkCtx.strokeStyle=ec;mkCtx.fillStyle=ec;mkCtx.lineWidth=dlw; + mkCtx.save();mkCtx.strokeStyle=_ecAt(0);mkCtx.fillStyle=_ecAt(0);mkCtx.lineWidth=dlw; // Optional clip path (matplotlib set_clip_path): a data-coord polygon the // group is clipped to — e.g. a pcolormesh mesh clipped to a curved @@ -6029,33 +6055,34 @@ fn fs(in : VsOut) -> @location(0) vec4 { } if(type==='points'){ - // Per-point face/edge colours (matplotlib scatter c=[...]): fill_color - // and/or color may be arrays parallel to offsets. - const _fcArr = Array.isArray(fch) ? fch : null; - const _ecArr = Array.isArray(ec) ? ec : null; for(let i=0;i @location(0) vec4 { const rh=Math.max(1, tfm==='data' ? Math.abs(_yPx((off[1]||0)-hd/2)-_yPx((off[1]||0)+hd/2))/2 : hd/2); const ang=((ms.angles&&(ms.angles[i]!=null?ms.angles[i]:ms.angles[0])||0)*Math.PI)/180; mkCtx.beginPath();mkCtx.ellipse(cx,cy,rw,rh,ang,0,Math.PI*2); - if(fch){mkCtx.save();mkCtx.globalAlpha=fa;mkCtx.fillStyle=fch;mkCtx.fill();mkCtx.restore();} + const _fc=_fcAt(i); + if(_fc){mkCtx.save();mkCtx.globalAlpha=fa;mkCtx.fillStyle=_fc;mkCtx.fill();mkCtx.restore();} + if(_ecArr) mkCtx.strokeStyle=_ecAt(i); mkCtx.stroke(); } } else if(type==='rectangles'||type==='squares'){ @@ -6082,15 +6111,13 @@ fn fs(in : VsOut) -> @location(0) vec4 { const rh=Math.max(1, tfm==='data' ? Math.abs(_yPx((off[1]||0)-hd/2)-_yPx((off[1]||0)+hd/2)) : hd); const ang=((ms.angles&&(ms.angles[i]!=null?ms.angles[i]:ms.angles[0])||0)*Math.PI)/180; mkCtx.save();mkCtx.translate(cx,cy);mkCtx.rotate(ang); - if(fch){mkCtx.save();mkCtx.globalAlpha=fa;mkCtx.fillStyle=fch;mkCtx.fillRect(-rw/2,-rh/2,rw,rh);mkCtx.restore();} + const _fc=_fcAt(i); + if(_fc){mkCtx.save();mkCtx.globalAlpha=fa;mkCtx.fillStyle=_fc;mkCtx.fillRect(-rw/2,-rh/2,rw,rh);mkCtx.restore();} + if(_ecArr) mkCtx.strokeStyle=_ecAt(i); mkCtx.strokeRect(-rw/2,-rh/2,rw,rh); mkCtx.restore(); } } else if(type==='polygons'){ - // Per-polygon face/edge colours (matplotlib PathCollection / pcolormesh): - // fill_color and/or color may be arrays parallel to vertices_list. - const _fcArr = Array.isArray(fch) ? fch : null; - const _ecArr = Array.isArray(ec) ? ec : null; for(let i=0;i<(ms.vertices_list||[]).length;i++){ const verts=ms.vertices_list[i]; if(!verts||verts.length<2) continue; @@ -6101,9 +6128,9 @@ fn fs(in : VsOut) -> @location(0) vec4 { mkCtx.lineTo(px,py); } mkCtx.closePath(); - const _fc=_fcArr?_fcArr[i%_fcArr.length]:fch; + const _fc=_fcAt(i); if(_fc){mkCtx.save();mkCtx.globalAlpha=fa;mkCtx.fillStyle=_fc;mkCtx.fill();mkCtx.restore();} - if(_ecArr) mkCtx.strokeStyle=_ecArr[i%_ecArr.length]; + if(_ecArr) mkCtx.strokeStyle=_ecAt(i); mkCtx.stroke(); } } else if(type==='raster'){ diff --git a/anyplotlib/markers.py b/anyplotlib/markers.py index 19ae111c..9c8c31bd 100644 --- a/anyplotlib/markers.py +++ b/anyplotlib/markers.py @@ -28,6 +28,17 @@ (``color``, ``fill_color``, ``fill_alpha``, ``sizes``, etc.). ``MarkerGroup`` stores matplotlib-style names and ``to_wire()`` translates before JSON serialisation. + +Per-marker colours +------------------ +``edgecolors`` and ``facecolors`` accept either one colour for the whole group +or a sequence of colours parallel to the markers — matplotlib's +``edgecolors=[...]`` / scatter ``c=[...]``:: + + plot.add_circles(offsets, edgecolors=["#f00", "#0f0", "#00f"], radius=3) + +A sequence shorter than the group cycles, as matplotlib's colour cycle does. +This works for every marker type on both 1-D and 2-D panels. """ from __future__ import annotations diff --git a/anyplotlib/tests/test_markers/test_per_marker_colors.py b/anyplotlib/tests/test_markers/test_per_marker_colors.py new file mode 100644 index 00000000..bebc76ba --- /dev/null +++ b/anyplotlib/tests/test_markers/test_per_marker_colors.py @@ -0,0 +1,188 @@ +""" +Per-marker edge/face colours (matplotlib ``edgecolors=[...]`` / scatter ``c=``). + +``edgecolors`` and ``facecolors`` may be a sequence parallel to the markers +instead of one colour for the whole group. ``points`` and ``polygons`` on 1-D +panels already honoured this; every other type painted the whole group in the +first colour (or, on 2-D panels, in whatever the canvas made of being handed +an array as a ``strokeStyle``). + +Canvas contents can't be read back as geometry, so these tests count pixels of +each requested colour in the rendered screenshot: a group asked for red, green +and blue markers must actually put all three on the canvas. +""" +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl + +RGB = { + "#ff0000": (255, 0, 0), + "#00ff00": (0, 255, 0), + "#0000ff": (0, 0, 255), +} +COLORS = list(RGB) + + +def _count(img: np.ndarray, rgb: tuple[int, int, int], tol: int = 60) -> int: + """Pixels within *tol* of an exact RGB triple.""" + a = img[..., :3].astype(int) + return int((np.abs(a - np.array(rgb)).sum(axis=-1) < tol).sum()) + + +def _colors_present(img: np.ndarray) -> set[str]: + return {c for c in COLORS if _count(img, RGB[c]) > 0} + + +def _image_fig(add, **kwargs): + """A 2-D panel with one marker group added by *add*.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 400)) + plot = ax.imshow(np.zeros((40, 40), dtype=np.float32)) + add(plot, **kwargs) + return fig + + +def _line_fig(add, **kwargs): + """A 1-D panel with one marker group added by *add*.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 400)) + plot = ax.plot(np.zeros(40)) + add(plot, **kwargs) + return fig + + +# Three well-separated positions on a 40x40 image. +OFFSETS = [[8, 8], [20, 20], [32, 32]] + + +# ══════════════════════════════════════════════════════════════════════════════ +# 2-D panels +# ══════════════════════════════════════════════════════════════════════════════ + +class TestPerMarkerEdgeColors2D: + """Every 2-D marker type must paint each marker its own edge colour.""" + + @pytest.mark.parametrize("kind,extra", [ + ("circles", {"radius": 4}), + ("ellipses", {"widths": 8, "heights": 5}), + ("rectangles", {"widths": 8, "heights": 5}), + ("squares", {"widths": 8}), + ("arrows", {"U": 6, "V": 6}), + ]) + def test_all_three_colors_rendered(self, take_screenshot, kind, extra): + fig = _image_fig( + lambda p, **k: p.markers.add(kind, offsets=OFFSETS, + edgecolors=COLORS, linewidths=3, **k), + **extra, + ) + present = _colors_present(take_screenshot(fig)) + assert present == set(COLORS), ( + f"{kind}: expected all of {COLORS} on the canvas, got {sorted(present)}" + ) + + def test_lines_segments_take_their_own_colors(self, take_screenshot): + segs = [[[4, 4], [36, 4]], [[4, 20], [36, 20]], [[4, 36], [36, 36]]] + fig = _image_fig( + lambda p: p.markers.add("lines", segments=segs, + edgecolors=COLORS, linewidths=3) + ) + assert _colors_present(take_screenshot(fig)) == set(COLORS) + + def test_polygons_take_their_own_colors(self, take_screenshot): + polys = [ + [[2, 2], [12, 2], [12, 12]], + [[14, 14], [26, 14], [26, 26]], + [[28, 28], [38, 28], [38, 38]], + ] + fig = _image_fig( + lambda p: p.markers.add("polygons", vertices_list=polys, + edgecolors=COLORS, linewidths=3) + ) + assert _colors_present(take_screenshot(fig)) == set(COLORS) + + def test_texts_take_their_own_colors(self, take_screenshot): + fig = _image_fig( + lambda p: p.markers.add("texts", offsets=OFFSETS, + texts=["AAA", "BBB", "CCC"], + edgecolors=COLORS, fontsize=20) + ) + assert _colors_present(take_screenshot(fig)) == set(COLORS) + + def test_per_marker_facecolors(self, take_screenshot): + """Fills are per-marker too, not just edges.""" + fig = _image_fig( + lambda p: p.markers.add("circles", offsets=OFFSETS, radius=5, + edgecolors="#ffffff", facecolors=COLORS, + alpha=1.0) + ) + assert _colors_present(take_screenshot(fig)) == set(COLORS) + + def test_shorter_sequence_cycles(self, take_screenshot): + """A 2-colour sequence over 3 markers cycles, as matplotlib does.""" + fig = _image_fig( + lambda p: p.markers.add("circles", offsets=OFFSETS, radius=4, + edgecolors=COLORS[:2], linewidths=3) + ) + img = take_screenshot(fig) + # Two markers red (indices 0 and 2), one green. + assert _count(img, RGB["#ff0000"]) > _count(img, RGB["#00ff00"]) + assert _count(img, RGB["#0000ff"]) == 0 + + +class TestScalarColorUnchanged2D: + """A single colour must keep painting the whole group — no regression.""" + + def test_single_edgecolor_applies_to_all(self, take_screenshot): + fig = _image_fig( + lambda p: p.markers.add("circles", offsets=OFFSETS, radius=4, + edgecolors="#ff0000", linewidths=3) + ) + present = _colors_present(take_screenshot(fig)) + assert present == {"#ff0000"} + + +# ══════════════════════════════════════════════════════════════════════════════ +# 1-D panels +# ══════════════════════════════════════════════════════════════════════════════ + +class TestPerMarkerColors1D: + def test_vlines_take_their_own_colors(self, take_screenshot): + fig = _line_fig( + lambda p: p.markers.add("vlines", offsets=[[8], [20], [32]], + edgecolors=COLORS, linewidths=3) + ) + assert _colors_present(take_screenshot(fig)) == set(COLORS) + + def test_hlines_take_their_own_colors(self, take_screenshot): + fig, ax = apl.subplots(1, 1, figsize=(400, 400)) + plot = ax.plot(np.linspace(-1.0, 1.0, 40)) + plot.markers.add("hlines", offsets=[[-0.5], [0.0], [0.5]], + edgecolors=COLORS, linewidths=3) + assert _colors_present(take_screenshot(fig)) == set(COLORS) + + def test_points_take_their_own_colors(self, take_screenshot): + """Already supported before this change — kept as a regression guard.""" + fig = _line_fig( + lambda p: p.markers.add("points", offsets=[[8], [20], [32]], + sizes=6, edgecolors=COLORS, linewidths=3) + ) + assert _colors_present(take_screenshot(fig)) == set(COLORS) + + +# ══════════════════════════════════════════════════════════════════════════════ +# Wire format +# ══════════════════════════════════════════════════════════════════════════════ + +class TestWireFormat: + def test_color_sequence_survives_to_wire(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((10, 10))) + g = plot.markers.add("circles", offsets=OFFSETS, edgecolors=COLORS) + assert g.to_wire("gid")["color"] == COLORS + + def test_facecolor_sequence_survives_to_wire(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((10, 10))) + g = plot.markers.add("circles", offsets=OFFSETS, facecolors=COLORS) + assert g.to_wire("gid")["fill_color"] == COLORS diff --git a/upcoming_changes/42.new_feature_2.rst b/upcoming_changes/42.new_feature_2.rst new file mode 100644 index 00000000..b487aa50 --- /dev/null +++ b/upcoming_changes/42.new_feature_2.rst @@ -0,0 +1,11 @@ +``edgecolors`` and ``facecolors`` accept a sequence of colours parallel to the +markers — matplotlib's ``edgecolors=[...]`` / scatter ``c=[...]`` — for **every** +marker type on both 1-D and 2-D panels:: + + plot.add_circles(offsets, edgecolors=["#f00", "#0f0", "#00f"], radius=3) + +Only ``points`` and ``polygons`` on 1-D panels honoured this before; other +types painted the whole group one colour. A sequence shorter than the group +cycles, as matplotlib's colour cycle does. This is what a value-to-colour +mapping (a colormapped scatter, or HyperSpy's +``Markers.set_ScalarMappable_array``) needs to render. From 0859a59df8eb2cb657c90bc5da028e462ffe28f6 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 26 Jul 2026 16:04:39 -0500 Subject: [PATCH 04/12] feat(widgets): set(_notify=False) and Widget.remove() 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) --- anyplotlib/_base_plot.py | 5 + .../test_widget_set_notify.py | 110 ++++++++++++++++++ anyplotlib/widgets/_base.py | 46 +++++++- upcoming_changes/42.improvement_2.rst | 6 + upcoming_changes/42.new_feature_3.rst | 4 + 5 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 anyplotlib/tests/test_interactive/test_widget_set_notify.py create mode 100644 upcoming_changes/42.improvement_2.rst create mode 100644 upcoming_changes/42.new_feature_3.rst diff --git a/anyplotlib/_base_plot.py b/anyplotlib/_base_plot.py index 0d877645..214470f9 100644 --- a/anyplotlib/_base_plot.py +++ b/anyplotlib/_base_plot.py @@ -95,8 +95,13 @@ def _make_widget_push_fn(self, widget): Replaces the repeated _tp / _targeted_push closures in every add_*_widget method. + + Also back-links the widget to this plot so :meth:`Widget.remove` can + find its owner. Every ``add_*_widget`` routes through here, so this is + the one place that has to know. """ plot_ref, wid_id = self, widget._id + widget._plot = self def _push(): if plot_ref._fig is not None: fields = {k: v for k, v in widget._data.items() diff --git a/anyplotlib/tests/test_interactive/test_widget_set_notify.py b/anyplotlib/tests/test_interactive/test_widget_set_notify.py new file mode 100644 index 00000000..e1434647 --- /dev/null +++ b/anyplotlib/tests/test_interactive/test_widget_set_notify.py @@ -0,0 +1,110 @@ +""" +Widget.set(_notify=False) and Widget.remove(). + +``set()`` fires ``pointer_move`` so that a JS-driven drag reaches Python +handlers. A ``set()`` made *from* Python is indistinguishable from that, so a +handler that writes back to the widget feeds into itself. ``_notify=False`` +suppresses just this update's echo, without the blast radius of wrapping the +call in ``pause_events()``. +""" +from __future__ import annotations + +import numpy as np + +import anyplotlib as apl + + +def _plot_with_widget(): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((16, 16), dtype=np.float32)) + widget = plot.add_widget("rectangle", x=2, y=2, w=4, h=4) + return fig, plot, widget + + +class TestSetNotify: + def test_set_fires_by_default(self): + _, _, widget = _plot_with_widget() + seen = [] + widget.add_event_handler(lambda e: seen.append(e), "pointer_move") + widget.set(x=5) + assert len(seen) == 1 + + def test_notify_false_suppresses_callbacks(self): + _, _, widget = _plot_with_widget() + seen = [] + widget.add_event_handler(lambda e: seen.append(e), "pointer_move") + widget.set(_notify=False, x=5) + assert seen == [] + + def test_notify_false_still_updates_state(self): + _, _, widget = _plot_with_widget() + widget.set(_notify=False, x=7) + assert widget.x == 7 + + def test_notify_false_still_pushes(self): + """Suppressing the callback must not suppress the render update.""" + _, _, widget = _plot_with_widget() + pushed = [] + widget._push_fn = lambda: pushed.append(True) + widget.set(_notify=False, x=9) + assert pushed == [True] + + def test_attribute_assignment_still_notifies(self): + """widget.x = 5 keeps its existing behaviour.""" + _, _, widget = _plot_with_widget() + seen = [] + widget.add_event_handler(lambda e: seen.append(e), "pointer_move") + widget.x = 5 + assert len(seen) == 1 + + def test_notify_is_not_swallowed_as_a_property(self): + """The underscore keeps the flag from shadowing widget state.""" + _, _, widget = _plot_with_widget() + widget.set(_notify=False, x=3) + assert widget.get("_notify") is None + assert widget.get("notify") is None + + def test_write_back_handler_does_not_recurse(self): + """The motivating case: a handler that moves the widget it listens to.""" + _, _, widget = _plot_with_widget() + calls = [] + + def clamp(event): + calls.append(event) + # Without _notify=False this re-enters clamp for ever. + widget.set(_notify=False, x=min(widget.x, 10)) + + widget.add_event_handler(clamp, "pointer_move") + widget.set(x=50) + assert len(calls) == 1 + assert widget.x == 10 + + +class TestWidgetRemove: + def test_remove_detaches_from_plot(self): + _, plot, widget = _plot_with_widget() + assert widget in plot.list_widgets() + widget.remove() + assert widget not in plot.list_widgets() + + def test_remove_is_idempotent(self): + _, _, widget = _plot_with_widget() + widget.remove() + widget.remove() # must not raise + + def test_remove_after_clear_widgets(self): + _, plot, widget = _plot_with_widget() + plot.clear_widgets() + widget.remove() # must not raise + + def test_remove_on_unattached_widget(self): + from anyplotlib.widgets import RectangleWidget + + widget = RectangleWidget(lambda: None, x=0, y=0, w=1, h=1) + widget.remove() # no owning plot — no-op, not an error + + def test_matches_plot_remove_widget(self): + _, plot, widget = _plot_with_widget() + other = plot.add_widget("circle", cx=8, cy=8, r=2) + widget.remove() + assert plot.list_widgets() == [other] diff --git a/anyplotlib/widgets/_base.py b/anyplotlib/widgets/_base.py index e73f2301..32c0451e 100644 --- a/anyplotlib/widgets/_base.py +++ b/anyplotlib/widgets/_base.py @@ -39,6 +39,10 @@ class Widget(_EventMixin): - ``"pointer_down"`` — fires on click/press event """ + # Set by _PanelMixin._make_widget_push_fn when the widget is added to a + # plot; stays None for a widget constructed standalone (e.g. in a test). + _plot = None + def __init__(self, wtype: str, push_fn: Callable, **kwargs): self._id: str = str(_uuid.uuid4())[:8] self._type: str = wtype @@ -80,7 +84,7 @@ def __setattr__(self, key: str, value) -> None: # ── set / get ───────────────────────────────────────────────────── - def set(self, _push: bool = True, **kwargs) -> None: + def set(self, _push: bool = True, _notify: bool = True, **kwargs) -> None: """Update properties and send targeted update to JavaScript. Parameters @@ -88,18 +92,36 @@ def set(self, _push: bool = True, **kwargs) -> None: _push : bool, optional Whether to push update to renderer. Default True. Set to False internally to avoid echo loops. + _notify : bool, optional + Whether to fire ``pointer_move`` callbacks. Default True. + + A ``set()`` from Python is otherwise indistinguishable from a user + drag: handlers that react to the widget moving will run, and one + that writes back to the widget feeds into itself. Pass + ``_notify=False`` when *you* are the one moving the widget and the + handlers are only meant to hear about user input:: + + widget.set(_notify=False, x=new_x) + + This supersedes wrapping the call in :meth:`pause_events`, which + suppresses every event type for the duration rather than just this + update's echo. **kwargs : dict Properties to update (e.g., x=100, y=50, radius=20). Notes ----- + Both flags are spelled with a leading underscore so they can never + collide with a widget property of the same name in ``**kwargs``. + Updates are sent as targeted widget updates, not full panel re-renders. This is more efficient for frequent updates during dragging. """ self._data.update(kwargs) if _push: self._push_fn() - self.callbacks.fire(Event("pointer_move", source=self)) + if _notify: + self.callbacks.fire(Event("pointer_move", source=self)) def get(self, key: str, default=None): """Get a widget property by name. @@ -153,6 +175,26 @@ def hide(self) -> None: self._data["visible"] = False self._push_fn() + # ── removal ─────────────────────────────────────────────────────────── + + def remove(self) -> None: + """Remove this widget from the plot that owns it. + + Equivalent to ``plot.remove_widget(widget)``, but callable when you + only hold the widget — handle-based APIs otherwise have to re-derive + the owning plot to delete something they already have. + + Removing a widget that is not attached to a plot, or removing twice, + is a no-op. + """ + plot = self._plot + if plot is None: + return + try: + plot.remove_widget(self._id) + except KeyError: + pass # already removed (e.g. via clear_widgets) + # ── JS → Python sync ────────────────────────────────────────────── def _update_from_js(self, msg: dict, event_type: str = "pointer_move") -> bool: diff --git a/upcoming_changes/42.improvement_2.rst b/upcoming_changes/42.improvement_2.rst new file mode 100644 index 00000000..dd3146b3 --- /dev/null +++ b/upcoming_changes/42.improvement_2.rst @@ -0,0 +1,6 @@ +:meth:`Widget.set` takes ``_notify=False`` to update a widget without firing +``pointer_move`` callbacks. A ``set()`` from Python was previously +indistinguishable from a user drag, so a handler that writes back to the +widget it listens to fed into itself; the only defence was wrapping the call +in ``pause_events()``, which suppresses every event for the duration rather +than just this update's echo. diff --git a/upcoming_changes/42.new_feature_3.rst b/upcoming_changes/42.new_feature_3.rst new file mode 100644 index 00000000..48dd200d --- /dev/null +++ b/upcoming_changes/42.new_feature_3.rst @@ -0,0 +1,4 @@ +Widgets have a :meth:`~anyplotlib.widgets.Widget.remove` method. +``plot.remove_widget(widget)`` still works; ``widget.remove()`` is for callers +that hold only the handle and would otherwise have to re-derive the owning +plot. Removing an unattached or already-removed widget is a no-op. From ce7c2244f628d16971c74f5d373092bc20728c8f Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 26 Jul 2026 16:12:14 -0500 Subject: [PATCH 05/12] feat(widgets): line, vline and hline kinds on Plot2D MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- anyplotlib/figure_esm.js | 70 +++- anyplotlib/plot2d/_plot2d.py | 114 +++++- .../test_interactive/test_widgets_line_2d.py | 330 ++++++++++++++++++ anyplotlib/widgets/__init__.py | 3 +- anyplotlib/widgets/_widgets2d.py | 40 +++ upcoming_changes/42.improvement_3.rst | 3 + upcoming_changes/42.new_feature_4.rst | 13 + 7 files changed, 569 insertions(+), 4 deletions(-) create mode 100644 anyplotlib/tests/test_interactive/test_widgets_line_2d.py create mode 100644 upcoming_changes/42.improvement_3.rst create mode 100644 upcoming_changes/42.new_feature_4.rst diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index e1d25d42..095b540d 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -3203,6 +3203,28 @@ function render({ model, el, onResize }) { ovCtx.beginPath();ovCtx.moveTo(0,ccy);ovCtx.lineTo(imgW,ccy);ovCtx.stroke(); ovCtx.beginPath();ovCtx.moveTo(ccx,0);ovCtx.lineTo(ccx,imgH);ovCtx.stroke(); if(_handles){ovCtx.beginPath();ovCtx.arc(ccx,ccy,4,0,Math.PI*2);ovCtx.fillStyle=w.color||'#00e5ff';ovCtx.fill();} + } else if(w.type==='vline'){ + // Full-height rule at image x. No handle dot: the whole line is the + // grab target, so a dot would only add clutter. + const [vx]=_imgToCanvas2d(w.x,0,st,imgW,imgH); + ovCtx.beginPath();ovCtx.moveTo(vx,0);ovCtx.lineTo(vx,imgH);ovCtx.stroke(); + } else if(w.type==='hline'){ + const [,hy]=_imgToCanvas2d(0,w.y,st,imgW,imgH); + ovCtx.beginPath();ovCtx.moveTo(0,hy);ovCtx.lineTo(imgW,hy);ovCtx.stroke(); + } else if(w.type==='line'){ + // Bare segment: no arrowhead (that's 'arrow'), no closed path + // (that's 'polygon'). Handles mark the two draggable endpoints. + const [ax,ay]=_imgToCanvas2d(w.x1,w.y1,st,imgW,imgH); + const [bx,by]=_imgToCanvas2d(w.x2,w.y2,st,imgW,imgH); + ovCtx.beginPath();ovCtx.moveTo(ax,ay);ovCtx.lineTo(bx,by);ovCtx.stroke(); + // Canvas-space readback for Playwright, same role as the rectangle + // branch above: a test cannot derive an endpoint's page position from + // figure padding, because the image->canvas mapping depends on the + // zoom/extent state. + if(!window._aplWidgetGeom) window._aplWidgetGeom={}; + if(!window._aplWidgetGeom[p.id]) window._aplWidgetGeom[p.id]={}; + window._aplWidgetGeom[p.id][w.id]={type:'line',ax,ay,bx,by}; + if(_handles){_drawHandle2d(ovCtx,ax,ay,w.color);_drawHandle2d(ovCtx,bx,by,w.color);} } else if(w.type==='polygon'){ const verts=w.vertices||[]; if(verts.length>=2){ @@ -7191,8 +7213,37 @@ fn fs(in : VsOut) -> @location(0) vec4 { } else if (w.type === 'crosshair') { const [ccx, ccy] = _imgToCanvas2d(w.cx, w.cy, st, imgW, imgH); + // Centre hotspot moves both axes at once. if (Math.hypot(mx-ccx, my-ccy) <= HR + 4) return { idx:i, mode:'move', snapW:{...w}, startMX:mx, startMY:my }; + // Anywhere along either rule is also a grab target: for a line-style + // pointer the lines ARE the widget, and requiring the user to find a + // one-pixel intersection made it feel broken. Grabbing a rule + // constrains the drag to that rule's own axis. + if (Math.abs(mx-ccx) <= HR) + return { idx:i, mode:'move_x', snapW:{...w}, startMX:mx, startMY:my }; + if (Math.abs(my-ccy) <= HR) + return { idx:i, mode:'move_y', snapW:{...w}, startMX:mx, startMY:my }; + + } else if (w.type === 'vline') { + const [vx] = _imgToCanvas2d(w.x, 0, st, imgW, imgH); + if (Math.abs(mx - vx) <= HR) + return { idx:i, mode:'move', snapW:{...w}, startMX:mx, startMY:my }; + + } else if (w.type === 'hline') { + const [, hy] = _imgToCanvas2d(0, w.y, st, imgW, imgH); + if (Math.abs(my - hy) <= HR) + return { idx:i, mode:'move', snapW:{...w}, startMX:mx, startMY:my }; + + } else if (w.type === 'line') { + const [ax, ay] = _imgToCanvas2d(w.x1, w.y1, st, imgW, imgH); + const [bx, by] = _imgToCanvas2d(w.x2, w.y2, st, imgW, imgH); + if (Math.hypot(mx-ax, my-ay) <= HR) + return { idx:i, mode:'move_p1', snapW:{...w}, startMX:mx, startMY:my }; + if (Math.hypot(mx-bx, my-by) <= HR) + return { idx:i, mode:'move_p2', snapW:{...w}, startMX:mx, startMY:my }; + if (_distToSegment2d(mx, my, ax, ay, bx, by) <= HR) + return { idx:i, mode:'move', snapW:{...w}, startMX:mx, startMY:my }; } else if (w.type === 'polygon') { const verts = w.vertices || []; @@ -7309,7 +7360,24 @@ fn fs(in : VsOut) -> @location(0) vec4 { w.y = s.y + (s.h - newH); w.h = newH; } } else if (w.type === 'crosshair') { - w.cx = s.cx + dix; w.cy = s.cy + diy; + // Grabbing a single rule constrains the drag to that rule's own axis; + // grabbing the centre moves both (see _ovHitTest2d). + if (d.mode === 'move_x') { w.cx = s.cx + dix; } + else if (d.mode === 'move_y') { w.cy = s.cy + diy; } + else { w.cx = s.cx + dix; w.cy = s.cy + diy; } + } else if (w.type === 'vline') { + w.x = s.x + dix; + } else if (w.type === 'hline') { + w.y = s.y + diy; + } else if (w.type === 'line') { + if (d.mode === 'move') { + w.x1 = s.x1 + dix; w.y1 = s.y1 + diy; + w.x2 = s.x2 + dix; w.y2 = s.y2 + diy; + } else if (d.mode === 'move_p1') { + w.x1 = imgMX; w.y1 = imgMY; + } else if (d.mode === 'move_p2') { + w.x2 = imgMX; w.y2 = imgMY; + } } else if (w.type === 'polygon') { if (d.mode === 'move') { w.vertices = s.vertices.map(v => [v[0]+dix, v[1]+diy]); diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py index d9c092df..2b7f8aed 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -26,7 +26,8 @@ from anyplotlib.widgets import ( Widget, RectangleWidget, CircleWidget, AnnularWidget, - CrosshairWidget, PolygonWidget, LabelWidget, ArrowWidget, + CrosshairWidget, PolygonWidget, LabelWidget, ArrowWidget, LineWidget, + VLineWidget, HLineWidget, ) from anyplotlib._utils import (_normalize_image, _build_colormap_lut, _build_tint_lut, _to_rgba_u8) @@ -1571,10 +1572,13 @@ def add_widget(self, kind: str, color: str = "#00e5ff", **kwargs) -> Widget: Dispatches to the dedicated ``add__widget`` method. Supported kinds: ``"circle"``, ``"rectangle"``, ``"annular"``, - ``"polygon"``, ``"crosshair"``, ``"label"``, ``"arrow"``. + ``"polygon"``, ``"crosshair"``, ``"label"``, ``"arrow"``, ``"line"``, + ``"vline"``, ``"hline"``. Every kind also accepts ``show_handles`` (default ``True``) to toggle the grab-handle dots without changing hit-testing / draggability. + ``vline`` / ``hline`` have no handles — the whole line is the grab + target — so they ignore it. """ dispatch = { "circle": self.add_circle_widget, @@ -1584,6 +1588,9 @@ def add_widget(self, kind: str, color: str = "#00e5ff", **kwargs) -> Widget: "crosshair": self.add_crosshair_widget, "label": self.add_label_widget, "arrow": self.add_arrow_widget, + "line": self.add_line_widget, + "vline": self.add_vline_widget, + "hline": self.add_hline_widget, } key = kind.lower() if key not in dispatch: @@ -1732,6 +1739,109 @@ def add_arrow_widget(self, x: float | None = None, y: float | None = None, self._push() return widget + def add_line_widget(self, x1: float | None = None, y1: float | None = None, + x2: float | None = None, y2: float | None = None, + color: str = "#00e5ff", linewidth: float = 2, + show_handles: bool = True) -> LineWidget: + """Add a draggable two-endpoint line segment overlay. + + A bare segment with a grab handle at each end — no arrowhead (see + :meth:`add_arrow_widget`) and no closed path (see + :meth:`add_polygon_widget`). Use it for a line profile, a + cross-section cut, or a two-point measurement. + + Parameters + ---------- + x1, y1 : float, optional + First endpoint in image coordinates. Defaults to 25 % of the + image size. + x2, y2 : float, optional + Second endpoint. Defaults to 75 % of the image size. + color : str, optional + CSS colour string. Default ``"#00e5ff"``. + linewidth : float, optional + Stroke width in px. Default 2. + show_handles : bool, optional + Draw the endpoint grab handles. Default ``True``. + + Returns + ------- + LineWidget + Widget object. ``widget.length`` gives the segment length in + data coordinates. + """ + iw, ih = self._state["image_width"], self._state["image_height"] + widget = LineWidget(lambda: None, + x1=float(x1) if x1 is not None else iw * 0.25, + y1=float(y1) if y1 is not None else ih * 0.25, + x2=float(x2) if x2 is not None else iw * 0.75, + y2=float(y2) if y2 is not None else ih * 0.75, + color=color, linewidth=linewidth, + show_handles=show_handles) + widget._push_fn = self._make_widget_push_fn(widget) + self._widgets[widget.id] = widget + self._push() + return widget + + def add_vline_widget(self, x: float | None = None, color: str = "#00e5ff", + linewidth: float = 2) -> VLineWidget: + """Add a draggable full-height vertical line overlay. + + The line spans the whole panel and is grabbable anywhere along its + length, which makes it the right pointer for "select a column" — + a crosshair pinned to one axis leaves a stray perpendicular line. + + Parameters + ---------- + x : float, optional + Initial x position in image coordinates. Defaults to the middle. + color : str, optional + CSS colour string. Default ``"#00e5ff"``. + linewidth : float, optional + Stroke width in px. Default 2. + + Returns + ------- + VLineWidget + """ + iw = self._state["image_width"] + widget = VLineWidget(lambda: None, + x=float(x) if x is not None else iw / 2, + color=color, linewidth=linewidth) + widget._push_fn = self._make_widget_push_fn(widget) + self._widgets[widget.id] = widget + self._push() + return widget + + def add_hline_widget(self, y: float | None = None, color: str = "#00e5ff", + linewidth: float = 2) -> HLineWidget: + """Add a draggable full-width horizontal line overlay. + + The row counterpart of :meth:`add_vline_widget`; grabbable anywhere + along its length. + + Parameters + ---------- + y : float, optional + Initial y position in image coordinates. Defaults to the middle. + color : str, optional + CSS colour string. Default ``"#00e5ff"``. + linewidth : float, optional + Stroke width in px. Default 2. + + Returns + ------- + HLineWidget + """ + ih = self._state["image_height"] + widget = HLineWidget(lambda: None, + y=float(y) if y is not None else ih / 2, + color=color, linewidth=linewidth) + widget._push_fn = self._make_widget_push_fn(widget) + self._widgets[widget.id] = widget + self._push() + return widget + # ------------------------------------------------------------------ # View control # ------------------------------------------------------------------ diff --git a/anyplotlib/tests/test_interactive/test_widgets_line_2d.py b/anyplotlib/tests/test_interactive/test_widgets_line_2d.py new file mode 100644 index 00000000..3d7a093c --- /dev/null +++ b/anyplotlib/tests/test_interactive/test_widgets_line_2d.py @@ -0,0 +1,330 @@ +""" +tests/test_interactive/test_widgets_line_2d.py +=============================================== + +Three 2-D overlay widget kinds: + +``line`` + A bare two-endpoint segment. ``arrow`` draws a head and ``polygon`` + needs >= 3 vertices and closes the path, so neither could stand in for a + line profile / cross-section cut / two-point measurement. + +``vline`` / ``hline`` + Full-height / full-width rules, grabbable anywhere along their length. + These existed on 1-D panels only; a 2-D panel had to fake a single-axis + pointer with a ``crosshair``, which leaves a stray perpendicular rule. + +Also covered: a ``crosshair`` can now be grabbed by either of its rules, not +only at the one-pixel centre hotspot. Grabbing a rule constrains the drag to +that rule's own axis. + +Drag tests aim at the real on-canvas geometry published by the draw path +(``window._aplWidgetGeom``) rather than deriving it from figure padding — the +image->canvas mapping depends on the zoom/extent state and the grab radius is +only HR=9 px, so a hard-coded guess silently misses and asserts nothing. +""" +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.widgets import LineWidget +from anyplotlib.tests.test_interactive._event_test_utils import _collect_events + +FIG_W, FIG_H = 400, 400 +IMG = 64 + + +# ── helpers ─────────────────────────────────────────────────────────────────── + +def _collect_panel_state(page) -> None: + page.evaluate("""() => { + window._aplPanelState = {}; + const m = window._aplModel; + const orig = m.set.bind(m); + m.set = (k, v) => { + const mm = /^panel_(.+)_json$/.exec(k); + if (mm) { try { window._aplPanelState[mm[1]] = JSON.parse(v); } catch(_) {} } + return orig(k, v); + }; + }""") + + +def _seed_panel_state(page, plot_id) -> None: + page.evaluate( + """(pid) => { + try { + const v = window._aplModel.get('panel_' + pid + '_json'); + if (v) window._aplPanelState[pid] = JSON.parse(v); + } catch(_) {} + }""", str(plot_id)) + + +def _widget_state(page, plot_id, idx=0): + return page.evaluate( + """([pid, i]) => { + const st = window._aplPanelState && window._aplPanelState[pid]; + const ws = st && st.overlay_widgets; + return ws && ws[i] ? ws[i] : null; + }""", [str(plot_id), idx]) + + +def _canvas_origin(page): + return page.evaluate("""() => { + const c = document.querySelector('canvas'); + const r = c.getBoundingClientRect(); + return {left: r.left, top: r.top}; + }""") + + +def _geom(page, plot_id, widget_id): + return page.evaluate( + """([pid, wid]) => { + const g = window._aplWidgetGeom && window._aplWidgetGeom[pid]; + return g ? (g[wid] || null) : null; + }""", [str(plot_id), str(widget_id)]) + + +def _open(interact_page, add): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((IMG, IMG), dtype=np.float32)) + widget = add(plot) + page = interact_page(fig) + _collect_events(page) + _collect_panel_state(page) + _seed_panel_state(page, plot._id) + return fig, plot, page, widget + + +def _drag(page, x0, y0, dx, dy): + page.mouse.move(x0, y0) + page.mouse.down() + page.mouse.move(x0 + dx, y0 + dy, steps=10) + page.mouse.up() + page.wait_for_timeout(80) + + +# ═══════════════════════════════════════════════════════════════════════════ +# 1. Python API +# ═══════════════════════════════════════════════════════════════════════════ + +class TestLineWidgetApi: + def test_stores_endpoints(self): + w = LineWidget(lambda: None, x1=1, y1=2, x2=3, y2=4) + assert (w.x1, w.y1, w.x2, w.y2) == (1.0, 2.0, 3.0, 4.0) + + def test_type_is_line(self): + assert LineWidget(lambda: None, x1=0, y1=0, x2=1, y2=1).get("type") == "line" + + def test_length(self): + w = LineWidget(lambda: None, x1=0, y1=0, x2=3, y2=4) + assert w.length == pytest.approx(5.0) + + def test_reaches_the_state_dict(self): + w = LineWidget(lambda: None, x1=0, y1=0, x2=3, y2=4) + assert w.to_dict()["x2"] == 3.0 + + def test_add_line_widget_defaults(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((IMG, IMG))) + w = plot.add_line_widget() + assert w.x1 == IMG * 0.25 and w.x2 == IMG * 0.75 + + @pytest.mark.parametrize("kind", ["line", "vline", "hline"]) + def test_add_widget_dispatch(self, kind): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((IMG, IMG))) + assert plot.add_widget(kind).get("type") == kind + + def test_vline_defaults_to_middle(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((IMG, IMG))) + assert plot.add_vline_widget().x == IMG / 2 + + def test_hline_defaults_to_middle(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((IMG, IMG))) + assert plot.add_hline_widget().y == IMG / 2 + + def test_widgets_reach_the_wire(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((IMG, IMG))) + plot.add_widget("line") + plot.add_widget("vline") + plot.add_widget("hline") + types = [w["type"] for w in plot.to_state_dict()["overlay_widgets"]] + assert types == ["line", "vline", "hline"] + + +# ═══════════════════════════════════════════════════════════════════════════ +# 2. Rendering +# ═══════════════════════════════════════════════════════════════════════════ + +def _ink(img, rgb=(255, 0, 0), tol=60): + a = img[..., :3].astype(int) + return int((np.abs(a - np.array(rgb)).sum(axis=-1) < tol).sum()) + + +class TestRendering: + def test_line_is_drawn(self, take_screenshot): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((IMG, IMG), dtype=np.float32)) + plot.add_line_widget(x1=8, y1=8, x2=56, y2=56, color="#ff0000", + show_handles=False) + assert _ink(take_screenshot(fig)) > 100 + + def test_vline_spans_more_rows_than_a_short_segment(self, take_screenshot): + """A full-height rule must be taller than a stub segment.""" + def render(add): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((IMG, IMG), dtype=np.float32)) + add(plot) + img = take_screenshot(fig)[..., :3].astype(int) + mask = np.abs(img - np.array([255, 0, 0])).sum(axis=-1) < 60 + return mask.any(axis=1).sum() # number of rows containing ink + + rule = render(lambda p: p.add_vline_widget(x=32, color="#ff0000")) + stub = render(lambda p: p.add_line_widget(x1=32, y1=30, x2=32, y2=34, + color="#ff0000", + show_handles=False)) + assert rule > stub * 3 + + def test_hline_spans_more_columns_than_a_short_segment(self, take_screenshot): + def render(add): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((IMG, IMG), dtype=np.float32)) + add(plot) + img = take_screenshot(fig)[..., :3].astype(int) + mask = np.abs(img - np.array([255, 0, 0])).sum(axis=-1) < 60 + return mask.any(axis=0).sum() # columns containing ink + + rule = render(lambda p: p.add_hline_widget(y=32, color="#ff0000")) + stub = render(lambda p: p.add_line_widget(x1=30, y1=32, x2=34, y2=32, + color="#ff0000", + show_handles=False)) + assert rule > stub * 3 + + def test_line_has_no_arrowhead(self, take_screenshot): + """The whole point of a separate kind: less ink than the arrow.""" + def render(add): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((IMG, IMG), dtype=np.float32)) + add(plot) + return _ink(take_screenshot(fig)) + + seg = render(lambda p: p.add_line_widget(x1=8, y1=8, x2=56, y2=56, + color="#ff0000", + show_handles=False)) + arw = render(lambda p: p.add_arrow_widget(x=8, y=8, u=48, v=48, + color="#ff0000", + show_handles=False)) + assert seg < arw, "the segment must not draw an arrowhead" + + +# ═══════════════════════════════════════════════════════════════════════════ +# 3. Dragging (real browser input) +# ═══════════════════════════════════════════════════════════════════════════ + +class TestLineDrag: + def test_endpoint_handle_moves_only_that_end(self, interact_page): + fig, plot, page, w = _open( + interact_page, + lambda p: p.add_line_widget(x1=16, y1=16, x2=48, y2=48)) + g = _geom(page, plot._id, w.id) + assert g is not None, "line geometry readback missing" + o = _canvas_origin(page) + before = _widget_state(page, plot._id) + + _drag(page, o["left"] + g["ax"], o["top"] + g["ay"], 40, 0) + + after = _widget_state(page, plot._id) + assert after["x1"] > before["x1"] + 0.5, "p1 did not move" + assert after["x2"] == pytest.approx(before["x2"], abs=1e-6), "p2 moved" + assert after["y2"] == pytest.approx(before["y2"], abs=1e-6), "p2 moved" + + def test_shaft_drag_translates_both_ends(self, interact_page): + fig, plot, page, w = _open( + interact_page, + lambda p: p.add_line_widget(x1=16, y1=16, x2=48, y2=48)) + g = _geom(page, plot._id, w.id) + o = _canvas_origin(page) + before = _widget_state(page, plot._id) + + mid_x = o["left"] + (g["ax"] + g["bx"]) / 2 + mid_y = o["top"] + (g["ay"] + g["by"]) / 2 + _drag(page, mid_x, mid_y, 30, 0) + + after = _widget_state(page, plot._id) + d1 = after["x1"] - before["x1"] + d2 = after["x2"] - before["x2"] + assert d1 > 0.5, "the segment did not translate" + assert d1 == pytest.approx(d2, abs=1e-6), "ends moved by different amounts" + + +class TestRuleDrag: + def test_vline_grabbable_away_from_centre(self, interact_page): + """The whole rule is the grab target, not just a hotspot.""" + fig, plot, page, w = _open( + interact_page, lambda p: p.add_vline_widget(x=32)) + o = _canvas_origin(page) + before = _widget_state(page, plot._id) + # Aim near the top of the panel, far from the vertical middle. + cx = o["left"] + _canvas_size(page)["w"] * 0.5 + _drag(page, cx, o["top"] + 20, 40, 0) + after = _widget_state(page, plot._id) + assert after["x"] > before["x"] + 0.5 + + def test_vline_ignores_vertical_drag(self, interact_page): + fig, plot, page, w = _open( + interact_page, lambda p: p.add_vline_widget(x=32)) + o = _canvas_origin(page) + before = _widget_state(page, plot._id) + cx = o["left"] + _canvas_size(page)["w"] * 0.5 + _drag(page, cx, o["top"] + 20, 0, 40) + after = _widget_state(page, plot._id) + assert after["x"] == pytest.approx(before["x"], abs=1e-6) + + def test_hline_grabbable_away_from_centre(self, interact_page): + fig, plot, page, w = _open( + interact_page, lambda p: p.add_hline_widget(y=32)) + o = _canvas_origin(page) + before = _widget_state(page, plot._id) + cy = o["top"] + _canvas_size(page)["h"] * 0.5 + _drag(page, o["left"] + 20, cy, 0, 40) + after = _widget_state(page, plot._id) + assert after["y"] > before["y"] + 0.5 + + +class TestCrosshairRuleGrab: + def test_vertical_rule_grab_moves_x_only(self, interact_page): + fig, plot, page, w = _open( + interact_page, lambda p: p.add_crosshair_widget(cx=32, cy=32)) + o = _canvas_origin(page) + before = _widget_state(page, plot._id) + # On the vertical rule but well above the centre. + cx = o["left"] + _canvas_size(page)["w"] * 0.5 + _drag(page, cx, o["top"] + 20, 40, 25) + after = _widget_state(page, plot._id) + assert after["cx"] > before["cx"] + 0.5, "grabbing the rule did nothing" + assert after["cy"] == pytest.approx(before["cy"], abs=1e-6), \ + "a vertical-rule drag must not move cy" + + def test_centre_still_moves_both(self, interact_page): + fig, plot, page, w = _open( + interact_page, lambda p: p.add_crosshair_widget(cx=32, cy=32)) + o = _canvas_origin(page) + size = _canvas_size(page) + before = _widget_state(page, plot._id) + _drag(page, o["left"] + size["w"] * 0.5, o["top"] + size["h"] * 0.5, 30, 30) + after = _widget_state(page, plot._id) + assert after["cx"] > before["cx"] + 0.5 + assert after["cy"] > before["cy"] + 0.5 + + +def _canvas_size(page): + return page.evaluate("""() => { + const c = document.querySelector('canvas'); + const r = c.getBoundingClientRect(); + return {w: r.width, h: r.height}; + }""") diff --git a/anyplotlib/widgets/__init__.py b/anyplotlib/widgets/__init__.py index e61a0a82..1eeee94a 100644 --- a/anyplotlib/widgets/__init__.py +++ b/anyplotlib/widgets/__init__.py @@ -2,7 +2,7 @@ from anyplotlib.widgets._base import Widget from anyplotlib.widgets._widgets2d import ( RectangleWidget, CircleWidget, AnnularWidget, - CrosshairWidget, PolygonWidget, LabelWidget, ArrowWidget, + CrosshairWidget, PolygonWidget, LabelWidget, ArrowWidget, LineWidget, ) from anyplotlib.widgets._widgets1d import ( VLineWidget, HLineWidget, RangeWidget, PointWidget, @@ -13,6 +13,7 @@ "Widget", "RectangleWidget", "CircleWidget", "AnnularWidget", "CrosshairWidget", "PolygonWidget", "LabelWidget", "ArrowWidget", + "LineWidget", "VLineWidget", "HLineWidget", "RangeWidget", "PointWidget", "PlaneWidget", ] diff --git a/anyplotlib/widgets/_widgets2d.py b/anyplotlib/widgets/_widgets2d.py index 47e99180..5818fa1b 100644 --- a/anyplotlib/widgets/_widgets2d.py +++ b/anyplotlib/widgets/_widgets2d.py @@ -118,6 +118,46 @@ def __init__(self, push_fn, *, cx, cy, r_outer, r_inner, color="#00e5ff", show_handles=bool(show_handles)) +class LineWidget(Widget): + """Draggable two-endpoint line segment overlay widget for 2-D plots. + + A plain segment from ``(x1, y1)`` to ``(x2, y2)``: drag either endpoint + handle to move that end, or drag the shaft to translate the whole + segment. Unlike :class:`ArrowWidget` it has no head, and unlike + :class:`PolygonWidget` it does not close the path — this is the widget for + a line profile, a cross-section cut, or a two-point measurement. + + Parameters + ---------- + push_fn : Callable + Update callback. + x1, y1 : float + First endpoint in pixel/data coordinates. + x2, y2 : float + Second endpoint in pixel/data coordinates. + color : str, optional + CSS colour for the segment. Default ``"#00e5ff"``. + linewidth : float, optional + Stroke width in px. Default 2. + show_handles : bool, optional + Draw the endpoint grab handles. Default ``True``. + """ + def __init__(self, push_fn, *, x1, y1, x2, y2, color="#00e5ff", + linewidth=2, show_handles=True): + super().__init__("line", push_fn, + x1=float(x1), y1=float(y1), + x2=float(x2), y2=float(y2), + color=color, linewidth=float(linewidth), + show_handles=bool(show_handles)) + + @property + def length(self) -> float: + """Euclidean length of the segment in data coordinates.""" + return float( + ((self.x2 - self.x1) ** 2 + (self.y2 - self.y1) ** 2) ** 0.5 + ) + + class CrosshairWidget(Widget): """Draggable crosshair overlay widget for 2-D plots. diff --git a/upcoming_changes/42.improvement_3.rst b/upcoming_changes/42.improvement_3.rst new file mode 100644 index 00000000..222310d0 --- /dev/null +++ b/upcoming_changes/42.improvement_3.rst @@ -0,0 +1,3 @@ +A ``crosshair`` widget can be grabbed anywhere along either of its rules, not +only at the one-pixel centre hotspot. Grabbing a rule constrains the drag to +that rule's own axis; the centre still moves both. diff --git a/upcoming_changes/42.new_feature_4.rst b/upcoming_changes/42.new_feature_4.rst new file mode 100644 index 00000000..d4152000 --- /dev/null +++ b/upcoming_changes/42.new_feature_4.rst @@ -0,0 +1,13 @@ +New 2-D overlay widget kinds: + +* ``line`` (:meth:`~anyplotlib.Plot2D.add_line_widget`) — a bare two-endpoint + segment with a grab handle at each end. Drag an endpoint to move that end + or the shaft to translate the whole segment. ``arrow`` draws a head and + ``polygon`` requires >= 3 vertices and closes the path, so neither could + stand in for a line profile, a cross-section cut, or a two-point + measurement. ``widget.length`` reports the segment length. +* ``vline`` / ``hline`` (:meth:`~anyplotlib.Plot2D.add_vline_widget`, + :meth:`~anyplotlib.Plot2D.add_hline_widget`) — full-height / full-width + rules, grabbable anywhere along their length. These existed on 1-D panels + only, so a 2-D panel had to fake a single-axis pointer with a ``crosshair``, + which leaves a stray perpendicular rule across the image. From 04515e67249e533b4cff6156f56951b789d5c218 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 26 Jul 2026 16:19:26 -0500 Subject: [PATCH 06/12] feat(widgets): vertical range orientation and snap_values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- anyplotlib/figure_esm.js | 61 +++- anyplotlib/plot1d/_plot1d.py | 30 +- .../test_range_orientation_snap.py | 286 ++++++++++++++++++ anyplotlib/widgets/_widgets1d.py | 71 ++++- upcoming_changes/42.new_feature_5.rst | 6 + upcoming_changes/42.new_feature_6.rst | 7 + 6 files changed, 444 insertions(+), 17 deletions(-) create mode 100644 anyplotlib/tests/test_interactive/test_range_orientation_snap.py create mode 100644 upcoming_changes/42.new_feature_5.rst create mode 100644 upcoming_changes/42.new_feature_6.rst diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 095b540d..c674f981 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -5956,6 +5956,20 @@ fn fs(in : VsOut) -> @location(0) vec4 { const py=_valToPy1d(w.y,dMin,dMax,r); ovCtx.setLineDash([5,3]);ovCtx.beginPath();ovCtx.moveTo(r.x,py);ovCtx.lineTo(r.x+r.w,py);ovCtx.stroke();ovCtx.setLineDash([]); _ovHandle1d(ovCtx,r.x+r.w-7,py,color); + } else if(w.type==='range' && w.orientation==='vertical'){ + // Vertical range: the two edges are VALUES on the y axis and the band + // spans the full plot width. x0/x1 stay the field names — they are + // the extents along the selection axis, the same way matplotlib's + // SpanSelector treats `extents` regardless of its `direction`. + const vpy0=_valToPy1d(w.x0,dMin,dMax,r); + const vpy1=_valToPy1d(w.x1,dMin,dMax,r); + const vtop=Math.min(vpy0,vpy1), vbot=Math.max(vpy0,vpy1); + ovCtx.save();ovCtx.globalAlpha=0.15;ovCtx.fillStyle=color;ovCtx.fillRect(r.x,vtop,r.w,vbot-vtop);ovCtx.restore(); + ovCtx.setLineDash([5,3]); + ovCtx.beginPath();ovCtx.moveTo(r.x,vpy0);ovCtx.lineTo(r.x+r.w,vpy0);ovCtx.stroke(); + ovCtx.beginPath();ovCtx.moveTo(r.x,vpy1);ovCtx.lineTo(r.x+r.w,vpy1);ovCtx.stroke(); + ovCtx.setLineDash([]); + _ovHandle1d(ovCtx,r.x+r.w-7,vpy0,color);_ovHandle1d(ovCtx,r.x+r.w-7,vpy1,color); } else if(w.type==='range'){ const px0=_fracToPx1d(_axisValToFrac(xArr,w.x0),x0,x1,r); const px1b=_fracToPx1d(_axisValToFrac(xArr,w.x1),x0,x1,r); @@ -7440,6 +7454,16 @@ fn fs(in : VsOut) -> @location(0) vec4 { } else if(w.type==='hline'){ const py=_valToPy1d(w.y,st.data_min,st.data_max,r); if(Math.abs(my-py)<=5) return{idx:i,mode:'move',wtype:'hline',startMY:my,snapW:{...w}}; + } else if(w.type==='range' && w.orientation==='vertical'){ + const vpy0=_valToPy1d(w.x0,st.data_min,st.data_max,r); + const vpy1=_valToPy1d(w.x1,st.data_min,st.data_max,r); + const vtop=Math.min(vpy0,vpy1), vbot=Math.max(vpy0,vpy1); + // Same one-third rule as the horizontal band: a thin band must keep a + // grabbable middle rather than being all edge. + const vgrab=Math.min(HR+5,(vbot-vtop)/3); + if(Math.abs(my-vpy0)<=vgrab) return{idx:i,mode:'edge0',wtype:'range',startMY:my,snapW:{...w}}; + if(Math.abs(my-vpy1)<=vgrab) return{idx:i,mode:'edge1',wtype:'range',startMY:my,snapW:{...w}}; + if(my>=vtop&&my<=vbot&&mx>=r.x&&mx<=r.x+r.w) return{idx:i,mode:'move',wtype:'range',startMY:my,snapW:{...w}}; } else if(w.type==='range'){ const px0=_fracToPx1d(_axisValToFrac(xArr,w.x0),x0,x1,r); const px1b=_fracToPx1d(_axisValToFrac(xArr,w.x1),x0,x1,r); @@ -7468,17 +7492,46 @@ fn fs(in : VsOut) -> @location(0) vec4 { return null; } + // Snap a value to the nearest entry of a widget's snap_values, if it has any. + // Mirrors matplotlib SpanSelector.snap_values: the drag follows the cursor + // but lands only on allowed positions. + function _snapVal(v, snapValues){ + if(!Array.isArray(snapValues) || !snapValues.length) return v; + let best=snapValues[0], bestD=Math.abs(v-best); + for(let i=1;i=2?_axisFracToVal(xArr,_canvasXToFrac1d(mx,x0,x1,r)):_canvasXToFrac1d(mx,x0,x1,r); const widgets=st.overlay_widgets; const d=p.ovDrag, s=d.snapW, w=widgets[d.idx]; + const snap=(v)=>_snapVal(v,w.snap_values); + const xUnit=snap(xArr.length>=2?_axisFracToVal(xArr,_canvasXToFrac1d(mx,x0,x1,r)):_canvasXToFrac1d(mx,x0,x1,r)); + const yUnit=snap(st.data_max-((py-r.y)/(r.h||1))*(st.data_max-st.data_min)); if(w.type==='vline'){w.x=xUnit;} - else if(w.type==='hline'){w.y=st.data_max-((py-r.y)/(r.h||1))*(st.data_max-st.data_min);} + else if(w.type==='hline'){w.y=yUnit;} + else if(w.type==='range' && w.orientation==='vertical'){ + // Same cap semantics as the horizontal band, on the value axis. + const vcap = (w.max_extent == null ? null : Math.abs(w.max_extent)); + if(d.mode==='edge0'){ + w.x0 = (vcap!=null && Math.abs(w.x1-yUnit) > vcap) + ? w.x1 + (yUnit < w.x1 ? -vcap : vcap) : yUnit; + } else if(d.mode==='edge1'){ + w.x1 = (vcap!=null && Math.abs(yUnit-w.x0) > vcap) + ? w.x0 + (yUnit < w.x0 ? -vcap : vcap) : yUnit; + } else { + const dv=(st.data_max-st.data_min)*((d.startMY-py)/(r.h||1)); + w.x0=snap(s.x0+dv);w.x1=snap(s.x1+dv); + } + } else if(w.type==='range'){ // max_extent caps the span DURING the drag. The edge NOT being dragged is // the anchor and never moves, so the span stops growing at the cap without @@ -7498,13 +7551,13 @@ fn fs(in : VsOut) -> @location(0) vec4 { else { const snapPx=_fracToPx1d(xArr.length>=2?_axisValToFrac(xArr,s.x0):0,x0,x1,r); const dxUnit=xArr.length>=2?_axisFracToVal(xArr,_canvasXToFrac1d(snapPx+(mx-d.startMX),x0,x1,r))-s.x0:(mx-d.startMX)/(r.w||1); - w.x0=s.x0+dxUnit;w.x1=s.x1+dxUnit; + w.x0=snap(s.x0+dxUnit);w.x1=snap(s.x1+dxUnit); } } else if(w.type==='point'){ // Clamp to plot rectangle const clampX=Math.max(r.x,Math.min(r.x+r.w,mx)); const clampY=Math.max(r.y,Math.min(r.y+r.h,py)); - w.x=xArr.length>=2?_axisFracToVal(xArr,_canvasXToFrac1d(clampX,x0,x1,r)):_canvasXToFrac1d(clampX,x0,x1,r); + w.x=snap(xArr.length>=2?_axisFracToVal(xArr,_canvasXToFrac1d(clampX,x0,x1,r)):_canvasXToFrac1d(clampX,x0,x1,r)); w.y=st.data_max-((clampY-r.y)/(r.h||1))*(st.data_max-st.data_min); } drawOverlay1d(p); diff --git a/anyplotlib/plot1d/_plot1d.py b/anyplotlib/plot1d/_plot1d.py index 05d6a4d0..650245cd 100644 --- a/anyplotlib/plot1d/_plot1d.py +++ b/anyplotlib/plot1d/_plot1d.py @@ -702,7 +702,8 @@ def clear_spans(self) -> None: # Overlay Widgets # ------------------------------------------------------------------ def add_vline_widget(self, x: float, color: str = "#00e5ff", - linewidth: float = 2) -> _VLineWidget: + linewidth: float = 2, + snap_values=None) -> _VLineWidget: """Add a draggable vertical-line overlay. Parameters @@ -721,14 +722,15 @@ def add_vline_widget(self, x: float, color: str = "#00e5ff", :meth:`on_changed` / :meth:`on_release`. """ widget = _VLineWidget(lambda: None, x=float(x), color=color, - linewidth=linewidth) + linewidth=linewidth, snap_values=snap_values) widget._push_fn = self._make_widget_push_fn(widget) self._widgets[widget.id] = widget self._push() return widget def add_hline_widget(self, y: float, color: str = "#00e5ff", - linewidth: float = 2) -> _HLineWidget: + linewidth: float = 2, + snap_values=None) -> _HLineWidget: """Add a draggable horizontal-line overlay. Parameters @@ -747,7 +749,7 @@ def add_hline_widget(self, y: float, color: str = "#00e5ff", :meth:`on_changed` / :meth:`on_release`. """ widget = _HLineWidget(lambda: None, y=float(y), color=color, - linewidth=linewidth) + linewidth=linewidth, snap_values=snap_values) widget._push_fn = self._make_widget_push_fn(widget) self._widgets[widget.id] = widget self._push() @@ -759,13 +761,17 @@ def add_range_widget(self, x0: float, x1: float, y: float = 0.0, linewidth: float = 2, max_extent: float | None = None, + orientation: str = "horizontal", + snap_values=None, _push: bool = True) -> _RangeWidget: """Add a draggable range overlay to this panel. Parameters ---------- x0, x1 : float - Initial left and right edges in data coordinates. + The two edges of the range, in data coordinates along the + selection axis: x positions when horizontal (default), y values + when ``orientation="vertical"``. color : str, optional CSS colour string. Default ``"#00e5ff"``. style : {'band', 'fwhm'}, optional @@ -782,6 +788,16 @@ def add_range_widget(self, x0: float, x1: float, Maximum span width in data units. The span physically stops growing at this width while dragging (the dragged edge pins, the opposite edge stays put). ``None`` (default) leaves it unbounded. + orientation : {'horizontal', 'vertical'}, optional + Which axis the range selects along. ``"vertical"`` draws a band + spanning the plot width that selects a range of *values* — an + intensity window rather than a spectral one. Default + ``"horizontal"``. + snap_values : sequence of float, optional + Allowed edge positions. Each edge follows the cursor while + dragging but lands only on the nearest of these values + (matplotlib's ``SpanSelector.snap_values``). ``None`` (default) + drags continuously. _push : bool, optional Push state to JS immediately. Set to ``False`` when adding several widgets at once; call :meth:`_push` manually afterward. @@ -794,7 +810,9 @@ def add_range_widget(self, x0: float, x1: float, """ widget = _RangeWidget(lambda: None, x0=float(x0), x1=float(x1), color=color, style=style, y=float(y), - linewidth=linewidth, max_extent=max_extent) + linewidth=linewidth, max_extent=max_extent, + orientation=orientation, + snap_values=snap_values) widget._push_fn = self._make_widget_push_fn(widget) self._widgets[widget.id] = widget if _push: diff --git a/anyplotlib/tests/test_interactive/test_range_orientation_snap.py b/anyplotlib/tests/test_interactive/test_range_orientation_snap.py new file mode 100644 index 00000000..bbdf4e6f --- /dev/null +++ b/anyplotlib/tests/test_interactive/test_range_orientation_snap.py @@ -0,0 +1,286 @@ +""" +tests/test_interactive/test_range_orientation_snap.py +====================================================== + +Two range-widget capabilities: + +``orientation="vertical"`` + A band spanning the plot width that selects a range of *values* rather + than of x — an intensity window instead of a spectral one. ``x0``/``x1`` + stay the field names: they are the extents along the selection axis, the + same way matplotlib's ``SpanSelector.extents`` reads for either + ``direction``. + +``snap_values`` + Allowed positions. The drag follows the cursor but lands only on the + nearest allowed value (matplotlib's ``SpanSelector.snap_values``). This + has to happen inside the JS drag: snapping in Python afterwards moves an + edge the user is holding, which reads as the selection fighting back. +""" +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.widgets import RangeWidget, VLineWidget, HLineWidget +from anyplotlib.tests.test_interactive._event_test_utils import ( + _collect_events, GRID_PAD, PAD_L, PAD_R, PAD_T, PAD_B, +) + +FIG_W, FIG_H = 400, 300 +N = 128 + + +# ── helpers ─────────────────────────────────────────────────────────────────── + +def _collect_panel_state(page) -> None: + page.evaluate("""() => { + window._aplPanelState = {}; + const m = window._aplModel; + const orig = m.set.bind(m); + m.set = (k, v) => { + const mm = /^panel_(.+)_json$/.exec(k); + if (mm) { try { window._aplPanelState[mm[1]] = JSON.parse(v); } catch(_) {} } + return orig(k, v); + }; + }""") + + +def _seed_panel_state(page, plot_id) -> None: + page.evaluate( + """(pid) => { + try { + const v = window._aplModel.get('panel_' + pid + '_json'); + if (v) window._aplPanelState[pid] = JSON.parse(v); + } catch(_) {} + }""", str(plot_id)) + + +def _widget_state(page, plot_id, idx=0): + return page.evaluate( + """([pid, i]) => { + const st = window._aplPanelState && window._aplPanelState[pid]; + const ws = st && st.overlay_widgets; + return ws && ws[i] ? ws[i] : null; + }""", [str(plot_id), idx]) + + +def _plot_rect(): + """Page-coord plot rectangle (x, y, w, h).""" + return (GRID_PAD + PAD_L, GRID_PAD + PAD_T, + FIG_W - PAD_L - PAD_R, FIG_H - PAD_T - PAD_B) + + +def _drag(page, x0, y0, dx, dy): + page.mouse.move(x0, y0) + page.mouse.down() + page.mouse.move(x0 + dx, y0 + dy, steps=10) + page.mouse.up() + page.wait_for_timeout(80) + + +def _open(interact_page, add): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.plot(np.linspace(0.0, 100.0, N)) + widget = add(plot) + page = interact_page(fig) + _collect_events(page) + _collect_panel_state(page) + _seed_panel_state(page, plot._id) + return fig, plot, page, widget + + +# ═══════════════════════════════════════════════════════════════════════════ +# 1. Python API +# ═══════════════════════════════════════════════════════════════════════════ + +class TestOrientationApi: + def test_defaults_to_horizontal(self): + assert RangeWidget(lambda: None, x0=0, x1=1).orientation == "horizontal" + + def test_vertical_stored(self): + w = RangeWidget(lambda: None, x0=0, x1=1, orientation="vertical") + assert w.orientation == "vertical" + + def test_reaches_the_state_dict(self): + w = RangeWidget(lambda: None, x0=0, x1=1, orientation="vertical") + assert w.to_dict()["orientation"] == "vertical" + + def test_bad_orientation_raises(self): + with pytest.raises(ValueError, match="orientation must be"): + RangeWidget(lambda: None, x0=0, x1=1, orientation="diagonal") + + def test_vertical_fwhm_raises(self): + """The FWHM indicator is defined against the x axis only.""" + with pytest.raises(ValueError, match="fwhm"): + RangeWidget(lambda: None, x0=0, x1=1, + orientation="vertical", style="fwhm") + + def test_factory_passes_orientation(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.zeros(N)) + w = plot.add_range_widget(1, 2, orientation="vertical") + assert w.orientation == "vertical" + + +class TestSnapValuesApi: + def test_defaults_to_none(self): + assert RangeWidget(lambda: None, x0=0, x1=1).snap_values is None + + def test_empty_sequence_is_none(self): + assert RangeWidget(lambda: None, x0=0, x1=1, snap_values=[]).snap_values is None + + def test_numpy_array_becomes_a_list(self): + """A numpy array is not JSON-serialisable and would break the push.""" + w = RangeWidget(lambda: None, x0=0, x1=1, + snap_values=np.array([0.0, 1.0, 2.0])) + assert isinstance(w.snap_values, list) + assert w.snap_values == [0.0, 1.0, 2.0] + + @pytest.mark.parametrize("cls,kwargs", [ + (VLineWidget, {"x": 0}), + (HLineWidget, {"y": 0}), + ]) + def test_line_widgets_take_snap_values(self, cls, kwargs): + w = cls(lambda: None, snap_values=[1, 2, 3], **kwargs) + assert w.snap_values == [1.0, 2.0, 3.0] + + def test_state_dict_is_json_serialisable(self): + import json + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.zeros(N)) + plot.add_range_widget(1, 2, snap_values=np.arange(5.0)) + json.dumps(plot.to_state_dict()) # must not raise + + +# ═══════════════════════════════════════════════════════════════════════════ +# 2. Rendering +# ═══════════════════════════════════════════════════════════════════════════ + +class TestVerticalRendering: + def _band_mask(self, take_screenshot, **kwargs): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.plot(np.linspace(0.0, 100.0, N)) + plot.add_range_widget(color="#ff0000", **kwargs) + img = take_screenshot(fig)[..., :3].astype(int) + # The band fill is a translucent red wash over the background. + return (img[..., 0] > img[..., 2] + 20) + + def test_vertical_band_spans_the_width(self, take_screenshot): + mask = self._band_mask(take_screenshot, x0=30.0, x1=70.0, + orientation="vertical") + plot_w = FIG_W - PAD_L - PAD_R + cols = mask.any(axis=0).sum() + assert cols > plot_w * 0.8, ( + f"a vertical band must span the plot width, covered {cols} of " + f"~{plot_w} columns" + ) + + def test_horizontal_band_spans_the_height(self, take_screenshot): + mask = self._band_mask(take_screenshot, x0=30.0, x1=70.0) + plot_h = FIG_H - PAD_T - PAD_B + rows = mask.any(axis=1).sum() + assert rows > plot_h * 0.8 + + def test_orientation_changes_the_shape(self, take_screenshot): + """Sanity: the two orientations must not render the same thing.""" + horiz = self._band_mask(take_screenshot, x0=30.0, x1=70.0) + vert = self._band_mask(take_screenshot, x0=30.0, x1=70.0, + orientation="vertical") + assert horiz.any(axis=1).sum() != vert.any(axis=1).sum() + + +# ═══════════════════════════════════════════════════════════════════════════ +# 3. Dragging (real browser input) +# ═══════════════════════════════════════════════════════════════════════════ + +class TestVerticalDrag: + def test_dragging_the_band_moves_the_value_range(self, interact_page): + fig, plot, page, w = _open( + interact_page, + lambda p: p.add_range_widget(x0=30.0, x1=70.0, + orientation="vertical")) + px, py, pw, ph = _plot_rect() + before = _widget_state(page, plot._id) + # Middle of the band: the data spans 0..100, the band 30..70, so the + # band's centre value 50 sits at the vertical middle of the plot. + _drag(page, px + pw / 2, py + ph / 2, 0, -30) + after = _widget_state(page, plot._id) + assert after["x0"] > before["x0"] + 0.5, "dragging up must raise x0" + assert after["x1"] > before["x1"] + 0.5, "dragging up must raise x1" + + def test_band_width_is_preserved_by_a_translation(self, interact_page): + fig, plot, page, w = _open( + interact_page, + lambda p: p.add_range_widget(x0=30.0, x1=70.0, + orientation="vertical")) + px, py, pw, ph = _plot_rect() + before = _widget_state(page, plot._id) + _drag(page, px + pw / 2, py + ph / 2, 0, -30) + after = _widget_state(page, plot._id) + assert (after["x1"] - after["x0"]) == pytest.approx( + before["x1"] - before["x0"], abs=1e-6) + + def test_horizontal_drag_does_not_move_a_vertical_band(self, interact_page): + fig, plot, page, w = _open( + interact_page, + lambda p: p.add_range_widget(x0=30.0, x1=70.0, + orientation="vertical")) + px, py, pw, ph = _plot_rect() + before = _widget_state(page, plot._id) + _drag(page, px + pw / 2, py + ph / 2, 60, 0) + after = _widget_state(page, plot._id) + assert after["x0"] == pytest.approx(before["x0"], abs=1e-6) + + +class TestSnapDrag: + def test_edge_lands_on_an_allowed_value(self, interact_page): + snaps = [0.0, 25.0, 50.0, 75.0, 100.0] + fig, plot, page, w = _open( + interact_page, + lambda p: p.add_range_widget(x0=0.0, x1=25.0, snap_values=snaps)) + px, py, pw, ph = _plot_rect() + # The x axis is the sample index 0..N-1; grab the right edge, which sits + # at value 25 -> index 32 of 128. + edge_x = px + pw * (25.0 / (N - 1)) + _drag(page, edge_x, py + ph / 2, pw * 0.3, 0) + after = _widget_state(page, plot._id) + assert after["x1"] in snaps, ( + f"x1={after['x1']} is not one of the allowed values {snaps}" + ) + + def test_snapping_actually_moved_the_edge(self, interact_page): + """Guard against the test passing because nothing happened.""" + snaps = [0.0, 25.0, 50.0, 75.0, 100.0] + fig, plot, page, w = _open( + interact_page, + lambda p: p.add_range_widget(x0=0.0, x1=25.0, snap_values=snaps)) + px, py, pw, ph = _plot_rect() + before = _widget_state(page, plot._id) + edge_x = px + pw * (25.0 / (N - 1)) + _drag(page, edge_x, py + ph / 2, pw * 0.3, 0) + after = _widget_state(page, plot._id) + assert after["x1"] > before["x1"], "the drag missed the edge" + + def test_without_snap_values_the_edge_lands_anywhere(self, interact_page): + """Contrast case: continuous dragging is still the default.""" + fig, plot, page, w = _open( + interact_page, + lambda p: p.add_range_widget(x0=0.0, x1=25.0)) + px, py, pw, ph = _plot_rect() + edge_x = px + pw * (25.0 / (N - 1)) + _drag(page, edge_x, py + ph / 2, pw * 0.3, 0) + after = _widget_state(page, plot._id) + assert after["x1"] not in (0.0, 25.0, 50.0, 75.0, 100.0) + + def test_vline_snaps(self, interact_page): + snaps = [0.0, 32.0, 64.0, 96.0] + fig, plot, page, w = _open( + interact_page, + lambda p: p.add_vline_widget(x=32.0, snap_values=snaps)) + px, py, pw, ph = _plot_rect() + start = px + pw * (32.0 / (N - 1)) + _drag(page, start, py + ph / 2, pw * 0.2, 0) + after = _widget_state(page, plot._id) + assert after["x"] in snaps diff --git a/anyplotlib/widgets/_widgets1d.py b/anyplotlib/widgets/_widgets1d.py index 7916dd04..ad3bb256 100644 --- a/anyplotlib/widgets/_widgets1d.py +++ b/anyplotlib/widgets/_widgets1d.py @@ -2,11 +2,34 @@ widgets/_widgets1d.py ===================== Interactive overlay widgets for 1-D line panels (Plot1D). + +``VLineWidget`` and ``HLineWidget`` are also used by ``Plot2D``, where they +draw a full-height / full-width rule over the image. """ from __future__ import annotations from anyplotlib.widgets._base import Widget +SNAP_DOC = """snap_values : sequence of float, optional + Allowed positions. While dragging, the widget follows the cursor but + lands only on the nearest of these values — matplotlib's + ``SpanSelector.snap_values``. ``None`` (default) drags continuously. + Set it later with ``widget.snap_values = [...]``.""" + + +def _norm_snap_values(values): + """Validate snap_values and return a plain list of floats (or None). + + A list is what crosses the wire, so numpy arrays have to be converted + here — they are not JSON-serialisable and would break the panel push. + """ + if values is None: + return None + out = [float(v) for v in values] + if not out: + return None + return out + class VLineWidget(Widget): """Draggable vertical line overlay widget for 1-D plots. @@ -25,9 +48,11 @@ class VLineWidget(Widget): linewidth : float, optional Line stroke width in px. Default 2. """ - def __init__(self, push_fn, *, x, color="#00e5ff", linewidth=2): + def __init__(self, push_fn, *, x, color="#00e5ff", linewidth=2, + snap_values=None): super().__init__("vline", push_fn, x=float(x), color=color, - linewidth=float(linewidth)) + linewidth=float(linewidth), + snap_values=_norm_snap_values(snap_values)) class HLineWidget(Widget): @@ -47,9 +72,11 @@ class HLineWidget(Widget): linewidth : float, optional Line stroke width in px. Default 2. """ - def __init__(self, push_fn, *, y, color="#00e5ff", linewidth=2): + def __init__(self, push_fn, *, y, color="#00e5ff", linewidth=2, + snap_values=None): super().__init__("hline", push_fn, y=float(y), color=color, - linewidth=float(linewidth)) + linewidth=float(linewidth), + snap_values=_norm_snap_values(snap_values)) class RangeWidget(Widget): @@ -68,19 +95,28 @@ class RangeWidget(Widget): handles are draggable. Use this to show/edit a FWHM interval on a peak. + With ``orientation='vertical'`` the band spans the plot width and selects + a range on the *value* axis instead — for picking an intensity window + rather than a spectral one. + Parameters ---------- push_fn : Callable Update callback. x0, x1 : float - Initial left and right positions in data coordinates. + The two edges of the range, in data coordinates **along the selection + axis**: x positions when horizontal, y values when vertical. The + names do not change with orientation, mirroring how matplotlib's + ``SpanSelector.extents`` is read the same way for either ``direction``. color : str, optional CSS colour. Default ``"#00e5ff"``. style : {'band', 'fwhm'}, optional - Visual style. Default ``"band"``. + Visual style. Default ``"band"``. ``'fwhm'`` is horizontal-only. y : float, optional Y-position (data coordinates) for the connecting line when ``style='fwhm'``. Ignored for ``style='band'``. Default ``0.0``. + orientation : {'horizontal', 'vertical'}, optional + Which axis the range selects along. Default ``"horizontal"``. linewidth : float, optional Line stroke width in px. Default 2. max_extent : float, optional @@ -93,14 +129,35 @@ class RangeWidget(Widget): integrating selector where the width is a number of frames to read. Enforcing it in the widget makes the limit visible (the edge simply stops) instead of applying a silent clamp after the fact. + snap_values : sequence of float, optional + Allowed edge positions. While dragging, each edge follows the cursor + but lands only on the nearest of these values — matplotlib's + ``SpanSelector.snap_values``. ``None`` (default) drags continuously. + Set it later with ``widget.snap_values = [...]``. + + Raises + ------ + ValueError + If *orientation* is not ``'horizontal'`` or ``'vertical'``, or if + ``style='fwhm'`` is combined with a vertical orientation. """ def __init__(self, push_fn, *, x0, x1, color="#00e5ff", style: str = "band", y: float = 0.0, linewidth=2, - max_extent=None): + max_extent=None, orientation: str = "horizontal", + snap_values=None): + if orientation not in ("horizontal", "vertical"): + raise ValueError( + f"orientation must be 'horizontal' or 'vertical', " + f"got {orientation!r}" + ) + if orientation == "vertical" and style == "fwhm": + raise ValueError("style='fwhm' is only defined for a horizontal range") super().__init__("range", push_fn, x0=float(x0), x1=float(x1), color=color, style=str(style), y=float(y), linewidth=float(linewidth), + orientation=str(orientation), + snap_values=_norm_snap_values(snap_values), max_extent=(None if max_extent is None else float(max_extent))) diff --git a/upcoming_changes/42.new_feature_5.rst b/upcoming_changes/42.new_feature_5.rst new file mode 100644 index 00000000..44e2a985 --- /dev/null +++ b/upcoming_changes/42.new_feature_5.rst @@ -0,0 +1,6 @@ +:meth:`~anyplotlib.Plot1D.add_range_widget` takes ``orientation="vertical"`` +for a band spanning the plot width that selects a range of *values* — an +intensity window rather than a spectral one. ``x0``/``x1`` remain the field +names: they are the extents along the selection axis, the same way +matplotlib's ``SpanSelector.extents`` reads for either ``direction``. +``style="fwhm"`` stays horizontal-only and raises if combined with it. diff --git a/upcoming_changes/42.new_feature_6.rst b/upcoming_changes/42.new_feature_6.rst new file mode 100644 index 00000000..7f3fd544 --- /dev/null +++ b/upcoming_changes/42.new_feature_6.rst @@ -0,0 +1,7 @@ +Range, vline, hline and point widgets take ``snap_values`` — a sequence of +allowed positions. While dragging, the widget follows the cursor but lands +only on the nearest allowed value, matching matplotlib's +``SpanSelector.snap_values``. Snapping happens inside the JS drag, so the +widget visibly steps between allowed positions instead of being corrected +afterwards. numpy arrays are accepted and converted (a raw array is not +JSON-serialisable and would break the panel push). From ce6016a0fbf2d300be534dff91a38ba8b573c7cc Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 26 Jul 2026 16:30:01 -0500 Subject: [PATCH 07/12] feat(plot2d): colorbar gap and scale-bar colours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- anyplotlib/figure_esm.js | 48 +++-- anyplotlib/plot2d/_plot2d.py | 47 ++++ anyplotlib/tests/baselines/imshow_labels.png | Bin 24187 -> 24671 bytes .../test_plot2d/test_colorbar_gap_scalebar.py | 200 ++++++++++++++++++ upcoming_changes/42.bugfix.rst | 10 + upcoming_changes/42.new_feature_7.rst | 5 + 6 files changed, 296 insertions(+), 14 deletions(-) create mode 100644 anyplotlib/tests/test_plot2d/test_colorbar_gap_scalebar.py create mode 100644 upcoming_changes/42.bugfix.rst create mode 100644 upcoming_changes/42.new_feature_7.rst diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index c674f981..2c2cca9a 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -305,6 +305,16 @@ function render({ model, el, onResize }) { return 16 + labelW; } + // Gap between the right edge of the image and the colorbar strip. Without a + // real gap the strip reads as part of the image — most plots have no + // colorbar_label, so the label gutter that would otherwise separate them is + // zero-width. Overridable per panel via colorbar_pad (set_colorbar_pad). + const CB_GAP = 6; + function _cbGap(st) { + const v = st && st.colorbar_pad; + return (v == null) ? CB_GAP : Math.max(0, v); + } + // Height of the title strip. Stays at PAD_T for default-size plain titles // so existing layouts are pixel-identical; grows for title_size > 11 and // for TeX titles (superscripts rise above the cap height) so 2D titles are @@ -2230,7 +2240,7 @@ function render({ model, el, onResize }) { const imgX = hasPhysAxis ? PAD_L : 0; const imgY = padT; const imgW = Math.max(1, (hasPhysAxis ? pw - PAD_L - PAD_R : pw) - - (cbW ? cbW + 2 : 0)); + - (cbW ? cbW + _cbGap(st) : 0)); let imgH = Math.max(1, ph - padT - (hasPhysAxis ? PAD_B : 0)); // Enforce aspect ratio (st.aspect = number or "equal" → 1.0). if (st && st.aspect != null) { @@ -2333,7 +2343,7 @@ function render({ model, el, onResize }) { if (p.cbCanvas && p.cbCtx) { if (cbW) { p.cbCanvas.style.display = 'block'; - p.cbCanvas.style.left = (imgX + imgW + 2) + 'px'; + p.cbCanvas.style.left = (imgX + imgW + _cbGap(st)) + 'px'; p.cbCanvas.style.top = imgY + 'px'; _sz(p.cbCanvas, p.cbCtx, cbW, imgH); } else { @@ -2907,20 +2917,30 @@ function render({ model, el, onResize }) { ctx.setTransform(dpr,0,0,dpr,0,0); ctx.clearRect(0,0,cvW,cvH); + // Colours. Default is the original white-on-black pill; scalebar_color + // recolours the bar and its label, and scalebar_bgcolor the pill — + // 'none' drops the pill entirely, for a bar drawn straight onto a light + // image where the dark slab is the thing that looks wrong. + const sbFg = st.scalebar_color || 'white'; + const sbBg = st.scalebar_bgcolor === undefined || st.scalebar_bgcolor === null + ? 'rgba(0,0,0,0.60)' : st.scalebar_bgcolor; + // Background pill - ctx.fillStyle='rgba(0,0,0,0.60)'; - const r=5; - ctx.beginPath(); - ctx.moveTo(r,0);ctx.lineTo(cvW-r,0);ctx.arcTo(cvW,0,cvW,r,r); - ctx.lineTo(cvW,cvH-r);ctx.arcTo(cvW,cvH,cvW-r,cvH,r); - ctx.lineTo(r,cvH);ctx.arcTo(0,cvH,0,cvH-r,r); - ctx.lineTo(0,r);ctx.arcTo(0,0,r,0,r); - ctx.closePath();ctx.fill(); + if(sbBg !== 'none'){ + ctx.fillStyle=sbBg; + const r=5; + ctx.beginPath(); + ctx.moveTo(r,0);ctx.lineTo(cvW-r,0);ctx.arcTo(cvW,0,cvW,r,r); + ctx.lineTo(cvW,cvH-r);ctx.arcTo(cvW,cvH,cvW-r,cvH,r); + ctx.lineTo(r,cvH);ctx.arcTo(0,cvH,0,cvH-r,r); + ctx.lineTo(0,r);ctx.arcTo(0,0,r,0,r); + ctx.closePath();ctx.fill(); + } // Label (centred over the bar line) const lineX=(cvW-barPx)/2; const textY=padTop+fontSize; - ctx.fillStyle='white'; + ctx.fillStyle=sbFg; ctx.font=`bold ${fontSize}px sans-serif`; ctx.textAlign='center'; ctx.textBaseline='alphabetic'; @@ -2928,7 +2948,7 @@ function render({ model, el, onResize }) { // Bar line const lineY=padTop+fontSize+gap; - ctx.fillStyle='white'; + ctx.fillStyle=sbFg; ctx.fillRect(lineX, lineY, barPx, lineH); // End ticks @@ -7710,7 +7730,7 @@ fn fs(in : VsOut) -> @location(0) vec4 { const imgX = hasPhysAxis ? PAD_L : 0; const imgY = hasPhysAxis ? padT : 0; const imgW = Math.max(1, (hasPhysAxis ? pw - PAD_L - PAD_R : pw) - - (cbW ? cbW + 2 : 0)); + - (cbW ? cbW + _cbGap(st) : 0)); const imgH = hasPhysAxis ? Math.max(1, ph - padT - PAD_B) : ph; // Update stored dims so event handlers stay consistent during CSS resize p.imgX = imgX; p.imgY = imgY; p.imgW = imgW; p.imgH = imgH; @@ -7738,7 +7758,7 @@ fn fs(in : VsOut) -> @location(0) vec4 { _szCSS(p.xAxisCanvas, imgW, PAD_B); } if (p.cbCanvas && p.cbCanvas.style.display !== 'none') { - p.cbCanvas.style.left = (imgX + imgW + 2) + 'px'; p.cbCanvas.style.top = imgY + 'px'; + p.cbCanvas.style.left = (imgX + imgW + _cbGap(st)) + 'px'; p.cbCanvas.style.top = imgY + 'px'; _szCSS(p.cbCanvas, cbW || 16, imgH); } } else if (p.kind === '3d') { diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py index 2b7f8aed..88f97d26 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -271,11 +271,16 @@ def __init__(self, data, "units": units, "scale_x": scale_x, "scale_y": scale_y, + # None => the renderer's white-on-dark-pill default. + "scalebar_color": None, + "scalebar_bgcolor": None, "display_min": disp_min, "display_max": disp_max, "raw_min": raw_vmin, "raw_max": raw_vmax, "show_colorbar": False, + # None => the renderer's default 6 px image-to-strip gap. + "colorbar_pad": None, "scale_mode": "linear", "colormap_name": cmap_name, "colormap_data": cmap_lut, @@ -1558,12 +1563,54 @@ def set_colorbar_visible(self, visible: bool) -> None: self._state["show_colorbar"] = bool(visible) self._push() + def set_colorbar_pad(self, pad: float | None) -> None: + """Set the gap in px between the image and the colorbar strip. + + Parameters + ---------- + pad : float or None + Gap in CSS pixels. ``None`` restores the default (6 px). The gap + comes out of the image width, so widening it shrinks the image + rather than pushing the strip off the panel. + """ + self._state["colorbar_pad"] = None if pad is None else max(0.0, float(pad)) + self._push() + def set_aspect(self, ratio) -> None: if ratio == "equal": ratio = 1.0 self._state["aspect"] = float(ratio) if ratio is not None else None self._push() + def set_scalebar_style(self, color: str | None = None, + bgcolor: str | None = None) -> None: + """Recolour the automatic scale bar. + + The scale bar appears on its own whenever the panel has calibrated + axes (``units`` other than ``"px"``). It defaults to white on a + translucent dark pill, which reads well on most images but not on a + light one. + + Parameters + ---------- + color : str, optional + CSS colour for the bar and its label. ``None`` (default) leaves + it at white. + bgcolor : str, optional + CSS colour for the pill behind them, or the string ``"none"`` to + draw no pill at all. ``None`` (default) leaves it at the + translucent dark pill. + + Examples + -------- + >>> plot.set_scalebar_style(color="black", bgcolor="none") + """ + if color is not None: + self._state["scalebar_color"] = str(color) + if bgcolor is not None: + self._state["scalebar_bgcolor"] = str(bgcolor) + self._push() + # ------------------------------------------------------------------ # Overlay Widgets # ------------------------------------------------------------------ diff --git a/anyplotlib/tests/baselines/imshow_labels.png b/anyplotlib/tests/baselines/imshow_labels.png index 64aa38a405d3a3fdf9739f7190d1864b8be29b05..3ef1dd1f256683dc972ce1faac4fdf4c01f2ee05 100644 GIT binary patch literal 24671 zcma&NWmuGL_dN_FDLHh5bcfV{q;z+u(jYLDARUTyHzSC2NQyKvqyo|v4GoPzO;u4J4GsM@>JJA5d}3$F zN{EJL%dVy zD8STPm)BKBr?yv?%cG{Im1N?KicuAwP6X>>355|8->&R5R)+-@o^H)`>~&x6c3g*4 zQwAHIE(IEORCRZZ%Uu3y>My)GE-x<#+-ewN2s)lrVHA3TZqY-L^PM6kLv4c9jiiOG zAo%bTb*O|)2=IA5=3sqzzdNqlH>$#bY(87O1(5I(2sp{{jP2~$9d#+yJcy#zOKX(~ zxRGs{MGr`Pdhhl43hL74a7+)&>9aei2W(Q*_k2En@bjnz^GSpkpKS!~yvPme0gmmM zyinn5!iy{5cD9yyriek&MA>@atXvOtyd}*KQPWRvC~bld*z=z>!a*^XezQiauZAmu zml)w|F)WlhLqu)YeYo+Pw-UFu+iStRa7M?oWK3*>Ft~2K@?Z8}3GrJqu?cCm*GBq% z7M)iec>2w6zIo2vt*-00Vd9?4zrHl_Od8&4zS{NwP#snA1&+;FqjhbvbNa=paOG-UJihpC$Y;_P z;Kh+*N8+{-<_K0z6^3U&ZZ7u(vUlIa^It3s$*?!4iG4Cl=YHior8b-G8ewp`^Zu*H zUe{^X@++@FYF7cSC5LP)ZJBd>mleVXznBuIRmx%Bas7JU&j!d%Vt(ehR<5p+X}bRx z$JdFN+!2>6x7RkYsU<4JZNTk*Jd1tOvGe|DV?0~X%KH=B>?9|&{MFM%;KpTH7pjc` z>$y$^0mxW=1$z5wEph4H)>W~UV4o1y$&4QQrwfl}3n#*oCDwDqHrB=7hhvV7_muGJ z;=(Qxre5Lkw<4lxJyIU9Z)^h(4P8S8%8aCSapO(TtI&(hY@5($e9*-idDa)$3<0mN z9=H@{c#Od$wz||2NirACCt-{M2z2_}4cXiC675AB>nFUn5f)E8PG&5fzbR!Nu}P;)L&DHR=Ea}@^{c@767FZ6uz20irVmjP> z`qf>WUZtvhwc@k%>5{Oh$!c*@9*|qbPgTkf0)JSmeW)T$(eakB$RJR@_fj@Eta))!P5 zeL2bt?F$JDe)>L~PY@SxR5ii?$ysz@@tFFeq@ECUDBUp>1z7&4;h zWScQ9-dU3`^Bu=0hOotmeO(K?*$Aug2nj0-k+TS=nBWmBBe`x6eMg~r6fd=Yz|rQj zfT^wEU3h!7(X!~uQ>vIK{**mp^_3uM1y$Df7e1!Ex#A+lAFe5EbJ^E09lfg{Vn^Idbz%g zpPMz-fw{yLcI%g(TtcuADV~ZufCQHwweL$24y<+i^Dvf@;Mw z-EQJ-t{&Q7?zGH@mE(vn2NZkDR7bfnBx8DT7nn0nQ#iXs9ExUdPkTR?|6CFc+=0?M zpoqVQqSe&^WJ2DPs~=&&-*JE5f$~+)0iJH2Y0xitbFkdEQHq=d%;6T#5)ADS{b$f9 zG6=OH3x3{0h2YX4t!K@r*wR=&X!}`>{ z@a~%|v0vT5u^VbX4U5-Kco}tXFm_Mh77bFgPDOt^HAMu5+FvXE`Nk%Cv6jir(f>c6 zFbqAja#JPvro`vI5R!~pdo8?`^Wm}F30@&Zl{>A_P38`wOtX;47hYW+TLppf2zJ9InKu)w|D}{7)8;hq_c=9N$c*I}4 zCp+FL)?2YA3i-;p^_A}D*RRFhT_t}`xWx$xQkrS$8flpXvZ&-Xh!;u$IrsXFz!HFx zgnZU6-YI^Y1! zv2J#Z^i2MwLI^q$KSNMOn9Zq<(o`XEA_W8wX4wYEgN=^0l;vs=%;vgh6yJn7M_812 zcS=)0{PpOgwilJUR>vM}d@fu$ECdpJKK^-iyt<7t>`@unV?;lqnfZnW!Wwz!JFRm4 z;)*et7L7~uNF=59TmwuAvQQ2tu!X8z`Sg4Nm!oqj`IsR_am<$b(ms_rnnP!a!}{JZ(wu9PUX&A+9{?_r{%?;ZA&361xP2fUqUgfj@GU2 z7`Mk$U~$kvdZ{d7uuNo&OG}JDIu{uybye!a#7+6xGR)d{YPY998{YSrA%Gu%q7@xy zX{TkS_SgHY*43m3zu+g#VHA*O-arH_eGxLUXc5yN>(XS+AG$h6H1$Z61Xb8PE@%I( z+oDJ`3u;Nj^f1TJwkzhlC;G1EgcHg&yM3oe2E2k%o3udQgDy!1emh=TG!+#==6J!n zpNPYry-WtSGpMs0CV45}PtE!eR;BQ)S2i&~j5sRAO(?ZEr(tWO6Xw8CTL}Snjo|KyfJ>^Ydz>U5? z9)E_pKNd3&f;@d$K-ekt*osM7BH`l>`GI5j<{WZugzK|wrH8BIU)%_7=L6CR&vK?k z{Nc*nE9Qv&2mT&3VAq9rJdn*hjx?Jk?|A7}A@Z6LZ9EvJc8HO`o*mH%79Q-m zo#JEYCpAaP6KwFWf|A4g6-Vuk7cosqvS2twiQDOzJN&#ISyMuG$8Fy)QnrJ+`uM3IONnHIk*s%nZ|Rtua$lepdbFkf!`k6RPytK5-&FE>OOyI zFM15^UjF0a1oKV-q!!vJns)WNuc}V;mqynE5%+T1-Xv6{MbdEiV~TK7ZjVLBle3@KVIJfjfn_XR&h>n?l91 zTS3D2XgEdy^e!I~M_T6_im^vBgx!BGHIy0~_LV_(Yh0eBtT80e_JL>bu3qE?!LYlD3? zTS@fpoA>(fW>i7h?KcT+CH)_@!ZV&flS)bJH50!o!5MqFu|hs%dA4SEGIx{{2Sc)K zD|KR?Y4zWitD)WJO~M(XZ)z@@JwsdP6xa;E_ z|COR(PlH4=cH5y!ZzLv7HkrKfK7#Y+@D@;fc`PNAo2ub+PjRlu z21{SwrB-h|ilg9-%Pn@vh<&$^@vkl2Ij}1F@bR+C(ZzP1j*aKS%pKbZo7f z$&!H_Vvt#}y8PdYB7mjncibhflbK z(%8`SUE)5V4|3)tEYZ)ZaroUoM#VSL31~3J`#7@^rqsNA z%v{|i&b+4KL&ucJ3Qf7UxGuO1&*1F4>^(sDTBi!*1&t8j9Pxwc)Z`xezHAgsJ=^U` z2lIyYs_4PEI)c6XbLDPoaU6YxKUl$DybxNRDAG*P7JYdh<4&p99S8K6?AI%H5$R|^ zYcPjeHwitZhR@fvf}mX=3xSmctQv+Tgfv8Q&#LX+IBIGwo@K+X$%&HO)eAnMx)4#bBCKx#>X}m)26#N4ZEDNGq z0y`zuLYiEsbwu2H4u1?#Zzsy<`+H3V-Mu}aGw?&hs-F&E+itA|!wU{W!LkVf^u!vY z-N~OCVm$mf?bK+M>Yv|daYK&g52Q~OjYewEhPCdx4(Dq*`QfBIuG5rVPb7Bw@|}w2 znunErFJlg?^KO7g|Dv2F^9~PAUu_R@Ipi*+CdZ?{m(pYMHlLg?#5KagzBR9MyNJUG z?)l=uNzd%18ZT5|5>u9EZ}?e z-&lbFsNEn`MzmT!5zf9cLr}!c_1=!>i|ZIV42UfUtOE2Y==<(|7?5sbpz_)+1g3Ue zG`}~cYXn}SktZjtYjRT44_$CS7aSgP1RR3A#6UK+GM z*K_4}0VaG=Xim)3T`nzDfbfb8qz$ZR_t<%^IZvtHhxkuu&#y9N0X>chSUba zSz>&GStv5j3Ahzxlf}rKwCo6HVtimUN}7@xQTV|<{pWE%?fbQAF$#B4H0Vz&j+~@)l%Ne_)fJAcJlC~S%g<9+i0F6FYmyR{9`H{ zCudaKU+zavz7t7((d=U&^kD8ixzpH26AL8sT9z5oCi#}ok2*#DJ+tE(%?laaF^(aS z1^sZp6Bc&AMCt=f;^|kRGWEU~GS5Qeo(kFaZ(bmSztr!Wy*#{{W;PO0RquBIJ7T_w z%^*o&+OXoXp)q1w?(yzN2H&4%@xtf{fI<4~Z^_}_!}v^bO|G=llLOR&TBW2@eA8ut zrJGtVK(CXD^`vLoOc-8i1Q&fwp_vTkc(!b1#}JW3isZc;$nyniF! z+&Q(OfZz_)m&da5(fUmP31MZOBmu~OfE*{lw`i+tF^bjz-u~+yfL*-MX=Z4X)3|mf zC5_nZ0%%@oWbn!qlpPmVuNu8BOMDgM7dY?%bBQjZ8B6?85#a%SxC)CY&hu)#xF+T5 zu|n0hnk6a0$bH*I|^0mU}NaM@Sf))R{^U_0Z)bjyUC+tmg?KtdX&6 zo8hBW8#vLB?D&jEV#_SS&nX+G>VeK9EqjL}0y^`{^)7!K#u+&9w(%EFTTyH99EIn= zKd2S}n`7ewIhWGGA<&pPKJ`JFqE+eI@L2Z)c~FIT(WEb4+3inIPHFn+@5p zDqj_DXe!t?20tGtu;s3U@PMF_U69pS%TP_a_vhue35eF^Y49e;52oq&0%^q(#)m<;scjw8WiKFF3UgdBa)Tt=h#Q+bqQczw!~owpPam!TM6 zy&YhQ@Is>~Azl8T$~_j|Vb6avqiuTeKh=%K7Gb~O$S>+k>8FXg-S{_oxTwVWK-sQ7 zVo%fw_@Gwro{T25K`RCC97$#;+d5@EvO7>(t?bw-E6u=7B6FuH3hWgXDGr~IBoVcy zgReDU04J z6zSuV>-8$2k@jUQ^1x$>2C<(C8RHPY3j+dmXOGKh#tYf} zwwVbAOA9@_2hvMIWACN58@=Uuy)Z$p(7_Iff~m z_D+=r6G~@UVhJM1wCut&sppX*HwWM=i-Yu4>N(AOZ$Bj2W*n`s1mqJlZ|g=2>OXBk>t=*2u2Ip23dIW+Cn>%>EUj++OEOInCBDHvyik${cy!0^@fDsc zd&NCoH#)-EG>cK&N;fDlc?N(9fU2*;;OD$qv!j`8s6Od>*=lA%Aw-}f5#Z~^FI^)Z zEQ3sjhhj8mz46Da41-YF#rC4a;S-HmCbJV!Z3bdDXhum(VbAMpWzXaB5y z@^X{RxqClcN@hPln%o;4xq!UAcVih21V!pQS;)rYXwL-OL%vlnqzH~d>VVnKoodym zoOoALy2zR{<{QeM4whCXWIQwEVP@JJmwg+hPC`@mNSqxlpc@!570OdZ5$3 zIbAzEOP9V~K*UI+`olhf&W{P|&D)yfDr^EA`GbyZ0;;Jd(r#!4Bny%ieT=q81}Kt>f^1f(i<)F@z|f1ueltTUHrJ-w zli%PKbdPvj8Oc={q^0w2Aq&-)uKSUdM92ii@&#c>;?77PzJBVn6>+396Zr-rMIleL zfiDGgw1)}u7A4V5_%MW0G8Fy?pO;{yqtg;Kqa7D{zkN4x;fze z?Vthp*?TjVC%T<6SKpJlIP02>!9S43fF(l)Z#>vbwrz`HV`-I}>|mxcG82v@hfpn( zICXo)5quDf1T$lFNcvWmW>?(`QLv0TU9*8TeU$nu6+F?YNJzr)Qe0hlu}k*pG+au& zmGSSyhLNhiP!1Sref}B}gv6hXJ(|*27Pqs%rST%^$^1UBSy|s7AE3^<-lZy@&3)B@ zlU1~S#I8NfhIfVjx~RYHcx?=9*{na3NizCMo5=lKjQi2qkGT|Jef+~4?hj=T|0%9U zy+X`F07IYv{-%*|_}b3HwcRgK+q-p{eE-9orln?P96SKl4sfl16bh6K0VouC!NY>i z!Yw_1^Nm6lKzErdNWm-36d8r>;!U(#U&$@rxAjt~9@&e{obxHScPhcu=Zo}#Y0uRu zZ*MAzz!xE%0oh{%v(;ssEO2rZvs)w9Ct7AmV;c9I*FCnG$)CFHHHT#}<5+Gu0X@`Bk4Dc~~EWOCpP{4LO{eUnYdU?psF>kGY}&SA-umv#{_N zN_X63-_7O46x=a`QN(=OY-v!`3YH~Am<}0(*S-+}{>#OWW?hSD`aqE|3} zyZLaNs6v-2k9A`jJ8h;atBc~(Kyg16=6mNV*M7gV%!?z7nh)-!Gw)L|W4SEZDx}sdLrmOQ~?5 zkpT%C={dI6!xJ>8*m&XdtfuV&fGm@sS&Ae(cn(m%_z`J@{B$8JE2Y=9#;5y&CPK1-E}qA2z6H(o&T0Yn&&4$Rt&WH7Z-;=R_eM@z#(tyGbh zDnkh3$jf>=XnHLCQp&4ysaBDveO}=i%0aUGsW{OK)Gx?yS%`fEldnj6 zXzn3n@?`}Qq0)qc@upG~q|bH=df33ctoe=l+mHsFHEUS*`6|xMrmu006YX|WaLl8< z9p;?1KoyK>|A8-)YL1rls-miuZ!F%81m)+F(0@cd_ij8;x(HKx_^ltxXdlZlp zqjDugk~2YNk#wu|4;|1@35VBUT5S>n+FuLKdEaSxH&TjGRlX}R{GBFc6eMQGk8^sC zN{DPjO4w`e<1145i^_mcNXz4FBl`h~y^CrvK0|3-H*7m^7`>A?MyQKXcp^)`WH;6n zGe8pbGVFUJSo2h4q>5!OFf4CDXSGChR0gkq;Nx$H<|fl_#4=gA(<4kr`S_+XQe->* zu(NF->)FO`65?}+4F>;O(^tg_P;gwogno}|ZNRyA6-2+PbE3ZTzp>7-r8!VL^j4O| z(IN~&N<=owA5H%&+X1L0PfhwXm-#XYaUrf&P+!(wc$h|k^%F&rbT|P z`lShtD=i>;*p%%C-B*O22q#llBDq~FDrn-1PCWuYn!4N_EJDki#F z$N8Zsn^dzw=)mbYhSk_p&IJAfFcXk2T`Tckc_>{6%lSN-YX zi`#*!!ukqm+^y-t)&P=2^cP2XtoXE2`!SmMXBaG9dW?gP?Ho`@BHaoPf)2YYuFdfq z{#$N+ZPNRi348jL4s6i5crtUdxF!Zg=Ia!LNze9h90|+A3FqrBj1}`X#xv}U&q$o^ z3YSmBf55a&(7cM0<2E0e7_VdD2g%S+B9^3SUt_c0Ea70yZ;_X9AL=vZf={2l%7FwE z32Wzq*~fTR+2WgA73f!La9j^Ur9YoLW#|SIF9!i%) z!37|Ld+dWa2(Go=DjK9)8}=89>J%*WRxw-i52-A;bR^&nX_61%2_KF9FW=Ky#x&8$mUeX6l{kdGW8~ud zNUn_OC@zj~P^y=GTHLpd?4wi_0ARG(>s2<-sxsc1i+dX#XUweFW8U-1ylVJE0Sv_t z;5c+8jd+)&7dhVaCJ!2=S-vnX0$q}Bht6=u=;vFKLkKBX%;57gjQ8R|^KKvXSJl-4^4l+n0&{tVMtPbl;>^ki<>|Sr=!U`R$zy#U05` zelLb772*{zfJ*+}znNLmkeZLy6TG~l8&Z&qMIxc32KC z0b2M?$-UIMGW8#*R{8M67DPD`F6yoZ5Jt5Qb9WTDp&%O&ovlX`s4Sl?FWLR+8q1Ur zhLcQMk*K~X2ufD_-V#)fPl6nftBUFICfg(u{4P{_EVy{F2j0WX5#bp#!Y!nPe~%PF z%Zu#BuMJ_2tR^d4?Cca_N5(Zds}JWp@DTvOGkKc9)J2yPZARr$F4}L<%RMjfgDUmx zYq$tg)Q3(>^Rrw7*6D9oWGM;bq@g+(^Y?%Bv4^T)P*T~S6Cd!QEwgwLek^%JN9bln z1D~uigs4=n&BYi6$&TNjL^YBJsw}LV&zmbn-=%TUhym|}Zn4wQqXv{P3RIUL{cXA= zDOw9vn2h7HXi&L;VhR+kCHRYUsZ^HU3BXvh0i`&!$4e?%89sw@9W?+e{l8ki#sWDv zD|sf*LMkZuGYuGjJl4lKAwtmWDRvG4&zRaO zuL|$jvzt?ueHLZbfwnZ`ypC=>IfHoNRLB?iYaC@tqnl(zNKx}ZTf$(NV!fe2b^*!8 zUE0x3awkqfWv#-Y<5fdE^J)TO0+;WW@54C+#$HUkai81xti{+MRu{KREE3wTSsU^+ z6vLB$gf1q#?#26li#rEQ@2)Qte3N_>F8000zS*pD6fr(9GS zW2O9O&=4fgAt2jmD~yTpkOaeHT-(!C38U9Cl3*A^O;>-rt_SS0Xw?u z2MtoO5Q}9x_(Uij;#@rI3!P}C?87E-?}TGzkWvvUj{Quv(H!RTB2v5qx@Cuqw4cNTDk6d-dvr}7FUB~ z54Z!4zU&Tn;4OKEU0P_NPkMHcxzE*@4eJ{1-2Hqb4S0c!){M}LpB=6HT?-p5%7KPX zs{7jtmfOIZ;#$*-M&w6>kE&nS;Y3Rz z`}a?ot`1+!D@tMR;~=Hd0}GxiQR&!!xACeae9_z>nek}voguNECh|(;8>O}RMT}os zdcSZ*wg>e_*FFM?ulklI;^&w936ZgNB=A<+ZM67XvKXTEHp^zb>U=*=H#}iXb68ls zQTD(&{saxuQl7jt<3UD|{v0K-z{+FXr7?i2+TDe1NsQ+Tm&>$TN~}rE=cluN!_>xO z`PrCxAhT|hCmr;@x7#jMr2ZA4s2K$6okLYZf(VXxxbQ3+^D#Yo0MRjPqvQ+mY)t&R z#>f1Q)8(McQigAswKp0UZLPcQ+mL31pe>#9RMZUGd+isTU^&X5Q?s}bFuasv`_CGg zRH|mr8CJeBK5q>nOUK251s2aThc?r%Eu9<~2FhgosY3=f&7nd(4+TQw+AHpM)+bmA zo2;@zX}FC@U`UTGOm3vHf?1+;wfqGV14CQQFly8 z{QERmL#bYhH=C2p>AEhHdBIK9X+Zc9u8mhaTICEM>0^Ub+=6>}uo&Uy{Tv)Z=NKOc zSx3Tn;i&9MuLDL99S+IHgo-&v+Ea~kI5XxZOP06V;CJe{CZrJF{rD!4V6lF2nr^qrD~-jwI(}x9$a2 zBYjYIRb1m17f(jq(L6h5ITd9e**akeZoRX|JSdv2a5;-4#0ISdGmdH#A@%ftj34fM z!a;>YI=}ZQ(Ki=%Joe=TX{m(Ubn+l$;5l_7$;QlOw(=8#V5vylW1MWi3WmoIH$kE| zVv%qGJ|flqV*5c^w9!d&St3=JT1t=$n@jL!r#nkpdh-tccJrKHY6euLXSwNt>mwYrrC0dU47+2v>}vR_s95A4HVWhepefy3{n1G z4%(Ah@7B{~XM)B}R;UaL5aIq+fA0 z8-34y`CJ2`-&XP5JbuL66Tg2#_Q_`H4u!>)=rGQ#hiRhaynD58xW^3^#GLmRh!Q@rt!k5q&~ zSMxjHxkW6jst=W+jXtKxWlq7Q z6+)!FD8jb%J6G^)5%9IULyf9KUFV_tIgCbc))gEDZalb&1HNXt?H|HZo!rrUEU{CTqrGNISoDjU(ouND3piq7iJ%`!OzMJVcYc?bXw zU3~cQG1+jY+`Q^Zv)M%C(OJL5?LBguM~V^-xwqf|2MnkCf+PY3!-Y^*R!lD&LKro? zorxh+%DAhV~u8 z;0}&Y9PH5aD?5W@zdu}(?whz*pd>{r7YT&zOG&6#-+BN84Ax(z!)>BXBJ@+qsZ2HV zBES?l_wV4kqk^Qh?tL^(w$;o%bKkP$w3!tJHN=cRdroXs1MNfkJ{qnmxT(%4^on^y z)d$oT5F!Gu{m2r^ni3f}s-Yh(4#!D_q#LdclH$O-EQ{_$4#;N3+qPlU)^UzClNkJ< z`t<%oU7stt87*QnR``eEP!xBhIuE@$wz5*rSKhId!mT4>O+Rh3i9c3IJ6%oK^x_Jc ze0o^`QWl-d-@MwHB#v#?lm$sH7q@WVPv`~B`9@9x-`Gc{?V1>$`?o60y25J@*)`&u zOuZy+&lkBfMl((TL@4d4e4elyv%7gLAX&`N=p8~H&Qs?T2*^*z=KA0I`Jm11u_&We zhK7AtHHqs(a#94rRNWl9RCn3$J(CC z`_YX8mp_~JH~|QxK{LKJ1o;Eo9ys6F&?}+aooqbohx_2tjcmnLGs`8mgA|pOPJ!Pwc>`O@De_t5oZ;3a!{|0MNTyk5(;~>Id^2>-_@WG%$vokXOKa5%|GaeJCJR z`K^Cs*oZ@sAKw7w4e0gSqw^qgGdI&enjJtO@pD-*C6#)h$Nn*FhDbXV0fab74fP7w z1w){_I)qO%BWE2M>ACQh-guP$L*9z`w^%W?{S+6>YTJU_LnRbgn4z#ht|YbHFwJyB zs!i1u^VTfwjav;BXT1a(eAAdPkZr8I7gloG_88ZNhGIz%wh5loM>SXjH_#*TEbHCT z8zNkjH{~{l3CTFB#5c{j=e^W15MngFA_(W6AO@6x^}e!CP59}h7dY{uuWr^or&@k~ z*e@k_dLz(hflRnp=FpRaU9iRSQ;98r{ZpO&_g*sk^>@GRVdH3AdF*@@@OFHpAEslf zFHKD@Y?poY!$C-+({dBNYe&inGSvU3zm`PMc0s#_zha zRwMNq;3@8Zr4v#BAb#G5zXr++pjHD4q{zp5)lv#Uf4%LS${19LwDl-w0Tr`=eWe$L z*>=!`;MfOP;^9=Vsky1BdEi(0jR;JAfJ+Curk4a5$Ayjgq1Dl-Ykfd8L|6itF8f;A zEnol!s8D{myqaVtQXGvd=Xwf9xvnl5ap+%3NV!dQ+lFz}dn=?}m{qO-`ZeIMG6CV5 zY!Pe5nNAy!DCw*Ehy<)>glF0q5440v{2a1U)FRFURkiIqa0=7R1|(QBN$SlU#2pfZZOp096s39D^>vPFa0ckwVkm{T2>jxumjVq;+a{B)f)=Tt?|G<)t5QiLo~92o!Z z#i-6wKfA6(tII;xdinILL}btmr@aLnhz1$*;$E4-6$j}!Z>jWIuBV^_hg7DQW5re?qp>R;TRVH4Wk*NHvLA_FhZUP98!O*bP z4BTHYb2oi1(PIHHAmD>Lel?DW+$v<2WPB2_-n9K&aZviUW!29wfJ*6$&+leQjxlRJ zuxE528rErR0oWA$px*l2tkqk~qGL$`qCNQf2USStlNB;O$1=8Q$bdxEz(qU~KVp8v zAoX1j;#}@-knr z3 z=-MAilXxXXU-L&ww73MsZH%Ccc$u44{{&`d94*DQT;DP)8Zh}6bfZ*er^L83Ycmz9 zU(_k!;VnH(1yV-ubt9^)E%ng6Ww!=zkdt`rh-SG%zBJ1Usk0e1$c?7k--=otlQ&Y4s>(x z1ls_9HaXXY66GoOlObH~Uphub3wU;7HIV9ovcskr(A!*)dl>FMUBB5D&*wbd(m5I! z1%HzIyTb1PukpRw0M*b7j5YQFZlJ-CV+B<~(L)%{SYMbi#^;aixX7B=lsC^WHraB5 z^Y0tFD#Vt&gJL~5aKSP*it)Si{s9K!h<&WzTY0YEEF#j0XN&}`lIH38Z@|f!ulZyg z*p|mekR~xuGNeI~x@3wtb2JA$4}d<0L-#87ufqlZJiSk};GNN1KIowXFVuGh7)~D` zMAkJqx=@hjfgiEmK7vY3l=JnBzY2s?=z&tZ{EV7}Xi&e%KQ3~~gpc-@8GY9d;a~?Z zid)G2NS;+MbQ&*y@|-P&`YDBBnq($fY}K#XFNJE@s6$OjXJe}^96Mes2{R!S?JV4{;G-rx?Sn%vZ7zc@@AIK|S} z7gDk9rin|IX#5{5(5xViZPGiH}juSPoZB+S*5_*of_~64^rp zn9y9EGo@%3h>J^al?Fd)Tg$4koEQakN5EmG@{eCSpCUrTXpdH`^dE~afL%oY4j9UV zcm9|orfQAz{~0S7;aOIq1iko(jJo?I{SPaV{^-@pyy!Jxcr=$xFS45JS!R4X`)y=C zSL0xYxfcZW0p#1`dC8%vxAO9E^ zQ1iDe+Q00NmvCv65Bjb7$tVTNR)^Bu-r>+`NQnBNWZnPF@>WIwDAgL?9YRpNm?w_3 zV2(!2na()R8qr7j;ExHls;g(b&NjYs{_EAp@Lype;&)iMpDxVT5>YZE4PmsPJK-&; z9%k#e2oH;W&08iPO^b%rHbCv+)Nf+B`4HnJo~fB4?1esmB4R?3#Eswau@XwpVy&|CRLr^Od2dk33!XfbOW^<=^foj$zBgs-t39|7v~?O4*F^ zkTNuN*+`XnS9e+dh9a)~iFE-qVU<22=8JV@FQi(jY{Y(ocqG)1 zvyeiD2x*yqZ%tUZ=6nu^zc}-*Otxn+gSHG6fTr~Snm%-z0M>)5+eDf)rfz@C9RIq3 z0-vG!C4D+&R;&&FD!@pNGLaqG%P!LVTiySg&>}E*HEb^QNAJ2XHva4Smg9|tRc_(cYw`g2 zf!`3u|F8+u4Mthi+~Ouk$3I3a131TzM5btbxdg|fO4ulbGEb0TUm}JlpzR>|Ax=YnLZTLIg5xfY) z>)ewkLVMi`2c?wvH%(xvG^3Jxy0rtDQ!LwCt>lmRjB5kglKhH)uRmZK+kYH)AjI8o zRN0@6mDA0mZSj^OqW^pTR4^BC!JCvyozL}~VT!tY`a70lKct>|Sv$sA-7wg?ICwqX znXFPccV_n#qArw1zGD(6I^4pcxq1eM*`zvBPB3w0UhJX!5#ipf8A8I1UKyl<3W5Qd z5w(p|#mW%RG7wO^C?G$-t{8mlzXaI$fhwV5Uzv0(L-Y9G%_e{LjsSyrQxPB0rIU zqq|;Z;zKR2=Z6D;XIX6X_V2Lp&)n3-+mvS39at{@dO@kOKnna*QOcK<&3#e*^L%{! z=7aw!DNHQ-{9KrL#E}mcP~MlIB_RmP{Sx%m{xS3rq2F2$^qjz6R<_nlhG)>q_T!F- zb}3~gdn66l%S&b^KCzEE_oLuQ^PPslappNJWWeA(OKSlFn)@myu+2P}Hq+PzDUS+N ziA|e>*okdhnuTgq`-LZDG;P>c3R`tEpyN&rmbJGz`Syk%=gZ7C)nbUo8^EfZ8t0s! zDdCYm$Y`o!HBJuCrGgbRj~!rp)gG4P=3wAa4nE$z9XjNeQaI>~u>bB5E@I#RC`;r; zT(bpa?;*_L1QN_@-5nR0HEbcV%pf{fZNgx$ewq*i*!ng0>QTr7?CHDTZQ0WDhf`6C z&X3msp8_@^JXD5N#!CP7Eem*4&RQA%))@do(3MOew!q}TF7^a)rlEG*GIX0L)$<$0 zLA@U890ukDer!WH?6!+Q*HEFl-%&7L*#KTn3umdrYIn8W&&GQFtV+6v!->5<<17u5|jGg7l5qh z*z%60xw=TG6g--edHzu{L)^auXKn)`9?20Lg0ao-^IQ?;@|`ndy6Eej(HIJ+2AHon zwX!uwWU|Tt8+6Z|0JS3i$FGdqV-yE^Ig-~#V}WQNF#|Si{Y#!Qk%G)Zt$_VB6SZZl zd(_I%U$49Zpi;FNGXM|)Q$xV{5$OeJlVo(7vJ8N$_ZPpbz#&J(SFy;_KC^iX*mkeb zJ%eD{aE6pKcQiN2c%K+10xc*P%ldYlPfe5oUcY$q8TH zHtTNK->o>s9aJao7Jxl>hfQy#HoAKUH89swM*N`YtOi_j43+|isJN({YAu1Vv3H$= zpawtSYE6jwr$rR4)-zfwC_J7q@uT$Hw=#SoW8wm|uGsVOgKCJWA5Ks8B)ZICfNLwL zH?PBTKpR&5^d9?MQ9`oRgUA*2c!F*;z`h=k*Y;V{5CivtFPEHIM!z*0@+JMS(apMI zy*Y266B724vF`8-OtWAt<$a(Cvyw!cHLidntZ5=S!B$c9^TUlq>-&+}&y+Em-0hB^ zxLNxJE*~$G9NXtG^N*RTS2d2bV^_YX_en52m4}TBx^s>tKOWqb6uF^g3F!4Y=-tQ^ zDF4^2%<8K~_S!QDMJf4QBDS-8T=H~jVo+VxHU9DKwWky^2H0+71K5vI+Vq`$#GHj^ zmq;y2=Amd+7G>B_Ky4BEHyAF)cSFR0f!H9p-lOBMymloDm@>&(rj#7WjA1W$#*$NL z=3=xH6(nDQwd zI@#E;b0YkpAxV}M2WtHF$T*+Zk#=AtmHIPullx?0p%}u5w)X{)>dyLHh=HrY7b>g~ z1H#X5eKzK9_1T%Wzj))ydr^Se#gXFR;Liap{j!Ah#4(U@*~RtqU#HS^LMV-dF$?#X z?sDTabI#L(tk_5V65IVTNA29Tr=@TQ4aQ2qSb#DNrP{Kczm`%8dGNtl91wv~*g_p) z;s4Fg#Ptt{`Qxn9E&w*X1XiWXcawoeR==?Rd(j#P`zOvFH3|LWnV)S zvSi6*E6L7S#%?TS8EdGNr6J0$?EBdF8C%(986nA@Eh1y=lr0jG_c?z3miLeMkM|#! zYpyfr9OpdG{oMEce7_$?33W!q!pL8QURsBHoae)#P4tgvc$oIXrV|H8j_DyIHW*7C zF7&21aX1AiC{9w}qOKlM9Vn#Jp5;@76DyA~PMt$#yHYu%oob_-QQ0k_ld$aE5rp+Em+UpIs66Mm9T{qKzbXp^JzY#-H; zhWVX-xv&g0Shl6X&ssfxyPF&nif80<^`6cZjUM^ksWIH{piA2`jmj+p@msFewCcB} z{)$38y@Yc9*{=;=-#0s`t9jkJZi@=1-sdy9|7aklkeaE3jVbpmN*Dj|rr{gy&R18S zMQ6`wW@$c%kY{Vzj~wRyLAn}hHFih2quqhcjyAlRuJ$TXjj+a@6crujwCU?EC++^y zG-OadQCU=%*~3pJ&Z}>c51!Crqj&5wtd zyWWXLBQVsL@>?>btxascg*21hizJxj-C5M-E-3UiFkp4-jP^_}a zSAmtCEhCtV$nxcjBd+AM{7@RUG15) zj=B4q${{hV+hxZRVdoYuGQ3l7^D^WNRpk=5gX^WWUuO&s3~Wz+{cD`kH@xUQ5%Ym# zrJ*U>|JDh?b`zcR(e3t7h12~5`*P^;4~X=&hj(_%opziX^{wB%y6WMJV*g59gbU{MvK;HU~(2Hl-=;bf;Qo6 zRJj8j`7YuOyuI9=L^cU;?KTGdDEAlY^{jIXlR&Zs6USNq-k#cU&@T)o6l%P_M)4s= zCFt>LpMJ`4ru`BRC_#Yc^T?kGg*c<-_VP1gaVX>}wD>`Ne4wTI;BC?b7R;+h&Rpno zAkf2|wjc}*&+SQq+76WYz{<G*1-ASoGn&|1m^7Ae zfvUYdp2_4qDva}iR(o=O_@YU$SO-jQrwpWa!+h&EwVJqE}LdW8HD*c|fdZ<}>3Ccq~f) zkYFpOV2V*_UAsLr_e4qWa?}nlZ>b-vDhE?eAq`(xVp;m%>*AK)KconZuh738^?x&L z4vd7L){+x&5pHMS+k1I5v{Dm$D@woB1j32`hlSQNE*M&?Z90m`}Hcmi^rJz zE}ZPaQ$*ZO;7zK8HPYNC*7GTRO&pB{Z~<6Tm#XMA#Av_s-MJ9G6y&gEV)Lx(&;mUs%rg&JbgSlyL_xYEFQO8Zmp z!Wx%@!!Ogf0nsn0K?f?Fz=Sz^+nc06ea57DUCV}He*1ubl_wF zsvRH@Fpj<4Bt)9Q27$XGPsypzihn>{hRi4&vsJH|vqud=wQ(!I){LLYMe zV@mIwnV`Rqxzne=$^de>Iuu28C)=`I{gSx-5kkol-=J3gf3%v~P^8iLkl91aY~#iV zp2X4kIO<&4vOQZdqJcZ0i{zbJ^I6`6o2t9*fDL;wj7YDQ>{=cyr$N(#9+gK`y3g19 zus@E?=Q30QCeWzuCJP=frsqn>eieHrp|o@TS4_0$hWOK9ARD){w`7yFok|U|O)kW| z)2of+gzR5I*rE`!4(Yb*l(81)@}&oJL&YnMFgpiflwc3CE;i%1F^LgIDSbxg11{l( zjzr@L(mO;N-Mo&-K7-A64ibyA8%D>Jk9W0QvptGvZVO zAc~p~L6S~2f-gtQkk)e%={Ru=qBxkp>gvFzcM#loQy+7_LC-C0&c)hvg%(4}PI}uY zmkNiGZoQcVL#1c1`G=KE&3A@B`1os#wt+#oiPx7GG`TI0`?9w%SyC+zbkHr>f=Xd) zMuP|TEyJOY7(tW&@nq$?6wj>fkQJTwK8iZa4NE0lc-oEqwdTh0FNvUV=ei+$lFuUe z+wEGGWEe@cAXjLu;`K+Ekq%;;?{9PW_Ft8zsT@WnS@HTOLt8)RUq%5V7jVq}<=JV} z)t!P@zHrhjyX(=Xu#HiO689eVa{ewjv=jSOy`CCiWC7Thvb*0cM9LOv9jn`h^7e){D zLfBV6p=tAkrMFIA;PqOi$W_zTY|}E}P0DDo-DqPj(3WJi0MZ*?ez>1hxdEAYETWx2 ze>^`TL_Am`0pV5ZSTpj8muN~DpoC}55Q1t0OvrqcEyDmRl? zigy8mv4J> zHk-629vXq*$pRZpc)ktfI7L``obb?0dw6Ld=8ky2Q0F34A_~qYI_5wqu&kgydJ**ji6w*IW*?>&Y0$CMjkI`-ylo`cNCK#R zxL0NUAdZ0YodpWdJy-k@J0=0VfqFIwc7q}_fEpu@E$u|$$l>gICnro$X?d;=DJ5(- zY1MMcOHIU-b2%{gs_#&1R*`Qheq`*E7=7)Lpk?n)^Wi}^ z8MKZ42dYxqGq`juwg$hrWQtaDi9r0z7jgRju)b#&e76OB_Z`2b`AMXv&Asv0f9wUw z)Pg}5K3D&Zhb7XRKs(m?dp_3L8nfx5Z;pW#h6QShO$LZT)s68jXH7YN|g6g9o|H|4px3*|V=#u)N}iKTgqY&&9en z>tXKl718u!&3p7sCAX1A;h)m}6~4+#deUx_p`QvRU@4of+1-Co&s}0R@(wnKt6j$8 z@<~Ra;PBsffqsQwD#(ym40LW7G?bf8NZ_|r`n!J*(GrVA7<9hXSx3qlQrEq_#4LWd zx!2Mt%gMntc5^r$pT~Qb_1a(Cg#Tp|y3PpTir==sO|63l#;dI59$deHOHVL|cj1^Y zLv1>ziQfQ&y{rFTd~xVV?EGE@c$%q}vwP*}i>(F&J2e2{c}i-c8~`Z4H6Q*SR!Rq` zQ`0O(91HGkhU4**W(o4DOgi3*N5H`%d$xc5%|7Z%m^RW#;1Dy^d>hpy9D?5JjT5hpAd!8aCb~nm-y#vILvS zXo*Px!)<^Flq5Ttk#dS16saCTxPFnlBtr0O!}ngmpEMl<@+8!#N;!GGDd0~UBHi0j zH9=5PHoi_f*ER{ta_cAMZi(cjD>+Lis`0ZzFq&g}J{BSD{80`5Dps4!TqvX=c&sot{5R(z#**|*A@KDc20040eCX`}eA- zpDu}1cfsiQIs;+PK1JWyAHOj_jQh?Ylu=M5vJ~ zGP3NzI5c=?TkW8Sw#haY$UxBG5oiUu?x4WY$ypoW-?#KKtT#_ zq;J`!*$mil5huI>s13I^O} zvzofy`PA*r#e%9Y;yIyMiLQZ>6^Wd(fJ-+XuOf%HQ>0ui8Xxd|dHscfrclHS))_*H z2|IASh0!dIQI9!UG9-tYzbB7dJ{2vE`)TfR-QZWaNRu%Krc@(5%F=5tzK4Q&Q`_T3 z{aN$g0{QJC1pK<~`@ZVArKN>S+pKo0Lb&kwDx_#;UT8jU44gW>{ClrVnHbU3sU**G zXi04G;^C^c-yUL@1XApfo{`nzTn-Z~h0P~f>rWZGM%Uk(8E(gYJ|kWSEOJ$ZARBmB zf!Y#ykT37Zf4;VmZ;1%?Sa#~0Ff*h^c9D~gC)u-njtIfC`~I93a1fTk&hV^R1)Y{$ zEQylNc+h)&$-ln~;jy@785$tLEt#Z{x@&GYA|?XfQQYKz5H!k z%-W+OSxH}9g;c@&aIQ_QM+N9CwS}7B)Kl}gbvgPE(pQ;?TD|9Nrlo})j62sa<$K@B zr5!~+=!s%QcMlGh7d2F{yjiWs?NQdVNg66xhX$lqgHWf_l6JLO9`Q)ML-Olo-6%aL zd;5IM56^|7_V*rLju{Wh?lFU9E8zHyiJj_?$Pm22$WS@@pzu^E`j;mQ%Z1gMf&3kv z`%}txI<+k^;7(>^uZb}AyY05F52&7;5M>%#fM#~9HK1g#Pf%xs0zCKD7=UrhvknDU z07mfiD}bT)Tn+4NgK)&ofQp@QYPofcq#D0&3^3iYJ8w~U!3od+?_AnD<0(k&H9z{LdbAx7*EHth98rPFQkyPKz008fm3I#L_0)TS>W_Qqn#ZOIe zg3LZ}*!Eba{Ddb%E#QzEw?AmGn`lnz?p)wa-GNZyzsCnvSU;0s*|WV@0GoT~6K7o1Z2Gyo{MJ3Q%r}79Jn1sa01^tYh&6%rLTd|le@}l#TXK|dPpPJcTmW2f|Ke5$ zNezG*s^53aBWJ${$(Xp$$(G7vsiLv(=lm%+3b9>!!Uh6{tl!3b)*7Y8Y|b0B$bv+# zX3UNieNnPFAzg>xx+MR-V)AEz<+bv6auuxkdepk+`gw636T!oeOmHZ_x#*CXIiM%LNu0rXshguvNKlBCR_HtD5_GJm9x(W}3Zf4nm zg(8{k)l%7BMW@Nl)rt{1!_X%}iCH|~0Iz*7YaE3Bs=}O+3!^Hg2I%{KcchzL{8Qm8 zk)Ng#3i!^zNsJs2XpfIF4klIn*JXfLORzKzAneQ=RRq5dX)F(sNTGV!Jj_=VleZ>G zOh0S(z9vaC^+8aK=KDrHq=1L+zE5i z3FsTaMKVB0y6pVMlikFcQtvAH=upQ{h{EtnvDSr7+{j35^N)iyzvl6>Gy}$}@thNR z6a}MKUKkB^NGyeu3v_4&W64U#bMZZkRZc=~XfM?#QNj#S&)GrhWX?j*Dd+<~7^F3p zsj7X=HGVZ6-^n>@0+?6XW^)K;Q|ikpIqPh}hMzImC&Avgv$cF`UXjO;e{JD?=J^17 z#SMU{we<;=Ow}WE80`5?cSce$wiuqJe$}WXXE1_(AC*~KsU~Om|9^gE18}p2P9!dY zQ#t#$_d?iwh^^ngT90l4Gpd})_$IOXZy>+A-p*o9r0|JE=*rIiv^7t2H3RreVrvil Z;tnwK6mh2Y;2#GZQ&WQ9F1=+H@IR(MV37a- literal 24187 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 diff --git a/anyplotlib/tests/test_plot2d/test_colorbar_gap_scalebar.py b/anyplotlib/tests/test_plot2d/test_colorbar_gap_scalebar.py new file mode 100644 index 00000000..0fe33779 --- /dev/null +++ b/anyplotlib/tests/test_plot2d/test_colorbar_gap_scalebar.py @@ -0,0 +1,200 @@ +""" +Colorbar gap and scale-bar colours. + +The colorbar strip used to be drawn 2 px from the image edge, and most plots +have no ``colorbar_label`` — so the label gutter that would otherwise separate +them is zero-width and the strip reads as part of the image. There is now a +real gap, taken out of the image width so the strip can never be pushed off +the panel, and configurable per panel. + +The automatic scale bar was hardcoded white-on-a-dark-pill, which is unreadable +over a light image and cannot be matched to a house style. +""" +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl + +FIG_W, FIG_H = 400, 300 + + +def _img_fig(**calls): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + for name, arg in calls.items(): + getattr(plot, name)(arg) + return fig, plot + + +class TestColorbarPadApi: + def test_defaults_to_none(self): + _, plot = _img_fig() + assert plot._state["colorbar_pad"] is None + + def test_setter_stores_value(self): + _, plot = _img_fig() + plot.set_colorbar_pad(12) + assert plot._state["colorbar_pad"] == 12.0 + + def test_none_restores_default(self): + _, plot = _img_fig() + plot.set_colorbar_pad(12) + plot.set_colorbar_pad(None) + assert plot._state["colorbar_pad"] is None + + def test_negative_is_clamped(self): + _, plot = _img_fig() + plot.set_colorbar_pad(-5) + assert plot._state["colorbar_pad"] == 0.0 + + def test_reaches_the_state_dict(self): + _, plot = _img_fig() + plot.set_colorbar_pad(9) + assert plot.to_state_dict()["colorbar_pad"] == 9.0 + + +class TestColorbarGapRendering: + """The gap is measured on the rendered canvas, not asserted in Python.""" + + def _strip_left_edge(self, take_screenshot, pad=None): + """Return the x of the leftmost colorbar-strip pixel, and of the image's + right edge, from a rendered screenshot.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + # A bright uniform image so the image area is easy to segment. + plot = ax.imshow(np.ones((32, 32), dtype=np.float32), cmap="gray") + plot.set_colorbar_visible(True) + if pad is not None: + plot.set_colorbar_pad(pad) + img = take_screenshot(fig)[..., :3].astype(int) + row = img[img.shape[0] // 2] + # Non-background pixels along the middle row. + bg = img[2, 2] + ink = np.where(np.abs(row - bg).sum(axis=-1) > 30)[0] + assert ink.size, "nothing rendered" + # The image block is the long run; the strip is the run after the gap. + gaps = np.where(np.diff(ink) > 1)[0] + assert gaps.size, "image and colorbar strip are not separated" + return int(ink[gaps[-1]]), int(ink[gaps[-1] + 1]) + + def test_there_is_a_gap_at_all(self, take_screenshot): + img_right, strip_left = self._strip_left_edge(take_screenshot) + assert strip_left - img_right >= 4, ( + f"expected a visible gap, image ends at {img_right} and the strip " + f"starts at {strip_left}" + ) + + def test_pad_widens_the_gap(self, take_screenshot): + d_default = np.subtract(*reversed(self._strip_left_edge(take_screenshot))) + d_wide = np.subtract(*reversed(self._strip_left_edge(take_screenshot, pad=20))) + assert d_wide > d_default + 5 + + def test_strip_stays_inside_the_panel(self, take_screenshot): + """The gap comes out of the image width, so a big pad must not push + the strip off the right edge.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.ones((32, 32), dtype=np.float32), cmap="gray") + plot.set_colorbar_visible(True) + plot.set_colorbar_pad(40) + img = take_screenshot(fig)[..., :3].astype(int) + row = img[img.shape[0] // 2] + bg = img[2, 2] + ink = np.where(np.abs(row - bg).sum(axis=-1) > 30)[0] + assert ink.size, "the colorbar strip was pushed off the panel" + assert ink[-1] < img.shape[1] - 1 + + +class TestScalebarStyleApi: + def test_defaults_are_none(self): + _, plot = _img_fig() + assert plot._state["scalebar_color"] is None + assert plot._state["scalebar_bgcolor"] is None + + def test_sets_color(self): + _, plot = _img_fig() + plot.set_scalebar_style(color="black") + assert plot._state["scalebar_color"] == "black" + + def test_sets_bgcolor(self): + _, plot = _img_fig() + plot.set_scalebar_style(bgcolor="none") + assert plot._state["scalebar_bgcolor"] == "none" + + def test_partial_update_leaves_the_other(self): + _, plot = _img_fig() + plot.set_scalebar_style(color="red", bgcolor="white") + plot.set_scalebar_style(color="blue") + assert plot._state["scalebar_bgcolor"] == "white" + + def test_reaches_the_state_dict(self): + _, plot = _img_fig() + plot.set_scalebar_style(color="#123456") + assert plot.to_state_dict()["scalebar_color"] == "#123456" + + +class TestScalebarRendering: + # The scale bar sits 12 px in from the image's bottom-right corner. For a + # 400x300 figure with physical axes the image spans x 66..396, y 20..266 in + # screenshot coords (GRID_PAD=8 plus PAD_L/PAD_T), so this box contains the + # bar and nothing else. + SB_BOX = (slice(200, 266), slice(280, 396)) + + @staticmethod + def _white_image_fig(**style): + """A figure whose image renders uniformly WHITE under the scale bar. + + The pill is ``rgba(0,0,0,0.60)`` — translucent, so it can only be + measured against what is underneath. Explicit ``vmin``/``vmax`` are + what make the image white: passing a uniform array instead would + normalise vmin == vmax straight to black, and passing a ramp leaves + mid-grey data in the box that is indistinguishable from the pill. + """ + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + data = np.full((32, 32), 1.0, dtype=np.float32) + plot = ax.imshow(data, cmap="gray", vmin=0.0, vmax=1.0, + axes=[np.linspace(0, 100, 32)] * 2, units="nm") + if style: + plot.set_scalebar_style(**style) + return fig + + def _scalebar_pixels(self, take_screenshot, rgb, **style): + """Count pixels of a colour in the scale-bar box.""" + img = take_screenshot(self._white_image_fig(**style))[..., :3].astype(int) + box = img[self.SB_BOX[0], self.SB_BOX[1]] + return int((np.abs(box - np.array(rgb)).sum(axis=-1) < 40).sum()) + + def _pill_pixels(self, take_screenshot, **style): + """Count pill pixels: neutral grey, clearly darker than the white data. + + Over white the translucent pill renders mid-grey (~102 per channel), + never black. Requiring the three channels to match excludes a + coloured bar; the upper sum bound excludes the white image. + """ + img = take_screenshot(self._white_image_fig(**style))[..., :3].astype(int) + box = img[self.SB_BOX[0], self.SB_BOX[1]] + neutral = (box.max(axis=-1) - box.min(axis=-1)) < 12 + midtone = (box.sum(axis=-1) > 100) & (box.sum(axis=-1) < 600) + return int((neutral & midtone).sum()) + + def test_default_draws_a_pill(self, take_screenshot): + """Baseline for the tests below.""" + assert self._pill_pixels(take_screenshot) > 100 + + def test_color_recolours_the_bar(self, take_screenshot): + red = self._scalebar_pixels(take_screenshot, (255, 0, 0), color="#ff0000") + assert red > 0, "scalebar_color did not reach the rendered bar" + + def test_default_bar_is_not_red(self, take_screenshot): + """Contrast case, so the test above cannot pass by accident.""" + assert self._scalebar_pixels(take_screenshot, (255, 0, 0)) == 0 + + def test_bgcolor_none_drops_the_pill(self, take_screenshot): + """The dark pill is the thing that looks wrong on a light image.""" + with_pill = self._pill_pixels(take_screenshot) + without = self._pill_pixels(take_screenshot, color="#ff0000", + bgcolor="none") + assert without < with_pill * 0.2, ( + f"bgcolor='none' must remove the pill " + f"({without} pill px vs {with_pill} with it)" + ) diff --git a/upcoming_changes/42.bugfix.rst b/upcoming_changes/42.bugfix.rst new file mode 100644 index 00000000..8fdb08be --- /dev/null +++ b/upcoming_changes/42.bugfix.rst @@ -0,0 +1,10 @@ +The colorbar strip is no longer drawn flush against the image. It sat 2 px +from the image edge, and most plots have no ``colorbar_label``, so the label +gutter that would otherwise separate them is zero-width and the strip read as +part of the image. There is now a 6 px gap, taken out of the image width so +the strip can never be pushed off the panel, and settable per panel with +:meth:`~anyplotlib.Plot2D.set_colorbar_pad`. + +.. note:: + This shifts every colorbar plot 4 px — the ``imshow_labels`` visual + baseline was regenerated to match. diff --git a/upcoming_changes/42.new_feature_7.rst b/upcoming_changes/42.new_feature_7.rst new file mode 100644 index 00000000..413fd92c --- /dev/null +++ b/upcoming_changes/42.new_feature_7.rst @@ -0,0 +1,5 @@ +:meth:`~anyplotlib.Plot2D.set_scalebar_style` recolours the automatic scale +bar. It was hardcoded white-on-a-translucent-dark-pill, which is unreadable +over a light image and could not be matched to a house style. ``color`` sets +the bar and its label; ``bgcolor`` sets the pill, or ``"none"`` to drop the +pill entirely. From 2510e83c33240a663bbaf4de3e7d9fff2e1d24b6 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 26 Jul 2026 16:42:23 -0500 Subject: [PATCH 08/12] feat(layout): plot_box / data_to_display, and fix stale export state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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__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) --- anyplotlib/_base_plot.py | 201 ++++++++++++++++++ anyplotlib/_repr_utils.py | 12 +- anyplotlib/figure/_figure.py | 26 ++- .../test_embed/test_export_widget_sync.py | 92 ++++++++ .../test_layouts/test_coord_conversion.py | 181 ++++++++++++++++ upcoming_changes/42.bugfix_2.rst | 6 + upcoming_changes/42.new_feature_8.rst | 14 ++ 7 files changed, 530 insertions(+), 2 deletions(-) create mode 100644 anyplotlib/tests/test_embed/test_export_widget_sync.py create mode 100644 anyplotlib/tests/test_layouts/test_coord_conversion.py create mode 100644 upcoming_changes/42.bugfix_2.rst create mode 100644 upcoming_changes/42.new_feature_8.rst diff --git a/anyplotlib/_base_plot.py b/anyplotlib/_base_plot.py index 214470f9..15576253 100644 --- a/anyplotlib/_base_plot.py +++ b/anyplotlib/_base_plot.py @@ -129,6 +129,207 @@ def _push(self) -> None: self._state["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] self._fig._push(self._id) + # ── geometry / coordinate conversion ────────────────────────────────── + # + # These mirror the layout constants and letterbox math in figure_esm.js + # (the PAD_* block near the top, and _imgFitRect). They are here so that + # callers doing display-space work — pixel-sized handles, hit-test + # tolerances, screenshot-driven tests — do not each have to re-derive the + # renderer's layout in their own code and then drift from it. + + #: Plot-area padding in CSS px: (left, right, top, bottom). + #: Matches ``PAD_L``/``PAD_R``/``PAD_T``/``PAD_B`` in ``figure_esm.js``. + PLOT_PADDING = (58, 12, 12, 42) + + def _panel_size(self) -> tuple[float, float]: + """(panel_width, panel_height) in CSS px, from the figure's layout.""" + import json + + if self._fig is None: + raise RuntimeError("panel is not attached to a figure") + try: + layout = json.loads(self._fig.layout_json) + spec = next(s for s in layout["panel_specs"] if s["id"] == self._id) + except (AttributeError, KeyError, StopIteration, ValueError) as exc: + raise RuntimeError(f"no layout entry for panel {self._id!r}") from exc + return float(spec["panel_width"]), float(spec["panel_height"]) + + def _pad_top(self) -> float: + """Title-strip height. Mirrors ``_padT`` in figure_esm.js.""" + pad_t = float(self.PLOT_PADDING[2]) + title = self._state.get("title") + if not title: + return pad_t + size = float(self._state.get("title_size") or 11) + has_tex = "$" in str(title) + if size <= 11 and not has_tex: + return pad_t + import math + + return max(pad_t, math.ceil(size * 1.3) + (4 if has_tex else 2)) + + def plot_box(self) -> dict: + """Return the panel's plot area in CSS pixels. + + Returns + ------- + dict + ``{"x", "y", "width", "height"}`` — the drawable rectangle + relative to the panel's top-left corner. On a 2-D image panel it + is the *letterboxed image* rather than the whole padded area, + because that is what image coordinates actually map onto. + + Raises + ------ + RuntimeError + If the panel is not attached to a figure, so it has no layout. + + Notes + ----- + Computed from the figure's own layout, so it is exact for the sizes + the renderer was given. A browser resized without a re-layout will + disagree. Zoom and pan are *not* folded in — this is the box, not + the visible data window; :meth:`data_to_display` accounts for them. + """ + left, right, top, bottom = self.PLOT_PADDING + pw, ph = self._panel_size() + pad_t = self._pad_top() + + # A 2-D panel drops the left/right/bottom gutters when it has no + # physical axes to label — the image then fills the panel width. + # 1-D panels always draw axes. Mirrors `hasPhysAxis` in the JS. + iw = self._state.get("image_width") + ih = self._state.get("image_height") + is_image = bool(iw and ih) + has_axes = (not is_image) or bool( + self._state.get("has_axes") or self._state.get("is_mesh") + ) + + x = float(left) if has_axes else 0.0 + y = float(pad_t) + width = max((pw - left - right) if has_axes else pw, 1.0) + height = max(ph - pad_t - (bottom if has_axes else 0.0), 1.0) + + # The colorbar strip and its gap come out of the image width. + if self._state.get("show_colorbar") and not self._state.get("is_rgb"): + label = self._state.get("colorbar_label") + label_w = round((self._state.get("colorbar_label_size") or 10) + 8) \ + if label else 0 + pad = self._state.get("colorbar_pad") + gap = 6.0 if pad is None else max(0.0, float(pad)) + width = max(width - (16 + label_w) - gap, 1.0) + + if is_image: + # Images are drawn "contain" — see _imgFitRect. + scale = min(width / float(iw), height / float(ih)) + fit_w, fit_h = float(iw) * scale, float(ih) * scale + x += (width - fit_w) / 2.0 + y += (height - fit_h) / 2.0 + width, height = fit_w, fit_h + + return {"x": x, "y": y, "width": width, "height": height} + + def _visible_image_window(self) -> tuple[float, float, float, float]: + """(src_x, src_y, vis_w, vis_h) of the visible image region, in image px. + + Mirrors the zoom/pan window in ``_imgToCanvas2d``. + """ + iw = float(self._state.get("image_width") or 1) + ih = float(self._state.get("image_height") or 1) + zoom = float(self._state.get("zoom") or 1.0) + cx = float(self._state.get("center_x", 0.5)) + cy = float(self._state.get("center_y", 0.5)) + if zoom < 1.0: + # Zoomed out: the whole image, drawn smaller and centred. The box + # shrinks rather than the source window growing. + return 0.0, 0.0, iw, ih + vis_w, vis_h = iw / zoom, ih / zoom + src_x = max(0.0, min(iw - vis_w, cx * iw - vis_w / 2.0)) + src_y = max(0.0, min(ih - vis_h, cy * ih - vis_h / 2.0)) + return src_x, src_y, vis_w, vis_h + + def data_to_display(self, points): + """Convert data coordinates to CSS pixels within the panel. + + Parameters + ---------- + points : array-like + A single ``(x, y)`` pair or an ``(N, 2)`` sequence of them. + + On a 1-D panel these are axis values. On a 2-D panel they are + **image-pixel coordinates** — the same space marker ``offsets`` + and widget positions use — where integer *i* means the *centre* + of pixel *i*, not its leading edge. + + Returns + ------- + numpy.ndarray + Same shape as *points*, in panel pixels with the origin at the + panel's top-left corner and y increasing downwards. Zoom and pan + are accounted for. + """ + import numpy as np + + pts = np.atleast_2d(np.asarray(points, dtype=float)) + if pts.shape[-1] != 2: + raise ValueError(f"points must have shape (N, 2); got {pts.shape}") + box = self.plot_box() + + if self._state.get("image_width") and self._state.get("image_height"): + zoom = float(self._state.get("zoom") or 1.0) + src_x, src_y, vis_w, vis_h = self._visible_image_window() + w = box["width"] * (zoom if zoom < 1.0 else 1.0) + h = box["height"] * (zoom if zoom < 1.0 else 1.0) + x0 = box["x"] + (box["width"] - w) / 2.0 + y0 = box["y"] + (box["height"] - h) / 2.0 + px = x0 + (pts[:, 0] + 0.5 - src_x) / vis_w * w + py = y0 + (pts[:, 1] + 0.5 - src_y) / vis_h * h + else: + xlo, xhi = self.get_xlim() + ylo, yhi = self.get_ylim() + px = box["x"] + (pts[:, 0] - xlo) / ((xhi - xlo) or 1.0) * box["width"] + # Screen y grows downwards, data y upwards. + py = box["y"] + ( + 1.0 - (pts[:, 1] - ylo) / ((yhi - ylo) or 1.0) + ) * box["height"] + + out = np.column_stack([px, py]) + return out if np.ndim(points) == 2 else out[0] + + def display_to_data(self, points): + """Convert CSS pixels within the panel to data coordinates. + + The inverse of :meth:`data_to_display`; same argument shapes and the + same per-panel-kind coordinate meaning. + """ + import numpy as np + + pts = np.atleast_2d(np.asarray(points, dtype=float)) + if pts.shape[-1] != 2: + raise ValueError(f"points must have shape (N, 2); got {pts.shape}") + box = self.plot_box() + + if self._state.get("image_width") and self._state.get("image_height"): + zoom = float(self._state.get("zoom") or 1.0) + src_x, src_y, vis_w, vis_h = self._visible_image_window() + w = box["width"] * (zoom if zoom < 1.0 else 1.0) + h = box["height"] * (zoom if zoom < 1.0 else 1.0) + x0 = box["x"] + (box["width"] - w) / 2.0 + y0 = box["y"] + (box["height"] - h) / 2.0 + dx = (pts[:, 0] - x0) / (w or 1.0) * vis_w + src_x - 0.5 + dy = (pts[:, 1] - y0) / (h or 1.0) * vis_h + src_y - 0.5 + else: + xlo, xhi = self.get_xlim() + ylo, yhi = self.get_ylim() + dx = xlo + (pts[:, 0] - box["x"]) / (box["width"] or 1.0) \ + * ((xhi - xlo) or 1.0) + dy = ylo + ( + 1.0 - (pts[:, 1] - box["y"]) / (box["height"] or 1.0) + ) * ((yhi - ylo) or 1.0) + + out = np.column_stack([dx, dy]) + return out if np.ndim(points) == 2 else out[0] + def set_tick_label_size(self, size: float) -> None: """Set the font size of the tick (axis number) labels in CSS pixels. diff --git a/anyplotlib/_repr_utils.py b/anyplotlib/_repr_utils.py index f9d0b172..2ebd5110 100644 --- a/anyplotlib/_repr_utils.py +++ b/anyplotlib/_repr_utils.py @@ -35,7 +35,17 @@ # --------------------------------------------------------------------------- def _widget_state(widget) -> dict: - """Return a {name: value} dict of every synced traitlet.""" + """Return a {name: value} dict of every synced traitlet. + + A widget that keeps state outside its traits between renders may define + ``_sync_for_export()`` to reconcile it first. ``Figure`` uses this to fold + widget geometry — pushed to JS as targeted events that never touch the + panel traits — back in, so a snapshot captures widgets where they are + rather than where they were created. + """ + sync = getattr(widget, "_sync_for_export", None) + if callable(sync): + sync() state: dict = {} for name, trait in widget.traits(sync=True).items(): if name.startswith("_"): diff --git a/anyplotlib/figure/_figure.py b/anyplotlib/figure/_figure.py index 8d3306f6..c6335a7e 100644 --- a/anyplotlib/figure/_figure.py +++ b/anyplotlib/figure/_figure.py @@ -786,12 +786,36 @@ def figure_markers(self) -> list: return [dict(m) for m in self._figure_markers] def _push_widget(self, panel_id: str, widget_id: str, fields: dict) -> None: - """Send a targeted widget-position update to JS (no image data).""" + """Send a targeted widget-position update to JS (no image data). + + This writes ``event_json`` only — deliberately, because re-serialising + a whole panel (image bytes included) on every drag frame is exactly + what this path exists to avoid. The consequence is that + ``panel__json`` carries stale widget geometry between plot-level + pushes; :meth:`_sync_for_export` reconciles it before any snapshot. + """ payload = {"source": "python", "panel_id": panel_id, "widget_id": widget_id} payload.update(fields) self.event_json = json.dumps(payload) + def _sync_for_export(self) -> None: + """Refresh every panel trait so a snapshot sees current widget state. + + Widget moves reach JS as targeted ``event_json`` updates that never + touch the panel traits (see :meth:`_push_widget`), so ``save_html`` / + ``to_html`` / ``figure_state`` would otherwise capture every widget at + the position it was *created* at rather than where it now is. Python + holds the authoritative geometry — a JS-side drag writes back through + ``_dispatch_event`` — so re-pushing each panel is enough to reconcile. + + Called from ``_repr_utils._widget_state``, the one chokepoint every + export path goes through. The live Jupyter path deliberately keeps + using targeted pushes and does not pay this cost. + """ + for panel_id in list(self._plots_map): + self._push(panel_id) + def _push_panel_fields(self, panel_id: str, fields: dict) -> None: """Apply a small set of changed *fields* to a panel, then push once. diff --git a/anyplotlib/tests/test_embed/test_export_widget_sync.py b/anyplotlib/tests/test_embed/test_export_widget_sync.py new file mode 100644 index 00000000..aeb323b9 --- /dev/null +++ b/anyplotlib/tests/test_embed/test_export_widget_sync.py @@ -0,0 +1,92 @@ +""" +Snapshots must capture widgets where they *are*. + +``Widget.set()`` reaches JS through ``Figure._push_widget``, which writes only +the ``event_json`` trait — re-serialising a whole panel (image bytes included) +on every drag frame is the cost that path exists to avoid. The side effect was +that ``panel__json`` kept the geometry from widget-creation time, so +``save_html`` / ``to_html`` / ``figure_state`` snapshotted a widget at its +original position no matter how far it had since moved. + +``Figure._sync_for_export()`` reconciles the panel traits, and every export +path reaches it through ``_repr_utils._widget_state``. +""" +from __future__ import annotations + +import json + +import numpy as np + +import anyplotlib as apl +from anyplotlib.embed import figure_state, to_html + + +def _fig_with_widget(): + fig, ax = apl.subplots(1, 1, figsize=(300, 300)) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + widget = plot.add_widget("rectangle", x=2, y=2, w=4, h=4) + return fig, plot, widget + + +def _panel_widgets(state, plot): + return json.loads(state[f"panel_{plot._id}_json"])["overlay_widgets"] + + +class TestFigureState: + def test_moved_widget_is_current(self): + fig, plot, widget = _fig_with_widget() + widget.set(x=20, y=25) + got = _panel_widgets(figure_state(fig), plot)[0] + assert got["x"] == 20 and got["y"] == 25 + + def test_resized_widget_is_current(self): + fig, plot, widget = _fig_with_widget() + widget.set(w=17, h=19) + got = _panel_widgets(figure_state(fig), plot)[0] + assert got["w"] == 17 and got["h"] == 19 + + def test_unmoved_widget_still_captured(self): + fig, plot, widget = _fig_with_widget() + got = _panel_widgets(figure_state(fig), plot)[0] + assert got["x"] == 2 and got["y"] == 2 + + def test_removed_widget_is_gone(self): + fig, plot, widget = _fig_with_widget() + widget.remove() + assert _panel_widgets(figure_state(fig), plot) == [] + + def test_notify_false_move_is_also_captured(self): + """A silent Python move must not be silent to the snapshot.""" + fig, plot, widget = _fig_with_widget() + widget.set(_notify=False, x=13) + assert _panel_widgets(figure_state(fig), plot)[0]["x"] == 13 + + def test_every_panel_is_refreshed(self): + fig, axs = apl.subplots(1, 2, figsize=(400, 200)) + p0 = axs[0].imshow(np.zeros((16, 16), dtype=np.float32)) + p1 = axs[1].imshow(np.zeros((16, 16), dtype=np.float32)) + w0 = p0.add_widget("circle", cx=1, cy=1, r=1) + w1 = p1.add_widget("circle", cx=1, cy=1, r=1) + w0.set(cx=9) + w1.set(cx=11) + state = figure_state(fig) + assert _panel_widgets(state, p0)[0]["cx"] == 9 + assert _panel_widgets(state, p1)[0]["cx"] == 11 + + +class TestHtmlExport: + def test_moved_widget_reaches_the_html(self): + fig, plot, widget = _fig_with_widget() + widget.set(x=23.5) + html = to_html(fig) + assert "23.5" in html, "the moved position never reached the HTML" + + def test_stale_position_is_not_in_the_html(self): + """The regression: the creation-time geometry used to be what shipped.""" + fig, ax = apl.subplots(1, 1, figsize=(300, 300)) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + widget = plot.add_widget("rectangle", x=3.25, y=3.25, w=4, h=4) + widget.set(x=21.75, y=21.75) + html = to_html(fig) + assert "3.25" not in html, "the stale creation position is still in the export" + assert "21.75" in html diff --git a/anyplotlib/tests/test_layouts/test_coord_conversion.py b/anyplotlib/tests/test_layouts/test_coord_conversion.py new file mode 100644 index 00000000..c48297c7 --- /dev/null +++ b/anyplotlib/tests/test_layouts/test_coord_conversion.py @@ -0,0 +1,181 @@ +""" +plot_box() / data_to_display() / display_to_data(). + +Anything doing display-space work — pixel-sized handles, hit-test tolerances, +screenshot-driven tests — previously had to re-derive the renderer's layout +(the ``PAD_*`` constants and the ``_imgFitRect`` letterbox math) in its own +code, and then keep it in step with ``figure_esm.js`` by hand. + +The important test here is :class:`TestAgreesWithTheRenderer`: it draws a +marker at a known data coordinate, finds it in a real screenshot, and checks +the Python answer against where the browser actually put it. Without that, +these methods could be self-consistently wrong. +""" +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl + +FIG_W, FIG_H = 400, 300 +GRID_PAD = 8 # figure_esm.js gridDiv padding, present in every screenshot + + +def _line_plot(n=50): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + return fig, ax.plot(np.linspace(0.0, 1.0, n)) + + +class TestPlotBox: + def test_matches_the_padding_constants(self): + _, plot = _line_plot() + box = plot.plot_box() + left, right, top, bottom = plot.PLOT_PADDING + assert box["x"] == left + assert box["y"] == top + assert box["width"] == FIG_W - left - right + assert box["height"] == FIG_H - top - bottom + + def test_image_box_is_letterboxed(self): + """Data coords map onto the fitted image, not the full padded area.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 400)) + plot = ax.imshow(np.zeros((10, 20), dtype=np.float32)) + box = plot.plot_box() + # A 20x10 image is twice as wide as tall: it fills the width and is + # centred vertically. + assert box["width"] == pytest.approx(box["height"] * 2.0) + assert box["y"] > plot.PLOT_PADDING[2] + + def test_square_image_is_pillarboxed(self): + """A square image in a wide panel is centred horizontally. + + This one has no physical axes, so the renderer drops the left/right + gutters entirely and the image fills the panel width before fitting — + hence the box starts left of PLOT_PADDING, not right of it. + """ + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + box = plot.plot_box() + assert box["width"] == pytest.approx(box["height"]) + assert box["x"] > 0 + assert box["x"] < plot.PLOT_PADDING[0] + + def test_axes_reinstate_the_gutters(self): + """With physical axes the padded layout applies again.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + x = np.linspace(0.0, 10.0, 32) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32), + axes=[x, x], units="nm") + assert plot.plot_box()["x"] >= plot.PLOT_PADDING[0] + + def test_colorbar_narrows_the_box(self): + """The strip and its gap come out of the image width. + + The image is deliberately WIDE so width is the binding constraint — + a square image in this panel is height-limited, and narrowing the + available width would not change its fitted size at all. + """ + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((8, 64), dtype=np.float32), + axes=[np.linspace(0.0, 10.0, 64), + np.linspace(0.0, 1.0, 8)], units="nm") + before = plot.plot_box()["width"] + plot.set_colorbar_visible(True) + assert plot.plot_box()["width"] < before + + def test_unattached_panel_raises(self): + from anyplotlib.plot1d import Plot1D + + plot = Plot1D(np.zeros(8)) + with pytest.raises(RuntimeError, match="not attached"): + plot.plot_box() + + +class TestConversions: + def test_round_trip(self): + _, plot = _line_plot() + pts = [[5.0, 0.2], [25.0, 0.5], [40.0, 0.9]] + back = plot.display_to_data(plot.data_to_display(pts)) + np.testing.assert_allclose(back, pts, atol=1e-9) + + def test_single_point_shape_is_preserved(self): + _, plot = _line_plot() + out = plot.data_to_display([25.0, 0.5]) + assert out.shape == (2,) + + def test_sequence_shape_is_preserved(self): + _, plot = _line_plot() + out = plot.data_to_display([[1.0, 0.1], [2.0, 0.2]]) + assert out.shape == (2, 2) + + def test_bad_shape_raises(self): + _, plot = _line_plot() + with pytest.raises(ValueError, match=r"\(N, 2\)"): + plot.data_to_display([[1.0, 2.0, 3.0]]) + + def test_x_increases_rightwards(self): + _, plot = _line_plot() + lo = plot.data_to_display([5.0, 0.5]) + hi = plot.data_to_display([40.0, 0.5]) + assert hi[0] > lo[0] + + def test_y_increases_upwards_in_data_downwards_on_screen(self): + _, plot = _line_plot() + lo = plot.data_to_display([25.0, 0.1]) + hi = plot.data_to_display([25.0, 0.9]) + assert hi[1] < lo[1], "a larger data y must map to a smaller screen y" + + def test_left_edge_maps_to_the_box_edge(self): + _, plot = _line_plot() + x0, _ = plot.get_xlim() + box = plot.plot_box() + assert plot.data_to_display([x0, 0.0])[0] == pytest.approx(box["x"]) + + +class TestAgreesWithTheRenderer: + """The Python geometry must match what the browser draws. + + A marker is placed at a known data coordinate; its centre of mass in the + screenshot is compared with ``data_to_display``. Marker offsets go + through the renderer's own transform, so any drift between the Python + mirror and ``figure_esm.js`` shows up here. + """ + + def _marker_centre(self, take_screenshot, fig, rgb=(255, 0, 0)): + img = take_screenshot(fig)[..., :3].astype(int) + hit = np.abs(img - np.array(rgb)).sum(axis=-1) < 60 + assert hit.any(), "marker not found in the screenshot" + ys, xs = np.where(hit) + return xs.mean(), ys.mean() + + def test_1d_vline_marker_lands_where_predicted(self, take_screenshot): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.plot(np.linspace(0.0, 1.0, 50)) + # A vlines marker is positioned by x alone, so its column is exactly + # what data_to_display's x should predict. + plot.add_vlines([25.0], color="#ff0000", linewidths=2) + cx, _ = self._marker_centre(take_screenshot, fig) + want_x = plot.data_to_display([25.0, 0.5])[0] + GRID_PAD + assert cx == pytest.approx(want_x, abs=3.0), ( + f"renderer drew the marker at x={cx:.1f}, " + f"data_to_display predicted {want_x:.1f}" + ) + + def test_2d_circle_marker_lands_where_predicted(self, take_screenshot): + fig, ax = apl.subplots(1, 1, figsize=(400, 400)) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + plot.add_circles(offsets=[[8.0, 24.0]], radius=2, + edgecolors="#ff0000", facecolors="#ff0000") + cx, cy = self._marker_centre(take_screenshot, fig) + want = plot.data_to_display([8.0, 24.0]) + assert cx == pytest.approx(want[0] + GRID_PAD, abs=4.0) + assert cy == pytest.approx(want[1] + GRID_PAD, abs=4.0) + + def test_prediction_is_not_trivially_the_centre(self, take_screenshot): + """Guard: an off-centre point must be predicted off-centre.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 400)) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + box = plot.plot_box() + want = plot.data_to_display([8.0, 24.0]) + assert abs(want[0] - (box["x"] + box["width"] / 2)) > 20 diff --git a/upcoming_changes/42.bugfix_2.rst b/upcoming_changes/42.bugfix_2.rst new file mode 100644 index 00000000..f26984e6 --- /dev/null +++ b/upcoming_changes/42.bugfix_2.rst @@ -0,0 +1,6 @@ +``save_html`` / ``to_html`` / ``figure_state`` now capture overlay widgets at +their current positions. Widget moves reach JS as targeted ``event_json`` +updates that never rewrite ``panel__json`` — deliberately, since +re-serialising a panel's image bytes on every drag frame is what that path +avoids — so a snapshot used to show every widget where it was *created*. +Exports reconcile the panel traits first. diff --git a/upcoming_changes/42.new_feature_8.rst b/upcoming_changes/42.new_feature_8.rst new file mode 100644 index 00000000..943fb8d6 --- /dev/null +++ b/upcoming_changes/42.new_feature_8.rst @@ -0,0 +1,14 @@ +Panels expose their geometry, so callers no longer have to re-derive the +renderer's layout to work in display space: + +* :meth:`~anyplotlib.Plot1D.plot_box` — the drawable rectangle in CSS pixels + (the letterboxed image on a 2-D panel, since that is what coordinates map + onto). +* :meth:`~anyplotlib.Plot1D.data_to_display` / + :meth:`~anyplotlib.Plot1D.display_to_data` — coordinate conversion in both + directions, accounting for zoom and pan. + +``PLOT_PADDING`` exposes the ``PAD_*`` constants the layout is built from. +On a 2-D panel the data space is image-pixel coordinates — the same space +marker offsets and widget positions use — where integer *i* is the *centre* +of pixel *i*. From f51391f6c68d6bd667e5c3f361024f5d2a8d8c93 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 26 Jul 2026 16:48:53 -0500 Subject: [PATCH 09/12] feat(markers): size_units="px" for zoom-invariant marker sizes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- anyplotlib/figure_esm.js | 28 +++-- anyplotlib/markers.py | 13 +++ anyplotlib/plot2d/_plot2d.py | 44 +++++-- .../tests/test_markers/test_size_units.py | 110 ++++++++++++++++++ upcoming_changes/42.new_feature_9.rst | 7 ++ 5 files changed, 180 insertions(+), 22 deletions(-) create mode 100644 anyplotlib/tests/test_markers/test_size_units.py create mode 100644 upcoming_changes/42.new_feature_9.rst diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 2c2cca9a..5e6ea7cc 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -3330,6 +3330,12 @@ function render({ model, el, onResize }) { _tc=(ix,iy)=>_imgToCanvas2d(ix,iy,st,imgW,imgH); } const scl = tfm==='data' ? scale : 1; + // Sizes have their own space, independent of the position transform: + // 'data' (default) grows with zoom the way a shape drawn on the data + // does; 'px' pins them to screen pixels. A marker standing in for a + // *point* wants px — matplotlib sizes scatter markers in display + // points for exactly that reason. + const sscl = ms.size_units==='px' ? 1 : scl; mkCtx.save(); if(clipSet){ @@ -3341,7 +3347,7 @@ function render({ model, el, onResize }) { if(type==='circles'){ for(let i=0;i @location(0) vec4 { _tc=(ix,iy)=>_imgToCanvas2d(ix,iy,st,pw,ph); } const scl = tfm==='data' ? scale : 1; + // Match the draw loop's size space (see drawMarkers2d). + const sscl = ms.size_units==='px' ? 1 : scl; if (type === 'circles') { for (let i=0;i<(ms.offsets||[]).length;i++) { const [cx,cy]=_tc(ms.offsets[i][0],ms.offsets[i][1]); - const r=Math.max(1,(ms.sizes[i]!=null?ms.sizes[i]:ms.sizes[0]||5)*scl); + const r=Math.max(1,(ms.sizes[i]!=null?ms.sizes[i]:ms.sizes[0]||5)*sscl); if(Math.sqrt((mx-cx)**2+(my-cy)**2)<=r+MARKER_HIT) return{si,i,collectionLabel:collLabel,markerLabel:perLabels?String(perLabels[i]??''):null}; } } else if (type === 'ellipses') { for (let i=0;i<(ms.offsets||[]).length;i++) { const [cx,cy]=_tc(ms.offsets[i][0],ms.offsets[i][1]); - const rw=(ms.widths[i]||ms.widths[0]||10)*scl/2+MARKER_HIT; - const rh=(ms.heights[i]||ms.heights[0]||10)*scl/2+MARKER_HIT; + const rw=(ms.widths[i]||ms.widths[0]||10)*sscl/2+MARKER_HIT; + const rh=(ms.heights[i]||ms.heights[0]||10)*sscl/2+MARKER_HIT; const dx=(mx-cx)/Math.max(1,rw), dy=(my-cy)/Math.max(1,rh); if(dx*dx+dy*dy<=1.0) return{si,i,collectionLabel:collLabel,markerLabel:perLabels?String(perLabels[i]??''):null}; @@ -6349,8 +6357,8 @@ fn fs(in : VsOut) -> @location(0) vec4 { const heights = type==='squares' ? ms.widths : ms.heights; for (let i=0;i<(ms.offsets||[]).length;i++) { const [cx,cy]=_tc(ms.offsets[i][0],ms.offsets[i][1]); - const hw=(ms.widths[i]||ms.widths[0]||20)*scl/2+MARKER_HIT; - const hh=((heights[i]||heights[0]||20))*scl/2+MARKER_HIT; + const hw=(ms.widths[i]||ms.widths[0]||20)*sscl/2+MARKER_HIT; + const hh=((heights[i]||heights[0]||20))*sscl/2+MARKER_HIT; if(Math.abs(mx-cx)<=hw&&Math.abs(my-cy)<=hh) return{si,i,collectionLabel:collLabel,markerLabel:perLabels?String(perLabels[i]??''):null}; } diff --git a/anyplotlib/markers.py b/anyplotlib/markers.py index 9c8c31bd..b7dc724d 100644 --- a/anyplotlib/markers.py +++ b/anyplotlib/markers.py @@ -383,6 +383,19 @@ def to_wire(self, group_id: str) -> dict: # this flag allows opting out for HUD-style annotations. wire["clip_display"] = bool(d.get("clip_display", True)) + # ── size space (independent of the position transform) ───────────── + # "data" (default) scales sizes with zoom, as a shape drawn on the + # data does. "px" pins them to screen pixels, which is what a marker + # standing in for a *point* wants — matplotlib sizes scatter markers + # in display points for exactly that reason. + size_units = d.get("size_units") + if size_units is not None: + if size_units not in ("data", "px"): + raise ValueError( + f"size_units must be 'data' or 'px', got {size_units!r}" + ) + wire["size_units"] = size_units + # ── common optional fields ────────────────────────────────────────── label = d.get("label") if label is not None: diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py index 88f97d26..29e65cdb 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -1955,8 +1955,14 @@ def add_circles(self, offsets, name=None, *, radius=5, hover_edgecolors=None, hover_facecolors=None, labels=None, label=None, transform: str = "data", - clip_display: bool = True) -> "MarkerGroup": # noqa: F821 - """Add circle markers at (x, y) positions in data coordinates.""" + clip_display: bool = True, + size_units: str = "data") -> "MarkerGroup": # noqa: F821 + """Add circle markers at (x, y) positions in data coordinates. + + ``size_units="px"`` pins *radius* to screen pixels so the circles keep + their size through a zoom; the default ``"data"`` scales them with the + data, as a shape drawn on the image does. + """ return self._add_marker("circles", name, offsets=offsets, radius=radius, facecolors=facecolors, edgecolors=edgecolors, linewidths=linewidths, alpha=alpha, @@ -1964,7 +1970,8 @@ def add_circles(self, offsets, name=None, *, radius=5, hover_facecolors=hover_facecolors, labels=labels, label=label, transform=transform, - clip_display=clip_display) + clip_display=clip_display, + size_units=size_units) def add_points(self, offsets, name=None, *, sizes=5, color="#ff0000", facecolors=None, @@ -1972,8 +1979,14 @@ def add_points(self, offsets, name=None, *, sizes=5, hover_edgecolors=None, hover_facecolors=None, labels=None, label=None, transform: str = "data", - clip_display: bool = True) -> "MarkerGroup": # noqa: F821 - """Add point markers at (x, y) positions in data coordinates.""" + clip_display: bool = True, + size_units: str = "data") -> "MarkerGroup": # noqa: F821 + """Add point markers at (x, y) positions in data coordinates. + + A marker standing in for a *point* usually wants ``size_units="px"`` + so it does not grow with zoom — that is what matplotlib does, sizing + scatter markers in display points. + """ return self._add_marker("circles", name, offsets=offsets, radius=sizes, edgecolors=color, facecolors=facecolors, linewidths=linewidths, alpha=alpha, @@ -1981,7 +1994,8 @@ def add_points(self, offsets, name=None, *, sizes=5, hover_facecolors=hover_facecolors, labels=labels, label=label, transform=transform, - clip_display=clip_display) + clip_display=clip_display, + size_units=size_units) def add_hlines(self, y_values, name=None, *, color="#ff0000", linewidths=1.5, @@ -2030,7 +2044,8 @@ def add_ellipses(self, offsets, widths, heights, name=None, *, hover_edgecolors=None, hover_facecolors=None, labels=None, label=None, transform: str = "data", - clip_display: bool = True) -> "MarkerGroup": # noqa: F821 + clip_display: bool = True, + size_units: str = "data") -> "MarkerGroup": # noqa: F821 return self._add_marker("ellipses", name, offsets=offsets, widths=widths, heights=heights, angles=angles, facecolors=facecolors, edgecolors=edgecolors, @@ -2039,7 +2054,8 @@ def add_ellipses(self, offsets, widths, heights, name=None, *, hover_facecolors=hover_facecolors, labels=labels, label=label, transform=transform, - clip_display=clip_display) + clip_display=clip_display, + size_units=size_units) def add_lines(self, segments, name=None, *, edgecolors="#ff0000", linewidths=1.5, @@ -2060,7 +2076,8 @@ def add_rectangles(self, offsets, widths, heights, name=None, *, hover_edgecolors=None, hover_facecolors=None, labels=None, label=None, transform: str = "data", - clip_display: bool = True) -> "MarkerGroup": # noqa: F821 + clip_display: bool = True, + size_units: str = "data") -> "MarkerGroup": # noqa: F821 return self._add_marker("rectangles", name, offsets=offsets, widths=widths, heights=heights, angles=angles, facecolors=facecolors, edgecolors=edgecolors, @@ -2069,7 +2086,8 @@ def add_rectangles(self, offsets, widths, heights, name=None, *, hover_facecolors=hover_facecolors, labels=labels, label=label, transform=transform, - clip_display=clip_display) + clip_display=clip_display, + size_units=size_units) def add_squares(self, offsets, widths, name=None, *, angles=0, facecolors=None, edgecolors="#ff0000", @@ -2077,7 +2095,8 @@ def add_squares(self, offsets, widths, name=None, *, hover_edgecolors=None, hover_facecolors=None, labels=None, label=None, transform: str = "data", - clip_display: bool = True) -> "MarkerGroup": # noqa: F821 + clip_display: bool = True, + size_units: str = "data") -> "MarkerGroup": # noqa: F821 return self._add_marker("squares", name, offsets=offsets, widths=widths, angles=angles, facecolors=facecolors, edgecolors=edgecolors, @@ -2086,7 +2105,8 @@ def add_squares(self, offsets, widths, name=None, *, hover_facecolors=hover_facecolors, labels=labels, label=label, transform=transform, - clip_display=clip_display) + clip_display=clip_display, + size_units=size_units) def add_polygons(self, vertices_list, name=None, *, facecolors=None, edgecolors="#ff0000", diff --git a/anyplotlib/tests/test_markers/test_size_units.py b/anyplotlib/tests/test_markers/test_size_units.py new file mode 100644 index 00000000..ee041379 --- /dev/null +++ b/anyplotlib/tests/test_markers/test_size_units.py @@ -0,0 +1,110 @@ +""" +``size_units="px"`` — marker sizes that do not grow with zoom. + +Marker radii and widths were always in data units, so a marker standing in for +a *point* (a detected peak, a cursor) swelled as the user zoomed in. That is +right for a shape drawn *on* the data and wrong for a glyph marking a +position — matplotlib sizes scatter markers in display points for the same +reason. + +``size_units`` is independent of ``transform``: positions can be in data +coordinates while sizes are in pixels. +""" +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl + +FIG = 400 +IMG = 32 + + +def _fig_with(**kwargs): + fig, ax = apl.subplots(1, 1, figsize=(FIG, FIG)) + plot = ax.imshow(np.zeros((IMG, IMG), dtype=np.float32)) + plot.add_circles([[16.0, 16.0]], radius=3, edgecolors="#ff0000", + facecolors="#ff0000", **kwargs) + return fig, plot + + +def _marker_px(take_screenshot, fig): + img = take_screenshot(fig)[..., :3].astype(int) + return int((np.abs(img - np.array([255, 0, 0])).sum(axis=-1) < 60).sum()) + + +class TestWireFormat: + def test_px_reaches_the_wire(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((IMG, IMG))) + g = plot.add_circles([[5, 5]], radius=4, size_units="px") + assert g.to_wire("g")["size_units"] == "px" + + def test_default_is_data(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((IMG, IMG))) + g = plot.add_circles([[5, 5]], radius=4) + assert g.to_wire("g")["size_units"] == "data" + + def test_registry_path_may_omit_it(self): + """A group added straight through the registry need not set it; + the renderer treats absent as 'data'.""" + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((IMG, IMG))) + g = plot.markers.add("circles", offsets=[[5, 5]], radius=4) + assert "size_units" not in g.to_wire("g") + + def test_invalid_value_raises(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((IMG, IMG))) + with pytest.raises(ValueError, match="size_units must be"): + plot.add_circles([[5, 5]], radius=4, size_units="inches").to_wire("g") + + @pytest.mark.parametrize("factory,kwargs", [ + ("add_circles", {"radius": 3}), + ("add_points", {"sizes": 3}), + ("add_ellipses", {"widths": 3, "heights": 2}), + ("add_rectangles", {"widths": 3, "heights": 2}), + ("add_squares", {"widths": 3}), + ]) + def test_every_sized_factory_accepts_it(self, factory, kwargs): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((IMG, IMG))) + g = getattr(plot, factory)([[5, 5]], size_units="px", **kwargs) + assert g.to_wire("g")["size_units"] == "px" + + +class TestRendering: + # Zoom in on the middle quarter, where the marker sits. + ZOOM_VIEW = dict(x0=8.0, x1=24.0, y0=8.0, y1=24.0) + + def test_data_sized_markers_grow_with_zoom(self, take_screenshot): + """The existing behaviour, as a contrast case. + + The count is of the *outline* — the fill is translucent and does not + match a saturated red — so it grows with the circumference, linearly + in the radius, not with the area. + """ + fig, plot = _fig_with() + before = _marker_px(take_screenshot, fig) + fig2, plot2 = _fig_with() + plot2.set_view(**self.ZOOM_VIEW) + after = _marker_px(take_screenshot, fig2) + assert after > before * 1.5, ( + f"data-sized markers must scale with zoom ({before} -> {after} px)" + ) + + def test_px_sized_markers_keep_their_size(self, take_screenshot): + fig, plot = _fig_with(size_units="px") + before = _marker_px(take_screenshot, fig) + fig2, plot2 = _fig_with(size_units="px") + plot2.set_view(**self.ZOOM_VIEW) + after = _marker_px(take_screenshot, fig2) + assert after == pytest.approx(before, rel=0.25), ( + f"px-sized markers must not scale with zoom ({before} -> {after} px)" + ) + + def test_px_marker_is_still_drawn(self, take_screenshot): + fig, plot = _fig_with(size_units="px") + assert _marker_px(take_screenshot, fig) > 0 diff --git a/upcoming_changes/42.new_feature_9.rst b/upcoming_changes/42.new_feature_9.rst new file mode 100644 index 00000000..92da89e4 --- /dev/null +++ b/upcoming_changes/42.new_feature_9.rst @@ -0,0 +1,7 @@ +Sized marker types take ``size_units="px"`` so their radii and widths stay +fixed in screen pixels through a zoom, instead of scaling with the data. A +marker standing in for a *point* — a detected peak, a cursor — wants this; +matplotlib sizes scatter markers in display points for the same reason. +``size_units`` is independent of ``transform``, so positions can stay in data +coordinates while sizes are in pixels. Available on ``add_circles``, +``add_points``, ``add_ellipses``, ``add_rectangles`` and ``add_squares``. From 7d41de987b1a1e510433f159384c230107e04cd8 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 26 Jul 2026 16:54:25 -0500 Subject: [PATCH 10/12] fix(plot1d): keep the y label clear of the tick numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- anyplotlib/figure_esm.js | 28 +++- .../test_labels/test_ylabel_clearance.py | 129 ++++++++++++++++++ upcoming_changes/42.bugfix_3.rst | 12 ++ 3 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 anyplotlib/tests/test_labels/test_ylabel_clearance.py create mode 100644 upcoming_changes/42.bugfix_3.rst diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 5e6ea7cc..dc44d101 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -5826,8 +5826,18 @@ fn fs(in : VsOut) -> @location(0) vec4 { if(axisVis1d&&yTicksVis1d){ ctx.font=(st.tick_size||10)+'px monospace';ctx.textAlign='right';ctx.textBaseline='middle'; const tickRX=r.x-8; + // Widest tick string, in both branches: the y label is placed relative + // to it below, so it has to be measured whatever the scale. + let maxTW=0; if(isLog){ const lo=Math.floor(effDMin), hi=Math.ceil(effDMax); + for(let e=lo;e<=hi;e++){ + // Plain-text measure of "10^{e}" over-estimates the TeX-rendered + // width (the exponent is drawn smaller). Over-estimating is the + // safe direction: it pushes the label further from the ticks. + const tw=ctx.measureText('10'+e).width; + if(tw>maxTW)maxTW=tw; + } for(let e=lo;e<=hi;e++){ const v=Math.pow(10,e); const py=_toPlotY(v); @@ -5836,7 +5846,6 @@ fn fs(in : VsOut) -> @location(0) vec4 { ctx.fillStyle=theme.tickText;_drawTex(ctx,'$10^{'+e+'}$',tickRX,py,st.tick_size||10,{align:'right',family:'monospace'}); } } else { - let maxTW=0; for(let v=Math.ceil(dMin/yStep)*yStep;v<=dMax+yStep*0.01;v+=yStep){const tw=ctx.measureText(fmtVal(v)).width;if(tw>maxTW)maxTW=tw;} for(let v=Math.ceil(dMin/yStep)*yStep;v<=dMax+yStep*0.01;v+=yStep){ const py=_valToPy1d(v,dMin,dMax,r); @@ -5848,10 +5857,21 @@ fn fs(in : VsOut) -> @location(0) vec4 { if(yUnits){ ctx.save(); // Centre the rotated label in the left gutter (x = 0..r.x). - // Using a fixed x of PAD_L*0.28 keeps it clear of the tick numbers - // regardless of how wide those numbers are. + // + // The x used to be a fixed PAD_L*0.28 on the assumption that it + // cleared the tick numbers whatever their width. It does not: wide + // strings ("-5.6e-17" at the default tick size) reach back past it + // and the label is drawn through them. So take the fixed position as + // a *preference* and shift left when the ticks actually need the + // room, clamped so the label stays on the canvas — half the rotated + // glyph height is the least it can sit at. const ylpx1d = st.y_label_size||9; - const lcx = Math.max(Math.round(PAD_L * 0.28), Math.ceil(ylpx1d*0.62)+1); + const halfGlyph = Math.ceil(ylpx1d*0.62)+1; + const clearOfTicks = tickRX - maxTW - halfGlyph - 2; + const lcx = Math.max( + halfGlyph, + Math.min(Math.round(PAD_L * 0.28), clearOfTicks) + ); ctx.translate(lcx, r.y+r.h/2); ctx.rotate(-Math.PI/2); ctx.textBaseline='middle'; ctx.fillStyle=theme.unitText; diff --git a/anyplotlib/tests/test_labels/test_ylabel_clearance.py b/anyplotlib/tests/test_labels/test_ylabel_clearance.py new file mode 100644 index 00000000..c67e7b30 --- /dev/null +++ b/anyplotlib/tests/test_labels/test_ylabel_clearance.py @@ -0,0 +1,129 @@ +""" +The 1-D y-axis label must not be drawn through the tick numbers. + +The label's x was a fixed ``PAD_L * 0.28``, with a comment asserting that +cleared the tick numbers "regardless of how wide those numbers are". It did +not: at the default tick size a string like ``-5.6e-17`` reaches back past +that column, and the rotated label was drawn straight through it. + +The label is now placed relative to the widest tick string, so these tests +compare where the label's ink lands with where the ticks' ink lands. Both are +drawn into the same gutter, so they are separated by rendering each alone. + +Not fixed here: a tick string wide enough to fill the gutter on its own (8 +characters, e.g. ``-5.6e-17``) leaves nowhere clear to put the label. See +``test_extreme_ticks_push_the_label_to_the_edge``. +""" +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl + +FIG_W, FIG_H = 400, 300 +PAD_L = 58 +GRID_PAD = 8 + +WIDE_TICKS = np.linspace(0.0, 100000.0, 64) # -> "100000" (6 chars) +NARROW_TICKS = np.linspace(0.0, 1.0, 64) # -> "0.2" +EXTREME_TICKS = np.linspace(-5.6e-17, 5.6e-17, 64) # -> "-5.6e-17" (8 chars) + + +def _gutter_ink_per_column(take_screenshot, data, label=None): + """Ink count per column of the left axis gutter.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.plot(data) + if label is not None: + plot.set_ylabel(label) + img = take_screenshot(fig)[..., :3].astype(int) + gutter = img[:, GRID_PAD:GRID_PAD + PAD_L] + bg = img[2, 2] + ink = np.abs(gutter - bg).sum(axis=-1) > 40 + return ink.sum(axis=0) + + +def _label_columns(take_screenshot, data, label="Intensity"): + """Columns whose ink is contributed by the label, not the ticks.""" + without = _gutter_ink_per_column(take_screenshot, data) + with_label = _gutter_ink_per_column(take_screenshot, data, label) + extra = with_label - without + return {i for i, v in enumerate(extra) if v > 0}, without + + +class TestNoOverlap: + def test_wide_ticks_do_not_collide_with_the_label(self, take_screenshot): + label_cols, tick_ink = _label_columns(take_screenshot, WIDE_TICKS) + tick_cols = {i for i, v in enumerate(tick_ink) if v > 0} + clash = label_cols & tick_cols + assert not clash, ( + f"label and tick numbers share columns {sorted(clash)} — " + "the label is drawn through the ticks" + ) + + def test_narrow_ticks_do_not_collide_either(self, take_screenshot): + label_cols, tick_ink = _label_columns(take_screenshot, NARROW_TICKS) + tick_cols = {i for i, v in enumerate(tick_ink) if v > 0} + assert not (label_cols & tick_cols) + + def test_label_is_actually_drawn(self, take_screenshot): + """Guard: the tests above would pass trivially on a missing label.""" + label_cols, _ = _label_columns(take_screenshot, WIDE_TICKS) + assert label_cols, "no label ink found at all" + + def test_label_stays_on_canvas(self, take_screenshot): + """Shifting left must stop at the canvas edge, not run off it.""" + label_cols, _ = _label_columns(take_screenshot, EXTREME_TICKS) + assert min(label_cols) >= 0 + assert max(label_cols) < PAD_L + + def test_extreme_ticks_push_the_label_to_the_edge(self, take_screenshot): + """A gutter too narrow for both is not something placement can fix. + + ``-5.6e-17`` is 8 characters; at the default tick size it spans almost + the whole 58 px gutter, leaving no clear column for a rotated label. + The label goes as far left as it can and overlaps only the leading + characters — better than the fixed mid-gutter position, which struck + through the middle of every number — but eliminating the overlap needs + a wider gutter, and PAD_L is shared across panels to keep their plot + areas aligned. That is a layout policy decision, not a placement one. + """ + label_cols, tick_ink = _label_columns(take_screenshot, EXTREME_TICKS) + assert min(label_cols) <= 6, ( + "with no room to spare the label should sit at the far left edge" + ) + + def test_wide_ticks_push_the_label_left(self, take_screenshot): + """The mechanism, not just the outcome.""" + wide, _ = _label_columns(take_screenshot, WIDE_TICKS) + narrow, _ = _label_columns(take_screenshot, NARROW_TICKS) + assert min(wide) <= min(narrow), ( + f"wide ticks should move the label left or leave it " + f"(wide starts at {min(wide)}, narrow at {min(narrow)})" + ) + + +class TestLogScale: + def test_log_ticks_do_not_collide(self, take_screenshot): + """The log branch measures its own tick width too.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.plot(np.logspace(-12, 3, 64)) + plot.set_yscale("log") + img_no = take_screenshot(fig)[..., :3].astype(int) + + fig2, ax2 = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot2 = ax2.plot(np.logspace(-12, 3, 64)) + plot2.set_yscale("log") + plot2.set_ylabel("Counts") + img_yes = take_screenshot(fig2)[..., :3].astype(int) + + def cols(img): + gutter = img[:, GRID_PAD:GRID_PAD + PAD_L] + bg = img[2, 2] + return (np.abs(gutter - bg).sum(axis=-1) > 40).sum(axis=0) + + without, with_label = cols(img_no), cols(img_yes) + label_cols = {i for i, v in enumerate(with_label - without) if v > 0} + tick_cols = {i for i, v in enumerate(without) if v > 0} + assert label_cols, "no label ink found on the log panel" + assert not (label_cols & tick_cols) diff --git a/upcoming_changes/42.bugfix_3.rst b/upcoming_changes/42.bugfix_3.rst new file mode 100644 index 00000000..665909d1 --- /dev/null +++ b/upcoming_changes/42.bugfix_3.rst @@ -0,0 +1,12 @@ +The 1-D y-axis label is no longer drawn through the tick numbers. Its x was a +fixed fraction of the left gutter, on the assumption that this cleared the +ticks whatever their width; a string like ``100000`` reaches back past it and +the rotated label was struck through the numbers. The label is now placed +relative to the widest tick string, and clamped so it stays on the canvas. + +.. note:: + A tick string wide enough to fill the gutter on its own (8 characters, e.g. + ``-5.6e-17``) still leaves nowhere clear for the label — it goes as far + left as it can and clips the leading characters. Fixing that needs a wider + left gutter, and ``PAD_L`` is shared across panels to keep their plot areas + aligned. From 00640884f41d4fcd93bc053ac7720ca38b79c4d7 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 26 Jul 2026 17:01:36 -0500 Subject: [PATCH 11/12] docs: changelog fragments and refreshed FIGURE_ESM anchors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- anyplotlib/FIGURE_ESM.md | 67 ++++++++++--------- upcoming_changes/+colorbar-gap.bugfix.rst | 4 ++ upcoming_changes/+coord-api.new_feature.rst | 5 ++ upcoming_changes/+crosshair-grab.bugfix.rst | 3 + .../+export-widget-sync.bugfix.rst | 4 ++ .../+line-rule-widgets.new_feature.rst | 4 ++ .../+linestyle-none.new_feature.rst | 5 ++ .../+marker-size-units.new_feature.rst | 4 ++ .../+per-marker-colors.new_feature.rst | 4 ++ .../+pointer-down-1d.new_feature.rst | 4 ++ .../+range-orientation-snap.new_feature.rst | 5 ++ .../+scalebar-style.new_feature.rst | 3 + .../+widget-notify-remove.new_feature.rst | 4 ++ upcoming_changes/+ylabel-clearance.bugfix.rst | 3 + upcoming_changes/42.bugfix.rst | 10 --- upcoming_changes/42.bugfix_2.rst | 6 -- upcoming_changes/42.bugfix_3.rst | 12 ---- upcoming_changes/42.improvement.rst | 7 -- upcoming_changes/42.improvement_2.rst | 6 -- upcoming_changes/42.improvement_3.rst | 3 - upcoming_changes/42.new_feature.rst | 6 -- upcoming_changes/42.new_feature_2.rst | 11 --- upcoming_changes/42.new_feature_3.rst | 4 -- upcoming_changes/42.new_feature_4.rst | 13 ---- upcoming_changes/42.new_feature_5.rst | 6 -- upcoming_changes/42.new_feature_6.rst | 7 -- upcoming_changes/42.new_feature_7.rst | 5 -- upcoming_changes/42.new_feature_8.rst | 14 ---- upcoming_changes/42.new_feature_9.rst | 7 -- 29 files changed, 86 insertions(+), 150 deletions(-) create mode 100644 upcoming_changes/+colorbar-gap.bugfix.rst create mode 100644 upcoming_changes/+coord-api.new_feature.rst create mode 100644 upcoming_changes/+crosshair-grab.bugfix.rst create mode 100644 upcoming_changes/+export-widget-sync.bugfix.rst create mode 100644 upcoming_changes/+line-rule-widgets.new_feature.rst create mode 100644 upcoming_changes/+linestyle-none.new_feature.rst create mode 100644 upcoming_changes/+marker-size-units.new_feature.rst create mode 100644 upcoming_changes/+per-marker-colors.new_feature.rst create mode 100644 upcoming_changes/+pointer-down-1d.new_feature.rst create mode 100644 upcoming_changes/+range-orientation-snap.new_feature.rst create mode 100644 upcoming_changes/+scalebar-style.new_feature.rst create mode 100644 upcoming_changes/+widget-notify-remove.new_feature.rst create mode 100644 upcoming_changes/+ylabel-clearance.bugfix.rst delete mode 100644 upcoming_changes/42.bugfix.rst delete mode 100644 upcoming_changes/42.bugfix_2.rst delete mode 100644 upcoming_changes/42.bugfix_3.rst delete mode 100644 upcoming_changes/42.improvement.rst delete mode 100644 upcoming_changes/42.improvement_2.rst delete mode 100644 upcoming_changes/42.improvement_3.rst delete mode 100644 upcoming_changes/42.new_feature.rst delete mode 100644 upcoming_changes/42.new_feature_2.rst delete mode 100644 upcoming_changes/42.new_feature_3.rst delete mode 100644 upcoming_changes/42.new_feature_4.rst delete mode 100644 upcoming_changes/42.new_feature_5.rst delete mode 100644 upcoming_changes/42.new_feature_6.rst delete mode 100644 upcoming_changes/42.new_feature_7.rst delete mode 100644 upcoming_changes/42.new_feature_8.rst delete mode 100644 upcoming_changes/42.new_feature_9.rst diff --git a/anyplotlib/FIGURE_ESM.md b/anyplotlib/FIGURE_ESM.md index 3931476f..e1ace56d 100644 --- a/anyplotlib/FIGURE_ESM.md +++ b/anyplotlib/FIGURE_ESM.md @@ -1,6 +1,6 @@ # FIGURE_ESM.md — Navigator for `figure_esm.js` -`figure_esm.js` is **~6,000 lines** and one big closure. Everything lives inside +`figure_esm.js` is **~9,000 lines** and one big closure. Everything lives inside `function render({ model, el })` so that all helpers share the same scope (`theme`, `PAD_*`, `panels` Map, etc.). This document is a section map so you can jump straight to the relevant code without reading the whole file. @@ -48,29 +48,30 @@ Rule 5 – Text never clips. Optional gutters earn real layout space: | Section / function | Line | |--------------------|------| | Shared plot-area padding (`PAD_*`) | 9 | -| Theme (dark/light detection) | 15 | -| Shared math helpers | 53 | -| b64 array decode helpers | 95 | -| **Rich-text (mini-TeX) engine**: `_texRuns` / `_texLayout` / `_drawTex` | 147 / 214 / 236 | -| **2D gutter geometry**: `_cbWidth` / `_padT` / `_titlePx` | 287 / 299 / 309 | -| **Layout engine** `applyLayout` | 590 | -| `_buildCanvasStack` | 656 | -| `_createPanelDOM` | 763 | -| `_createInsetDOM` / `_applyAllInsetStates` | 846 / 968 | -| `_resizePanelDOM` | 1027 | -| **2D drawing**: `_imgFitRect` | 1176 | -| `draw2d` | 1258 | -| `drawScaleBar2d` / `drawColorbar2d` | 1360 / 1436 | -| `_drawAxes2d` (ticks, labels, title) | 1491 | -| `drawOverlay2d` / `drawMarkers2d` | 1629 / 1685 | -| **Image layers**: `_layerBytes` / `_layerBitmap` / `_drawLayers2d` | 1553 / 1577 / 1629 | -| Binary-bytes splice: `_spliceBinaryBytes` / `_registerBinaryPixelListeners` | 675 / 706 | -| **3D drawing**: `draw3d` | 1833 | -| Event emission `_emitEvent` | 2031 | -| 3D event handlers `_attachEvents3d` | 2059 | -| **1D drawing**: `draw1d` | 2177 | -| `drawOverlay1d` / `drawMarkers1d` | 2516 / 2586 | -| Marker hit-test `_markerHitTest2d` | 2787 | +| Theme (dark/light detection) | 26 | +| Shared math helpers | 64 | +| b64 array decode helpers | 109 | +| **Rich-text (mini-TeX) engine**: `_texRuns` / `_texLayout` / `_drawTex` | 161 / 228 / 250 | +| **2D gutter geometry**: `_cbWidth` / `_cbGap` / `_padT` / `_titlePx` | 301 / 310 / 323 / 333 | +| **Layout engine** `applyLayout` | 774 | +| `_buildCanvasStack` | 857 | +| `_createPanelDOM` | 989 | +| `_createInsetDOM` / `_applyAllInsetStates` | 1118 / 1500 | +| `_resizePanelDOM` | 2213 | +| **2D drawing**: `_imgFitRect` | 2372 | +| `draw2d` | 2680 | +| `drawScaleBar2d` / `drawColorbar2d` | 2875 / 2961 | +| `_drawAxes2d` (ticks, labels, title) | 3016 | +| `drawOverlay2d` / `drawMarkers2d` | 3169 / 3287 | +| **Image layers**: `_layerBytes` / `_layerBitmap` / `_drawLayers2d` | 2500 / 2524 / 2585 | +| Binary-bytes splice: `_spliceBinaryBytes` / `_registerBinaryPixelListeners` | 730 / 761 | +| **3D drawing**: `draw3d` | 4623 | +| Event emission `_emitEvent` | 5270 | +| 3D event handlers `_attachEvents3d` | 5322 | +| **1D drawing**: `draw1d` | 5536 | +| `_drawLine` (1D series + markers) | 5689 | +| `drawOverlay1d` / `drawMarkers1d` | 5982 / 6066 | +| Marker hit-test `_markerHitTest2d` | 6334 | > **`raster` marker (1D/PlotXY)** — `drawMarkers1d` has a `type==='raster'` > branch that blits a single RGBA image across data-coord `extent` (the fast @@ -79,15 +80,15 @@ 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` | 2905 | -| 2D events `_attachEvents2d` | 2928 | -| 1D events `_attachEvents1d` | 3201 | -| 2D widget drag `_ovHitTest2d` / `_doDrag2d` | 3409 / 3491 | -| 1D widget drag `_canvasXToFrac1d` … | 3565 | -| Shared-axis propagation `_getShareGroups` | 3650 | -| Figure resize `_applyFigResizeDOM` | 3714 | -| **Bar chart**: `_barGeom` / `drawBar` / `_attachEventsBar` | 3902 / 3965 / 4341 | -| Generic redraw `_redrawPanel` | 4531 | +| Panel event dispatch `_attachPanelEvents` | 6591 | +| 2D events `_attachEvents2d` | 6615 | +| 1D events `_attachEvents1d` | 6962 | +| 2D widget drag `_ovHitTest2d` / `_doDrag2d` | 7229 / 7363 | +| 1D widget drag `_canvasXToFrac1d` … / snapping `_snapVal` | 7473 / 7546 | +| Shared-axis propagation `_getShareGroups` | 7617 | +| Figure resize `_applyFigResizeDOM` | 7681 | +| **Bar chart**: `_barGeom` / `drawBar` / `_attachEventsBar` | 7872 / 7935 / 8311 | +| Generic redraw `_redrawPanel` | 8501 | --- diff --git a/upcoming_changes/+colorbar-gap.bugfix.rst b/upcoming_changes/+colorbar-gap.bugfix.rst new file mode 100644 index 00000000..5315b874 --- /dev/null +++ b/upcoming_changes/+colorbar-gap.bugfix.rst @@ -0,0 +1,4 @@ +The colorbar strip is no longer drawn flush against the image: there is now a +6 px gap, taken out of the image width so the strip cannot be pushed off the +panel, and settable with :meth:`~anyplotlib.Plot2D.set_colorbar_pad`. This +shifts every colorbar plot by 4 px. diff --git a/upcoming_changes/+coord-api.new_feature.rst b/upcoming_changes/+coord-api.new_feature.rst new file mode 100644 index 00000000..d7fab970 --- /dev/null +++ b/upcoming_changes/+coord-api.new_feature.rst @@ -0,0 +1,5 @@ +Panels expose their geometry through :meth:`~anyplotlib.Plot1D.plot_box`, +:meth:`~anyplotlib.Plot1D.data_to_display` and +:meth:`~anyplotlib.Plot1D.display_to_data`, so callers working in display space +no longer have to re-derive the renderer's layout constants and letterbox maths +themselves. diff --git a/upcoming_changes/+crosshair-grab.bugfix.rst b/upcoming_changes/+crosshair-grab.bugfix.rst new file mode 100644 index 00000000..d157f0dc --- /dev/null +++ b/upcoming_changes/+crosshair-grab.bugfix.rst @@ -0,0 +1,3 @@ +A ``crosshair`` widget can now be grabbed anywhere along either of its rules +rather than only at the one-pixel centre hotspot; grabbing a rule constrains +the drag to that rule's own axis. diff --git a/upcoming_changes/+export-widget-sync.bugfix.rst b/upcoming_changes/+export-widget-sync.bugfix.rst new file mode 100644 index 00000000..047500ee --- /dev/null +++ b/upcoming_changes/+export-widget-sync.bugfix.rst @@ -0,0 +1,4 @@ +``save_html`` / ``to_html`` / ``figure_state`` now capture overlay widgets at +their current positions; widget moves reach JS as targeted events that never +rewrite the panel traits, so a snapshot used to show every widget where it was +created. diff --git a/upcoming_changes/+line-rule-widgets.new_feature.rst b/upcoming_changes/+line-rule-widgets.new_feature.rst new file mode 100644 index 00000000..b5144937 --- /dev/null +++ b/upcoming_changes/+line-rule-widgets.new_feature.rst @@ -0,0 +1,4 @@ +Added three 2-D overlay widget kinds: ``line`` +(:meth:`~anyplotlib.Plot2D.add_line_widget`), a bare two-endpoint segment for +line profiles and two-point measurements, and ``vline`` / ``hline``, full-height +and full-width rules grabbable anywhere along their length. diff --git a/upcoming_changes/+linestyle-none.new_feature.rst b/upcoming_changes/+linestyle-none.new_feature.rst new file mode 100644 index 00000000..20f02f8b --- /dev/null +++ b/upcoming_changes/+linestyle-none.new_feature.rst @@ -0,0 +1,5 @@ +Added ``linestyle="none"`` (also spelled ``"None"``) for a series drawn as +markers with no connecting line — matplotlib's scatter idiom, +``ax.plot(y, linestyle="none", marker="o")``. An explicit ``linewidth=0`` +now means the same thing; it previously fell back to the 1.5 default in the +renderer. diff --git a/upcoming_changes/+marker-size-units.new_feature.rst b/upcoming_changes/+marker-size-units.new_feature.rst new file mode 100644 index 00000000..b9cfb1d4 --- /dev/null +++ b/upcoming_changes/+marker-size-units.new_feature.rst @@ -0,0 +1,4 @@ +Sized marker types take ``size_units="px"`` so their radii and widths stay +fixed in screen pixels through a zoom instead of scaling with the data — what +a marker standing in for a *point* wants, and what matplotlib does by sizing +scatter markers in display points. diff --git a/upcoming_changes/+per-marker-colors.new_feature.rst b/upcoming_changes/+per-marker-colors.new_feature.rst new file mode 100644 index 00000000..14db1043 --- /dev/null +++ b/upcoming_changes/+per-marker-colors.new_feature.rst @@ -0,0 +1,4 @@ +``edgecolors`` and ``facecolors`` accept a sequence of colours parallel to the +markers — matplotlib's ``edgecolors=[...]`` / scatter ``c=[...]`` — for every +marker type on both 1-D and 2-D panels, where previously only ``points`` and +``polygons`` on 1-D panels honoured it. A short sequence cycles. diff --git a/upcoming_changes/+pointer-down-1d.new_feature.rst b/upcoming_changes/+pointer-down-1d.new_feature.rst new file mode 100644 index 00000000..a1d01b4b --- /dev/null +++ b/upcoming_changes/+pointer-down-1d.new_feature.rst @@ -0,0 +1,4 @@ +Clicking a 1-D panel now emits a ``pointer_down`` event carrying the clicked +position as ``xdata``/``ydata``, matching 2-D panels; it previously fired only +when the click landed on a line. Clicks on a line still report ``line_id``, +so existing line-click handlers are unaffected. diff --git a/upcoming_changes/+range-orientation-snap.new_feature.rst b/upcoming_changes/+range-orientation-snap.new_feature.rst new file mode 100644 index 00000000..33e255a0 --- /dev/null +++ b/upcoming_changes/+range-orientation-snap.new_feature.rst @@ -0,0 +1,5 @@ +:meth:`~anyplotlib.Plot1D.add_range_widget` takes ``orientation="vertical"`` +for a band that selects a range of values, and ``snap_values`` to restrict a +drag to a set of allowed positions (matplotlib's +``SpanSelector.snap_values``). ``snap_values`` is also available on the +vline, hline and point widgets. diff --git a/upcoming_changes/+scalebar-style.new_feature.rst b/upcoming_changes/+scalebar-style.new_feature.rst new file mode 100644 index 00000000..36dcc41f --- /dev/null +++ b/upcoming_changes/+scalebar-style.new_feature.rst @@ -0,0 +1,3 @@ +Added :meth:`~anyplotlib.Plot2D.set_scalebar_style` to recolour the automatic +scale bar, which was hardcoded white on a translucent dark pill and unreadable +over a light image. ``bgcolor="none"`` drops the pill entirely. diff --git a/upcoming_changes/+widget-notify-remove.new_feature.rst b/upcoming_changes/+widget-notify-remove.new_feature.rst new file mode 100644 index 00000000..15677d2e --- /dev/null +++ b/upcoming_changes/+widget-notify-remove.new_feature.rst @@ -0,0 +1,4 @@ +:meth:`Widget.set` takes ``_notify=False`` to move a widget without firing +``pointer_move`` callbacks, so a handler that writes back to its own widget no +longer feeds into itself. Widgets also gained a +:meth:`~anyplotlib.widgets.Widget.remove` method. diff --git a/upcoming_changes/+ylabel-clearance.bugfix.rst b/upcoming_changes/+ylabel-clearance.bugfix.rst new file mode 100644 index 00000000..13b58bfc --- /dev/null +++ b/upcoming_changes/+ylabel-clearance.bugfix.rst @@ -0,0 +1,3 @@ +The 1-D y-axis label is no longer drawn through the tick numbers; its position +was a fixed fraction of the left gutter and is now measured against the widest +tick string. diff --git a/upcoming_changes/42.bugfix.rst b/upcoming_changes/42.bugfix.rst deleted file mode 100644 index 8fdb08be..00000000 --- a/upcoming_changes/42.bugfix.rst +++ /dev/null @@ -1,10 +0,0 @@ -The colorbar strip is no longer drawn flush against the image. It sat 2 px -from the image edge, and most plots have no ``colorbar_label``, so the label -gutter that would otherwise separate them is zero-width and the strip read as -part of the image. There is now a 6 px gap, taken out of the image width so -the strip can never be pushed off the panel, and settable per panel with -:meth:`~anyplotlib.Plot2D.set_colorbar_pad`. - -.. note:: - This shifts every colorbar plot 4 px — the ``imshow_labels`` visual - baseline was regenerated to match. diff --git a/upcoming_changes/42.bugfix_2.rst b/upcoming_changes/42.bugfix_2.rst deleted file mode 100644 index f26984e6..00000000 --- a/upcoming_changes/42.bugfix_2.rst +++ /dev/null @@ -1,6 +0,0 @@ -``save_html`` / ``to_html`` / ``figure_state`` now capture overlay widgets at -their current positions. Widget moves reach JS as targeted ``event_json`` -updates that never rewrite ``panel__json`` — deliberately, since -re-serialising a panel's image bytes on every drag frame is what that path -avoids — so a snapshot used to show every widget where it was *created*. -Exports reconcile the panel traits first. diff --git a/upcoming_changes/42.bugfix_3.rst b/upcoming_changes/42.bugfix_3.rst deleted file mode 100644 index 665909d1..00000000 --- a/upcoming_changes/42.bugfix_3.rst +++ /dev/null @@ -1,12 +0,0 @@ -The 1-D y-axis label is no longer drawn through the tick numbers. Its x was a -fixed fraction of the left gutter, on the assumption that this cleared the -ticks whatever their width; a string like ``100000`` reaches back past it and -the rotated label was struck through the numbers. The label is now placed -relative to the widest tick string, and clamped so it stays on the canvas. - -.. note:: - A tick string wide enough to fill the gutter on its own (8 characters, e.g. - ``-5.6e-17``) still leaves nowhere clear for the label — it goes as far - left as it can and clips the leading characters. Fixing that needs a wider - left gutter, and ``PAD_L`` is shared across panels to keep their plot areas - aligned. diff --git a/upcoming_changes/42.improvement.rst b/upcoming_changes/42.improvement.rst deleted file mode 100644 index 2b3a17ae..00000000 --- a/upcoming_changes/42.improvement.rst +++ /dev/null @@ -1,7 +0,0 @@ -Clicking a 1-D panel now always emits a ``pointer_down`` event carrying the -clicked position as ``xdata``/``ydata``, matching 2-D panels. Previously a -1-D panel emitted ``pointer_down`` only when the click landed on a line, so -click-position features had nothing to listen to and ``pointer_down`` meant -different things on different panel kinds. Clicks on a line still report -``line_id`` (and the snapped on-line ``x``/``y``), so existing line-click -handlers are unaffected — they can filter on ``event.line_id is not None``. diff --git a/upcoming_changes/42.improvement_2.rst b/upcoming_changes/42.improvement_2.rst deleted file mode 100644 index dd3146b3..00000000 --- a/upcoming_changes/42.improvement_2.rst +++ /dev/null @@ -1,6 +0,0 @@ -:meth:`Widget.set` takes ``_notify=False`` to update a widget without firing -``pointer_move`` callbacks. A ``set()`` from Python was previously -indistinguishable from a user drag, so a handler that writes back to the -widget it listens to fed into itself; the only defence was wrapping the call -in ``pause_events()``, which suppresses every event for the duration rather -than just this update's echo. diff --git a/upcoming_changes/42.improvement_3.rst b/upcoming_changes/42.improvement_3.rst deleted file mode 100644 index 222310d0..00000000 --- a/upcoming_changes/42.improvement_3.rst +++ /dev/null @@ -1,3 +0,0 @@ -A ``crosshair`` widget can be grabbed anywhere along either of its rules, not -only at the one-pixel centre hotspot. Grabbing a rule constrains the drag to -that rule's own axis; the centre still moves both. diff --git a/upcoming_changes/42.new_feature.rst b/upcoming_changes/42.new_feature.rst deleted file mode 100644 index e74ab01e..00000000 --- a/upcoming_changes/42.new_feature.rst +++ /dev/null @@ -1,6 +0,0 @@ -``linestyle="none"`` (also spelled ``"None"``) draws a series' markers with no -connecting line — matplotlib's scatter idiom, ``ax.plot(y, linestyle="none", -marker="o")``. An explicit ``linewidth=0`` now means the same thing; it -previously fell back to the 1.5 default in the renderer. Both apply to the -primary line and to :meth:`~anyplotlib.Plot1D.add_line` overlays, and the -legend swatch drops its rule to match. diff --git a/upcoming_changes/42.new_feature_2.rst b/upcoming_changes/42.new_feature_2.rst deleted file mode 100644 index b487aa50..00000000 --- a/upcoming_changes/42.new_feature_2.rst +++ /dev/null @@ -1,11 +0,0 @@ -``edgecolors`` and ``facecolors`` accept a sequence of colours parallel to the -markers — matplotlib's ``edgecolors=[...]`` / scatter ``c=[...]`` — for **every** -marker type on both 1-D and 2-D panels:: - - plot.add_circles(offsets, edgecolors=["#f00", "#0f0", "#00f"], radius=3) - -Only ``points`` and ``polygons`` on 1-D panels honoured this before; other -types painted the whole group one colour. A sequence shorter than the group -cycles, as matplotlib's colour cycle does. This is what a value-to-colour -mapping (a colormapped scatter, or HyperSpy's -``Markers.set_ScalarMappable_array``) needs to render. diff --git a/upcoming_changes/42.new_feature_3.rst b/upcoming_changes/42.new_feature_3.rst deleted file mode 100644 index 48dd200d..00000000 --- a/upcoming_changes/42.new_feature_3.rst +++ /dev/null @@ -1,4 +0,0 @@ -Widgets have a :meth:`~anyplotlib.widgets.Widget.remove` method. -``plot.remove_widget(widget)`` still works; ``widget.remove()`` is for callers -that hold only the handle and would otherwise have to re-derive the owning -plot. Removing an unattached or already-removed widget is a no-op. diff --git a/upcoming_changes/42.new_feature_4.rst b/upcoming_changes/42.new_feature_4.rst deleted file mode 100644 index d4152000..00000000 --- a/upcoming_changes/42.new_feature_4.rst +++ /dev/null @@ -1,13 +0,0 @@ -New 2-D overlay widget kinds: - -* ``line`` (:meth:`~anyplotlib.Plot2D.add_line_widget`) — a bare two-endpoint - segment with a grab handle at each end. Drag an endpoint to move that end - or the shaft to translate the whole segment. ``arrow`` draws a head and - ``polygon`` requires >= 3 vertices and closes the path, so neither could - stand in for a line profile, a cross-section cut, or a two-point - measurement. ``widget.length`` reports the segment length. -* ``vline`` / ``hline`` (:meth:`~anyplotlib.Plot2D.add_vline_widget`, - :meth:`~anyplotlib.Plot2D.add_hline_widget`) — full-height / full-width - rules, grabbable anywhere along their length. These existed on 1-D panels - only, so a 2-D panel had to fake a single-axis pointer with a ``crosshair``, - which leaves a stray perpendicular rule across the image. diff --git a/upcoming_changes/42.new_feature_5.rst b/upcoming_changes/42.new_feature_5.rst deleted file mode 100644 index 44e2a985..00000000 --- a/upcoming_changes/42.new_feature_5.rst +++ /dev/null @@ -1,6 +0,0 @@ -:meth:`~anyplotlib.Plot1D.add_range_widget` takes ``orientation="vertical"`` -for a band spanning the plot width that selects a range of *values* — an -intensity window rather than a spectral one. ``x0``/``x1`` remain the field -names: they are the extents along the selection axis, the same way -matplotlib's ``SpanSelector.extents`` reads for either ``direction``. -``style="fwhm"`` stays horizontal-only and raises if combined with it. diff --git a/upcoming_changes/42.new_feature_6.rst b/upcoming_changes/42.new_feature_6.rst deleted file mode 100644 index 7f3fd544..00000000 --- a/upcoming_changes/42.new_feature_6.rst +++ /dev/null @@ -1,7 +0,0 @@ -Range, vline, hline and point widgets take ``snap_values`` — a sequence of -allowed positions. While dragging, the widget follows the cursor but lands -only on the nearest allowed value, matching matplotlib's -``SpanSelector.snap_values``. Snapping happens inside the JS drag, so the -widget visibly steps between allowed positions instead of being corrected -afterwards. numpy arrays are accepted and converted (a raw array is not -JSON-serialisable and would break the panel push). diff --git a/upcoming_changes/42.new_feature_7.rst b/upcoming_changes/42.new_feature_7.rst deleted file mode 100644 index 413fd92c..00000000 --- a/upcoming_changes/42.new_feature_7.rst +++ /dev/null @@ -1,5 +0,0 @@ -:meth:`~anyplotlib.Plot2D.set_scalebar_style` recolours the automatic scale -bar. It was hardcoded white-on-a-translucent-dark-pill, which is unreadable -over a light image and could not be matched to a house style. ``color`` sets -the bar and its label; ``bgcolor`` sets the pill, or ``"none"`` to drop the -pill entirely. diff --git a/upcoming_changes/42.new_feature_8.rst b/upcoming_changes/42.new_feature_8.rst deleted file mode 100644 index 943fb8d6..00000000 --- a/upcoming_changes/42.new_feature_8.rst +++ /dev/null @@ -1,14 +0,0 @@ -Panels expose their geometry, so callers no longer have to re-derive the -renderer's layout to work in display space: - -* :meth:`~anyplotlib.Plot1D.plot_box` — the drawable rectangle in CSS pixels - (the letterboxed image on a 2-D panel, since that is what coordinates map - onto). -* :meth:`~anyplotlib.Plot1D.data_to_display` / - :meth:`~anyplotlib.Plot1D.display_to_data` — coordinate conversion in both - directions, accounting for zoom and pan. - -``PLOT_PADDING`` exposes the ``PAD_*`` constants the layout is built from. -On a 2-D panel the data space is image-pixel coordinates — the same space -marker offsets and widget positions use — where integer *i* is the *centre* -of pixel *i*. diff --git a/upcoming_changes/42.new_feature_9.rst b/upcoming_changes/42.new_feature_9.rst deleted file mode 100644 index 92da89e4..00000000 --- a/upcoming_changes/42.new_feature_9.rst +++ /dev/null @@ -1,7 +0,0 @@ -Sized marker types take ``size_units="px"`` so their radii and widths stay -fixed in screen pixels through a zoom, instead of scaling with the data. A -marker standing in for a *point* — a detected peak, a cursor — wants this; -matplotlib sizes scatter markers in display points for the same reason. -``size_units`` is independent of ``transform``, so positions can stay in data -coordinates while sizes are in pixels. Available on ``add_circles``, -``add_points``, ``add_ellipses``, ``add_rectangles`` and ``add_squares``. From 5a2cb86e3412512d1513407ce4327a69b2166d10 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 26 Jul 2026 17:06:34 -0500 Subject: [PATCH 12/12] chore: leave upcoming_changes/.gitkeep untracked 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) --- upcoming_changes/.gitkeep | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 upcoming_changes/.gitkeep diff --git a/upcoming_changes/.gitkeep b/upcoming_changes/.gitkeep deleted file mode 100644 index 061573b3..00000000 --- a/upcoming_changes/.gitkeep +++ /dev/null @@ -1,3 +0,0 @@ -# This file keeps the upcoming_changes/ directory tracked by git. -# Replace it with real fragment files (e.g. 42.new_feature.rst) as PRs land. -