diff --git a/anyplotlib/_base_plot.py b/anyplotlib/_base_plot.py index 0dbe7be9..0d877645 100644 --- a/anyplotlib/_base_plot.py +++ b/anyplotlib/_base_plot.py @@ -179,6 +179,37 @@ def clear_widgets(self) -> None: self._push() +class _TextHandle: + """Handle for a single text annotation created by :meth:`add_text`. + + Wraps the underlying single-text :class:`~anyplotlib.markers.MarkerGroup` + and exposes label-oriented mutators (``set_text`` / ``set_color`` / + ``remove``) so callers do not need to know it is backed by a texts + marker collection. + """ + + __slots__ = ("_group",) + + def __init__(self, group): + self._group = group + + def set_text(self, s: str) -> None: + """Replace the displayed string.""" + self._group.set(texts=[s]) + + def set_color(self, color: str) -> None: + """Change the text colour.""" + self._group.set(color=color) + + def remove(self) -> None: + """Remove the annotation from its panel.""" + self._group.remove() + + def __repr__(self) -> str: # pragma: no cover + texts = self._group._data.get("texts") or [""] + return f"_TextHandle(text={texts[0]!r})" + + class _MarkerMixin: """Mixin for panels that support static marker collections. @@ -197,6 +228,44 @@ def _push_markers(self) -> None: def _add_marker(self, mtype: str, name, **kwargs): return self.markers.add(mtype, name, **kwargs) + def add_text(self, x, y, s, name=None, *, color="#ff0000", + fontsize=12, transform="data", **kwargs) -> "_TextHandle": + """Add a single text annotation at ``(x, y)``. + + A convenience wrapper over :meth:`add_texts` for the common + single-label case (e.g. a navigation index or scale-bar label). + The returned handle exposes ``set_text``, ``set_color`` and + ``remove`` so callers can mutate the label after creation. + + Parameters + ---------- + x, y : float + Anchor position. Interpreted in the coordinate system named + by ``transform`` (``"data"``, ``"axes"``, or ``"display"``). + s : str + The text to display. + name : str, optional + Registry key. Auto-generated if omitted. + color : str, optional + Text colour. Default ``"#ff0000"``. + fontsize : int, optional + Font size in pixels. Default ``12``. + transform : str, optional + Coordinate system for ``(x, y)``. Default ``"data"``. + **kwargs : dict + Forwarded to :meth:`add_texts` (e.g. ``clip_display``). + + Returns + ------- + _TextHandle + Handle wrapping the underlying single-text marker group. + """ + group = self.add_texts( + offsets=[(x, y)], texts=[s], name=name, + color=color, fontsize=fontsize, transform=transform, **kwargs, + ) + return _TextHandle(group) + def remove_marker(self, marker_type: str, name: str) -> None: """Remove a named marker collection by type and name. diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 65101abd..c42ce02c 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -4291,7 +4291,28 @@ fn fs(in : VsOut) -> @location(0) vec4 { const gpuGeomChanged = p._gpuObj && p._gpuObj.geom !== geom; if (gpuGeomChanged) { _gpuDisposePanel(p); p._gpu = undefined; } if (p.kind === '3d' && _gpuWanted(st)) { - if (p._gpu === undefined) { + // Zero-drawable-size guard: SpyDE (and any host) mounts a 3-D panel HIDDEN + // (display:none) and reveals it later, so the FIRST draw3d can run with + // pw/ph == 0 (a zero-size layout) and the gpuCanvas collapsed to 0 client + // px. _gpuInitPanel configures the WebGPU swapchain and every draw pass + // calls getCurrentTexture() against that canvas — on a zero-drawable-size + // surface this is undefined behaviour: some drivers throw (→ the mid-draw + // catch latches p._gpu='unavailable', TERMINAL) and others configure a + // dead 0×0 context that silently draws nothing. Either way the panel is + // then stuck on Canvas2D forever because the `_gpu === undefined` guard + // never re-runs. So DON'T init at zero size: leave _gpu === undefined and + // let the canvas path render (a hidden panel isn't visible anyway). When + // the panel is revealed/resized, the layout/fig-size change fires a + // redrawAll() → this draw3d re-runs at the real size → init re-attempts. + // Gate on pw/ph (the LOGICAL panel size that drives the gpuCanvas backing + // store in _gpuDrawPoints/_gpuEnsureSize and every getCurrentTexture()). + // NB: don't test the gpuCanvas' own client size — it starts display:none + // until GPU activates, so clientWidth is 0 even for a fully-visible panel; + // that would wrongly block the normal case. A panel revealed by a pure + // display toggle (pw/ph already non-zero, only client size was 0) is + // handled by the ResizeObserver reveal branch, which redraws it. + const hasDrawableSize = (pw || 0) > 0 && (ph || 0) > 0; + if (p._gpu === undefined && hasDrawableSize) { p._gpu = 'pending'; const initGeom = geom; _gpuDevice().then((device) => { @@ -5077,9 +5098,15 @@ fn fs(in : VsOut) -> @location(0) vec4 { // unit spans equal pixels on x and y (an IPF triangle etc. must not stretch). // Baked in here so EVERY consumer (draw1d / markers / overlay / hit-test) uses // the identical box — matplotlib's transData derives from the adjusted axes box. + // Extra right-hand gutter reserved for a secondary (twinx) y-axis so its + // tick labels and rotated units clear the panel edge. + // Right gutter for a twinx axis: tick numbers (fmtVal caps them at ~6 chars, + // e.g. "1.0e+6") plus the rotated units label. ~58px clears both. + const PAD_R_TWIN=58; function _plotRect1d(p){ const pw=p.pw, ph=p.ph, st=p.state; - const r={x:PAD_L,y:PAD_T,w:Math.max(1,pw-PAD_L-PAD_R),h:Math.max(1,ph-PAD_T-PAD_B)}; + const padR=(st && st.right_axis) ? PAD_R_TWIN : PAD_R; + const r={x:PAD_L,y:PAD_T,w:Math.max(1,pw-PAD_L-padR),h:Math.max(1,ph-PAD_T-PAD_B)}; if(!st || st.aspect!=='equal') return r; const xArr=p._1dXArr || (st.x_axis_b64 ? _decodeF64(st.x_axis_b64) : (st.x_axis||[])); const x0=st.view_x0||0, x1=st.view_x1||1; @@ -5135,6 +5162,21 @@ fn fs(in : VsOut) -> @location(0) vec4 { return _valToPy1d(isLog ? Math.log10(Math.max(_logEps, v)) : v, effDMin, effDMax, r); } + // ── secondary (right-hand) y-axis scale ── + // A twinx line maps against right_y_range (explicit) or the auto range + // derived from the right-axis overlays. Always linear. + const hasRightAxis = st.right_axis === true; + let rMin = 0, rMax = 1; + if (hasRightAxis) { + if (st.right_y_range && st.right_y_range.length === 2) { + rMin = st.right_y_range[0]; rMax = st.right_y_range[1]; + } else { + rMin = st.right_data_min != null ? st.right_data_min : 0; + rMax = st.right_data_max != null ? st.right_data_max : 1; + } + } + function _toRightY(v) { return _valToPy1d(v, rMin, rMax, r); } + ctx.clearRect(0,0,pw,ph); ctx.fillStyle=theme.bg; ctx.fillRect(0,0,pw,ph); ctx.fillStyle=theme.bgPlot; ctx.fillRect(r.x,r.y,r.w,r.h); @@ -5236,8 +5278,9 @@ fn fs(in : VsOut) -> @location(0) vec4 { // Stroke-only markers (no meaningful fill area) const _MARKER_STROKE_ONLY = new Set(['+', 'x']); - function _drawLine(yData, lineXArr, color, lw, linestyle, alpha, marker, markersize) { + function _drawLine(yData, lineXArr, color, lw, linestyle, alpha, marker, markersize, yMap) { if (!yData || !yData.length) return; + const _yMap = yMap || _toPlotY; // left scale by default; right lines pass _toRightY const n = yData.length; const isStepMid = linestyle === 'step-mid'; const dash = isStepMid ? [] : (_LINESTYLE_DASH[linestyle || 'solid'] || []); @@ -5252,7 +5295,7 @@ fn fs(in : VsOut) -> @location(0) vec4 { ? (lineXArr[i] - lineXArr[0]) / ((lineXArr[lineXArr.length - 1] - lineXArr[0]) || 1) : i / ((n - 1) || 1); allPx[i] = _fracToPx1d(xFrac, x0, x1, r); - allPy[i] = _toPlotY(yData[i]); + allPy[i] = _yMap(yData[i]); } ctx.save(); @@ -5305,11 +5348,12 @@ fn fs(in : VsOut) -> @location(0) vec4 { for (const ex of (st.extra_lines || [])) { const exY = ex.data_b64 ? _decodeF64(ex.data_b64) : (ex.data || []); const exX = ex.x_axis_b64 ? _decodeF64(ex.x_axis_b64) : (ex.x_axis ? ex.x_axis : xArr); + const exMap = ex.axis === 'right' ? _toRightY : _toPlotY; _drawLine(exY, exX, ex.color || (theme.dark ? '#fff' : '#333'), ex.linewidth || 1.5, ex.linestyle || 'solid', ex.alpha != null ? ex.alpha : 1.0, - ex.marker || 'none', ex.markersize || 4); + ex.marker || 'none', ex.markersize || 4, exMap); } // ── hovered-line highlight: redraw on top with brightened colour + thicker stroke ── const _hovId = p._lineHoverId; @@ -5324,10 +5368,11 @@ fn fs(in : VsOut) -> @location(0) vec4 { if (ex.id === _hovId) { const exY = ex.data_b64 ? _decodeF64(ex.data_b64) : (ex.data||[]); const exX = ex.x_axis_b64 ? _decodeF64(ex.x_axis_b64) : (ex.x_axis?ex.x_axis:xArr); + const exMap = ex.axis === 'right' ? _toRightY : _toPlotY; _drawLine(exY, exX, _brightenColor(ex.color||(theme.dark?'#fff':'#333')), (ex.linewidth||1.5)+1, ex.linestyle||'solid', ex.alpha!=null?ex.alpha:1.0, - ex.marker||'none', ex.markersize||4); + ex.marker||'none', ex.markersize||4, exMap); break; } } @@ -5399,6 +5444,46 @@ fn fs(in : VsOut) -> @location(0) vec4 { } } + // ── secondary (right-hand) y-axis: spine, ticks, label ── + if(hasRightAxis&&axisVis1d&&yTicksVis1d){ + const rColor=st.right_axis_color||theme.tickText; + const rx=r.x+r.w; + // Axis spine on the right edge of the plot rect. + ctx.strokeStyle=rColor; ctx.lineWidth=1; + ctx.beginPath();ctx.moveTo(rx,r.y);ctx.lineTo(rx,r.y+r.h);ctx.stroke(); + ctx.font=(st.tick_size||10)+'px monospace';ctx.textAlign='left';ctx.textBaseline='middle'; + // Iterate the ordered range so ticks render regardless of whether the + // caller pinned set_right_ylim(lo,hi) or an inverted (hi,lo); _valToPy1d + // still maps against the raw rMin/rMax so a deliberate flip is preserved. + const rLo=Math.min(rMin,rMax), rHi=Math.max(rMin,rMax); + const rRange=(rHi-rLo)||1; + const rStep=findNice(rRange/Math.max(2,Math.floor(r.h/40))); + let rMaxTW=0; + for(let v=Math.ceil(rLo/rStep)*rStep;v<=rHi+rStep*0.01;v+=rStep){ + const tw=ctx.measureText(fmtVal(v)).width;if(tw>rMaxTW)rMaxTW=tw; + } + for(let v=Math.ceil(rLo/rStep)*rStep;v<=rHi+rStep*0.01;v+=rStep){ + const py=_valToPy1d(v,rMin,rMax,r); + if(pyr.y+r.h) continue; + ctx.strokeStyle=rColor;ctx.beginPath();ctx.moveTo(rx,py);ctx.lineTo(rx+5,py);ctx.stroke(); + ctx.fillStyle=rColor;ctx.fillText(fmtVal(v),rx+8,py); + } + const rUnits=st.right_y_units||''; + if(rUnits){ + ctx.save(); + const rylpx=st.y_label_size||9; + // Place the rotated units label just past the widest tick number, but + // never past the panel edge — clamp keeps it on-canvas even when the + // fixed right gutter is too narrow for very wide tick strings. + const rcx=Math.min(pw-Math.ceil(rylpx*0.62)-1, rx+8+rMaxTW+Math.ceil(rylpx*0.9)); + ctx.translate(rcx, r.y+r.h/2); ctx.rotate(-Math.PI/2); + ctx.textBaseline='middle'; + ctx.fillStyle=rColor; + _drawTex(ctx, rUnits, 0, 0, rylpx, {align:'center',family:'monospace'}); + ctx.restore(); + } + } + // Legend const labels=[]; if(st.line_label) labels.push({ @@ -7959,9 +8044,32 @@ fn fs(in : VsOut) -> @location(0) vec4 { let _lastCellW = 0; let _lastRoW = 0, _lastRoH = 0; let _roTimer = null; + let _wasZeroSize = true; new ResizeObserver(entries => { const cr = entries[0].contentRect; const cellW = cr.width, cellH = cr.height; + // (0) Reveal detection: a host (SpyDE) can mount a panel HIDDEN + // (display:none) and reveal it later by toggling display alone, WITHOUT + // any layout_json / fig_width change (so neither model listener below + // fires a redraw). A 3-D GPU panel that skipped init while zero-size + // (see the hasDrawableSize guard in draw3d) would then stay on Canvas2D + // forever. When the observed element transitions from zero-size to a + // real size, re-attempt GPU init on any 3-D panel still on the canvas + // path and redraw it, so the reveal actually flips it to WebGPU. + const _nowZero = !(cellW > 0 && cellH > 0); + if (_wasZeroSize && !_nowZero) { + _wasZeroSize = false; + requestAnimationFrame(() => { + let _need = false; + for (const p of panels.values()) { + if (p.kind === '3d' && p._gpu === undefined && p.state + && _gpuWanted(p.state)) { _need = true; break; } + } + if (_need) redrawAll(); + }); + } else if (_nowZero) { + _wasZeroSize = true; + } // (1) Jupyter fit-to-cell down-scale — width-only to avoid a feedback // loop (our own scaleWrap height updates would re-trigger the observer). if (cellW !== _lastCellW) { diff --git a/anyplotlib/markers.py b/anyplotlib/markers.py index 330c5992..19ae111c 100644 --- a/anyplotlib/markers.py +++ b/anyplotlib/markers.py @@ -225,6 +225,9 @@ def to_wire(self, group_id: str) -> dict: segs = np.asarray(d["segments"], dtype=float) if segs.ndim == 2 and segs.shape == (2, 2): segs = segs[np.newaxis] + elif segs.size == 0: + # Empty collection (zero line segments) — valid, renders nothing. + segs = segs.reshape(0, 2, 2) if segs.ndim != 3 or segs.shape[1:] != (2, 2): raise ValueError("segments must be shape (N,2,2)") wire = { diff --git a/anyplotlib/plot1d/_plot1d.py b/anyplotlib/plot1d/_plot1d.py index 2928471c..5e567a30 100644 --- a/anyplotlib/plot1d/_plot1d.py +++ b/anyplotlib/plot1d/_plot1d.py @@ -126,15 +126,16 @@ def set_data(self, y: "np.ndarray", x_axis=None) -> None: y = np.asarray(y, dtype=float) if y.ndim != 1: raise ValueError("y must be 1-D") - for entry in self._plot._state["extra_lines"]: - if entry["id"] == self._lid: - entry["data"] = y - if x_axis is not None: - entry["x_axis"] = np.asarray(x_axis, dtype=float) - break + entry = self._entry() + entry["data"] = y + if x_axis is not None: + entry["x_axis"] = np.asarray(x_axis, dtype=float) + # Recompute whichever axis this line belongs to so its scale tracks the + # new data (a right-axis overlay is excluded from the left range). + if entry.get("axis", "left") == "right": + self._plot._recompute_right_range() else: - raise KeyError(self._lid) - self._plot._recompute_data_range() + self._plot._recompute_data_range() self._plot._push() def remove(self) -> None: @@ -143,6 +144,64 @@ def remove(self) -> None: raise ValueError("Cannot remove the primary line via Line1D.remove().") self._plot.remove_line(self._lid) + # ------------------------------------------------------------------ + # Property accessors (read/write the backing extra_lines entry) + # ------------------------------------------------------------------ + def _entry(self) -> dict: + """Return this line's ``extra_lines`` entry, or raise KeyError.""" + if self._lid is None: + raise ValueError("primary line has no extra_lines entry") + for entry in self._plot._state["extra_lines"]: + if entry["id"] == self._lid: + return entry + raise KeyError(self._lid) + + def _set_field(self, key: str, value) -> None: + self._entry()[key] = value + self._plot._push() + + @property + def x(self): + """The x-axis array for this overlay line.""" + return self._entry()["x_axis"] + + @property + def data(self): + """The y-data array for this overlay line.""" + return self._entry()["data"] + + @property + def color(self) -> str: + return self._entry().get("color", "#4fc3f7") + + @color.setter + def color(self, value: str) -> None: + self._set_field("color", value) + + @property + def linewidth(self) -> float: + return float(self._entry().get("linewidth", 1.5)) + + @linewidth.setter + def linewidth(self, value: float) -> None: + self._set_field("linewidth", float(value)) + + @property + def linestyle(self) -> str: + return self._entry().get("linestyle", "solid") + + @linestyle.setter + def linestyle(self, value: str) -> None: + self._set_field("linestyle", _norm_linestyle(value)) + + @property + def alpha(self) -> float: + return float(self._entry().get("alpha", 1.0)) + + @alpha.setter + def alpha(self, value: float) -> None: + self._set_field("alpha", float(value)) + # --------------------------------------------------------------------------- # Plot1D @@ -297,6 +356,14 @@ def __init__(self, data: np.ndarray, "title": "", # Explicit y-range override: [ymin, ymax] or None (auto) "y_range": None, + # Secondary (right-hand) y-axis. Enabled on demand via + # add_right_axis(); lines opt in with add_line(..., axis="right"). + "right_axis": False, + "right_y_range": None, # [ymin, ymax] or None (auto) + "right_data_min": 0.0, # auto right scale (from right lines) + "right_data_max": 1.0, + "right_y_units": "", # right-axis label + "right_axis_color": "#000000", # Visibility toggles "axis_visible": True, "x_ticks_visible": True, @@ -414,13 +481,16 @@ def set_data(self, data: np.ndarray, x_axis=None, self._push() def _recompute_data_range(self) -> None: - """Recompute data_min/data_max across the primary line and all overlays. + """Recompute data_min/data_max across the primary line and left overlays. Called automatically whenever the set of lines changes so that every - curve stays fully visible. + left-axis curve stays fully visible. Right-axis overlays are excluded + here — they scale independently via :meth:`_recompute_right_range`. """ all_vals = [self._state["data"]] # already a numpy float64 array for ex in self._state["extra_lines"]: + if ex.get("axis", "left") == "right": + continue d = ex.get("data") if d is not None and len(d): all_vals.append(d) @@ -431,6 +501,28 @@ def _recompute_data_range(self) -> None: self._state["data_min"] = dmin - pad self._state["data_max"] = dmax + pad + def _recompute_right_range(self) -> None: + """Recompute the auto right-axis range from all right-axis overlays. + + No-op when ``right_y_range`` is set explicitly (that overrides the + auto range) or when there are no right-axis lines yet. + """ + if self._state.get("right_y_range") is not None: + return + vals = [ex["data"] for ex in self._state["extra_lines"] + if ex.get("axis", "left") == "right" + and ex.get("data") is not None and len(ex["data"])] + if not vals: + self._state["right_data_min"] = 0.0 + self._state["right_data_max"] = 1.0 + return + combined = np.concatenate(vals) + dmin = float(np.nanmin(combined)) + dmax = float(np.nanmax(combined)) + pad = (dmax - dmin) * 0.05 if dmax > dmin else 0.5 + self._state["right_data_min"] = dmin - pad + self._state["right_data_max"] = dmax + pad + # ------------------------------------------------------------------ # Extra lines # ------------------------------------------------------------------ @@ -439,7 +531,7 @@ def add_line(self, data: np.ndarray, x_axis=None, linestyle: str = "solid", ls: str | None = None, alpha: float = 1.0, marker: str = "none", markersize: float = 4.0, - label: str = "") -> "Line1D": + label: str = "", axis: str = "left") -> "Line1D": """Overlay an additional curve on this panel. The y-axis range is automatically expanded to include the new data so @@ -484,6 +576,8 @@ def clicked(event): ... data = np.asarray(data, dtype=float) if data.ndim != 1: raise ValueError("data must be 1-D") + if axis not in ("left", "right"): + raise ValueError(f"axis must be 'left' or 'right', got {axis!r}") xa = (np.asarray(x_axis, dtype=float) if x_axis is not None else self._state["x_axis"]) lid = str(_uuid.uuid4())[:8] @@ -498,8 +592,14 @@ def clicked(event): ... "marker": marker if marker is not None else "none", "markersize": float(markersize), "label": label, + "axis": axis, }) - self._recompute_data_range() + if axis == "right": + # A right-axis line implies a secondary axis exists. + self._state["right_axis"] = True + self._recompute_right_range() + else: + self._recompute_data_range() self._push() return Line1D(self, lid) @@ -526,6 +626,7 @@ def remove_line(self, lid: "str | Line1D") -> None: if len(self._state["extra_lines"]) == before: raise KeyError(lid) self._recompute_data_range() + self._recompute_right_range() self._push() def clear_lines(self) -> None: @@ -535,6 +636,7 @@ def clear_lines(self) -> None: """ self._state["extra_lines"] = [] self._recompute_data_range() + self._recompute_right_range() self._push() # ------------------------------------------------------------------ @@ -860,6 +962,62 @@ def get_ylim(self) -> tuple: return (float(yr[0]), float(yr[1])) return (float(self._state["data_min"]), float(self._state["data_max"])) + # ------------------------------------------------------------------ + # Secondary (right-hand) y-axis + # ------------------------------------------------------------------ + def add_right_axis(self, color: str = "#000000") -> None: + """Enable a secondary y-axis on the right-hand side of the panel. + + Lines added with ``add_line(..., axis="right")`` are scaled against + and labelled by this axis, independently of the left y-axis. Calling + this more than once just updates the axis colour. + + Parameters + ---------- + color : str, optional + Tick and label colour for the right axis. Default ``"#000000"``. + """ + self._state["right_axis"] = True + self._state["right_axis_color"] = color + self._recompute_right_range() + self._push() + + def remove_right_axis(self) -> None: + """Remove the secondary y-axis and any lines anchored to it. + + This is a full teardown: a pinned range (:meth:`set_right_ylim`), the + label, and the axis colour all reset to their defaults, mirroring + matplotlib where a removed twin axis is gone entirely. A subsequent + :meth:`add_right_axis` therefore starts fresh (auto-scaled, no label). + """ + self._state["extra_lines"] = [ + ex for ex in self._state["extra_lines"] + if ex.get("axis", "left") != "right" + ] + self._state["right_axis"] = False + self._state["right_y_range"] = None + self._state["right_y_units"] = "" + self._state["right_axis_color"] = "#000000" + self._recompute_right_range() + self._push() + + def set_right_ylabel(self, label: str) -> None: + """Set the label (units) shown on the secondary y-axis.""" + self._state["right_y_units"] = label + self._push() + + def set_right_ylim(self, ymin: float, ymax: float) -> None: + """Pin the secondary y-axis range explicitly (overrides auto-scaling).""" + self._state["right_y_range"] = [float(ymin), float(ymax)] + self._push() + + def get_right_ylim(self) -> tuple: + yr = self._state.get("right_y_range") + if yr is not None: + return (float(yr[0]), float(yr[1])) + return (float(self._state["right_data_min"]), + float(self._state["right_data_max"])) + def get_xlim(self) -> tuple: xarr = np.asarray(self._state["x_axis"]) if len(xarr) < 2: diff --git a/anyplotlib/tests/baselines/plot1d_twinx.png b/anyplotlib/tests/baselines/plot1d_twinx.png new file mode 100644 index 00000000..3947aa09 Binary files /dev/null and b/anyplotlib/tests/baselines/plot1d_twinx.png differ diff --git a/anyplotlib/tests/test_layouts/test_visual.py b/anyplotlib/tests/test_layouts/test_visual.py index 09bb000a..30751cf4 100644 --- a/anyplotlib/tests/test_layouts/test_visual.py +++ b/anyplotlib/tests/test_layouts/test_visual.py @@ -154,6 +154,26 @@ def test_plot1d_multi(self, take_screenshot, update_baselines): arr = take_screenshot(fig) _check("plot1d_multi", arr, update_baselines) + def test_plot1d_twinx(self, take_screenshot, update_baselines): + """Secondary right y-axis — exercises add_right_axis + axis='right' line. + + The right line spans a very different range (0..1000) than the left + sine (-1..1); a correct render scales each against its own axis and + draws a coloured right-hand tick column + rotated label. + """ + fig, ax = apl.subplots(1, 1, figsize=(480, 300)) + x = np.linspace(0.0, 10.0, 200) + plot = ax.plot(np.sin(x), axes=[x], color="#4fc3f7", label="signal") + plot.set_ylabel("Amplitude") + plot.add_right_axis(color="#e05a2b") + plot.add_line( + 300.0 + 350.0 * np.cos(x / 2.0), + x_axis=x, color="#e05a2b", axis="right", label="temp", + ) + plot.set_right_ylabel("Temp (K)") + arr = take_screenshot(fig) + _check("plot1d_twinx", arr, update_baselines) + def test_plot1d_dashed(self, take_screenshot, update_baselines): """Dashed primary line — exercises linestyle→setLineDash path.""" fig, ax = apl.subplots(1, 1, figsize=(400, 240)) diff --git a/anyplotlib/tests/test_markers/test_markers.py b/anyplotlib/tests/test_markers/test_markers.py index 1971cb57..95c010e8 100644 --- a/anyplotlib/tests/test_markers/test_markers.py +++ b/anyplotlib/tests/test_markers/test_markers.py @@ -724,3 +724,80 @@ def test_line1d_data_length_not_in_wire(self): p = ax.plot(np.linspace(0, 1, 64)) wire = p.to_state_dict() assert "data_length" not in wire + + +class TestAddTextSingular: + """Plot.add_text() — single-annotation convenience wrapper. + + Backs the HyperSpy anyplotlib backend's ``add_text`` (nav index and + scale-bar labels), which relies on the handle's set_text / set_color / + remove methods. + """ + + def test_add_text_returns_handle_plot1d(self): + p = _make_plot1d() + h = p.add_text(0.5, 0.5, "hi") + assert hasattr(h, "set_text") + assert hasattr(h, "set_color") + assert hasattr(h, "remove") + + def test_add_text_returns_handle_plot2d(self): + p = _make_plot2d() + h = p.add_text(1.0, 1.0, "hi") + assert hasattr(h, "set_text") + + def test_add_text_creates_texts_group(self): + p = _make_plot1d() + p.add_text(0.5, 0.5, "peak") + markers = p.list_markers() + assert any(m["type"] == "texts" and m["n"] == 1 for m in markers) + + def test_add_text_stores_string_and_offset(self): + p = _make_plot1d() + h = p.add_text(2.0, 3.0, "label") + assert h._group._data["texts"] == ["label"] + assert list(h._group._data["offsets"][0]) == [2.0, 3.0] + + def test_add_text_default_transform_is_data(self): + p = _make_plot1d() + h = p.add_text(0.5, 0.5, "x") + assert h._group._data["transform"] == "data" + + def test_add_text_axes_transform_round_trips(self): + p = _make_plot1d() + h = p.add_text(0.05, 0.95, "(3, 7)", transform="axes") + assert h._group._data["transform"] == "axes" + + def test_add_text_display_transform_round_trips(self): + p = _make_plot2d() + h = p.add_text(8, 8, "10 nm", transform="display") + assert h._group._data["transform"] == "display" + + def test_set_text_updates_string(self): + p = _make_plot1d() + h = p.add_text(0.5, 0.5, "(3, 7)") + h.set_text("(4, 7)") + assert h._group._data["texts"] == ["(4, 7)"] + + def test_set_color_updates_color(self): + p = _make_plot1d() + h = p.add_text(0.5, 0.5, "x", color="red") + h.set_color("#00ff00") + assert h._group._data["color"] == "#00ff00" + + def test_remove_deletes_group(self): + p = _make_plot1d() + h = p.add_text(0.5, 0.5, "x") + assert p.list_markers() + h.remove() + assert not p.list_markers() + + def test_invalid_transform_raises(self): + p = _make_plot1d() + with pytest.raises(ValueError): + p.add_text(0.5, 0.5, "x", transform="bogus") + + def test_named_text_uses_name(self): + p = _make_plot1d() + h = p.add_text(0.5, 0.5, "x", name="nav_index") + assert h._group._name == "nav_index" diff --git a/anyplotlib/tests/test_plot1d/test_plot1d.py b/anyplotlib/tests/test_plot1d/test_plot1d.py index fedd5313..4ba530f8 100644 --- a/anyplotlib/tests/test_plot1d/test_plot1d.py +++ b/anyplotlib/tests/test_plot1d/test_plot1d.py @@ -387,6 +387,164 @@ def test_line1d_str(self): assert str(line) == line.id +# =========================================================================== +# Secondary (right-hand) y-axis / twinx +# =========================================================================== + +class TestPlot1DTwinx: + + def test_right_axis_off_by_default(self): + p = _plot() + assert p._state["right_axis"] is False + + def test_add_right_axis_enables_and_sets_color(self): + p = _plot() + p.add_right_axis(color="#e05a2b") + assert p._state["right_axis"] is True + assert p._state["right_axis_color"] == "#e05a2b" + + def test_add_line_right_sets_axis_field(self): + p = _plot() + p.add_right_axis() + p.add_line(np.cos(t) * 100, axis="right") + assert p._state["extra_lines"][-1]["axis"] == "right" + + def test_add_line_right_implies_right_axis(self): + p = _plot() + p.add_line(np.cos(t) * 100, axis="right") # no explicit add_right_axis + assert p._state["right_axis"] is True + + def test_add_line_default_axis_is_left(self): + p = _plot() + p.add_line(np.cos(t)) + assert p._state["extra_lines"][-1]["axis"] == "left" + + def test_add_line_bad_axis_raises(self): + p = _plot() + with pytest.raises(ValueError, match="axis must be"): + p.add_line(np.cos(t), axis="top") + + def test_right_line_excluded_from_left_range(self): + """A large-scale right line must not stretch the left y-range.""" + p = _plot() # sine, roughly -1..1 + left_max_before = p._state["data_max"] + p.add_line(np.full(128, 1000.0), axis="right") + # left range unchanged (right line does not participate) + assert p._state["data_max"] == pytest.approx(left_max_before) + + def test_right_range_auto_from_right_lines(self): + p = _plot() + p.add_line(np.linspace(0.0, 500.0, 128), axis="right") + assert p._state["right_data_min"] < 50.0 + assert p._state["right_data_max"] > 450.0 + + def test_set_right_ylim_overrides_auto(self): + p = _plot() + p.add_line(np.linspace(0.0, 500.0, 128), axis="right") + p.set_right_ylim(0.0, 100.0) + assert p.get_right_ylim() == (0.0, 100.0) + + def test_set_right_ylabel(self): + p = _plot() + p.add_right_axis() + p.set_right_ylabel("Temp (K)") + assert p._state["right_y_units"] == "Temp (K)" + + def test_remove_right_axis_drops_right_lines(self): + p = _plot() + p.add_line(np.cos(t), axis="left") + p.add_line(np.cos(t) * 100, axis="right") + p.remove_right_axis() + assert p._state["right_axis"] is False + # left line survives; right line removed + axes = [e.get("axis", "left") for e in p._state["extra_lines"]] + assert axes == ["left"] + + def test_axis_field_survives_wire(self): + p = _plot() + p.add_line(np.cos(t) * 100, axis="right") + wire = p.to_state_dict() + assert wire["extra_lines"][-1]["axis"] == "right" + assert wire["right_axis"] is True + + def test_right_line_set_data_recomputes_right_range(self): + """Updating a right-axis overlay must refresh right_data_min/max.""" + p = _plot() + line = p.add_line(np.linspace(0.0, 100.0, 128), axis="right") + assert p._state["right_data_max"] < 200.0 + line.set_data(np.linspace(0.0, 5000.0, 128)) + assert p._state["right_data_max"] > 4000.0 # tracked the new magnitude + + def test_left_line_set_data_does_not_touch_right_range(self): + p = _plot() + p.add_line(np.linspace(0.0, 100.0, 128), axis="right") + rmax_before = p._state["right_data_max"] + left = p.add_line(np.full(128, 9999.0), axis="left") + left.set_data(np.full(128, 12345.0)) + assert p._state["right_data_max"] == pytest.approx(rmax_before) + + +class TestLine1DAccessors: + """Line1D property accessors used by the HyperSpy backend's line-props path.""" + + def test_x_and_data_read_back(self): + p = _plot() + x = np.linspace(0.0, 5.0, 128) + line = p.add_line(np.cos(t), x_axis=x) + np.testing.assert_allclose(line.x, x) + np.testing.assert_allclose(line.data, np.cos(t)) + + def test_color_get_set(self): + p = _plot() + line = p.add_line(np.cos(t), color="#111111") + assert line.color == "#111111" + line.color = "#abcdef" + assert line.color == "#abcdef" + assert p._state["extra_lines"][-1]["color"] == "#abcdef" + + def test_linewidth_get_set(self): + p = _plot() + line = p.add_line(np.cos(t), linewidth=2.0) + assert line.linewidth == pytest.approx(2.0) + line.linewidth = 4.5 + assert line.linewidth == pytest.approx(4.5) + + def test_linestyle_get_set_normalises(self): + p = _plot() + line = p.add_line(np.cos(t)) + line.linestyle = "--" + assert line.linestyle == "dashed" # normalised + + def test_alpha_get_set(self): + p = _plot() + line = p.add_line(np.cos(t), alpha=0.5) + assert line.alpha == pytest.approx(0.5) + line.alpha = 0.25 + assert line.alpha == pytest.approx(0.25) + + def test_accessor_after_remove_raises(self): + p = _plot() + line = p.add_line(np.cos(t)) + line.remove() + with pytest.raises(KeyError): + _ = line.color + + +class TestEmptyLinesCollection: + """Empty line collections must be valid (zero segments), not raise.""" + + def test_empty_lines_segments(self): + p2 = _make_plot2d_for_markers() + g = p2.markers.add("lines", segments=[]) + wire = g.to_wire("gid") + assert wire["segments"] == [] + + +def _make_plot2d_for_markers(): + fig, ax = apl.subplots(1, 1) + return ax.imshow(np.zeros((8, 8))) + + # =========================================================================== # add_line() field parity # =========================================================================== diff --git a/anyplotlib/tests/test_plot3d/test_gpu_hidden_reveal.py b/anyplotlib/tests/test_plot3d/test_gpu_hidden_reveal.py new file mode 100644 index 00000000..c9d60df5 --- /dev/null +++ b/anyplotlib/tests/test_plot3d/test_gpu_hidden_reveal.py @@ -0,0 +1,330 @@ +"""WebGPU 3-D scatter/voxels lifecycle when the panel is mounted HIDDEN. + +Regression for the "hidden-then-revealed 3-D scatter renders on the Canvas2D +fallback forever" bug. + +A host (SpyDE's 3-D IPF window) mounts an anyplotlib figure via ``mount()`` +inside a ``display:none`` container and reveals it later. The FIRST +``draw3d`` then runs with the panel at ZERO drawable size (``pw``/``ph`` == 0 +from a zero-size initial layout). ``_gpuInitPanel`` configures the WebGPU +swapchain against that zero-size canvas and every draw pass calls +``getCurrentTexture()`` on it — undefined behaviour: some drivers throw (the +mid-draw catch latches ``p._gpu = 'unavailable'``, which is TERMINAL because +the ``_gpu === undefined`` init guard never re-runs) and others configure a +dead 0×0 context that silently draws nothing. Either way the panel is stuck +on Canvas2D forever, even after it's revealed at a real size. + +The fix: + +* ``draw3d`` does NOT transition ``_gpu`` from ``undefined`` → ``pending`` + while ``pw``/``ph`` == 0 — it leaves ``_gpu === undefined`` so a later draw + (after reveal/resize, when the size is real) re-attempts init. +* A ResizeObserver reveal branch fires ``redrawAll()`` when the observed + container transitions from zero-size to a real size, so a reveal by a pure + ``display`` toggle (no layout/fig-size model change) still re-attempts init. + +These tests drive the exact host mounting sequence through ``mount()`` on a +real WebGPU adapter (skipped when none is available) and assert the panel +flips to the WebGPU path AFTER reveal, while a normally-visible panel still +activates on first draw (no regression). +""" +from __future__ import annotations + +import json +import pathlib +import tempfile + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.embed import esm_path, figure_state + + +# --------------------------------------------------------------------------- +# Scene builders +# --------------------------------------------------------------------------- + +def _scatter_state(): + fig, ax = apl.subplots(1, 1, figsize=(320, 320)) + rng = np.random.default_rng(1) + pts = rng.uniform(-1, 1, size=(3000, 3)) + v = ax.scatter3d(pts[:, 0], pts[:, 1], pts[:, 2], bounds=((-1, 1),) * 3, + gpu="always", + colors=np.tile([255, 80, 80], (3000, 1)).astype(np.uint8), + point_size=4) + v.set_axis_off() + return figure_state(fig) + + +def _voxel_state(): + fig, ax = apl.subplots(1, 1, figsize=(320, 320)) + n = 8 + g = np.arange(0, n, dtype=float) + zz, yy, xx = np.meshgrid(g, g, g, indexing="ij") + v = ax.voxels(xx.ravel(), yy.ravel(), zz.ravel(), + bounds=((0, n - 1),) * 3, gpu="always", + colors=np.tile([255, 60, 60], (n ** 3, 1)).astype(np.uint8), + alpha=0.5) + v.set_axis_off() + return figure_state(fig) + + +def _zero_size(state): + """Return (zeroed_state, full_layout_json, full_w, full_h). + + Zeroes the layout so the first draw3d sees pw==ph==0 (the hidden-mount + condition), and stashes the real layout for the reveal step. + """ + layout = json.loads(state["layout_json"]) + full_w, full_h = layout["fig_width"], layout["fig_height"] + full_specs = json.loads(json.dumps(layout["panel_specs"])) + for spec in layout["panel_specs"]: + spec["panel_width"] = 0 + spec["panel_height"] = 0 + layout["fig_width"] = 0 + layout["fig_height"] = 0 + state = dict(state) + state["layout_json"] = json.dumps(layout) + state["fig_width"] = 0 + state["fig_height"] = 0 + full_layout = json.loads(state["layout_json"]) + full_layout["panel_specs"] = full_specs + full_layout["fig_width"] = full_w + full_layout["fig_height"] = full_h + return state, json.dumps(full_layout), full_w, full_h + + +_ESM = pathlib.Path(esm_path()).read_text(encoding="utf-8") + + +def _page_html(state, *, hidden, full_layout=None, full_w=0, full_h=0, + resize_on_reveal=True): + """Build a mount()-based page. + + hidden mount inside a display:none container + full_layout when given, __reveal() applies it (host-driven relayout) + resize_on_reveal also push fig_width/height on reveal (SpyDE onResize path) + """ + reveal_body = "document.getElementById('wrap').style.display='block';" + if full_layout is not None: + reveal_body += f"handle.applyUpdate('layout_json', {json.dumps(full_layout)});" + if resize_on_reveal: + reveal_body += ( + f"handle.applyUpdate('fig_width', {full_w});" + f"handle.applyUpdate('fig_height', {full_h});" + ) + wrap_style = "display:none" if hidden else "" + return ( + "" + f"
" + "" + ) + + +def _open(browser, html): + with tempfile.NamedTemporaryFile( + suffix=".html", mode="w", encoding="utf-8", delete=False) as fh: + fh.write(html) + tmp = pathlib.Path(fh.name) + page = browser.new_page() + page.set_viewport_size({"width": 600, "height": 600}) + page.goto(tmp.as_uri()) + page.wait_for_function("() => window._aplReady === true", timeout=15_000) + return page, tmp + + +def _panel_gpu(page): + """Return the (single) panel's GPU diagnostic dict.""" + diag = page.evaluate("""() => { + const api = window._api; const o = {}; + if (api && api.panels) for (const [id, p] of api.panels) { + const g = p.gpuCanvas; + o[id] = { gpu: p._gpu, hasObj: !!p._gpuObj, + active: p._gpuActiveNow, + gpuDisp: g ? g.style.display : null, + pw: p.pw, ph: p.ph }; + } + return o; + }""") + assert diag, "no panels found" + return next(iter(diag.values())) + + +# --------------------------------------------------------------------------- +# Real-WebGPU lifecycle tests (skip without an adapter) +# --------------------------------------------------------------------------- + +class TestHiddenRevealActivatesGpu: + """The load-bearing regression: hidden+zero-size mount → reveal → WebGPU.""" + + def test_scatter_visible_from_start_activates(self, _pw_gpu_browser): + """No regression: a normally-visible scatter3d(gpu=True) must still + activate WebGPU on first draw.""" + page, tmp = _open(_pw_gpu_browser, + _page_html(_scatter_state(), hidden=False)) + try: + page.wait_for_timeout(700) + d = _panel_gpu(page) + assert d["gpu"] == "active" and d["active"], \ + f"visible scatter3d did not activate GPU: {d}" + finally: + page.close() + tmp.unlink(missing_ok=True) + + def test_scatter_hidden_zero_then_reveal_activates(self, _pw_gpu_browser): + state, full, fw, fh = _zero_size(_scatter_state()) + page, tmp = _open( + _pw_gpu_browser, + _page_html(state, hidden=True, full_layout=full, + full_w=fw, full_h=fh)) + errors = [] + page.on("pageerror", lambda e: errors.append(str(e))) + try: + page.wait_for_timeout(700) + # While hidden + zero size the GPU must NOT have been initialised + # (and MUST NOT have latched to a terminal 'unavailable'). + d0 = _panel_gpu(page) + assert d0["gpu"] in (None, "pending"), \ + f"GPU init should be deferred while zero-size, got {d0}" + assert not d0["active"], f"GPU active while zero-size: {d0}" + + page.evaluate("() => window.__reveal()") + page.wait_for_timeout(1200) + d1 = _panel_gpu(page) + assert d1["gpu"] == "active" and d1["active"], \ + f"scatter3d did not activate GPU after reveal: {d1}" + assert d1["gpuDisp"] == "block" + assert not errors, f"reveal raised page errors: {errors}" + finally: + page.close() + tmp.unlink(missing_ok=True) + + def test_voxels_hidden_zero_then_reveal_activates(self, _pw_gpu_browser): + """Voxels go through the same draw3d init — the size-gate must cover + them too.""" + state, full, fw, fh = _zero_size(_voxel_state()) + page, tmp = _open( + _pw_gpu_browser, + _page_html(state, hidden=True, full_layout=full, + full_w=fw, full_h=fh)) + try: + page.wait_for_timeout(700) + d0 = _panel_gpu(page) + assert d0["gpu"] in (None, "pending"), \ + f"voxel GPU init should be deferred while zero-size: {d0}" + page.evaluate("() => window.__reveal()") + page.wait_for_timeout(1200) + d1 = _panel_gpu(page) + assert d1["gpu"] == "active" and d1["active"], \ + f"voxels did not activate GPU after reveal: {d1}" + finally: + page.close() + tmp.unlink(missing_ok=True) + + def test_reveal_by_display_toggle_only_activates(self, _pw_gpu_browser): + """Reveal WITHOUT any layout/fig-size push: only the container's + display flips. The ResizeObserver reveal branch must fire the redraw + that re-attempts GPU init at the now-real size.""" + state, _full, _fw, _fh = _zero_size(_scatter_state()) + # No full_layout → __reveal() flips display only. But zero-size panels + # need a real size to init, so this variant keeps the panel at real + # pw/ph via a non-zeroed layout revealed purely by display toggle. + vis_state = _scatter_state() # real pw/ph, hidden by ancestor + page, tmp = _open(_pw_gpu_browser, + _page_html(vis_state, hidden=True)) + try: + page.wait_for_timeout(700) + page.evaluate("() => window.__reveal()") + page.wait_for_timeout(1000) + d = _panel_gpu(page) + assert d["gpu"] == "active" and d["active"], \ + f"display-toggle reveal did not activate GPU: {d}" + finally: + page.close() + tmp.unlink(missing_ok=True) + + +# --------------------------------------------------------------------------- +# Size-gate logic test (no real adapter needed) — proves the DEFER, which is +# the core of the fix, runs even on GPU-less CI via a fake device that latches +# 'unavailable' if init is ever attempted at zero size. +# --------------------------------------------------------------------------- + +_FAKE_STRICT_GPU = """ +() => { + const tex = () => ({ createView:()=>({}), destroy:()=>{} }); + const buf = () => ({ destroy:()=>{} }); + const mkCtx = (canvas) => ({ + configure:()=>{}, + getCurrentTexture:()=>{ + if (canvas.width <= 1 || canvas.height <= 1) + throw new Error('SIMULATED zero-size getCurrentTexture'); + return tex(); + }, + }); + const _getCtx = HTMLCanvasElement.prototype.getContext; + HTMLCanvasElement.prototype.getContext = function(type, ...rest){ + if (type === 'webgpu') return mkCtx(this); + return _getCtx.call(this, type, ...rest); + }; + const dev = { + lost: new Promise(()=>{}), + createShaderModule:()=>({}), createBuffer:()=>buf(), + createBindGroupLayout:()=>({}), createPipelineLayout:()=>({}), + createBindGroup:()=>({}), createTexture:()=>tex(), + createRenderPipeline:()=>({ getBindGroupLayout:()=>({}) }), + createCommandEncoder:()=>({ + beginRenderPass:()=>({ setPipeline:()=>{}, setBindGroup:()=>{}, + setVertexBuffer:()=>{}, draw:()=>{}, end:()=>{} }), + finish:()=>({}), + }), + queue:{ writeBuffer:()=>{}, submit:()=>{}, onSubmittedWorkDone:async()=>{} }, + }; + navigator.gpu = { + getPreferredCanvasFormat:()=>'bgra8unorm', + requestAdapter: async ()=>({ info:{}, requestDevice: async ()=>dev }), + }; +}""" + + +class TestZeroSizeInitDeferred: + """With a driver that THROWS on a zero-size getCurrentTexture(), a panel + that (pre-fix) initialised while zero-size latched permanently to + 'unavailable'. The gate must DEFER init so it never latches — provable + without a real adapter.""" + + def test_zero_size_defers_not_latches(self, _pw_browser): + state, _full, _fw, _fh = _zero_size(_scatter_state()) + html = _page_html(state, hidden=True) # display toggle only + with tempfile.NamedTemporaryFile( + suffix=".html", mode="w", encoding="utf-8", delete=False) as fh: + fh.write(html) + tmp = pathlib.Path(fh.name) + page = _pw_browser.new_page() + page.set_viewport_size({"width": 600, "height": 600}) + page.add_init_script(_FAKE_STRICT_GPU) + try: + page.goto(tmp.as_uri()) + page.wait_for_function("() => window._aplReady === true", + timeout=15_000) + page.wait_for_timeout(600) + d = _panel_gpu(page) + # The key assertion: init was NOT attempted at zero size, so the + # terminal 'unavailable' latch never fired. _gpu stays undefined + # (None) / pending — recoverable once a real size arrives. + assert d["gpu"] in (None, "pending"), ( + f"zero-size init was NOT deferred (latched {d['gpu']!r}); the " + f"panel would be stuck on Canvas2D forever: {d}" + ) + finally: + page.close() + tmp.unlink(missing_ok=True)