From bc7424ce95e23451292ace88ba061fd496c62d2e Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Thu, 13 Aug 2026 13:28:51 -0500 Subject: [PATCH] feat(widgets): circle lock_center -- pin the centre, keep the radius draggable A ring measured on a power spectrum is centred on the DC term, so its centre is not a free parameter: a draggable one is a control that can only ever be wrong, and one nudged off-centre silently corrupts every radius measured from it. Enforced in the HIT-TEST, not afterwards. Correcting the centre from Python when the drag settles cannot work -- _doDrag2d recomputes the position every frame from its own grab-time snapshot, so a pushed-back centre is overwritten on the next mousemove; the ring tracks the cursor for the whole drag and jumps back on release, which reads as a broken lock rather than a locked ring. Refusing the grab also leaves the hover cursor at 'default' and lets the drag fall through to the plot's own pan, so the ring simply stops being a handle. _doDrag2d keeps a guard for a drag already in flight when the flag is set. test_circle_lock_center.py drives real browser drags: the two lock assertions fail with the hit-test gate removed, and the unlocked circle + the radius handle keep working. --- anyplotlib/figure_esm.js | 12 +- anyplotlib/plot2d/_plot2d.py | 15 +- .../test_circle_lock_center.py | 160 ++++++++++++++++++ anyplotlib/widgets/_widgets2d.py | 20 ++- .../+circle_lock_center.new_feature.rst | 6 + 5 files changed, 206 insertions(+), 7 deletions(-) create mode 100644 anyplotlib/tests/test_interactive/test_circle_lock_center.py create mode 100644 upcoming_changes/+circle_lock_center.new_feature.rst diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 44adebd5..9f743fc9 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -8182,7 +8182,13 @@ fn fs(in : VsOut) -> @location(0) vec4 { // outer radius handle if (Math.hypot(mx - (ccx + cr), my - ccy) <= HR) return { idx:i, mode:'resize_r', snapW:{...w}, startMX:mx, startMY:my }; - // body (inside ring ± tolerance) + // body (inside ring ± tolerance). `lock_center` refuses the grab + // outright rather than letting the drag run and correcting afterwards: + // a centre snapped back on release still tracks the cursor for the + // whole drag, which reads as a broken lock. Refusing here also leaves + // the hover cursor at 'default' and lets the drag fall through to the + // plot's own pan, so the ring is simply not a handle any more. + if (w.lock_center) continue; if (Math.abs(Math.hypot(mx-ccx, my-ccy) - cr) <= Math.max(HR, cr*0.18) || Math.hypot(mx-ccx, my-ccy) <= HR) return { idx:i, mode:'move', snapW:{...w}, startMX:mx, startMY:my }; @@ -8441,7 +8447,9 @@ fn fs(in : VsOut) -> @location(0) vec4 { } if (w.type === 'circle') { - if (d.mode === 'move') { + // A drag already in flight when `lock_center` is turned on must not keep + // translating the widget: the hit-test gate only runs at grab time. + if (d.mode === 'move' && !w.lock_center) { w.cx = s.cx + dix; w.cy = s.cy + diy; } else if (d.mode === 'resize_r') { // distance from centre in image-px diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py index 43586021..6939bd91 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -1731,15 +1731,24 @@ def add_widget(self, kind: str, color: str = "#00e5ff", **kwargs) -> Widget: def add_circle_widget(self, cx: float | None = None, cy: float | None = None, r: float | None = None, color: str = "#00e5ff", linewidth: float = 2, - show_handles: bool = True) -> CircleWidget: - """Add a draggable circle overlay.""" + show_handles: bool = True, + lock_center: bool = False) -> CircleWidget: + """Add a draggable circle overlay. + + ``lock_center`` pins the centre and leaves only the radius draggable — + a grab on the ring body is refused and pans the plot instead. Use it + when the centre is fixed by the data (a ring on a power spectrum is + centred on the DC term), so it cannot be nudged off and silently + corrupt every radius measured from it. + """ iw, ih = self._state["image_width"], self._state["image_height"] widget = CircleWidget(lambda: None, cx=float(cx) if cx is not None else iw / 2, cy=float(cy) if cy is not None else ih / 2, r=float(r) if r is not None else iw * 0.1, color=color, linewidth=linewidth, - show_handles=show_handles) + show_handles=show_handles, + lock_center=lock_center) widget._push_fn = self._make_widget_push_fn(widget) self._widgets[widget.id] = widget self._push() diff --git a/anyplotlib/tests/test_interactive/test_circle_lock_center.py b/anyplotlib/tests/test_interactive/test_circle_lock_center.py new file mode 100644 index 00000000..93ea72be --- /dev/null +++ b/anyplotlib/tests/test_interactive/test_circle_lock_center.py @@ -0,0 +1,160 @@ +""" +tests/test_interactive/test_circle_lock_center.py +================================================= + +``lock_center`` — a circle whose centre is fixed by the data. + +Why it exists: a ring measured on a power spectrum is centred on the DC term. +Its centre is not a free parameter, so a draggable one is a control that can +only ever be wrong — and a ring nudged off-centre silently corrupts every +radius measured from it. + +Why it is enforced in the HIT-TEST and not afterwards. The obvious +implementation is a Python handler that watches the widget and snaps the centre +back when the drag settles. It cannot work: ``_doDrag2d`` recomputes the +position every frame from its own grab-time snapshot, so a pushed-back centre +is overwritten on the next mousemove. The ring tracks the cursor for the whole +drag and jumps back on release, which reads as a broken lock rather than a +locked ring. Refusing the grab is the only place the constraint holds. + +Coordinate system mirrors figure_esm.js — see ``_img_to_page``. +""" +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.widgets import CircleWidget +from anyplotlib.tests.test_interactive._event_test_utils import ( + _collect_events, _get_events, +) + +FIG_W, FIG_H = 400, 300 + + +# ═══════════════════════════════════════════════════════════════════════════ +# 1. Python API — the flag has to reach the JS renderer to do anything +# ═══════════════════════════════════════════════════════════════════════════ + +class TestLockCenterAttribute: + def test_defaults_to_unlocked(self): + w = CircleWidget(lambda: None, cx=16, cy=16, r=6) + assert w.lock_center is False + + def test_stores_the_flag(self): + w = CircleWidget(lambda: None, cx=16, cy=16, r=6, lock_center=True) + assert w.lock_center is True + + def test_reaches_the_state_dict(self): + """JS reads the widget dict, so the flag has to survive to_dict().""" + w = CircleWidget(lambda: None, cx=16, cy=16, r=6, lock_center=True) + assert w.to_dict()["lock_center"] is True + + def test_add_circle_widget_passes_it_through(self): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + v = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + w = v.add_circle_widget(cx=16, cy=16, r=6, lock_center=True) + assert w.lock_center is True + + def test_add_circle_widget_defaults_to_unlocked(self): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + v = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + assert v.add_circle_widget(cx=16, cy=16, r=6).lock_center is False + + +# ═══════════════════════════════════════════════════════════════════════════ +# 2. Real drags in a real browser — the only place the constraint is enforced +# ═══════════════════════════════════════════════════════════════════════════ + +_OVERLAY_RECT_JS = """() => { + for (const cv of document.querySelectorAll('canvas')) { + if (getComputedStyle(cv).pointerEvents === 'all') { + const r = cv.getBoundingClientRect(); + return {left:r.left, top:r.top, w:r.width, h:r.height}; + } + } + return null; +}""" + + +def _img_to_page(page, ix, iy, iw=32, ih=32): + """Image px → page coords, for an overlay laid out 'contain' with no zoom.""" + r = page.evaluate(_OVERLAY_RECT_JS) + assert r is not None, "overlay canvas not found" + s = min(r["w"] / iw, r["h"] / ih) + ox = (r["w"] - iw * s) / 2.0 + oy = (r["h"] - ih * s) / 2.0 + return r["left"] + ox + ix * s, r["top"] + oy + iy * s + + +def _setup(interact_page, lock_center): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + v = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + v.add_circle_widget(cx=16, cy=16, r=6, color="#ff0000", + lock_center=lock_center) + page = interact_page(fig) + _collect_events(page) + return page + + +def _drag(page, frm, to, steps=8): + page.mouse.move(*_img_to_page(page, *frm)) + page.mouse.down() + page.mouse.move(*_img_to_page(page, *to), steps=steps) + page.mouse.up() + page.wait_for_timeout(80) + + +@pytest.mark.usefixtures("interact_page") +class TestLockedCentreDrag: + def test_body_drag_does_not_move_a_locked_circle(self, interact_page): + """THE regression. Grab the centre and pull: nothing about the widget + may change — not even transiently, which is why the assertion is on + every event and not only the last one.""" + page = _setup(interact_page, lock_center=True) + + _drag(page, (16, 16), (24, 24)) + + moved = [e for e in _get_events(page) + if e.get("cx") is not None + and (abs(e["cx"] - 16.0) > 0.5 or abs(e["cy"] - 16.0) > 0.5)] + assert not moved, ( + f"a locked circle's centre moved during the drag: {moved[:3]}") + + def test_an_unlocked_circle_still_moves(self, interact_page): + """The counterpart, so a lock that accidentally applied to every circle + would fail here rather than passing the whole file.""" + page = _setup(interact_page, lock_center=False) + + _drag(page, (16, 16), (20, 22)) + + last = _get_events(page, "pointer_up")[-1] + assert last["cx"] > 16.0 and last["cy"] > 16.0 + assert last["r"] == pytest.approx(6.0, abs=0.5) + + def test_the_radius_handle_still_drags_when_locked(self, interact_page): + """A locked centre must not cost the measurement. The handle sits at + the east point (cx+r, cy) = (22, 16); drag it out to (26, 16).""" + page = _setup(interact_page, lock_center=True) + + _drag(page, (22, 16), (26, 16)) + + ups = _get_events(page, "pointer_up") + assert ups, "the radius drag should still emit a pointer_up" + last = ups[-1] + assert last["r"] > 6.0, "the radius handle stopped working under the lock" + assert last["cx"] == pytest.approx(16.0, abs=0.5) + assert last["cy"] == pytest.approx(16.0, abs=0.5) + + def test_a_locked_ring_band_is_not_grabbable_either(self, interact_page): + """Not just the centre hotspot: the ring BAND is the circle's move + target, and it is the part a user actually grabs. (22, 16) is the + handle, so aim at the west point (10, 16) — same band, no handle.""" + page = _setup(interact_page, lock_center=True) + + _drag(page, (10, 16), (4, 16)) + + moved = [e for e in _get_events(page) + if e.get("cx") is not None and abs(e["cx"] - 16.0) > 0.5] + assert not moved, f"the ring band dragged the locked circle: {moved[:3]}" diff --git a/anyplotlib/widgets/_widgets2d.py b/anyplotlib/widgets/_widgets2d.py index 9de2eb51..cfd4ddd7 100644 --- a/anyplotlib/widgets/_widgets2d.py +++ b/anyplotlib/widgets/_widgets2d.py @@ -74,13 +74,29 @@ class CircleWidget(Widget): Outline stroke width in px. Default 2. show_handles : bool, optional Draw the radius grab handle. Default ``True``. + lock_center : bool, optional + Pin ``cx, cy`` and let only the radius change. Default ``False``. + + The centre is refused at HIT-TEST time, not corrected afterwards: a + grab on the ring body is simply not a widget grab, so it falls through + to the plot's own pan and the hover cursor never promises a move. + Enforcing it from Python instead — snapping the centre back when the + drag settles — cannot work, because the JS drag recomputes the position + from its own grab-time snapshot on every frame; the ring tracks the + cursor for the whole drag and only jumps back on release. + + Use it when the centre is not a free parameter — a ring on a power + spectrum is centred on the DC term, so a draggable centre is a control + that can only ever be wrong, and one nudged off-centre silently + corrupts every radius measured from it. """ def __init__(self, push_fn, *, cx, cy, r, color="#00e5ff", - linewidth=2, show_handles=True): + linewidth=2, show_handles=True, lock_center=False): super().__init__("circle", push_fn, cx=float(cx), cy=float(cy), r=float(r), color=color, linewidth=float(linewidth), - show_handles=bool(show_handles)) + show_handles=bool(show_handles), + lock_center=bool(lock_center)) class AnnularWidget(Widget): diff --git a/upcoming_changes/+circle_lock_center.new_feature.rst b/upcoming_changes/+circle_lock_center.new_feature.rst new file mode 100644 index 00000000..344ddcef --- /dev/null +++ b/upcoming_changes/+circle_lock_center.new_feature.rst @@ -0,0 +1,6 @@ +``Plot2D.add_circle_widget`` gains ``lock_center``: the centre is pinned and +only the radius is draggable. A grab on the ring body is refused at hit-test +time and falls through to the plot's own pan, so the hover cursor never +promises a move and the centre cannot drift. Use it when the centre is fixed by +the data — a ring on a power spectrum is centred on the DC term, and one nudged +off-centre silently corrupts every radius measured from it.