anyplotlib 0.3.0__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
anyplotlib/_utils.py CHANGED
@@ -192,6 +192,44 @@ def _build_colormap_lut(name: str) -> list:
192
192
  return [[v, v, v] for v in range(256)]
193
193
 
194
194
 
195
+ def _parse_hex_color(color: str) -> tuple:
196
+ """Parse a ``#rgb`` / ``#rrggbb`` hex string → ``(r, g, b)`` ints (0–255).
197
+
198
+ Raises ``ValueError`` for anything else — tint colours are user API input
199
+ and a silent fallback would just render the wrong colour.
200
+ """
201
+ if not isinstance(color, str):
202
+ raise ValueError(f"expected a hex colour string, got {color!r}")
203
+ h = color.strip().lstrip("#")
204
+ try:
205
+ if len(h) == 3:
206
+ return tuple(int(c * 2, 16) for c in h)
207
+ if len(h) == 6:
208
+ return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
209
+ except ValueError:
210
+ pass
211
+ raise ValueError(
212
+ f"invalid hex colour {color!r} — expected '#rgb' or '#rrggbb'")
213
+
214
+
215
+ @functools.lru_cache(maxsize=64)
216
+ def _build_tint_lut(color: str) -> list:
217
+ """Return a 256-entry ``[[r, g, b, a], ...]`` clear→colour TINT LUT.
218
+
219
+ RGB is the parsed *color* at every entry; alpha ramps linearly 0 → 255, so
220
+ a layer using this LUT is fully transparent at low intensity and the
221
+ opaque tint colour at high intensity (a solid-colour intensity ramp, the
222
+ overlay look, vs a named colormap's opaque colour gradient).
223
+
224
+ CACHED like :func:`_build_colormap_lut` and treated as read-only by
225
+ callers (they JSON-serialise it); do NOT mutate the returned list. The
226
+ 4th (alpha) channel is honoured by the JS layer compositor
227
+ (``_layerBitmap`` reads ``cmapData[i][3] ?? 255``).
228
+ """
229
+ r, g, b = _parse_hex_color(color)
230
+ return [[r, g, b, a] for a in range(256)]
231
+
232
+
195
233
  def _resample_mesh(data: np.ndarray, x_edges, y_edges) -> np.ndarray:
