Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions anyplotlib/figure_esm.js
Original file line number Diff line number Diff line change
Expand Up @@ -8182,7 +8182,13 @@ fn fs(in : VsOut) -> @location(0) vec4<f32> {
// 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 };
Expand Down Expand Up @@ -8441,7 +8447,9 @@ fn fs(in : VsOut) -> @location(0) vec4<f32> {
}

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
Expand Down
15 changes: 12 additions & 3 deletions anyplotlib/plot2d/_plot2d.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
160 changes: 160 additions & 0 deletions anyplotlib/tests/test_interactive/test_circle_lock_center.py
Original file line number Diff line number Diff line change
@@ -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]}"
20 changes: 18 additions & 2 deletions anyplotlib/widgets/_widgets2d.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
6 changes: 6 additions & 0 deletions upcoming_changes/+circle_lock_center.new_feature.rst
Original file line number Diff line number Diff line change
@@ -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.
Loading