Skip to content

Commit ea7082c

Browse files
committed
Enhance widgets and callbacks: add PointWidget for draggable control points and extend callback functionality with line hover and click events
1 parent 81b723b commit ea7082c

8 files changed

Lines changed: 1695 additions & 67 deletions

File tree

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""
2+
Draggable Point Widget
3+
======================
4+
5+
Demonstrates the :class:`~anyplotlib.widgets.PointWidget` on a 1-D panel.
6+
7+
A smooth curve ``f(x) = sin(x) · e^(−x/6)`` is shown together with a
8+
cyan control point that the user can drag freely inside the plot area.
9+
10+
**Interaction**
11+
12+
* **Drag the point** anywhere inside the plot — the widget reports its
13+
data-space ``(x, y)`` position on every frame via the
14+
:meth:`~anyplotlib.widgets.Widget.on_changed` callback.
15+
* **Release** — the :meth:`~anyplotlib.widgets.Widget.on_release` callback
16+
snaps the point's y-coordinate to the curve value at the dragged x
17+
and draws the **tangent line** through that point.
18+
19+
**What is computed on release**
20+
21+
Given the dragged x position *xq*, the code evaluates:
22+
23+
* **Curve value**: ``yq = f(xq)``
24+
* **Derivative** (central finite difference): ``dy/dx ≈ [f(xq+h) − f(xq−h)] / 2h``
25+
* **Tangent line**: ``y_tan(x) = yq + slope · (x − xq)``
26+
27+
The tangent line is added with :meth:`~anyplotlib.figure_plots.Plot1D.add_line`
28+
and the previous one is removed, so only one tangent is shown at a time.
29+
30+
.. note::
31+
Move the point to an interesting part of the curve (e.g. a local maximum)
32+
and release — the tangent will be horizontal there.
33+
"""
34+
35+
import numpy as np
36+
import anyplotlib as vw
37+
38+
# ── Curve ──────────────────────────────────────────────────────────────────
39+
x = np.linspace(0.0, 4.0 * np.pi, 512)
40+
41+
def f(t):
42+
return np.sin(t) * np.exp(-t / 6.0)
43+
44+
def df(t, h=1e-5):
45+
"""Central finite-difference derivative of f."""
46+
return (f(t + h) - f(t - h)) / (2.0 * h)
47+
48+
y = f(x)
49+
50+
# ── Figure ─────────────────────────────────────────────────────────────────
51+
fig, ax = vw.subplots(figsize=(680, 340))
52+
plot = ax.plot(y, axes=[x], units="rad",
53+
color="#4fc3f7", linewidth=2.0, label="f(x)")
54+
55+
# ── Initial point widget — placed at the first local maximum ───────────────
56+
x0_init = float(x[np.argmax(y)])
57+
y0_init = float(np.max(y))
58+
pt = plot.add_point_widget(x0_init, y0_init, color="#00e5ff")
59+
60+
# Track the current tangent line handle so we can replace it
61+
_tangent_line: "vw.Line1D | None" = None # type: ignore[name-defined]
62+
63+
def _draw_tangent(xq: float) -> None:
64+
"""Snap point to curve, compute slope, draw tangent overlay."""
65+
global _tangent_line
66+
67+
# Evaluate curve and slope at xq
68+
yq = float(f(xq))
69+
slope = float(df(xq))
70+
71+
# Snap the widget y to the curve (visual feedback)
72+
pt._data["y"] = yq
73+
pt._push_fn()
74+
75+
# Tangent line spans the full visible x range
76+
x_tan = np.array([float(x[0]), float(x[-1])])
77+
y_tan = yq + slope * (x_tan - xq)
78+
79+
# Replace previous tangent
80+
if _tangent_line is not None:
81+
_tangent_line.remove()
82+
_tangent_line = plot.add_line(
83+
y_tan, x_axis=x_tan,
84+
color="#ff7043", linewidth=1.5,
85+
linestyle="dashed",
86+
label=f"slope = {slope:+.3f}",
87+
)
88+
89+
# Draw the tangent at the initial position
90+
_draw_tangent(x0_init)
91+
92+
93+
# ── Callbacks ──────────────────────────────────────────────────────────────
94+
95+
@pt.on_changed
96+
def _live(event):
97+
"""Every drag frame — print the current widget position."""
98+
print(f" dragging x={event.x:.4f} y={event.y:.4f}", end="\r")
99+
100+
101+
@pt.on_release
102+
def _settled(event):
103+
"""On mouse-up — snap y to the curve and refresh the tangent line."""
104+
print(f" released x={event.x:.4f} ")
105+
_draw_tangent(event.x)
106+
107+
108+
fig
109+

anyplotlib/callbacks.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
77
:class:`CallbackRegistry`
88
Per-object store of named callbacks. Every plot object and widget
9-
exposes ``on_changed``, ``on_release``, ``on_click``, and ``on_key``
10-
decorator methods that connect handlers through this registry.
9+
exposes ``on_changed``, ``on_release``, ``on_click``, ``on_key``,
10+
``on_line_hover``, and ``on_line_click`` decorator methods that
11+
connect handlers through this registry.
1112
1213
:class:`Event`
1314
Immutable data-carrier passed to every callback. All keys in the
@@ -31,15 +32,31 @@ def on_settle(event):
3132
from dataclasses import dataclass, field
3233
from typing import Any, Callable
3334

34-
_VALID_EVENT_TYPES = ("on_click", "on_changed", "on_release", "on_key")
35+
_VALID_EVENT_TYPES = (
36+
"on_click",
37+
"on_changed",
38+
"on_release",
39+
"on_key",
40+
"on_line_hover",
41+
"on_line_click",
42+
)
3543

3644

3745
@dataclass
3846
class Event:
3947
"""A single interactive event.
40-
event_type: one of on_click / on_changed / on_release
48+
event_type: one of on_click / on_changed / on_release / on_key /
49+
on_line_hover / on_line_click
4150
source: the originating Python object (Widget, Plot, or None)
4251
data: full state dict; all keys also accessible as event.x
52+
53+
For ``on_line_hover`` and ``on_line_click`` events the data dict
54+
contains:
55+
56+
* ``line_id`` – ``None`` for the primary line, or the 8-char ID
57+
string assigned by :meth:`Plot1D.add_line`.
58+
* ``x`` – data-space x coordinate of the nearest point on the line.
59+
* ``y`` – data-space y coordinate of the nearest point on the line.
4360
"""
4461
event_type: str
4562
source: Any
@@ -71,7 +88,8 @@ def __repr__(self) -> str:
7188

7289

7390
class CallbackRegistry:
74-
"""Per-object registry for on_click / on_changed / on_release callbacks."""
91+
"""Per-object registry for on_click / on_changed / on_release / on_key /
92+
on_line_hover / on_line_click callbacks."""
7593

7694
def __init__(self) -> None:
7795
self._next_cid: int = 1

anyplotlib/figure.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,13 @@ class Figure(anywidget.AnyWidget):
9696
will-change: transform;
9797
transform-origin: top left;
9898
vertical-align: top;
99+
/* min-width: max-content prevents the inline-block from shrinking when
100+
the parent container (scaleWrap, width:100%) narrows because the
101+
Jupyter cell is narrower than the figure's native width. Without
102+
this, outerDiv.offsetWidth collapses to cellW, causing _applyScale()
103+
to compute s = cellW/cellW = 1.0 (no-op) instead of the correct
104+
s = cellW/nativeW < 1. */
105+
min-width: max-content;
99106
}
100107
"""
101108

0 commit comments

Comments
 (0)