diff --git a/anyplotlib/FIGURE_ESM.md b/anyplotlib/FIGURE_ESM.md index e4c06be6..3931476f 100644 --- a/anyplotlib/FIGURE_ESM.md +++ b/anyplotlib/FIGURE_ESM.md @@ -148,18 +148,25 @@ inside the figure). Minimize / maximize / restore work for both — a maximized inset floats centred at ~72 % (z 45); a minimized one collapses to its title bar in place. -#### Region indications (callouts — `_drawCallouts`) +#### Region/point indications (callouts — `_drawCallouts`) `layout.indications` is an array of mark_inset-style callouts, each -`{inset_id, parent_id, region:[x,y,w,h], color, linestyle, linewidth}`. -`_drawCallouts()` renders them onto a figure-level `calloutCanvas` (z 30, above -panels + insets, below maximized-inset float and the resize handle, -`pointer-events:none`): +`{inset_id, parent_id, region:[x,y,w,h], color, linestyle, linewidth}` (from +`indicate_region`) or `{inset_id, parent_id, point:[x,y], color, linestyle, +linewidth, marker_size}` (from `indicate_point` — the `point` key selects the +branch). `_drawCallouts()` renders them onto a figure-level `calloutCanvas` +(z 30, above panels + insets, below maximized-inset float and the resize +handle, `pointer-events:none`): - The **dashed source rect** maps `region` (parent DATA coords) through the parent's `_imgToCanvas2d` every draw, so it tracks the parent's zoom/pan; it is clipped to the parent's image area. - Two **leader lines** connect the rect's corners facing the inset to the inset's nearest corners (loc1/loc2-auto by comparing centres); they follow the inset's live DOM rect and are **hidden while the inset is minimized**. +- A **point indication** draws a solid circle-and-cross marker (radius + `marker_size`, clipped to the parent image area like the rect) at the mapped + data point, plus ONE leader from the marker's rim to the inset's nearest + corner (same minimized-hide rule; the leader uses the indication's + linestyle, the marker itself is always solid). `_drawCallouts()` is called at the end of `_redrawPanel` / `redrawAll` (tracks zoom/pan), at the end of `_applyAllInsetStates` (inset moved), on `applyLayout` @@ -345,9 +352,13 @@ draws each **visible** layer bottom-up on `plotCanvas`: - `_layerBytes(st, layer)` prefers `layer__b64_bytes` (binary) over the entry `image_b64` base64; - `_layerBitmap(p, st, layer)` builds a LUT-colormapped RGBA `OffscreenCanvas`, - **cached per layer id** by `(pixel key, cmap, clim)` — rebuilt only when the - layer's data or appearance changes (a live scrub that only swaps one layer's - data rebuilds just that layer); + **cached per layer id** by `(pixel key, cmap, tint, has-alpha, clim)` — + rebuilt only when the layer's data or appearance changes (a live scrub that + only swaps one layer's data rebuilds just that layer). The LUT honours a 4th + (alpha) channel when present (`cmapData[i][3] ?? 255`) — a `tint=` layer + ships a 256×4 clear→colour ramp (`_build_tint_lut`), so per-texel alpha + composites through the unpremultiplied `ImageData` and multiplies naturally + with the per-layer `ctx.globalAlpha`; - it blits with the SAME fit-rect + zoom/pan transform as the base blit (`_imgFitRect` + the `zoom>=1` window math) at `ctx.globalAlpha = layer.alpha`, so zoom/pan track the base exactly. @@ -493,9 +504,14 @@ figure onto one offscreen canvas at `devicePixelRatio × scale`: - **WebGPU hazard first**: a WebGPU canvas's drawing buffer is only valid right after its render pass, so exportPNG force-calls `draw2d(p)` on every - active-GPU 2-D panel (`p._gpu==='active' && p.gpuCanvas` visible) to re-submit - its pass, THEN composites in the SAME synchronous task — so - `drawImage(gpuCanvas,…)` reads live pixels, not a blank buffer. + active-GPU 2-D panel and `draw3d(p)` on every active-GPU 3-D panel + (`p._gpu==='active' && p.gpuCanvas` visible, `_gpuImg`/`_gpuObj` present) to + re-submit its pass, THEN composites in the SAME synchronous task — so + `drawImage(gpuCanvas,…)` reads live pixels, not a blank buffer. (draw3d's + active-GPU path uploads + submits in-task, no rAF, so the same-task re-render + suffices for 3-D too — without it a scatter3d/voxels panel exported as an + empty background rectangle; see `TestExportGpu3d` in + `tests/test_embed/test_export_png.py`.) - **Extent**: `fig_width/height + 2×8 px` gridDiv padding (NOT the measured `gridDiv` width — a bare `mount()` page has no `.apl-outer` inline-block CSS, so the grid container can stretch to the viewport). **Origin**: `gridDiv`'s diff --git a/anyplotlib/_utils.py b/anyplotlib/_utils.py index 0c85708c..11bb2333 100644 --- a/anyplotlib/_utils.py +++ b/anyplotlib/_utils.py @@ -192,6 +192,44 @@ def _build_colormap_lut(name: str) -> list: return [[v, v, v] for v in range(256)] +def _parse_hex_color(color: str) -> tuple: + """Parse a ``#rgb`` / ``#rrggbb`` hex string → ``(r, g, b)`` ints (0–255). + + Raises ``ValueError`` for anything else — tint colours are user API input + and a silent fallback would just render the wrong colour. + """ + if not isinstance(color, str): + raise ValueError(f"expected a hex colour string, got {color!r}") + h = color.strip().lstrip("#") + try: + if len(h) == 3: + return tuple(int(c * 2, 16) for c in h) + if len(h) == 6: + return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)) + except ValueError: + pass + raise ValueError( + f"invalid hex colour {color!r} — expected '#rgb' or '#rrggbb'") + + +@functools.lru_cache(maxsize=64) +def _build_tint_lut(color: str) -> list: + """Return a 256-entry ``[[r, g, b, a], ...]`` clear→colour TINT LUT. + + RGB is the parsed *color* at every entry; alpha ramps linearly 0 → 255, so + a layer using this LUT is fully transparent at low intensity and the + opaque tint colour at high intensity (a solid-colour intensity ramp, the + overlay look, vs a named colormap's opaque colour gradient). + + CACHED like :func:`_build_colormap_lut` and treated as read-only by + callers (they JSON-serialise it); do NOT mutate the returned list. The + 4th (alpha) channel is honoured by the JS layer compositor + (``_layerBitmap`` reads ``cmapData[i][3] ?? 255``). + """ + r, g, b = _parse_hex_color(color) + return [[r, g, b, a] for a in range(256)] + + def _resample_mesh(data: np.ndarray, x_edges, y_edges) -> np.ndarray: """Resample a mesh to a regular pixel grid via nearest-neighbour lookup. diff --git a/anyplotlib/axes/_inset_axes.py b/anyplotlib/axes/_inset_axes.py index 59226080..d25f2ee0 100644 --- a/anyplotlib/axes/_inset_axes.py +++ b/anyplotlib/axes/_inset_axes.py @@ -96,9 +96,10 @@ def __init__(self, fig, w_frac: float, h_frac: float, *, self.h_frac = h_frac self.title = title self._inset_state: str = "normal" - # Region indication (mark_inset-style callout) tied to this inset, or - # None. Set via :meth:`indicate_region`, cleared via - # :meth:`clear_indication`. Persisted in Figure.layout_json. + # Region/point indication (mark_inset-style callout) tied to this + # inset, or None. Set via :meth:`indicate_region` / + # :meth:`indicate_point`, cleared via :meth:`clear_indication`. + # Persisted in Figure.layout_json. self._indication: dict | None = None # ── state API ───────────────────────────────────────────────────────── @@ -129,6 +130,76 @@ def restore(self) -> None: self._inset_state = "normal" self._fig._push_layout() + def set_geometry(self, *, anchor=None, w_frac: float | None = None, + h_frac: float | None = None) -> "InsetAxes": + """Move and/or resize the inset in figure-fraction coordinates. + + This is the Python-side counterpart to the interactive drag / resize + of an inset in the renderer's edit mode: the ``inset_geometry_change`` + event dispatches here so the authoritative Python state converges with + what the user did on screen. It is also callable directly to place or + size an inset programmatically. + + Passing *anchor* switches the inset to free (anchor) placement and + drops any corner snapping (``corner`` becomes ``None``), mirroring the + renderer's corner-to-anchor conversion on drag start. + + Parameters + ---------- + anchor : (x_frac, y_frac), optional + New position of the inset's TOP-LEFT corner as fractions of the + figure size (0–1), measured from the figure's top-left. Each + component is clamped into ``[0, 1]``. When given, ``corner`` is + set to ``None`` (free placement). When omitted, the current + placement (corner or existing anchor) is left unchanged. + w_frac, h_frac : float, optional + New width / height as fractions of the figure size. Each is + clamped into ``(0, 1]`` (must be positive, at most the full + figure). When omitted, the corresponding dimension is unchanged. + + Returns + ------- + InsetAxes + ``self``, for chaining. + + Raises + ------ + ValueError + If *anchor* is not a pair of finite numbers, or ``w_frac`` / + ``h_frac`` is not a finite number. + """ + if anchor is not None: + try: + ax_, ay_ = (float(v) for v in anchor) + except (TypeError, ValueError): + raise ValueError( + f"set_geometry: anchor must be 2 numbers (x_frac, y_frac), " + f"got {anchor!r}") from None + if not all(math.isfinite(v) for v in (ax_, ay_)): + raise ValueError( + f"set_geometry: anchor values must be finite, got " + f"({ax_}, {ay_})") + ax_ = min(1.0, max(0.0, ax_)) + ay_ = min(1.0, max(0.0, ay_)) + self.anchor = (ax_, ay_) + # Anchor placement supersedes corner (matches __init__ / the JS + # corner→anchor conversion on drag start). + self.corner = None + if w_frac is not None: + w = float(w_frac) + if not math.isfinite(w): + raise ValueError( + f"set_geometry: w_frac must be finite, got {w_frac!r}") + self.w_frac = min(1.0, max(1e-6, w)) + if h_frac is not None: + h = float(h_frac) + if not math.isfinite(h): + raise ValueError( + f"set_geometry: h_frac must be finite, got {h_frac!r}") + self.h_frac = min(1.0, max(1e-6, h)) + self._fig._push_layout() + return self + # ── region indication (mark_inset-style callout) ────────────────────── def indicate_region(self, parent_plot, region, *, @@ -218,8 +289,94 @@ def indicate_region(self, parent_plot, region, *, self._fig._push_layout() return self + def indicate_point(self, parent_plot, point, *, + color: str = "#ff9800", + linestyle: str = "dashed", + linewidth: float = 1.5, + marker_size: float = 5.0) -> "InsetAxes": + """Draw a callout tying this inset to a single POINT of *parent_plot*. + + The point sibling of :meth:`indicate_region`: renders a small circular + marker (with a centre cross) at *point* — in the parent image's DATA + coordinates — plus ONE leader line joining the marker to the inset's + nearest corner. The marker tracks the parent's zoom / pan and the + leader follows the inset as it moves (the leader hides while the inset + is minimized), exactly like the region callout. + + Calling ``indicate_point`` (or ``indicate_region``) again REPLACES any + previous indication for this inset — an inset carries at most one. + Remove it with :meth:`clear_indication`. + + Parameters + ---------- + parent_plot : Plot2D + The parent image plot the point lives on. Must be a 2-D image + panel registered on the SAME figure as this inset. + point : tuple of float + The source point in the parent image's data coordinates, as + ``(x, y)`` — same convention as :meth:`indicate_region`'s region + origin. Both values must be finite; a point outside the parent's + data bounds is allowed (it simply clips visually). + color : str, optional + Stroke colour of the marker and the leader line. Default warm + orange ``"#ff9800"``. + linestyle : str, optional + Leader style — ``"dashed"`` (default), ``"solid"``, or + ``"dotted"``. The marker itself is always drawn solid. + linewidth : float, optional + Stroke width in CSS px. Default ``1.5``. + marker_size : float, optional + Marker circle radius in CSS px. Default ``5.0``. Must be > 0. + + Returns + ------- + InsetAxes + ``self``, for chaining. + + Raises + ------ + ValueError + If ``parent_plot`` has no panel id, is not registered on this + inset's Figure, ``point`` is not 2 finite numbers, or + ``marker_size`` is not > 0. + """ + pid = getattr(parent_plot, "_id", None) + if pid is None: + raise ValueError("indicate_point: parent_plot has no panel id " + "(attach it to the figure first)") + if self._fig._plots_map.get(pid) is not parent_plot: + raise ValueError( + "indicate_point: parent_plot is not registered on this " + "inset's Figure — pass a plot created on the same figure " + "as this inset (fig.add_inset / fig.subplots)") + try: + x, y = (float(v) for v in point) + except (TypeError, ValueError): + raise ValueError( + f"indicate_point: point must be 2 numbers (x, y), " + f"got {point!r}") from None + if not all(math.isfinite(v) for v in (x, y)): + raise ValueError( + f"indicate_point: point values must be finite, got " + f"(x={x}, y={y})") + ms = float(marker_size) + if not (math.isfinite(ms) and ms > 0): + raise ValueError( + f"indicate_point: marker_size must be > 0, got {marker_size!r}") + self._indication = { + "parent_id": pid, + "point": [x, y], + "color": color, + "linestyle": linestyle, + "linewidth": float(linewidth), + "marker_size": ms, + } + self._fig._push_layout() + return self + def clear_indication(self) -> None: - """Remove any region indication attached to this inset (idempotent).""" + """Remove any region/point indication attached to this inset + (idempotent).""" if self._indication is None: return self._indication = None @@ -227,7 +384,8 @@ def clear_indication(self) -> None: @property def indication(self) -> "dict | None": - """The current region-indication spec (``dict``) or ``None``.""" + """The current indication spec (``dict`` with either a ``region`` or a + ``point`` key) or ``None``.""" return self._indication # ── internal ────────────────────────────────────────────────────────── diff --git a/anyplotlib/callbacks.py b/anyplotlib/callbacks.py index ac65e643..47e656b6 100644 --- a/anyplotlib/callbacks.py +++ b/anyplotlib/callbacks.py @@ -24,7 +24,10 @@ VALID_EVENT_TYPES = frozenset({ "pointer_down", "pointer_up", "pointer_move", "pointer_settled", "pointer_enter", "pointer_leave", "double_click", "wheel", - "key_down", "key_up", "close", "view_changed", "*", + "key_down", "key_up", "close", "view_changed", + # Figure-level inset drag / resize (JS → Python), so a host can persist the + # inset's new geometry. Fired through the figure's CallbackRegistry. + "inset_geometry_change", "*", }) @@ -43,6 +46,7 @@ class Event: ray — Plot3D only: {"origin": [...], "direction": [...]} line_id — Plot1D only: set when pointer is over a line dwell_ms — pointer_settled only: actual dwell time + target — double_click only: hit chrome element, or None for plot-area (title/x_label/x_ticks/y_label/y_ticks/colorbar_label/legend) PlotBar extra fields (pointer_down only): bar_index, value, x_label, group_index @@ -54,6 +58,14 @@ class Event: key — key name e.g. "q", "Enter", "ArrowLeft" last_widget_id — id of the last widget the user clicked, or None + Panel-swap fields (figure-level panel_swap events): + source_panel_id, target_panel_id — the two panel ids dragged between + + Inset drag/resize fields (figure-level inset_geometry_change events): + inset_id — the inset panel id + anchor — new top-left position [fx, fy] in figure fractions + w_frac, h_frac — new size in figure fractions + Propagation: stop_propagation — set True inside a handler to halt remaining handlers """ @@ -94,6 +106,18 @@ class Event: # ids the user dragged between. Set only on panel_swap; None otherwise. source_panel_id: str | None = None target_panel_id: str | None = None + # Inset drag / resize (figure-level inset_geometry_change events): the + # inset panel id plus its new geometry in figure fractions. Set only on + # inset_geometry_change; None otherwise. + inset_id: str | None = None + anchor: list | None = None + w_frac: float | None = None + h_frac: float | None = None + # Tagged double-click hit-target (double_click events): identifies WHICH + # text/chrome element was double-clicked — one of 'title', 'x_label', + # 'x_ticks', 'y_label', 'y_ticks', 'colorbar_label', 'legend'. None for a + # plain plot-area double_click (back-compatible) and every other event. + target: str | None = None # Propagation (not repr'd) stop_propagation: bool = field(default=False, repr=False) @@ -101,7 +125,7 @@ def __repr__(self) -> str: src = type(self.source).__name__ if self.source is not None else "None" parts = [f"event_type={self.event_type!r}", f"source={src}"] for fname in ("x", "y", "xdata", "ydata", "button", "key", - "line_id", "bar_index", "dwell_ms"): + "line_id", "bar_index", "dwell_ms", "target"): v = getattr(self, fname) if v is not None: parts.append(f"{fname}={v!r}") diff --git a/anyplotlib/figure/_figure.py b/anyplotlib/figure/_figure.py index 52741da9..8d3306f6 100644 --- a/anyplotlib/figure/_figure.py +++ b/anyplotlib/figure/_figure.py @@ -596,6 +596,23 @@ def _dispatch_event(self, raw: str) -> None: self._push_layout() return + # Inset drag / resize: the JS ships the inset's final geometry on + # pointer_up (anchor top-left fraction + size fractions). Apply it to + # the InsetAxes via set_geometry so the authoritative Python state + # converges (the event fires once, on release, so re-pushing layout is + # an idempotent echo), then fire the figure-level callbacks with the + # geometry so a host can persist it. Mirrors inset_state_change above. + if event_type == "inset_geometry_change": + inset_ax = self._insets_map.get(panel_id) + if inset_ax is not None: + anchor = msg.get("anchor") + w_frac = msg.get("w_frac") + h_frac = msg.get("h_frac") + inset_ax.set_geometry(anchor=anchor, w_frac=w_frac, + h_frac=h_frac) + self._fire_figure_event(event_type, msg) + return + plot = self._plots_map.get(panel_id) if plot is None: if event_type == "view_changed": @@ -656,6 +673,7 @@ def _dispatch_event(self, raw: str) -> None: display_height=msg.get("display_height"), key=msg.get("key"), last_widget_id=msg.get("last_widget_id"), + target=msg.get("target"), ) plot.callbacks.fire(event) @@ -683,6 +701,17 @@ def _fire_figure_event(self, event_type: str, msg: dict) -> None: last_widget_id=msg.get("marker_id"), source_panel_id=msg.get("source_panel_id"), target_panel_id=msg.get("target_panel_id"), + # Inset drag / resize geometry — populated only for the + # inset_geometry_change event; None for every other figure-level + # event (figure_background click, figure-marker drag, panel_swap). + inset_id=(msg.get("panel_id") + if event_type == "inset_geometry_change" else None), + anchor=(msg.get("anchor") + if event_type == "inset_geometry_change" else None), + w_frac=(msg.get("w_frac") + if event_type == "inset_geometry_change" else None), + h_frac=(msg.get("h_frac") + if event_type == "inset_geometry_change" else None), ) self.callbacks.fire(event) diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 544a44a1..add93258 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -549,6 +549,25 @@ function render({ model, el, onResize }) { const INSET_TITLE_H = 22; // px — title bar height const INSET_GAP = 8; // px — gap between stacked insets in same corner const INSET_MARGIN = 10; // px — distance from figure edge to first inset + const INSET_RESIZE_PX = 12; // px — edit-mode bottom-right resize handle + const INSET_MIN_PX = 64; // px — min inset dim on drag (matches Python clamp) + const INSET_DRAG_THRESH = 3;// px — move before a pointerdown becomes a drag + + // An inset with no title (empty/whitespace) hides its title-bar strip + // entirely — the content fills the whole box, no useless empty header. + // This is the ONE place that decides "does this inset have a bar"; every + // layout computation (stack height, drag/resize clamps) reads through it + // instead of the raw INSET_TITLE_H constant so a title-less inset's box + // height equals its content height exactly (no phantom gap at the top). + function _hasInsetTitle(spec) { + return !!(spec && spec.title && String(spec.title).trim()); + } + function _insetTitleH(spec) { + return _hasInsetTitle(spec) ? INSET_TITLE_H : 0; + } + // Active inset move / resize drag (edit mode). See _wireInsetDrag. + // {p, mode:'move'|'resize', snap:{layout,spec}, ...} or null. + let _insetDrag = null; // Resize handle (figure-level) const resizeHandle = document.createElement('div'); @@ -1079,13 +1098,16 @@ function render({ model, el, onResize }) { // ── _createInsetDOM ─────────────────────────────────────────────────────── // Builds a floating inset panel: // insetDiv (position:absolute inside insetsContainer) - // ├── titleBar — always visible; click to toggle min/normal - // │ ├── titleSpan - // │ └── maxBtn (⤢ / ⤡) + // ├── titleBar — visible only when spec.title is non-empty; click to + // │ │ toggle min/normal. A title-less inset (the common + // │ │ "callout" case) has NO title bar at all — the content + // │ │ div fills the whole box, just a clean bordered plot. + // │ └── titleSpan // └── contentDiv — canvas stack; display:none when minimized // └── _buildCanvasStack(kind, pw, ph) function _createInsetDOM(spec) { const { id, kind, panel_width: pw, panel_height: ph, title, inset_state } = spec; + const hasTitle = _hasInsetTitle(spec); const insetDiv = document.createElement('div'); insetDiv.style.cssText = @@ -1093,11 +1115,13 @@ function render({ model, el, onResize }) { `box-shadow:0 2px 14px rgba(0,0,0,0.55);border:1px solid ${theme.border};z-index:25;background:${theme.bg};`; insetsContainer.appendChild(insetDiv); - // Title bar + // Title bar — laid out even when empty (kept as a DOM node so later specs + // that DO carry a title can reveal it), but display:none + zero height + // when there is no title so the content fills the whole inset box. const tbBg = theme.dark ? 'rgba(30,32,46,0.97)' : 'rgba(210,213,224,0.97)'; const titleBar = document.createElement('div'); titleBar.style.cssText = - `display:flex;align-items:center;height:${INSET_TITLE_H}px;` + + `display:${hasTitle ? 'flex' : 'none'};align-items:center;height:${INSET_TITLE_H}px;` + `cursor:pointer;padding:0 5px 0 8px;user-select:none;background:${tbBg};` + `border-bottom:1px solid ${theme.border};box-sizing:border-box;flex-shrink:0;`; insetDiv.appendChild(titleBar); @@ -1116,6 +1140,21 @@ function render({ model, el, onResize }) { `overflow:hidden;display:${inset_state === 'minimized' ? 'none' : 'block'};`; insetDiv.appendChild(contentDiv); + // Edit-mode resize handle — a small grabber pinned to the inset's + // bottom-right corner. Hidden by default; shown/hidden by _applyEditMode + // (like the panel move-grip). Sits above the canvas stack (z 5) and the + // content div so it stays grabbable; cursor is the diagonal resize arrow. + const resizeGrip = document.createElement('div'); + resizeGrip.className = 'apl-inset-resize'; + resizeGrip.style.cssText = + `position:absolute;right:0;bottom:0;width:${INSET_RESIZE_PX}px;` + + `height:${INSET_RESIZE_PX}px;z-index:14;cursor:nwse-resize;display:none;` + + `background:${theme.bg || '#1e1e2e'};border-top:1px solid ${EDIT_ACCENT};` + + `border-left:1px solid ${EDIT_ACCENT};border-top-left-radius:4px;` + + `box-shadow:-1px -1px 3px rgba(0,0,0,0.35);`; + resizeGrip.title = 'Drag to resize this inset'; + insetDiv.appendChild(resizeGrip); + // Canvas stack inside contentDiv const stack = _buildCanvasStack(kind, pw, ph, contentDiv); @@ -1134,7 +1173,7 @@ function render({ model, el, onResize }) { const p = { id, kind, pw, ph, cell: insetDiv, // stale-cleanup compatibility (p.cell.remove()) - isInset: true, insetDiv, contentDiv, titleBar, + isInset: true, insetDiv, contentDiv, titleBar, resizeGrip, insetSpec: spec, plotCanvas: stack.plotCanvas, overlayCanvas: stack.overlayCanvas, @@ -1163,12 +1202,19 @@ function render({ model, el, onResize }) { _resizePanelDOM(id, pw, ph); _attachPanelEvents(p); - // Title bar click: toggle normal ↔ minimized + // Title bar click: toggle normal ↔ minimized. A completed move drag on + // the inset sets p._dragSuppressClick so the release click here does NOT + // also flip the minimize state (drag-to-move and click-to-minimize share + // the title bar). titleBar.addEventListener('click', (e) => { + if (p._dragSuppressClick) { p._dragSuppressClick = false; return; } const cur = p.insetSpec ? p.insetSpec.inset_state : 'normal'; _applyButtonState(p, cur === 'minimized' ? 'normal' : 'minimized'); }); + // Edit-mode drag-to-move (whole inset) and drag-to-resize (corner grip). + _wireInsetDrag(p); + // Geometry channel (only when this panel declared one on the Python side). const _geomTrait = `panel_${id}_geom`; @@ -1237,10 +1283,208 @@ function render({ model, el, onResize }) { _emitEvent(p.id, 'inset_state_change', null, { new_state: newState }); } + // ── inset drag-to-move / drag-to-resize (edit mode only) ────────────────── + // Wires two pointer gestures on an inset, both gated on _editOn(): + // • move — a capture-phase pointerdown anywhere on the inset (except the + // resize grip) drags the whole inset. A corner-stacked inset is + // converted to a free anchor on drag start (reading its current + // DOM position) so it floats and its corner siblings re-stack. + // • resize — a pointerdown on the bottom-right grip resizes the inset, + // clamped to INSET_MIN_PX per dim. + // Both work on a LOCAL layout snapshot (like _applyButtonState) so the DOM + // updates optimistically; on pointerup an 'inset_geometry_change' carries the + // final anchor + size fractions to Python, which echoes an authoritative + // layout_json back. A 3 px threshold distinguishes a move-drag from a click + // (so the title-bar minimize toggle still works on a plain click). + function _wireInsetDrag(p) { + const fig = () => { + const fw = Number(model.get('fig_width')) || 640; + const fh = Number(model.get('fig_height')) || 480; + return [fw, fh]; + }; + // Snapshot layout_json and return {layout, spec} for THIS inset (a private + // copy we mutate during the drag). + const snapshot = () => { + let layout; + try { layout = JSON.parse(model.get('layout_json')); } catch (_) { return null; } + const spec = (layout.inset_specs || []).find(s => s.id === p.id); + if (!spec) return null; + return { layout, spec }; + }; + + // ── MOVE: capture-phase pointerdown on the whole inset ────────────────── + p.insetDiv.addEventListener('pointerdown', (e) => { + if (!_editOn() || e.button !== 0) return; + // The resize grip owns its own gesture. + if (e.target === p.resizeGrip) return; + // Don't drag a maximized inset (it is centred / not anchor-positioned). + const st = p.insetSpec ? p.insetSpec.inset_state : 'normal'; + if (st === 'maximized') return; + + const snap = snapshot(); + if (!snap) return; + const [fw, fh] = fig(); + // Current top-left in figure px (DOM is already positioned). + const startLeft = parseFloat(p.insetDiv.style.left) || 0; + const startTop = parseFloat(p.insetDiv.style.top) || 0; + + _insetDrag = { + p, mode: 'move', snap, + fw, fh, + startPX: e.clientX, startPY: e.clientY, + startLeft, startTop, + pw: snap.spec.panel_width, ph: snap.spec.panel_height, + moved: false, + }; + try { p.insetDiv.setPointerCapture(e.pointerId); } catch (_) {} + // Capture phase + stopPropagation so the panel event handlers underneath + // don't also react to this pointerdown. + e.preventDefault(); e.stopPropagation(); + }, true); + + // ── RESIZE: pointerdown on the bottom-right grip ──────────────────────── + p.resizeGrip.addEventListener('pointerdown', (e) => { + if (!_editOn() || e.button !== 0) return; + const st = p.insetSpec ? p.insetSpec.inset_state : 'normal'; + if (st === 'maximized' || st === 'minimized') return; // nothing to resize + const snap = snapshot(); + if (!snap) return; + const [fw, fh] = fig(); + _insetDrag = { + p, mode: 'resize', snap, + fw, fh, + startPX: e.clientX, startPY: e.clientY, + startW: snap.spec.panel_width, startH: snap.spec.panel_height, + moved: false, + }; + try { p.resizeGrip.setPointerCapture(e.pointerId); } catch (_) {} + e.preventDefault(); e.stopPropagation(); + }); + + // Move / up handlers are attached to the same elements so pointer capture + // routes them here even when the cursor leaves the inset. + const onMove = (e) => { + if (!_insetDrag || _insetDrag.p !== p) return; + _doInsetDrag(e); + }; + const onUp = (e) => { + if (!_insetDrag || _insetDrag.p !== p) return; + _endInsetDrag(e); + }; + p.insetDiv.addEventListener('pointermove', onMove); + p.insetDiv.addEventListener('pointerup', onUp); + p.insetDiv.addEventListener('pointercancel', onUp); + p.resizeGrip.addEventListener('pointermove', onMove); + p.resizeGrip.addEventListener('pointerup', onUp); + p.resizeGrip.addEventListener('pointercancel', onUp); + } + + // Per-move handler for an in-progress inset move / resize (see _wireInsetDrag). + // rAF-throttles the callout redraw so leader lines re-aim live without a draw + // per pointer event. + let _insetCalloutRAF = 0; + function _doInsetDrag(e) { + const d = _insetDrag; + const p = d.p; + const dx = e.clientX - d.startPX; + const dy = e.clientY - d.startPY; + if (!d.moved && Math.hypot(dx, dy) < INSET_DRAG_THRESH) return; + const th = _insetTitleH(d.snap.spec); // 0 for a title-less inset + + if (d.mode === 'move') { + // On the FIRST real move: convert a corner-stacked inset to a free anchor + // at its current DOM position so it floats and its corner siblings + // re-stack. Mutate the LOCAL snapshot spec only. + if (!d.moved && d.snap.spec.corner) { + d.snap.spec.anchor = [d.startLeft / d.fw, d.startTop / d.fh]; + d.snap.spec.corner = null; + p.insetSpec = d.snap.spec; + } + d.moved = true; + // New top-left, clamped inside the figure box exactly like + // _applyAllInsetStates (the inset must stay fully visible). + const stackH = (d.snap.spec.inset_state === 'minimized') + ? th : th + d.ph; + let left = d.startLeft + dx; + let top = d.startTop + dy; + left = Math.max(0, Math.min(left, d.fw - d.pw)); + top = Math.max(0, Math.min(top, d.fh - stackH)); + d.curLeft = left; d.curTop = top; + d.snap.spec.anchor = [left / d.fw, top / d.fh]; + _applyAllInsetStates(d.snap.layout); + } else { // resize + d.moved = true; + let w = Math.round(d.startW + dx); + let h = Math.round(d.startH + dy); + // Min size (both dims) + keep the inset inside the figure from its + // current top-left. + const left = parseFloat(p.insetDiv.style.left) || 0; + const top = parseFloat(p.insetDiv.style.top) || 0; + w = Math.max(INSET_MIN_PX, Math.min(w, d.fw - left)); + h = Math.max(INSET_MIN_PX, Math.min(h, d.fh - top - th)); + d.curW = w; d.curH = h; + d.snap.spec.panel_width = w; + d.snap.spec.panel_height = h; + // Track the DOM box live; the canvas re-render is deferred to pointerup. + p.insetDiv.style.width = w + 'px'; + p.insetDiv.style.height = (th + h) + 'px'; + p.contentDiv.style.height = h + 'px'; + } + + // Re-aim callout leaders live (rAF-throttled). + if (!_insetCalloutRAF) { + _insetCalloutRAF = requestAnimationFrame(() => { + _insetCalloutRAF = 0; + _drawCallouts([d.fw, d.fh]); + }); + } + e.preventDefault(); + } + + // Finalise an inset move / resize: emit the geometry to Python and clear the + // drag. A move sets _dragSuppressClick so the title-bar click that follows a + // move-release does not also toggle minimize. + function _endInsetDrag(e) { + const d = _insetDrag; _insetDrag = null; + const p = d.p; + try { p.insetDiv.releasePointerCapture(e.pointerId); } catch (_) {} + try { p.resizeGrip.releasePointerCapture(e.pointerId); } catch (_) {} + if (_insetCalloutRAF) { cancelAnimationFrame(_insetCalloutRAF); _insetCalloutRAF = 0; } + + if (!d.moved) { e.preventDefault(); return; } // sub-threshold → a click + + if (d.mode === 'move') p._dragSuppressClick = true; + + // Resolve the final geometry in figure fractions. + const fw = d.fw, fh = d.fh; + let left, top, pw, ph; + if (d.mode === 'move') { + left = d.curLeft != null ? d.curLeft : (parseFloat(p.insetDiv.style.left) || 0); + top = d.curTop != null ? d.curTop : (parseFloat(p.insetDiv.style.top) || 0); + pw = d.pw; ph = d.ph; + } else { // resize + left = parseFloat(p.insetDiv.style.left) || 0; + top = parseFloat(p.insetDiv.style.top) || 0; + pw = d.curW != null ? d.curW : d.startW; + ph = d.curH != null ? d.curH : d.startH; + // Re-render the inset's canvas stack at the new size (deferred to now). + _resizePanelDOM(p.id, pw, ph); + _redrawPanel(p); + } + + _emitEvent(p.id, 'inset_geometry_change', null, { + anchor: [left / fw, top / fh], + w_frac: pw / fw, + h_frac: ph / fh, + }); + e.preventDefault(); + } + // ── _applyAllInsetStates ────────────────────────────────────────────────── // Positions every inset for the given layout snapshot. // Corner-placed insets are grouped by corner and stacked with INSET_GAP - // spacing (minimized ones contribute only INSET_TITLE_H to the stack). + // spacing (minimized ones contribute only their title-bar height, 0 for a + // title-less inset, to the stack). // Anchor-placed insets (spec.anchor != null) float at their anchor fraction. // Maximized insets float centred at z-index:45, outside any stack. function _applyAllInsetStates(layout) { @@ -1260,6 +1504,20 @@ function render({ model, el, onResize }) { (byCorner[spec.corner] = byCorner[spec.corner] || []).push(spec); } + // Sync each inset's title-bar visibility to its (possibly just-changed) + // spec before computing any geometry below — every stackH/height below + // reads through _insetTitleH(spec), so this must run first. + for (const spec of insetSpecs) { + const p = panels.get(spec.id); + if (!p || !p.isInset || !p.titleBar) continue; + const hasTitle = _hasInsetTitle(spec); + p.titleBar.style.display = hasTitle ? 'flex' : 'none'; + // A title-less inset has no minimize affordance; guard against a + // stale/programmatic 'minimized' state wedging it at zero height by + // treating it as 'normal' for layout purposes (no crash, sane visual). + if (!hasTitle && spec.inset_state === 'minimized') spec.inset_state = 'normal'; + } + // ── anchor-placed insets ──────────────────────────────────────────────── for (const spec of anchored) { const p = panels.get(spec.id); @@ -1267,7 +1525,8 @@ function render({ model, el, onResize }) { const pw = spec.panel_width; const ph = spec.panel_height; const state = spec.inset_state; - const stackH = state === 'minimized' ? INSET_TITLE_H : INSET_TITLE_H + ph; + const th = _insetTitleH(spec); + const stackH = state === 'minimized' ? th : th + ph; // Maximized floats centred (same treatment as corner insets below). if (state === 'maximized') { @@ -1275,7 +1534,7 @@ function render({ model, el, onResize }) { p.insetDiv.style.left = Math.round((fw - mw) / 2) + 'px'; p.insetDiv.style.top = Math.round((fh - mh) / 2) + 'px'; p.insetDiv.style.width = mw + 'px'; - p.insetDiv.style.height = (INSET_TITLE_H + mh) + 'px'; + p.insetDiv.style.height = (th + mh) + 'px'; p.insetDiv.style.zIndex = '45'; p.contentDiv.style.display = 'block'; p.contentDiv.style.height = mh + 'px'; @@ -1325,7 +1584,8 @@ function render({ model, el, onResize }) { // Normal or minimized: compute position from corner - const stackH = state === 'minimized' ? INSET_TITLE_H : INSET_TITLE_H + ph; + const th = _insetTitleH(spec); + const stackH = state === 'minimized' ? th : th + ph; const left = isRight ? fw - pw - INSET_MARGIN : INSET_MARGIN; const top = isBottom ? fh - offset - stackH : offset; @@ -1408,13 +1668,12 @@ function render({ model, el, onResize }) { const inset = panels.get(ind.inset_id); if (!parent || !parent.state || !inset || !inset.isInset) continue; - // ── source rectangle in parent-canvas px → figure px ───────────────── - // Map the region's four data-space corners through the parent's - // data→canvas transform (tracks zoom/pan), then offset by the parent - // markers canvas's position relative to the callout canvas. + // ── shared data→figure-px mapping + stroke setup ────────────────────── + // Map data-space coords through the parent's data→canvas transform + // (tracks zoom/pan), then offset by the parent markers canvas's position + // relative to the callout canvas. const st = parent.state; const imgW = parent.imgW || 1, imgH = parent.imgH || 1; - const [rx, ry, rw, rh] = ind.region; const mkRect = (parent.markersCanvas || parent.plotCanvas).getBoundingClientRect(); const offX = mkRect.left - base.left; const offY = mkRect.top - base.top; @@ -1422,13 +1681,6 @@ function render({ model, el, onResize }) { const [cx, cy] = _imgToCanvas2d(dx, dy, st, imgW, imgH); return [offX + cx, offY + cy]; }; - const [x0, y0] = toFig(rx, ry); - const [x1, y1] = toFig(rx + rw, ry); - const [x2, y2] = toFig(rx + rw, ry + rh); - const [x3, y3] = toFig(rx, ry + rh); - // Axis-aligned bounds of the (untransformed) rect in figure px. - const rL = Math.min(x0, x1, x2, x3), rR = Math.max(x0, x1, x2, x3); - const rT = Math.min(y0, y1, y2, y3), rB = Math.max(y0, y1, y2, y3); const color = ind.color || '#ff9800'; const lw = ind.linewidth != null ? ind.linewidth : 1.5; @@ -1441,6 +1693,60 @@ function render({ model, el, onResize }) { calloutCtx.lineWidth = lw; calloutCtx.setLineDash(dash); + // ── POINT indication (indicate_point): marker + single leader ──────── + // A small solid circle-with-cross at the data point (clipped to the + // parent's image area, like the region rect) plus one leader from the + // marker's edge to the inset's nearest corner (hidden while minimized). + if (ind.point) { + const [mx, my] = toFig(ind.point[0], ind.point[1]); + const ms = ind.marker_size != null ? ind.marker_size : 5; + calloutCtx.save(); + calloutCtx.beginPath(); + calloutCtx.rect(offX, offY, imgW, imgH); + calloutCtx.clip(); + calloutCtx.setLineDash([]); // the marker itself is solid + calloutCtx.beginPath(); + calloutCtx.arc(mx, my, ms, 0, Math.PI * 2); + calloutCtx.stroke(); + calloutCtx.beginPath(); // centre cross + calloutCtx.moveTo(mx - ms * 0.6, my); calloutCtx.lineTo(mx + ms * 0.6, my); + calloutCtx.moveTo(mx, my - ms * 0.6); calloutCtx.lineTo(mx, my + ms * 0.6); + calloutCtx.stroke(); + calloutCtx.restore(); + + const insSpecP = (layout.inset_specs || []).find(s => s.id === ind.inset_id); + const minimizedP = insSpecP && insSpecP.inset_state === 'minimized'; + if (!minimizedP) { + const iRect = inset.insetDiv.getBoundingClientRect(); + const iL = iRect.left - base.left, iT = iRect.top - base.top; + const iR = iL + iRect.width, iB = iT + iRect.height; + // Nearest inset corner to the marker → shortest, non-crossing leader. + let best = null, bd = Infinity; + for (const [qx, qy] of [[iL, iT], [iR, iT], [iR, iB], [iL, iB]]) { + const dd = (qx - mx) * (qx - mx) + (qy - my) * (qy - my); + if (dd < bd) { bd = dd; best = [qx, qy]; } + } + // Start at the marker's rim (not its centre) toward the corner. + const ang = Math.atan2(best[1] - my, best[0] - mx); + calloutCtx.beginPath(); + calloutCtx.moveTo(mx + Math.cos(ang) * ms, my + Math.sin(ang) * ms); + calloutCtx.lineTo(best[0], best[1]); + calloutCtx.stroke(); + } + calloutCtx.restore(); + continue; + } + + // ── REGION indication: the region's four data-space corners ────────── + const [rx, ry, rw, rh] = ind.region; + const [x0, y0] = toFig(rx, ry); + const [x1, y1] = toFig(rx + rw, ry); + const [x2, y2] = toFig(rx + rw, ry + rh); + const [x3, y3] = toFig(rx, ry + rh); + // Axis-aligned bounds of the (untransformed) rect in figure px. + const rL = Math.min(x0, x1, x2, x3), rR = Math.max(x0, x1, x2, x3); + const rT = Math.min(y0, y1, y2, y3), rB = Math.max(y0, y1, y2, y3); + // Dashed source rectangle (drawn as the 4 transformed corners so it // stays correct even if a future transform rotates/skews it). Clipped // to the parent's image area so a zoomed-in region that runs off the @@ -1786,6 +2092,18 @@ function render({ model, el, onResize }) { _ensureGrip(p); if (p.grip) p.grip.style.display = edit ? 'flex' : 'none'; } + // Insets: the bottom-right resize grip + an accent outline are edit-only + // affordances; off-edit the inset is inert (no handle, no drag) because + // _wireInsetDrag's handlers all gate on _editOn(). The outline is a + // box-shadow (not `outline`) so it doesn't fight the maximized-float / + // selection styling and rides the inset's own rounded corners. + if (p.isInset) { + if (p.resizeGrip) p.resizeGrip.style.display = edit ? 'block' : 'none'; + p.insetDiv.style.boxShadow = edit + ? `0 2px 14px rgba(0,0,0,0.55), inset 0 0 0 1px ${EDIT_ACCENT}` + : '0 2px 14px rgba(0,0,0,0.55)'; + continue; // insets skip the grid-panel selection/hover chrome below + } // Selection outline (persistent, solid) wins over hover. // outline-offset is fully NEGATIVE (−width) so the whole ring is inset // INSIDE the cell bounds — otherwise an edge/corner panel's outline is @@ -2200,7 +2518,14 @@ function render({ model, el, onResize }) { if (!d.bytes || !lw || !lh || d.bytes.length < lw * lh) return null; const cmap = layer.cmap || 'gray'; const dMin = layer.clim_min, dMax = layer.clim_max; - const lutKey = [cmap, dMin, dMax].join('|'); + // A LUT may carry a 4th (alpha) channel — a clear→colour TINT ramp from + // Python's _build_tint_lut. Both the tint colour and the presence of alpha + // go into lutKey so toggling tint ↔ cmap invalidates the cached bitmap. + const cmapData = layer.colormap_data || st.colormap_data || []; + const hasAlpha = cmapData.length === 256 && cmapData[0] + && cmapData[0].length > 3; + const lutKey = [cmap, layer.tint || '', hasAlpha ? 'a' : '', + dMin, dMax].join('|'); const cache = (p._layerBlit ||= {}); const c = cache[layer.id]; if (c && c.key === d.key && c.lutKey === lutKey && c.w === lw && c.h === lh) @@ -2208,13 +2533,15 @@ function render({ model, el, onResize }) { // Build a 256-entry uint32 LUT from the layer's own colormap + clim. The layer // bytes are quantised over [clim_min, clim_max] (Python side), so code i maps // linearly through that window; the LUT bakes clim→colour like the base path. - const cmapData = layer.colormap_data || st.colormap_data || []; + // Per-texel alpha (4-channel LUT) rides the ImageData (unpremultiplied) and + // multiplies naturally with the per-layer ctx.globalAlpha at drawImage time. let cmapFlat = null; if (cmapData.length === 256) { cmapFlat = new Uint8Array(256 * 4); for (let i = 0; i < 256; i++) { cmapFlat[i*4] = cmapData[i][0]; cmapFlat[i*4+1] = cmapData[i][1]; - cmapFlat[i*4+2] = cmapData[i][2]; cmapFlat[i*4+3] = 255; + cmapFlat[i*4+2] = cmapData[i][2]; + cmapFlat[i*4+3] = cmapData[i][3] ?? 255; } } const lut = new Uint32Array(256); @@ -2226,7 +2553,7 @@ function render({ model, el, onResize }) { const idx = raw; if (cmapFlat) { dv.setUint8(0, cmapFlat[idx*4]); dv.setUint8(1, cmapFlat[idx*4+1]); - dv.setUint8(2, cmapFlat[idx*4+2]); dv.setUint8(3, 255); + dv.setUint8(2, cmapFlat[idx*4+2]); dv.setUint8(3, cmapFlat[idx*4+3]); } else { dv.setUint8(0, idx); dv.setUint8(1, idx); dv.setUint8(2, idx); dv.setUint8(3, 255); } lut[raw] = u32[0]; } @@ -2837,7 +3164,7 @@ function render({ model, el, onResize }) { // Handle dots are drawn unless the widget opts out (show_handles:false), // or the caller forces them off entirely (export redraw). const _handles = !o.forceNoHandles && w.show_handles !== false; - ovCtx.save(); ovCtx.strokeStyle=w.color||'#00e5ff'; ovCtx.lineWidth=2; + ovCtx.save(); ovCtx.strokeStyle=w.color||'#00e5ff'; ovCtx.lineWidth=(w.linewidth!=null?w.linewidth:2); if(w.type==='circle'){ const [ccx,ccy]=_imgToCanvas2d(w.cx,w.cy,st,imgW,imgH); ovCtx.beginPath(); ovCtx.arc(ccx,ccy,w.r*scale,0,Math.PI*2); ovCtx.stroke(); @@ -2887,7 +3214,6 @@ function render({ model, el, onResize }) { // reusing the head math from the static 'arrows' marker branch. const [tx,ty]=_imgToCanvas2d(w.x,w.y,st,imgW,imgH); const [hx,hy]=_imgToCanvas2d(w.x+w.u,w.y+w.v,st,imgW,imgH); - ovCtx.lineWidth=(w.linewidth!=null?w.linewidth:2); ovCtx.fillStyle=w.color||'#00e5ff'; const ang=Math.atan2(hy-ty,hx-tx), HL=10; ovCtx.beginPath();ovCtx.moveTo(tx,ty);ovCtx.lineTo(hx,hy);ovCtx.stroke(); @@ -5500,20 +5826,33 @@ fn fs(in : VsOut) -> @location(0) vec4 { linestyle: ex.linestyle||'solid', marker: ex.marker||'none', ms: ex.markersize||4, }); + p._legendRect = null; // consumed by the 1D double-click legend hit-test if(labels.length){ - ctx.font='10px monospace'; let ly=r.y+6; + const legFs = st.legend_fontsize||10; + const legScale = legFs/10; + const rowH = Math.round(legFs*1.6); + const swatchX0 = r.x+Math.round(8*legScale), swatchX1 = r.x+Math.round(24*legScale); + const markerX = r.x+Math.round(16*legScale), textX = r.x+Math.round(28*legScale); + ctx.font=legFs+'px monospace'; let ly=r.y+6; + let legendMaxTextW = 0; + 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(r.x+8,ly+5); ctx.lineTo(r.x+24,ly+5); ctx.stroke(); + 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,r.x+16,ly+5,Math.min(lb.ms||4,4)); ctx.fill(); ctx.stroke(); + ctx.beginPath(); _drawMarkerSymbol(ctx,lb.marker,markerX,ly+5,Math.min(lb.ms||4,4)); ctx.fill(); ctx.stroke(); } - ctx.fillStyle=theme.tickText;ctx.textAlign='left';ctx.textBaseline='top';ctx.fillText(lb.text,r.x+28,ly);ly+=16; + ctx.fillStyle=theme.tickText;ctx.textAlign='left';ctx.textBaseline='top';ctx.fillText(lb.text,textX,ly); + legendMaxTextW = Math.max(legendMaxTextW, ctx.measureText(lb.text).width); + ly+=rowH; } + // Cache the legend's on-canvas bounding box for the 1D double-click + // legend hit-test (see the dblclick handler in _attachEvents1d). + p._legendRect = {x: r.x, y: legendTop, w: (textX-r.x)+legendMaxTextW, h: ly-legendTop}; } const title1d=st.title||''; @@ -5544,7 +5883,7 @@ fn fs(in : VsOut) -> @location(0) vec4 { for(const w of widgets){ if(w.visible === false) continue; const color=w.color||'#00e5ff'; - ovCtx.save();ovCtx.strokeStyle=color;ovCtx.lineWidth=2; + ovCtx.save();ovCtx.strokeStyle=color;ovCtx.lineWidth=(w.linewidth!=null?w.linewidth:2); if(w.type==='vline'){ const px=_fracToPx1d(_axisValToFrac(xArr,w.x),x0,x1,r); ovCtx.setLineDash([5,3]);ovCtx.beginPath();ovCtx.moveTo(px,r.y);ovCtx.lineTo(px,r.y+r.h);ovCtx.stroke();ovCtx.setLineDash([]); @@ -6363,6 +6702,68 @@ fn fs(in : VsOut) -> @location(0) vec4 { const physY=yArr.length>=2?_axisFracToVal(yArr,imgY/_ih):imgY; _emitEvent(p.id,'double_click',null,{..._pointerFields(e),button:e.button,x:mx,y:my,img_x:imgX,img_y:imgY,xdata:physX,ydata:physY}); }); + + // ── Tagged chrome double-clicks ────────────────────────────────────────── + // The axis gutters, colorbar strip and title band are SEPARATE canvases + // from the plot-area overlay above. Double-clicking one emits a + // `double_click` with a `target` field so a host can tell WHICH text + // element was hit (axis label vs ticks vs title vs colorbar label). The + // zone splits mirror the metrics the draw code (_drawAxes2d) uses: + // • x gutter (xAxisCanvas, height PAD_B): the x_label is drawn along the + // BOTTOM (baseline ah-2) at `x_label_size` px, so the bottom band of + // height ceil(x_label_size)+3 is 'x_label'; everything above is + // 'x_ticks'. No label → whole gutter is 'x_ticks'. + // • y gutter (yAxisCanvas, width PAD_L): the y_label is drawn rotated in + // the LEFT strip; its horizontal reach is the same + // max(round(PAD_L*0.15), ceil(y_label_size*0.62)+1) translate plus the + // glyph half-height, so the left band of width ceil(y_label_size)+3 is + // 'y_label'; the rest is 'y_ticks'. No label → whole gutter 'y_ticks'. + // • colorbar (cbCanvas) → 'colorbar_label' everywhere on the strip. + // • title band (titleCanvas, height _padT) → 'title'; emits nothing + // outside the drawn band (there is none — the canvas IS the band). + // Do NOT preventDefault (keeps the native click→dblclick cascade intact, + // matching the plot-area handler). + if (p.xAxisCanvas) { + p.xAxisCanvas.addEventListener('dblclick', (e) => { + const st = p.state; if (!st) return; + const labelH = st.x_label ? Math.ceil(st.x_label_size || 11) + 3 : 0; + const ah = p.xAxisCanvas.clientHeight || PAD_B; + const target = (labelH && e.offsetY >= ah - labelH) ? 'x_label' : 'x_ticks'; + _emitEvent(p.id, 'double_click', null, + { ..._pointerFields(e), button: e.button, x: e.offsetX, y: e.offsetY, target }); + }); + } + if (p.yAxisCanvas) { + p.yAxisCanvas.addEventListener('dblclick', (e) => { + const st = p.state; if (!st) return; + const labelW = st.y_label ? Math.ceil(st.y_label_size || 11) + 3 : 0; + const target = (labelW && e.offsetX <= labelW) ? 'y_label' : 'y_ticks'; + _emitEvent(p.id, 'double_click', null, + { ..._pointerFields(e), button: e.button, x: e.offsetX, y: e.offsetY, target }); + }); + } + if (p.cbCanvas) { + // cbCanvas is pointer-events:none by default (it's a passive strip) — + // enable so it can receive the double-click. + p.cbCanvas.style.pointerEvents = 'auto'; + p.cbCanvas.addEventListener('dblclick', (e) => { + if (!p.state) return; + _emitEvent(p.id, 'double_click', null, + { ..._pointerFields(e), button: e.button, x: e.offsetX, y: e.offsetY, + target: 'colorbar_label' }); + }); + } + if (p.titleCanvas) { + // titleCanvas is pointer-events:none by default — enable for the dblclick. + p.titleCanvas.style.pointerEvents = 'auto'; + p.titleCanvas.addEventListener('dblclick', (e) => { + if (!p.state) return; + _emitEvent(p.id, 'double_click', null, + { ..._pointerFields(e), button: e.button, x: e.offsetX, y: e.offsetY, + target: 'title' }); + }); + } + overlayCanvas.addEventListener('wheel',(e)=>{ _emitEvent(p.id,'wheel',null,{ time_stamp:performance.now()/1000, @@ -6616,6 +7017,37 @@ fn fs(in : VsOut) -> @location(0) vec4 { let dMin=(p._1dDMin!=null?p._1dDMin:st.data_min), dMax=(p._1dDMax!=null?p._1dDMax:st.data_max); if(dMin==null && st.y_range && st.y_range.length===2){ dMin=st.y_range[0]; dMax=st.y_range[1]; } if(dMin!=null && dMax!=null) ydata=dMin+((r.y+r.h-my)/(r.h||1))*(dMax-dMin); + + // ── Tagged chrome hit-tests (priority order) ───────────────────────── + // The 1D panel is a SINGLE canvas: the plot rect `r` plus the title + // strip (top), the x gutter (below r) and the y gutter (left of r). + // Test the chrome zones first; if the cursor is over one, emit a + // `double_click` with the matching `target` and stop. Metrics mirror + // the 1D draw code: + // • legend → inside the cached p._legendRect (canvas px). + // • title → title present AND my < r.y (the fixed PAD_T top strip + // the 1D title is drawn in at baseline PAD_T/2). + // • x gutter (my > r.y+r.h): bottom band of height + // ceil(x_label_size)+3 → 'x_label'; rest → 'x_ticks'. + // • y gutter (mx < r.x): left band of width ceil(y_label_size)+3 → + // 'y_label'; rest → 'y_ticks'. + let target=null; + const lr=p._legendRect; + if(lr && mx>=lr.x && mx<=lr.x+lr.w && my>=lr.y && my<=lr.y+lr.h){ + target='legend'; + } else if(st.title && my < r.y){ + target='title'; + } else if(my > r.y+r.h){ + const labelH=Math.ceil(st.x_label_size||11)+3; + target=(my >= p.ph-labelH) ? 'x_label' : 'x_ticks'; + } else if(mx < r.x){ + const labelW=Math.ceil(st.y_label_size||11)+3; + target=(mx <= labelW) ? 'y_label' : 'y_ticks'; + } + if(target){ + _emitEvent(p.id,'double_click',null,{..._pointerFields(e),button:e.button,x:mx,y:my,xdata,ydata,target}); + return; + } } _emitEvent(p.id,'double_click',null,{..._pointerFields(e),button:e.button,x:mx,y:my,xdata,ydata}); }); @@ -7841,13 +8273,19 @@ fn fs(in : VsOut) -> @location(0) vec4 { const outScale = (window.devicePixelRatio || 1) * scale; // WebGPU hazard: a WebGPU canvas's drawing buffer is only valid right after - // its render pass. Force a synchronous re-render of every active-GPU 2-D - // panel FIRST, then composite in the SAME task — so drawImage(gpuCanvas) - // reads freshly-rendered pixels instead of a blank/cleared buffer. + // its render pass. Force a synchronous re-render of every active-GPU panel + // FIRST — 2-D image rasters AND 3-D geometry (draw3d's active-GPU path + // uploads + submits its render pass in-task, no rAF) — then composite in + // the SAME task, so drawImage(gpuCanvas) reads freshly-rendered pixels + // instead of a blank/cleared buffer. Without the 3-D branch a scatter3d/ + // voxels panel exports as an empty background rectangle. for (const p of panels.values()) { - if (p.kind === '2d' && p._gpu === 'active' && p._gpuImg - && p.gpuCanvas && p.gpuCanvas.style.display !== 'none') { + if (p._gpu !== 'active' || !p.gpuCanvas + || p.gpuCanvas.style.display === 'none') continue; + if (p.kind === '2d' && p._gpuImg) { try { draw2d(p); } catch (_) {} + } else if (p.kind === '3d' && p._gpuObj) { + try { draw3d(p); } catch (_) {} } } diff --git a/anyplotlib/plot1d/_plot1d.py b/anyplotlib/plot1d/_plot1d.py index 5e567a30..220c0efa 100644 --- a/anyplotlib/plot1d/_plot1d.py +++ b/anyplotlib/plot1d/_plot1d.py @@ -6,6 +6,7 @@ from __future__ import annotations +import math import uuid as _uuid import numpy as np @@ -698,7 +699,8 @@ def clear_spans(self) -> None: # ------------------------------------------------------------------ # Overlay Widgets # ------------------------------------------------------------------ - def add_vline_widget(self, x: float, color: str = "#00e5ff") -> _VLineWidget: + def add_vline_widget(self, x: float, color: str = "#00e5ff", + linewidth: float = 2) -> _VLineWidget: """Add a draggable vertical-line overlay. Parameters @@ -707,6 +709,8 @@ def add_vline_widget(self, x: float, color: str = "#00e5ff") -> _VLineWidget: Initial x position in data coordinates. color : str, optional CSS colour string. Default ``"#00e5ff"``. + linewidth : float, optional + Line stroke width in px. Default 2. Returns ------- @@ -714,13 +718,15 @@ def add_vline_widget(self, x: float, color: str = "#00e5ff") -> _VLineWidget: Widget object. Register position callbacks with :meth:`on_changed` / :meth:`on_release`. """ - widget = _VLineWidget(lambda: None, x=float(x), color=color) + widget = _VLineWidget(lambda: None, x=float(x), 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, color: str = "#00e5ff") -> _HLineWidget: + def add_hline_widget(self, y: float, color: str = "#00e5ff", + linewidth: float = 2) -> _HLineWidget: """Add a draggable horizontal-line overlay. Parameters @@ -729,6 +735,8 @@ def add_hline_widget(self, y: float, color: str = "#00e5ff") -> _HLineWidget: Initial y position in data coordinates. color : str, optional CSS colour string. Default ``"#00e5ff"``. + linewidth : float, optional + Line stroke width in px. Default 2. Returns ------- @@ -736,7 +744,8 @@ def add_hline_widget(self, y: float, color: str = "#00e5ff") -> _HLineWidget: Widget object. Register position callbacks with :meth:`on_changed` / :meth:`on_release`. """ - widget = _HLineWidget(lambda: None, y=float(y), color=color) + widget = _HLineWidget(lambda: None, y=float(y), color=color, + linewidth=linewidth) widget._push_fn = self._make_widget_push_fn(widget) self._widgets[widget.id] = widget self._push() @@ -746,6 +755,7 @@ def add_range_widget(self, x0: float, x1: float, color: str = "#00e5ff", style: str = "band", y: float = 0.0, + linewidth: float = 2, _push: bool = True) -> _RangeWidget: """Add a draggable range overlay to this panel. @@ -763,6 +773,8 @@ def add_range_widget(self, x0: float, x1: float, y : float, optional Y-coordinate (data space) for the connecting line when ``style='fwhm'``. Ignored when ``style='band'``. Default 0. + linewidth : float, optional + Line stroke width in px. Default 2. _push : bool, optional Push state to JS immediately. Set to ``False`` when adding several widgets at once; call :meth:`_push` manually afterward. @@ -774,7 +786,8 @@ def add_range_widget(self, x0: float, x1: float, :meth:`on_changed` / :meth:`on_release`. """ widget = _RangeWidget(lambda: None, x0=float(x0), x1=float(x1), - color=color, style=style, y=float(y)) + color=color, style=style, y=float(y), + linewidth=linewidth) widget._push_fn = self._make_widget_push_fn(widget) self._widgets[widget.id] = widget if _push: @@ -784,6 +797,7 @@ def add_range_widget(self, x0: float, x1: float, def add_point_widget(self, x: float, y: float, color: str = "#00e5ff", show_crosshair: bool = True, + linewidth: float = 2, _push: bool = True) -> _PointWidget: """Add a freely-draggable control point to this panel. @@ -798,6 +812,8 @@ def add_point_widget(self, x: float, y: float, show_crosshair : bool, optional Draw dashed guide lines through the handle. Default ``True``. Pass ``False`` for a plain dot with no guide lines. + linewidth : float, optional + Guide-line stroke width in px. Default 2. _push : bool, optional Push state to JS immediately. Set to ``False`` when adding several widgets at once; call :meth:`_push` manually afterward. @@ -807,7 +823,7 @@ def add_point_widget(self, x: float, y: float, PointWidget """ widget = _PointWidget(lambda: None, x=float(x), y=float(y), color=color, - show_crosshair=show_crosshair) + show_crosshair=show_crosshair, linewidth=linewidth) widget._push_fn = self._make_widget_push_fn(widget) self._widgets[widget.id] = widget if _push: @@ -861,6 +877,30 @@ def set_color(self, color: str) -> None: self._state["line_color"] = color self._push() + def set_legend_fontsize(self, size: float) -> None: + """Set the legend text font size. + + Parameters + ---------- + size : float + Legend font size in CSS pixels. Must be a positive number. + + Raises + ------ + ValueError + If ``size`` is not a positive, finite number. + """ + try: + fs = float(size) + except (TypeError, ValueError): + raise ValueError( + f"set_legend_fontsize: size must be a number, got {size!r}") from None + if not (math.isfinite(fs) and fs > 0): + raise ValueError( + f"set_legend_fontsize: size must be > 0, got {size!r}") + self._state["legend_fontsize"] = fs + self._push() + def set_linewidth(self, linewidth: float) -> None: """Set the primary line stroke width. diff --git a/anyplotlib/plot2d/_layer.py b/anyplotlib/plot2d/_layer.py index 69061b2d..1aa953bc 100644 --- a/anyplotlib/plot2d/_layer.py +++ b/anyplotlib/plot2d/_layer.py @@ -4,7 +4,8 @@ Multi-image LAYER handle for :class:`~anyplotlib.plot2d.Plot2D`. A ``Layer`` is a second (third, …) scalar image drawn OVER the base image in the -same panel, each with its OWN colormap, clim (display range), and alpha. It is +same panel, each with its OWN colormap (or clear→colour ``tint`` ramp), clim +(display range), and alpha. It is composited client-side (JS ``draw2d``) with the exact same image→screen transform as the base image, so zoom/pan track perfectly. This is distinct from :meth:`Plot2D.set_overlay_mask`, which is a single-colour boolean mask. @@ -68,6 +69,11 @@ def cmap(self) -> str: def alpha(self) -> float: return float(self._entry().get("alpha", 1.0)) + @property + def tint(self) -> "str | None": + """The clear→colour tint hex string, or ``None`` (named-cmap mode).""" + return self._entry().get("tint") + @property def visible(self) -> bool: return bool(self._entry().get("visible", True)) @@ -78,12 +84,19 @@ def clim(self): return (e.get("clim_min"), e.get("clim_max")) # ── mutations (forwarded to the plot) ───────────────────────────────────── - def set(self, *, cmap=None, alpha=None, clim=None, visible=None) -> "Layer": + def set(self, *, cmap=None, alpha=None, clim=None, visible=None, + tint=None) -> "Layer": """Partial update of this layer's appearance (any subset of fields). ``cmap`` — colormap name. ``alpha`` — opacity in [0, 1]. ``visible`` — draw or hide. + ``tint`` — a ``#rgb`` / ``#rrggbb`` hex colour: switch the layer to a + clear→colour intensity ramp (see :meth:`Plot2D.add_layer`). ``None`` + leaves the current tint unchanged; to REVERT a tinted layer to + named-cmap display, pass ``cmap=...`` (which clears the tint). + Passing both ``cmap`` and ``tint`` raises ``ValueError``. + ``clim`` — display range, with three distinct meanings: - ``None`` (default) — leave the current clim UNCHANGED (this call @@ -97,11 +110,11 @@ def set(self, *, cmap=None, alpha=None, clim=None, visible=None) -> "Layer": any previously-set explicit clim. A pixel re-encode happens only when ``clim`` is a tuple or ``"auto"`` - (it re-quantises the cached frame); ``cmap``/``alpha``/``visible`` are - cheap LUT/compositor-only changes. + (it re-quantises the cached frame); ``cmap``/``alpha``/``visible``/ + ``tint`` are cheap LUT/compositor-only changes. """ self._plot._layer_set(self._id, cmap=cmap, alpha=alpha, clim=clim, - visible=visible) + visible=visible, tint=tint) return self def set_data(self, frame) -> "Layer": diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py index a6e51656..a2aa984f 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -28,7 +28,8 @@ RectangleWidget, CircleWidget, AnnularWidget, CrosshairWidget, PolygonWidget, LabelWidget, ArrowWidget, ) -from anyplotlib._utils import _normalize_image, _build_colormap_lut, _to_rgba_u8 +from anyplotlib._utils import (_normalize_image, _build_colormap_lut, + _build_tint_lut, _to_rgba_u8) def _binary_transport_active() -> bool: @@ -1178,7 +1179,7 @@ def _encode_layer_pixels(self, layer_id: str, frame: np.ndarray, clim): return token, int(arr.shape[0]), int(arr.shape[1]), vmin, vmax def add_layer(self, data, *, cmap: str = "magma", alpha: float = 0.5, - clim=None, visible: bool = True): + clim=None, visible: bool = True, tint: str | None = None): """Add an image LAYER drawn over the base image. Each layer has its OWN colormap, display range (``clim``), and opacity @@ -1194,13 +1195,23 @@ def add_layer(self, data, *, cmap: str = "magma", alpha: float = 0.5, uint8 via ``clim`` → LUT exactly like the base image. A shape mismatch raises ``ValueError``. cmap : str, optional - Colormap name (default ``"magma"``). + Colormap name (default ``"magma"``). Ignored for display while + ``tint`` is set (the tint LUT replaces the colormap), but kept so + ``set(cmap=...)`` can revert to it. alpha : float, optional Opacity in [0, 1] (default ``0.5``). clim : (vmin, vmax) or None, optional Display range; ``None`` (default) auto-scales to the data min/max. visible : bool, optional Whether the layer is drawn (default ``True``). + tint : str or None, optional + A ``#rgb`` / ``#rrggbb`` hex colour. When set, the layer renders + as a clear→colour intensity ramp instead of a named colormap: RGB + is *tint* at every intensity and per-texel alpha ramps linearly + 0 → 255 (transparent at low intensity → opaque tint at high). The + per-texel alpha multiplies with the layer's ``alpha``. ``None`` + (default) keeps the named-colormap behaviour. An invalid colour + raises ``ValueError``. Returns ------- @@ -1225,19 +1236,26 @@ def add_layer(self, data, *, cmap: str = "magma", alpha: float = 0.5, alpha = float(alpha) if not (0.0 <= alpha <= 1.0): raise ValueError(f"alpha must be in [0, 1], got {alpha!r}") + # Validate the tint (and build its LUT) BEFORE any state mutation so a + # bad colour raises without leaving a half-added layer behind. + lut = _build_tint_lut(tint) if tint is not None else _build_colormap_lut(cmap) layer_id = _next_layer_id() token, h, w, vmin, vmax = self._encode_layer_pixels(layer_id, data, clim) entry = { "id": layer_id, "cmap": cmap, + # Solid-colour clear→tint ramp, or None for named-cmap display. + # The JS keys its LUT bitmap cache on this too (lutKey). + "tint": tint, "clim_min": vmin, "clim_max": vmax, "alpha": alpha, "visible": bool(visible), "width": w, "height": h, - # The layer's colormap LUT (256 [r,g,b]); the JS composites via this. - "colormap_data": _build_colormap_lut(cmap), + # The layer's colormap LUT — 256 [r,g,b] entries for a named cmap, + # 256 [r,g,b,a] entries for a tint; the JS composites via this. + "colormap_data": lut, # Mirror of the pixel token so the JS reads bytes for this layer even # when it only sees the light `layers` list (the heavy bytes ride the # geom key layer__b64, spliced into the panel state by the binary / @@ -1260,11 +1278,21 @@ def _layer_entry(self, layer_id: str) -> dict: raise ValueError(f"no layer {layer_id!r} on this plot") def _layer_set(self, layer_id: str, *, cmap=None, alpha=None, clim=None, - visible=None) -> None: + visible=None, tint=None) -> None: entry = self._layer_entry(layer_id) + if cmap is not None and tint is not None: + raise ValueError( + "pass either cmap or tint, not both — they both select the " + "layer's LUT (set cmap=... to revert a tinted layer to its " + "colormap)") if cmap is not None: + # An explicit cmap reverts a tinted layer to named-cmap display. entry["cmap"] = cmap + entry["tint"] = None entry["colormap_data"] = _build_colormap_lut(cmap) + if tint is not None: + entry["tint"] = tint + entry["colormap_data"] = _build_tint_lut(tint) if alpha is not None: a = float(alpha) if not (0.0 <= a <= 1.0): @@ -1564,6 +1592,7 @@ 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.""" iw, ih = self._state["image_width"], self._state["image_height"] @@ -1571,7 +1600,8 @@ def add_circle_widget(self, cx: float | None = None, cy: float | None = 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, show_handles=show_handles) + color=color, linewidth=linewidth, + show_handles=show_handles) widget._push_fn = self._make_widget_push_fn(widget) self._widgets[widget.id] = widget self._push() @@ -1579,7 +1609,7 @@ def add_circle_widget(self, cx: float | None = None, cy: float | None = None, def add_rectangle_widget(self, x: float | None = None, y: float | None = None, w: float | None = None, h: float | None = None, - color: str = "#00e5ff", + color: str = "#00e5ff", linewidth: float = 2, show_handles: bool = True) -> RectangleWidget: """Add a draggable rectangle overlay.""" iw, ih = self._state["image_width"], self._state["image_height"] @@ -1588,7 +1618,8 @@ def add_rectangle_widget(self, x: float | None = None, y: float | None = None, y=float(y) if y is not None else ih * 0.25, w=float(w) if w is not None else iw * 0.5, h=float(h) if h is not None else ih * 0.5, - color=color, show_handles=show_handles) + color=color, linewidth=linewidth, + show_handles=show_handles) widget._push_fn = self._make_widget_push_fn(widget) self._widgets[widget.id] = widget self._push() @@ -1596,7 +1627,7 @@ def add_rectangle_widget(self, x: float | None = None, y: float | None = None, def add_annular_widget(self, cx: float | None = None, cy: float | None = None, r_outer: float | None = None, r_inner: float | None = None, - color: str = "#00e5ff", + color: str = "#00e5ff", linewidth: float = 2, show_handles: bool = True) -> AnnularWidget: """Add a draggable annular (ring) overlay.""" iw, ih = self._state["image_width"], self._state["image_height"] @@ -1605,13 +1636,15 @@ def add_annular_widget(self, cx: float | None = None, cy: float | None = None, cy=float(cy) if cy is not None else ih / 2, r_outer=float(r_outer) if r_outer is not None else iw * 0.2, r_inner=float(r_inner) if r_inner is not None else iw * 0.1, - color=color, show_handles=show_handles) + 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_polygon_widget(self, vertices=None, color: str = "#00e5ff", + linewidth: float = 2, show_handles: bool = True) -> PolygonWidget: """Add a draggable polygon overlay.""" iw, ih = self._state["image_width"], self._state["image_height"] @@ -1619,21 +1652,22 @@ def add_polygon_widget(self, vertices=None, color: str = "#00e5ff", vertices = [[iw * .25, ih * .25], [iw * .75, ih * .25], [iw * .75, ih * .75], [iw * .25, ih * .75]] widget = PolygonWidget(lambda: None, vertices=vertices, color=color, - show_handles=show_handles) + 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_crosshair_widget(self, cx: float | None = None, cy: float | None = None, - color: str = "#00e5ff", + color: str = "#00e5ff", linewidth: float = 2, show_handles: bool = True) -> CrosshairWidget: """Add a draggable crosshair overlay.""" iw, ih = self._state["image_width"], self._state["image_height"] widget = CrosshairWidget(lambda: None, cx=float(cx) if cx is not None else iw / 2, cy=float(cy) if cy is not None else ih / 2, - color=color, show_handles=show_handles) + color=color, linewidth=linewidth, + show_handles=show_handles) widget._push_fn = self._make_widget_push_fn(widget) self._widgets[widget.id] = widget self._push() diff --git a/anyplotlib/tests/test_embed/test_export_png.py b/anyplotlib/tests/test_embed/test_export_png.py index 28d59fad..2acef5ee 100644 --- a/anyplotlib/tests/test_embed/test_export_png.py +++ b/anyplotlib/tests/test_embed/test_export_png.py @@ -433,62 +433,70 @@ def _inset_titlebar_rect(page, plot_id): class TestExportInsetTitle: def test_inset_title_drawn_in_titlebar_band(self, mount_page): - """A non-empty inset title must leave title-text-coloured ink in the - titlebar row band; an otherwise-identical inset with an empty title - must NOT — ruling out a false positive from the titlebar's own flat - fill colour (which both figures share) rather than actual text.""" - dpr = None + """A non-empty inset title draws title-text ink in its titlebar band. + + The false-positive guard (that we're seeing text, not the titlebar's + own flat fill) compares the band's TOP text row against a text-free + strip of the SAME titlebar: a real title leaves the two rows visibly + different, a blank fill would leave them identical. (Previously this + compared against a second inset with ``title=""``; that inset no longer + has a titlebar at all — see ``test_empty_title_has_no_titlebar_band``.) + """ GRID_PAD = 8 + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.imshow(np.zeros((32, 32), dtype=np.float32), cmap="gray", + vmin=0.0, vmax=1.0) + inset = fig.add_inset(0.35, 0.35, corner="top-right", title="Zoom View") + plot = inset.imshow(np.zeros((16, 16), dtype=np.float32), + cmap="gray", vmin=0.0, vmax=1.0) + page = mount_page(fig) + rect = _inset_titlebar_rect(page, plot._id) + res = _export_via_handle(page) + assert "error" not in res, res.get("error") + arr = _decode_data_url(res["dataUrl"]) + dpr = page.evaluate("() => window.devicePixelRatio || 1") - def _titlebar_band(fig_factory, title): - fig, ax = fig_factory() - ax.imshow(np.zeros((32, 32), dtype=np.float32), cmap="gray", - vmin=0.0, vmax=1.0) - inset = fig.add_inset(0.35, 0.35, corner="top-right", title=title) - plot = inset.imshow(np.zeros((16, 16), dtype=np.float32), - cmap="gray", vmin=0.0, vmax=1.0) - page = mount_page(fig) - rect = _inset_titlebar_rect(page, plot._id) - res = _export_via_handle(page) - assert "error" not in res, res.get("error") - arr = _decode_data_url(res["dataUrl"]) - dpr_ = page.evaluate("() => window.devicePixelRatio || 1") - y0 = max(0, round((rect["top"] + GRID_PAD) * dpr_)) - y1 = min(arr.shape[0], round((rect["top"] + rect["height"] + GRID_PAD) * dpr_)) - x0 = max(0, round((rect["left"] + GRID_PAD) * dpr_)) - x1 = min(arr.shape[1], round((rect["left"] + rect["width"] + GRID_PAD) * dpr_)) - return arr[y0:y1, x0:x1, :3], dpr_ - - def _make_fig(): - return apl.subplots(1, 1, figsize=(400, 300)) - - band_titled, dpr = _titlebar_band(_make_fig, "Zoom View") - band_empty, _ = _titlebar_band(_make_fig, "") - assert band_titled.size > 0 and band_empty.size > 0, ( - f"empty titlebar band(s): titled={band_titled.shape} " - f"empty={band_empty.shape}" - ) - assert band_titled.shape == band_empty.shape, ( - "titlebar bands differ in size between the two figures — " - f"titled={band_titled.shape} empty={band_empty.shape}" - ) - - # Both bands share the identical titlebar chrome (same theme, corner, - # size — including its border-bottom row), so any per-pixel - # difference between the two must be the drawn title TEXT. - diff = np.abs(band_titled.astype(np.int32) - band_empty.astype(np.int32)) + assert rect["height"] > 0, ( + "titled inset has no titlebar band — the bar should render when a " + f"title is set (rect={rect})" + ) + y0 = max(0, round((rect["top"] + GRID_PAD) * dpr)) + y1 = min(arr.shape[0], round((rect["top"] + rect["height"] + GRID_PAD) * dpr)) + x0 = max(0, round((rect["left"] + GRID_PAD) * dpr)) + x1 = min(arr.shape[1], round((rect["left"] + rect["width"] + GRID_PAD) * dpr)) + band = arr[y0:y1, x0:x1, :3] + assert band.size > 0, f"empty titlebar band: {band.shape}" + + # The title text sits in the upper rows of the band; the bottom row is + # the (text-free) border. Any row carrying glyph ink differs markedly + # from that text-free baseline — a flat fill would not. + baseline = band[-1:, :, :].astype(np.int32) # text-free border row + diff = np.abs(band.astype(np.int32) - baseline) changed_px = int((diff.sum(axis=-1) > 20).sum()) - assert changed_px > 0, ( - "titled and empty-title exports are pixel-identical in the " - "titlebar band — exportPNG is not drawing the inset title text" + assert changed_px > band.shape[1] * dpr, ( + f"only {changed_px} px differ from the flat baseline — exportPNG is " + f"not drawing the inset title text (band width {band.shape[1]})" ) - # The border-bottom row (~1 CSS px) is the only chrome difference a - # mis-measured band could pick up; text ink should account for far - # more than one scaled row's worth of pixels. - assert changed_px > band_titled.shape[1] * dpr, ( - f"only {changed_px} px differ — looks like border noise, not text " - f"(band width {band_titled.shape[1]})" + + def test_empty_title_has_no_titlebar_band(self, mount_page): + """An inset with an empty title renders NO titlebar strip (the + title-bar auto-hide feature): its ``titleBar`` collapses to zero + height, so the plot content fills the whole inset box.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.imshow(np.zeros((32, 32), dtype=np.float32), cmap="gray", + vmin=0.0, vmax=1.0) + inset = fig.add_inset(0.35, 0.35, corner="top-right", title="") + plot = inset.imshow(np.zeros((16, 16), dtype=np.float32), + cmap="gray", vmin=0.0, vmax=1.0) + page = mount_page(fig) + rect = _inset_titlebar_rect(page, plot._id) + # A hidden (display:none) titlebar reports a zero-height rect. + assert rect["height"] == 0, ( + f"empty-title inset still has a titlebar band (rect={rect})" ) + # The export itself must still succeed. + res = _export_via_handle(page) + assert "error" not in res, res.get("error") # --------------------------------------------------------------------------- @@ -743,6 +751,78 @@ def _gpu_asym_image(n=GPU_IMG_N): return img +def _scatter3d_fig(gpu): + """A 3-D scatter of pure-red points (the point fragment shader returns the + per-point colour unshaded, so exported point pixels are exactly red).""" + fig, ax = apl.subplots(1, 1, figsize=(320, 320)) + rng = np.random.default_rng(7) + pts = rng.uniform(-1, 1, size=(3000, 3)) + v = ax.scatter3d(pts[:, 0], pts[:, 1], pts[:, 2], bounds=((-1, 1),) * 3, + gpu=gpu, + colors=np.tile([255, 0, 0], (3000, 1)).astype(np.uint8), + point_size=5) + v.set_axis_off() + return fig + + +_WAIT_3D_GPU_ACTIVE = """() => { + const api = window._handle && window._handle.api; + if (!api || !api.panels) return false; + for (const p of api.panels.values()) + if (p.kind === '3d') + return p._gpu === 'active' && !!p._gpuObj && p._gpuActiveNow; + return false; +}""" + + +class TestExportGpu3d: + """3-D WebGPU panels in exportPNG — the safeguard that re-renders the 3-D + pass in-task before drawImage(gpuCanvas). Without it a scatter3d/voxels + panel exports as an empty background rectangle (the WebGPU drawing buffer + is cleared after the frame that rendered it is presented).""" + + def test_scatter3d_gpu_export_nonblank(self, gpu_mount_page): + fig = _scatter3d_fig(gpu="always") + page = gpu_mount_page(fig) + page.wait_for_function(_WAIT_3D_GPU_ACTIVE, timeout=20_000) + # Settle a couple of frames so the export runs in a LATER task than + # the activation render — the exact condition under which the drawing + # buffer would read back blank without the in-task re-render. + page.evaluate( + "() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))" + ) + + res = _export_via_handle(page) + assert "error" not in res, res.get("error") + arr = _decode_data_url(res["dataUrl"]) + assert _is_nonblank(arr), "3-D GPU export is a single flat colour" + red = _closest_color(arr, (255, 0, 0)) + assert red > 200, ( + f"only {red} red point pixels in the 3-D GPU export — " + "gpuCanvas read back blank (missing in-task draw3d re-render?)" + ) + + def test_scatter3d_canvas_fallback_export_nonblank(self, mount_page): + """No-regression guard: the Canvas2D 3-D path (gpu=False, and CI + runners without an adapter) draws points on plotCanvas, which the + composite has always captured.""" + fig = _scatter3d_fig(gpu=False) + page = mount_page(fig) + res = _export_via_handle(page) + assert "error" not in res, res.get("error") + arr = _decode_data_url(res["dataUrl"]) + assert _is_nonblank(arr), "3-D canvas export is a single flat colour" + # Canvas path dims far-side points (alpha), so count "reddish" ink + # rather than exact red. + rgb = arr[..., :3].astype(np.int32) + reddish = int(((rgb[..., 0] > 150) + & (rgb[..., 0] - rgb[..., 1] > 60) + & (rgb[..., 0] - rgb[..., 2] > 60)).sum()) + assert reddish > 200, ( + f"only {reddish} reddish pixels in the 3-D canvas export" + ) + + class TestExportGpu: def test_gpu_export_nonblank(self, gpu_mount_page): """A large scalar image engaging the WebGPU path must export non-blank. diff --git a/anyplotlib/tests/test_interactive/test_callbacks_playwright.py b/anyplotlib/tests/test_interactive/test_callbacks_playwright.py index 6920f467..d84a9e4c 100644 --- a/anyplotlib/tests/test_interactive/test_callbacks_playwright.py +++ b/anyplotlib/tests/test_interactive/test_callbacks_playwright.py @@ -217,6 +217,106 @@ def test_wheel_has_dx_dy_fields(self, interact_page): assert "dy" in e or "dx" in e, "wheel event should carry dx or dy" +# ═══════════════════════════════════════════════════════════════════════════════ +# 1b. Tagged double-click hit-targets — JS emits a `target` naming the chrome +# element (axis label / ticks / title / colorbar label / legend) that was +# double-clicked. A plain plot-area double-click carries NO `target`. +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestDoubleClickTargets: + """Double-clicking the axis gutters / legend of a real rendered figure emits + a `double_click` whose `target` names the element (see the chrome dblclick + handlers in figure_esm.js). Page geometry for a 400×300 figure with physical + axes (GRID_PAD=8): + plot area page-coords x∈[66, 396], y∈[20, 266] + x gutter (xAxisCanvas) y∈[266, 308]; label band bottom ~14px → y≥294 + y gutter (yAxisCanvas) x∈[8, 66]; label band left ~14px → x≤22 + """ + + def _make_2d_axes(self, interact_page): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + # Physical axes ⇒ tick gutters + label strips are drawn on the separate + # xAxisCanvas / yAxisCanvas. + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32), + axes=[np.linspace(0, 31, 32), np.linspace(0, 31, 32)]) + plot.set_xlabel("x [nm]") + plot.set_ylabel("y [nm]") + plot.set_title("frame") + page = interact_page(fig) + _collect_events(page) + page.wait_for_timeout(60) + return fig, plot, page + + def test_x_ticks_target_emitted(self, interact_page): + _, _, page = self._make_2d_axes(interact_page) + # Upper part of the bottom gutter = tick-label zone. + page.mouse.dblclick(GRID_PAD + PAD_L + 120, 270) + page.wait_for_timeout(80) + events = _get_events(page, "double_click") + assert events, "no double_click emitted on the x-axis gutter" + assert events[-1].get("target") == "x_ticks", events[-1] + + def test_x_label_target_emitted(self, interact_page): + _, _, page = self._make_2d_axes(interact_page) + # Bottom band of the bottom gutter = x_label strip. + page.mouse.dblclick(GRID_PAD + PAD_L + 120, 305) + page.wait_for_timeout(80) + events = _get_events(page, "double_click") + assert events, "no double_click emitted on the x-axis label strip" + assert events[-1].get("target") == "x_label", events[-1] + + def test_y_ticks_target_emitted(self, interact_page): + _, _, page = self._make_2d_axes(interact_page) + # Right part of the left gutter = tick-label zone. + page.mouse.dblclick(GRID_PAD + PAD_L - 8, 140) + page.wait_for_timeout(80) + events = _get_events(page, "double_click") + assert events, "no double_click emitted on the y-axis gutter" + assert events[-1].get("target") == "y_ticks", events[-1] + + def test_y_label_target_emitted(self, interact_page): + _, _, page = self._make_2d_axes(interact_page) + # Leftmost band of the left gutter = y_label strip. + page.mouse.dblclick(GRID_PAD + 4, 140) + page.wait_for_timeout(80) + events = _get_events(page, "double_click") + assert events, "no double_click emitted on the y-axis label strip" + assert events[-1].get("target") == "y_label", events[-1] + + def test_title_target_emitted(self, interact_page): + _, _, page = self._make_2d_axes(interact_page) + # Title strip sits above the image area (top PAD_T band). + page.mouse.dblclick(GRID_PAD + PAD_L + 120, GRID_PAD + 5) + page.wait_for_timeout(80) + events = _get_events(page, "double_click") + assert events, "no double_click emitted on the title strip" + assert events[-1].get("target") == "title", events[-1] + + def test_plot_area_double_click_has_no_target(self, interact_page): + _, _, page = self._make_2d_axes(interact_page) + cx, cy = _center() + page.mouse.dblclick(cx, cy) + page.wait_for_timeout(80) + events = _get_events(page, "double_click") + assert events, "no double_click emitted on the plot area" + # Back-compat: plot-area double-clicks carry NO target key. + assert events[-1].get("target") is None, events[-1] + + def test_legend_target_emitted_1d(self, interact_page): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.plot(np.sin(np.linspace(0, 6.28, 128)), label="signal") + page = interact_page(fig) + _collect_events(page) + page.wait_for_timeout(60) + # The legend is drawn at the top-left of the plot rect (r.x, r.y+6). + # A point a few px inside the first legend row lands on the swatch/text. + page.mouse.dblclick(GRID_PAD + PAD_L + 20, GRID_PAD + PAD_T + 12) + page.wait_for_timeout(80) + events = _get_events(page, "double_click") + assert events, "no double_click emitted on the 1D legend" + assert events[-1].get("target") == "legend", events[-1] + + # ═══════════════════════════════════════════════════════════════════════════════ # 2. Python dispatch — via _sim + real Python handlers # ═══════════════════════════════════════════════════════════════════════════════ diff --git a/anyplotlib/tests/test_interactive/test_callbacks_unit.py b/anyplotlib/tests/test_interactive/test_callbacks_unit.py index 9d53fdf9..28a512f9 100644 --- a/anyplotlib/tests/test_interactive/test_callbacks_unit.py +++ b/anyplotlib/tests/test_interactive/test_callbacks_unit.py @@ -74,6 +74,16 @@ def test_stop_propagation_default_false(self): e = Event(event_type="pointer_down") assert e.stop_propagation is False + def test_target_default_none(self): + # Tagged double-click hit-target defaults to None (plain plot-area + # double_click / any non-double_click event). + e = Event(event_type="double_click") + assert e.target is None + + def test_target_settable(self): + e = Event(event_type="double_click", target="x_label") + assert e.target == "x_label" + def test_all_fields_settable(self): e = Event( event_type="pointer_down", @@ -526,3 +536,65 @@ def test_figure_close_is_idempotent(self): fig.close() fig.close() assert len(received) == 1 + + +# ── tagged double-click hit-targets ─────────────────────────────────────────── + +class TestDoubleClickTarget: + """The JS chrome double-click handlers ship a ``target`` field naming which + text element was hit (axis label / ticks / title / colorbar label / legend); + ``_dispatch_event`` flows it through to ``event.target``. These simulate the + JS message the way ``_dispatch_event`` receives it — no browser.""" + + def test_target_reaches_handler(self): + import json + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((16, 16))) + received = [] + v.add_event_handler(lambda e: received.append(e.target), "double_click") + fig._dispatch_event(json.dumps({ + "source": "js", "panel_id": v._id, "event_type": "double_click", + "x": 30.0, "y": 40.0, "target": "x_label", + })) + assert received == ["x_label"] + + def test_untagged_double_click_target_is_none(self): + import json + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((16, 16))) + received = [] + v.add_event_handler(lambda e: received.append(e), "double_click") + # No "target" key → plot-area double_click → event.target is None. + fig._dispatch_event(json.dumps({ + "source": "js", "panel_id": v._id, "event_type": "double_click", + "x": 8.0, "y": 8.0, + })) + assert len(received) == 1 + assert received[0].target is None + + def test_each_target_value_flows(self): + import json + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((16, 16))) + seen = [] + v.add_event_handler(lambda e: seen.append(e.target), "double_click") + for t in ("title", "x_label", "x_ticks", "y_label", "y_ticks", + "colorbar_label", "legend"): + fig._dispatch_event(json.dumps({ + "source": "js", "panel_id": v._id, "event_type": "double_click", + "x": 1.0, "y": 1.0, "target": t, + })) + assert seen == ["title", "x_label", "x_ticks", "y_label", "y_ticks", + "colorbar_label", "legend"] + + def test_target_on_1d_panel(self): + import json + fig, ax = apl.subplots(1, 1) + p = ax.plot(np.zeros(10)) + received = [] + p.callbacks.connect("double_click", lambda e: received.append(e.target)) + fig._dispatch_event(json.dumps({ + "source": "js", "panel_id": p._id, "event_type": "double_click", + "x": 5.0, "y": 5.0, "target": "legend", + })) + assert received == ["legend"] diff --git a/anyplotlib/tests/test_interactive/test_widgets.py b/anyplotlib/tests/test_interactive/test_widgets.py index 17c6dd23..a7cfb283 100644 --- a/anyplotlib/tests/test_interactive/test_widgets.py +++ b/anyplotlib/tests/test_interactive/test_widgets.py @@ -29,7 +29,7 @@ from anyplotlib.widgets import ( Widget, RectangleWidget, CircleWidget, AnnularWidget, CrosshairWidget, PolygonWidget, LabelWidget, - VLineWidget, HLineWidget, RangeWidget, + VLineWidget, HLineWidget, RangeWidget, PointWidget, ) @@ -1378,3 +1378,198 @@ def test_example_wrong_line_id_not_clickable(self): _simulate_js_event(fig, plot, "pointer_down", line_id="no-such-line") assert ctrls[0]._active is False assert ctrls[1]._active is False + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 8. linewidth kwarg — 2-D and 1-D overlay widgets +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestWidgetLinewidth: + """linewidth is stored in _data exactly like color: defaults to 2, is + overridable at construction, round-trips through Widget.set(), and is + forwarded by every Plot2D/Plot1D add_*_widget factory.""" + + # ── 2-D widget constructors: default + custom ────────────────────────── + + def test_rectangle_default_linewidth(self): + w = RectangleWidget(lambda: None, x=0, y=0, w=10, h=10) + assert w.linewidth == 2.0 + assert w.to_dict()["linewidth"] == 2.0 + + def test_rectangle_custom_linewidth(self): + w = RectangleWidget(lambda: None, x=0, y=0, w=10, h=10, linewidth=4) + assert w.linewidth == 4.0 + assert w.to_dict()["linewidth"] == 4.0 + + def test_circle_default_linewidth(self): + w = CircleWidget(lambda: None, cx=0, cy=0, r=5) + assert w.linewidth == 2.0 + assert w.to_dict()["linewidth"] == 2.0 + + def test_circle_custom_linewidth(self): + w = CircleWidget(lambda: None, cx=0, cy=0, r=5, linewidth=5) + assert w.linewidth == 5.0 + assert w.to_dict()["linewidth"] == 5.0 + + def test_annular_default_linewidth(self): + w = AnnularWidget(lambda: None, cx=0, cy=0, r_outer=10, r_inner=5) + assert w.linewidth == 2.0 + assert w.to_dict()["linewidth"] == 2.0 + + def test_annular_custom_linewidth(self): + w = AnnularWidget(lambda: None, cx=0, cy=0, r_outer=10, r_inner=5, + linewidth=3.5) + assert w.linewidth == 3.5 + assert w.to_dict()["linewidth"] == 3.5 + + def test_crosshair_default_linewidth(self): + w = CrosshairWidget(lambda: None, cx=0, cy=0) + assert w.linewidth == 2.0 + assert w.to_dict()["linewidth"] == 2.0 + + def test_crosshair_custom_linewidth(self): + w = CrosshairWidget(lambda: None, cx=0, cy=0, linewidth=1) + assert w.linewidth == 1.0 + assert w.to_dict()["linewidth"] == 1.0 + + def test_polygon_default_linewidth(self): + w = PolygonWidget(lambda: None, vertices=[[0, 0], [1, 0], [1, 1]]) + assert w.linewidth == 2.0 + assert w.to_dict()["linewidth"] == 2.0 + + def test_polygon_custom_linewidth(self): + w = PolygonWidget(lambda: None, vertices=[[0, 0], [1, 0], [1, 1]], + linewidth=6) + assert w.linewidth == 6.0 + assert w.to_dict()["linewidth"] == 6.0 + + # ── 1-D widget constructors: default + custom ────────────────────────── + + def test_vline_default_linewidth(self): + w = VLineWidget(lambda: None, x=1.0) + assert w.linewidth == 2.0 + assert w.to_dict()["linewidth"] == 2.0 + + def test_vline_custom_linewidth(self): + w = VLineWidget(lambda: None, x=1.0, linewidth=3) + assert w.linewidth == 3.0 + assert w.to_dict()["linewidth"] == 3.0 + + def test_hline_default_linewidth(self): + w = HLineWidget(lambda: None, y=1.0) + assert w.linewidth == 2.0 + assert w.to_dict()["linewidth"] == 2.0 + + def test_hline_custom_linewidth(self): + w = HLineWidget(lambda: None, y=1.0, linewidth=3) + assert w.linewidth == 3.0 + assert w.to_dict()["linewidth"] == 3.0 + + def test_range_default_linewidth(self): + w = RangeWidget(lambda: None, x0=0, x1=10) + assert w.linewidth == 2.0 + assert w.to_dict()["linewidth"] == 2.0 + + def test_range_custom_linewidth(self): + w = RangeWidget(lambda: None, x0=0, x1=10, linewidth=2.5) + assert w.linewidth == 2.5 + assert w.to_dict()["linewidth"] == 2.5 + + def test_point_default_linewidth(self): + w = PointWidget(lambda: None, x=0, y=0) + assert w.linewidth == 2.0 + assert w.to_dict()["linewidth"] == 2.0 + + def test_point_custom_linewidth(self): + w = PointWidget(lambda: None, x=0, y=0, linewidth=0.5) + assert w.linewidth == 0.5 + assert w.to_dict()["linewidth"] == 0.5 + + # ── Widget.set() round-trip ────────────────────────────────────────── + + def test_set_linewidth_round_trips(self): + w = RectangleWidget(lambda: None, x=0, y=0, w=10, h=10) + w.set(linewidth=7) + assert w.linewidth == 7.0 + assert w.to_dict()["linewidth"] == 7.0 + + def test_set_linewidth_pushes(self): + pushed = [] + w = CircleWidget(lambda: pushed.append(1), cx=0, cy=0, r=5) + pushed.clear() + w.set(linewidth=9) + assert w.linewidth == 9.0 + assert len(pushed) == 1 + + # ── factory passthrough (Plot2D / Plot1D) ────────────────────────────── + + def test_plot2d_add_circle_widget_linewidth(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + w = v.add_circle_widget(cx=16, cy=16, r=5, linewidth=4) + assert w.linewidth == 4.0 + d = v.to_state_dict()["overlay_widgets"] + assert d[0]["linewidth"] == 4.0 + + def test_plot2d_add_rectangle_widget_linewidth(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + w = v.add_rectangle_widget(x=1, y=1, w=5, h=5, linewidth=3) + assert w.linewidth == 3.0 + + def test_plot2d_add_annular_widget_linewidth(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + w = v.add_annular_widget(r_outer=10, r_inner=5, linewidth=1.5) + assert w.linewidth == 1.5 + + def test_plot2d_add_crosshair_widget_linewidth(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + w = v.add_crosshair_widget(linewidth=6) + assert w.linewidth == 6.0 + + def test_plot2d_add_polygon_widget_linewidth(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + w = v.add_polygon_widget( + vertices=[[0, 0], [10, 0], [10, 10], [0, 10]], linewidth=2.5) + assert w.linewidth == 2.5 + + def test_plot2d_widget_factory_default_linewidth(self): + """No linewidth kwarg → factory forwards the default of 2.""" + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + w = v.add_rectangle_widget(x=1, y=1, w=5, h=5) + assert w.linewidth == 2.0 + + def test_plot1d_add_vline_widget_linewidth(self): + fig, ax = apl.subplots(1, 1) + v = ax.plot(np.zeros(64)) + w = v.add_vline_widget(x=10.0, linewidth=4) + assert w.linewidth == 4.0 + + def test_plot1d_add_hline_widget_linewidth(self): + fig, ax = apl.subplots(1, 1) + v = ax.plot(np.zeros(64)) + w = v.add_hline_widget(y=0.5, linewidth=3) + assert w.linewidth == 3.0 + + def test_plot1d_add_range_widget_linewidth(self): + fig, ax = apl.subplots(1, 1) + v = ax.plot(np.zeros(64)) + w = v.add_range_widget(x0=10, x1=20, linewidth=2.5) + assert w.linewidth == 2.5 + + def test_plot1d_add_point_widget_linewidth(self): + fig, ax = apl.subplots(1, 1) + v = ax.plot(np.zeros(64)) + w = v.add_point_widget(x=1, y=1, linewidth=1.0) + assert w.linewidth == 1.0 + + def test_plot1d_widget_factory_default_linewidth(self): + """No linewidth kwarg → factory forwards the default of 2.""" + fig, ax = apl.subplots(1, 1) + v = ax.plot(np.zeros(64)) + w = v.add_vline_widget(x=10.0) + assert w.linewidth == 2.0 diff --git a/anyplotlib/tests/test_labels/test_label_api.py b/anyplotlib/tests/test_labels/test_label_api.py index a4cc7714..edcfecab 100644 --- a/anyplotlib/tests/test_labels/test_label_api.py +++ b/anyplotlib/tests/test_labels/test_label_api.py @@ -112,6 +112,52 @@ def test_set_tick_label_size(self, make): assert v._state["tick_size"] == 14.0 +class TestLegendFontsize: + """Plot1D.set_legend_fontsize writes state["legend_fontsize"]; absent by + default; rejects non-positive / non-numeric values (mirrors the + marker_size > 0 validation style used in indicate_point).""" + + def test_absent_by_default(self): + v = _plot() + assert "legend_fontsize" not in v._state + + def test_sets_state_key(self): + v = _plot() + v.set_legend_fontsize(14) + assert v._state["legend_fontsize"] == 14.0 + + def test_value_is_float(self): + v = _plot() + v.set_legend_fontsize(12) + assert isinstance(v._state["legend_fontsize"], float) + + def test_zero_rejected(self): + v = _plot() + with pytest.raises(ValueError, match="legend_fontsize"): + v.set_legend_fontsize(0) + + def test_negative_rejected(self): + v = _plot() + with pytest.raises(ValueError): + v.set_legend_fontsize(-5) + + def test_nan_rejected(self): + v = _plot() + with pytest.raises(ValueError): + v.set_legend_fontsize(float("nan")) + + def test_non_numeric_rejected(self): + v = _plot() + with pytest.raises(ValueError): + v.set_legend_fontsize("big") + + def test_overwrites_previous_value(self): + v = _plot() + v.set_legend_fontsize(10) + v.set_legend_fontsize(20) + assert v._state["legend_fontsize"] == 20.0 + + class TestTexPassThrough: """Python stores TeX strings verbatim; all parsing happens at JS draw time.""" diff --git a/anyplotlib/tests/test_layouts/test_inset.py b/anyplotlib/tests/test_layouts/test_inset.py index 7c67bd33..f2a14849 100644 --- a/anyplotlib/tests/test_layouts/test_inset.py +++ b/anyplotlib/tests/test_layouts/test_inset.py @@ -233,6 +233,159 @@ def test_on_event_inset_state_restore_via_event(): assert inset.inset_state == "normal" +# ── set_geometry (drag / resize state API) ─────────────────────────────────── + +def test_set_geometry_anchor_updates_state_and_layout(): + """set_geometry(anchor=...) switches to free placement (drops corner) and + serializes the new anchor into layout_json.""" + fig = _make_fig() + inset = fig.add_inset(0.3, 0.3, corner="top-right") + plot = inset.imshow(np.zeros((32, 32))) + + ret = inset.set_geometry(anchor=(0.4, 0.15)) + + assert ret is inset # chaining + assert inset.anchor == (0.4, 0.15) + assert inset.corner is None + spec = _inset_spec(fig, plot._id) + assert spec["anchor"] == [0.4, 0.15] + assert spec["corner"] is None + + +def test_set_geometry_size_updates_fracs_and_panel_px(): + """set_geometry(w_frac/h_frac) updates the size fractions and the derived + panel_width/height in layout_json.""" + fig = _make_fig() # 640x480 + inset = fig.add_inset(0.3, 0.3, anchor=(0.5, 0.1)) + plot = inset.imshow(np.zeros((32, 32))) + + inset.set_geometry(w_frac=0.4, h_frac=0.25) + + assert inset.w_frac == 0.4 + assert inset.h_frac == 0.25 + spec = _inset_spec(fig, plot._id) + assert spec["w_frac"] == 0.4 + assert spec["h_frac"] == 0.25 + assert spec["panel_width"] == max(64, round(640 * 0.4)) + assert spec["panel_height"] == max(64, round(480 * 0.25)) + + +def test_set_geometry_clamps_anchor_and_size(): + """Anchor fractions clamp into [0, 1]; sizes clamp into (0, 1].""" + fig = _make_fig() + inset = fig.add_inset(0.3, 0.3, corner="top-right") + inset.imshow(np.zeros((32, 32))) + + inset.set_geometry(anchor=(1.5, -0.2), w_frac=2.0, h_frac=-1.0) + assert inset.anchor == (1.0, 0.0) + assert inset.w_frac == 1.0 + # negative/zero clamps to the tiny positive floor, not to 0. + assert 0.0 < inset.h_frac <= 1e-6 + + +def test_set_geometry_partial_leaves_others_unchanged(): + """Omitting a dimension leaves it unchanged; omitting anchor keeps the + existing placement.""" + fig = _make_fig() + inset = fig.add_inset(0.3, 0.25, anchor=(0.5, 0.1)) + inset.imshow(np.zeros((32, 32))) + + inset.set_geometry(w_frac=0.45) + assert inset.w_frac == 0.45 + assert inset.h_frac == 0.25 # unchanged + assert inset.anchor == (0.5, 0.1) # placement unchanged + assert inset.corner is None + + +def test_set_geometry_bad_anchor_raises(): + fig = _make_fig() + inset = fig.add_inset(0.3, 0.3, corner="top-right") + inset.imshow(np.zeros((32, 32))) + with pytest.raises(ValueError, match="anchor"): + inset.set_geometry(anchor=(0.1,)) # not a pair + with pytest.raises(ValueError, match="finite"): + inset.set_geometry(anchor=(float("nan"), 0.2)) + with pytest.raises(ValueError, match="w_frac"): + inset.set_geometry(w_frac=float("inf")) + + +# ── inset_geometry_change event (JS→Python path) ───────────────────────────── + +def test_on_event_inset_geometry_change(): + """A simulated JS inset_geometry_change updates the InsetAxes geometry AND + delivers a figure-level Event carrying inset_id / anchor / w_frac / h_frac.""" + fig = _make_fig() + inset = fig.add_inset(0.3, 0.3, corner="top-right") + plot = inset.imshow(np.zeros((32, 32))) + + seen = [] + fig.add_event_handler(lambda ev: seen.append(ev), "inset_geometry_change") + + fig._dispatch_event(json.dumps({ + "source": "js", + "panel_id": plot._id, + "event_type": "inset_geometry_change", + "anchor": [0.1, 0.2], + "w_frac": 0.4, + "h_frac": 0.3, + })) + + # Python-side geometry converged. + assert inset.anchor == (0.1, 0.2) + assert inset.corner is None + assert inset.w_frac == 0.4 + assert inset.h_frac == 0.3 + spec = _inset_spec(fig, plot._id) + assert spec["anchor"] == [0.1, 0.2] + + # Figure-level handler received the geometry. + assert len(seen) == 1 + ev = seen[0] + assert ev.event_type == "inset_geometry_change" + assert ev.inset_id == plot._id + assert ev.anchor == [0.1, 0.2] + assert ev.w_frac == 0.4 + assert ev.h_frac == 0.3 + + +def test_on_event_inset_geometry_change_unknown_panel_still_fires(): + """An event for an unregistered inset id fires the figure callback but + touches no InsetAxes (defensive: host may have removed the inset).""" + fig = _make_fig() + seen = [] + fig.add_event_handler(lambda ev: seen.append(ev), "inset_geometry_change") + + fig._dispatch_event(json.dumps({ + "source": "js", + "panel_id": "nope1234", + "event_type": "inset_geometry_change", + "anchor": [0.1, 0.2], + "w_frac": 0.4, + "h_frac": 0.3, + })) + assert len(seen) == 1 + assert seen[0].inset_id == "nope1234" + + +def test_inset_geometry_fields_none_on_other_figure_events(): + """The new Event fields default to None for non-geometry figure events.""" + fig = _make_fig() + seen = [] + fig.add_event_handler(lambda ev: seen.append(ev), "pointer_down") + fig._dispatch_event(json.dumps({ + "source": "js", + "panel_id": "", + "event_type": "pointer_down", + "figure_background": True, + })) + assert len(seen) == 1 + ev = seen[0] + assert ev.inset_id is None + assert ev.anchor is None + assert ev.w_frac is None + assert ev.h_frac is None + + # ── figure resize updates inset dimensions ─────────────────────────────────── def test_resize_updates_inset_panel_size(): diff --git a/anyplotlib/tests/test_layouts/test_inset_callout.py b/anyplotlib/tests/test_layouts/test_inset_callout.py index f2b382f5..0db60ce1 100644 --- a/anyplotlib/tests/test_layouts/test_inset_callout.py +++ b/anyplotlib/tests/test_layouts/test_inset_callout.py @@ -269,6 +269,126 @@ def test_two_insets_two_indications(): assert ids == {i1._plot._id, i2._plot._id} +# ── indicate_point ───────────────────────────────────────────────────────── + +def test_indicate_point_state(): + fig, ax = _make_fig() + inset = fig.add_inset(0.3, 0.3, anchor=(0.55, 0.1)) + inset.imshow(np.zeros((16, 16), dtype=np.float32)) + + inset.indicate_point(ax._plot, (20, 30), color="#00ff00", + linestyle="solid", linewidth=2.0, marker_size=7.0) + + ind = json.loads(fig.layout_json)["indications"][0] + assert ind["inset_id"] == inset._plot._id + assert ind["parent_id"] == ax._plot._id + assert ind["point"] == [20.0, 30.0] + assert "region" not in ind + assert ind["color"] == "#00ff00" + assert ind["linestyle"] == "solid" + assert ind["linewidth"] == 2.0 + assert ind["marker_size"] == 7.0 + assert inset.indication["point"] == [20.0, 30.0] + + +def test_indicate_point_defaults(): + fig, ax = _make_fig() + inset = fig.add_inset(0.3, 0.3, corner="top-right") + inset.imshow(np.zeros((16, 16), dtype=np.float32)) + inset.indicate_point(ax._plot, (5, 5)) + ind = json.loads(fig.layout_json)["indications"][0] + assert ind["color"] == "#ff9800" + assert ind["linestyle"] == "dashed" + assert ind["linewidth"] == 1.5 + assert ind["marker_size"] == 5.0 + + +def test_indicate_point_replaces_region_and_vice_versa(): + """point and region share the one _indication slot — each replaces the + other (an inset carries at most one indication).""" + fig, ax = _make_fig() + inset = fig.add_inset(0.3, 0.3, anchor=(0.55, 0.1)) + inset.imshow(np.zeros((16, 16), dtype=np.float32)) + + inset.indicate_region(ax._plot, (10, 10, 20, 20)) + inset.indicate_point(ax._plot, (40, 40)) + inds = json.loads(fig.layout_json)["indications"] + assert len(inds) == 1 + assert inds[0]["point"] == [40.0, 40.0] and "region" not in inds[0] + + inset.indicate_region(ax._plot, (1, 2, 3, 4)) + inds = json.loads(fig.layout_json)["indications"] + assert len(inds) == 1 + assert inds[0]["region"] == [1.0, 2.0, 3.0, 4.0] and "point" not in inds[0] + + +def test_clear_indication_clears_point(): + fig, ax = _make_fig() + inset = fig.add_inset(0.3, 0.3, anchor=(0.55, 0.1)) + inset.imshow(np.zeros((16, 16), dtype=np.float32)) + inset.indicate_point(ax._plot, (10, 10)) + inset.clear_indication() + assert json.loads(fig.layout_json)["indications"] == [] + assert inset.indication is None + + +def test_indicate_point_bad_parent_raises(): + fig, _ = _make_fig() + inset = fig.add_inset(0.3, 0.3, anchor=(0.5, 0.1)) + inset.imshow(np.zeros((16, 16), dtype=np.float32)) + + class _NoId: + pass + + with pytest.raises(ValueError, match="panel id"): + inset.indicate_point(_NoId(), (0, 0)) + + +def test_indicate_point_foreign_figure_parent_raises(): + fig, ax = _make_fig() + inset = fig.add_inset(0.3, 0.3, anchor=(0.5, 0.1)) + inset.imshow(np.zeros((16, 16), dtype=np.float32)) + other_fig, other_ax = _make_fig() + with pytest.raises(ValueError, match="not registered"): + inset.indicate_point(other_ax._plot, (0, 0)) + assert inset.indication is None + + +@pytest.mark.parametrize("point", [ + (float("nan"), 0), + (0, float("nan")), + (float("inf"), 0), + (0, 1, 2), # wrong length (too many) + (0,), # wrong length (too few) + "nope", +]) +def test_indicate_point_degenerate_point_raises(point): + fig, ax = _make_fig() + inset = fig.add_inset(0.3, 0.3, anchor=(0.5, 0.1)) + inset.imshow(np.zeros((16, 16), dtype=np.float32)) + with pytest.raises(ValueError): + inset.indicate_point(ax._plot, point) + + +@pytest.mark.parametrize("ms", [0, -1, float("nan"), float("inf")]) +def test_indicate_point_bad_marker_size_raises(ms): + fig, ax = _make_fig() + inset = fig.add_inset(0.3, 0.3, anchor=(0.5, 0.1)) + inset.imshow(np.zeros((16, 16), dtype=np.float32)) + with pytest.raises(ValueError, match="marker_size"): + inset.indicate_point(ax._plot, (5, 5), marker_size=ms) + + +def test_indicate_point_out_of_bounds_is_allowed(): + """Same policy as indicate_region: outside the parent's data bounds is a + visual-clipping concern, not a validation error.""" + fig, ax = _make_fig() # parent image is 64x64 + inset = fig.add_inset(0.3, 0.3, anchor=(0.5, 0.1)) + inset.imshow(np.zeros((16, 16), dtype=np.float32)) + inset.indicate_point(ax._plot, (1000, 1000)) + assert inset.indication["point"] == [1000.0, 1000.0] + + # ── figure_state / save_html round-trip ─────────────────────────────────── def test_indication_survives_figure_state_roundtrip(): @@ -451,6 +571,166 @@ def test_anchored_inset_dom_position(self, mount_page): assert abs(rect["width"] - 0.30 * 640) <= 4, rect +# ── title-bar auto-hide (empty-title insets have no header strip) ────────── + +def _inset_page_rect(page, plot_id): + """Absolute PAGE coordinates of the inset's insetDiv (for page.mouse.*).""" + return page.evaluate( + """(pid) => { + const p = window._handle.api.panels.get(pid); + const r = p.insetDiv.getBoundingClientRect(); + return {left: r.left, top: r.top, width: r.width, height: r.height}; + }""", + plot_id, + ) + + +def _title_bar_info(page, plot_id): + """Return {display, height, insetH, contentH} for the given inset's DOM.""" + return page.evaluate( + """(pid) => { + const p = window._handle.api.panels.get(pid); + const tb = p.titleBar; + const cs = getComputedStyle(tb); + return { + display: cs.display, + tbHeight: tb.getBoundingClientRect().height, + insetHeight: p.insetDiv.getBoundingClientRect().height, + contentHeight: p.contentDiv.getBoundingClientRect().height, + }; + }""", + plot_id, + ) + + +class TestInsetTitleBarAutoHide: + """An inset with no title (default) renders with NO title-bar strip at + all — a clean bordered plot box, content filling the whole area. A + titled inset keeps its bar (and click-to-minimize on it).""" + + def test_default_title_has_no_bar(self, mount_page): + """title omitted (default "") → title bar is display:none and the + inset's total height equals just the content height (no header gap).""" + fig, ax = apl.subplots(1, 1, figsize=(640, 480)) + ax.imshow(np.zeros((64, 64), dtype=np.float32)) + inset = fig.add_inset(0.30, 0.25, anchor=(0.1, 0.1)) # no title= + plot = inset.imshow(np.zeros((16, 16), dtype=np.float32)) + + page = mount_page(fig) + info = _title_bar_info(page, plot._id) + assert info["display"] == "none", info + assert info["tbHeight"] == 0, info + # No phantom header gap: inset box height == content height, modulo + # the insetDiv's own 1px top+bottom border (getBoundingClientRect + # includes the border box). + assert abs(info["insetHeight"] - info["contentHeight"]) <= 2, info + + def test_empty_string_title_has_no_bar(self, mount_page): + """Explicit title="" behaves the same as omitting it.""" + fig, ax = apl.subplots(1, 1, figsize=(640, 480)) + ax.imshow(np.zeros((64, 64), dtype=np.float32)) + inset = fig.add_inset(0.30, 0.25, anchor=(0.1, 0.1), title="") + plot = inset.imshow(np.zeros((16, 16), dtype=np.float32)) + + page = mount_page(fig) + info = _title_bar_info(page, plot._id) + assert info["display"] == "none", info + + def test_whitespace_title_has_no_bar(self, mount_page): + """A whitespace-only title counts as empty (trimmed before the check).""" + fig, ax = apl.subplots(1, 1, figsize=(640, 480)) + ax.imshow(np.zeros((64, 64), dtype=np.float32)) + inset = fig.add_inset(0.30, 0.25, anchor=(0.1, 0.1), title=" ") + plot = inset.imshow(np.zeros((16, 16), dtype=np.float32)) + + page = mount_page(fig) + info = _title_bar_info(page, plot._id) + assert info["display"] == "none", info + + def test_titled_inset_keeps_bar(self, mount_page): + """A non-empty title still renders its title-bar strip as before.""" + fig, ax = apl.subplots(1, 1, figsize=(640, 480)) + ax.imshow(np.zeros((64, 64), dtype=np.float32)) + inset = fig.add_inset(0.30, 0.25, anchor=(0.1, 0.1), title="Zoom") + plot = inset.imshow(np.zeros((16, 16), dtype=np.float32)) + + page = mount_page(fig) + info = _title_bar_info(page, plot._id) + assert info["display"] == "flex", info + assert info["tbHeight"] > 0, info + # Titled inset box is taller than its content (title strip on top). + assert info["insetHeight"] > info["contentHeight"], info + + def test_titled_inset_click_still_minimizes(self, mount_page): + """Clicking the title bar of a TITLED inset still toggles minimize — + the auto-hide change must not break the existing affordance. + + The click handler (a) optimistically collapses contentDiv locally via + _applyAllInsetStates and (b) emits inset_state_change on event_json — + there is no live Python bridge in this harness to push an updated + layout_json back, so assert on those two directly-observable effects + rather than layout_json (which only a real host round-trip updates). + """ + fig, ax = apl.subplots(1, 1, figsize=(640, 480)) + ax.imshow(np.zeros((64, 64), dtype=np.float32)) + inset = fig.add_inset(0.30, 0.25, anchor=(0.1, 0.1), title="Zoom") + plot = inset.imshow(np.zeros((16, 16), dtype=np.float32)) + + page = mount_page(fig) + rect = _inset_page_rect(page, plot._id) + page.mouse.click(rect["left"] + rect["width"] / 2, rect["top"] + 5) + page.wait_for_timeout(80) + + content_display = page.evaluate( + """(pid) => window._handle.api.panels.get(pid).contentDiv.style.display""", + plot._id, + ) + assert content_display == "none", content_display + + # event_json is a synced trait; read it directly since this harness + # has no onEvent callback wired. + last_event = page.evaluate( + "() => { try { return JSON.parse(window._handle.get('event_json')); } " + "catch(_) { return null; } }" + ) + assert last_event is not None + assert last_event["event_type"] == "inset_state_change" + assert last_event["new_state"] == "minimized" + + def test_titleless_inset_body_drag_still_moves_it(self, mount_page): + """No title bar → no minimize affordance, but drag-to-move (edit mode) + still works because the drag pointerdown listens on insetDiv itself, + not the title bar. Real mouse drag on the content area must emit + inset_geometry_change with a shifted anchor.""" + fig, ax = apl.subplots(1, 1, figsize=(640, 480)) + ax.imshow(np.zeros((64, 64), dtype=np.float32)) + inset = fig.add_inset(0.25, 0.22, anchor=(0.1, 0.1)) # title-less + plot = inset.imshow(np.zeros((16, 16), dtype=np.float32)) + + page = mount_page(fig) + page.evaluate("() => { window._handle.set('edit_chrome', true); }") + page.wait_for_timeout(60) + + rect = _inset_page_rect(page, plot._id) + cx = rect["left"] + rect["width"] / 2 + cy = rect["top"] + rect["height"] / 2 + + page.mouse.move(cx, cy) + page.mouse.down() + page.mouse.move(cx + 40, cy + 25, steps=10) + page.mouse.up() + page.wait_for_timeout(120) + + # Read the authoritative anchor back off the model (the mount() bridge + # writes inset_geometry_change to event_json; simplest robust check is + # that the DOM actually moved to the new position). + new_rect = _inset_page_rect(page, plot._id) + assert abs(new_rect["left"] - rect["left"]) > 15 or \ + abs(new_rect["top"] - rect["top"]) > 15, ( + f"title-less inset body drag did not move it: before={rect} after={new_rect}" + ) + + class TestCalloutRendering: def test_dashed_rect_and_leaders_present(self, mount_page): """The callout canvas paints lime ink spanning FROM the parent region @@ -598,6 +878,97 @@ def test_empty_overlays_do_not_shadow_panel_content(self, mount_page): f"largest canvas has no rendered content: {info}") +def _point_callout_fig(color="#00ff00", point=(8, 8), anchor=(0.55, 0.55)): + """640×480 image + anchored inset with a bright point indication.""" + fig, ax = apl.subplots(1, 1, figsize=(640, 480)) + parent = ax.imshow(np.zeros((64, 64), dtype=np.float32), + cmap="gray", vmin=0.0, vmax=1.0) + inset = fig.add_inset(0.30, 0.30, anchor=anchor, title="Point") + inset.imshow(np.zeros((32, 32), dtype=np.float32)) + inset.indicate_point(parent, point, color=color, linewidth=2.5, + marker_size=6.0) + return fig, ax, parent, inset + + +class TestPointCalloutRendering: + def test_marker_and_leader_present(self, mount_page): + """Lime ink spans FROM the marked point (top-left of the parent) TO the + inset (bottom-right) — i.e. marker + leader both drew.""" + fig, ax, parent, inset = _point_callout_fig(point=(8, 8), + anchor=(0.55, 0.55)) + page = mount_page(fig) + + bbox = _color_bbox(page, (0, 255, 0)) + assert bbox is not None, "no lime point-callout pixels found" + assert bbox["count"] > 20, f"too little callout ink: {bbox}" + # Marker sits near the parent's top-left; the leader reaches toward the + # inset anchored at (0.55, 0.55) → wide diagonal span in BOTH axes. + assert bbox["minX"] < 0.25 * 640, f"marker not near top-left: {bbox}" + assert bbox["minY"] < 0.30 * 480, f"marker not near top-left: {bbox}" + assert bbox["maxX"] >= 0.55 * 640 - 6, ( + f"leader doesn't reach the inset's left edge: {bbox}") + assert bbox["maxY"] >= 0.55 * 480 - 6, ( + f"leader doesn't reach the inset's top edge: {bbox}") + + def test_minimize_hides_leader_keeps_marker(self, mount_page): + fig, ax, parent, inset = _point_callout_fig(point=(8, 8), + anchor=(0.55, 0.55)) + page = mount_page(fig) + full = _color_bbox(page, (0, 255, 0)) + assert full is not None + + page.evaluate( + """(pid) => { + const layout = JSON.parse(window._handle.get('layout_json')); + for (const s of layout.inset_specs) + if (s.id === pid) s.inset_state = 'minimized'; + window._handle.set('layout_json', JSON.stringify(layout)); + }""", + inset._plot._id, + ) + page.evaluate("() => new Promise(r => requestAnimationFrame(" + "() => requestAnimationFrame(() => requestAnimationFrame(r))))") + + mini = _color_bbox(page, (0, 255, 0)) + assert mini is not None, "marker vanished when minimized (should stay)" + # The leader reached toward the inset; minimized ink is just the small + # marker, so the ink extent collapses back toward the point. + assert mini["maxX"] < full["maxX"] - 15, ( + f"leader still present when minimized: full={full} mini={mini}") + assert (mini["maxX"] - mini["minX"]) < 30, ( + f"minimized ink wider than a marker: {mini}") + + def test_marker_tracks_parent_zoom(self, mount_page): + """Zooming the parent moves the marker (mapped through the parent's + data→screen transform every draw).""" + fig, ax, parent, inset = _point_callout_fig(point=(8, 8), + anchor=(0.55, 0.55)) + page = mount_page(fig) + before = _color_bbox(page, (0, 255, 0)) + assert before is not None + + _set_parent_view(page, parent._id, zoom=2.0, cx=0.5, cy=0.5) + after = _color_bbox(page, (0, 255, 0)) + assert after is not None, "point callout gone after zoom" + shift = ((after["minX"] - before["minX"]) ** 2 + + (after["minY"] - before["minY"]) ** 2) ** 0.5 + assert shift > 15, ( + f"marker did not move on zoom (shift={shift:.1f}px): " + f"before={before} after={after}") + + def test_export_png_includes_point_indication(self, mount_page): + fig, ax, parent, inset = _point_callout_fig(point=(20, 20), + anchor=(0.55, 0.1)) + page = mount_page(fig) + res = page.evaluate( + "() => window._handle.exportPNG({}).then(r => ({dataUrl:r.dataUrl}))") + raw = base64.b64decode(res["dataUrl"].split(",", 1)[1]) + arr = decode_png(raw) + d = np.abs(arr[..., :3].astype(np.int32) - np.array([0, 255, 0])) + n = int(((d <= 40).all(axis=-1)).sum()) + assert n > 20, f"point indication missing from export ({n} lime px)" + + class TestCalloutExport: def _decode(self, data_url): raw = base64.b64decode(data_url.split(",", 1)[1]) diff --git a/anyplotlib/tests/test_plot2d/test_layers.py b/anyplotlib/tests/test_plot2d/test_layers.py index 42a9d616..dc8a0f23 100644 --- a/anyplotlib/tests/test_plot2d/test_layers.py +++ b/anyplotlib/tests/test_plot2d/test_layers.py @@ -15,6 +15,7 @@ import pytest import anyplotlib as apl +from anyplotlib._utils import _build_tint_lut from anyplotlib.plot2d import Layer @@ -74,6 +75,99 @@ def test_add_layer_alpha_out_of_range_raises(self): p.add_layer(np.ones((32, 32), np.float32), alpha=1.5) +class TestTintLut: + """_build_tint_lut: 256×4 clear→colour ramp from a hex string.""" + + def test_rrggbb_rgb_constant_alpha_ramp(self): + lut = _build_tint_lut("#ff9800") + assert len(lut) == 256 + assert all(len(e) == 4 for e in lut) + # RGB is the tint at EVERY entry; alpha is the linear 0→255 ramp. + assert all(e[:3] == [255, 152, 0] for e in lut) + assert [e[3] for e in lut] == list(range(256)) + assert lut[0] == [255, 152, 0, 0] # transparent at low intensity + assert lut[255] == [255, 152, 0, 255] # opaque tint at high intensity + + def test_rgb_shorthand(self): + assert _build_tint_lut("#f00")[255] == [255, 0, 0, 255] + assert _build_tint_lut("#f00") == _build_tint_lut("#ff0000") + + def test_invalid_colour_raises(self): + for bad in ("red", "#12345", "#gggggg", "", None, 42): + with pytest.raises(ValueError): + _build_tint_lut(bad) + + +class TestTint: + """add_layer(tint=) / Layer.set(tint=) — the clear→colour ramp mode.""" + + def test_add_layer_tint_records_state(self): + _fig, p = _imshow() + lyr = p.add_layer(np.ones((32, 32), np.float32), tint="#ff0000", + alpha=0.8, clim=(0, 1)) + e = p._state["layers"][0] + assert e["tint"] == "#ff0000" + assert lyr.tint == "#ff0000" + # colormap_data is the 256×4 tint LUT, not the named-cmap LUT. + assert e["colormap_data"] == _build_tint_lut("#ff0000") + # cmap is retained (set(cmap=...) reverts to it) but display is tinted. + assert e["cmap"] == "magma" + + def test_default_tint_none_keeps_cmap_lut(self): + _fig, p = _imshow() + lyr = p.add_layer(np.ones((32, 32), np.float32), cmap="magma") + e = p._state["layers"][0] + assert e["tint"] is None and lyr.tint is None + # Named-cmap LUT entries stay 3-channel (today's behaviour). + assert all(len(c) == 3 for c in e["colormap_data"]) + + def test_add_layer_invalid_tint_raises_before_mutation(self): + _fig, p = _imshow() + with pytest.raises(ValueError): + p.add_layer(np.ones((32, 32), np.float32), tint="not-a-colour") + assert p.layers == [] and p._state.get("layers", []) == [] + + def test_set_tint_switches_lut(self): + _fig, p = _imshow() + lyr = p.add_layer(np.ones((32, 32), np.float32), cmap="magma") + lyr.set(tint="#00ff00") + e = p._state["layers"][0] + assert lyr.tint == "#00ff00" + assert e["colormap_data"] == _build_tint_lut("#00ff00") + + def test_set_cmap_clears_tint(self): + _fig, p = _imshow() + lyr = p.add_layer(np.ones((32, 32), np.float32), tint="#ff0000") + lyr.set(cmap="viridis") + e = p._state["layers"][0] + assert lyr.tint is None and e["tint"] is None + assert all(len(c) == 3 for c in e["colormap_data"]) + assert e["cmap"] == "viridis" + + def test_set_cmap_and_tint_together_raises(self): + _fig, p = _imshow() + lyr = p.add_layer(np.ones((32, 32), np.float32)) + with pytest.raises(ValueError, match="not both"): + lyr.set(cmap="viridis", tint="#ff0000") + + def test_set_tint_none_leaves_tint_unchanged(self): + _fig, p = _imshow() + lyr = p.add_layer(np.ones((32, 32), np.float32), tint="#ff0000") + lyr.set(alpha=0.3) # tint=None default — no change + assert lyr.tint == "#ff0000" + + def test_tint_survives_figure_state(self): + from anyplotlib.embed import figure_state + fig, ax = apl.subplots(1, 1, figsize=(200, 200)) + p = ax.imshow(np.zeros((16, 16), np.float32), cmap="gray", + vmin=0, vmax=1, gpu=False) + p.add_layer(np.full((16, 16), 0.7, np.float32), tint="#ff9800") + st = figure_state(fig) + pj = json.loads(st[f"panel_{p._id}_json"]) + assert pj["layers"][0]["tint"] == "#ff9800" + assert pj["layers"][0]["colormap_data"][255] == [255, 152, 0, 255] + + class TestZOrder: def test_layers_kept_in_add_order(self): _fig, p = _imshow() diff --git a/anyplotlib/tests/test_plot2d/test_layers_playwright.py b/anyplotlib/tests/test_plot2d/test_layers_playwright.py index a86310a1..f8388290 100644 --- a/anyplotlib/tests/test_plot2d/test_layers_playwright.py +++ b/anyplotlib/tests/test_plot2d/test_layers_playwright.py @@ -220,6 +220,113 @@ def test_exportpng_includes_layer(self, mount_page): ) +class TestTintedLayer: + """tint= layers: clear→colour ramp composited via per-texel LUT alpha.""" + + def test_tint_ramp_transparent_low_opaque_high(self, mount_page): + """A half-0 / half-1 tinted layer (alpha=1) must show the OPAQUE tint + colour where intensity is high and the untouched base where intensity + is low (per-texel alpha 0 → fully transparent).""" + fig, ax = apl.subplots(1, 1, figsize=(300, 300)) + # Mid-gray base (distinct from the page/theme background). + p = ax.imshow(np.full((32, 32), 0.5, np.float32), + cmap="gray", vmin=0.0, vmax=1.0, gpu=False) + data = np.zeros((32, 32), np.float32) + data[:, 16:] = 1.0 # right half at clim top + p.add_layer(data, tint="#ff0000", alpha=1.0, clim=(0, 1)) + + base_mid = np.array(_lut_endpoint("gray", 127), np.float64) # ~mid gray + red = np.array([255, 0, 0], np.float64) + + page = mount_page(fig) + res = _export(page) + assert "error" not in res, res.get("error") + arr = _decode(res["dataUrl"]) + npx = arr.shape[0] * arr.shape[1] + + # Opaque tint where intensity is high (right half of the fit-rect). + assert _count_near(arr, red) > 0.08 * npx, ( + "opaque tint colour missing where layer intensity is high" + ) + # Untouched base where intensity is low (alpha-0 texels draw nothing). + assert _count_near(arr, base_mid) > 0.08 * npx, ( + "base colour not visible where layer intensity is low — " + "alpha-0 texels are not transparent" + ) + + def test_texel_alpha_multiplies_with_layer_alpha(self, mount_page): + """Per-texel LUT alpha (255 at clim top) × layer alpha 0.5 must give + the 50 % base⊕tint blend — proving the two alphas compose (ImageData is + unpremultiplied; globalAlpha multiplies at drawImage time).""" + alpha = 0.5 + fig, ax = apl.subplots(1, 1, figsize=(300, 300)) + p = ax.imshow(np.full((32, 32), 0.5, np.float32), + cmap="gray", vmin=0.0, vmax=1.0, gpu=False) + p.add_layer(np.full((32, 32), 1.0, np.float32), + tint="#ff0000", alpha=alpha, clim=(0, 1)) + + base_mid = np.array(_lut_endpoint("gray", 127), np.float64) + red = np.array([255, 0, 0], np.float64) + expected = np.round(base_mid * (1 - alpha) + red * alpha) + + page = mount_page(fig) + arr = _decode(_export(page)["dataUrl"]) + centre = _center_rgb(arr) + d = int(np.max(np.abs(centre - expected))) + assert d <= 16, ( + f"centre {centre.tolist()} != blend {expected.tolist()} (dmax={d})" + ) + + def test_set_tint_live_updates_composite(self, mount_page): + """Toggling tint via layer.set() must invalidate the JS LUT-bitmap + cache (lutKey) and repaint in the new colour.""" + fig, ax = apl.subplots(1, 1, figsize=(300, 300)) + p = ax.imshow(np.full((32, 32), 0.5, np.float32), + cmap="gray", vmin=0.0, vmax=1.0, gpu=False) + lyr = p.add_layer(np.full((32, 32), 1.0, np.float32), + tint="#ff0000", alpha=1.0, clim=(0, 1)) + page = mount_page(fig) + before = _center_rgb(_decode(_export(page)["dataUrl"])) + assert int(np.max(np.abs(before - [255, 0, 0]))) <= 16, ( + f"initial tint not red: {before.tolist()}" + ) + + lyr.set(tint="#00ff00") + _set_panel(page, p) + _flush(page) + after = _center_rgb(_decode(_export(page)["dataUrl"])) + assert int(np.max(np.abs(after - [0, 255, 0]))) <= 16, ( + f"tint change did not repaint (still {after.tolist()}) — stale " + "lutKey cache?" + ) + + def test_set_cmap_reverts_tint_to_opaque_cmap(self, mount_page): + """set(cmap=...) clears the tint: low-intensity texels go back to the + OPAQUE cmap colour (a 3-channel LUT defaults alpha to 255).""" + fig, ax = apl.subplots(1, 1, figsize=(300, 300)) + p = ax.imshow(np.full((32, 32), 0.5, np.float32), + cmap="gray", vmin=0.0, vmax=1.0, gpu=False) + # Zero-intensity tinted layer → fully transparent → base shows. + lyr = p.add_layer(np.zeros((32, 32), np.float32), + tint="#ff0000", alpha=1.0, clim=(0, 1)) + page = mount_page(fig) + base_mid = np.array(_lut_endpoint("gray", 127), np.int32) + tinted = _center_rgb(_decode(_export(page)["dataUrl"])) + assert int(np.max(np.abs(tinted - base_mid))) <= 16, ( + f"alpha-0 tinted layer altered the base: {tinted.tolist()}" + ) + + lyr.set(cmap="magma") + _set_panel(page, p) + _flush(page) + reverted = _center_rgb(_decode(_export(page)["dataUrl"])) + cmap_lo = np.array(_lut_endpoint("magma", 0), np.int32) # opaque dark + assert int(np.max(np.abs(reverted - cmap_lo))) <= 16, ( + f"cmap revert did not restore opaque colormap display: " + f"{reverted.tolist()} != {cmap_lo.tolist()}" + ) + + def _set_panel(page, plot): """Push the plot's CURRENT state into the mounted figure, splitting the geom channel exactly like ``Figure._push`` does (base64-resolved pixels, since the diff --git a/anyplotlib/widgets/_widgets1d.py b/anyplotlib/widgets/_widgets1d.py index f25141db..87886947 100644 --- a/anyplotlib/widgets/_widgets1d.py +++ b/anyplotlib/widgets/_widgets1d.py @@ -22,9 +22,12 @@ class VLineWidget(Widget): Initial x-position in data coordinates. color : str, optional CSS colour for the line. Default ``"#00e5ff"``. + linewidth : float, optional + Line stroke width in px. Default 2. """ - def __init__(self, push_fn, *, x, color="#00e5ff"): - super().__init__("vline", push_fn, x=float(x), color=color) + def __init__(self, push_fn, *, x, color="#00e5ff", linewidth=2): + super().__init__("vline", push_fn, x=float(x), color=color, + linewidth=float(linewidth)) class HLineWidget(Widget): @@ -41,9 +44,12 @@ class HLineWidget(Widget): Initial y-position in data coordinates. color : str, optional CSS colour for the line. Default ``"#00e5ff"``. + linewidth : float, optional + Line stroke width in px. Default 2. """ - def __init__(self, push_fn, *, y, color="#00e5ff"): - super().__init__("hline", push_fn, y=float(y), color=color) + def __init__(self, push_fn, *, y, color="#00e5ff", linewidth=2): + super().__init__("hline", push_fn, y=float(y), color=color, + linewidth=float(linewidth)) class RangeWidget(Widget): @@ -75,12 +81,15 @@ class RangeWidget(Widget): y : float, optional Y-position (data coordinates) for the connecting line when ``style='fwhm'``. Ignored for ``style='band'``. Default ``0.0``. + linewidth : float, optional + Line stroke width in px. Default 2. """ def __init__(self, push_fn, *, x0, x1, color="#00e5ff", - style: str = "band", y: float = 0.0): + style: str = "band", y: float = 0.0, linewidth=2): super().__init__("range", push_fn, x0=float(x0), x1=float(x1), color=color, - style=str(style), y=float(y)) + style=str(style), y=float(y), + linewidth=float(linewidth)) class PointWidget(Widget): @@ -103,7 +112,11 @@ class PointWidget(Widget): show_crosshair : bool, optional If ``True`` (default), draw dashed crosshair guide lines through the handle. Set to ``False`` for a bare draggable dot with no guides. + linewidth : float, optional + Guide-line stroke width in px. Default 2. """ - def __init__(self, push_fn, *, x, y, color="#00e5ff", show_crosshair=True): + def __init__(self, push_fn, *, x, y, color="#00e5ff", show_crosshair=True, + linewidth=2): super().__init__("point", push_fn, x=float(x), y=float(y), color=color, - show_crosshair=bool(show_crosshair)) + show_crosshair=bool(show_crosshair), + linewidth=float(linewidth)) diff --git a/anyplotlib/widgets/_widgets2d.py b/anyplotlib/widgets/_widgets2d.py index 9f5090d9..2e52b758 100644 --- a/anyplotlib/widgets/_widgets2d.py +++ b/anyplotlib/widgets/_widgets2d.py @@ -27,14 +27,17 @@ class RectangleWidget(Widget): Width and height in pixel/data coordinates. color : str, optional CSS colour for the rectangle outline. Default ``"#00e5ff"``. + linewidth : float, optional + Outline stroke width in px. Default 2. show_handles : bool, optional Draw the corner grab handles. Default ``True``. """ def __init__(self, push_fn, *, x, y, w, h, color="#00e5ff", - show_handles=True): + linewidth=2, show_handles=True): super().__init__("rectangle", push_fn, x=float(x), y=float(y), w=float(w), h=float(h), color=color, + linewidth=float(linewidth), show_handles=bool(show_handles)) @@ -51,13 +54,16 @@ class CircleWidget(Widget): Radius in pixel/data coordinates. color : str, optional CSS colour for the circle outline. Default ``"#00e5ff"``. + linewidth : float, optional + Outline stroke width in px. Default 2. show_handles : bool, optional Draw the radius grab handle. Default ``True``. """ def __init__(self, push_fn, *, cx, cy, r, color="#00e5ff", - show_handles=True): + linewidth=2, show_handles=True): super().__init__("circle", push_fn, cx=float(cx), cy=float(cy), r=float(r), color=color, + linewidth=float(linewidth), show_handles=bool(show_handles)) @@ -75,6 +81,8 @@ class AnnularWidget(Widget): Inner radius must be less than outer radius. color : str, optional CSS colour for the ring outline. Default ``"#00e5ff"``. + linewidth : float, optional + Outline stroke width in px. Default 2. show_handles : bool, optional Draw the inner/outer radius grab handles. Default ``True``. @@ -84,13 +92,14 @@ class AnnularWidget(Widget): If r_inner >= r_outer. """ def __init__(self, push_fn, *, cx, cy, r_outer, r_inner, color="#00e5ff", - show_handles=True): + linewidth=2, show_handles=True): if r_inner >= r_outer: raise ValueError("r_inner must be < r_outer") super().__init__("annular", push_fn, cx=float(cx), cy=float(cy), r_outer=float(r_outer), r_inner=float(r_inner), - color=color, show_handles=bool(show_handles)) + color=color, linewidth=float(linewidth), + show_handles=bool(show_handles)) class CrosshairWidget(Widget): @@ -104,12 +113,16 @@ class CrosshairWidget(Widget): Center position in pixel/data coordinates. color : str, optional CSS colour for the crosshair. Default ``"#00e5ff"``. + linewidth : float, optional + Line stroke width in px. Default 2. show_handles : bool, optional Draw the centre dot handle. Default ``True``. """ - def __init__(self, push_fn, *, cx, cy, color="#00e5ff", show_handles=True): + def __init__(self, push_fn, *, cx, cy, color="#00e5ff", linewidth=2, + show_handles=True): super().__init__("crosshair", push_fn, cx=float(cx), cy=float(cy), color=color, + linewidth=float(linewidth), show_handles=bool(show_handles)) @@ -125,6 +138,8 @@ class PolygonWidget(Widget): Must have at least 3 vertices. color : str, optional CSS colour for the polygon outline. Default ``"#00e5ff"``. + linewidth : float, optional + Outline stroke width in px. Default 2. show_handles : bool, optional Draw the per-vertex grab handles. Default ``True``. @@ -133,11 +148,13 @@ class PolygonWidget(Widget): ValueError If fewer than 3 vertices provided. """ - def __init__(self, push_fn, *, vertices, color="#00e5ff", show_handles=True): + def __init__(self, push_fn, *, vertices, color="#00e5ff", linewidth=2, + show_handles=True): verts = [[float(x), float(y)] for x, y in vertices] if len(verts) < 3: raise ValueError("polygon needs >= 3 vertices") super().__init__("polygon", push_fn, vertices=verts, color=color, + linewidth=float(linewidth), show_handles=bool(show_handles)) diff --git a/upcoming_changes/+double_click_targets.new_feature.rst b/upcoming_changes/+double_click_targets.new_feature.rst new file mode 100644 index 00000000..2e449385 --- /dev/null +++ b/upcoming_changes/+double_click_targets.new_feature.rst @@ -0,0 +1,10 @@ +Double-clicking a plot's text chrome now reports which element was hit. The +``double_click`` :class:`~anyplotlib.callbacks.Event` gains a ``target`` field +naming the hit element — one of ``'title'``, ``'x_label'``, ``'x_ticks'``, +``'y_label'``, ``'y_ticks'``, ``'colorbar_label'`` or ``'legend'`` — so a host +can open the right edit affordance for the axis label vs the ticks vs the title +vs the colorbar label vs the legend. The axis gutters, colorbar strip and title +band each get their own hit-test (2-D panels emit from the separate axis/title +canvases; 1-D panels zone-split the single canvas around the plot rect and +legend box). A plain plot-area double-click is unchanged and carries no +``target`` (``event.target is None``), so existing handlers keep working. diff --git a/upcoming_changes/+export_png_3d.bugfix.rst b/upcoming_changes/+export_png_3d.bugfix.rst new file mode 100644 index 00000000..68000664 --- /dev/null +++ b/upcoming_changes/+export_png_3d.bugfix.rst @@ -0,0 +1,4 @@ +Fixed ``exportPNG`` compositing WebGPU-rendered 3-D panels (``scatter3d`` / +``voxels``) as blank background rectangles — the 3-D render pass is now +re-rendered synchronously in-task before the canvas readback, exactly like +active-GPU 2-D image panels. diff --git a/upcoming_changes/+indicate_point.new_feature.rst b/upcoming_changes/+indicate_point.new_feature.rst new file mode 100644 index 00000000..685ab434 --- /dev/null +++ b/upcoming_changes/+indicate_point.new_feature.rst @@ -0,0 +1,4 @@ +Added :meth:`~anyplotlib.axes.InsetAxes.indicate_point` — the point sibling of +:meth:`~anyplotlib.axes.InsetAxes.indicate_region`: a circle-and-cross marker +at a data point of the parent plot plus a single leader line to the inset's +nearest corner, tracking zoom/pan and hiding the leader while minimized. diff --git a/upcoming_changes/+inset_drag_resize.new_feature.rst b/upcoming_changes/+inset_drag_resize.new_feature.rst new file mode 100644 index 00000000..60025dcf --- /dev/null +++ b/upcoming_changes/+inset_drag_resize.new_feature.rst @@ -0,0 +1,10 @@ +Insets can now be dragged and resized directly in the renderer's edit mode +(``edit_chrome``): drag the body to move an inset (a corner-stacked inset +converts to a free anchor and its siblings re-stack), or drag the bottom-right +grip to resize it (min 64 px per dimension). On release the renderer emits a +new figure-level ``inset_geometry_change`` event carrying the final +``anchor``/``w_frac``/``h_frac`` (figure fractions), which +:meth:`~anyplotlib.figure.Figure.add_event_handler` handlers can observe to +persist the layout. The same geometry is applied programmatically via the new +:meth:`~anyplotlib.axes.InsetAxes.set_geometry` (``anchor``, ``w_frac``, +``h_frac``). Off edit mode the affordances are hidden and the inset is inert. diff --git a/upcoming_changes/+inset_no_title_bar.new_feature.rst b/upcoming_changes/+inset_no_title_bar.new_feature.rst new file mode 100644 index 00000000..e108a8bf --- /dev/null +++ b/upcoming_changes/+inset_no_title_bar.new_feature.rst @@ -0,0 +1,7 @@ +An :meth:`~anyplotlib.figure.Figure.add_inset` with no title (the default, +``title=""``) now renders with NO title-bar strip at all — a clean bordered +plot box, content filling the whole area, instead of a useless empty header. +A titled inset is unchanged: its bar renders as before, with click-to-toggle +minimize. A title-less inset has no minimize affordance (there is no bar to +click), but drag-to-move / drag-to-resize in edit mode still work exactly as +before, since those gestures are wired on the inset body, not the bar. diff --git a/upcoming_changes/+layer_tint.new_feature.rst b/upcoming_changes/+layer_tint.new_feature.rst new file mode 100644 index 00000000..eed073d0 --- /dev/null +++ b/upcoming_changes/+layer_tint.new_feature.rst @@ -0,0 +1,5 @@ +Added ``tint=`` to :meth:`~anyplotlib.plot2d.Plot2D.add_layer` and +:meth:`~anyplotlib.plot2d.Layer.set` — a ``#rgb``/``#rrggbb`` hex colour that +renders the layer as a clear→colour intensity ramp (transparent at low +intensity, opaque tint at high, via a 256×4 RGBA LUT) instead of a named +colormap; passing ``cmap=`` reverts a tinted layer to colormap display. diff --git a/upcoming_changes/+legend_fontsize.new_feature.rst b/upcoming_changes/+legend_fontsize.new_feature.rst new file mode 100644 index 00000000..58736d7b --- /dev/null +++ b/upcoming_changes/+legend_fontsize.new_feature.rst @@ -0,0 +1,2 @@ +Added :meth:`~anyplotlib.plot1d.Plot1D.set_legend_fontsize` to control the +legend text size on 1-D line plots. diff --git a/upcoming_changes/+widget_linewidth.new_feature.rst b/upcoming_changes/+widget_linewidth.new_feature.rst new file mode 100644 index 00000000..100b30b8 --- /dev/null +++ b/upcoming_changes/+widget_linewidth.new_feature.rst @@ -0,0 +1,5 @@ +Added ``linewidth=`` to every overlay widget constructor and ``add_*_widget`` +factory on :class:`~anyplotlib.plot2d.Plot2D` and +:class:`~anyplotlib.plot1d.Plot1D` (rectangle, circle, annular, crosshair, +polygon, vline, hline, range, point) — stroke width in px, default 2, stored +and round-tripped like ``color``.