196
234
  """Resample a mesh to a regular pixel grid via nearest-neighbour lookup.
197
235
 
@@ -96,9 +96,10 @@ class InsetAxes(Axes):
96
96
  self.h_frac = h_frac
97
97
  self.title = title
98
98
  self._inset_state: str = "normal"
99
- # Region indication (mark_inset-style callout) tied to this inset, or
100
- # None. Set via :meth:`indicate_region`, cleared via
101
- # :meth:`clear_indication`. Persisted in Figure.layout_json.
99
+ # Region/point indication (mark_inset-style callout) tied to this
100
+ # inset, or None. Set via :meth:`indicate_region` /
101
+ # :meth:`indicate_point`, cleared via :meth:`clear_indication`.
102
+ # Persisted in Figure.layout_json.
102
103
  self._indication: dict | None = None
103
104
 
104
105
  # ── state API ─────────────────────────────────────────────────────────
@@ -129,6 +130,76 @@ class InsetAxes(Axes):
129
130
  self._inset_state = "normal"
130
131
  self._fig._push_layout()
131
132
 
133
+ def set_geometry(self, *, anchor=None, w_frac: float | None = None,
134
+ h_frac: float | None = None) -> "InsetAxes":
135
+ """Move and/or resize the inset in figure-fraction coordinates.
136
+
137
+ This is the Python-side counterpart to the interactive drag / resize
138
+ of an inset in the renderer's edit mode: the ``inset_geometry_change``
139
+ event dispatches here so the authoritative Python state converges with
140
+ what the user did on screen. It is also callable directly to place or
141
+ size an inset programmatically.
142
+
143
+ Passing *anchor* switches the inset to free (anchor) placement and
144
+ drops any corner snapping (``corner`` becomes ``None``), mirroring the
145
+ renderer's corner-to-anchor conversion on drag start.
146
+
147
+ Parameters
148
+ ----------
149
+ anchor : (x_frac, y_frac), optional
150
+ New position of the inset's TOP-LEFT corner as fractions of the
151
+ figure size (0–1), measured from the figure's top-left. Each
152
+ component is clamped into ``[0, 1]``. When given, ``corner`` is
153
+ set to ``None`` (free placement). When omitted, the current
154
+ placement (corner or existing anchor) is left unchanged.
155
+ w_frac, h_frac : float, optional
156
+ New width / height as fractions of the figure size. Each is
157
+ clamped into ``(0, 1]`` (must be positive, at most the full
158
+ figure). When omitted, the corresponding dimension is unchanged.
159
+
160
+ Returns
161
+ -------
162
+ InsetAxes
163
+ ``self``, for chaining.
164
+
165
+ Raises
166
+ ------
167
+ ValueError
168
+ If *anchor* is not a pair of finite numbers, or ``w_frac`` /
169
+ ``h_frac`` is not a finite number.
170
+ """
171
+ if anchor is not None:
172
+ try:
173
+ ax_, ay_ = (float(v) for v in anchor)
174
+ except (TypeError, ValueError):
175
+ raise ValueError(
176
+ f"set_geometry: anchor must be 2 numbers (x_frac, y_frac), "
177
+ f"got {anchor!r}") from None
178
+ if not all(math.isfinite(v) for v in (ax_, ay_)):
179
+ raise ValueError(
180
+ f"set_geometry: anchor values must be finite, got "
181
+ f"({ax_}, {ay_})")
182
+ ax_ = min(1.0, max(0.0, ax_))
183
+ ay_ = min(1.0, max(0.0, ay_))
184
+ self.anchor = (ax_, ay_)
185
+ # Anchor placement supersedes corner (matches __init__ / the JS
186
+ # corner→anchor conversion on drag start).
187
+ self.corner = None
188
+ if w_frac is not None:
189
+ w = float(w_frac)
190
+ if not math.isfinite(w):
191
+ raise ValueError(
192
+ f"set_geometry: w_frac must be finite, got {w_frac!r}")
193
+ self.w_frac = min(1.0, max(1e-6, w))
194
+ if h_frac is not None:
195
+ h = float(h_frac)
196
+ if not math.isfinite(h):
197
+ raise ValueError(
198
+ f"set_geometry: h_frac must be finite, got {h_frac!r}")
199
+ self.h_frac = min(1.0, max(1e-6, h))
200
+ self._fig._push_layout()
201
+ return self
202
+
132
203
  # ── region indication (mark_inset-style callout) ──────────────────────
133
204
 
134
205
  def indicate_region(self, parent_plot, region, *,
@@ -218,8 +289,94 @@ class InsetAxes(Axes):
218
289
  self._fig._push_layout()
219
290
  return self
220
291
 
292
+ def indicate_point(self, parent_plot, point, *,
293
+ color: str = "#ff9800",
294
+ linestyle: str = "dashed",
295
+ linewidth: float = 1.5,
296
+ marker_size: float = 5.0) -> "InsetAxes":
297
+ """Draw a callout tying this inset to a single POINT of *parent_plot*.
298
+
299
+ The point sibling of :meth:`indicate_region`: renders a small circular
300
+ marker (with a centre cross) at *point* — in the parent image's DATA
301
+ coordinates — plus ONE leader line joining the marker to the inset's
302
+ nearest corner. The marker tracks the parent's zoom / pan and the
303
+ leader follows the inset as it moves (the leader hides while the inset
304
+ is minimized), exactly like the region callout.
305
+
306
+ Calling ``indicate_point`` (or ``indicate_region``) again REPLACES any
307
+ previous indication for this inset — an inset carries at most one.
308
+ Remove it with :meth:`clear_indication`.
309
+
310
+ Parameters
311
+ ----------
312
+ parent_plot : Plot2D
313
+ The parent image plot the point lives on. Must be a 2-D image
314
+ panel registered on the SAME figure as this inset.
315
+ point : tuple of float
316
+ The source point in the parent image's data coordinates, as
317
+ ``(x, y)`` — same convention as :meth:`indicate_region`'s region
318
+ origin. Both values must be finite; a point outside the parent's
319
+ data bounds is allowed (it simply clips visually).
320
+ color : str, optional
321
+ Stroke colour of the marker and the leader line. Default warm
322
+ orange ``"#ff9800"``.
323
+ linestyle : str, optional
324
+ Leader style — ``"dashed"`` (default), ``"solid"``, or
325
+ ``"dotted"``. The marker itself is always drawn solid.
326
+ linewidth : float, optional
327
+ Stroke width in CSS px. Default ``1.5``.
328
+ marker_size : float, optional
329
+ Marker circle radius in CSS px. Default ``5.0``. Must be > 0.
330
+
331
+ Returns
332
+ -------
333
+ InsetAxes
334
+ ``self``, for chaining.
335
+
336
+ Raises
337
+ ------
338
+ ValueError
339
+ If ``parent_plot`` has no panel id, is not registered on this
340
+ inset's Figure, ``point`` is not 2 finite numbers, or
341
+ ``marker_size`` is not > 0.
342
+ """
343
+ pid = getattr(parent_plot, "_id", None)
344
+ if pid is None:
345
+ raise ValueError("indicate_point: parent_plot has no panel id "
346
+ "(attach it to the figure first)")
347
+ if self._fig._plots_map.get(pid) is not parent_plot:
348
+ raise ValueError(
349
+ "indicate_point: parent_plot is not registered on this "
350
+ "inset's Figure — pass a plot created on the same figure "
351
+ "as this inset (fig.add_inset / fig.subplots)")
352
+ try:
353
+ x, y = (float(v) for v in point)
354
+ except (TypeError, ValueError):
355
+ raise ValueError(
356
+ f"indicate_point: point must be 2 numbers (x, y), "
357
+ f"got {point!r}") from None
358
+ if not all(math.isfinite(v) for v in (x, y)):
359
+ raise ValueError(
360
+ f"indicate_point: point values must be finite, got "
361
+ f"(x={x}, y={y})")
362
+ ms = float(marker_size)
363
+ if not (math.isfinite(ms) and ms > 0):
364
+ raise ValueError(
365
+ f"indicate_point: marker_size must be > 0, got {marker_size!r}")
366
+ self._indication = {
367
+ "parent_id": pid,
368
+ "point": [x, y],
369
+ "color": color,
370
+ "linestyle": linestyle,
371
+ "linewidth": float(linewidth),
372
+ "marker_size": ms,
373
+ }
374
+ self._fig._push_layout()
375
+ return self
376
+
221
377
  def clear_indication(self) -> None:
222
- """Remove any region indication attached to this inset (idempotent)."""
378
+ """Remove any region/point indication attached to this inset
379
+ (idempotent)."""
223
380
  if self._indication is None:
224
381
  return
225
382
  self._indication = None
@@ -227,7 +384,8 @@ class InsetAxes(Axes):
227
384
 
228
385
  @property
229
386
  def indication(self) -> "dict | None":
230
- """The current region-indication spec (``dict``) or ``None``."""
387
+ """The current indication spec (``dict`` with either a ``region`` or a
388
+ ``point`` key) or ``None``."""
231
389
  return self._indication
232
390
 
233
391
  # ── internal ──────────────────────────────────────────────────────────
anyplotlib/callbacks.py CHANGED
@@ -24,7 +24,10 @@ from typing import Any, Callable
24
24
  VALID_EVENT_TYPES = frozenset({
25
25
  "pointer_down", "pointer_up", "pointer_move", "pointer_settled",
26
26
  "pointer_enter", "pointer_leave", "double_click", "wheel",
27
- "key_down", "key_up", "close", "view_changed", "*",
27
+ "key_down", "key_up", "close", "view_changed",
28
+ # Figure-level inset drag / resize (JS → Python), so a host can persist the
29
+ # inset's new geometry. Fired through the figure's CallbackRegistry.
30
+ "inset_geometry_change", "*",
28
31
  })
29
32
 
30
33
 
@@ -43,6 +46,7 @@ class Event:
43
46
  ray — Plot3D only: {"origin": [...], "direction": [...]}
44
47
  line_id — Plot1D only: set when pointer is over a line
45
48
  dwell_ms — pointer_settled only: actual dwell time
49
+ target — double_click only: hit chrome element, or None for plot-area (title/x_label/x_ticks/y_label/y_ticks/colorbar_label/legend)
46
50
 
47
51
  PlotBar extra fields (pointer_down only):
48
52
  bar_index, value, x_label, group_index
@@ -54,6 +58,14 @@ class Event:
54
58
  key — key name e.g. "q", "Enter", "ArrowLeft"
55
59
  last_widget_id — id of the last widget the user clicked, or None
56
60
 
61
+ Panel-swap fields (figure-level panel_swap events):
62
+ source_panel_id, target_panel_id — the two panel ids dragged between
63
+
64
+ Inset drag/resize fields (figure-level inset_geometry_change events):
65
+ inset_id — the inset panel id
66
+ anchor — new top-left position [fx, fy] in figure fractions
67
+ w_frac, h_frac — new size in figure fractions
68
+
57
69
  Propagation:
58
70
  stop_propagation — set True inside a handler to halt remaining handlers
59
71
  """
