diff --git a/anyplotlib/FIGURE_ESM.md b/anyplotlib/FIGURE_ESM.md index 3931476f..e1ace56d 100644 --- a/anyplotlib/FIGURE_ESM.md +++ b/anyplotlib/FIGURE_ESM.md @@ -1,6 +1,6 @@ # FIGURE_ESM.md — Navigator for `figure_esm.js` -`figure_esm.js` is **~6,000 lines** and one big closure. Everything lives inside +`figure_esm.js` is **~9,000 lines** and one big closure. Everything lives inside `function render({ model, el })` so that all helpers share the same scope (`theme`, `PAD_*`, `panels` Map, etc.). This document is a section map so you can jump straight to the relevant code without reading the whole file. @@ -48,29 +48,30 @@ Rule 5 – Text never clips. Optional gutters earn real layout space: | Section / function | Line | |--------------------|------| | Shared plot-area padding (`PAD_*`) | 9 | -| Theme (dark/light detection) | 15 | -| Shared math helpers | 53 | -| b64 array decode helpers | 95 | -| **Rich-text (mini-TeX) engine**: `_texRuns` / `_texLayout` / `_drawTex` | 147 / 214 / 236 | -| **2D gutter geometry**: `_cbWidth` / `_padT` / `_titlePx` | 287 / 299 / 309 | -| **Layout engine** `applyLayout` | 590 | -| `_buildCanvasStack` | 656 | -| `_createPanelDOM` | 763 | -| `_createInsetDOM` / `_applyAllInsetStates` | 846 / 968 | -| `_resizePanelDOM` | 1027 | -| **2D drawing**: `_imgFitRect` | 1176 | -| `draw2d` | 1258 | -| `drawScaleBar2d` / `drawColorbar2d` | 1360 / 1436 | -| `_drawAxes2d` (ticks, labels, title) | 1491 | -| `drawOverlay2d` / `drawMarkers2d` | 1629 / 1685 | -| **Image layers**: `_layerBytes` / `_layerBitmap` / `_drawLayers2d` | 1553 / 1577 / 1629 | -| Binary-bytes splice: `_spliceBinaryBytes` / `_registerBinaryPixelListeners` | 675 / 706 | -| **3D drawing**: `draw3d` | 1833 | -| Event emission `_emitEvent` | 2031 | -| 3D event handlers `_attachEvents3d` | 2059 | -| **1D drawing**: `draw1d` | 2177 | -| `drawOverlay1d` / `drawMarkers1d` | 2516 / 2586 | -| Marker hit-test `_markerHitTest2d` | 2787 | +| Theme (dark/light detection) | 26 | +| Shared math helpers | 64 | +| b64 array decode helpers | 109 | +| **Rich-text (mini-TeX) engine**: `_texRuns` / `_texLayout` / `_drawTex` | 161 / 228 / 250 | +| **2D gutter geometry**: `_cbWidth` / `_cbGap` / `_padT` / `_titlePx` | 301 / 310 / 323 / 333 | +| **Layout engine** `applyLayout` | 774 | +| `_buildCanvasStack` | 857 | +| `_createPanelDOM` | 989 | +| `_createInsetDOM` / `_applyAllInsetStates` | 1118 / 1500 | +| `_resizePanelDOM` | 2213 | +| **2D drawing**: `_imgFitRect` | 2372 | +| `draw2d` | 2680 | +| `drawScaleBar2d` / `drawColorbar2d` | 2875 / 2961 | +| `_drawAxes2d` (ticks, labels, title) | 3016 | +| `drawOverlay2d` / `drawMarkers2d` | 3169 / 3287 | +| **Image layers**: `_layerBytes` / `_layerBitmap` / `_drawLayers2d` | 2500 / 2524 / 2585 | +| Binary-bytes splice: `_spliceBinaryBytes` / `_registerBinaryPixelListeners` | 730 / 761 | +| **3D drawing**: `draw3d` | 4623 | +| Event emission `_emitEvent` | 5270 | +| 3D event handlers `_attachEvents3d` | 5322 | +| **1D drawing**: `draw1d` | 5536 | +| `_drawLine` (1D series + markers) | 5689 | +| `drawOverlay1d` / `drawMarkers1d` | 5982 / 6066 | +| Marker hit-test `_markerHitTest2d` | 6334 | > **`raster` marker (1D/PlotXY)** — `drawMarkers1d` has a `type==='raster'` > branch that blits a single RGBA image across data-coord `extent` (the fast @@ -79,15 +80,15 @@ Rule 5 – Text never clips. Optional gutters earn real layout space: > redraws never re-transmit them; the decoded `OffscreenCanvas` is cached on > the marker set (`ms._rasterBmp`/`_rasterKey`). The shared `clip_path` block > clips it to a curved sector. -| Panel event dispatch `_attachPanelEvents` | 2905 | -| 2D events `_attachEvents2d` | 2928 | -| 1D events `_attachEvents1d` | 3201 | -| 2D widget drag `_ovHitTest2d` / `_doDrag2d` | 3409 / 3491 | -| 1D widget drag `_canvasXToFrac1d` … | 3565 | -| Shared-axis propagation `_getShareGroups` | 3650 | -| Figure resize `_applyFigResizeDOM` | 3714 | -| **Bar chart**: `_barGeom` / `drawBar` / `_attachEventsBar` | 3902 / 3965 / 4341 | -| Generic redraw `_redrawPanel` | 4531 | +| Panel event dispatch `_attachPanelEvents` | 6591 | +| 2D events `_attachEvents2d` | 6615 | +| 1D events `_attachEvents1d` | 6962 | +| 2D widget drag `_ovHitTest2d` / `_doDrag2d` | 7229 / 7363 | +| 1D widget drag `_canvasXToFrac1d` … / snapping `_snapVal` | 7473 / 7546 | +| Shared-axis propagation `_getShareGroups` | 7617 | +| Figure resize `_applyFigResizeDOM` | 7681 | +| **Bar chart**: `_barGeom` / `drawBar` / `_attachEventsBar` | 7872 / 7935 / 8311 | +| Generic redraw `_redrawPanel` | 8501 | --- diff --git a/anyplotlib/_base_plot.py b/anyplotlib/_base_plot.py index 0d877645..15576253 100644 --- a/anyplotlib/_base_plot.py +++ b/anyplotlib/_base_plot.py @@ -95,8 +95,13 @@ def _make_widget_push_fn(self, widget): Replaces the repeated _tp / _targeted_push closures in every add_*_widget method. + + Also back-links the widget to this plot so :meth:`Widget.remove` can + find its owner. Every ``add_*_widget`` routes through here, so this is + the one place that has to know. """ plot_ref, wid_id = self, widget._id + widget._plot = self def _push(): if plot_ref._fig is not None: fields = {k: v for k, v in widget._data.items() @@ -124,6 +129,207 @@ def _push(self) -> None: self._state["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] self._fig._push(self._id) + # ── geometry / coordinate conversion ────────────────────────────────── + # + # These mirror the layout constants and letterbox math in figure_esm.js + # (the PAD_* block near the top, and _imgFitRect). They are here so that + # callers doing display-space work — pixel-sized handles, hit-test + # tolerances, screenshot-driven tests — do not each have to re-derive the + # renderer's layout in their own code and then drift from it. + + #: Plot-area padding in CSS px: (left, right, top, bottom). + #: Matches ``PAD_L``/``PAD_R``/``PAD_T``/``PAD_B`` in ``figure_esm.js``. + PLOT_PADDING = (58, 12, 12, 42) + + def _panel_size(self) -> tuple[float, float]: + """(panel_width, panel_height) in CSS px, from the figure's layout.""" + import json + + if self._fig is None: + raise RuntimeError("panel is not attached to a figure") + try: + layout = json.loads(self._fig.layout_json) + spec = next(s for s in layout["panel_specs"] if s["id"] == self._id) + except (AttributeError, KeyError, StopIteration, ValueError) as exc: + raise RuntimeError(f"no layout entry for panel {self._id!r}") from exc + return float(spec["panel_width"]), float(spec["panel_height"]) + + def _pad_top(self) -> float: + """Title-strip height. Mirrors ``_padT`` in figure_esm.js.""" + pad_t = float(self.PLOT_PADDING[2]) + title = self._state.get("title") + if not title: + return pad_t + size = float(self._state.get("title_size") or 11) + has_tex = "$" in str(title) + if size <= 11 and not has_tex: + return pad_t + import math + + return max(pad_t, math.ceil(size * 1.3) + (4 if has_tex else 2)) + + def plot_box(self) -> dict: + """Return the panel's plot area in CSS pixels. + + Returns + ------- + dict + ``{"x", "y", "width", "height"}`` — the drawable rectangle + relative to the panel's top-left corner. On a 2-D image panel it + is the *letterboxed image* rather than the whole padded area, + because that is what image coordinates actually map onto. + + Raises + ------ + RuntimeError + If the panel is not attached to a figure, so it has no layout. + + Notes + ----- + Computed from the figure's own layout, so it is exact for the sizes + the renderer was given. A browser resized without a re-layout will + disagree. Zoom and pan are *not* folded in — this is the box, not + the visible data window; :meth:`data_to_display` accounts for them. + """ + left, right, top, bottom = self.PLOT_PADDING + pw, ph = self._panel_size() + pad_t = self._pad_top() + + # A 2-D panel drops the left/right/bottom gutters when it has no + # physical axes to label — the image then fills the panel width. + # 1-D panels always draw axes. Mirrors `hasPhysAxis` in the JS. + iw = self._state.get("image_width") + ih = self._state.get("image_height") + is_image = bool(iw and ih) + has_axes = (not is_image) or bool( + self._state.get("has_axes") or self._state.get("is_mesh") + ) + + x = float(left) if has_axes else 0.0 + y = float(pad_t) + width = max((pw - left - right) if has_axes else pw, 1.0) + height = max(ph - pad_t - (bottom if has_axes else 0.0), 1.0) + + # The colorbar strip and its gap come out of the image width. + if self._state.get("show_colorbar") and not self._state.get("is_rgb"): + label = self._state.get("colorbar_label") + label_w = round((self._state.get("colorbar_label_size") or 10) + 8) \ + if label else 0 + pad = self._state.get("colorbar_pad") + gap = 6.0 if pad is None else max(0.0, float(pad)) + width = max(width - (16 + label_w) - gap, 1.0) + + if is_image: + # Images are drawn "contain" — see _imgFitRect. + scale = min(width / float(iw), height / float(ih)) + fit_w, fit_h = float(iw) * scale, float(ih) * scale + x += (width - fit_w) / 2.0 + y += (height - fit_h) / 2.0 + width, height = fit_w, fit_h + + return {"x": x, "y": y, "width": width, "height": height} + + def _visible_image_window(self) -> tuple[float, float, float, float]: + """(src_x, src_y, vis_w, vis_h) of the visible image region, in image px. + + Mirrors the zoom/pan window in ``_imgToCanvas2d``. + """ + iw = float(self._state.get("image_width") or 1) + ih = float(self._state.get("image_height") or 1) + zoom = float(self._state.get("zoom") or 1.0) + cx = float(self._state.get("center_x", 0.5)) + cy = float(self._state.get("center_y", 0.5)) + if zoom < 1.0: + # Zoomed out: the whole image, drawn smaller and centred. The box + # shrinks rather than the source window growing. + return 0.0, 0.0, iw, ih + vis_w, vis_h = iw / zoom, ih / zoom + src_x = max(0.0, min(iw - vis_w, cx * iw - vis_w / 2.0)) + src_y = max(0.0, min(ih - vis_h, cy * ih - vis_h / 2.0)) + return src_x, src_y, vis_w, vis_h + + def data_to_display(self, points): + """Convert data coordinates to CSS pixels within the panel. + + Parameters + ---------- + points : array-like + A single ``(x, y)`` pair or an ``(N, 2)`` sequence of them. + + On a 1-D panel these are axis values. On a 2-D panel they are + **image-pixel coordinates** — the same space marker ``offsets`` + and widget positions use — where integer *i* means the *centre* + of pixel *i*, not its leading edge. + + Returns + ------- + numpy.ndarray + Same shape as *points*, in panel pixels with the origin at the + panel's top-left corner and y increasing downwards. Zoom and pan + are accounted for. + """ + import numpy as np + + pts = np.atleast_2d(np.asarray(points, dtype=float)) + if pts.shape[-1] != 2: + raise ValueError(f"points must have shape (N, 2); got {pts.shape}") + box = self.plot_box() + + if self._state.get("image_width") and self._state.get("image_height"): + zoom = float(self._state.get("zoom") or 1.0) + src_x, src_y, vis_w, vis_h = self._visible_image_window() + w = box["width"] * (zoom if zoom < 1.0 else 1.0) + h = box["height"] * (zoom if zoom < 1.0 else 1.0) + x0 = box["x"] + (box["width"] - w) / 2.0 + y0 = box["y"] + (box["height"] - h) / 2.0 + px = x0 + (pts[:, 0] + 0.5 - src_x) / vis_w * w + py = y0 + (pts[:, 1] + 0.5 - src_y) / vis_h * h + else: + xlo, xhi = self.get_xlim() + ylo, yhi = self.get_ylim() + px = box["x"] + (pts[:, 0] - xlo) / ((xhi - xlo) or 1.0) * box["width"] + # Screen y grows downwards, data y upwards. + py = box["y"] + ( + 1.0 - (pts[:, 1] - ylo) / ((yhi - ylo) or 1.0) + ) * box["height"] + + out = np.column_stack([px, py]) + return out if np.ndim(points) == 2 else out[0] + + def display_to_data(self, points): + """Convert CSS pixels within the panel to data coordinates. + + The inverse of :meth:`data_to_display`; same argument shapes and the + same per-panel-kind coordinate meaning. + """ + import numpy as np + + pts = np.atleast_2d(np.asarray(points, dtype=float)) + if pts.shape[-1] != 2: + raise ValueError(f"points must have shape (N, 2); got {pts.shape}") + box = self.plot_box() + + if self._state.get("image_width") and self._state.get("image_height"): + zoom = float(self._state.get("zoom") or 1.0) + src_x, src_y, vis_w, vis_h = self._visible_image_window() + w = box["width"] * (zoom if zoom < 1.0 else 1.0) + h = box["height"] * (zoom if zoom < 1.0 else 1.0) + x0 = box["x"] + (box["width"] - w) / 2.0 + y0 = box["y"] + (box["height"] - h) / 2.0 + dx = (pts[:, 0] - x0) / (w or 1.0) * vis_w + src_x - 0.5 + dy = (pts[:, 1] - y0) / (h or 1.0) * vis_h + src_y - 0.5 + else: + xlo, xhi = self.get_xlim() + ylo, yhi = self.get_ylim() + dx = xlo + (pts[:, 0] - box["x"]) / (box["width"] or 1.0) \ + * ((xhi - xlo) or 1.0) + dy = ylo + ( + 1.0 - (pts[:, 1] - box["y"]) / (box["height"] or 1.0) + ) * ((yhi - ylo) or 1.0) + + out = np.column_stack([dx, dy]) + return out if np.ndim(points) == 2 else out[0] + def set_tick_label_size(self, size: float) -> None: """Set the font size of the tick (axis number) labels in CSS pixels. diff --git a/anyplotlib/_repr_utils.py b/anyplotlib/_repr_utils.py index f9d0b172..2ebd5110 100644 --- a/anyplotlib/_repr_utils.py +++ b/anyplotlib/_repr_utils.py @@ -35,7 +35,17 @@ # --------------------------------------------------------------------------- def _widget_state(widget) -> dict: - """Return a {name: value} dict of every synced traitlet.""" + """Return a {name: value} dict of every synced traitlet. + + A widget that keeps state outside its traits between renders may define + ``_sync_for_export()`` to reconcile it first. ``Figure`` uses this to fold + widget geometry — pushed to JS as targeted events that never touch the + panel traits — back in, so a snapshot captures widgets where they are + rather than where they were created. + """ + sync = getattr(widget, "_sync_for_export", None) + if callable(sync): + sync() state: dict = {} for name, trait in widget.traits(sync=True).items(): if name.startswith("_"): diff --git a/anyplotlib/_utils.py b/anyplotlib/_utils.py index 11bb2333..6e12d3ff 100644 --- a/anyplotlib/_utils.py +++ b/anyplotlib/_utils.py @@ -21,6 +21,12 @@ "dashdot": "dashdot", "step-mid": "step-mid", "steps-mid": "step-mid", + # "no connecting line" — draw the per-point markers only, so a matplotlib + # ``linestyle="None", marker="o"`` scatter ports over unchanged. + # matplotlib also spells this ``""`` and ``" "``; those stay errors here + # because an empty style string is far more often a typo than an intent. + "none": "none", + "None": "none", } @@ -42,7 +48,9 @@ def _norm_linestyle(ls: str) -> str: Accepted values --------------- ``"solid"`` / ``"-"``, ``"dashed"`` / ``"--"``, - ``"dotted"`` / ``":"``, ``"dashdot"`` / ``"-."``. + ``"dotted"`` / ``":"``, ``"dashdot"`` / ``"-."``, + ``"step-mid"`` / ``"steps-mid"``, and ``"none"`` / ``"None"`` / ``""`` / + ``" "`` for no connecting line (markers only). Raises ------ @@ -53,8 +61,8 @@ def _norm_linestyle(ls: str) -> str: if canonical is None: raise ValueError( f"Unknown linestyle {ls!r}. Expected one of: " - "'solid', 'dashed', 'dotted', 'dashdot', 'step-mid' (alias: 'steps-mid') " - "or shorthands '-', '--', ':', '-.'." + "'solid', 'dashed', 'dotted', 'dashdot', 'step-mid' (alias: 'steps-mid'), " + "'none' (markers only) or shorthands '-', '--', ':', '-.'." ) return canonical diff --git a/anyplotlib/axes/_axes.py b/anyplotlib/axes/_axes.py index e7ab23aa..99b38220 100644 --- a/anyplotlib/axes/_axes.py +++ b/anyplotlib/axes/_axes.py @@ -309,7 +309,9 @@ def plot(self, data: np.ndarray, linestyle : str, optional Dash pattern. Accepted values: ``"solid"`` (``"-"``), ``"dashed"`` (``"--"``), ``"dotted"`` (``":"``), - ``"dashdot"`` (``"-."``) . Default ``"solid"``. + ``"dashdot"`` (``"-."``), or ``"none"`` to draw no connecting + line at all — pair it with *marker* for a scatter. + Default ``"solid"``. ls : str, optional Short alias for *linestyle*. Takes precedence if both are given. alpha : float, optional diff --git a/anyplotlib/figure/_figure.py b/anyplotlib/figure/_figure.py index 8d3306f6..c6335a7e 100644 --- a/anyplotlib/figure/_figure.py +++ b/anyplotlib/figure/_figure.py @@ -786,12 +786,36 @@ def figure_markers(self) -> list: return [dict(m) for m in self._figure_markers] def _push_widget(self, panel_id: str, widget_id: str, fields: dict) -> None: - """Send a targeted widget-position update to JS (no image data).""" + """Send a targeted widget-position update to JS (no image data). + + This writes ``event_json`` only — deliberately, because re-serialising + a whole panel (image bytes included) on every drag frame is exactly + what this path exists to avoid. The consequence is that + ``panel__json`` carries stale widget geometry between plot-level + pushes; :meth:`_sync_for_export` reconciles it before any snapshot. + """ payload = {"source": "python", "panel_id": panel_id, "widget_id": widget_id} payload.update(fields) self.event_json = json.dumps(payload) + def _sync_for_export(self) -> None: + """Refresh every panel trait so a snapshot sees current widget state. + + Widget moves reach JS as targeted ``event_json`` updates that never + touch the panel traits (see :meth:`_push_widget`), so ``save_html`` / + ``to_html`` / ``figure_state`` would otherwise capture every widget at + the position it was *created* at rather than where it now is. Python + holds the authoritative geometry — a JS-side drag writes back through + ``_dispatch_event`` — so re-pushing each panel is enough to reconcile. + + Called from ``_repr_utils._widget_state``, the one chokepoint every + export path goes through. The live Jupyter path deliberately keeps + using targeted pushes and does not pay this cost. + """ + for panel_id in list(self._plots_map): + self._push(panel_id) + def _push_panel_fields(self, panel_id: str, fields: dict) -> None: """Apply a small set of changed *fields* to a panel, then push once. diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index fe5ac617..dc44d101 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -305,6 +305,16 @@ function render({ model, el, onResize }) { return 16 + labelW; } + // Gap between the right edge of the image and the colorbar strip. Without a + // real gap the strip reads as part of the image — most plots have no + // colorbar_label, so the label gutter that would otherwise separate them is + // zero-width. Overridable per panel via colorbar_pad (set_colorbar_pad). + const CB_GAP = 6; + function _cbGap(st) { + const v = st && st.colorbar_pad; + return (v == null) ? CB_GAP : Math.max(0, v); + } + // Height of the title strip. Stays at PAD_T for default-size plain titles // so existing layouts are pixel-identical; grows for title_size > 11 and // for TeX titles (superscripts rise above the cap height) so 2D titles are @@ -2230,7 +2240,7 @@ function render({ model, el, onResize }) { const imgX = hasPhysAxis ? PAD_L : 0; const imgY = padT; const imgW = Math.max(1, (hasPhysAxis ? pw - PAD_L - PAD_R : pw) - - (cbW ? cbW + 2 : 0)); + - (cbW ? cbW + _cbGap(st) : 0)); let imgH = Math.max(1, ph - padT - (hasPhysAxis ? PAD_B : 0)); // Enforce aspect ratio (st.aspect = number or "equal" → 1.0). if (st && st.aspect != null) { @@ -2333,7 +2343,7 @@ function render({ model, el, onResize }) { if (p.cbCanvas && p.cbCtx) { if (cbW) { p.cbCanvas.style.display = 'block'; - p.cbCanvas.style.left = (imgX + imgW + 2) + 'px'; + p.cbCanvas.style.left = (imgX + imgW + _cbGap(st)) + 'px'; p.cbCanvas.style.top = imgY + 'px'; _sz(p.cbCanvas, p.cbCtx, cbW, imgH); } else { @@ -2907,20 +2917,30 @@ function render({ model, el, onResize }) { ctx.setTransform(dpr,0,0,dpr,0,0); ctx.clearRect(0,0,cvW,cvH); + // Colours. Default is the original white-on-black pill; scalebar_color + // recolours the bar and its label, and scalebar_bgcolor the pill — + // 'none' drops the pill entirely, for a bar drawn straight onto a light + // image where the dark slab is the thing that looks wrong. + const sbFg = st.scalebar_color || 'white'; + const sbBg = st.scalebar_bgcolor === undefined || st.scalebar_bgcolor === null + ? 'rgba(0,0,0,0.60)' : st.scalebar_bgcolor; + // Background pill - ctx.fillStyle='rgba(0,0,0,0.60)'; - const r=5; - ctx.beginPath(); - ctx.moveTo(r,0);ctx.lineTo(cvW-r,0);ctx.arcTo(cvW,0,cvW,r,r); - ctx.lineTo(cvW,cvH-r);ctx.arcTo(cvW,cvH,cvW-r,cvH,r); - ctx.lineTo(r,cvH);ctx.arcTo(0,cvH,0,cvH-r,r); - ctx.lineTo(0,r);ctx.arcTo(0,0,r,0,r); - ctx.closePath();ctx.fill(); + if(sbBg !== 'none'){ + ctx.fillStyle=sbBg; + const r=5; + ctx.beginPath(); + ctx.moveTo(r,0);ctx.lineTo(cvW-r,0);ctx.arcTo(cvW,0,cvW,r,r); + ctx.lineTo(cvW,cvH-r);ctx.arcTo(cvW,cvH,cvW-r,cvH,r); + ctx.lineTo(r,cvH);ctx.arcTo(0,cvH,0,cvH-r,r); + ctx.lineTo(0,r);ctx.arcTo(0,0,r,0,r); + ctx.closePath();ctx.fill(); + } // Label (centred over the bar line) const lineX=(cvW-barPx)/2; const textY=padTop+fontSize; - ctx.fillStyle='white'; + ctx.fillStyle=sbFg; ctx.font=`bold ${fontSize}px sans-serif`; ctx.textAlign='center'; ctx.textBaseline='alphabetic'; @@ -2928,7 +2948,7 @@ function render({ model, el, onResize }) { // Bar line const lineY=padTop+fontSize+gap; - ctx.fillStyle='white'; + ctx.fillStyle=sbFg; ctx.fillRect(lineX, lineY, barPx, lineH); // End ticks @@ -3203,6 +3223,28 @@ function render({ model, el, onResize }) { ovCtx.beginPath();ovCtx.moveTo(0,ccy);ovCtx.lineTo(imgW,ccy);ovCtx.stroke(); ovCtx.beginPath();ovCtx.moveTo(ccx,0);ovCtx.lineTo(ccx,imgH);ovCtx.stroke(); if(_handles){ovCtx.beginPath();ovCtx.arc(ccx,ccy,4,0,Math.PI*2);ovCtx.fillStyle=w.color||'#00e5ff';ovCtx.fill();} + } else if(w.type==='vline'){ + // Full-height rule at image x. No handle dot: the whole line is the + // grab target, so a dot would only add clutter. + const [vx]=_imgToCanvas2d(w.x,0,st,imgW,imgH); + ovCtx.beginPath();ovCtx.moveTo(vx,0);ovCtx.lineTo(vx,imgH);ovCtx.stroke(); + } else if(w.type==='hline'){ + const [,hy]=_imgToCanvas2d(0,w.y,st,imgW,imgH); + ovCtx.beginPath();ovCtx.moveTo(0,hy);ovCtx.lineTo(imgW,hy);ovCtx.stroke(); + } else if(w.type==='line'){ + // Bare segment: no arrowhead (that's 'arrow'), no closed path + // (that's 'polygon'). Handles mark the two draggable endpoints. + const [ax,ay]=_imgToCanvas2d(w.x1,w.y1,st,imgW,imgH); + const [bx,by]=_imgToCanvas2d(w.x2,w.y2,st,imgW,imgH); + ovCtx.beginPath();ovCtx.moveTo(ax,ay);ovCtx.lineTo(bx,by);ovCtx.stroke(); + // Canvas-space readback for Playwright, same role as the rectangle + // branch above: a test cannot derive an endpoint's page position from + // figure padding, because the image->canvas mapping depends on the + // zoom/extent state. + if(!window._aplWidgetGeom) window._aplWidgetGeom={}; + if(!window._aplWidgetGeom[p.id]) window._aplWidgetGeom[p.id]={}; + window._aplWidgetGeom[p.id][w.id]={type:'line',ax,ay,bx,by}; + if(_handles){_drawHandle2d(ovCtx,ax,ay,w.color);_drawHandle2d(ovCtx,bx,by,w.color);} } else if(w.type==='polygon'){ const verts=w.vertices||[]; if(verts.length>=2){ @@ -3266,6 +3308,13 @@ function render({ model, el, onResize }) { const fch = isHov && ms.hover_facecolor ? ms.hover_facecolor : fc; const dlw = isHov && (ms.hover_color || ms.hover_facecolor) ? lw+1 : lw; const type = ms.type || 'circles'; + // Per-marker edge/face colours: `color` and `fill_color` may be arrays + // parallel to the markers (matplotlib's edgecolors=[...] / c=[...]). + // Shorter arrays cycle, as matplotlib's colour cycle does. + const _ecArr = Array.isArray(ec) ? ec : null; + const _fcArr = Array.isArray(fch) ? fch : null; + const _ecAt = (i) => _ecArr ? _ecArr[i % _ecArr.length] : ec; + const _fcAt = (i) => _fcArr ? _fcArr[i % _fcArr.length] : fch; // Coordinate transform dispatch: "data" (default), "axes", "display". // For non-data transforms sizes are in pixels, not scaled by zoom. @@ -3281,19 +3330,28 @@ function render({ model, el, onResize }) { _tc=(ix,iy)=>_imgToCanvas2d(ix,iy,st,imgW,imgH); } const scl = tfm==='data' ? scale : 1; + // Sizes have their own space, independent of the position transform: + // 'data' (default) grows with zoom the way a shape drawn on the data + // does; 'px' pins them to screen pixels. A marker standing in for a + // *point* wants px — matplotlib sizes scatter markers in display + // points for exactly that reason. + const sscl = ms.size_units==='px' ? 1 : scl; mkCtx.save(); if(clipSet){ mkCtx.beginPath(); mkCtx.rect(vr.x, vr.y, vr.w, vr.h); mkCtx.clip(); } - mkCtx.strokeStyle=ec; mkCtx.fillStyle=ec; mkCtx.lineWidth=dlw; + // Group-level style; per-marker arrays override inside each loop below. + mkCtx.strokeStyle=_ecAt(0); mkCtx.fillStyle=_ecAt(0); mkCtx.lineWidth=dlw; if(type==='circles'){ for(let i=0;i @location(0) vec4 { const eff_alpha = (alpha != null && alpha < 1.0) ? alpha : 1.0; const ms = Math.max(1, markersize || 4); const doMarker = marker && marker !== 'none'; + // linestyle 'none' (or a zero width) means "markers only" — matplotlib's + // scatter idiom. Skip the connecting stroke but keep drawing markers. + const doLine = linestyle !== 'none' && lw > 0; // Pre-compute pixel positions const allPx = new Array(n), allPy = new Array(n); @@ -5639,26 +5711,29 @@ fn fs(in : VsOut) -> @location(0) vec4 { ctx.save(); if (eff_alpha < 1.0) ctx.globalAlpha = eff_alpha; - ctx.setLineDash(dash); - ctx.beginPath(); - ctx.strokeStyle = color; ctx.lineWidth = lw; ctx.lineJoin = 'round'; - - if (isStepMid && n >= 2) { - ctx.moveTo(allPx[0], allPy[0]); - for (let i = 0; i < n - 1; i++) { - const midX = (allPx[i] + allPx[i + 1]) / 2; - ctx.lineTo(midX, allPy[i]); - ctx.lineTo(midX, allPy[i + 1]); - } - ctx.lineTo(allPx[n - 1], allPy[n - 1]); - } else { - for (let i = 0; i < n; i++) { - if (i === 0) ctx.moveTo(allPx[i], allPy[i]); - else ctx.lineTo(allPx[i], allPy[i]); + + if (doLine) { + ctx.setLineDash(dash); + ctx.beginPath(); + ctx.strokeStyle = color; ctx.lineWidth = lw; ctx.lineJoin = 'round'; + + if (isStepMid && n >= 2) { + ctx.moveTo(allPx[0], allPy[0]); + for (let i = 0; i < n - 1; i++) { + const midX = (allPx[i] + allPx[i + 1]) / 2; + ctx.lineTo(midX, allPy[i]); + ctx.lineTo(midX, allPy[i + 1]); + } + ctx.lineTo(allPx[n - 1], allPy[n - 1]); + } else { + for (let i = 0; i < n; i++) { + if (i === 0) ctx.moveTo(allPx[i], allPy[i]); + else ctx.lineTo(allPx[i], allPy[i]); + } } + ctx.stroke(); + ctx.setLineDash([]); } - ctx.stroke(); - ctx.setLineDash([]); const pts = doMarker ? allPx.map((px, i) => [px, allPy[i]]) : null; @@ -5679,8 +5754,10 @@ fn fs(in : VsOut) -> @location(0) vec4 { ctx.restore(); } + // ?? not || on linewidth: an explicit 0 means "no stroke" and must not + // fall back to the 1.5 default (see _drawLine's doLine). _drawLine(yData, xArr, - st.line_color || '#4fc3f7', st.line_linewidth || 1.5, + st.line_color || '#4fc3f7', st.line_linewidth ?? 1.5, st.line_linestyle || 'solid', st.line_alpha != null ? st.line_alpha : 1.0, st.line_marker || 'none', st.line_markersize || 4); @@ -5689,7 +5766,7 @@ fn fs(in : VsOut) -> @location(0) vec4 { 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.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, exMap); @@ -5699,7 +5776,7 @@ fn fs(in : VsOut) -> @location(0) vec4 { if (_hovId !== undefined && _hovId !== '__none__') { if (_hovId === null) { _drawLine(yData, xArr, - _brightenColor(st.line_color||'#4fc3f7'), (st.line_linewidth||1.5)+1, + _brightenColor(st.line_color||'#4fc3f7'), (st.line_linewidth??1.5)+1, st.line_linestyle||'solid', st.line_alpha!=null?st.line_alpha:1.0, st.line_marker||'none', st.line_markersize||4); } else { @@ -5709,7 +5786,7 @@ fn fs(in : VsOut) -> @location(0) vec4 { 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, + _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, exMap); break; @@ -5749,8 +5826,18 @@ fn fs(in : VsOut) -> @location(0) vec4 { if(axisVis1d&&yTicksVis1d){ ctx.font=(st.tick_size||10)+'px monospace';ctx.textAlign='right';ctx.textBaseline='middle'; const tickRX=r.x-8; + // Widest tick string, in both branches: the y label is placed relative + // to it below, so it has to be measured whatever the scale. + let maxTW=0; if(isLog){ const lo=Math.floor(effDMin), hi=Math.ceil(effDMax); + for(let e=lo;e<=hi;e++){ + // Plain-text measure of "10^{e}" over-estimates the TeX-rendered + // width (the exponent is drawn smaller). Over-estimating is the + // safe direction: it pushes the label further from the ticks. + const tw=ctx.measureText('10'+e).width; + if(tw>maxTW)maxTW=tw; + } for(let e=lo;e<=hi;e++){ const v=Math.pow(10,e); const py=_toPlotY(v); @@ -5759,7 +5846,6 @@ fn fs(in : VsOut) -> @location(0) vec4 { ctx.fillStyle=theme.tickText;_drawTex(ctx,'$10^{'+e+'}$',tickRX,py,st.tick_size||10,{align:'right',family:'monospace'}); } } else { - let maxTW=0; for(let v=Math.ceil(dMin/yStep)*yStep;v<=dMax+yStep*0.01;v+=yStep){const tw=ctx.measureText(fmtVal(v)).width;if(tw>maxTW)maxTW=tw;} for(let v=Math.ceil(dMin/yStep)*yStep;v<=dMax+yStep*0.01;v+=yStep){ const py=_valToPy1d(v,dMin,dMax,r); @@ -5771,10 +5857,21 @@ fn fs(in : VsOut) -> @location(0) vec4 { if(yUnits){ ctx.save(); // Centre the rotated label in the left gutter (x = 0..r.x). - // Using a fixed x of PAD_L*0.28 keeps it clear of the tick numbers - // regardless of how wide those numbers are. + // + // The x used to be a fixed PAD_L*0.28 on the assumption that it + // cleared the tick numbers whatever their width. It does not: wide + // strings ("-5.6e-17" at the default tick size) reach back past it + // and the label is drawn through them. So take the fixed position as + // a *preference* and shift left when the ticks actually need the + // room, clamped so the label stays on the canvas — half the rotated + // glyph height is the least it can sit at. const ylpx1d = st.y_label_size||9; - const lcx = Math.max(Math.round(PAD_L * 0.28), Math.ceil(ylpx1d*0.62)+1); + const halfGlyph = Math.ceil(ylpx1d*0.62)+1; + const clearOfTicks = tickRX - maxTW - halfGlyph - 2; + const lcx = Math.max( + halfGlyph, + Math.min(Math.round(PAD_L * 0.28), clearOfTicks) + ); ctx.translate(lcx, r.y+r.h/2); ctx.rotate(-Math.PI/2); ctx.textBaseline='middle'; ctx.fillStyle=theme.unitText; @@ -5847,10 +5944,14 @@ fn fs(in : VsOut) -> @location(0) vec4 { 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(swatchX0,ly+5); ctx.lineTo(swatchX1,ly+5); ctx.stroke(); - ctx.setLineDash([]); + // linestyle 'none' is a markers-only series: the swatch shows just the + // marker, with no connecting rule (matches how the series is drawn). + if(lb.linestyle!=='none'){ + ctx.strokeStyle=lb.color; ctx.lineWidth=2; + ctx.setLineDash(ldash); + 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,markerX,ly+5,Math.min(lb.ms||4,4)); ctx.fill(); ctx.stroke(); @@ -5901,6 +6002,20 @@ fn fs(in : VsOut) -> @location(0) vec4 { const py=_valToPy1d(w.y,dMin,dMax,r); ovCtx.setLineDash([5,3]);ovCtx.beginPath();ovCtx.moveTo(r.x,py);ovCtx.lineTo(r.x+r.w,py);ovCtx.stroke();ovCtx.setLineDash([]); _ovHandle1d(ovCtx,r.x+r.w-7,py,color); + } else if(w.type==='range' && w.orientation==='vertical'){ + // Vertical range: the two edges are VALUES on the y axis and the band + // spans the full plot width. x0/x1 stay the field names — they are + // the extents along the selection axis, the same way matplotlib's + // SpanSelector treats `extents` regardless of its `direction`. + const vpy0=_valToPy1d(w.x0,dMin,dMax,r); + const vpy1=_valToPy1d(w.x1,dMin,dMax,r); + const vtop=Math.min(vpy0,vpy1), vbot=Math.max(vpy0,vpy1); + ovCtx.save();ovCtx.globalAlpha=0.15;ovCtx.fillStyle=color;ovCtx.fillRect(r.x,vtop,r.w,vbot-vtop);ovCtx.restore(); + ovCtx.setLineDash([5,3]); + ovCtx.beginPath();ovCtx.moveTo(r.x,vpy0);ovCtx.lineTo(r.x+r.w,vpy0);ovCtx.stroke(); + ovCtx.beginPath();ovCtx.moveTo(r.x,vpy1);ovCtx.lineTo(r.x+r.w,vpy1);ovCtx.stroke(); + ovCtx.setLineDash([]); + _ovHandle1d(ovCtx,r.x+r.w-7,vpy0,color);_ovHandle1d(ovCtx,r.x+r.w-7,vpy1,color); } else if(w.type==='range'){ const px0=_fracToPx1d(_axisValToFrac(xArr,w.x0),x0,x1,r); const px1b=_fracToPx1d(_axisValToFrac(xArr,w.x1),x0,x1,r); @@ -5987,6 +6102,11 @@ fn fs(in : VsOut) -> @location(0) vec4 { const ec = isHov && ms.hover_color ? ms.hover_color : color; const fch = isHov && ms.hover_facecolor ? ms.hover_facecolor : fc; const dlw = isHov && (ms.hover_color || ms.hover_facecolor) ? lw+1 : lw; + // Per-marker edge/face colours — see the matching block in _drawMarkers2d. + const _ecArr = Array.isArray(ec) ? ec : null; + const _fcArr = Array.isArray(fch) ? fch : null; + const _ecAt = (i) => _ecArr ? _ecArr[i % _ecArr.length] : ec; + const _fcAt = (i) => _fcArr ? _fcArr[i % _fcArr.length] : fch; // Coordinate transform: "axes" and "display" map 2-D offsets to panel // space independently of data values; vlines/hlines stay in data coords. @@ -6000,7 +6120,7 @@ fn fs(in : VsOut) -> @location(0) vec4 { _tc2d=(off0,off1)=>_offToCanvas([off0,off1]); } - mkCtx.save();mkCtx.strokeStyle=ec;mkCtx.fillStyle=ec;mkCtx.lineWidth=dlw; + mkCtx.save();mkCtx.strokeStyle=_ecAt(0);mkCtx.fillStyle=_ecAt(0);mkCtx.lineWidth=dlw; // Optional clip path (matplotlib set_clip_path): a data-coord polygon the // group is clipped to — e.g. a pcolormesh mesh clipped to a curved @@ -6017,33 +6137,34 @@ fn fs(in : VsOut) -> @location(0) vec4 { } if(type==='points'){ - // Per-point face/edge colours (matplotlib scatter c=[...]): fill_color - // and/or color may be arrays parallel to offsets. - const _fcArr = Array.isArray(fch) ? fch : null; - const _ecArr = Array.isArray(ec) ? ec : null; for(let i=0;i @location(0) vec4 { const rh=Math.max(1, tfm==='data' ? Math.abs(_yPx((off[1]||0)-hd/2)-_yPx((off[1]||0)+hd/2))/2 : hd/2); const ang=((ms.angles&&(ms.angles[i]!=null?ms.angles[i]:ms.angles[0])||0)*Math.PI)/180; mkCtx.beginPath();mkCtx.ellipse(cx,cy,rw,rh,ang,0,Math.PI*2); - if(fch){mkCtx.save();mkCtx.globalAlpha=fa;mkCtx.fillStyle=fch;mkCtx.fill();mkCtx.restore();} + const _fc=_fcAt(i); + if(_fc){mkCtx.save();mkCtx.globalAlpha=fa;mkCtx.fillStyle=_fc;mkCtx.fill();mkCtx.restore();} + if(_ecArr) mkCtx.strokeStyle=_ecAt(i); mkCtx.stroke(); } } else if(type==='rectangles'||type==='squares'){ @@ -6070,15 +6193,13 @@ fn fs(in : VsOut) -> @location(0) vec4 { const rh=Math.max(1, tfm==='data' ? Math.abs(_yPx((off[1]||0)-hd/2)-_yPx((off[1]||0)+hd/2)) : hd); const ang=((ms.angles&&(ms.angles[i]!=null?ms.angles[i]:ms.angles[0])||0)*Math.PI)/180; mkCtx.save();mkCtx.translate(cx,cy);mkCtx.rotate(ang); - if(fch){mkCtx.save();mkCtx.globalAlpha=fa;mkCtx.fillStyle=fch;mkCtx.fillRect(-rw/2,-rh/2,rw,rh);mkCtx.restore();} + const _fc=_fcAt(i); + if(_fc){mkCtx.save();mkCtx.globalAlpha=fa;mkCtx.fillStyle=_fc;mkCtx.fillRect(-rw/2,-rh/2,rw,rh);mkCtx.restore();} + if(_ecArr) mkCtx.strokeStyle=_ecAt(i); mkCtx.strokeRect(-rw/2,-rh/2,rw,rh); mkCtx.restore(); } } else if(type==='polygons'){ - // Per-polygon face/edge colours (matplotlib PathCollection / pcolormesh): - // fill_color and/or color may be arrays parallel to vertices_list. - const _fcArr = Array.isArray(fch) ? fch : null; - const _ecArr = Array.isArray(ec) ? ec : null; for(let i=0;i<(ms.vertices_list||[]).length;i++){ const verts=ms.vertices_list[i]; if(!verts||verts.length<2) continue; @@ -6089,9 +6210,9 @@ fn fs(in : VsOut) -> @location(0) vec4 { mkCtx.lineTo(px,py); } mkCtx.closePath(); - const _fc=_fcArr?_fcArr[i%_fcArr.length]:fch; + const _fc=_fcAt(i); if(_fc){mkCtx.save();mkCtx.globalAlpha=fa;mkCtx.fillStyle=_fc;mkCtx.fill();mkCtx.restore();} - if(_ecArr) mkCtx.strokeStyle=_ecArr[i%_ecArr.length]; + if(_ecArr) mkCtx.strokeStyle=_ecAt(i); mkCtx.stroke(); } } else if(type==='raster'){ @@ -6234,18 +6355,20 @@ fn fs(in : VsOut) -> @location(0) vec4 { _tc=(ix,iy)=>_imgToCanvas2d(ix,iy,st,pw,ph); } const scl = tfm==='data' ? scale : 1; + // Match the draw loop's size space (see drawMarkers2d). + const sscl = ms.size_units==='px' ? 1 : scl; if (type === 'circles') { for (let i=0;i<(ms.offsets||[]).length;i++) { const [cx,cy]=_tc(ms.offsets[i][0],ms.offsets[i][1]); - const r=Math.max(1,(ms.sizes[i]!=null?ms.sizes[i]:ms.sizes[0]||5)*scl); + const r=Math.max(1,(ms.sizes[i]!=null?ms.sizes[i]:ms.sizes[0]||5)*sscl); if(Math.sqrt((mx-cx)**2+(my-cy)**2)<=r+MARKER_HIT) return{si,i,collectionLabel:collLabel,markerLabel:perLabels?String(perLabels[i]??''):null}; } } else if (type === 'ellipses') { for (let i=0;i<(ms.offsets||[]).length;i++) { const [cx,cy]=_tc(ms.offsets[i][0],ms.offsets[i][1]); - const rw=(ms.widths[i]||ms.widths[0]||10)*scl/2+MARKER_HIT; - const rh=(ms.heights[i]||ms.heights[0]||10)*scl/2+MARKER_HIT; + const rw=(ms.widths[i]||ms.widths[0]||10)*sscl/2+MARKER_HIT; + const rh=(ms.heights[i]||ms.heights[0]||10)*sscl/2+MARKER_HIT; const dx=(mx-cx)/Math.max(1,rw), dy=(my-cy)/Math.max(1,rh); if(dx*dx+dy*dy<=1.0) return{si,i,collectionLabel:collLabel,markerLabel:perLabels?String(perLabels[i]??''):null}; @@ -6254,8 +6377,8 @@ fn fs(in : VsOut) -> @location(0) vec4 { const heights = type==='squares' ? ms.widths : ms.heights; for (let i=0;i<(ms.offsets||[]).length;i++) { const [cx,cy]=_tc(ms.offsets[i][0],ms.offsets[i][1]); - const hw=(ms.widths[i]||ms.widths[0]||20)*scl/2+MARKER_HIT; - const hh=((heights[i]||heights[0]||20))*scl/2+MARKER_HIT; + const hw=(ms.widths[i]||ms.widths[0]||20)*sscl/2+MARKER_HIT; + const hh=((heights[i]||heights[0]||20))*sscl/2+MARKER_HIT; if(Math.abs(mx-cx)<=hw&&Math.abs(my-cy)<=hh) return{si,i,collectionLabel:collLabel,markerLabel:perLabels?String(perLabels[i]??''):null}; } @@ -6915,16 +7038,38 @@ fn fs(in : VsOut) -> @location(0) vec4 { const st=p.state; if(st) _emitEvent(p.id,'pointer_up',null,{view_x0:st.view_x0,view_x1:st.view_x1,..._pointerFields(e),button:e.button}); } - // Line click: fire when no widget was being dragged and mouse barely moved. + // Click: fire when no widget was being dragged and mouse barely moved. // NOTE: p.isPanning is always set true on mousedown (pan start), so we // deliberately only block on wasWidgetDragging here — the distance // threshold below already excludes real pan gestures. + // + // A click always emits exactly one pointer_down carrying the clicked + // position in data coords, mirroring 2-D panels. When the click also + // landed on a line, line_id (and the snapped on-line x/y) come along — + // that is the pre-existing line-click contract, kept intact. if(!wasWidgetDragging && p._mousedownX!=null){ const mdx=e.clientX-p._mousedownX, mdy=e.clientY-p._mousedownY; if(Math.hypot(mdx,mdy)<5){ const {mx,my}=_clientPos(e,overlayCanvas,p.pw,p.ph); - const lhit=_lineHitTest1d(mx,my,p); - if(lhit) _emitEvent(p.id,'pointer_down',null,{line_id:lhit.lineId,x:lhit.x,y:lhit.y,..._pointerFields(e),button:e.button}); + const st=p.state; + const r=_plotRect1d(p); + const inPlot = st && mx>=r.x && mx<=r.x+r.w && my>=r.y && my<=r.y+r.h; + if(inPlot){ + const lhit=_lineHitTest1d(mx,my,p); + const xArr=p._1dXArr||(st.x_axis_b64?_decodeF64(st.x_axis_b64):(st.x_axis||[])); + const frac=_canvasXToFrac1d(mx,st.view_x0||0,st.view_x1||1,r); + const physX=xArr.length>=2?_axisFracToVal(xArr,frac):frac; + const dMin=st.data_min, dMax=st.data_max; + const physY=dMin+(r.y+r.h-my)/(r.h||1)*(dMax-dMin); + _emitEvent(p.id,'pointer_down',null,{ + ...(lhit?{line_id:lhit.lineId}:{}), + // On-line coords when a line was hit, else the raw click point. + x:lhit?lhit.x:physX, y:lhit?lhit.y:physY, + xdata:physX, ydata:physY, + ..._pointerFields(e), + button:e.button, + }); + } } } p._mousedownX=null; @@ -7130,8 +7275,37 @@ fn fs(in : VsOut) -> @location(0) vec4 { } else if (w.type === 'crosshair') { const [ccx, ccy] = _imgToCanvas2d(w.cx, w.cy, st, imgW, imgH); + // Centre hotspot moves both axes at once. if (Math.hypot(mx-ccx, my-ccy) <= HR + 4) return { idx:i, mode:'move', snapW:{...w}, startMX:mx, startMY:my }; + // Anywhere along either rule is also a grab target: for a line-style + // pointer the lines ARE the widget, and requiring the user to find a + // one-pixel intersection made it feel broken. Grabbing a rule + // constrains the drag to that rule's own axis. + if (Math.abs(mx-ccx) <= HR) + return { idx:i, mode:'move_x', snapW:{...w}, startMX:mx, startMY:my }; + if (Math.abs(my-ccy) <= HR) + return { idx:i, mode:'move_y', snapW:{...w}, startMX:mx, startMY:my }; + + } else if (w.type === 'vline') { + const [vx] = _imgToCanvas2d(w.x, 0, st, imgW, imgH); + if (Math.abs(mx - vx) <= HR) + return { idx:i, mode:'move', snapW:{...w}, startMX:mx, startMY:my }; + + } else if (w.type === 'hline') { + const [, hy] = _imgToCanvas2d(0, w.y, st, imgW, imgH); + if (Math.abs(my - hy) <= HR) + return { idx:i, mode:'move', snapW:{...w}, startMX:mx, startMY:my }; + + } else if (w.type === 'line') { + const [ax, ay] = _imgToCanvas2d(w.x1, w.y1, st, imgW, imgH); + const [bx, by] = _imgToCanvas2d(w.x2, w.y2, st, imgW, imgH); + if (Math.hypot(mx-ax, my-ay) <= HR) + return { idx:i, mode:'move_p1', snapW:{...w}, startMX:mx, startMY:my }; + if (Math.hypot(mx-bx, my-by) <= HR) + return { idx:i, mode:'move_p2', snapW:{...w}, startMX:mx, startMY:my }; + if (_distToSegment2d(mx, my, ax, ay, bx, by) <= HR) + return { idx:i, mode:'move', snapW:{...w}, startMX:mx, startMY:my }; } else if (w.type === 'polygon') { const verts = w.vertices || []; @@ -7248,7 +7422,24 @@ fn fs(in : VsOut) -> @location(0) vec4 { w.y = s.y + (s.h - newH); w.h = newH; } } else if (w.type === 'crosshair') { - w.cx = s.cx + dix; w.cy = s.cy + diy; + // Grabbing a single rule constrains the drag to that rule's own axis; + // grabbing the centre moves both (see _ovHitTest2d). + if (d.mode === 'move_x') { w.cx = s.cx + dix; } + else if (d.mode === 'move_y') { w.cy = s.cy + diy; } + else { w.cx = s.cx + dix; w.cy = s.cy + diy; } + } else if (w.type === 'vline') { + w.x = s.x + dix; + } else if (w.type === 'hline') { + w.y = s.y + diy; + } else if (w.type === 'line') { + if (d.mode === 'move') { + w.x1 = s.x1 + dix; w.y1 = s.y1 + diy; + w.x2 = s.x2 + dix; w.y2 = s.y2 + diy; + } else if (d.mode === 'move_p1') { + w.x1 = imgMX; w.y1 = imgMY; + } else if (d.mode === 'move_p2') { + w.x2 = imgMX; w.y2 = imgMY; + } } else if (w.type === 'polygon') { if (d.mode === 'move') { w.vertices = s.vertices.map(v => [v[0]+dix, v[1]+diy]); @@ -7311,6 +7502,16 @@ fn fs(in : VsOut) -> @location(0) vec4 { } else if(w.type==='hline'){ const py=_valToPy1d(w.y,st.data_min,st.data_max,r); if(Math.abs(my-py)<=5) return{idx:i,mode:'move',wtype:'hline',startMY:my,snapW:{...w}}; + } else if(w.type==='range' && w.orientation==='vertical'){ + const vpy0=_valToPy1d(w.x0,st.data_min,st.data_max,r); + const vpy1=_valToPy1d(w.x1,st.data_min,st.data_max,r); + const vtop=Math.min(vpy0,vpy1), vbot=Math.max(vpy0,vpy1); + // Same one-third rule as the horizontal band: a thin band must keep a + // grabbable middle rather than being all edge. + const vgrab=Math.min(HR+5,(vbot-vtop)/3); + if(Math.abs(my-vpy0)<=vgrab) return{idx:i,mode:'edge0',wtype:'range',startMY:my,snapW:{...w}}; + if(Math.abs(my-vpy1)<=vgrab) return{idx:i,mode:'edge1',wtype:'range',startMY:my,snapW:{...w}}; + if(my>=vtop&&my<=vbot&&mx>=r.x&&mx<=r.x+r.w) return{idx:i,mode:'move',wtype:'range',startMY:my,snapW:{...w}}; } else if(w.type==='range'){ const px0=_fracToPx1d(_axisValToFrac(xArr,w.x0),x0,x1,r); const px1b=_fracToPx1d(_axisValToFrac(xArr,w.x1),x0,x1,r); @@ -7339,17 +7540,46 @@ fn fs(in : VsOut) -> @location(0) vec4 { return null; } + // Snap a value to the nearest entry of a widget's snap_values, if it has any. + // Mirrors matplotlib SpanSelector.snap_values: the drag follows the cursor + // but lands only on allowed positions. + function _snapVal(v, snapValues){ + if(!Array.isArray(snapValues) || !snapValues.length) return v; + let best=snapValues[0], bestD=Math.abs(v-best); + for(let i=1;i=2?_axisFracToVal(xArr,_canvasXToFrac1d(mx,x0,x1,r)):_canvasXToFrac1d(mx,x0,x1,r); const widgets=st.overlay_widgets; const d=p.ovDrag, s=d.snapW, w=widgets[d.idx]; + const snap=(v)=>_snapVal(v,w.snap_values); + const xUnit=snap(xArr.length>=2?_axisFracToVal(xArr,_canvasXToFrac1d(mx,x0,x1,r)):_canvasXToFrac1d(mx,x0,x1,r)); + const yUnit=snap(st.data_max-((py-r.y)/(r.h||1))*(st.data_max-st.data_min)); if(w.type==='vline'){w.x=xUnit;} - else if(w.type==='hline'){w.y=st.data_max-((py-r.y)/(r.h||1))*(st.data_max-st.data_min);} + else if(w.type==='hline'){w.y=yUnit;} + else if(w.type==='range' && w.orientation==='vertical'){ + // Same cap semantics as the horizontal band, on the value axis. + const vcap = (w.max_extent == null ? null : Math.abs(w.max_extent)); + if(d.mode==='edge0'){ + w.x0 = (vcap!=null && Math.abs(w.x1-yUnit) > vcap) + ? w.x1 + (yUnit < w.x1 ? -vcap : vcap) : yUnit; + } else if(d.mode==='edge1'){ + w.x1 = (vcap!=null && Math.abs(yUnit-w.x0) > vcap) + ? w.x0 + (yUnit < w.x0 ? -vcap : vcap) : yUnit; + } else { + const dv=(st.data_max-st.data_min)*((d.startMY-py)/(r.h||1)); + w.x0=snap(s.x0+dv);w.x1=snap(s.x1+dv); + } + } else if(w.type==='range'){ // max_extent caps the span DURING the drag. The edge NOT being dragged is // the anchor and never moves, so the span stops growing at the cap without @@ -7369,13 +7599,13 @@ fn fs(in : VsOut) -> @location(0) vec4 { else { const snapPx=_fracToPx1d(xArr.length>=2?_axisValToFrac(xArr,s.x0):0,x0,x1,r); const dxUnit=xArr.length>=2?_axisFracToVal(xArr,_canvasXToFrac1d(snapPx+(mx-d.startMX),x0,x1,r))-s.x0:(mx-d.startMX)/(r.w||1); - w.x0=s.x0+dxUnit;w.x1=s.x1+dxUnit; + w.x0=snap(s.x0+dxUnit);w.x1=snap(s.x1+dxUnit); } } else if(w.type==='point'){ // Clamp to plot rectangle const clampX=Math.max(r.x,Math.min(r.x+r.w,mx)); const clampY=Math.max(r.y,Math.min(r.y+r.h,py)); - w.x=xArr.length>=2?_axisFracToVal(xArr,_canvasXToFrac1d(clampX,x0,x1,r)):_canvasXToFrac1d(clampX,x0,x1,r); + w.x=snap(xArr.length>=2?_axisFracToVal(xArr,_canvasXToFrac1d(clampX,x0,x1,r)):_canvasXToFrac1d(clampX,x0,x1,r)); w.y=st.data_max-((clampY-r.y)/(r.h||1))*(st.data_max-st.data_min); } drawOverlay1d(p); @@ -7528,7 +7758,7 @@ fn fs(in : VsOut) -> @location(0) vec4 { const imgX = hasPhysAxis ? PAD_L : 0; const imgY = hasPhysAxis ? padT : 0; const imgW = Math.max(1, (hasPhysAxis ? pw - PAD_L - PAD_R : pw) - - (cbW ? cbW + 2 : 0)); + - (cbW ? cbW + _cbGap(st) : 0)); const imgH = hasPhysAxis ? Math.max(1, ph - padT - PAD_B) : ph; // Update stored dims so event handlers stay consistent during CSS resize p.imgX = imgX; p.imgY = imgY; p.imgW = imgW; p.imgH = imgH; @@ -7556,7 +7786,7 @@ fn fs(in : VsOut) -> @location(0) vec4 { _szCSS(p.xAxisCanvas, imgW, PAD_B); } if (p.cbCanvas && p.cbCanvas.style.display !== 'none') { - p.cbCanvas.style.left = (imgX + imgW + 2) + 'px'; p.cbCanvas.style.top = imgY + 'px'; + p.cbCanvas.style.left = (imgX + imgW + _cbGap(st)) + 'px'; p.cbCanvas.style.top = imgY + 'px'; _szCSS(p.cbCanvas, cbW || 16, imgH); } } else if (p.kind === '3d') { diff --git a/anyplotlib/markers.py b/anyplotlib/markers.py index 19ae111c..b7dc724d 100644 --- a/anyplotlib/markers.py +++ b/anyplotlib/markers.py @@ -28,6 +28,17 @@ (``color``, ``fill_color``, ``fill_alpha``, ``sizes``, etc.). ``MarkerGroup`` stores matplotlib-style names and ``to_wire()`` translates before JSON serialisation. + +Per-marker colours +------------------ +``edgecolors`` and ``facecolors`` accept either one colour for the whole group +or a sequence of colours parallel to the markers — matplotlib's +``edgecolors=[...]`` / scatter ``c=[...]``:: + + plot.add_circles(offsets, edgecolors=["#f00", "#0f0", "#00f"], radius=3) + +A sequence shorter than the group cycles, as matplotlib's colour cycle does. +This works for every marker type on both 1-D and 2-D panels. """ from __future__ import annotations @@ -372,6 +383,19 @@ def to_wire(self, group_id: str) -> dict: # this flag allows opting out for HUD-style annotations. wire["clip_display"] = bool(d.get("clip_display", True)) + # ── size space (independent of the position transform) ───────────── + # "data" (default) scales sizes with zoom, as a shape drawn on the + # data does. "px" pins them to screen pixels, which is what a marker + # standing in for a *point* wants — matplotlib sizes scatter markers + # in display points for exactly that reason. + size_units = d.get("size_units") + if size_units is not None: + if size_units not in ("data", "px"): + raise ValueError( + f"size_units must be 'data' or 'px', got {size_units!r}" + ) + wire["size_units"] = size_units + # ── common optional fields ────────────────────────────────────────── label = d.get("label") if label is not None: diff --git a/anyplotlib/plot1d/_plot1d.py b/anyplotlib/plot1d/_plot1d.py index e25d25cd..650245cd 100644 --- a/anyplotlib/plot1d/_plot1d.py +++ b/anyplotlib/plot1d/_plot1d.py @@ -237,7 +237,8 @@ class Plot1D(_BasePlot, _PanelMixin, _MarkerMixin): - ``"solid"`` - Dash pattern: ``"solid"``, ``"dashed"``, ``"dotted"``, ``"dashdot"``. Shorthands ``"-"``, ``"--"``, ``":"``, - ``"-."`` also accepted. + ``"-."`` also accepted, as is ``"none"`` (markers only, no + connecting line). * - ``alpha`` - ``1.0`` - Line opacity (0 = transparent, 1 = fully opaque). @@ -550,7 +551,8 @@ def add_line(self, data: np.ndarray, x_axis=None, Stroke width in pixels. Default ``1.5``. linestyle : str, optional Dash pattern: ``"solid"``, ``"dashed"``, ``"dotted"``, - ``"dashdot"`` (or shorthands). Default ``"solid"``. + ``"dashdot"`` (or shorthands), or ``"none"`` for markers only + with no connecting line. Default ``"solid"``. ls : str, optional Short alias for *linestyle*. alpha : float, optional @@ -700,7 +702,8 @@ def clear_spans(self) -> None: # Overlay Widgets # ------------------------------------------------------------------ def add_vline_widget(self, x: float, color: str = "#00e5ff", - linewidth: float = 2) -> _VLineWidget: + linewidth: float = 2, + snap_values=None) -> _VLineWidget: """Add a draggable vertical-line overlay. Parameters @@ -719,14 +722,15 @@ def add_vline_widget(self, x: float, color: str = "#00e5ff", :meth:`on_changed` / :meth:`on_release`. """ widget = _VLineWidget(lambda: None, x=float(x), color=color, - linewidth=linewidth) + linewidth=linewidth, snap_values=snap_values) 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", - linewidth: float = 2) -> _HLineWidget: + linewidth: float = 2, + snap_values=None) -> _HLineWidget: """Add a draggable horizontal-line overlay. Parameters @@ -745,7 +749,7 @@ def add_hline_widget(self, y: float, color: str = "#00e5ff", :meth:`on_changed` / :meth:`on_release`. """ widget = _HLineWidget(lambda: None, y=float(y), color=color, - linewidth=linewidth) + linewidth=linewidth, snap_values=snap_values) widget._push_fn = self._make_widget_push_fn(widget) self._widgets[widget.id] = widget self._push() @@ -757,13 +761,17 @@ def add_range_widget(self, x0: float, x1: float, y: float = 0.0, linewidth: float = 2, max_extent: float | None = None, + orientation: str = "horizontal", + snap_values=None, _push: bool = True) -> _RangeWidget: """Add a draggable range overlay to this panel. Parameters ---------- x0, x1 : float - Initial left and right edges in data coordinates. + The two edges of the range, in data coordinates along the + selection axis: x positions when horizontal (default), y values + when ``orientation="vertical"``. color : str, optional CSS colour string. Default ``"#00e5ff"``. style : {'band', 'fwhm'}, optional @@ -780,6 +788,16 @@ def add_range_widget(self, x0: float, x1: float, Maximum span width in data units. The span physically stops growing at this width while dragging (the dragged edge pins, the opposite edge stays put). ``None`` (default) leaves it unbounded. + orientation : {'horizontal', 'vertical'}, optional + Which axis the range selects along. ``"vertical"`` draws a band + spanning the plot width that selects a range of *values* — an + intensity window rather than a spectral one. Default + ``"horizontal"``. + snap_values : sequence of float, optional + Allowed edge positions. Each edge follows the cursor while + dragging but lands only on the nearest of these values + (matplotlib's ``SpanSelector.snap_values``). ``None`` (default) + drags continuously. _push : bool, optional Push state to JS immediately. Set to ``False`` when adding several widgets at once; call :meth:`_push` manually afterward. @@ -792,7 +810,9 @@ def add_range_widget(self, x0: float, x1: float, """ widget = _RangeWidget(lambda: None, x0=float(x0), x1=float(x1), color=color, style=style, y=float(y), - linewidth=linewidth, max_extent=max_extent) + linewidth=linewidth, max_extent=max_extent, + orientation=orientation, + snap_values=snap_values) widget._push_fn = self._make_widget_push_fn(widget) self._widgets[widget.id] = widget if _push: @@ -924,7 +944,8 @@ def set_linestyle(self, linestyle: str) -> None: ---------- linestyle : str ``"solid"`` (``"-"``), ``"dashed"`` (``"--"``), - ``"dotted"`` (``":"``), or ``"dashdot"`` (``"-."``) + ``"dotted"`` (``":"``), ``"dashdot"`` (``"-."``), or ``"none"`` + to suppress the connecting line and draw only the markers. """ self._state["line_linestyle"] = _norm_linestyle(linestyle) self._push() diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py index d9c092df..29e65cdb 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -26,7 +26,8 @@ from anyplotlib.widgets import ( Widget, RectangleWidget, CircleWidget, AnnularWidget, - CrosshairWidget, PolygonWidget, LabelWidget, ArrowWidget, + CrosshairWidget, PolygonWidget, LabelWidget, ArrowWidget, LineWidget, + VLineWidget, HLineWidget, ) from anyplotlib._utils import (_normalize_image, _build_colormap_lut, _build_tint_lut, _to_rgba_u8) @@ -270,11 +271,16 @@ def __init__(self, data, "units": units, "scale_x": scale_x, "scale_y": scale_y, + # None => the renderer's white-on-dark-pill default. + "scalebar_color": None, + "scalebar_bgcolor": None, "display_min": disp_min, "display_max": disp_max, "raw_min": raw_vmin, "raw_max": raw_vmax, "show_colorbar": False, + # None => the renderer's default 6 px image-to-strip gap. + "colorbar_pad": None, "scale_mode": "linear", "colormap_name": cmap_name, "colormap_data": cmap_lut, @@ -1557,12 +1563,54 @@ def set_colorbar_visible(self, visible: bool) -> None: self._state["show_colorbar"] = bool(visible) self._push() + def set_colorbar_pad(self, pad: float | None) -> None: + """Set the gap in px between the image and the colorbar strip. + + Parameters + ---------- + pad : float or None + Gap in CSS pixels. ``None`` restores the default (6 px). The gap + comes out of the image width, so widening it shrinks the image + rather than pushing the strip off the panel. + """ + self._state["colorbar_pad"] = None if pad is None else max(0.0, float(pad)) + self._push() + def set_aspect(self, ratio) -> None: if ratio == "equal": ratio = 1.0 self._state["aspect"] = float(ratio) if ratio is not None else None self._push() + def set_scalebar_style(self, color: str | None = None, + bgcolor: str | None = None) -> None: + """Recolour the automatic scale bar. + + The scale bar appears on its own whenever the panel has calibrated + axes (``units`` other than ``"px"``). It defaults to white on a + translucent dark pill, which reads well on most images but not on a + light one. + + Parameters + ---------- + color : str, optional + CSS colour for the bar and its label. ``None`` (default) leaves + it at white. + bgcolor : str, optional + CSS colour for the pill behind them, or the string ``"none"`` to + draw no pill at all. ``None`` (default) leaves it at the + translucent dark pill. + + Examples + -------- + >>> plot.set_scalebar_style(color="black", bgcolor="none") + """ + if color is not None: + self._state["scalebar_color"] = str(color) + if bgcolor is not None: + self._state["scalebar_bgcolor"] = str(bgcolor) + self._push() + # ------------------------------------------------------------------ # Overlay Widgets # ------------------------------------------------------------------ @@ -1571,10 +1619,13 @@ def add_widget(self, kind: str, color: str = "#00e5ff", **kwargs) -> Widget: Dispatches to the dedicated ``add__widget`` method. Supported kinds: ``"circle"``, ``"rectangle"``, ``"annular"``, - ``"polygon"``, ``"crosshair"``, ``"label"``, ``"arrow"``. + ``"polygon"``, ``"crosshair"``, ``"label"``, ``"arrow"``, ``"line"``, + ``"vline"``, ``"hline"``. Every kind also accepts ``show_handles`` (default ``True``) to toggle the grab-handle dots without changing hit-testing / draggability. + ``vline`` / ``hline`` have no handles — the whole line is the grab + target — so they ignore it. """ dispatch = { "circle": self.add_circle_widget, @@ -1584,6 +1635,9 @@ def add_widget(self, kind: str, color: str = "#00e5ff", **kwargs) -> Widget: "crosshair": self.add_crosshair_widget, "label": self.add_label_widget, "arrow": self.add_arrow_widget, + "line": self.add_line_widget, + "vline": self.add_vline_widget, + "hline": self.add_hline_widget, } key = kind.lower() if key not in dispatch: @@ -1732,6 +1786,109 @@ def add_arrow_widget(self, x: float | None = None, y: float | None = None, self._push() return widget + def add_line_widget(self, x1: float | None = None, y1: float | None = None, + x2: float | None = None, y2: float | None = None, + color: str = "#00e5ff", linewidth: float = 2, + show_handles: bool = True) -> LineWidget: + """Add a draggable two-endpoint line segment overlay. + + A bare segment with a grab handle at each end — no arrowhead (see + :meth:`add_arrow_widget`) and no closed path (see + :meth:`add_polygon_widget`). Use it for a line profile, a + cross-section cut, or a two-point measurement. + + Parameters + ---------- + x1, y1 : float, optional + First endpoint in image coordinates. Defaults to 25 % of the + image size. + x2, y2 : float, optional + Second endpoint. Defaults to 75 % of the image size. + color : str, optional + CSS colour string. Default ``"#00e5ff"``. + linewidth : float, optional + Stroke width in px. Default 2. + show_handles : bool, optional + Draw the endpoint grab handles. Default ``True``. + + Returns + ------- + LineWidget + Widget object. ``widget.length`` gives the segment length in + data coordinates. + """ + iw, ih = self._state["image_width"], self._state["image_height"] + widget = LineWidget(lambda: None, + x1=float(x1) if x1 is not None else iw * 0.25, + y1=float(y1) if y1 is not None else ih * 0.25, + x2=float(x2) if x2 is not None else iw * 0.75, + y2=float(y2) if y2 is not None else ih * 0.75, + 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_vline_widget(self, x: float | None = None, color: str = "#00e5ff", + linewidth: float = 2) -> VLineWidget: + """Add a draggable full-height vertical line overlay. + + The line spans the whole panel and is grabbable anywhere along its + length, which makes it the right pointer for "select a column" — + a crosshair pinned to one axis leaves a stray perpendicular line. + + Parameters + ---------- + x : float, optional + Initial x position in image coordinates. Defaults to the middle. + color : str, optional + CSS colour string. Default ``"#00e5ff"``. + linewidth : float, optional + Stroke width in px. Default 2. + + Returns + ------- + VLineWidget + """ + iw = self._state["image_width"] + widget = VLineWidget(lambda: None, + x=float(x) if x is not None else iw / 2, + 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 | None = None, color: str = "#00e5ff", + linewidth: float = 2) -> HLineWidget: + """Add a draggable full-width horizontal line overlay. + + The row counterpart of :meth:`add_vline_widget`; grabbable anywhere + along its length. + + Parameters + ---------- + y : float, optional + Initial y position in image coordinates. Defaults to the middle. + color : str, optional + CSS colour string. Default ``"#00e5ff"``. + linewidth : float, optional + Stroke width in px. Default 2. + + Returns + ------- + HLineWidget + """ + ih = self._state["image_height"] + widget = HLineWidget(lambda: None, + y=float(y) if y is not None else ih / 2, + color=color, linewidth=linewidth) + widget._push_fn = self._make_widget_push_fn(widget) + self._widgets[widget.id] = widget + self._push() + return widget + # ------------------------------------------------------------------ # View control # ------------------------------------------------------------------ @@ -1798,8 +1955,14 @@ def add_circles(self, offsets, name=None, *, radius=5, hover_edgecolors=None, hover_facecolors=None, labels=None, label=None, transform: str = "data", - clip_display: bool = True) -> "MarkerGroup": # noqa: F821 - """Add circle markers at (x, y) positions in data coordinates.""" + clip_display: bool = True, + size_units: str = "data") -> "MarkerGroup": # noqa: F821 + """Add circle markers at (x, y) positions in data coordinates. + + ``size_units="px"`` pins *radius* to screen pixels so the circles keep + their size through a zoom; the default ``"data"`` scales them with the + data, as a shape drawn on the image does. + """ return self._add_marker("circles", name, offsets=offsets, radius=radius, facecolors=facecolors, edgecolors=edgecolors, linewidths=linewidths, alpha=alpha, @@ -1807,7 +1970,8 @@ def add_circles(self, offsets, name=None, *, radius=5, hover_facecolors=hover_facecolors, labels=labels, label=label, transform=transform, - clip_display=clip_display) + clip_display=clip_display, + size_units=size_units) def add_points(self, offsets, name=None, *, sizes=5, color="#ff0000", facecolors=None, @@ -1815,8 +1979,14 @@ def add_points(self, offsets, name=None, *, sizes=5, hover_edgecolors=None, hover_facecolors=None, labels=None, label=None, transform: str = "data", - clip_display: bool = True) -> "MarkerGroup": # noqa: F821 - """Add point markers at (x, y) positions in data coordinates.""" + clip_display: bool = True, + size_units: str = "data") -> "MarkerGroup": # noqa: F821 + """Add point markers at (x, y) positions in data coordinates. + + A marker standing in for a *point* usually wants ``size_units="px"`` + so it does not grow with zoom — that is what matplotlib does, sizing + scatter markers in display points. + """ return self._add_marker("circles", name, offsets=offsets, radius=sizes, edgecolors=color, facecolors=facecolors, linewidths=linewidths, alpha=alpha, @@ -1824,7 +1994,8 @@ def add_points(self, offsets, name=None, *, sizes=5, hover_facecolors=hover_facecolors, labels=labels, label=label, transform=transform, - clip_display=clip_display) + clip_display=clip_display, + size_units=size_units) def add_hlines(self, y_values, name=None, *, color="#ff0000", linewidths=1.5, @@ -1873,7 +2044,8 @@ def add_ellipses(self, offsets, widths, heights, name=None, *, hover_edgecolors=None, hover_facecolors=None, labels=None, label=None, transform: str = "data", - clip_display: bool = True) -> "MarkerGroup": # noqa: F821 + clip_display: bool = True, + size_units: str = "data") -> "MarkerGroup": # noqa: F821 return self._add_marker("ellipses", name, offsets=offsets, widths=widths, heights=heights, angles=angles, facecolors=facecolors, edgecolors=edgecolors, @@ -1882,7 +2054,8 @@ def add_ellipses(self, offsets, widths, heights, name=None, *, hover_facecolors=hover_facecolors, labels=labels, label=label, transform=transform, - clip_display=clip_display) + clip_display=clip_display, + size_units=size_units) def add_lines(self, segments, name=None, *, edgecolors="#ff0000", linewidths=1.5, @@ -1903,7 +2076,8 @@ def add_rectangles(self, offsets, widths, heights, name=None, *, hover_edgecolors=None, hover_facecolors=None, labels=None, label=None, transform: str = "data", - clip_display: bool = True) -> "MarkerGroup": # noqa: F821 + clip_display: bool = True, + size_units: str = "data") -> "MarkerGroup": # noqa: F821 return self._add_marker("rectangles", name, offsets=offsets, widths=widths, heights=heights, angles=angles, facecolors=facecolors, edgecolors=edgecolors, @@ -1912,7 +2086,8 @@ def add_rectangles(self, offsets, widths, heights, name=None, *, hover_facecolors=hover_facecolors, labels=labels, label=label, transform=transform, - clip_display=clip_display) + clip_display=clip_display, + size_units=size_units) def add_squares(self, offsets, widths, name=None, *, angles=0, facecolors=None, edgecolors="#ff0000", @@ -1920,7 +2095,8 @@ def add_squares(self, offsets, widths, name=None, *, hover_edgecolors=None, hover_facecolors=None, labels=None, label=None, transform: str = "data", - clip_display: bool = True) -> "MarkerGroup": # noqa: F821 + clip_display: bool = True, + size_units: str = "data") -> "MarkerGroup": # noqa: F821 return self._add_marker("squares", name, offsets=offsets, widths=widths, angles=angles, facecolors=facecolors, edgecolors=edgecolors, @@ -1929,7 +2105,8 @@ def add_squares(self, offsets, widths, name=None, *, hover_facecolors=hover_facecolors, labels=labels, label=label, transform=transform, - clip_display=clip_display) + clip_display=clip_display, + size_units=size_units) def add_polygons(self, vertices_list, name=None, *, facecolors=None, edgecolors="#ff0000", diff --git a/anyplotlib/tests/baselines/imshow_labels.png b/anyplotlib/tests/baselines/imshow_labels.png index 64aa38a4..3ef1dd1f 100644 Binary files a/anyplotlib/tests/baselines/imshow_labels.png and b/anyplotlib/tests/baselines/imshow_labels.png differ diff --git a/anyplotlib/tests/test_embed/test_export_widget_sync.py b/anyplotlib/tests/test_embed/test_export_widget_sync.py new file mode 100644 index 00000000..aeb323b9 --- /dev/null +++ b/anyplotlib/tests/test_embed/test_export_widget_sync.py @@ -0,0 +1,92 @@ +""" +Snapshots must capture widgets where they *are*. + +``Widget.set()`` reaches JS through ``Figure._push_widget``, which writes only +the ``event_json`` trait — re-serialising a whole panel (image bytes included) +on every drag frame is the cost that path exists to avoid. The side effect was +that ``panel__json`` kept the geometry from widget-creation time, so +``save_html`` / ``to_html`` / ``figure_state`` snapshotted a widget at its +original position no matter how far it had since moved. + +``Figure._sync_for_export()`` reconciles the panel traits, and every export +path reaches it through ``_repr_utils._widget_state``. +""" +from __future__ import annotations + +import json + +import numpy as np + +import anyplotlib as apl +from anyplotlib.embed import figure_state, to_html + + +def _fig_with_widget(): + fig, ax = apl.subplots(1, 1, figsize=(300, 300)) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + widget = plot.add_widget("rectangle", x=2, y=2, w=4, h=4) + return fig, plot, widget + + +def _panel_widgets(state, plot): + return json.loads(state[f"panel_{plot._id}_json"])["overlay_widgets"] + + +class TestFigureState: + def test_moved_widget_is_current(self): + fig, plot, widget = _fig_with_widget() + widget.set(x=20, y=25) + got = _panel_widgets(figure_state(fig), plot)[0] + assert got["x"] == 20 and got["y"] == 25 + + def test_resized_widget_is_current(self): + fig, plot, widget = _fig_with_widget() + widget.set(w=17, h=19) + got = _panel_widgets(figure_state(fig), plot)[0] + assert got["w"] == 17 and got["h"] == 19 + + def test_unmoved_widget_still_captured(self): + fig, plot, widget = _fig_with_widget() + got = _panel_widgets(figure_state(fig), plot)[0] + assert got["x"] == 2 and got["y"] == 2 + + def test_removed_widget_is_gone(self): + fig, plot, widget = _fig_with_widget() + widget.remove() + assert _panel_widgets(figure_state(fig), plot) == [] + + def test_notify_false_move_is_also_captured(self): + """A silent Python move must not be silent to the snapshot.""" + fig, plot, widget = _fig_with_widget() + widget.set(_notify=False, x=13) + assert _panel_widgets(figure_state(fig), plot)[0]["x"] == 13 + + def test_every_panel_is_refreshed(self): + fig, axs = apl.subplots(1, 2, figsize=(400, 200)) + p0 = axs[0].imshow(np.zeros((16, 16), dtype=np.float32)) + p1 = axs[1].imshow(np.zeros((16, 16), dtype=np.float32)) + w0 = p0.add_widget("circle", cx=1, cy=1, r=1) + w1 = p1.add_widget("circle", cx=1, cy=1, r=1) + w0.set(cx=9) + w1.set(cx=11) + state = figure_state(fig) + assert _panel_widgets(state, p0)[0]["cx"] == 9 + assert _panel_widgets(state, p1)[0]["cx"] == 11 + + +class TestHtmlExport: + def test_moved_widget_reaches_the_html(self): + fig, plot, widget = _fig_with_widget() + widget.set(x=23.5) + html = to_html(fig) + assert "23.5" in html, "the moved position never reached the HTML" + + def test_stale_position_is_not_in_the_html(self): + """The regression: the creation-time geometry used to be what shipped.""" + fig, ax = apl.subplots(1, 1, figsize=(300, 300)) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + widget = plot.add_widget("rectangle", x=3.25, y=3.25, w=4, h=4) + widget.set(x=21.75, y=21.75) + html = to_html(fig) + assert "3.25" not in html, "the stale creation position is still in the export" + assert "21.75" in html diff --git a/anyplotlib/tests/test_interactive/test_pointer_down_1d.py b/anyplotlib/tests/test_interactive/test_pointer_down_1d.py new file mode 100644 index 00000000..c0a3e702 --- /dev/null +++ b/anyplotlib/tests/test_interactive/test_pointer_down_1d.py @@ -0,0 +1,144 @@ +""" +tests/test_interactive/test_pointer_down_1d.py +=============================================== + +Playwright tests for positional ``pointer_down`` on 1-D panels. + +2-D panels have always emitted ``pointer_down`` with the clicked position in +data coordinates. 1-D panels used to emit it *only* when the click landed +within the hit-test radius of a line, which made ``pointer_down`` mean two +different things depending on panel kind and left click-position features +(e.g. "jump the cursor where I clicked") with nothing to listen to. + +A click on a 1-D panel now always emits exactly one ``pointer_down`` carrying +``xdata``/``ydata``. ``line_id`` still rides along when a line was hit, so the +older line-click contract is unchanged. +""" +from __future__ import annotations + +import numpy as np + +import anyplotlib as apl +from anyplotlib.tests.test_interactive._event_test_utils import ( + _collect_events, + _get_events, + _plot_center_page, + GRID_PAD, + PAD_L, PAD_R, PAD_T, PAD_B, +) + +FIG_W, FIG_H = 400, 300 + + +def _make_flat_1d(interact_page, value=0.0): + """A flat line at *value* — its pixel row is easy to avoid or hit.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.plot(np.full(64, value), axes=[np.linspace(0.0, 10.0, 64)]) + page = interact_page(fig) + _collect_events(page) + return fig, plot, page + + +def _click(page, x, y): + page.mouse.click(x, y) + page.wait_for_timeout(100) + + +class TestPositionalPointerDown: + def test_click_away_from_any_line_emits_pointer_down(self, interact_page): + """The regression this fixes: an empty-area click used to emit nothing.""" + _, _, page = _make_flat_1d(interact_page) + cx, _ = _plot_center_page(FIG_W, FIG_H) + # A flat line at y=0 sits on the vertical mid-row; click well above it. + near_top = GRID_PAD + PAD_T + 20 + _click(page, cx, near_top) + events = _get_events(page, "pointer_down") + assert len(events) == 1, ( + f"a click in empty plot area must emit exactly one pointer_down, " + f"got {len(events)}" + ) + + def test_positional_event_carries_data_coords(self, interact_page): + _, _, page = _make_flat_1d(interact_page) + cx, _ = _plot_center_page(FIG_W, FIG_H) + _click(page, cx, GRID_PAD + PAD_T + 20) + ev = _get_events(page, "pointer_down")[0] + assert ev.get("xdata") is not None + assert ev.get("ydata") is not None + # Clicked the horizontal centre of a 0..10 x-axis. + assert 4.0 < ev["xdata"] < 6.0, ev["xdata"] + + def test_empty_area_click_has_no_line_id(self, interact_page): + _, _, page = _make_flat_1d(interact_page) + cx, _ = _plot_center_page(FIG_W, FIG_H) + _click(page, cx, GRID_PAD + PAD_T + 20) + ev = _get_events(page, "pointer_down")[0] + assert ev.get("line_id") is None + + def test_click_outside_plot_area_emits_nothing(self, interact_page): + """The axis gutters are not the plot; clicking there is not a position.""" + _, _, page = _make_flat_1d(interact_page) + cx, _ = _plot_center_page(FIG_W, FIG_H) + in_bottom_gutter = GRID_PAD + FIG_H - PAD_B + 15 + _click(page, cx, in_bottom_gutter) + assert _get_events(page, "pointer_down") == [] + + def test_xdata_tracks_click_x(self, interact_page): + """Two clicks at different x must report different, ordered xdata.""" + _, _, page = _make_flat_1d(interact_page) + y = GRID_PAD + PAD_T + 20 + left_x = GRID_PAD + PAD_L + 20 + right_x = GRID_PAD + FIG_W - PAD_R - 20 + _click(page, left_x, y) + _click(page, right_x, y) + events = _get_events(page, "pointer_down") + assert len(events) == 2 + assert events[0]["xdata"] < events[1]["xdata"] + + +class TestLineClickContractPreserved: + def test_click_on_overlay_still_reports_line_id(self, interact_page): + """The pre-existing line-click payload must keep working. + + Only ``add_line`` overlays carry an id — a hit on the primary line + reports ``line_id`` as ``None`` (``_lineHitTest1d`` passes ``null`` + for it), so the overlay is what pins this contract. + """ + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.plot(np.full(64, -1.0), axes=[np.linspace(0.0, 10.0, 64)]) + line = plot.add_line(np.full(64, 1.0)) + page = interact_page(fig) + _collect_events(page) + + # The overlay sits at the top of the -1..1 range and the primary at the + # bottom, but the exact pixel row depends on renderer padding — so scan + # down the upper half of the plot rather than hard-coding it. + cx, _ = _plot_center_page(FIG_W, FIG_H) + top = GRID_PAD + PAD_T + plot_h = FIG_H - PAD_T - PAD_B + for dy in range(0, plot_h // 2, 4): + _click(page, cx, top + dy) + + ids = {e.get("line_id") for e in _get_events(page, "pointer_down")} + assert line.id in ids, ( + f"clicking the overlay must report its id; saw line_ids {ids}" + ) + + def test_line_click_also_carries_data_coords(self, interact_page): + _, _, page = _make_flat_1d(interact_page) + cx, cy = _plot_center_page(FIG_W, FIG_H) + _click(page, cx, cy) + ev = _get_events(page, "pointer_down")[0] + assert ev.get("xdata") is not None + assert ev.get("ydata") is not None + + def test_drag_does_not_emit_pointer_down(self, interact_page): + """Panning is not a click.""" + _, _, page = _make_flat_1d(interact_page) + cx, cy = _plot_center_page(FIG_W, FIG_H) + page.mouse.move(cx, cy) + page.mouse.down() + page.mouse.move(cx + 60, cy, steps=10) + page.mouse.up() + page.wait_for_timeout(100) + assert _get_events(page, "pointer_down") == [] diff --git a/anyplotlib/tests/test_interactive/test_range_orientation_snap.py b/anyplotlib/tests/test_interactive/test_range_orientation_snap.py new file mode 100644 index 00000000..bbdf4e6f --- /dev/null +++ b/anyplotlib/tests/test_interactive/test_range_orientation_snap.py @@ -0,0 +1,286 @@ +""" +tests/test_interactive/test_range_orientation_snap.py +====================================================== + +Two range-widget capabilities: + +``orientation="vertical"`` + A band spanning the plot width that selects a range of *values* rather + than of x — an intensity window instead of a spectral one. ``x0``/``x1`` + stay the field names: they are the extents along the selection axis, the + same way matplotlib's ``SpanSelector.extents`` reads for either + ``direction``. + +``snap_values`` + Allowed positions. The drag follows the cursor but lands only on the + nearest allowed value (matplotlib's ``SpanSelector.snap_values``). This + has to happen inside the JS drag: snapping in Python afterwards moves an + edge the user is holding, which reads as the selection fighting back. +""" +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.widgets import RangeWidget, VLineWidget, HLineWidget +from anyplotlib.tests.test_interactive._event_test_utils import ( + _collect_events, GRID_PAD, PAD_L, PAD_R, PAD_T, PAD_B, +) + +FIG_W, FIG_H = 400, 300 +N = 128 + + +# ── helpers ─────────────────────────────────────────────────────────────────── + +def _collect_panel_state(page) -> None: + page.evaluate("""() => { + window._aplPanelState = {}; + const m = window._aplModel; + const orig = m.set.bind(m); + m.set = (k, v) => { + const mm = /^panel_(.+)_json$/.exec(k); + if (mm) { try { window._aplPanelState[mm[1]] = JSON.parse(v); } catch(_) {} } + return orig(k, v); + }; + }""") + + +def _seed_panel_state(page, plot_id) -> None: + page.evaluate( + """(pid) => { + try { + const v = window._aplModel.get('panel_' + pid + '_json'); + if (v) window._aplPanelState[pid] = JSON.parse(v); + } catch(_) {} + }""", str(plot_id)) + + +def _widget_state(page, plot_id, idx=0): + return page.evaluate( + """([pid, i]) => { + const st = window._aplPanelState && window._aplPanelState[pid]; + const ws = st && st.overlay_widgets; + return ws && ws[i] ? ws[i] : null; + }""", [str(plot_id), idx]) + + +def _plot_rect(): + """Page-coord plot rectangle (x, y, w, h).""" + return (GRID_PAD + PAD_L, GRID_PAD + PAD_T, + FIG_W - PAD_L - PAD_R, FIG_H - PAD_T - PAD_B) + + +def _drag(page, x0, y0, dx, dy): + page.mouse.move(x0, y0) + page.mouse.down() + page.mouse.move(x0 + dx, y0 + dy, steps=10) + page.mouse.up() + page.wait_for_timeout(80) + + +def _open(interact_page, add): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.plot(np.linspace(0.0, 100.0, N)) + widget = add(plot) + page = interact_page(fig) + _collect_events(page) + _collect_panel_state(page) + _seed_panel_state(page, plot._id) + return fig, plot, page, widget + + +# ═══════════════════════════════════════════════════════════════════════════ +# 1. Python API +# ═══════════════════════════════════════════════════════════════════════════ + +class TestOrientationApi: + def test_defaults_to_horizontal(self): + assert RangeWidget(lambda: None, x0=0, x1=1).orientation == "horizontal" + + def test_vertical_stored(self): + w = RangeWidget(lambda: None, x0=0, x1=1, orientation="vertical") + assert w.orientation == "vertical" + + def test_reaches_the_state_dict(self): + w = RangeWidget(lambda: None, x0=0, x1=1, orientation="vertical") + assert w.to_dict()["orientation"] == "vertical" + + def test_bad_orientation_raises(self): + with pytest.raises(ValueError, match="orientation must be"): + RangeWidget(lambda: None, x0=0, x1=1, orientation="diagonal") + + def test_vertical_fwhm_raises(self): + """The FWHM indicator is defined against the x axis only.""" + with pytest.raises(ValueError, match="fwhm"): + RangeWidget(lambda: None, x0=0, x1=1, + orientation="vertical", style="fwhm") + + def test_factory_passes_orientation(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.zeros(N)) + w = plot.add_range_widget(1, 2, orientation="vertical") + assert w.orientation == "vertical" + + +class TestSnapValuesApi: + def test_defaults_to_none(self): + assert RangeWidget(lambda: None, x0=0, x1=1).snap_values is None + + def test_empty_sequence_is_none(self): + assert RangeWidget(lambda: None, x0=0, x1=1, snap_values=[]).snap_values is None + + def test_numpy_array_becomes_a_list(self): + """A numpy array is not JSON-serialisable and would break the push.""" + w = RangeWidget(lambda: None, x0=0, x1=1, + snap_values=np.array([0.0, 1.0, 2.0])) + assert isinstance(w.snap_values, list) + assert w.snap_values == [0.0, 1.0, 2.0] + + @pytest.mark.parametrize("cls,kwargs", [ + (VLineWidget, {"x": 0}), + (HLineWidget, {"y": 0}), + ]) + def test_line_widgets_take_snap_values(self, cls, kwargs): + w = cls(lambda: None, snap_values=[1, 2, 3], **kwargs) + assert w.snap_values == [1.0, 2.0, 3.0] + + def test_state_dict_is_json_serialisable(self): + import json + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.zeros(N)) + plot.add_range_widget(1, 2, snap_values=np.arange(5.0)) + json.dumps(plot.to_state_dict()) # must not raise + + +# ═══════════════════════════════════════════════════════════════════════════ +# 2. Rendering +# ═══════════════════════════════════════════════════════════════════════════ + +class TestVerticalRendering: + def _band_mask(self, take_screenshot, **kwargs): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.plot(np.linspace(0.0, 100.0, N)) + plot.add_range_widget(color="#ff0000", **kwargs) + img = take_screenshot(fig)[..., :3].astype(int) + # The band fill is a translucent red wash over the background. + return (img[..., 0] > img[..., 2] + 20) + + def test_vertical_band_spans_the_width(self, take_screenshot): + mask = self._band_mask(take_screenshot, x0=30.0, x1=70.0, + orientation="vertical") + plot_w = FIG_W - PAD_L - PAD_R + cols = mask.any(axis=0).sum() + assert cols > plot_w * 0.8, ( + f"a vertical band must span the plot width, covered {cols} of " + f"~{plot_w} columns" + ) + + def test_horizontal_band_spans_the_height(self, take_screenshot): + mask = self._band_mask(take_screenshot, x0=30.0, x1=70.0) + plot_h = FIG_H - PAD_T - PAD_B + rows = mask.any(axis=1).sum() + assert rows > plot_h * 0.8 + + def test_orientation_changes_the_shape(self, take_screenshot): + """Sanity: the two orientations must not render the same thing.""" + horiz = self._band_mask(take_screenshot, x0=30.0, x1=70.0) + vert = self._band_mask(take_screenshot, x0=30.0, x1=70.0, + orientation="vertical") + assert horiz.any(axis=1).sum() != vert.any(axis=1).sum() + + +# ═══════════════════════════════════════════════════════════════════════════ +# 3. Dragging (real browser input) +# ═══════════════════════════════════════════════════════════════════════════ + +class TestVerticalDrag: + def test_dragging_the_band_moves_the_value_range(self, interact_page): + fig, plot, page, w = _open( + interact_page, + lambda p: p.add_range_widget(x0=30.0, x1=70.0, + orientation="vertical")) + px, py, pw, ph = _plot_rect() + before = _widget_state(page, plot._id) + # Middle of the band: the data spans 0..100, the band 30..70, so the + # band's centre value 50 sits at the vertical middle of the plot. + _drag(page, px + pw / 2, py + ph / 2, 0, -30) + after = _widget_state(page, plot._id) + assert after["x0"] > before["x0"] + 0.5, "dragging up must raise x0" + assert after["x1"] > before["x1"] + 0.5, "dragging up must raise x1" + + def test_band_width_is_preserved_by_a_translation(self, interact_page): + fig, plot, page, w = _open( + interact_page, + lambda p: p.add_range_widget(x0=30.0, x1=70.0, + orientation="vertical")) + px, py, pw, ph = _plot_rect() + before = _widget_state(page, plot._id) + _drag(page, px + pw / 2, py + ph / 2, 0, -30) + after = _widget_state(page, plot._id) + assert (after["x1"] - after["x0"]) == pytest.approx( + before["x1"] - before["x0"], abs=1e-6) + + def test_horizontal_drag_does_not_move_a_vertical_band(self, interact_page): + fig, plot, page, w = _open( + interact_page, + lambda p: p.add_range_widget(x0=30.0, x1=70.0, + orientation="vertical")) + px, py, pw, ph = _plot_rect() + before = _widget_state(page, plot._id) + _drag(page, px + pw / 2, py + ph / 2, 60, 0) + after = _widget_state(page, plot._id) + assert after["x0"] == pytest.approx(before["x0"], abs=1e-6) + + +class TestSnapDrag: + def test_edge_lands_on_an_allowed_value(self, interact_page): + snaps = [0.0, 25.0, 50.0, 75.0, 100.0] + fig, plot, page, w = _open( + interact_page, + lambda p: p.add_range_widget(x0=0.0, x1=25.0, snap_values=snaps)) + px, py, pw, ph = _plot_rect() + # The x axis is the sample index 0..N-1; grab the right edge, which sits + # at value 25 -> index 32 of 128. + edge_x = px + pw * (25.0 / (N - 1)) + _drag(page, edge_x, py + ph / 2, pw * 0.3, 0) + after = _widget_state(page, plot._id) + assert after["x1"] in snaps, ( + f"x1={after['x1']} is not one of the allowed values {snaps}" + ) + + def test_snapping_actually_moved_the_edge(self, interact_page): + """Guard against the test passing because nothing happened.""" + snaps = [0.0, 25.0, 50.0, 75.0, 100.0] + fig, plot, page, w = _open( + interact_page, + lambda p: p.add_range_widget(x0=0.0, x1=25.0, snap_values=snaps)) + px, py, pw, ph = _plot_rect() + before = _widget_state(page, plot._id) + edge_x = px + pw * (25.0 / (N - 1)) + _drag(page, edge_x, py + ph / 2, pw * 0.3, 0) + after = _widget_state(page, plot._id) + assert after["x1"] > before["x1"], "the drag missed the edge" + + def test_without_snap_values_the_edge_lands_anywhere(self, interact_page): + """Contrast case: continuous dragging is still the default.""" + fig, plot, page, w = _open( + interact_page, + lambda p: p.add_range_widget(x0=0.0, x1=25.0)) + px, py, pw, ph = _plot_rect() + edge_x = px + pw * (25.0 / (N - 1)) + _drag(page, edge_x, py + ph / 2, pw * 0.3, 0) + after = _widget_state(page, plot._id) + assert after["x1"] not in (0.0, 25.0, 50.0, 75.0, 100.0) + + def test_vline_snaps(self, interact_page): + snaps = [0.0, 32.0, 64.0, 96.0] + fig, plot, page, w = _open( + interact_page, + lambda p: p.add_vline_widget(x=32.0, snap_values=snaps)) + px, py, pw, ph = _plot_rect() + start = px + pw * (32.0 / (N - 1)) + _drag(page, start, py + ph / 2, pw * 0.2, 0) + after = _widget_state(page, plot._id) + assert after["x"] in snaps diff --git a/anyplotlib/tests/test_interactive/test_widget_set_notify.py b/anyplotlib/tests/test_interactive/test_widget_set_notify.py new file mode 100644 index 00000000..e1434647 --- /dev/null +++ b/anyplotlib/tests/test_interactive/test_widget_set_notify.py @@ -0,0 +1,110 @@ +""" +Widget.set(_notify=False) and Widget.remove(). + +``set()`` fires ``pointer_move`` so that a JS-driven drag reaches Python +handlers. A ``set()`` made *from* Python is indistinguishable from that, so a +handler that writes back to the widget feeds into itself. ``_notify=False`` +suppresses just this update's echo, without the blast radius of wrapping the +call in ``pause_events()``. +""" +from __future__ import annotations + +import numpy as np + +import anyplotlib as apl + + +def _plot_with_widget(): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((16, 16), dtype=np.float32)) + widget = plot.add_widget("rectangle", x=2, y=2, w=4, h=4) + return fig, plot, widget + + +class TestSetNotify: + def test_set_fires_by_default(self): + _, _, widget = _plot_with_widget() + seen = [] + widget.add_event_handler(lambda e: seen.append(e), "pointer_move") + widget.set(x=5) + assert len(seen) == 1 + + def test_notify_false_suppresses_callbacks(self): + _, _, widget = _plot_with_widget() + seen = [] + widget.add_event_handler(lambda e: seen.append(e), "pointer_move") + widget.set(_notify=False, x=5) + assert seen == [] + + def test_notify_false_still_updates_state(self): + _, _, widget = _plot_with_widget() + widget.set(_notify=False, x=7) + assert widget.x == 7 + + def test_notify_false_still_pushes(self): + """Suppressing the callback must not suppress the render update.""" + _, _, widget = _plot_with_widget() + pushed = [] + widget._push_fn = lambda: pushed.append(True) + widget.set(_notify=False, x=9) + assert pushed == [True] + + def test_attribute_assignment_still_notifies(self): + """widget.x = 5 keeps its existing behaviour.""" + _, _, widget = _plot_with_widget() + seen = [] + widget.add_event_handler(lambda e: seen.append(e), "pointer_move") + widget.x = 5 + assert len(seen) == 1 + + def test_notify_is_not_swallowed_as_a_property(self): + """The underscore keeps the flag from shadowing widget state.""" + _, _, widget = _plot_with_widget() + widget.set(_notify=False, x=3) + assert widget.get("_notify") is None + assert widget.get("notify") is None + + def test_write_back_handler_does_not_recurse(self): + """The motivating case: a handler that moves the widget it listens to.""" + _, _, widget = _plot_with_widget() + calls = [] + + def clamp(event): + calls.append(event) + # Without _notify=False this re-enters clamp for ever. + widget.set(_notify=False, x=min(widget.x, 10)) + + widget.add_event_handler(clamp, "pointer_move") + widget.set(x=50) + assert len(calls) == 1 + assert widget.x == 10 + + +class TestWidgetRemove: + def test_remove_detaches_from_plot(self): + _, plot, widget = _plot_with_widget() + assert widget in plot.list_widgets() + widget.remove() + assert widget not in plot.list_widgets() + + def test_remove_is_idempotent(self): + _, _, widget = _plot_with_widget() + widget.remove() + widget.remove() # must not raise + + def test_remove_after_clear_widgets(self): + _, plot, widget = _plot_with_widget() + plot.clear_widgets() + widget.remove() # must not raise + + def test_remove_on_unattached_widget(self): + from anyplotlib.widgets import RectangleWidget + + widget = RectangleWidget(lambda: None, x=0, y=0, w=1, h=1) + widget.remove() # no owning plot — no-op, not an error + + def test_matches_plot_remove_widget(self): + _, plot, widget = _plot_with_widget() + other = plot.add_widget("circle", cx=8, cy=8, r=2) + widget.remove() + assert plot.list_widgets() == [other] diff --git a/anyplotlib/tests/test_interactive/test_widgets_line_2d.py b/anyplotlib/tests/test_interactive/test_widgets_line_2d.py new file mode 100644 index 00000000..3d7a093c --- /dev/null +++ b/anyplotlib/tests/test_interactive/test_widgets_line_2d.py @@ -0,0 +1,330 @@ +""" +tests/test_interactive/test_widgets_line_2d.py +=============================================== + +Three 2-D overlay widget kinds: + +``line`` + A bare two-endpoint segment. ``arrow`` draws a head and ``polygon`` + needs >= 3 vertices and closes the path, so neither could stand in for a + line profile / cross-section cut / two-point measurement. + +``vline`` / ``hline`` + Full-height / full-width rules, grabbable anywhere along their length. + These existed on 1-D panels only; a 2-D panel had to fake a single-axis + pointer with a ``crosshair``, which leaves a stray perpendicular rule. + +Also covered: a ``crosshair`` can now be grabbed by either of its rules, not +only at the one-pixel centre hotspot. Grabbing a rule constrains the drag to +that rule's own axis. + +Drag tests aim at the real on-canvas geometry published by the draw path +(``window._aplWidgetGeom``) rather than deriving it from figure padding — the +image->canvas mapping depends on the zoom/extent state and the grab radius is +only HR=9 px, so a hard-coded guess silently misses and asserts nothing. +""" +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.widgets import LineWidget +from anyplotlib.tests.test_interactive._event_test_utils import _collect_events + +FIG_W, FIG_H = 400, 400 +IMG = 64 + + +# ── helpers ─────────────────────────────────────────────────────────────────── + +def _collect_panel_state(page) -> None: + page.evaluate("""() => { + window._aplPanelState = {}; + const m = window._aplModel; + const orig = m.set.bind(m); + m.set = (k, v) => { + const mm = /^panel_(.+)_json$/.exec(k); + if (mm) { try { window._aplPanelState[mm[1]] = JSON.parse(v); } catch(_) {} } + return orig(k, v); + }; + }""") + + +def _seed_panel_state(page, plot_id) -> None: + page.evaluate( + """(pid) => { + try { + const v = window._aplModel.get('panel_' + pid + '_json'); + if (v) window._aplPanelState[pid] = JSON.parse(v); + } catch(_) {} + }""", str(plot_id)) + + +def _widget_state(page, plot_id, idx=0): + return page.evaluate( + """([pid, i]) => { + const st = window._aplPanelState && window._aplPanelState[pid]; + const ws = st && st.overlay_widgets; + return ws && ws[i] ? ws[i] : null; + }""", [str(plot_id), idx]) + + +def _canvas_origin(page): + return page.evaluate("""() => { + const c = document.querySelector('canvas'); + const r = c.getBoundingClientRect(); + return {left: r.left, top: r.top}; + }""") + + +def _geom(page, plot_id, widget_id): + return page.evaluate( + """([pid, wid]) => { + const g = window._aplWidgetGeom && window._aplWidgetGeom[pid]; + return g ? (g[wid] || null) : null; + }""", [str(plot_id), str(widget_id)]) + + +def _open(interact_page, add): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((IMG, IMG), dtype=np.float32)) + widget = add(plot) + page = interact_page(fig) + _collect_events(page) + _collect_panel_state(page) + _seed_panel_state(page, plot._id) + return fig, plot, page, widget + + +def _drag(page, x0, y0, dx, dy): + page.mouse.move(x0, y0) + page.mouse.down() + page.mouse.move(x0 + dx, y0 + dy, steps=10) + page.mouse.up() + page.wait_for_timeout(80) + + +# ═══════════════════════════════════════════════════════════════════════════ +# 1. Python API +# ═══════════════════════════════════════════════════════════════════════════ + +class TestLineWidgetApi: + def test_stores_endpoints(self): + w = LineWidget(lambda: None, x1=1, y1=2, x2=3, y2=4) + assert (w.x1, w.y1, w.x2, w.y2) == (1.0, 2.0, 3.0, 4.0) + + def test_type_is_line(self): + assert LineWidget(lambda: None, x1=0, y1=0, x2=1, y2=1).get("type") == "line" + + def test_length(self): + w = LineWidget(lambda: None, x1=0, y1=0, x2=3, y2=4) + assert w.length == pytest.approx(5.0) + + def test_reaches_the_state_dict(self): + w = LineWidget(lambda: None, x1=0, y1=0, x2=3, y2=4) + assert w.to_dict()["x2"] == 3.0 + + def test_add_line_widget_defaults(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((IMG, IMG))) + w = plot.add_line_widget() + assert w.x1 == IMG * 0.25 and w.x2 == IMG * 0.75 + + @pytest.mark.parametrize("kind", ["line", "vline", "hline"]) + def test_add_widget_dispatch(self, kind): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((IMG, IMG))) + assert plot.add_widget(kind).get("type") == kind + + def test_vline_defaults_to_middle(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((IMG, IMG))) + assert plot.add_vline_widget().x == IMG / 2 + + def test_hline_defaults_to_middle(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((IMG, IMG))) + assert plot.add_hline_widget().y == IMG / 2 + + def test_widgets_reach_the_wire(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((IMG, IMG))) + plot.add_widget("line") + plot.add_widget("vline") + plot.add_widget("hline") + types = [w["type"] for w in plot.to_state_dict()["overlay_widgets"]] + assert types == ["line", "vline", "hline"] + + +# ═══════════════════════════════════════════════════════════════════════════ +# 2. Rendering +# ═══════════════════════════════════════════════════════════════════════════ + +def _ink(img, rgb=(255, 0, 0), tol=60): + a = img[..., :3].astype(int) + return int((np.abs(a - np.array(rgb)).sum(axis=-1) < tol).sum()) + + +class TestRendering: + def test_line_is_drawn(self, take_screenshot): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((IMG, IMG), dtype=np.float32)) + plot.add_line_widget(x1=8, y1=8, x2=56, y2=56, color="#ff0000", + show_handles=False) + assert _ink(take_screenshot(fig)) > 100 + + def test_vline_spans_more_rows_than_a_short_segment(self, take_screenshot): + """A full-height rule must be taller than a stub segment.""" + def render(add): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((IMG, IMG), dtype=np.float32)) + add(plot) + img = take_screenshot(fig)[..., :3].astype(int) + mask = np.abs(img - np.array([255, 0, 0])).sum(axis=-1) < 60 + return mask.any(axis=1).sum() # number of rows containing ink + + rule = render(lambda p: p.add_vline_widget(x=32, color="#ff0000")) + stub = render(lambda p: p.add_line_widget(x1=32, y1=30, x2=32, y2=34, + color="#ff0000", + show_handles=False)) + assert rule > stub * 3 + + def test_hline_spans_more_columns_than_a_short_segment(self, take_screenshot): + def render(add): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((IMG, IMG), dtype=np.float32)) + add(plot) + img = take_screenshot(fig)[..., :3].astype(int) + mask = np.abs(img - np.array([255, 0, 0])).sum(axis=-1) < 60 + return mask.any(axis=0).sum() # columns containing ink + + rule = render(lambda p: p.add_hline_widget(y=32, color="#ff0000")) + stub = render(lambda p: p.add_line_widget(x1=30, y1=32, x2=34, y2=32, + color="#ff0000", + show_handles=False)) + assert rule > stub * 3 + + def test_line_has_no_arrowhead(self, take_screenshot): + """The whole point of a separate kind: less ink than the arrow.""" + def render(add): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((IMG, IMG), dtype=np.float32)) + add(plot) + return _ink(take_screenshot(fig)) + + seg = render(lambda p: p.add_line_widget(x1=8, y1=8, x2=56, y2=56, + color="#ff0000", + show_handles=False)) + arw = render(lambda p: p.add_arrow_widget(x=8, y=8, u=48, v=48, + color="#ff0000", + show_handles=False)) + assert seg < arw, "the segment must not draw an arrowhead" + + +# ═══════════════════════════════════════════════════════════════════════════ +# 3. Dragging (real browser input) +# ═══════════════════════════════════════════════════════════════════════════ + +class TestLineDrag: + def test_endpoint_handle_moves_only_that_end(self, interact_page): + fig, plot, page, w = _open( + interact_page, + lambda p: p.add_line_widget(x1=16, y1=16, x2=48, y2=48)) + g = _geom(page, plot._id, w.id) + assert g is not None, "line geometry readback missing" + o = _canvas_origin(page) + before = _widget_state(page, plot._id) + + _drag(page, o["left"] + g["ax"], o["top"] + g["ay"], 40, 0) + + after = _widget_state(page, plot._id) + assert after["x1"] > before["x1"] + 0.5, "p1 did not move" + assert after["x2"] == pytest.approx(before["x2"], abs=1e-6), "p2 moved" + assert after["y2"] == pytest.approx(before["y2"], abs=1e-6), "p2 moved" + + def test_shaft_drag_translates_both_ends(self, interact_page): + fig, plot, page, w = _open( + interact_page, + lambda p: p.add_line_widget(x1=16, y1=16, x2=48, y2=48)) + g = _geom(page, plot._id, w.id) + o = _canvas_origin(page) + before = _widget_state(page, plot._id) + + mid_x = o["left"] + (g["ax"] + g["bx"]) / 2 + mid_y = o["top"] + (g["ay"] + g["by"]) / 2 + _drag(page, mid_x, mid_y, 30, 0) + + after = _widget_state(page, plot._id) + d1 = after["x1"] - before["x1"] + d2 = after["x2"] - before["x2"] + assert d1 > 0.5, "the segment did not translate" + assert d1 == pytest.approx(d2, abs=1e-6), "ends moved by different amounts" + + +class TestRuleDrag: + def test_vline_grabbable_away_from_centre(self, interact_page): + """The whole rule is the grab target, not just a hotspot.""" + fig, plot, page, w = _open( + interact_page, lambda p: p.add_vline_widget(x=32)) + o = _canvas_origin(page) + before = _widget_state(page, plot._id) + # Aim near the top of the panel, far from the vertical middle. + cx = o["left"] + _canvas_size(page)["w"] * 0.5 + _drag(page, cx, o["top"] + 20, 40, 0) + after = _widget_state(page, plot._id) + assert after["x"] > before["x"] + 0.5 + + def test_vline_ignores_vertical_drag(self, interact_page): + fig, plot, page, w = _open( + interact_page, lambda p: p.add_vline_widget(x=32)) + o = _canvas_origin(page) + before = _widget_state(page, plot._id) + cx = o["left"] + _canvas_size(page)["w"] * 0.5 + _drag(page, cx, o["top"] + 20, 0, 40) + after = _widget_state(page, plot._id) + assert after["x"] == pytest.approx(before["x"], abs=1e-6) + + def test_hline_grabbable_away_from_centre(self, interact_page): + fig, plot, page, w = _open( + interact_page, lambda p: p.add_hline_widget(y=32)) + o = _canvas_origin(page) + before = _widget_state(page, plot._id) + cy = o["top"] + _canvas_size(page)["h"] * 0.5 + _drag(page, o["left"] + 20, cy, 0, 40) + after = _widget_state(page, plot._id) + assert after["y"] > before["y"] + 0.5 + + +class TestCrosshairRuleGrab: + def test_vertical_rule_grab_moves_x_only(self, interact_page): + fig, plot, page, w = _open( + interact_page, lambda p: p.add_crosshair_widget(cx=32, cy=32)) + o = _canvas_origin(page) + before = _widget_state(page, plot._id) + # On the vertical rule but well above the centre. + cx = o["left"] + _canvas_size(page)["w"] * 0.5 + _drag(page, cx, o["top"] + 20, 40, 25) + after = _widget_state(page, plot._id) + assert after["cx"] > before["cx"] + 0.5, "grabbing the rule did nothing" + assert after["cy"] == pytest.approx(before["cy"], abs=1e-6), \ + "a vertical-rule drag must not move cy" + + def test_centre_still_moves_both(self, interact_page): + fig, plot, page, w = _open( + interact_page, lambda p: p.add_crosshair_widget(cx=32, cy=32)) + o = _canvas_origin(page) + size = _canvas_size(page) + before = _widget_state(page, plot._id) + _drag(page, o["left"] + size["w"] * 0.5, o["top"] + size["h"] * 0.5, 30, 30) + after = _widget_state(page, plot._id) + assert after["cx"] > before["cx"] + 0.5 + assert after["cy"] > before["cy"] + 0.5 + + +def _canvas_size(page): + return page.evaluate("""() => { + const c = document.querySelector('canvas'); + const r = c.getBoundingClientRect(); + return {w: r.width, h: r.height}; + }""") diff --git a/anyplotlib/tests/test_labels/test_ylabel_clearance.py b/anyplotlib/tests/test_labels/test_ylabel_clearance.py new file mode 100644 index 00000000..c67e7b30 --- /dev/null +++ b/anyplotlib/tests/test_labels/test_ylabel_clearance.py @@ -0,0 +1,129 @@ +""" +The 1-D y-axis label must not be drawn through the tick numbers. + +The label's x was a fixed ``PAD_L * 0.28``, with a comment asserting that +cleared the tick numbers "regardless of how wide those numbers are". It did +not: at the default tick size a string like ``-5.6e-17`` reaches back past +that column, and the rotated label was drawn straight through it. + +The label is now placed relative to the widest tick string, so these tests +compare where the label's ink lands with where the ticks' ink lands. Both are +drawn into the same gutter, so they are separated by rendering each alone. + +Not fixed here: a tick string wide enough to fill the gutter on its own (8 +characters, e.g. ``-5.6e-17``) leaves nowhere clear to put the label. See +``test_extreme_ticks_push_the_label_to_the_edge``. +""" +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl + +FIG_W, FIG_H = 400, 300 +PAD_L = 58 +GRID_PAD = 8 + +WIDE_TICKS = np.linspace(0.0, 100000.0, 64) # -> "100000" (6 chars) +NARROW_TICKS = np.linspace(0.0, 1.0, 64) # -> "0.2" +EXTREME_TICKS = np.linspace(-5.6e-17, 5.6e-17, 64) # -> "-5.6e-17" (8 chars) + + +def _gutter_ink_per_column(take_screenshot, data, label=None): + """Ink count per column of the left axis gutter.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.plot(data) + if label is not None: + plot.set_ylabel(label) + img = take_screenshot(fig)[..., :3].astype(int) + gutter = img[:, GRID_PAD:GRID_PAD + PAD_L] + bg = img[2, 2] + ink = np.abs(gutter - bg).sum(axis=-1) > 40 + return ink.sum(axis=0) + + +def _label_columns(take_screenshot, data, label="Intensity"): + """Columns whose ink is contributed by the label, not the ticks.""" + without = _gutter_ink_per_column(take_screenshot, data) + with_label = _gutter_ink_per_column(take_screenshot, data, label) + extra = with_label - without + return {i for i, v in enumerate(extra) if v > 0}, without + + +class TestNoOverlap: + def test_wide_ticks_do_not_collide_with_the_label(self, take_screenshot): + label_cols, tick_ink = _label_columns(take_screenshot, WIDE_TICKS) + tick_cols = {i for i, v in enumerate(tick_ink) if v > 0} + clash = label_cols & tick_cols + assert not clash, ( + f"label and tick numbers share columns {sorted(clash)} — " + "the label is drawn through the ticks" + ) + + def test_narrow_ticks_do_not_collide_either(self, take_screenshot): + label_cols, tick_ink = _label_columns(take_screenshot, NARROW_TICKS) + tick_cols = {i for i, v in enumerate(tick_ink) if v > 0} + assert not (label_cols & tick_cols) + + def test_label_is_actually_drawn(self, take_screenshot): + """Guard: the tests above would pass trivially on a missing label.""" + label_cols, _ = _label_columns(take_screenshot, WIDE_TICKS) + assert label_cols, "no label ink found at all" + + def test_label_stays_on_canvas(self, take_screenshot): + """Shifting left must stop at the canvas edge, not run off it.""" + label_cols, _ = _label_columns(take_screenshot, EXTREME_TICKS) + assert min(label_cols) >= 0 + assert max(label_cols) < PAD_L + + def test_extreme_ticks_push_the_label_to_the_edge(self, take_screenshot): + """A gutter too narrow for both is not something placement can fix. + + ``-5.6e-17`` is 8 characters; at the default tick size it spans almost + the whole 58 px gutter, leaving no clear column for a rotated label. + The label goes as far left as it can and overlaps only the leading + characters — better than the fixed mid-gutter position, which struck + through the middle of every number — but eliminating the overlap needs + a wider gutter, and PAD_L is shared across panels to keep their plot + areas aligned. That is a layout policy decision, not a placement one. + """ + label_cols, tick_ink = _label_columns(take_screenshot, EXTREME_TICKS) + assert min(label_cols) <= 6, ( + "with no room to spare the label should sit at the far left edge" + ) + + def test_wide_ticks_push_the_label_left(self, take_screenshot): + """The mechanism, not just the outcome.""" + wide, _ = _label_columns(take_screenshot, WIDE_TICKS) + narrow, _ = _label_columns(take_screenshot, NARROW_TICKS) + assert min(wide) <= min(narrow), ( + f"wide ticks should move the label left or leave it " + f"(wide starts at {min(wide)}, narrow at {min(narrow)})" + ) + + +class TestLogScale: + def test_log_ticks_do_not_collide(self, take_screenshot): + """The log branch measures its own tick width too.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.plot(np.logspace(-12, 3, 64)) + plot.set_yscale("log") + img_no = take_screenshot(fig)[..., :3].astype(int) + + fig2, ax2 = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot2 = ax2.plot(np.logspace(-12, 3, 64)) + plot2.set_yscale("log") + plot2.set_ylabel("Counts") + img_yes = take_screenshot(fig2)[..., :3].astype(int) + + def cols(img): + gutter = img[:, GRID_PAD:GRID_PAD + PAD_L] + bg = img[2, 2] + return (np.abs(gutter - bg).sum(axis=-1) > 40).sum(axis=0) + + without, with_label = cols(img_no), cols(img_yes) + label_cols = {i for i, v in enumerate(with_label - without) if v > 0} + tick_cols = {i for i, v in enumerate(without) if v > 0} + assert label_cols, "no label ink found on the log panel" + assert not (label_cols & tick_cols) diff --git a/anyplotlib/tests/test_layouts/test_coord_conversion.py b/anyplotlib/tests/test_layouts/test_coord_conversion.py new file mode 100644 index 00000000..c48297c7 --- /dev/null +++ b/anyplotlib/tests/test_layouts/test_coord_conversion.py @@ -0,0 +1,181 @@ +""" +plot_box() / data_to_display() / display_to_data(). + +Anything doing display-space work — pixel-sized handles, hit-test tolerances, +screenshot-driven tests — previously had to re-derive the renderer's layout +(the ``PAD_*`` constants and the ``_imgFitRect`` letterbox math) in its own +code, and then keep it in step with ``figure_esm.js`` by hand. + +The important test here is :class:`TestAgreesWithTheRenderer`: it draws a +marker at a known data coordinate, finds it in a real screenshot, and checks +the Python answer against where the browser actually put it. Without that, +these methods could be self-consistently wrong. +""" +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl + +FIG_W, FIG_H = 400, 300 +GRID_PAD = 8 # figure_esm.js gridDiv padding, present in every screenshot + + +def _line_plot(n=50): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + return fig, ax.plot(np.linspace(0.0, 1.0, n)) + + +class TestPlotBox: + def test_matches_the_padding_constants(self): + _, plot = _line_plot() + box = plot.plot_box() + left, right, top, bottom = plot.PLOT_PADDING + assert box["x"] == left + assert box["y"] == top + assert box["width"] == FIG_W - left - right + assert box["height"] == FIG_H - top - bottom + + def test_image_box_is_letterboxed(self): + """Data coords map onto the fitted image, not the full padded area.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 400)) + plot = ax.imshow(np.zeros((10, 20), dtype=np.float32)) + box = plot.plot_box() + # A 20x10 image is twice as wide as tall: it fills the width and is + # centred vertically. + assert box["width"] == pytest.approx(box["height"] * 2.0) + assert box["y"] > plot.PLOT_PADDING[2] + + def test_square_image_is_pillarboxed(self): + """A square image in a wide panel is centred horizontally. + + This one has no physical axes, so the renderer drops the left/right + gutters entirely and the image fills the panel width before fitting — + hence the box starts left of PLOT_PADDING, not right of it. + """ + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + box = plot.plot_box() + assert box["width"] == pytest.approx(box["height"]) + assert box["x"] > 0 + assert box["x"] < plot.PLOT_PADDING[0] + + def test_axes_reinstate_the_gutters(self): + """With physical axes the padded layout applies again.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + x = np.linspace(0.0, 10.0, 32) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32), + axes=[x, x], units="nm") + assert plot.plot_box()["x"] >= plot.PLOT_PADDING[0] + + def test_colorbar_narrows_the_box(self): + """The strip and its gap come out of the image width. + + The image is deliberately WIDE so width is the binding constraint — + a square image in this panel is height-limited, and narrowing the + available width would not change its fitted size at all. + """ + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((8, 64), dtype=np.float32), + axes=[np.linspace(0.0, 10.0, 64), + np.linspace(0.0, 1.0, 8)], units="nm") + before = plot.plot_box()["width"] + plot.set_colorbar_visible(True) + assert plot.plot_box()["width"] < before + + def test_unattached_panel_raises(self): + from anyplotlib.plot1d import Plot1D + + plot = Plot1D(np.zeros(8)) + with pytest.raises(RuntimeError, match="not attached"): + plot.plot_box() + + +class TestConversions: + def test_round_trip(self): + _, plot = _line_plot() + pts = [[5.0, 0.2], [25.0, 0.5], [40.0, 0.9]] + back = plot.display_to_data(plot.data_to_display(pts)) + np.testing.assert_allclose(back, pts, atol=1e-9) + + def test_single_point_shape_is_preserved(self): + _, plot = _line_plot() + out = plot.data_to_display([25.0, 0.5]) + assert out.shape == (2,) + + def test_sequence_shape_is_preserved(self): + _, plot = _line_plot() + out = plot.data_to_display([[1.0, 0.1], [2.0, 0.2]]) + assert out.shape == (2, 2) + + def test_bad_shape_raises(self): + _, plot = _line_plot() + with pytest.raises(ValueError, match=r"\(N, 2\)"): + plot.data_to_display([[1.0, 2.0, 3.0]]) + + def test_x_increases_rightwards(self): + _, plot = _line_plot() + lo = plot.data_to_display([5.0, 0.5]) + hi = plot.data_to_display([40.0, 0.5]) + assert hi[0] > lo[0] + + def test_y_increases_upwards_in_data_downwards_on_screen(self): + _, plot = _line_plot() + lo = plot.data_to_display([25.0, 0.1]) + hi = plot.data_to_display([25.0, 0.9]) + assert hi[1] < lo[1], "a larger data y must map to a smaller screen y" + + def test_left_edge_maps_to_the_box_edge(self): + _, plot = _line_plot() + x0, _ = plot.get_xlim() + box = plot.plot_box() + assert plot.data_to_display([x0, 0.0])[0] == pytest.approx(box["x"]) + + +class TestAgreesWithTheRenderer: + """The Python geometry must match what the browser draws. + + A marker is placed at a known data coordinate; its centre of mass in the + screenshot is compared with ``data_to_display``. Marker offsets go + through the renderer's own transform, so any drift between the Python + mirror and ``figure_esm.js`` shows up here. + """ + + def _marker_centre(self, take_screenshot, fig, rgb=(255, 0, 0)): + img = take_screenshot(fig)[..., :3].astype(int) + hit = np.abs(img - np.array(rgb)).sum(axis=-1) < 60 + assert hit.any(), "marker not found in the screenshot" + ys, xs = np.where(hit) + return xs.mean(), ys.mean() + + def test_1d_vline_marker_lands_where_predicted(self, take_screenshot): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.plot(np.linspace(0.0, 1.0, 50)) + # A vlines marker is positioned by x alone, so its column is exactly + # what data_to_display's x should predict. + plot.add_vlines([25.0], color="#ff0000", linewidths=2) + cx, _ = self._marker_centre(take_screenshot, fig) + want_x = plot.data_to_display([25.0, 0.5])[0] + GRID_PAD + assert cx == pytest.approx(want_x, abs=3.0), ( + f"renderer drew the marker at x={cx:.1f}, " + f"data_to_display predicted {want_x:.1f}" + ) + + def test_2d_circle_marker_lands_where_predicted(self, take_screenshot): + fig, ax = apl.subplots(1, 1, figsize=(400, 400)) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + plot.add_circles(offsets=[[8.0, 24.0]], radius=2, + edgecolors="#ff0000", facecolors="#ff0000") + cx, cy = self._marker_centre(take_screenshot, fig) + want = plot.data_to_display([8.0, 24.0]) + assert cx == pytest.approx(want[0] + GRID_PAD, abs=4.0) + assert cy == pytest.approx(want[1] + GRID_PAD, abs=4.0) + + def test_prediction_is_not_trivially_the_centre(self, take_screenshot): + """Guard: an off-centre point must be predicted off-centre.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 400)) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + box = plot.plot_box() + want = plot.data_to_display([8.0, 24.0]) + assert abs(want[0] - (box["x"] + box["width"] / 2)) > 20 diff --git a/anyplotlib/tests/test_markers/test_per_marker_colors.py b/anyplotlib/tests/test_markers/test_per_marker_colors.py new file mode 100644 index 00000000..bebc76ba --- /dev/null +++ b/anyplotlib/tests/test_markers/test_per_marker_colors.py @@ -0,0 +1,188 @@ +""" +Per-marker edge/face colours (matplotlib ``edgecolors=[...]`` / scatter ``c=``). + +``edgecolors`` and ``facecolors`` may be a sequence parallel to the markers +instead of one colour for the whole group. ``points`` and ``polygons`` on 1-D +panels already honoured this; every other type painted the whole group in the +first colour (or, on 2-D panels, in whatever the canvas made of being handed +an array as a ``strokeStyle``). + +Canvas contents can't be read back as geometry, so these tests count pixels of +each requested colour in the rendered screenshot: a group asked for red, green +and blue markers must actually put all three on the canvas. +""" +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl + +RGB = { + "#ff0000": (255, 0, 0), + "#00ff00": (0, 255, 0), + "#0000ff": (0, 0, 255), +} +COLORS = list(RGB) + + +def _count(img: np.ndarray, rgb: tuple[int, int, int], tol: int = 60) -> int: + """Pixels within *tol* of an exact RGB triple.""" + a = img[..., :3].astype(int) + return int((np.abs(a - np.array(rgb)).sum(axis=-1) < tol).sum()) + + +def _colors_present(img: np.ndarray) -> set[str]: + return {c for c in COLORS if _count(img, RGB[c]) > 0} + + +def _image_fig(add, **kwargs): + """A 2-D panel with one marker group added by *add*.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 400)) + plot = ax.imshow(np.zeros((40, 40), dtype=np.float32)) + add(plot, **kwargs) + return fig + + +def _line_fig(add, **kwargs): + """A 1-D panel with one marker group added by *add*.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 400)) + plot = ax.plot(np.zeros(40)) + add(plot, **kwargs) + return fig + + +# Three well-separated positions on a 40x40 image. +OFFSETS = [[8, 8], [20, 20], [32, 32]] + + +# ══════════════════════════════════════════════════════════════════════════════ +# 2-D panels +# ══════════════════════════════════════════════════════════════════════════════ + +class TestPerMarkerEdgeColors2D: + """Every 2-D marker type must paint each marker its own edge colour.""" + + @pytest.mark.parametrize("kind,extra", [ + ("circles", {"radius": 4}), + ("ellipses", {"widths": 8, "heights": 5}), + ("rectangles", {"widths": 8, "heights": 5}), + ("squares", {"widths": 8}), + ("arrows", {"U": 6, "V": 6}), + ]) + def test_all_three_colors_rendered(self, take_screenshot, kind, extra): + fig = _image_fig( + lambda p, **k: p.markers.add(kind, offsets=OFFSETS, + edgecolors=COLORS, linewidths=3, **k), + **extra, + ) + present = _colors_present(take_screenshot(fig)) + assert present == set(COLORS), ( + f"{kind}: expected all of {COLORS} on the canvas, got {sorted(present)}" + ) + + def test_lines_segments_take_their_own_colors(self, take_screenshot): + segs = [[[4, 4], [36, 4]], [[4, 20], [36, 20]], [[4, 36], [36, 36]]] + fig = _image_fig( + lambda p: p.markers.add("lines", segments=segs, + edgecolors=COLORS, linewidths=3) + ) + assert _colors_present(take_screenshot(fig)) == set(COLORS) + + def test_polygons_take_their_own_colors(self, take_screenshot): + polys = [ + [[2, 2], [12, 2], [12, 12]], + [[14, 14], [26, 14], [26, 26]], + [[28, 28], [38, 28], [38, 38]], + ] + fig = _image_fig( + lambda p: p.markers.add("polygons", vertices_list=polys, + edgecolors=COLORS, linewidths=3) + ) + assert _colors_present(take_screenshot(fig)) == set(COLORS) + + def test_texts_take_their_own_colors(self, take_screenshot): + fig = _image_fig( + lambda p: p.markers.add("texts", offsets=OFFSETS, + texts=["AAA", "BBB", "CCC"], + edgecolors=COLORS, fontsize=20) + ) + assert _colors_present(take_screenshot(fig)) == set(COLORS) + + def test_per_marker_facecolors(self, take_screenshot): + """Fills are per-marker too, not just edges.""" + fig = _image_fig( + lambda p: p.markers.add("circles", offsets=OFFSETS, radius=5, + edgecolors="#ffffff", facecolors=COLORS, + alpha=1.0) + ) + assert _colors_present(take_screenshot(fig)) == set(COLORS) + + def test_shorter_sequence_cycles(self, take_screenshot): + """A 2-colour sequence over 3 markers cycles, as matplotlib does.""" + fig = _image_fig( + lambda p: p.markers.add("circles", offsets=OFFSETS, radius=4, + edgecolors=COLORS[:2], linewidths=3) + ) + img = take_screenshot(fig) + # Two markers red (indices 0 and 2), one green. + assert _count(img, RGB["#ff0000"]) > _count(img, RGB["#00ff00"]) + assert _count(img, RGB["#0000ff"]) == 0 + + +class TestScalarColorUnchanged2D: + """A single colour must keep painting the whole group — no regression.""" + + def test_single_edgecolor_applies_to_all(self, take_screenshot): + fig = _image_fig( + lambda p: p.markers.add("circles", offsets=OFFSETS, radius=4, + edgecolors="#ff0000", linewidths=3) + ) + present = _colors_present(take_screenshot(fig)) + assert present == {"#ff0000"} + + +# ══════════════════════════════════════════════════════════════════════════════ +# 1-D panels +# ══════════════════════════════════════════════════════════════════════════════ + +class TestPerMarkerColors1D: + def test_vlines_take_their_own_colors(self, take_screenshot): + fig = _line_fig( + lambda p: p.markers.add("vlines", offsets=[[8], [20], [32]], + edgecolors=COLORS, linewidths=3) + ) + assert _colors_present(take_screenshot(fig)) == set(COLORS) + + def test_hlines_take_their_own_colors(self, take_screenshot): + fig, ax = apl.subplots(1, 1, figsize=(400, 400)) + plot = ax.plot(np.linspace(-1.0, 1.0, 40)) + plot.markers.add("hlines", offsets=[[-0.5], [0.0], [0.5]], + edgecolors=COLORS, linewidths=3) + assert _colors_present(take_screenshot(fig)) == set(COLORS) + + def test_points_take_their_own_colors(self, take_screenshot): + """Already supported before this change — kept as a regression guard.""" + fig = _line_fig( + lambda p: p.markers.add("points", offsets=[[8], [20], [32]], + sizes=6, edgecolors=COLORS, linewidths=3) + ) + assert _colors_present(take_screenshot(fig)) == set(COLORS) + + +# ══════════════════════════════════════════════════════════════════════════════ +# Wire format +# ══════════════════════════════════════════════════════════════════════════════ + +class TestWireFormat: + def test_color_sequence_survives_to_wire(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((10, 10))) + g = plot.markers.add("circles", offsets=OFFSETS, edgecolors=COLORS) + assert g.to_wire("gid")["color"] == COLORS + + def test_facecolor_sequence_survives_to_wire(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((10, 10))) + g = plot.markers.add("circles", offsets=OFFSETS, facecolors=COLORS) + assert g.to_wire("gid")["fill_color"] == COLORS diff --git a/anyplotlib/tests/test_markers/test_size_units.py b/anyplotlib/tests/test_markers/test_size_units.py new file mode 100644 index 00000000..ee041379 --- /dev/null +++ b/anyplotlib/tests/test_markers/test_size_units.py @@ -0,0 +1,110 @@ +""" +``size_units="px"`` — marker sizes that do not grow with zoom. + +Marker radii and widths were always in data units, so a marker standing in for +a *point* (a detected peak, a cursor) swelled as the user zoomed in. That is +right for a shape drawn *on* the data and wrong for a glyph marking a +position — matplotlib sizes scatter markers in display points for the same +reason. + +``size_units`` is independent of ``transform``: positions can be in data +coordinates while sizes are in pixels. +""" +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl + +FIG = 400 +IMG = 32 + + +def _fig_with(**kwargs): + fig, ax = apl.subplots(1, 1, figsize=(FIG, FIG)) + plot = ax.imshow(np.zeros((IMG, IMG), dtype=np.float32)) + plot.add_circles([[16.0, 16.0]], radius=3, edgecolors="#ff0000", + facecolors="#ff0000", **kwargs) + return fig, plot + + +def _marker_px(take_screenshot, fig): + img = take_screenshot(fig)[..., :3].astype(int) + return int((np.abs(img - np.array([255, 0, 0])).sum(axis=-1) < 60).sum()) + + +class TestWireFormat: + def test_px_reaches_the_wire(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((IMG, IMG))) + g = plot.add_circles([[5, 5]], radius=4, size_units="px") + assert g.to_wire("g")["size_units"] == "px" + + def test_default_is_data(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((IMG, IMG))) + g = plot.add_circles([[5, 5]], radius=4) + assert g.to_wire("g")["size_units"] == "data" + + def test_registry_path_may_omit_it(self): + """A group added straight through the registry need not set it; + the renderer treats absent as 'data'.""" + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((IMG, IMG))) + g = plot.markers.add("circles", offsets=[[5, 5]], radius=4) + assert "size_units" not in g.to_wire("g") + + def test_invalid_value_raises(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((IMG, IMG))) + with pytest.raises(ValueError, match="size_units must be"): + plot.add_circles([[5, 5]], radius=4, size_units="inches").to_wire("g") + + @pytest.mark.parametrize("factory,kwargs", [ + ("add_circles", {"radius": 3}), + ("add_points", {"sizes": 3}), + ("add_ellipses", {"widths": 3, "heights": 2}), + ("add_rectangles", {"widths": 3, "heights": 2}), + ("add_squares", {"widths": 3}), + ]) + def test_every_sized_factory_accepts_it(self, factory, kwargs): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((IMG, IMG))) + g = getattr(plot, factory)([[5, 5]], size_units="px", **kwargs) + assert g.to_wire("g")["size_units"] == "px" + + +class TestRendering: + # Zoom in on the middle quarter, where the marker sits. + ZOOM_VIEW = dict(x0=8.0, x1=24.0, y0=8.0, y1=24.0) + + def test_data_sized_markers_grow_with_zoom(self, take_screenshot): + """The existing behaviour, as a contrast case. + + The count is of the *outline* — the fill is translucent and does not + match a saturated red — so it grows with the circumference, linearly + in the radius, not with the area. + """ + fig, plot = _fig_with() + before = _marker_px(take_screenshot, fig) + fig2, plot2 = _fig_with() + plot2.set_view(**self.ZOOM_VIEW) + after = _marker_px(take_screenshot, fig2) + assert after > before * 1.5, ( + f"data-sized markers must scale with zoom ({before} -> {after} px)" + ) + + def test_px_sized_markers_keep_their_size(self, take_screenshot): + fig, plot = _fig_with(size_units="px") + before = _marker_px(take_screenshot, fig) + fig2, plot2 = _fig_with(size_units="px") + plot2.set_view(**self.ZOOM_VIEW) + after = _marker_px(take_screenshot, fig2) + assert after == pytest.approx(before, rel=0.25), ( + f"px-sized markers must not scale with zoom ({before} -> {after} px)" + ) + + def test_px_marker_is_still_drawn(self, take_screenshot): + fig, plot = _fig_with(size_units="px") + assert _marker_px(take_screenshot, fig) > 0 diff --git a/anyplotlib/tests/test_plot1d/test_line_none_rendering.py b/anyplotlib/tests/test_plot1d/test_line_none_rendering.py new file mode 100644 index 00000000..c1186e7d --- /dev/null +++ b/anyplotlib/tests/test_plot1d/test_line_none_rendering.py @@ -0,0 +1,118 @@ +""" +Playwright tests for ``linestyle="none"`` and ``linewidth=0`` on Plot1D. + +Strategy +-------- +Both mean "draw no connecting line". Canvas contents cannot be inspected as +geometry, so these tests assert on *ink*: a markers-only series must leave +far fewer coloured pixels than the same data drawn as a solid line, while +still leaving some (the markers themselves). + +The data is a ramp, so a connecting line would sweep across the whole plot +area — a markers-only version of the same series is unambiguously sparser. +""" +from __future__ import annotations + +import numpy as np + +import anyplotlib as apl + +PAD_L, PAD_R, PAD_T, PAD_B = 58, 12, 12, 42 + +# The series colour, chosen well away from both themes' backgrounds and grid. +LINE_COLOR = "#ff0000" + + +# ── helpers ─────────────────────────────────────────────────────────────────── + +def _plot_area(img: np.ndarray) -> np.ndarray: + """Return just the plot rectangle, excluding the axis gutters.""" + return img[PAD_T:-PAD_B, PAD_L:-PAD_R, :3].astype(int) + + +def _red_ink(img: np.ndarray) -> int: + """Count pixels in the plot area that are predominantly the line colour.""" + area = _plot_area(img) + r, g, b = area[..., 0], area[..., 1], area[..., 2] + return int(((r > 120) & (g < 100) & (b < 100)).sum()) + + +def _render(take_screenshot, **plot_kwargs) -> int: + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.plot(np.linspace(0.0, 1.0, 24), color=LINE_COLOR, **plot_kwargs) + return _red_ink(take_screenshot(fig)) + + +# ══════════════════════════════════════════════════════════════════════════════ + + +class TestLinestyleNone: + def test_none_draws_less_ink_than_solid(self, take_screenshot): + solid = _render(take_screenshot, linestyle="solid", marker="o") + none = _render(take_screenshot, linestyle="none", marker="o") + assert none < solid, ( + f"linestyle='none' must drop the connecting stroke " + f"(got {none} px vs {solid} px for solid)" + ) + + def test_ink_dropped_is_the_full_span(self, take_screenshot): + """The ink lost to ``none`` is a stroke crossing the whole plot area. + + The series is a monotonic ramp, so its connecting line spans the full + width of the plot rectangle. Anything less than that would mean part + of the stroke survived. + """ + solid = _render(take_screenshot, linestyle="solid", marker="o") + none = _render(take_screenshot, linestyle="none", marker="o") + plot_width = 400 - PAD_L - PAD_R + assert solid - none > plot_width * 0.5, ( + f"expected to lose a full-width stroke, lost only {solid - none} px " + f"across a {plot_width} px wide plot area" + ) + + def test_none_still_draws_markers(self, take_screenshot): + """Markers-only is not the same as invisible.""" + none = _render(take_screenshot, linestyle="none", marker="o", markersize=6) + assert none > 0, "markers must still be drawn when linestyle='none'" + + def test_none_without_markers_draws_nothing(self, take_screenshot): + blank = _render(take_screenshot, linestyle="none", marker="none") + assert blank == 0, ( + f"linestyle='none' with no marker must draw nothing, got {blank} px" + ) + + +class TestZeroLinewidth: + def test_zero_linewidth_suppresses_stroke(self, take_screenshot): + """A 0 width must not fall back to the 1.5 default in the renderer.""" + thin = _render(take_screenshot, linewidth=0, marker="none") + assert thin == 0, f"linewidth=0 must draw no stroke, got {thin} px" + + def test_zero_linewidth_keeps_markers(self, take_screenshot): + pts = _render(take_screenshot, linewidth=0, marker="o", markersize=6) + assert pts > 0, "markers must survive linewidth=0" + + +class TestOverlayLineNone: + """The same rules apply to add_line() overlays, not just the primary line.""" + + def _overlay_ink(self, take_screenshot, **line_kwargs) -> int: + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.plot(np.zeros(24), color="#222222", linewidth=0.5) + plot.add_line(np.linspace(0.0, 1.0, 24), color=LINE_COLOR, **line_kwargs) + return _red_ink(take_screenshot(fig)) + + def test_overlay_none_draws_less_than_solid(self, take_screenshot): + solid = self._overlay_ink(take_screenshot, linestyle="solid", marker="o") + none = self._overlay_ink(take_screenshot, linestyle="none", marker="o") + assert none < solid, ( + f"overlay linestyle='none' must drop the stroke " + f"(got {none} px vs {solid} px)" + ) + + def test_overlay_none_with_no_marker_draws_nothing(self, take_screenshot): + assert self._overlay_ink(take_screenshot, linestyle="none", + marker="none") == 0 + + def test_overlay_zero_linewidth_suppresses_stroke(self, take_screenshot): + assert self._overlay_ink(take_screenshot, linewidth=0, marker="none") == 0 diff --git a/anyplotlib/tests/test_plot1d/test_plot1d.py b/anyplotlib/tests/test_plot1d/test_plot1d.py index 4ba530f8..aff6224b 100644 --- a/anyplotlib/tests/test_plot1d/test_plot1d.py +++ b/anyplotlib/tests/test_plot1d/test_plot1d.py @@ -82,6 +82,27 @@ def test_invalid_empty_raises(self): with pytest.raises(ValueError): _norm_linestyle("") + @pytest.mark.parametrize("spelling", ["none", "None"]) + def test_none_means_no_connecting_line(self, spelling): + """matplotlib's markers-only idiom: linestyle="None", marker="o".""" + assert _norm_linestyle(spelling) == "none" + + def test_set_linestyle_none_accepted(self): + p = _plot_lin() + p.set_linestyle("None") + assert p._state["line_linestyle"] == "none" + + def test_plot_with_linestyle_none_and_marker(self): + p = _plot_lin(linestyle="None", marker="o", markersize=3) + assert p._state["line_linestyle"] == "none" + assert p._state["line_marker"] == "o" + + def test_zero_linewidth_is_preserved(self): + """0 must survive to the wire — the renderer treats it as no stroke.""" + p = _plot_lin(linewidth=0) + assert p._state["line_linewidth"] == 0.0 + assert p.to_state_dict()["line_linewidth"] == 0.0 + # =========================================================================== # Default state values diff --git a/anyplotlib/tests/test_plot2d/test_colorbar_gap_scalebar.py b/anyplotlib/tests/test_plot2d/test_colorbar_gap_scalebar.py new file mode 100644 index 00000000..0fe33779 --- /dev/null +++ b/anyplotlib/tests/test_plot2d/test_colorbar_gap_scalebar.py @@ -0,0 +1,200 @@ +""" +Colorbar gap and scale-bar colours. + +The colorbar strip used to be drawn 2 px from the image edge, and most plots +have no ``colorbar_label`` — so the label gutter that would otherwise separate +them is zero-width and the strip reads as part of the image. There is now a +real gap, taken out of the image width so the strip can never be pushed off +the panel, and configurable per panel. + +The automatic scale bar was hardcoded white-on-a-dark-pill, which is unreadable +over a light image and cannot be matched to a house style. +""" +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl + +FIG_W, FIG_H = 400, 300 + + +def _img_fig(**calls): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + for name, arg in calls.items(): + getattr(plot, name)(arg) + return fig, plot + + +class TestColorbarPadApi: + def test_defaults_to_none(self): + _, plot = _img_fig() + assert plot._state["colorbar_pad"] is None + + def test_setter_stores_value(self): + _, plot = _img_fig() + plot.set_colorbar_pad(12) + assert plot._state["colorbar_pad"] == 12.0 + + def test_none_restores_default(self): + _, plot = _img_fig() + plot.set_colorbar_pad(12) + plot.set_colorbar_pad(None) + assert plot._state["colorbar_pad"] is None + + def test_negative_is_clamped(self): + _, plot = _img_fig() + plot.set_colorbar_pad(-5) + assert plot._state["colorbar_pad"] == 0.0 + + def test_reaches_the_state_dict(self): + _, plot = _img_fig() + plot.set_colorbar_pad(9) + assert plot.to_state_dict()["colorbar_pad"] == 9.0 + + +class TestColorbarGapRendering: + """The gap is measured on the rendered canvas, not asserted in Python.""" + + def _strip_left_edge(self, take_screenshot, pad=None): + """Return the x of the leftmost colorbar-strip pixel, and of the image's + right edge, from a rendered screenshot.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + # A bright uniform image so the image area is easy to segment. + plot = ax.imshow(np.ones((32, 32), dtype=np.float32), cmap="gray") + plot.set_colorbar_visible(True) + if pad is not None: + plot.set_colorbar_pad(pad) + img = take_screenshot(fig)[..., :3].astype(int) + row = img[img.shape[0] // 2] + # Non-background pixels along the middle row. + bg = img[2, 2] + ink = np.where(np.abs(row - bg).sum(axis=-1) > 30)[0] + assert ink.size, "nothing rendered" + # The image block is the long run; the strip is the run after the gap. + gaps = np.where(np.diff(ink) > 1)[0] + assert gaps.size, "image and colorbar strip are not separated" + return int(ink[gaps[-1]]), int(ink[gaps[-1] + 1]) + + def test_there_is_a_gap_at_all(self, take_screenshot): + img_right, strip_left = self._strip_left_edge(take_screenshot) + assert strip_left - img_right >= 4, ( + f"expected a visible gap, image ends at {img_right} and the strip " + f"starts at {strip_left}" + ) + + def test_pad_widens_the_gap(self, take_screenshot): + d_default = np.subtract(*reversed(self._strip_left_edge(take_screenshot))) + d_wide = np.subtract(*reversed(self._strip_left_edge(take_screenshot, pad=20))) + assert d_wide > d_default + 5 + + def test_strip_stays_inside_the_panel(self, take_screenshot): + """The gap comes out of the image width, so a big pad must not push + the strip off the right edge.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.ones((32, 32), dtype=np.float32), cmap="gray") + plot.set_colorbar_visible(True) + plot.set_colorbar_pad(40) + img = take_screenshot(fig)[..., :3].astype(int) + row = img[img.shape[0] // 2] + bg = img[2, 2] + ink = np.where(np.abs(row - bg).sum(axis=-1) > 30)[0] + assert ink.size, "the colorbar strip was pushed off the panel" + assert ink[-1] < img.shape[1] - 1 + + +class TestScalebarStyleApi: + def test_defaults_are_none(self): + _, plot = _img_fig() + assert plot._state["scalebar_color"] is None + assert plot._state["scalebar_bgcolor"] is None + + def test_sets_color(self): + _, plot = _img_fig() + plot.set_scalebar_style(color="black") + assert plot._state["scalebar_color"] == "black" + + def test_sets_bgcolor(self): + _, plot = _img_fig() + plot.set_scalebar_style(bgcolor="none") + assert plot._state["scalebar_bgcolor"] == "none" + + def test_partial_update_leaves_the_other(self): + _, plot = _img_fig() + plot.set_scalebar_style(color="red", bgcolor="white") + plot.set_scalebar_style(color="blue") + assert plot._state["scalebar_bgcolor"] == "white" + + def test_reaches_the_state_dict(self): + _, plot = _img_fig() + plot.set_scalebar_style(color="#123456") + assert plot.to_state_dict()["scalebar_color"] == "#123456" + + +class TestScalebarRendering: + # The scale bar sits 12 px in from the image's bottom-right corner. For a + # 400x300 figure with physical axes the image spans x 66..396, y 20..266 in + # screenshot coords (GRID_PAD=8 plus PAD_L/PAD_T), so this box contains the + # bar and nothing else. + SB_BOX = (slice(200, 266), slice(280, 396)) + + @staticmethod + def _white_image_fig(**style): + """A figure whose image renders uniformly WHITE under the scale bar. + + The pill is ``rgba(0,0,0,0.60)`` — translucent, so it can only be + measured against what is underneath. Explicit ``vmin``/``vmax`` are + what make the image white: passing a uniform array instead would + normalise vmin == vmax straight to black, and passing a ramp leaves + mid-grey data in the box that is indistinguishable from the pill. + """ + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + data = np.full((32, 32), 1.0, dtype=np.float32) + plot = ax.imshow(data, cmap="gray", vmin=0.0, vmax=1.0, + axes=[np.linspace(0, 100, 32)] * 2, units="nm") + if style: + plot.set_scalebar_style(**style) + return fig + + def _scalebar_pixels(self, take_screenshot, rgb, **style): + """Count pixels of a colour in the scale-bar box.""" + img = take_screenshot(self._white_image_fig(**style))[..., :3].astype(int) + box = img[self.SB_BOX[0], self.SB_BOX[1]] + return int((np.abs(box - np.array(rgb)).sum(axis=-1) < 40).sum()) + + def _pill_pixels(self, take_screenshot, **style): + """Count pill pixels: neutral grey, clearly darker than the white data. + + Over white the translucent pill renders mid-grey (~102 per channel), + never black. Requiring the three channels to match excludes a + coloured bar; the upper sum bound excludes the white image. + """ + img = take_screenshot(self._white_image_fig(**style))[..., :3].astype(int) + box = img[self.SB_BOX[0], self.SB_BOX[1]] + neutral = (box.max(axis=-1) - box.min(axis=-1)) < 12 + midtone = (box.sum(axis=-1) > 100) & (box.sum(axis=-1) < 600) + return int((neutral & midtone).sum()) + + def test_default_draws_a_pill(self, take_screenshot): + """Baseline for the tests below.""" + assert self._pill_pixels(take_screenshot) > 100 + + def test_color_recolours_the_bar(self, take_screenshot): + red = self._scalebar_pixels(take_screenshot, (255, 0, 0), color="#ff0000") + assert red > 0, "scalebar_color did not reach the rendered bar" + + def test_default_bar_is_not_red(self, take_screenshot): + """Contrast case, so the test above cannot pass by accident.""" + assert self._scalebar_pixels(take_screenshot, (255, 0, 0)) == 0 + + def test_bgcolor_none_drops_the_pill(self, take_screenshot): + """The dark pill is the thing that looks wrong on a light image.""" + with_pill = self._pill_pixels(take_screenshot) + without = self._pill_pixels(take_screenshot, color="#ff0000", + bgcolor="none") + assert without < with_pill * 0.2, ( + f"bgcolor='none' must remove the pill " + f"({without} pill px vs {with_pill} with it)" + ) diff --git a/anyplotlib/widgets/__init__.py b/anyplotlib/widgets/__init__.py index e61a0a82..1eeee94a 100644 --- a/anyplotlib/widgets/__init__.py +++ b/anyplotlib/widgets/__init__.py @@ -2,7 +2,7 @@ from anyplotlib.widgets._base import Widget from anyplotlib.widgets._widgets2d import ( RectangleWidget, CircleWidget, AnnularWidget, - CrosshairWidget, PolygonWidget, LabelWidget, ArrowWidget, + CrosshairWidget, PolygonWidget, LabelWidget, ArrowWidget, LineWidget, ) from anyplotlib.widgets._widgets1d import ( VLineWidget, HLineWidget, RangeWidget, PointWidget, @@ -13,6 +13,7 @@ "Widget", "RectangleWidget", "CircleWidget", "AnnularWidget", "CrosshairWidget", "PolygonWidget", "LabelWidget", "ArrowWidget", + "LineWidget", "VLineWidget", "HLineWidget", "RangeWidget", "PointWidget", "PlaneWidget", ] diff --git a/anyplotlib/widgets/_base.py b/anyplotlib/widgets/_base.py index e73f2301..32c0451e 100644 --- a/anyplotlib/widgets/_base.py +++ b/anyplotlib/widgets/_base.py @@ -39,6 +39,10 @@ class Widget(_EventMixin): - ``"pointer_down"`` — fires on click/press event """ + # Set by _PanelMixin._make_widget_push_fn when the widget is added to a + # plot; stays None for a widget constructed standalone (e.g. in a test). + _plot = None + def __init__(self, wtype: str, push_fn: Callable, **kwargs): self._id: str = str(_uuid.uuid4())[:8] self._type: str = wtype @@ -80,7 +84,7 @@ def __setattr__(self, key: str, value) -> None: # ── set / get ───────────────────────────────────────────────────── - def set(self, _push: bool = True, **kwargs) -> None: + def set(self, _push: bool = True, _notify: bool = True, **kwargs) -> None: """Update properties and send targeted update to JavaScript. Parameters @@ -88,18 +92,36 @@ def set(self, _push: bool = True, **kwargs) -> None: _push : bool, optional Whether to push update to renderer. Default True. Set to False internally to avoid echo loops. + _notify : bool, optional + Whether to fire ``pointer_move`` callbacks. Default True. + + A ``set()`` from Python is otherwise indistinguishable from a user + drag: handlers that react to the widget moving will run, and one + that writes back to the widget feeds into itself. Pass + ``_notify=False`` when *you* are the one moving the widget and the + handlers are only meant to hear about user input:: + + widget.set(_notify=False, x=new_x) + + This supersedes wrapping the call in :meth:`pause_events`, which + suppresses every event type for the duration rather than just this + update's echo. **kwargs : dict Properties to update (e.g., x=100, y=50, radius=20). Notes ----- + Both flags are spelled with a leading underscore so they can never + collide with a widget property of the same name in ``**kwargs``. + Updates are sent as targeted widget updates, not full panel re-renders. This is more efficient for frequent updates during dragging. """ self._data.update(kwargs) if _push: self._push_fn() - self.callbacks.fire(Event("pointer_move", source=self)) + if _notify: + self.callbacks.fire(Event("pointer_move", source=self)) def get(self, key: str, default=None): """Get a widget property by name. @@ -153,6 +175,26 @@ def hide(self) -> None: self._data["visible"] = False self._push_fn() + # ── removal ─────────────────────────────────────────────────────────── + + def remove(self) -> None: + """Remove this widget from the plot that owns it. + + Equivalent to ``plot.remove_widget(widget)``, but callable when you + only hold the widget — handle-based APIs otherwise have to re-derive + the owning plot to delete something they already have. + + Removing a widget that is not attached to a plot, or removing twice, + is a no-op. + """ + plot = self._plot + if plot is None: + return + try: + plot.remove_widget(self._id) + except KeyError: + pass # already removed (e.g. via clear_widgets) + # ── JS → Python sync ────────────────────────────────────────────── def _update_from_js(self, msg: dict, event_type: str = "pointer_move") -> bool: diff --git a/anyplotlib/widgets/_widgets1d.py b/anyplotlib/widgets/_widgets1d.py index 7916dd04..ad3bb256 100644 --- a/anyplotlib/widgets/_widgets1d.py +++ b/anyplotlib/widgets/_widgets1d.py @@ -2,11 +2,34 @@ widgets/_widgets1d.py ===================== Interactive overlay widgets for 1-D line panels (Plot1D). + +``VLineWidget`` and ``HLineWidget`` are also used by ``Plot2D``, where they +draw a full-height / full-width rule over the image. """ from __future__ import annotations from anyplotlib.widgets._base import Widget +SNAP_DOC = """snap_values : sequence of float, optional + Allowed positions. While dragging, the widget follows the cursor but + lands only on the nearest of these values — matplotlib's + ``SpanSelector.snap_values``. ``None`` (default) drags continuously. + Set it later with ``widget.snap_values = [...]``.""" + + +def _norm_snap_values(values): + """Validate snap_values and return a plain list of floats (or None). + + A list is what crosses the wire, so numpy arrays have to be converted + here — they are not JSON-serialisable and would break the panel push. + """ + if values is None: + return None + out = [float(v) for v in values] + if not out: + return None + return out + class VLineWidget(Widget): """Draggable vertical line overlay widget for 1-D plots. @@ -25,9 +48,11 @@ class VLineWidget(Widget): linewidth : float, optional Line stroke width in px. Default 2. """ - def __init__(self, push_fn, *, x, color="#00e5ff", linewidth=2): + def __init__(self, push_fn, *, x, color="#00e5ff", linewidth=2, + snap_values=None): super().__init__("vline", push_fn, x=float(x), color=color, - linewidth=float(linewidth)) + linewidth=float(linewidth), + snap_values=_norm_snap_values(snap_values)) class HLineWidget(Widget): @@ -47,9 +72,11 @@ class HLineWidget(Widget): linewidth : float, optional Line stroke width in px. Default 2. """ - def __init__(self, push_fn, *, y, color="#00e5ff", linewidth=2): + def __init__(self, push_fn, *, y, color="#00e5ff", linewidth=2, + snap_values=None): super().__init__("hline", push_fn, y=float(y), color=color, - linewidth=float(linewidth)) + linewidth=float(linewidth), + snap_values=_norm_snap_values(snap_values)) class RangeWidget(Widget): @@ -68,19 +95,28 @@ class RangeWidget(Widget): handles are draggable. Use this to show/edit a FWHM interval on a peak. + With ``orientation='vertical'`` the band spans the plot width and selects + a range on the *value* axis instead — for picking an intensity window + rather than a spectral one. + Parameters ---------- push_fn : Callable Update callback. x0, x1 : float - Initial left and right positions in data coordinates. + The two edges of the range, in data coordinates **along the selection + axis**: x positions when horizontal, y values when vertical. The + names do not change with orientation, mirroring how matplotlib's + ``SpanSelector.extents`` is read the same way for either ``direction``. color : str, optional CSS colour. Default ``"#00e5ff"``. style : {'band', 'fwhm'}, optional - Visual style. Default ``"band"``. + Visual style. Default ``"band"``. ``'fwhm'`` is horizontal-only. y : float, optional Y-position (data coordinates) for the connecting line when ``style='fwhm'``. Ignored for ``style='band'``. Default ``0.0``. + orientation : {'horizontal', 'vertical'}, optional + Which axis the range selects along. Default ``"horizontal"``. linewidth : float, optional Line stroke width in px. Default 2. max_extent : float, optional @@ -93,14 +129,35 @@ class RangeWidget(Widget): integrating selector where the width is a number of frames to read. Enforcing it in the widget makes the limit visible (the edge simply stops) instead of applying a silent clamp after the fact. + snap_values : sequence of float, optional + Allowed edge positions. While dragging, each edge follows the cursor + but lands only on the nearest of these values — matplotlib's + ``SpanSelector.snap_values``. ``None`` (default) drags continuously. + Set it later with ``widget.snap_values = [...]``. + + Raises + ------ + ValueError + If *orientation* is not ``'horizontal'`` or ``'vertical'``, or if + ``style='fwhm'`` is combined with a vertical orientation. """ def __init__(self, push_fn, *, x0, x1, color="#00e5ff", style: str = "band", y: float = 0.0, linewidth=2, - max_extent=None): + max_extent=None, orientation: str = "horizontal", + snap_values=None): + if orientation not in ("horizontal", "vertical"): + raise ValueError( + f"orientation must be 'horizontal' or 'vertical', " + f"got {orientation!r}" + ) + if orientation == "vertical" and style == "fwhm": + raise ValueError("style='fwhm' is only defined for a horizontal range") super().__init__("range", push_fn, x0=float(x0), x1=float(x1), color=color, style=str(style), y=float(y), linewidth=float(linewidth), + orientation=str(orientation), + snap_values=_norm_snap_values(snap_values), max_extent=(None if max_extent is None else float(max_extent))) diff --git a/anyplotlib/widgets/_widgets2d.py b/anyplotlib/widgets/_widgets2d.py index 47e99180..5818fa1b 100644 --- a/anyplotlib/widgets/_widgets2d.py +++ b/anyplotlib/widgets/_widgets2d.py @@ -118,6 +118,46 @@ def __init__(self, push_fn, *, cx, cy, r_outer, r_inner, color="#00e5ff", show_handles=bool(show_handles)) +class LineWidget(Widget): + """Draggable two-endpoint line segment overlay widget for 2-D plots. + + A plain segment from ``(x1, y1)`` to ``(x2, y2)``: drag either endpoint + handle to move that end, or drag the shaft to translate the whole + segment. Unlike :class:`ArrowWidget` it has no head, and unlike + :class:`PolygonWidget` it does not close the path — this is the widget for + a line profile, a cross-section cut, or a two-point measurement. + + Parameters + ---------- + push_fn : Callable + Update callback. + x1, y1 : float + First endpoint in pixel/data coordinates. + x2, y2 : float + Second endpoint in pixel/data coordinates. + color : str, optional + CSS colour for the segment. Default ``"#00e5ff"``. + linewidth : float, optional + Stroke width in px. Default 2. + show_handles : bool, optional + Draw the endpoint grab handles. Default ``True``. + """ + def __init__(self, push_fn, *, x1, y1, x2, y2, color="#00e5ff", + linewidth=2, show_handles=True): + super().__init__("line", push_fn, + x1=float(x1), y1=float(y1), + x2=float(x2), y2=float(y2), + color=color, linewidth=float(linewidth), + show_handles=bool(show_handles)) + + @property + def length(self) -> float: + """Euclidean length of the segment in data coordinates.""" + return float( + ((self.x2 - self.x1) ** 2 + (self.y2 - self.y1) ** 2) ** 0.5 + ) + + class CrosshairWidget(Widget): """Draggable crosshair overlay widget for 2-D plots. diff --git a/upcoming_changes/+colorbar-gap.bugfix.rst b/upcoming_changes/+colorbar-gap.bugfix.rst new file mode 100644 index 00000000..5315b874 --- /dev/null +++ b/upcoming_changes/+colorbar-gap.bugfix.rst @@ -0,0 +1,4 @@ +The colorbar strip is no longer drawn flush against the image: there is now a +6 px gap, taken out of the image width so the strip cannot be pushed off the +panel, and settable with :meth:`~anyplotlib.Plot2D.set_colorbar_pad`. This +shifts every colorbar plot by 4 px. diff --git a/upcoming_changes/+coord-api.new_feature.rst b/upcoming_changes/+coord-api.new_feature.rst new file mode 100644 index 00000000..d7fab970 --- /dev/null +++ b/upcoming_changes/+coord-api.new_feature.rst @@ -0,0 +1,5 @@ +Panels expose their geometry through :meth:`~anyplotlib.Plot1D.plot_box`, +:meth:`~anyplotlib.Plot1D.data_to_display` and +:meth:`~anyplotlib.Plot1D.display_to_data`, so callers working in display space +no longer have to re-derive the renderer's layout constants and letterbox maths +themselves. diff --git a/upcoming_changes/+crosshair-grab.bugfix.rst b/upcoming_changes/+crosshair-grab.bugfix.rst new file mode 100644 index 00000000..d157f0dc --- /dev/null +++ b/upcoming_changes/+crosshair-grab.bugfix.rst @@ -0,0 +1,3 @@ +A ``crosshair`` widget can now be grabbed anywhere along either of its rules +rather than only at the one-pixel centre hotspot; grabbing a rule constrains +the drag to that rule's own axis. diff --git a/upcoming_changes/+export-widget-sync.bugfix.rst b/upcoming_changes/+export-widget-sync.bugfix.rst new file mode 100644 index 00000000..047500ee --- /dev/null +++ b/upcoming_changes/+export-widget-sync.bugfix.rst @@ -0,0 +1,4 @@ +``save_html`` / ``to_html`` / ``figure_state`` now capture overlay widgets at +their current positions; widget moves reach JS as targeted events that never +rewrite the panel traits, so a snapshot used to show every widget where it was +created. diff --git a/upcoming_changes/+line-rule-widgets.new_feature.rst b/upcoming_changes/+line-rule-widgets.new_feature.rst new file mode 100644 index 00000000..b5144937 --- /dev/null +++ b/upcoming_changes/+line-rule-widgets.new_feature.rst @@ -0,0 +1,4 @@ +Added three 2-D overlay widget kinds: ``line`` +(:meth:`~anyplotlib.Plot2D.add_line_widget`), a bare two-endpoint segment for +line profiles and two-point measurements, and ``vline`` / ``hline``, full-height +and full-width rules grabbable anywhere along their length. diff --git a/upcoming_changes/+linestyle-none.new_feature.rst b/upcoming_changes/+linestyle-none.new_feature.rst new file mode 100644 index 00000000..20f02f8b --- /dev/null +++ b/upcoming_changes/+linestyle-none.new_feature.rst @@ -0,0 +1,5 @@ +Added ``linestyle="none"`` (also spelled ``"None"``) for a series drawn as +markers with no connecting line — matplotlib's scatter idiom, +``ax.plot(y, linestyle="none", marker="o")``. An explicit ``linewidth=0`` +now means the same thing; it previously fell back to the 1.5 default in the +renderer. diff --git a/upcoming_changes/+marker-size-units.new_feature.rst b/upcoming_changes/+marker-size-units.new_feature.rst new file mode 100644 index 00000000..b9cfb1d4 --- /dev/null +++ b/upcoming_changes/+marker-size-units.new_feature.rst @@ -0,0 +1,4 @@ +Sized marker types take ``size_units="px"`` so their radii and widths stay +fixed in screen pixels through a zoom instead of scaling with the data — what +a marker standing in for a *point* wants, and what matplotlib does by sizing +scatter markers in display points. diff --git a/upcoming_changes/+per-marker-colors.new_feature.rst b/upcoming_changes/+per-marker-colors.new_feature.rst new file mode 100644 index 00000000..14db1043 --- /dev/null +++ b/upcoming_changes/+per-marker-colors.new_feature.rst @@ -0,0 +1,4 @@ +``edgecolors`` and ``facecolors`` accept a sequence of colours parallel to the +markers — matplotlib's ``edgecolors=[...]`` / scatter ``c=[...]`` — for every +marker type on both 1-D and 2-D panels, where previously only ``points`` and +``polygons`` on 1-D panels honoured it. A short sequence cycles. diff --git a/upcoming_changes/+pointer-down-1d.new_feature.rst b/upcoming_changes/+pointer-down-1d.new_feature.rst new file mode 100644 index 00000000..a1d01b4b --- /dev/null +++ b/upcoming_changes/+pointer-down-1d.new_feature.rst @@ -0,0 +1,4 @@ +Clicking a 1-D panel now emits a ``pointer_down`` event carrying the clicked +position as ``xdata``/``ydata``, matching 2-D panels; it previously fired only +when the click landed on a line. Clicks on a line still report ``line_id``, +so existing line-click handlers are unaffected. diff --git a/upcoming_changes/+range-orientation-snap.new_feature.rst b/upcoming_changes/+range-orientation-snap.new_feature.rst new file mode 100644 index 00000000..33e255a0 --- /dev/null +++ b/upcoming_changes/+range-orientation-snap.new_feature.rst @@ -0,0 +1,5 @@ +:meth:`~anyplotlib.Plot1D.add_range_widget` takes ``orientation="vertical"`` +for a band that selects a range of values, and ``snap_values`` to restrict a +drag to a set of allowed positions (matplotlib's +``SpanSelector.snap_values``). ``snap_values`` is also available on the +vline, hline and point widgets. diff --git a/upcoming_changes/+scalebar-style.new_feature.rst b/upcoming_changes/+scalebar-style.new_feature.rst new file mode 100644 index 00000000..36dcc41f --- /dev/null +++ b/upcoming_changes/+scalebar-style.new_feature.rst @@ -0,0 +1,3 @@ +Added :meth:`~anyplotlib.Plot2D.set_scalebar_style` to recolour the automatic +scale bar, which was hardcoded white on a translucent dark pill and unreadable +over a light image. ``bgcolor="none"`` drops the pill entirely. diff --git a/upcoming_changes/+widget-notify-remove.new_feature.rst b/upcoming_changes/+widget-notify-remove.new_feature.rst new file mode 100644 index 00000000..15677d2e --- /dev/null +++ b/upcoming_changes/+widget-notify-remove.new_feature.rst @@ -0,0 +1,4 @@ +:meth:`Widget.set` takes ``_notify=False`` to move a widget without firing +``pointer_move`` callbacks, so a handler that writes back to its own widget no +longer feeds into itself. Widgets also gained a +:meth:`~anyplotlib.widgets.Widget.remove` method. diff --git a/upcoming_changes/+ylabel-clearance.bugfix.rst b/upcoming_changes/+ylabel-clearance.bugfix.rst new file mode 100644 index 00000000..13b58bfc --- /dev/null +++ b/upcoming_changes/+ylabel-clearance.bugfix.rst @@ -0,0 +1,3 @@ +The 1-D y-axis label is no longer drawn through the tick numbers; its position +was a fixed fraction of the left gutter and is now measured against the widest +tick string.