@@ -94,6 +106,18 @@ class Event:
94
106
  # ids the user dragged between. Set only on panel_swap; None otherwise.
95
107
  source_panel_id: str | None = None
96
108
  target_panel_id: str | None = None
109
+ # Inset drag / resize (figure-level inset_geometry_change events): the
110
+ # inset panel id plus its new geometry in figure fractions. Set only on
111
+ # inset_geometry_change; None otherwise.
112
+ inset_id: str | None = None
113
+ anchor: list | None = None
114
+ w_frac: float | None = None
115
+ h_frac: float | None = None
116
+ # Tagged double-click hit-target (double_click events): identifies WHICH
117
+ # text/chrome element was double-clicked — one of 'title', 'x_label',
118
+ # 'x_ticks', 'y_label', 'y_ticks', 'colorbar_label', 'legend'. None for a
119
+ # plain plot-area double_click (back-compatible) and every other event.
120
+ target: str | None = None
97
121
  # Propagation (not repr'd)
98
122
  stop_propagation: bool = field(default=False, repr=False)
99
123
 
@@ -101,7 +125,7 @@ class Event:
101
125
  src = type(self.source).__name__ if self.source is not None else "None"
102
126
  parts = [f"event_type={self.event_type!r}", f"source={src}"]
103
127
  for fname in ("x", "y", "xdata", "ydata", "button", "key",
104
- "line_id", "bar_index", "dwell_ms"):
128
+ "line_id", "bar_index", "dwell_ms", "target"):
105
129
  v = getattr(self, fname)
106
130
  if v is not None:
107
131
  parts.append(f"{fname}={v!r}")
@@ -596,6 +596,23 @@ class Figure(anywidget.AnyWidget, _EventMixin):
596
596
  self._push_layout()
597
597
  return
598
598
 
599
+ # Inset drag / resize: the JS ships the inset's final geometry on
600
+ # pointer_up (anchor top-left fraction + size fractions). Apply it to
601
+ # the InsetAxes via set_geometry so the authoritative Python state
602
+ # converges (the event fires once, on release, so re-pushing layout is
603
+ # an idempotent echo), then fire the figure-level callbacks with the
604
+ # geometry so a host can persist it. Mirrors inset_state_change above.
605
+ if event_type == "inset_geometry_change":
606
+ inset_ax = self._insets_map.get(panel_id)
607
+ if inset_ax is not None:
608
+ anchor = msg.get("anchor")
609
+ w_frac = msg.get("w_frac")
610
+ h_frac = msg.get("h_frac")
611
+ inset_ax.set_geometry(anchor=anchor, w_frac=w_frac,
612
+ h_frac=h_frac)
613
+ self._fire_figure_event(event_type, msg)
614
+ return
615
+
599
616
  plot = self._plots_map.get(panel_id)
600
617
  if plot is None:
601
618
  if event_type == "view_changed":
@@ -656,6 +673,7 @@ class Figure(anywidget.AnyWidget, _EventMixin):
656
673
  display_height=msg.get("display_height"),
657
674
  key=msg.get("key"),
658
675
  last_widget_id=msg.get("last_widget_id"),
676
+ target=msg.get("target"),
659
677
  )
660
678
  plot.callbacks.fire(event)
661
679
 
@@ -683,6 +701,17 @@ class Figure(anywidget.AnyWidget, _EventMixin):
683
701
  last_widget_id=msg.get("marker_id"),
684
702
  source_panel_id=msg.get("source_panel_id"),
685
703
  target_panel_id=msg.get("target_panel_id"),
704
+ # Inset drag / resize geometry — populated only for the
705
+ # inset_geometry_change event; None for every other figure-level
706
+ # event (figure_background click, figure-marker drag, panel_swap).
707
+ inset_id=(msg.get("panel_id")
708
+ if event_type == "inset_geometry_change" else None),
709
+ anchor=(msg.get("anchor")
710
+ if event_type == "inset_geometry_change" else None),
711
+ w_frac=(msg.get("w_frac")
712
+ if event_type == "inset_geometry_change" else None),
713
+ h_frac=(msg.get("h_frac")
714
+ if event_type == "inset_geometry_change" else None),
686
715
  )
687
716
  self.callbacks.fire(event)
688
717