From 959002ad75d33d09fc2cdf376b28cb780b0d5a87 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 20 May 2026 18:01:54 -0500 Subject: [PATCH 01/11] Refactor: Add transform parameter to marker functions for coordinate flexibility --- anyplotlib/figure_esm.js | 65 +++++++++++++++++++++++++---------- anyplotlib/markers.py | 16 +++++++++ anyplotlib/plot1d/_plot1d.py | 66 ++++++++++++++++++++++++------------ anyplotlib/plot2d/_plot2d.py | 66 ++++++++++++++++++++++++------------ 4 files changed, 151 insertions(+), 62 deletions(-) diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index a77874a6..0a651cc4 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -1375,13 +1375,28 @@ function render({ model, el }) { 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'; + + // Coordinate transform dispatch: "data" (default), "axes", "display". + // For non-data transforms sizes are in pixels, not scaled by zoom. + const tfm = ms.transform || 'data'; + let _tc; + if(tfm==='axes'){ + const fr=_imgFitRect(st.image_width,st.image_height,imgW,imgH); + _tc=(fx,fy)=>[fr.x+fx*fr.w, fr.y+(1-fy)*fr.h]; + } else if(tfm==='display'){ + _tc=(ix,iy)=>[ix,iy]; + } else { + _tc=(ix,iy)=>_imgToCanvas2d(ix,iy,st,imgW,imgH); + } + const scl = tfm==='data' ? scale : 1; + mkCtx.save(); mkCtx.strokeStyle=ec; mkCtx.fillStyle=ec; mkCtx.lineWidth=dlw; if(type==='circles'){ for(let i=0;i[r.x+fx*r.w, r.y+(1-fy)*r.h]; + } else if(tfm==='display'){ + _tc2d=(ix,iy)=>[ix,iy]; + } else { + _tc2d=(off0,off1)=>_offToCanvas([off0,off1]); + } + mkCtx.save();mkCtx.strokeStyle=ec;mkCtx.fillStyle=ec;mkCtx.lineWidth=dlw; if(type==='points'){ for(let i=0;i list: return arr.tolist() +_VALID_TRANSFORMS = frozenset({"data", "axes", "display"}) + + def _offsets_1d(offsets) -> list: """Accept (N,), (N,1) or (N,2) — return (N,1) or (N,2) list.""" arr = np.asarray(offsets, dtype=float) @@ -94,6 +97,11 @@ class MarkerGroup: def __init__(self, marker_type: str, name: str, kwargs: dict, push_fn): self._type = marker_type self._name = name + tfm = kwargs.get("transform", "data") + if tfm not in _VALID_TRANSFORMS: + raise ValueError( + f"transform must be one of {sorted(_VALID_TRANSFORMS)}, got {tfm!r}" + ) self._data: dict = dict(kwargs) self._push_fn = push_fn @@ -107,6 +115,11 @@ def set(self, **kwargs) -> None: Properties to update (e.g., offsets, radius, facecolors). Matplotlib-style names are translated to wire format. """ + if "transform" in kwargs and kwargs["transform"] not in _VALID_TRANSFORMS: + raise ValueError( + f"transform must be one of {sorted(_VALID_TRANSFORMS)}, " + f"got {kwargs['transform']!r}" + ) self._data.update(kwargs) self._push_fn() @@ -322,6 +335,9 @@ def to_wire(self, group_id: str) -> dict: else: raise ValueError(f"Unknown marker type: {t!r}") + # ── coordinate transform (always emitted; defaults to "data") ────── + wire["transform"] = d.get("transform", "data") + # ── 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 259e4327..30c01bb3 100644 --- a/anyplotlib/plot1d/_plot1d.py +++ b/anyplotlib/plot1d/_plot1d.py @@ -852,7 +852,8 @@ def add_circles(self, offsets, name=None, *, radius=5, facecolors=None, edgecolors="#ff0000", linewidths=1.5, alpha=0.3, hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 """Add circle markers at explicit (x, y) positions. On 1-D panels circles are rendered as filled/stroked discs; *radius* @@ -893,13 +894,15 @@ def add_circles(self, offsets, name=None, *, radius=5, linewidths=linewidths, alpha=alpha, hover_edgecolors=hover_edgecolors, hover_facecolors=hover_facecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def add_points(self, offsets, name=None, *, sizes=5, color="#ff0000", facecolors=None, linewidths=1.5, alpha=0.3, hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 """Add point markers at (x, y) positions in data coordinates. Parameters @@ -934,12 +937,14 @@ def add_points(self, offsets, name=None, *, sizes=5, linewidths=linewidths, alpha=alpha, hover_edgecolors=hover_edgecolors, hover_facecolors=hover_facecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def add_hlines(self, y_values, name=None, *, color="#ff0000", linewidths=1.5, hover_edgecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 """Add static horizontal lines spanning the full x range. Parameters @@ -966,12 +971,14 @@ def add_hlines(self, y_values, name=None, *, return self._add_marker("hlines", name, offsets=y_values, color=color, linewidths=linewidths, hover_edgecolors=hover_edgecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def add_vlines(self, x_values, name=None, *, color="#ff0000", linewidths=1.5, hover_edgecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 """Add static vertical lines spanning the full y range. Parameters @@ -998,12 +1005,14 @@ def add_vlines(self, x_values, name=None, *, return self._add_marker("vlines", name, offsets=x_values, color=color, linewidths=linewidths, hover_edgecolors=hover_edgecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def add_arrows(self, offsets, U, V, name=None, *, edgecolors="#ff0000", linewidths=1.5, hover_edgecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 """Add arrow markers at explicit (x, y) positions. Parameters @@ -1032,13 +1041,15 @@ def add_arrows(self, offsets, U, V, name=None, *, return self._add_marker("arrows", name, offsets=offsets, U=U, V=V, edgecolors=edgecolors, linewidths=linewidths, hover_edgecolors=hover_edgecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def add_ellipses(self, offsets, widths, heights, name=None, *, angles=0, facecolors=None, edgecolors="#ff0000", linewidths=1.5, alpha=0.3, hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 """Add ellipse markers at explicit (x, y) positions. Parameters @@ -1076,12 +1087,14 @@ def add_ellipses(self, offsets, widths, heights, name=None, *, linewidths=linewidths, alpha=alpha, hover_edgecolors=hover_edgecolors, hover_facecolors=hover_facecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def add_lines(self, segments, name=None, *, edgecolors="#ff0000", linewidths=1.5, hover_edgecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 """Add line-segment markers (static, not draggable). Parameters @@ -1108,13 +1121,15 @@ def add_lines(self, segments, name=None, *, return self._add_marker("lines", name, segments=segments, edgecolors=edgecolors, linewidths=linewidths, hover_edgecolors=hover_edgecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def add_rectangles(self, offsets, widths, heights, name=None, *, angles=0, facecolors=None, edgecolors="#ff0000", linewidths=1.5, alpha=0.3, hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 """Add rectangle markers at explicit (x, y) positions. Parameters @@ -1152,13 +1167,15 @@ def add_rectangles(self, offsets, widths, heights, name=None, *, linewidths=linewidths, alpha=alpha, hover_edgecolors=hover_edgecolors, hover_facecolors=hover_facecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def add_squares(self, offsets, widths, name=None, *, angles=0, facecolors=None, edgecolors="#ff0000", linewidths=1.5, alpha=0.3, hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 """Add square markers at explicit (x, y) positions. Parameters @@ -1196,13 +1213,15 @@ def add_squares(self, offsets, widths, name=None, *, linewidths=linewidths, alpha=alpha, hover_edgecolors=hover_edgecolors, hover_facecolors=hover_facecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def add_polygons(self, vertices_list, name=None, *, facecolors=None, edgecolors="#ff0000", linewidths=1.5, alpha=0.3, hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 """Add polygon markers defined by explicit vertex lists. Parameters @@ -1236,12 +1255,14 @@ def add_polygons(self, vertices_list, name=None, *, linewidths=linewidths, alpha=alpha, hover_edgecolors=hover_edgecolors, hover_facecolors=hover_facecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def add_texts(self, offsets, texts, name=None, *, color="#ff0000", fontsize=12, hover_edgecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 """Add text annotations at explicit (x, y) positions. Parameters @@ -1270,7 +1291,8 @@ def add_texts(self, offsets, texts, name=None, *, return self._add_marker("texts", name, offsets=offsets, texts=texts, color=color, fontsize=fontsize, hover_edgecolors=hover_edgecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def remove_marker(self, marker_type: str, name: str) -> None: """Remove a named marker collection by type and name. diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py index 280af9f8..23e91b3a 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -458,125 +458,147 @@ def add_circles(self, offsets, name=None, *, radius=5, facecolors=None, edgecolors="#ff0000", linewidths=1.5, alpha=0.3, hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 """Add circle markers at (x, y) positions in data coordinates.""" return self._add_marker("circles", name, offsets=offsets, radius=radius, facecolors=facecolors, edgecolors=edgecolors, linewidths=linewidths, alpha=alpha, hover_edgecolors=hover_edgecolors, hover_facecolors=hover_facecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def add_points(self, offsets, name=None, *, sizes=5, color="#ff0000", facecolors=None, linewidths=1.5, alpha=0.3, hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 """Add point markers at (x, y) positions in data coordinates.""" return self._add_marker("circles", name, offsets=offsets, radius=sizes, edgecolors=color, facecolors=facecolors, linewidths=linewidths, alpha=alpha, hover_edgecolors=hover_edgecolors, hover_facecolors=hover_facecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def add_hlines(self, y_values, name=None, *, color="#ff0000", linewidths=1.5, hover_edgecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 """Add static horizontal lines at the given y positions.""" return self._add_marker("hlines", name, offsets=y_values, color=color, linewidths=linewidths, hover_edgecolors=hover_edgecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def add_vlines(self, x_values, name=None, *, color="#ff0000", linewidths=1.5, hover_edgecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 """Add static vertical lines at the given x positions.""" return self._add_marker("vlines", name, offsets=x_values, color=color, linewidths=linewidths, hover_edgecolors=hover_edgecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def add_arrows(self, offsets, U, V, name=None, *, edgecolors="#ff0000", linewidths=1.5, hover_edgecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 return self._add_marker("arrows", name, offsets=offsets, U=U, V=V, edgecolors=edgecolors, linewidths=linewidths, hover_edgecolors=hover_edgecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def add_ellipses(self, offsets, widths, heights, name=None, *, angles=0, facecolors=None, edgecolors="#ff0000", linewidths=1.5, alpha=0.3, hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 return self._add_marker("ellipses", name, offsets=offsets, widths=widths, heights=heights, angles=angles, facecolors=facecolors, edgecolors=edgecolors, linewidths=linewidths, alpha=alpha, hover_edgecolors=hover_edgecolors, hover_facecolors=hover_facecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def add_lines(self, segments, name=None, *, edgecolors="#ff0000", linewidths=1.5, hover_edgecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 return self._add_marker("lines", name, segments=segments, edgecolors=edgecolors, linewidths=linewidths, hover_edgecolors=hover_edgecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def add_rectangles(self, offsets, widths, heights, name=None, *, angles=0, facecolors=None, edgecolors="#ff0000", linewidths=1.5, alpha=0.3, hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 return self._add_marker("rectangles", name, offsets=offsets, widths=widths, heights=heights, angles=angles, facecolors=facecolors, edgecolors=edgecolors, linewidths=linewidths, alpha=alpha, hover_edgecolors=hover_edgecolors, hover_facecolors=hover_facecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def add_squares(self, offsets, widths, name=None, *, angles=0, facecolors=None, edgecolors="#ff0000", linewidths=1.5, alpha=0.3, hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 return self._add_marker("squares", name, offsets=offsets, widths=widths, angles=angles, facecolors=facecolors, edgecolors=edgecolors, linewidths=linewidths, alpha=alpha, hover_edgecolors=hover_edgecolors, hover_facecolors=hover_facecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def add_polygons(self, vertices_list, name=None, *, facecolors=None, edgecolors="#ff0000", linewidths=1.5, alpha=0.3, hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 return self._add_marker("polygons", name, vertices_list=vertices_list, facecolors=facecolors, edgecolors=edgecolors, linewidths=linewidths, alpha=alpha, hover_edgecolors=hover_edgecolors, hover_facecolors=hover_facecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def add_texts(self, offsets, texts, name=None, *, color="#ff0000", fontsize=12, hover_edgecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 return self._add_marker("texts", name, offsets=offsets, texts=texts, color=color, fontsize=fontsize, hover_edgecolors=hover_edgecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def remove_marker(self, marker_type: str, name: str) -> None: """Remove a named marker collection by type and name. From 033e771338941bb727bc92f87bda9af95fab81b8 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 20 May 2026 18:18:00 -0500 Subject: [PATCH 02/11] Refactor: Enhance Plot1D and Plot2D classes with additional state management methods and properties for axis labels, visibility, and ranges --- anyplotlib/plot1d/_plot1d.py | 62 ++++++++ anyplotlib/plot2d/_plot2d.py | 80 ++++++++++ anyplotlib/tests/test_plot1d/test_plot1d.py | 96 ++++++++++++ .../tests/test_plot2d/test_plot2d_api.py | 137 ++++++++++++++++++ 4 files changed, 375 insertions(+) diff --git a/anyplotlib/plot1d/_plot1d.py b/anyplotlib/plot1d/_plot1d.py index 30c01bb3..7c41a151 100644 --- a/anyplotlib/plot1d/_plot1d.py +++ b/anyplotlib/plot1d/_plot1d.py @@ -287,6 +287,14 @@ def __init__(self, data: np.ndarray, "markers": [], "pointer_settled_ms": 0, "pointer_settled_delta": 4, + # Annotation labels + "title": "", + # Explicit y-range override: [ymin, ymax] or None (auto) + "y_range": None, + # Visibility toggles + "axis_visible": True, + "x_ticks_visible": True, + "y_ticks_visible": True, } self.markers = MarkerRegistry(self._push_markers, @@ -842,6 +850,60 @@ def set_marker(self, marker: str, markersize: float | None = None) -> None: self._state["line_markersize"] = float(markersize) self._push() + @property + def color(self) -> str: + return self._state["line_color"] + + @property + def x(self) -> np.ndarray: + return np.asarray(self._state["x_axis"]) + + @property + def y(self) -> np.ndarray: + return np.asarray(self._state["data"]) + + def set_xlabel(self, label: str) -> None: + self._state["units"] = str(label) + self._push() + + def set_ylabel(self, label: str) -> None: + self._state["y_units"] = str(label) + self._push() + + def set_title(self, label: str) -> None: + self._state["title"] = str(label) + self._push() + + def set_xlim(self, xmin: float, xmax: float) -> None: + self.set_view(x0=xmin, x1=xmax) + + def set_ylim(self, ymin: float, ymax: float) -> None: + self._state["y_range"] = [float(ymin), float(ymax)] + self._push() + + def get_ylim(self) -> tuple: + return (float(self._state["data_min"]), float(self._state["data_max"])) + + def get_xbound(self) -> tuple: + xarr = np.asarray(self._state["x_axis"]) + return (float(xarr.min()), float(xarr.max())) + + def set_axis_off(self) -> None: + self._state["axis_visible"] = False + self._push() + + def set_ticks_visible(self, visible: bool, *, x: bool | None = None, + y: bool | None = None) -> None: + if x is None and y is None: + self._state["x_ticks_visible"] = bool(visible) + self._state["y_ticks_visible"] = bool(visible) + else: + if x is not None: + self._state["x_ticks_visible"] = bool(x) + if y is not None: + self._state["y_ticks_visible"] = bool(y) + self._push() + # ------------------------------------------------------------------ # Marker API (matplotlib-style kwargs → MarkerRegistry) # ------------------------------------------------------------------ diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py index 23e91b3a..55a9b81f 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -126,6 +126,17 @@ def __init__(self, data: np.ndarray, # Set True when Python explicitly changes view; JS uses it to # decide whether to preserve the current frontend zoom/pan state. "_view_from_python": False, + # Axis / annotation labels (rendered by JS in Phase 4) + "x_label": "", + "y_label": "", + "title": "", + "colorbar_label": "", + # Aspect ratio: None means free, float means width/height ratio + "aspect": None, + # Visibility toggles + "axis_visible": True, + "x_ticks_visible": True, + "y_ticks_visible": True, } self.markers = MarkerRegistry(self._push_markers, @@ -305,6 +316,75 @@ def colormap_name(self) -> str: def colormap_name(self, name: str) -> None: self.set_colormap(name) + def set_xlabel(self, label: str) -> None: + self._state["x_label"] = str(label) + self._push() + + def set_ylabel(self, label: str) -> None: + self._state["y_label"] = str(label) + self._push() + + def set_title(self, label: str) -> None: + self._state["title"] = str(label) + self._push() + + def set_xlim(self, xmin: float, xmax: float) -> None: + self.set_view(x0=xmin, x1=xmax) + + def set_ylim(self, ymin: float, ymax: float) -> None: + self.set_view(y0=ymin, y1=ymax) + + def get_ylim(self) -> tuple: + yarr = np.asarray(self._state["y_axis"]) + return (float(yarr.min()), float(yarr.max())) + + def get_xbound(self) -> tuple: + xarr = np.asarray(self._state["x_axis"]) + return (float(xarr.min()), float(xarr.max())) + + def set_extent(self, x_axis, y_axis) -> None: + x_axis = np.asarray(x_axis, dtype=float) + y_axis = np.asarray(y_axis, dtype=float) + w = self._state["image_width"] + h = self._state["image_height"] + scale_x = float(abs(x_axis[-1] - x_axis[0]) / max(w - 1, 1)) if len(x_axis) >= 2 else 1.0 + scale_y = float(abs(y_axis[-1] - y_axis[0]) / max(h - 1, 1)) if len(y_axis) >= 2 else 1.0 + self._state["x_axis"] = x_axis.tolist() + self._state["y_axis"] = y_axis.tolist() + self._state["scale_x"] = scale_x + self._state["scale_y"] = scale_y + self._push() + + def set_colorbar_label(self, label: str) -> None: + self._state["colorbar_label"] = str(label) + self._push() + + def set_colorbar_visible(self, visible: bool) -> None: + self._state["show_colorbar"] = bool(visible) + 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_axis_off(self) -> None: + self._state["axis_visible"] = False + self._push() + + def set_ticks_visible(self, visible: bool, *, x: bool | None = None, + y: bool | None = None) -> None: + if x is None and y is None: + self._state["x_ticks_visible"] = bool(visible) + self._state["y_ticks_visible"] = bool(visible) + else: + if x is not None: + self._state["x_ticks_visible"] = bool(x) + if y is not None: + self._state["y_ticks_visible"] = bool(y) + self._push() + # ------------------------------------------------------------------ # Overlay Widgets # ------------------------------------------------------------------ diff --git a/anyplotlib/tests/test_plot1d/test_plot1d.py b/anyplotlib/tests/test_plot1d/test_plot1d.py index abe21e32..b58c15fc 100644 --- a/anyplotlib/tests/test_plot1d/test_plot1d.py +++ b/anyplotlib/tests/test_plot1d/test_plot1d.py @@ -661,3 +661,99 @@ def test_clear_markers(self): p.clear_markers() assert p.markers.to_wire_list() == [] + +# =========================================================================== +# Phase 2 — Plot1D state methods +# =========================================================================== + +class TestPlot1DProperties: + + def test_color_property(self): + p = _plot(color="#ff0000") + assert p.color == "#ff0000" + + def test_x_property_returns_ndarray(self): + p = _plot_lin(32) + x = p.x + assert isinstance(x, np.ndarray) + assert len(x) == 32 + + def test_y_property_returns_ndarray(self): + data = np.linspace(0.0, 1.0, 64) + fig, ax = apl.subplots(1, 1) + p = ax.plot(data) + y = p.y + assert isinstance(y, np.ndarray) + assert len(y) == 64 + + +class TestPlot1DLabels: + + def test_set_xlabel_updates_units(self): + p = _plot() + p.set_xlabel("Energy (eV)") + assert p._state["units"] == "Energy (eV)" + + def test_set_ylabel_updates_y_units(self): + p = _plot() + p.set_ylabel("Counts") + assert p._state["y_units"] == "Counts" + + def test_set_title(self): + p = _plot() + p.set_title("Spectrum") + assert p._state["title"] == "Spectrum" + + def test_default_title_empty(self): + p = _plot() + assert p._state["title"] == "" + + +class TestPlot1DAxisLimits: + + def test_set_xlim_changes_view(self): + p = _plot_lin(64) + p.set_xlim(10, 50) + assert p._state["view_x0"] != 0.0 or p._state["view_x1"] != 1.0 + + def test_set_ylim_stores_y_range(self): + p = _plot() + p.set_ylim(-2.0, 2.0) + assert p._state["y_range"] == [-2.0, 2.0] + + def test_get_ylim_returns_data_bounds(self): + data = np.array([0.0, 1.0, 2.0, 3.0, 4.0]) + fig, ax = apl.subplots(1, 1) + p = ax.plot(data) + lo, hi = p.get_ylim() + assert lo < hi + assert lo <= 0.0 + assert hi >= 4.0 + + def test_get_xbound_returns_x_range(self): + p = _plot_lin(32) + lo, hi = p.get_xbound() + assert lo == pytest.approx(0.0) + assert hi == pytest.approx(31.0) + + +class TestPlot1DAxisVisibility: + + def test_set_axis_off(self): + p = _plot() + assert p._state["axis_visible"] is True + p.set_axis_off() + assert p._state["axis_visible"] is False + + def test_set_ticks_visible_false(self): + p = _plot() + p.set_ticks_visible(False) + assert p._state["x_ticks_visible"] is False + assert p._state["y_ticks_visible"] is False + + def test_set_ticks_visible_per_axis(self): + p = _plot() + p.set_ticks_visible(False, x=True, y=False) + assert p._state["x_ticks_visible"] is True + assert p._state["y_ticks_visible"] is False + diff --git a/anyplotlib/tests/test_plot2d/test_plot2d_api.py b/anyplotlib/tests/test_plot2d/test_plot2d_api.py index 2203c621..13d41630 100644 --- a/anyplotlib/tests/test_plot2d/test_plot2d_api.py +++ b/anyplotlib/tests/test_plot2d/test_plot2d_api.py @@ -115,3 +115,140 @@ def test_no_debug_print_in_on_event(capsys): fig._on_event({"new": json.dumps(payload)}) captured = capsys.readouterr() assert captured.out == "", f"Unexpected stdout: {captured.out!r}" + + +# =========================================================================== +# Phase 2 — Plot2D state methods +# =========================================================================== + +class TestPlot2DLabels: + + def test_set_xlabel(self): + p = _make_plot2d() + p.set_xlabel("x (nm)") + assert p._state["x_label"] == "x (nm)" + + def test_set_ylabel(self): + p = _make_plot2d() + p.set_ylabel("y (nm)") + assert p._state["y_label"] == "y (nm)" + + def test_set_title(self): + p = _make_plot2d() + p.set_title("My Image") + assert p._state["title"] == "My Image" + + def test_set_colorbar_label(self): + p = _make_plot2d() + p.set_colorbar_label("Intensity") + assert p._state["colorbar_label"] == "Intensity" + + def test_default_labels_empty(self): + p = _make_plot2d() + assert p._state["x_label"] == "" + assert p._state["y_label"] == "" + assert p._state["title"] == "" + assert p._state["colorbar_label"] == "" + + +class TestPlot2DAxisLimits: + + def test_set_xlim_delegates_to_set_view(self): + p = _make_plot2d((32, 32)) + p.set_xlim(5, 20) + assert p._state["zoom"] != 1.0 or p._state["center_x"] != 0.5 + + def test_set_ylim_delegates_to_set_view(self): + p = _make_plot2d((32, 32)) + p.set_ylim(5, 20) + assert p._state["zoom"] != 1.0 or p._state["center_y"] != 0.5 + + def test_get_ylim_returns_y_axis_bounds(self): + fig, ax = apl.subplots(1, 1) + y_axis = np.linspace(0.0, 5.0, 32) + p = ax.imshow(np.zeros((32, 32)), axes=[np.arange(32), y_axis]) + lo, hi = p.get_ylim() + assert lo == pytest.approx(0.0) + assert hi == pytest.approx(5.0) + + def test_get_xbound_returns_x_axis_bounds(self): + fig, ax = apl.subplots(1, 1) + x_axis = np.linspace(-1.0, 3.0, 32) + p = ax.imshow(np.zeros((32, 32)), axes=[x_axis, np.arange(32)]) + lo, hi = p.get_xbound() + assert lo == pytest.approx(-1.0) + assert hi == pytest.approx(3.0) + + +class TestPlot2DExtent: + + def test_set_extent_updates_axes(self): + p = _make_plot2d((32, 32)) + x_new = np.linspace(0.0, 10.0, 32) + y_new = np.linspace(0.0, 20.0, 32) + p.set_extent(x_new, y_new) + assert p._state["x_axis"][0] == pytest.approx(0.0) + assert p._state["x_axis"][-1] == pytest.approx(10.0) + assert p._state["y_axis"][-1] == pytest.approx(20.0) + + def test_set_extent_updates_scale(self): + p = _make_plot2d((32, 32)) + x_new = np.linspace(0.0, 31.0, 32) + y_new = np.linspace(0.0, 62.0, 32) + p.set_extent(x_new, y_new) + assert p._state["scale_x"] == pytest.approx(1.0) + assert p._state["scale_y"] == pytest.approx(2.0) + + +class TestPlot2DColorbar: + + def test_set_colorbar_visible_true(self): + p = _make_plot2d() + p.set_colorbar_visible(True) + assert p._state["show_colorbar"] is True + + def test_set_colorbar_visible_false(self): + p = _make_plot2d() + p.set_colorbar_visible(True) + p.set_colorbar_visible(False) + assert p._state["show_colorbar"] is False + + +class TestPlot2DAspect: + + def test_set_aspect_float(self): + p = _make_plot2d() + p.set_aspect(2.0) + assert p._state["aspect"] == pytest.approx(2.0) + + def test_set_aspect_equal_string(self): + p = _make_plot2d() + p.set_aspect("equal") + assert p._state["aspect"] == pytest.approx(1.0) + + def test_set_aspect_none(self): + p = _make_plot2d() + p.set_aspect("equal") + p.set_aspect(None) + assert p._state["aspect"] is None + + +class TestPlot2DAxisVisibility: + + def test_set_axis_off(self): + p = _make_plot2d() + assert p._state["axis_visible"] is True + p.set_axis_off() + assert p._state["axis_visible"] is False + + def test_set_ticks_visible_false(self): + p = _make_plot2d() + p.set_ticks_visible(False) + assert p._state["x_ticks_visible"] is False + assert p._state["y_ticks_visible"] is False + + def test_set_ticks_visible_per_axis(self): + p = _make_plot2d() + p.set_ticks_visible(False, x=False, y=True) + assert p._state["x_ticks_visible"] is False + assert p._state["y_ticks_visible"] is True From 4e849b1ca8824f5d8ab8950505ddf8d6838c3f9a Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 20 May 2026 20:30:39 -0500 Subject: [PATCH 03/11] Refactor: Introduce color cycle utility and enhance Plot1D with logarithmic y-axis support --- anyplotlib/__init__.py | 14 +- anyplotlib/_utils.py | 20 +- anyplotlib/axes/_axes.py | 11 +- anyplotlib/callbacks.py | 2 +- anyplotlib/figure/_figure.py | 19 ++ anyplotlib/figure_esm.js | 224 +++++++++++++----- anyplotlib/markers.py | 12 +- anyplotlib/plot1d/_plot1d.py | 4 +- .../tests/test_interactive/test_callbacks.py | 42 ++++ anyplotlib/tests/test_layouts/test_visual.py | 43 ++++ anyplotlib/tests/test_markers/test_markers.py | 34 +++ anyplotlib/tests/test_plot1d/test_plot1d.py | 48 ++++ .../tests/test_plot2d/test_plot2d_api.py | 24 ++ 13 files changed, 422 insertions(+), 75 deletions(-) diff --git a/anyplotlib/__init__.py b/anyplotlib/__init__.py index e215e53c..c897ddce 100644 --- a/anyplotlib/__init__.py +++ b/anyplotlib/__init__.py @@ -15,6 +15,18 @@ # Default True: badges appear whenever a figure has help text set. show_help: bool = True +_COLOR_CYCLE: list[str] = [ + "#4fc3f7", "#ff7043", "#aed581", "#ffd54f", + "#ba68c8", "#4db6ac", "#f06292", "#90a4ae", + "#ffb74d", "#a5d6a7", +] + + +def get_color_cycle() -> list[str]: + """Return the default color cycle as a list of CSS hex strings.""" + return list(_COLOR_CYCLE) + + __all__ = [ "Figure", "GridSpec", "SubplotSpec", "subplots", "Axes", "InsetAxes", "Plot1D", "Plot2D", "PlotMesh", "Plot3D", "PlotBar", @@ -22,5 +34,5 @@ "Widget", "RectangleWidget", "CircleWidget", "AnnularWidget", "CrosshairWidget", "PolygonWidget", "LabelWidget", "VLineWidget", "HLineWidget", "RangeWidget", - "show_help", + "show_help", "get_color_cycle", ] diff --git a/anyplotlib/_utils.py b/anyplotlib/_utils.py index 501de5db..404cfaf5 100644 --- a/anyplotlib/_utils.py +++ b/anyplotlib/_utils.py @@ -9,14 +9,16 @@ import numpy as np _LINESTYLE_ALIASES: dict[str, str] = { - "-": "solid", - "--": "dashed", - ":": "dotted", - "-.": "dashdot", - "solid": "solid", - "dashed": "dashed", - "dotted": "dotted", - "dashdot": "dashdot", + "-": "solid", + "--": "dashed", + ":": "dotted", + "-.": "dashdot", + "solid": "solid", + "dashed": "dashed", + "dotted": "dotted", + "dashdot": "dashdot", + "step-mid": "step-mid", + "steps-mid": "step-mid", } @@ -49,7 +51,7 @@ def _norm_linestyle(ls: str) -> str: if canonical is None: raise ValueError( f"Unknown linestyle {ls!r}. Expected one of: " - "'solid', 'dashed', 'dotted', 'dashdot', " + "'solid', 'dashed', 'dotted', 'dashdot', 'step-mid' " "or shorthands '-', '--', ':', '-.'." ) return canonical diff --git a/anyplotlib/axes/_axes.py b/anyplotlib/axes/_axes.py index 6d4c6ea8..e526573e 100644 --- a/anyplotlib/axes/_axes.py +++ b/anyplotlib/axes/_axes.py @@ -192,7 +192,8 @@ def plot(self, data: np.ndarray, alpha: float = 1.0, marker: str = "none", markersize: float = 4.0, - label: str = "") -> "Plot1D": + label: str = "", + yscale: str = "linear") -> "Plot1D": """Attach a 1-D line to this axes cell. Parameters @@ -265,10 +266,16 @@ def plot(self, data: np.ndarray, color=color, linewidth=linewidth, linestyle=ls if ls is not None else linestyle, alpha=alpha, marker=marker, markersize=markersize, - label=label) + label=label, yscale=yscale) self._attach(plot) return plot + def semilogy(self, data: np.ndarray, + axes: list | None = None, **kwargs) -> "Plot1D": + """Attach a 1-D line with a logarithmic y-axis.""" + kwargs.setdefault("yscale", "log") + return self.plot(data, axes=axes, **kwargs) + def bar(self, x, height=None, width: float = 0.8, bottom: float = 0.0, *, align: str = "center", color: str = "#4fc3f7", diff --git a/anyplotlib/callbacks.py b/anyplotlib/callbacks.py index 6330c1cf..386eab34 100644 --- a/anyplotlib/callbacks.py +++ b/anyplotlib/callbacks.py @@ -24,7 +24,7 @@ VALID_EVENT_TYPES = frozenset({ "pointer_down", "pointer_up", "pointer_move", "pointer_settled", "pointer_enter", "pointer_leave", "double_click", "wheel", - "key_down", "key_up", "*", + "key_down", "key_up", "close", "*", }) diff --git a/anyplotlib/figure/_figure.py b/anyplotlib/figure/_figure.py index 7d832dda..a3478708 100644 --- a/anyplotlib/figure/_figure.py +++ b/anyplotlib/figure/_figure.py @@ -464,6 +464,25 @@ def _repr_html_(self) -> str: """ return repr_html_iframe(self) + def close(self) -> None: + """Close the figure. + + Fires a ``"close"`` event on every panel's :attr:`callbacks`, then + hides the widget by setting its CSS ``display`` to ``"none"``. + Subsequent calls are no-ops. + """ + if getattr(self, "_closed", False): + return + self._closed = True + close_event = Event(event_type="close") + for plot in self._plots_map.values(): + if hasattr(plot, "callbacks"): + plot.callbacks.fire(close_event) + try: + self.layout = {"display": "none"} + except Exception: + pass + def __repr__(self) -> str: return (f"Figure({self._nrows}x{self._ncols}, " f"panels={len(self._plots_map)}, " diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 0a651cc4..4659860c 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -864,13 +864,14 @@ function render({ model, el }) { // Colorbar: narrow strip to the right of the image area if (p.cbCanvas && p.cbCtx) { - const cbW = 16; + const cbStripW = 16; + const cbTotalW = (st && st.colorbar_label) ? cbStripW + 14 : cbStripW; const vis = st && st.show_colorbar; if (vis) { p.cbCanvas.style.display = 'block'; p.cbCanvas.style.left = (imgX + imgW + 2) + 'px'; p.cbCanvas.style.top = imgY + 'px'; - _sz(p.cbCanvas, p.cbCtx, cbW, imgH); + _sz(p.cbCanvas, p.cbCtx, cbTotalW, imgH); } else { p.cbCanvas.style.display = 'none'; } @@ -1160,7 +1161,9 @@ function render({ model, el }) { p.cbCanvas.style.display = vis ? 'block' : 'none'; if(!vis) return; - const cbW=16; + const cbStripW=16; + const cbLabel=st.colorbar_label||''; + const cbW=cbLabel?(cbStripW+14):cbStripW; const imgH=p.imgH||Math.max(1,p.ph-PAD_T-PAD_B); const ctx=p.cbCtx; ctx.clearRect(0,0,cbW,imgH); @@ -1172,17 +1175,17 @@ function render({ model, el }) { const ci=Math.max(0,Math.min(255,Math.round(frac*255))); const [r2,g2,b2]=st.colormap_data[ci]; ctx.fillStyle=`rgb(${r2},${g2},${b2})`; - ctx.fillRect(0,py,cbW,1); + ctx.fillRect(0,py,cbStripW,1); } } else { ctx.fillStyle=theme.dark?'#444':'#ccc'; - ctx.fillRect(0,0,cbW,imgH); + ctx.fillRect(0,0,cbStripW,imgH); } // Border ctx.strokeStyle=theme.border||'#888'; ctx.lineWidth=0.5; - ctx.strokeRect(0,0,cbW,imgH); + ctx.strokeRect(0,0,cbStripW,imgH); // display_min / display_max tick marks const dMin=st.display_min, dMax=st.display_max; @@ -1191,8 +1194,20 @@ function render({ model, el }) { const vRange=(hMax-hMin)||1; function _vToY(v){return imgH-1-((v-hMin)/vRange)*(imgH-1);} ctx.strokeStyle='rgba(255,255,255,0.85)'; ctx.lineWidth=1.5; - ctx.beginPath();ctx.moveTo(0,_vToY(dMax));ctx.lineTo(cbW,_vToY(dMax));ctx.stroke(); - ctx.beginPath();ctx.moveTo(0,_vToY(dMin));ctx.lineTo(cbW,_vToY(dMin));ctx.stroke(); + ctx.beginPath();ctx.moveTo(0,_vToY(dMax));ctx.lineTo(cbStripW,_vToY(dMax));ctx.stroke(); + ctx.beginPath();ctx.moveTo(0,_vToY(dMin));ctx.lineTo(cbStripW,_vToY(dMin));ctx.stroke(); + + // Colorbar label (rotated −90° to the right of the strip) + if(cbLabel){ + ctx.save(); + ctx.translate(cbStripW+9, imgH/2); + ctx.rotate(-Math.PI/2); + ctx.textAlign='center'; ctx.textBaseline='middle'; + ctx.fillStyle=theme.unitText; + ctx.font='10px sans-serif'; + ctx.fillText(cbLabel,0,0); + ctx.restore(); + } } @@ -1206,8 +1221,15 @@ function render({ model, el }) { const zoom=st.zoom, cx=st.center_x, cy=st.center_y; const units=st.units||'px'; const hasPhysAxis = (st.is_mesh || st.has_axes) && xArr.length>=2 && yArr.length>=2; - const hasX = hasPhysAxis && p.xCtx && p.xAxisCanvas && p.xAxisCanvas.style.display!=='none'; - const hasY = hasPhysAxis && p.yCtx && p.yAxisCanvas && p.yAxisCanvas.style.display!=='none'; + if(st.axis_visible===false){ + if(p.xAxisCanvas) p.xAxisCanvas.style.display='none'; + if(p.yAxisCanvas) p.yAxisCanvas.style.display='none'; + } else if(hasPhysAxis){ + if(st.x_ticks_visible===false&&p.xAxisCanvas) p.xAxisCanvas.style.display='none'; + if(st.y_ticks_visible===false&&p.yAxisCanvas) p.yAxisCanvas.style.display='none'; + } + const hasX=hasPhysAxis&&st.axis_visible!==false&&st.x_ticks_visible!==false&&p.xCtx&&p.xAxisCanvas&&p.xAxisCanvas.style.display!=='none'; + const hasY=hasPhysAxis&&st.axis_visible!==false&&st.y_ticks_visible!==false&&p.yCtx&&p.yAxisCanvas&&p.yAxisCanvas.style.display!=='none'; function _visFrac(z,c){ if(z>=1.0){const h=0.5/z;const cc=Math.max(h,Math.min(1-h,c));return[cc-h,cc+h];} @@ -1258,6 +1280,8 @@ function render({ model, el }) { p.xCtx.textAlign='right'; p.xCtx.textBaseline='bottom'; p.xCtx.fillStyle=theme.unitText; p.xCtx.font='9px sans-serif'; p.xCtx.fillText(units, aw-2, ah-1); + const xlabel=st.x_label||''; + if(xlabel){p.xCtx.fillStyle=theme.tickText;p.xCtx.font='11px sans-serif';p.xCtx.textAlign='center';p.xCtx.textBaseline='bottom';p.xCtx.fillText(xlabel,aw/2,ah-2);} } // ── Y axis canvas: PAD_L × imgH, origin at top-left ───────────────── @@ -1293,6 +1317,28 @@ function render({ model, el }) { p.yCtx.textAlign='left'; p.yCtx.textBaseline='top'; p.yCtx.fillStyle=theme.unitText; p.yCtx.font='9px sans-serif'; p.yCtx.fillText(units, 2, 1); + const ylabel=st.y_label||''; + if(ylabel){ + p.yCtx.save(); + p.yCtx.translate(Math.round(aw*0.15),ah/2); + p.yCtx.rotate(-Math.PI/2); + p.yCtx.textAlign='center'; p.yCtx.textBaseline='middle'; + p.yCtx.fillStyle=theme.tickText; p.yCtx.font='11px sans-serif'; + p.yCtx.fillText(ylabel,0,0); + p.yCtx.restore(); + } + } + const title2d=st.title||''; + if(title2d&&p.plotCtx){ + const tw=p.imgW||imgW; + p.plotCtx.save(); + p.plotCtx.fillStyle='rgba(0,0,0,0.45)'; + p.plotCtx.fillRect(0,0,tw,18); + p.plotCtx.fillStyle='#ffffff'; + p.plotCtx.font='bold 11px sans-serif'; + p.plotCtx.textAlign='center'; p.plotCtx.textBaseline='middle'; + p.plotCtx.fillText(title2d,tw/2,9); + p.plotCtx.restore(); } } @@ -1913,6 +1959,14 @@ function render({ model, el }) { const dMin=st.data_min, dMax=st.data_max; const units=st.units||'', yUnits=st.y_units||''; + const isLog = st.yscale === 'log'; + const _logEps = 1e-300; + const effDMin = isLog ? Math.log10(Math.max(_logEps, dMin)) : dMin; + const effDMax = isLog ? Math.log10(Math.max(_logEps, dMax)) : dMax; + function _toPlotY(v) { + return _valToPy1d(isLog ? Math.log10(Math.max(_logEps, v)) : v, effDMin, effDMax, r); + } + ctx.clearRect(0,0,pw,ph); ctx.fillStyle=theme.bg; ctx.fillRect(0,0,pw,ph); ctx.fillStyle=theme.bgPlot; ctx.fillRect(r.x,r.y,r.w,r.h); @@ -1928,12 +1982,21 @@ function render({ model, el }) { ctx.beginPath();ctx.moveTo(px,r.y);ctx.lineTo(px,r.y+r.h);ctx.stroke(); } } - const yRange=(dMax-dMin)||1; + const yRange=(effDMax-effDMin)||1; const yStep=findNice(yRange/Math.max(2,Math.floor(r.h/40))); - for(let v=Math.ceil(dMin/yStep)*yStep;v<=dMax+yStep*0.01;v+=yStep){ - const py=_valToPy1d(v,dMin,dMax,r); - if(pyr.y+r.h) continue; - ctx.beginPath();ctx.moveTo(r.x,py);ctx.lineTo(r.x+r.w,py);ctx.stroke(); + if(isLog){ + const lo=Math.floor(effDMin), hi=Math.ceil(effDMax); + for(let e=lo;e<=hi;e++){ + const py=_toPlotY(Math.pow(10,e)); + if(pyr.y+r.h) continue; + ctx.beginPath();ctx.moveTo(r.x,py);ctx.lineTo(r.x+r.w,py);ctx.stroke(); + } + } else { + for(let v=Math.ceil(dMin/yStep)*yStep;v<=dMax+yStep*0.01;v+=yStep){ + const py=_valToPy1d(v,dMin,dMax,r); + if(pyr.y+r.h) continue; + ctx.beginPath();ctx.moveTo(r.x,py);ctx.lineTo(r.x+r.w,py);ctx.stroke(); + } } // Spans @@ -1944,7 +2007,7 @@ function render({ model, el }) { const px1b=_fracToPx1d(_xToFrac1d(xArr,sp.v1),x0,x1,r); ctx.fillRect(px0,r.y,px1b-px0,r.h); } else { - const py0=_valToPy1d(sp.v1,dMin,dMax,r), py1=_valToPy1d(sp.v0,dMin,dMax,r); + const py0=_toPlotY(sp.v1), py1=_toPlotY(sp.v0); ctx.fillRect(r.x,py0,r.w,py1-py0); } } @@ -2008,31 +2071,47 @@ function render({ model, el }) { function _drawLine(yData, lineXArr, color, lw, linestyle, alpha, marker, markersize) { if (!yData || !yData.length) return; const n = yData.length; - const dash = _LINESTYLE_DASH[linestyle || 'solid'] || []; + const isStepMid = linestyle === 'step-mid'; + const dash = isStepMid ? [] : (_LINESTYLE_DASH[linestyle || 'solid'] || []); const eff_alpha = (alpha != null && alpha < 1.0) ? alpha : 1.0; const ms = Math.max(1, markersize || 4); const doMarker = marker && marker !== 'none'; + // Pre-compute pixel positions + const allPx = new Array(n), allPy = new Array(n); + for (let i = 0; i < n; i++) { + const xFrac = lineXArr.length >= 2 + ? (lineXArr[i] - lineXArr[0]) / ((lineXArr[lineXArr.length - 1] - lineXArr[0]) || 1) + : i / ((n - 1) || 1); + allPx[i] = _fracToPx1d(xFrac, x0, x1, r); + allPy[i] = _toPlotY(yData[i]); + } + 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'; - const pts = doMarker ? [] : null; - let first = true; - for (let i = 0; i < n; i++) { - const xFrac = lineXArr.length >= 2 - ? (lineXArr[i] - lineXArr[0]) / ((lineXArr[lineXArr.length - 1] - lineXArr[0]) || 1) - : i / ((n - 1) || 1); - const px = _fracToPx1d(xFrac, x0, x1, r); - const py = _valToPy1d(yData[i], dMin, dMax, r); - if (first) { ctx.moveTo(px, py); first = false; } else { ctx.lineTo(px, py); } - if (pts) pts.push([px, py]); + 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([]); + const pts = doMarker ? allPx.map((px, i) => [px, allPy[i]]) : null; + // Per-point marker symbols if (doMarker && pts && pts.length) { ctx.strokeStyle = color; @@ -2088,45 +2167,64 @@ function render({ model, el }) { } ctx.restore(); + const axisVis1d=st.axis_visible!==false; + const xTicksVis1d=st.x_ticks_visible!==false; + const yTicksVis1d=st.y_ticks_visible!==false; + // Axes ctx.strokeStyle=theme.axisStroke; ctx.lineWidth=1; ctx.beginPath();ctx.moveTo(r.x,r.y+r.h);ctx.lineTo(r.x+r.w,r.y+r.h);ctx.stroke(); ctx.beginPath();ctx.moveTo(r.x,r.y);ctx.lineTo(r.x,r.y+r.h);ctx.stroke(); - ctx.fillStyle=theme.tickText; ctx.font='10px monospace'; - if(xArr.length>=2){ - const xVMin=_fracToX1d(xArr,x0), xVMax=_fracToX1d(xArr,x1); - const xStep=findNice((xVMax-xVMin)/Math.max(2,Math.floor(r.w/70))); - ctx.textAlign='center'; ctx.textBaseline='top'; - for(let v=Math.ceil(xVMin/xStep)*xStep;v<=xVMax+xStep*0.01;v+=xStep){ - const px=_fracToPx1d(_xToFrac1d(xArr,v),x0,x1,r); - if(pxr.x+r.w) continue; - ctx.strokeStyle=theme.axisStroke;ctx.beginPath();ctx.moveTo(px,r.y+r.h);ctx.lineTo(px,r.y+r.h+5);ctx.stroke(); - ctx.fillStyle=theme.tickText;ctx.fillText(fmtVal(v),px,r.y+r.h+7); + if(axisVis1d&&xTicksVis1d){ + ctx.fillStyle=theme.tickText; ctx.font='10px monospace'; + if(xArr.length>=2){ + const xVMin=_fracToX1d(xArr,x0), xVMax=_fracToX1d(xArr,x1); + const xStep=findNice((xVMax-xVMin)/Math.max(2,Math.floor(r.w/70))); + ctx.textAlign='center'; ctx.textBaseline='top'; + for(let v=Math.ceil(xVMin/xStep)*xStep;v<=xVMax+xStep*0.01;v+=xStep){ + const px=_fracToPx1d(_xToFrac1d(xArr,v),x0,x1,r); + if(pxr.x+r.w) continue; + ctx.strokeStyle=theme.axisStroke;ctx.beginPath();ctx.moveTo(px,r.y+r.h);ctx.lineTo(px,r.y+r.h+5);ctx.stroke(); + ctx.fillStyle=theme.tickText;ctx.fillText(fmtVal(v),px,r.y+r.h+7); + } + if(units&&units!=='px'){ctx.textAlign='right';ctx.textBaseline='top';ctx.fillStyle=theme.unitText;ctx.font='9px monospace';ctx.fillText(units,r.x+r.w,r.y+r.h+24);ctx.font='10px monospace';} } - if(units&&units!=='px'){ctx.textAlign='right';ctx.textBaseline='top';ctx.fillStyle=theme.unitText;ctx.font='9px monospace';ctx.fillText(units,r.x+r.w,r.y+r.h+24);ctx.font='10px monospace';} - } - ctx.font='10px monospace';ctx.textAlign='right';ctx.textBaseline='middle'; - 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;} - const tickRX=r.x-8; - for(let v=Math.ceil(dMin/yStep)*yStep;v<=dMax+yStep*0.01;v+=yStep){ - const py=_valToPy1d(v,dMin,dMax,r); - if(pyr.y+r.h) continue; - ctx.strokeStyle=theme.axisStroke;ctx.beginPath();ctx.moveTo(r.x,py);ctx.lineTo(r.x-5,py);ctx.stroke(); - ctx.fillStyle=theme.tickText;ctx.fillText(fmtVal(v),tickRX,py); } - 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. - const lcx = Math.round(PAD_L * 0.28); - ctx.translate(lcx, r.y+r.h/2); ctx.rotate(-Math.PI/2); - ctx.textAlign='center'; ctx.textBaseline='middle'; - ctx.fillStyle=theme.unitText; ctx.font='9px monospace'; - ctx.fillText(yUnits, 0, 0); - ctx.restore(); + if(axisVis1d&&yTicksVis1d){ + ctx.font='10px monospace';ctx.textAlign='right';ctx.textBaseline='middle'; + const tickRX=r.x-8; + if(isLog){ + const lo=Math.floor(effDMin), hi=Math.ceil(effDMax); + for(let e=lo;e<=hi;e++){ + const v=Math.pow(10,e); + const py=_toPlotY(v); + if(pyr.y+r.h) continue; + ctx.strokeStyle=theme.axisStroke;ctx.beginPath();ctx.moveTo(r.x,py);ctx.lineTo(r.x-5,py);ctx.stroke(); + ctx.fillStyle=theme.tickText;ctx.fillText('10^'+e,tickRX,py); + } + } 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); + if(pyr.y+r.h) continue; + ctx.strokeStyle=theme.axisStroke;ctx.beginPath();ctx.moveTo(r.x,py);ctx.lineTo(r.x-5,py);ctx.stroke(); + ctx.fillStyle=theme.tickText;ctx.fillText(fmtVal(v),tickRX,py); + } + } + 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. + const lcx = Math.round(PAD_L * 0.28); + ctx.translate(lcx, r.y+r.h/2); ctx.rotate(-Math.PI/2); + ctx.textAlign='center'; ctx.textBaseline='middle'; + ctx.fillStyle=theme.unitText; ctx.font='9px monospace'; + ctx.fillText(yUnits, 0, 0); + ctx.restore(); + } } // Legend @@ -2157,6 +2255,14 @@ function render({ model, el }) { } } + const title1d=st.title||''; + if(title1d){ + ctx.fillStyle=theme.tickText; + ctx.font='bold 11px sans-serif'; + ctx.textAlign='center'; ctx.textBaseline='middle'; + ctx.fillText(title1d, r.x+r.w/2, PAD_T/2); + } + drawOverlay1d(p); drawMarkers1d(p); } diff --git a/anyplotlib/markers.py b/anyplotlib/markers.py index 5630b155..66dba65a 100644 --- a/anyplotlib/markers.py +++ b/anyplotlib/markers.py @@ -94,7 +94,8 @@ class MarkerGroup: the parent figure trait. """ - def __init__(self, marker_type: str, name: str, kwargs: dict, push_fn): + def __init__(self, marker_type: str, name: str, kwargs: dict, push_fn, + parent: "MarkerTypeDict | None" = None): self._type = marker_type self._name = name tfm = kwargs.get("transform", "data") @@ -104,6 +105,7 @@ def __init__(self, marker_type: str, name: str, kwargs: dict, push_fn): ) self._data: dict = dict(kwargs) self._push_fn = push_fn + self._parent: "MarkerTypeDict | None" = parent # ------------------------------------------------------------------ def set(self, **kwargs) -> None: @@ -123,6 +125,12 @@ def set(self, **kwargs) -> None: self._data.update(kwargs) self._push_fn() + def remove(self) -> None: + """Remove this group from its parent and trigger a re-render.""" + if self._parent is None: + raise RuntimeError("MarkerGroup has no parent; cannot remove.") + del self._parent[self._name] + def __repr__(self) -> str: # pragma: no cover return f"MarkerGroup(type={self._type!r}, name={self._name!r}, n={self._count()})" @@ -499,7 +507,7 @@ def pop(self, name: str, *args): # ------------------------------------------------------------------ def _add(self, name: str, kwargs: dict) -> "MarkerGroup": """Internal: create and register a MarkerGroup without double-pushing.""" - g = MarkerGroup(self._type, name, kwargs, self._push_fn) + g = MarkerGroup(self._type, name, kwargs, self._push_fn, parent=self) self._groups[name] = g return g diff --git a/anyplotlib/plot1d/_plot1d.py b/anyplotlib/plot1d/_plot1d.py index 7c41a151..9ab8c130 100644 --- a/anyplotlib/plot1d/_plot1d.py +++ b/anyplotlib/plot1d/_plot1d.py @@ -245,7 +245,8 @@ def __init__(self, data: np.ndarray, alpha: float = 1.0, marker: str = "none", markersize: float = 4.0, - label: str = ""): + label: str = "", + yscale: str = "linear"): self._id: str = "" self._fig: object = None @@ -287,6 +288,7 @@ def __init__(self, data: np.ndarray, "markers": [], "pointer_settled_ms": 0, "pointer_settled_delta": 4, + "yscale": yscale, # Annotation labels "title": "", # Explicit y-range override: [ymin, ymax] or None (auto) diff --git a/anyplotlib/tests/test_interactive/test_callbacks.py b/anyplotlib/tests/test_interactive/test_callbacks.py index 7aff6784..4b33731a 100644 --- a/anyplotlib/tests/test_interactive/test_callbacks.py +++ b/anyplotlib/tests/test_interactive/test_callbacks.py @@ -492,3 +492,45 @@ def test_line1d_no_on_click(self): plot = ax.plot(np.zeros(10)) line = plot.add_line(np.zeros(10)) assert not hasattr(line, "on_click") + + +# ── Phase 3 — Figure.close() ────────────────────────────────────────────────── + +class TestFigureClose: + + def test_close_in_valid_event_types(self): + assert "close" in VALID_EVENT_TYPES + + def test_figure_close_sets_closed_flag(self): + fig, ax = apl.subplots(1, 1) + ax.plot(np.zeros(10)) + assert not getattr(fig, "_closed", False) + fig.close() + assert fig._closed is True + + def test_figure_close_fires_event_on_plot(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.zeros(10)) + received = [] + plot.callbacks.connect("close", lambda e: received.append(e.event_type)) + fig.close() + assert received == ["close"] + + def test_figure_close_fires_on_all_panels(self): + fig, (ax1, ax2) = apl.subplots(1, 2) + p1 = ax1.plot(np.zeros(10)) + p2 = ax2.imshow(np.zeros((8, 8))) + counts = [0, 0] + p1.callbacks.connect("close", lambda e: counts.__setitem__(0, counts[0] + 1)) + p2.callbacks.connect("close", lambda e: counts.__setitem__(1, counts[1] + 1)) + fig.close() + assert counts == [1, 1] + + def test_figure_close_is_idempotent(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.zeros(10)) + received = [] + plot.callbacks.connect("close", lambda e: received.append(e)) + fig.close() + fig.close() + assert len(received) == 1 diff --git a/anyplotlib/tests/test_layouts/test_visual.py b/anyplotlib/tests/test_layouts/test_visual.py index e94f341e..4487dc55 100644 --- a/anyplotlib/tests/test_layouts/test_visual.py +++ b/anyplotlib/tests/test_layouts/test_visual.py @@ -298,3 +298,46 @@ def test_gridspec_spanning_top_two_bottom(self, take_screenshot, update_baseline arr = take_screenshot(fig) _check("gridspec_spanning_top_two_bottom", arr, update_baselines) + # ── Phase 4 — labels, title, colorbar label, axis visibility ─────────── + + def test_plot1d_title(self, take_screenshot, update_baselines): + """1-D plot with set_title — title text drawn in top PAD area.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 240)) + p = ax.plot(np.sin(np.linspace(0, 2 * np.pi, 256)), color="#4fc3f7") + p.set_title("Sine Wave") + arr = take_screenshot(fig) + _check("plot1d_title", arr, update_baselines) + + def test_plot1d_axis_off(self, take_screenshot, update_baselines): + """1-D plot with set_axis_off — tick labels hidden.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 240)) + p = ax.plot(np.sin(np.linspace(0, 2 * np.pi, 256)), color="#4fc3f7") + p.set_axis_off() + arr = take_screenshot(fig) + _check("plot1d_axis_off", arr, update_baselines) + + def test_imshow_labels(self, take_screenshot, update_baselines): + """2-D image with x_label, y_label, title, and colorbar_label.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 400)) + x = np.linspace(0.0, 10.0, 64) + p = ax.imshow( + np.random.default_rng(0).uniform(size=(64, 64)), + axes=[x, x], units="nm", + ) + p.set_xlabel("x (nm)") + p.set_ylabel("y (nm)") + p.set_title("Test Image") + p.set_colorbar_visible(True) + p.set_colorbar_label("Intensity") + arr = take_screenshot(fig) + _check("imshow_labels", arr, update_baselines) + + def test_imshow_axis_off(self, take_screenshot, update_baselines): + """2-D image with set_axis_off — axis gutters hidden.""" + fig, ax = apl.subplots(1, 1, figsize=(320, 320)) + x = np.linspace(0.0, 5.0, 32) + p = ax.imshow(np.zeros((32, 32)), axes=[x, x], units="nm") + p.set_axis_off() + arr = take_screenshot(fig) + _check("imshow_axis_off", arr, update_baselines) + diff --git a/anyplotlib/tests/test_markers/test_markers.py b/anyplotlib/tests/test_markers/test_markers.py index db4a39e6..1333e249 100644 --- a/anyplotlib/tests/test_markers/test_markers.py +++ b/anyplotlib/tests/test_markers/test_markers.py @@ -553,3 +553,37 @@ def test_mesh_disallows_arrows(self): with pytest.raises(ValueError, match="not allowed"): mesh.add_arrows([[0.0, 0.0]], [1.0], [1.0]) + +# --------------------------------------------------------------------------- +# MarkerGroup.remove() +# --------------------------------------------------------------------------- + +class TestMarkerGroupRemove: + + def test_remove_deletes_from_parent(self): + p = _make_plot2d() + g = p.add_circles([[10.0, 20.0]], name="dot", radius=3) + assert "dot" in p.markers["circles"] + g.remove() + assert "dot" not in p.markers["circles"] + + def test_remove_triggers_push(self): + calls = [] + td = MarkerTypeDict("circles", lambda: calls.append(1)) + g = td._add("g", {"offsets": [[0.0, 0.0]], "radius": 2}) + calls.clear() + g.remove() + assert len(calls) == 1 + + def test_remove_no_parent_raises(self): + g = MarkerGroup("circles", "g", {"offsets": [[0.0, 0.0]]}, _push_noop) + with pytest.raises(RuntimeError, match="no parent"): + g.remove() + + def test_remove_1d_group(self): + p = _make_plot1d() + g = p.add_vlines([0.5, 1.5], name="marks") + assert "marks" in p.markers["vlines"] + g.remove() + assert "marks" not in p.markers["vlines"] + diff --git a/anyplotlib/tests/test_plot1d/test_plot1d.py b/anyplotlib/tests/test_plot1d/test_plot1d.py index b58c15fc..c26b50b1 100644 --- a/anyplotlib/tests/test_plot1d/test_plot1d.py +++ b/anyplotlib/tests/test_plot1d/test_plot1d.py @@ -757,3 +757,51 @@ def test_set_ticks_visible_per_axis(self): assert p._state["x_ticks_visible"] is True assert p._state["y_ticks_visible"] is False + +# =========================================================================== +# Phase 5 — step-mid linestyle + semilogy / yscale +# =========================================================================== + +class TestNormLinestyleStepMid: + + def test_step_mid_accepted(self): + from anyplotlib._utils import _norm_linestyle + assert _norm_linestyle("step-mid") == "step-mid" + + def test_steps_mid_alias(self): + from anyplotlib._utils import _norm_linestyle + assert _norm_linestyle("steps-mid") == "step-mid" + + def test_step_mid_stored_in_state(self): + fig, ax = apl.subplots(1, 1) + p = ax.plot(np.zeros(16), linestyle="step-mid") + assert p._state["line_linestyle"] == "step-mid" + + def test_step_mid_via_set_linestyle(self): + p = _plot() + p.set_linestyle("step-mid") + assert p._state["line_linestyle"] == "step-mid" + + +class TestSemilogy: + + def test_semilogy_sets_yscale_log(self): + fig, ax = apl.subplots(1, 1) + p = ax.semilogy(np.logspace(0, 3, 64)) + assert p._state["yscale"] == "log" + + def test_yscale_stored_in_state(self): + fig, ax = apl.subplots(1, 1) + p = ax.plot(np.zeros(16), yscale="log") + assert p._state["yscale"] == "log" + + def test_yscale_default_is_linear(self): + p = _plot() + assert p._state["yscale"] == "linear" + + def test_semilogy_passes_kwargs(self): + fig, ax = apl.subplots(1, 1) + p = ax.semilogy(np.ones(16), color="#ff0000") + assert p._state["line_color"] == "#ff0000" + assert p._state["yscale"] == "log" + diff --git a/anyplotlib/tests/test_plot2d/test_plot2d_api.py b/anyplotlib/tests/test_plot2d/test_plot2d_api.py index 13d41630..b00fbc02 100644 --- a/anyplotlib/tests/test_plot2d/test_plot2d_api.py +++ b/anyplotlib/tests/test_plot2d/test_plot2d_api.py @@ -252,3 +252,27 @@ def test_set_ticks_visible_per_axis(self): p.set_ticks_visible(False, x=False, y=True) assert p._state["x_ticks_visible"] is False assert p._state["y_ticks_visible"] is True + + +class TestGetColorCycle: + + def test_get_color_cycle_returns_list(self): + import anyplotlib as apl + result = apl.get_color_cycle() + assert isinstance(result, list) + + def test_get_color_cycle_elements_are_strings(self): + import anyplotlib as apl + result = apl.get_color_cycle() + assert all(isinstance(c, str) for c in result) + + def test_get_color_cycle_returns_copy(self): + import anyplotlib as apl + a = apl.get_color_cycle() + b = apl.get_color_cycle() + a.append("extra") + assert len(b) == len(apl.get_color_cycle()) + + def test_get_color_cycle_nonempty(self): + import anyplotlib as apl + assert len(apl.get_color_cycle()) > 0 From daa8b1f2d90e6eecc49377e8c4150c1b47652fe2 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 20 May 2026 21:21:13 -0500 Subject: [PATCH 04/11] Refactor: Implement subplot spacing adjustments with hspace and wspace parameters --- anyplotlib/figure/_figure.py | 23 ++++ anyplotlib/figure_esm.js | 105 +++++++++++++++++- .../tests/test_layouts/test_gridspec.py | 38 +++++++ 3 files changed, 165 insertions(+), 1 deletion(-) diff --git a/anyplotlib/figure/_figure.py b/anyplotlib/figure/_figure.py index a3478708..67358fbd 100644 --- a/anyplotlib/figure/_figure.py +++ b/anyplotlib/figure/_figure.py @@ -66,6 +66,8 @@ class Figure(anywidget.AnyWidget): # Figure-level help text shown in a '?' badge overlay in JS. # Empty string means no badge. Gated by apl.show_help at the Python level. help_text = traitlets.Unicode("").tag(sync=True) + # When True JS shows drag handles on all panels so they can be reordered. + drag_mode = traitlets.Bool(False).tag(sync=True) _esm = _ESM_SOURCE # Static CSS injected by anywidget alongside _esm. # .apl-scale-wrap — outer container; width:100% means it always fills @@ -114,6 +116,8 @@ def __init__(self, nrows=1, ncols=1, figsize=(640, 480), self._axes_map: dict = {} self._plots_map: dict = {} self._insets_map: dict = {} + self._hspace: float | None = None + self._wspace: float | None = None with self.hold_trait_notifications(): self.fig_width = figsize[0] self.fig_height = figsize[1] @@ -149,6 +153,23 @@ def set_help(self, text: str) -> None: """ self.help_text = self._resolve_help(text) + def subplots_adjust(self, hspace: float = 0.0, wspace: float = 0.0) -> None: + """Set the spacing between subplot panels. + + Parameters + ---------- + hspace : float, optional + Fraction of the average row height to use as vertical gap between + panels. ``0.1`` adds a gap of 10 % of the mean row height. + Default ``0.0`` (no gap). + wspace : float, optional + Fraction of the average column width to use as horizontal gap. + Default ``0.0`` (no gap). + """ + self._hspace = float(hspace) + self._wspace = float(wspace) + self._push_layout() + # ── subplot creation ────────────────────────────────────────────────────── def add_subplot(self, spec) -> Axes: """Add a subplot cell and return its :class:`Axes`. @@ -303,6 +324,8 @@ def _mg(flag, key): "panel_specs": panel_specs, "share_groups": share_groups, "inset_specs": inset_specs, + "hspace": self._hspace, + "wspace": self._wspace, }) # ── inset creation ──────────────────────────────────────────────────────── diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 4659860c..1dc475b7 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -346,6 +346,11 @@ function render({ model, el }) { gridDiv.style.gridTemplateRows = rowPx.map(px => px + 'px').join(' '); gridDiv.style.width = ''; gridDiv.style.height = ''; + const meanColPx = colPx.length ? colPx.reduce((a,b)=>a+b,0)/colPx.length : 0; + const meanRowPx = rowPx.length ? rowPx.reduce((a,b)=>a+b,0)/rowPx.length : 0; + // Only override the default gap:4px when the Python caller explicitly set a value. + if (layout.wspace != null) gridDiv.style.columnGap = (meanColPx ? Math.round(layout.wspace*meanColPx) : 0)+'px'; + if (layout.hspace != null) gridDiv.style.rowGap = (meanRowPx ? Math.round(layout.hspace*meanRowPx) : 0)+'px'; const seen = new Set(); for (const spec of panel_specs) { @@ -792,7 +797,12 @@ function render({ model, el }) { const imgX = hasPhysAxis ? PAD_L : 0; const imgY = hasPhysAxis ? PAD_T : 0; const imgW = hasPhysAxis ? Math.max(1, pw - PAD_L - PAD_R) : pw; - const imgH = hasPhysAxis ? Math.max(1, ph - PAD_T - PAD_B) : ph; + let imgH = hasPhysAxis ? Math.max(1, ph - PAD_T - PAD_B) : ph; + // Enforce aspect ratio (st.aspect = number or "equal" → 1.0). + if (st && st.aspect != null) { + const asp = (st.aspect === 'equal') ? 1.0 : parseFloat(st.aspect); + if (Number.isFinite(asp) && asp > 0) imgH = Math.max(1, Math.round(imgW / asp)); + } // Store on panel so event handlers and draw functions don't recompute. p.imgX = imgX; p.imgY = imgY; p.imgW = imgW; p.imgH = imgH; @@ -4318,6 +4328,99 @@ function render({ model, el }) { model.on('change:layout_json', () => { applyLayout(); redrawAll(); requestAnimationFrame(_applyScale); }); model.on('change:fig_width change:fig_height', () => { applyLayout(); redrawAll(); requestAnimationFrame(_applyScale); }); + // ── Panel rearrangement (drag mode) ────────────────────────────────────────── + // When fig.drag_mode = True, each panel cell shows a drag handle overlay. + // Dragging one panel onto another swaps their grid positions. + const _editOverlays = new Map(); + + function _setEditMode(active) { + for (const [id, p] of panels) { + let ov = _editOverlays.get(id); + if (active && !ov) { + ov = document.createElement('div'); + ov.style.cssText = + 'position:absolute;inset:0;z-index:50;cursor:grab;' + + 'border:2px dashed rgba(79,195,247,0.75);' + + 'background:rgba(79,195,247,0.06);border-radius:4px;' + + 'pointer-events:all;display:flex;align-items:center;' + + 'justify-content:center;user-select:none;'; + const badge = document.createElement('div'); + badge.style.cssText = + 'background:rgba(0,0,0,0.55);color:#4fc3f7;padding:3px 10px;' + + 'border-radius:12px;font-size:11px;font-family:monospace;' + + 'pointer-events:none;letter-spacing:0.04em;'; + badge.textContent = '⋮ drag'; + ov.appendChild(badge); + p.cell.appendChild(ov); + _editOverlays.set(id, ov); + + let dragging = false, startX = 0, startY = 0, ghost = null; + + ov.addEventListener('pointerdown', (e) => { + if (e.button !== 0) return; + dragging = true; + startX = e.clientX; startY = e.clientY; + ov.style.cursor = 'grabbing'; + const r = p.cell.getBoundingClientRect(); + ghost = document.createElement('div'); + ghost.style.cssText = + 'position:fixed;pointer-events:none;z-index:9999;' + + 'border:2px solid #4fc3f7;background:rgba(79,195,247,0.15);' + + 'border-radius:4px;opacity:0.85;' + + `width:${r.width}px;height:${r.height}px;` + + `left:${r.left}px;top:${r.top}px;`; + document.body.appendChild(ghost); + ov.setPointerCapture(e.pointerId); + e.preventDefault(); + }); + + ov.addEventListener('pointermove', (e) => { + if (!dragging || !ghost) return; + const dx = e.clientX - startX, dy = e.clientY - startY; + const r = p.cell.getBoundingClientRect(); + ghost.style.left = (r.left + dx) + 'px'; + ghost.style.top = (r.top + dy) + 'px'; + for (const [oid, op] of panels) { + if (oid === id) continue; + const tr = op.cell.getBoundingClientRect(); + const over = e.clientX >= tr.left && e.clientX <= tr.right && + e.clientY >= tr.top && e.clientY <= tr.bottom; + const ovEl = _editOverlays.get(oid); + if (ovEl) ovEl.style.borderColor = over ? '#ff7043' : 'rgba(79,195,247,0.75)'; + } + }); + + ov.addEventListener('pointerup', (e) => { + if (!dragging) return; + dragging = false; + if (ghost) { ghost.remove(); ghost = null; } + ov.style.cursor = 'grab'; + for (const [oid, op] of panels) { + const ovEl = _editOverlays.get(oid); + if (ovEl) ovEl.style.borderColor = 'rgba(79,195,247,0.75)'; + if (oid === id) continue; + const tr = op.cell.getBoundingClientRect(); + if (e.clientX >= tr.left && e.clientX <= tr.right && + e.clientY >= tr.top && e.clientY <= tr.bottom) { + const srcRow = p.cell.style.gridRow; + const srcCol = p.cell.style.gridColumn; + p.cell.style.gridRow = op.cell.style.gridRow; + p.cell.style.gridColumn = op.cell.style.gridColumn; + op.cell.style.gridRow = srcRow; + op.cell.style.gridColumn = srcCol; + } + } + }); + + } else if (!active && ov) { + ov.remove(); + _editOverlays.delete(id); + } + } + } + + model.on('change:drag_mode', () => { _setEditMode(model.get('drag_mode')); }); + // Toggle the per-panel stats overlay when display_stats changes. // Hiding is immediate; showing waits for the next natural redraw to // populate the overlay text — but we also call redrawAll() here so the diff --git a/anyplotlib/tests/test_layouts/test_gridspec.py b/anyplotlib/tests/test_layouts/test_gridspec.py index 572f6414..d5179c9a 100644 --- a/anyplotlib/tests/test_layouts/test_gridspec.py +++ b/anyplotlib/tests/test_layouts/test_gridspec.py @@ -1075,3 +1075,41 @@ def test_spanning_subplot_correct_size(self): assert approx(ph, 200, tol=2), f"{label} height should be 200, got {ph}" +# ───────────────────────────────────────────────────────────────────────────── +# subplots_adjust +# ───────────────────────────────────────────────────────────────────────────── + +class TestSubplotsAdjust: + + def test_hspace_in_layout_json(self): + fig, _ = vw.subplots(2, 1, figsize=(400, 400)) + fig.subplots_adjust(hspace=0.3) + layout = _layout(fig) + assert abs(layout['hspace'] - 0.3) < 1e-9 + + def test_wspace_in_layout_json(self): + fig, _ = vw.subplots(1, 2, figsize=(400, 200)) + fig.subplots_adjust(wspace=0.2) + layout = _layout(fig) + assert abs(layout['wspace'] - 0.2) < 1e-9 + + def test_defaults_are_none(self): + fig, _ = vw.subplots(2, 2, figsize=(400, 400)) + layout = _layout(fig) + assert layout['hspace'] is None + assert layout['wspace'] is None + + def test_both_together(self): + fig, _ = vw.subplots(2, 2, figsize=(600, 600)) + fig.subplots_adjust(hspace=0.15, wspace=0.25) + layout = _layout(fig) + assert abs(layout['hspace'] - 0.15) < 1e-9 + assert abs(layout['wspace'] - 0.25) < 1e-9 + + def test_retriggers_layout_push(self): + fig, _ = vw.subplots(2, 1, figsize=(400, 400)) + before = fig.layout_json + fig.subplots_adjust(hspace=0.1) + assert fig.layout_json != before + + From 125ce5cab8c5de1b682f4c88736e9b226ab42318 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 22 May 2026 14:35:45 -0500 Subject: [PATCH 05/11] Refactor: Enhance layout management for Plot2D with dynamic resizing and title support --- anyplotlib/figure_esm.js | 202 +++++++++++++++--- .../gridspec_height_ratio_image_histogram.png | Bin 18214 -> 18447 bytes .../baselines/gridspec_image_two_spectra.png | Bin 15967 -> 16027 bytes .../tests/baselines/imshow_checkerboard.png | Bin 8587 -> 6288 bytes .../tests/baselines/imshow_gradient.png | Bin 4843 -> 4863 bytes anyplotlib/tests/baselines/imshow_viridis.png | Bin 14685 -> 14663 bytes anyplotlib/tests/baselines/subplots_2x1.png | Bin 14552 -> 14035 bytes .../tests/test_plot2d/test_plot2d_api.py | 119 +++++++++++ 8 files changed, 288 insertions(+), 33 deletions(-) diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 1dc475b7..0f18cba5 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -323,6 +323,9 @@ function render({ model, el }) { let _suppressLayoutUpdate = false; // block re-entry during live resize // ── layout application ─────────────────────────────────────────────────── + let _colPx = []; // current column widths in CSS px + let _rowPx = []; // current row heights in CSS px + function applyLayout() { if (_suppressLayoutUpdate) return; let layout; @@ -342,6 +345,9 @@ function render({ model, el }) { for (let r = spec.row_start; r < spec.row_stop; r++) rowPx[r] = Math.max(rowPx[r], perRow); } + _colPx = colPx.slice(); + _rowPx = rowPx.slice(); + gridDiv.style.gridTemplateColumns = colPx.map(px => px + 'px').join(' '); gridDiv.style.gridTemplateRows = rowPx.map(px => px + 'px').join(' '); gridDiv.style.width = ''; @@ -358,6 +364,8 @@ function render({ model, el }) { if (!panels.has(spec.id)) { _createPanelDOM(spec.id, spec.kind, spec.panel_width, spec.panel_height, spec); } else { + const existingPanel = panels.get(spec.id); + if (existingPanel) existingPanel.spec = spec; _resizePanelDOM(spec.id, spec.panel_width, spec.panel_height); } } @@ -384,6 +392,20 @@ function render({ model, el }) { if (insetSpecs.length) _applyAllInsetStates(layout); } + function _applyTrackSizes() { + gridDiv.style.gridTemplateColumns = _colPx.map(px => px + 'px').join(' '); + gridDiv.style.gridTemplateRows = _rowPx.map(px => px + 'px').join(' '); + for (const [id, p] of panels) { + if (!p.spec) continue; + const { row_start, row_stop, col_start, col_stop } = p.spec; + const newPw = Math.max(40, Math.round(_colPx.slice(col_start, col_stop).reduce((a,b)=>a+b,0))); + const newPh = Math.max(40, Math.round(_rowPx.slice(row_start, row_stop).reduce((a,b)=>a+b,0))); + p.pw = newPw; p.ph = newPh; + _resizePanelDOM(id, newPw, newPh); + _redrawPanel(p); + } + } + // ── _buildCanvasStack ───────────────────────────────────────────────────── // Creates the canvas/element stack for one panel kind and appends the // top-level wrapper to `outerContainer`. Returns all canvas/element refs. @@ -393,6 +415,7 @@ function render({ model, el }) { let plotCanvas, overlayCanvas, markersCanvas, statusBar; let xAxisCanvas=null, yAxisCanvas=null, scaleBar=null; let cbCanvas=null, cbCtx=null, plotWrap=null, wrapNode=null; + let titleCanvas=null; if (kind === '2d') { plotWrap = document.createElement('div'); @@ -426,6 +449,9 @@ function render({ model, el }) { 'position:absolute;display:none;pointer-events:none;border-radius:0 2px 2px 0;'; cbCtx = cbCanvas.getContext('2d'); + titleCanvas = document.createElement('canvas'); + titleCanvas.style.cssText = `position:absolute;pointer-events:none;z-index:8;background:transparent;display:none;`; + plotWrap.appendChild(plotCanvas); plotWrap.appendChild(overlayCanvas); plotWrap.appendChild(markersCanvas); @@ -434,6 +460,7 @@ function render({ model, el }) { plotWrap.appendChild(cbCanvas); plotWrap.appendChild(scaleBar); plotWrap.appendChild(statusBar); + plotWrap.appendChild(titleCanvas); outerContainer.appendChild(plotWrap); wrapNode = plotWrap; @@ -488,7 +515,7 @@ function render({ model, el }) { return { plotCanvas, overlayCanvas, markersCanvas, statusBar, xAxisCanvas, yAxisCanvas, scaleBar, - cbCanvas, cbCtx, plotWrap, wrapNode }; + cbCanvas, cbCtx, plotWrap, wrapNode, titleCanvas }; } function _createPanelDOM(id, kind, pw, ph, spec) { @@ -507,6 +534,7 @@ function render({ model, el }) { const mkCtx = stack.markersCanvas.getContext('2d'); const xCtx = stack.xAxisCanvas ? stack.xAxisCanvas.getContext('2d') : null; const yCtx = stack.yAxisCanvas ? stack.yAxisCanvas.getContext('2d') : null; + const titleCtx = stack.titleCanvas ? stack.titleCanvas.getContext('2d') : null; const blitCache = { bitmap:null, bytesKey:null, lutKey:null, w:0, h:0 }; @@ -520,6 +548,7 @@ function render({ model, el }) { const p = { id, kind, cell, pw, ph, + spec, plotCanvas: stack.plotCanvas, overlayCanvas: stack.overlayCanvas, markersCanvas: stack.markersCanvas, @@ -527,6 +556,8 @@ function render({ model, el }) { xAxisCanvas: stack.xAxisCanvas, yAxisCanvas: stack.yAxisCanvas, xCtx, yCtx, + titleCanvas: stack.titleCanvas || null, + titleCtx, scaleBar: stack.scaleBar, statusBar: stack.statusBar, statsDiv, @@ -794,10 +825,12 @@ function render({ model, el }) { const hasPhysAxis = st && (st.is_mesh || st.has_axes) && st.x_axis && st.x_axis.length >= 2 && st.y_axis && st.y_axis.length >= 2; + // Always reserve the PAD_T top strip for the title (mirrors 1D behaviour). + // Left/right/bottom gutters are only used when physical axes are present. const imgX = hasPhysAxis ? PAD_L : 0; - const imgY = hasPhysAxis ? PAD_T : 0; + const imgY = PAD_T; const imgW = hasPhysAxis ? Math.max(1, pw - PAD_L - PAD_R) : pw; - let imgH = hasPhysAxis ? Math.max(1, ph - PAD_T - PAD_B) : ph; + let imgH = Math.max(1, ph - PAD_T - (hasPhysAxis ? PAD_B : 0)); // Enforce aspect ratio (st.aspect = number or "equal" → 1.0). if (st && st.aspect != null) { const asp = (st.aspect === 'equal') ? 1.0 : parseFloat(st.aspect); @@ -806,6 +839,18 @@ function render({ model, el }) { // Store on panel so event handlers and draw functions don't recompute. p.imgX = imgX; p.imgY = imgY; p.imgW = imgW; p.imgH = imgH; + // Title canvas: always sits in the PAD_T strip above the image area + if (p.titleCanvas && p.titleCtx) { + p.titleCanvas.style.left = imgX + 'px'; + p.titleCanvas.style.top = '0px'; + p.titleCanvas.style.display = 'block'; + p.titleCanvas.style.width = imgW + 'px'; + p.titleCanvas.style.height = PAD_T + 'px'; + p.titleCanvas.width = imgW * dpr; + p.titleCanvas.height = PAD_T * dpr; + p.titleCtx.setTransform(dpr, 0, 0, dpr, 0, 0); + } + if (p.plotWrap) { p.plotWrap.style.width = pw + 'px'; p.plotWrap.style.height = ph + 'px'; @@ -1338,17 +1383,17 @@ function render({ model, el }) { p.yCtx.restore(); } } - const title2d=st.title||''; - if(title2d&&p.plotCtx){ - const tw=p.imgW||imgW; - p.plotCtx.save(); - p.plotCtx.fillStyle='rgba(0,0,0,0.45)'; - p.plotCtx.fillRect(0,0,tw,18); - p.plotCtx.fillStyle='#ffffff'; - p.plotCtx.font='bold 11px sans-serif'; - p.plotCtx.textAlign='center'; p.plotCtx.textBaseline='middle'; - p.plotCtx.fillText(title2d,tw/2,9); - p.plotCtx.restore(); + const title2d = st.title || ''; + if (p.titleCanvas && p.titleCtx) { + const tw = p.imgW || imgW; + p.titleCtx.clearRect(0, 0, tw, PAD_T); + if (title2d) { + p.titleCtx.fillStyle = theme.tickText; + p.titleCtx.font = 'bold 11px sans-serif'; + p.titleCtx.textAlign = 'center'; + p.titleCtx.textBaseline = 'middle'; + p.titleCtx.fillText(title2d, tw / 2, PAD_T / 2); + } } } @@ -4328,53 +4373,64 @@ function render({ model, el }) { model.on('change:layout_json', () => { applyLayout(); redrawAll(); requestAnimationFrame(_applyScale); }); model.on('change:fig_width change:fig_height', () => { applyLayout(); redrawAll(); requestAnimationFrame(_applyScale); }); - // ── Panel rearrangement (drag mode) ────────────────────────────────────────── - // When fig.drag_mode = True, each panel cell shows a drag handle overlay. - // Dragging one panel onto another swaps their grid positions. + // ── Panel drag / resize / gap-adjust (drag mode) ────────────────────────── + // When fig.drag_mode = True, each panel shows: + // • A translucent drag handle (centre) → drag to swap panels + // • Resize handles on the right edge and bottom edge → drag to resize + // Grid gaps (rowGap / columnGap) are also draggable via invisible bands. const _editOverlays = new Map(); function _setEditMode(active) { for (const [id, p] of panels) { let ov = _editOverlays.get(id); if (active && !ov) { + // ── outer wrapper appended to p.cell ────────────────────────────── ov = document.createElement('div'); ov.style.cssText = - 'position:absolute;inset:0;z-index:50;cursor:grab;' + + 'position:absolute;inset:0;z-index:50;pointer-events:none;'; + p.cell.appendChild(ov); + _editOverlays.set(id, ov); + + // ── drag handle (covers top ~60% of panel, pointer-events:all) ──── + const dragHandle = document.createElement('div'); + dragHandle.style.cssText = + 'position:absolute;top:0;left:0;right:0;bottom:30%;' + + 'cursor:grab;pointer-events:all;z-index:51;' + 'border:2px dashed rgba(79,195,247,0.75);' + 'background:rgba(79,195,247,0.06);border-radius:4px;' + - 'pointer-events:all;display:flex;align-items:center;' + - 'justify-content:center;user-select:none;'; + 'display:flex;align-items:center;justify-content:center;' + + 'user-select:none;'; const badge = document.createElement('div'); badge.style.cssText = 'background:rgba(0,0,0,0.55);color:#4fc3f7;padding:3px 10px;' + 'border-radius:12px;font-size:11px;font-family:monospace;' + 'pointer-events:none;letter-spacing:0.04em;'; badge.textContent = '⋮ drag'; - ov.appendChild(badge); - p.cell.appendChild(ov); - _editOverlays.set(id, ov); + dragHandle.appendChild(badge); + ov.appendChild(dragHandle); + // ── drag handle logic ────────────────────────────────────────────── let dragging = false, startX = 0, startY = 0, ghost = null; - ov.addEventListener('pointerdown', (e) => { + dragHandle.addEventListener('pointerdown', (e) => { if (e.button !== 0) return; dragging = true; startX = e.clientX; startY = e.clientY; - ov.style.cursor = 'grabbing'; + dragHandle.style.cursor = 'grabbing'; const r = p.cell.getBoundingClientRect(); ghost = document.createElement('div'); ghost.style.cssText = 'position:fixed;pointer-events:none;z-index:9999;' + - 'border:2px solid #4fc3f7;background:rgba(79,195,247,0.15);' + + 'border:2px solid #4fc3f7;background:rgba(79,195,247,0.12);' + 'border-radius:4px;opacity:0.85;' + `width:${r.width}px;height:${r.height}px;` + `left:${r.left}px;top:${r.top}px;`; document.body.appendChild(ghost); - ov.setPointerCapture(e.pointerId); - e.preventDefault(); + dragHandle.setPointerCapture(e.pointerId); + e.stopPropagation(); e.preventDefault(); }); - ov.addEventListener('pointermove', (e) => { + dragHandle.addEventListener('pointermove', (e) => { if (!dragging || !ghost) return; const dx = e.clientX - startX, dy = e.clientY - startY; const r = p.cell.getBoundingClientRect(); @@ -4386,18 +4442,21 @@ function render({ model, el }) { const over = e.clientX >= tr.left && e.clientX <= tr.right && e.clientY >= tr.top && e.clientY <= tr.bottom; const ovEl = _editOverlays.get(oid); - if (ovEl) ovEl.style.borderColor = over ? '#ff7043' : 'rgba(79,195,247,0.75)'; + const dh = ovEl && ovEl.querySelector('[data-role=drag]'); + if (dh) dh.style.borderColor = over ? '#ff7043' : 'rgba(79,195,247,0.75)'; } + e.stopPropagation(); }); - ov.addEventListener('pointerup', (e) => { + dragHandle.addEventListener('pointerup', (e) => { if (!dragging) return; dragging = false; if (ghost) { ghost.remove(); ghost = null; } - ov.style.cursor = 'grab'; + dragHandle.style.cursor = 'grab'; for (const [oid, op] of panels) { const ovEl = _editOverlays.get(oid); - if (ovEl) ovEl.style.borderColor = 'rgba(79,195,247,0.75)'; + const dh = ovEl && ovEl.querySelector('[data-role=drag]'); + if (dh) dh.style.borderColor = 'rgba(79,195,247,0.75)'; if (oid === id) continue; const tr = op.cell.getBoundingClientRect(); if (e.clientX >= tr.left && e.clientX <= tr.right && @@ -4408,9 +4467,86 @@ function render({ model, el }) { p.cell.style.gridColumn = op.cell.style.gridColumn; op.cell.style.gridRow = srcRow; op.cell.style.gridColumn = srcCol; + // Swap stored specs + const tmpSpec = p.spec; + p.spec = op.spec; + op.spec = tmpSpec; } } + e.stopPropagation(); + }); + + dragHandle.dataset.role = 'drag'; + + // ── right-edge resize handle ───────────────────────────────────── + const rHandle = document.createElement('div'); + rHandle.style.cssText = + 'position:absolute;top:10%;right:0;width:12px;bottom:30%;' + + 'cursor:ew-resize;pointer-events:all;z-index:52;' + + 'background:rgba(79,195,247,0.25);border-radius:0 4px 4px 0;' + + 'display:flex;align-items:center;justify-content:center;'; + rHandle.title = 'Drag to resize width'; + ov.appendChild(rHandle); + + let rDragging = false, rStartX = 0, rStartCols = []; + + rHandle.addEventListener('pointerdown', (e) => { + if (e.button !== 0 || !p.spec) return; + rDragging = true; + rStartX = e.clientX; + rStartCols = _colPx.slice(); + rHandle.setPointerCapture(e.pointerId); + e.stopPropagation(); e.preventDefault(); + }); + rHandle.addEventListener('pointermove', (e) => { + if (!rDragging || !p.spec) return; + const dx = e.clientX - rStartX; + const c = p.spec.col_stop - 1; // rightmost column of this panel + const nc = _colPx.length; + if (c >= nc - 1) return; // can't resize last column + const newW = Math.max(80, rStartCols[c] + dx); + const delta = newW - rStartCols[c]; + _colPx[c] = newW; + _colPx[c+1] = Math.max(80, rStartCols[c+1] - delta); + _applyTrackSizes(); + e.stopPropagation(); + }); + rHandle.addEventListener('pointerup', (e) => { rDragging = false; e.stopPropagation(); }); + + // ── bottom-edge resize handle ──────────────────────────────────── + const bHandle = document.createElement('div'); + bHandle.style.cssText = + 'position:absolute;bottom:0;left:10%;right:0;height:12px;' + + 'cursor:ns-resize;pointer-events:all;z-index:52;' + + 'background:rgba(79,195,247,0.25);border-radius:0 0 4px 4px;' + + 'display:flex;align-items:center;justify-content:center;'; + bHandle.title = 'Drag to resize height / adjust spacing'; + ov.appendChild(bHandle); + + let bDragging = false, bStartY = 0, bStartRows = []; + + bHandle.addEventListener('pointerdown', (e) => { + if (e.button !== 0 || !p.spec) return; + bDragging = true; + bStartY = e.clientY; + bStartRows = _rowPx.slice(); + bHandle.setPointerCapture(e.pointerId); + e.stopPropagation(); e.preventDefault(); + }); + bHandle.addEventListener('pointermove', (e) => { + if (!bDragging || !p.spec) return; + const dy = e.clientY - bStartY; + const r = p.spec.row_stop - 1; // bottommost row of this panel + const nr = _rowPx.length; + if (r >= nr - 1) return; // can't resize last row + const newH = Math.max(80, bStartRows[r] + dy); + const delta = newH - bStartRows[r]; + _rowPx[r] = newH; + _rowPx[r+1] = Math.max(80, bStartRows[r+1] - delta); + _applyTrackSizes(); + e.stopPropagation(); }); + bHandle.addEventListener('pointerup', (e) => { bDragging = false; e.stopPropagation(); }); } else if (!active && ov) { ov.remove(); diff --git a/anyplotlib/tests/baselines/gridspec_height_ratio_image_histogram.png b/anyplotlib/tests/baselines/gridspec_height_ratio_image_histogram.png index b65992f6651f11bb9664f09d5a426ee14aacb113..b23a8d43caa558ad515df4248728400f94125e75 100644 GIT binary patch literal 18447 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 V)#s()Kl(v=DK00L`&`fOe*v?fE?@uv literal 18214 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 diff --git a/anyplotlib/tests/baselines/gridspec_image_two_spectra.png b/anyplotlib/tests/baselines/gridspec_image_two_spectra.png index abb8d4e382f1358e1abf91c3d9bd24fd41615ea0..9bcdd255c2d912661194430ffefe8e7f7898032c 100644 GIT binary patch literal 16027 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 literal 15967 zcmdUWc{r5c|FZjQ3_XaOOdLk7cBMFmN`QuB3l( z{W{FqfO29luH9`6e{BC6Uwl9f<#dwm$o+`pw-P5|xAuS8#KyYg+~(-$ZEw-^)aK6d zB=qftL1GF{K^hPkPORraqtLJ%S9~OdY1;A_H+%`D<_HD!Gs@J%Nk2f%sfI{yruW&g<*=f}aNE`$tHu?ztbpafc zE<3FXru+MZZQ77?Pk9>)#>YO-e=mk%u^PXk%!u8@96CXrZCw)9|Kmd}+eZdx7M-dc z(w9We+6?!=_GrBqwi%@0i-APhZA z>HdypLAn&w*0U3TdTb)%tMjiIddzQUXx^vaF>Dv=JurqQ?@xy6`KSXo<(h?1@5MWB zIl#pjB7@ho%Sa(7GI54+3G>BYy(FUbO>(VgOk&_*jS<6d{< zUf4=?rNDbv%zzKTfloxnMEpR4V;^w5416NEJTSkJ`;Sl5hI3=cnf(hL!-uG?tkH2YAIxSOt_904PrzKss$ch7w>R z^y%jb29kP?x%Bi1uDxSKo`6%eFm`G<_~hM}s|!$P^%XXUX92FQRWXbGZ_n_ zqhY_B0bmAqCMw_lkJyXrB5cUbAa8xham} zVl({WC3$NgQ|^wnFjU=W}2`hDH!l;9tR5gaoamKzb| zlLu#wrruRaCJfj0pb~WN^DzL&+5xU|bIxyS?8B)U@1A-y?7yyu4c$@MVRG#M8$MGt z;WGWp54DOi8U!R06+x?3ZuWu4t){=-I%RhL_*aii%RgZj%}W@v0)hcNu7VI-d0zSb zs!aX7pF*~SH6xE0^59@{Gq3r@8qLWYYz(gfo(7DZx<9*?x!ON56mmwx{@l8u!KYq~ zNG{Xs)?kU#0Zzy9-+K9P1}y1)gCK(#f-VF`&~zZA)5^{MKMySuq$S#5Tl)uG&#f7A z8W?gB@F*(;74RvPBJnPYve;MXw@O3>VA3-2MlK?hiX2eF zAkzU3`7af3_x}K-DzjV!IiwayniJm&>6b zuf#y`De!yah#x^Le{#cMT>8CJn}AH8%g*qaS+*zuZ#4W+t6A%o#gwq~rgmweV1tG` z7gBfT!4}4%xsam;XJ=b9Yn`2$LW0mjXbu#jl=3hTAx4CEj6VsrquT5Akb!LAziatt zgl9j9?hOBbPHMa34Wyk;y1Pk(6B-s<{L_6#5Z*?*$Ocv2MN&I4zcllGq+e zq}>3^YWIma$shtotdwha%aUSPpy$Ly1h}D?ZB8~r()E?+6cHx{87om@t{fTG=Wv)o zIGS?bynVnhha*VuP5;r%fF&xCY0!3_@<9-FDn;-q=HTjD*l4l}NNbTV@-jh&v>{kQ zgIkvAzi$6cl{g!){rZg)pP{PFP}P2@Y6DcY8@f;tLE8i&VTf-;QfGf|p$^;uNueaX z*FTPz5QVZ_JPyp+4J?bcToiI6_}|pB&FKT~_Lm)VT>aku^L8>EFeDR{f9&8060U!& zwqV;`fDd4wiA^wP5FqnGRtJRGaD!8yJbD-qOclCY38FA=E!kYx6@80N4-8zq;UzbE zw~VeEaTFdW;1nkSkCW?{mgmmWsd3V2cig?dCg3EIj~QkG z6`j{OAC<493zSG(@F`dS3P|rhCO`!U0NY|>4C3a*YJOs=OJamP zu;~C)wGOJ<301{IRS8g4DP(r&gl}cgnhz^NP*EVDOaeXBg;MN5TsZ=SPa({^(~g_9 zc7u7n3bqgD7sxQ*<`Lx6mCWL-LDd-q7W2K|%9(8V#}8qEhrsC#fQGyz=Q9lb$BmIk zokE!Ttm7K~1cUN%3`4J@pbXFz!_fJzZ4e*}BX4Ii*&lfJyf@hmGM|O&oP$S`9n+tb z8C6-QKPfS)a!U8DhYCBVUu=RMRxAg%tb~Bf+&`a}&?MwsUY z>l_LFmu!FzY+kOBY`)xPh73TVK3=7&nlmi}Pu0%?re)Oc3_4|^Q!|9rcKFXQuvRN< zrwm5jE$c0Sb^bcGFj;@3ms7k@ahGOJQPY10?vL4g0ir#n-2$C(nApB^6rz`w*AY|2lk>Xm>lzm zvhcG6g1|peNR_Oon zrPQIp)PHauQVvTxI>jIUayQ zaOPj~A%V=y7_b&n{!78|)IVazf64yqnNdE#s!h5t?jT7GLQ+?5{;AxIgvUYDp3%WoJ za4Ppig$$qzA+B$NHK}gVjT`*S^(^!$XW~uJFAhc^6vW(NqeFgs$i0t;nx#RevC#_&K(_-UsVxqVG(W< zFyxJ2NOXi-RD@eWgj-sKTR_&1=L2px*w>Xn2Q(D6sn2FB&;TKnLI(t84nYm|e4Jmz zkDPFl8x}{ZHd$>TmyuBM^OPfa{}yk}-iR#RzA0^EGHd<*AWM% zU5l-2cI6N9s@T2}f|;gWyYE_yR*V?V4nuFH>Kuzw*WZ4sobmxAsInPMFwZnuY*o8!#bcC;0N;RKv3F%ki$R@ z`H+{81(J{s1C;h!%jN%TAE+ITGW(@608Ow=*RV`)DK)a~lP-bBsivI2=83moGvSAz zDS1vnaDcV90zH7PV}i0%8mJWdQk>F^`5G;KSl0};(L)BkWS?uFJRJY!xsE~M;kAF` zV2u_2!omM7N5QN!Lra;_c%SqvJZ_V6v$1mXDG13PLP{`rb0R3*QFz@e+~K`i?V*%e zcyCorD8&$zz@RCYMzvm4sNa6`CjNhc2`>6A;mx;^jnIwUg1;dFy*Cw$d{CYwxN(x6 zDC)EqgS0|6!Z*(GkE79hU8XmCsK{)v3ROw9_msKM<8OZ%lx!N;!i)RBE`yiz25Rxi{eO2WN=L?qf-3L}P>9qkJ6YV;@f1ESMTmcX$E`5`59_Tb zsEkkX7PqXuE`Ot0EQ}``bj1Dw{X>#L`oH`~olod8>)!-fxecEJt!1ueLt98u*$lRS zp?>dk^@yOqX_-aP|L_AIY&OO465>HDw1J?iZ`aM60;hN{hdZ*CxBbujKz$*a%l0=r(r@|u zK-m6P!J38t)>QBU1-|)j*>DWQ{YwRF4)b@6FpmvB%JHul!SsW_5$U7Phbm-1;cDp~ zncQL{6M}RBLE6zNP6fQ+ab?QQ`8ELVJ)=pPC6zwHzu)Fxp^1FCMTk}71}qLz4NtmZA7_U0@XzVS(M+A z(|v%Cfn@*z(7T2FOwK+As&88e${A!0Cg6i?y*SU<@R&?>1p6IG>x$(qj68w5KPQ88 zqoXpKl~Kj_KS!oph}sjzp<{xvxH#`fifJOu>j+JM7w13L}(_9oBrE=K+Y zsukx8c`Wq1H|%cmFb-=gui#VKYBVJYlP2I^;DxL>TK1aZ1PNJtWabFc?8?o25Sy%s zVm!1Dp1!qT+DC+Uwfgc^zAwTUu3C*j5EMaEQ0rFRyqL1n5XgIB-~((p>Qqk^mqu>- zm>(5+x!`_dw4WK;O}sx$Z0UBJcx!r<** z#)V(B*oVy*PdD)~cfX0Clb$5tz?`70odwggvaOi8o6f4jK1>~dAi~^fr-19rv>!|{ zcH;jVZgWxzQ(#`{-Ct^7V%I)D<~&gDf#eh9PRzk7rM6x_!+H24i#sl+v@<*DzJp6O z1x09x6!Yn`iN~@#bX`nTv|su)BXIEI3k@%~lFxbedBiQ^esdk*Cj^s~@9d9M@7t#` zTrfHOvk(m@psX7CV8(ZdiQHE=wNN4(2_fAMOjLCWth_FVkt{>}MjRYl}{XR7b7hWqui7cMx}M7|){$Ef~T zsz~9#DaYmAlNS%h(S~iLY*Ic$UE>FKYkHSnWIMD&k=~qV-`#%stO3ibs&5iXFf4W& zhhL0J?Wta!t6bGmn1J`4U65-D@abr@ol9G~vKk>ruSTiXVY&+}iH5w-YN= z@u?Gpr{i&N=d1mRHcr%wo{ovQmriNIBmUoYf`!C8PK;>g1^Bcrk5L_~D`q>)7Q3qx zLbvjeo8pEaU07r9W}P>%f;)c~UtGO4I--PY_qEmSoEzP>l~{Va#~8werhc|3LZ){+9T_OM8!xYw zc}`tg3c5dj&`z_&svm+QVaOVMC)*nD$l zDxtUh_|?S2ut(T|gUfEinaL&e6RE%Ne=BVBds#HXTWQ;UC=Y#f+D6!`Y`KP=pBS*Z zY^X7F1{-!RUgY(+CIxE?(2t46Y?@((NyYeq{c~OGZV4P?}Hl9RTc4}j#Xc- z;ST#XG+V@CHIGa<=Q}m+bzD)JrxYlTT{;+-t8iV#Sey#i#ah1oc9xbce`rOd^2*nJ zF7XrnVXcp@X)h*-@u={9kMr2RI@~&tr7fmOm-u<6`sT5i8^avunx9l_DN8)jIAV{W z!{HZK{YR&w&R!ZU*e2Zd!e2K%AZhiXbxKQl*<;ss zwK)_ytlsbHP^z4kVC8GKw%1|>PDAOj&lFN|{y*M&8Q}#|hxS%t@?6<4s(Qwqo+eM8 zG?%*j=mw0Y9@;LP<@a{x2X91*@M^=wSduUK7&<+eHjEUZ(ZWSD7xUp$7k|E*$nCr) zaloag?+y{=)+3erZFO#HMU_0OWhT*`@9om~Zh))x@ybEW?>8y5<|x@@7B$G^Xs*=B zh;GAhABF3*$G)>s%iLi%e4b_YOw9N9h>Y?-x)#q~>e`$0eZ?!)BmaAMji0mfnBMP> zS-MuRpF-^DO1fV&EAS><^I=jq z`(#(U56uZ&ismrlRrAw`FX8TsqzMr_(QRTuQq=Ei&#q6rqrS~@9YW>oorX8N&pTHp z|Ki@2k{k5Z@%ePj@utR+9>mC?{J_`=<}%$!WU;<*dn=+&N7B9aw(kc^Mo^Qg#I)4G z6QpG$nrUU6R5)YG9HgtoK=s5AY)AMMEk!cr>G`h<5KjK&zJOoJD-%6g-u#s@I+@d@ zT+>Hmf9}7C^*1MyUhq0O-}HdaEzLyAbV}3?JI?Q-4yElpu+-d{J~kn9XhP3XX}~P% zYn3k{HQ4SaUMP+h5DK$XFg!Ay>+j$>r*lvJ^mlJ$O{GUMX)D|CpvL1NRk^Rw6*HsB zy~L!UBRIsV=!PiYY_9_Gl4#zvGHYE`>AeAscD@j?1z9&Y1KEQ{pA&V zL>Dl4nPc(d0&h%XMDwjvj+IXgzK0>$_sA9FO%1Jv#6JsXA48BA-LL;7!+XGsf5d8D$-&dGpTcH zFHZbDIL^_O8Zl>P&4kNd%mrIhIp^e=k#8R}eFw7RUQc))65G|K=ZnCnRLh>wI6qD< z*x6yzjeXbnNdSZA?K`W7Z7d&j4=(ayQ`8QXqw^}CxX~W{RVvV~Yg>3R)3>FG2Pibb zB;!G$%8N(n&yG}K+;P9OcqT1-iO;{l?plwI`GqegUITN|y~aida#iCGB zffbSaO*746oOcrYi3Z1}IHdirT{jFA$+Or)b-QWqd)PbSTI#*=l@p$g!9-6468si! zi*q|%FEPVl2S)J+dCI$>r0r@YWpg9UU*fcN>5>{wYd74G#cP^g8uD~n>Gx6#K`L;t z^8HsP1piF}5wzx&?_J}$OTRt(mfi>Eleb3@l50|p z&bs@|hDyU?O6}~tySElg+uz>bMK;hqLcM@GQ;dgW=LSWhmZbux!ilFYSKYVqm9xG# zoO+jPlPAZHRJ|2$t624Gu)$P&s2BES@$w259&jEF#lUA9G~-3Z{8IfsR+!kh>U49b z>&nZ!TA)1Ibrcp!Qtt>WPs#=ZjEhz|qWzw#x8YR^Q;*D4eHb8d5ld2I^~muLaH6(e zI}RJ3&5EQ@9|C1+qty`zyO4ocLkaIT^Uo2zF$bZSD-FJE{@^$BX<*YB7p1#v^19F6 zOKPbsZq*zM&!lITLyn>l$uG0wKKxlRKT_36f%>pF#^TqJmv@s1o93>cj8%ahKGc7p z+V6(RM>Wei3{-?FgPMLSQKas| z#dgaHA`@7U0SdI`SLC|ZkjkTP5(u^Gyu_DFY~Q)3`@9;js(Z%KOOG?P3KKWNIQRBN zL!8CraRC%Q+>gnr#;DNEwPZ~vODt$jzbZ7AArbiq_D$X6 zTHD@`a6%$!1n>B~(JJs`R`syQ9C}jFS&m*5Zm+i9VdP9#_4#+mF8|~ej%c%80k>`M zTH*a;Eu7{3+4k#a#E?7NDaBcc+3wH_<6KiJ%S66j`@+nzO5>+~pY1=toWbTH`_!nh z2lYj?{g)na3mh~|O}yr?b{56zBte$BVM`Q4v;q(dWas9)KC zPL0k%KaOk@nzYSTTF@|=AV5H@6qhLfNUA?wUHu)7nF?tz49s|R#m%o7tLbbmEP)L- z+>UJ>!30C>{4Us!CsZ#dcxI5FO;mOCBnm2R z`P@zsRw@`uqo5jNAoNW0C z)ZIjn^F5r%zM_tFU3_WuzWalQHj5W)m&?Oa@7PpV-Jh~GA==nURc>4G&w1l$W-nFK zdb&qh^SHy#tYpH~5n1D$fJuc8m8rAnu7WX*yz(T1s(Nb)HgNF7D2ta4^kEMrP8pq) zVw=+L@!oH$w}@t!9p^gQaDZaBDNXv*p{+v5$TD`!BI| z%Hf+L_0zQcR$uMY`RpHr6K(2Di$uuUjxCSvFko5scl-R|n7Ks~cKLfu6LO(#`IGwN z#Hcyf6qopcIE!~X`b;Z^skgY@&5(&6d}MgusO%!j`|Ul?9YoT@W+@$#Cl6Quul;vA zq)w7!UlPIwb?7a;qu~w;=(Hp;zg<6?kM~Dj1d}=Y@3=cdM5$k!BSv~8da#jO9RqzD z2iWQ3+hNt|GYP88wB^h)cENzKoKFHLt6m)(GVUB2`F`T`cU;gbtmeeZIQpKV#N=rI zu>Zh=J1P^}$)%1VZE$pFuCXbag{lS-Y+Nu5{6^(bkQ_3->g^u%)qn+?^Wpd`*BGC` zZz>Oy=$$tsRaM0?vO&{>j<^$sw04TDT5H8eP2<;MzP~uzL+e{9C}5VH^6en!e0ze= zjZLFk^|xCr(c&WY@};hoop;%qEcWZRL81%BWe4S_avavCATwjfre|Gd{e?NQc7EJfG-q%-e02|qYy%<~veOmOFiqC+t;@|XiM~+(Hw9xmmN<5=-kj5E z&#(|~S)b9mzv|8LmFUeMzEmp+c_PA++uK_ndX81pi7XR7D?)q`i|VZ)VRanNFEMfz zUE;WR>l?k6L@y&HI42yK;yyOAaBe=Vd;j1GX=ZjA@l`ffuWDS9{`veePe<*9=Zl4< z?_V1}E<)A!;bQY2q!7dwTflt5%=vD9;C7jP4BI#AMOrz3uSVb`RCY9N9{PI9t%aE* z49QYskY4Y6RxCRPOw9wZqY1_zh}2&DFhLWLjZ_7UF12U+whcNR9VSwbRlKSPc`HJI z=%IW#l1Gh|dZP?yJ#;#S#m(`4BVUM~A_$A8psX;df!U>h#b5-6J5=@!wab!!()UQ!^q4=it{yFeiA9}f zn%b-p7TG%fvtt&yP`vUvBvtY%kvM-h5pyo|F#Gd~Dd&ME6<2*booFnjH1Qf^+R(LW z0YJ?Aw#k63^Ol+2(b@66tK6S_mBv?k{0bUMp$R`Z-gf*}s)6yH9_ns*)2L|BkDggd z?$&v(QYyB;d<_%zE_Zr-S45NQ$>S4}c7v2Q>W*;~!M*~@xy$eSjKZWgM8Ns}(50gb zXWn&k4;$>dYTU-Xr~P3)5<6q!N>7{QqkE`}NNETU^tyfgf#Z3*wT%BlsZTT^;nrD_5qVfS%wy2{Tl zhv5ja0lk41)A7YUeeZzx^`tk#Ipr@1!aloE-ay~r$5jk{%Nw#_Pvh+-poXzRPE9}9 z&NXw`XtJL1T(J83(2L#iw*p@FbTb2Z%QwEthK$3wByE{MAhehS5g)+Tq-&g5 zUH$vnglChp--E-GKm?^A4ZCW$zBUcmTCp7A8ZUWRLM^T!Fje8*%m)~$ozgI;{yN`q zsNm_3r1Jy%*g1C`Zr;kwCMCz)oyoWP!yLQ&M;A?|mp8X6B)zXV6Fbfky{I0U+9g+< zwR8+O(YsbyRJz7np=Me^DMiB0=9A?_dSl2^C{2MqRpn!@ zdw>-{(NA%87SHo3*zQ2TdGCzXM-BRqP5DnJCR>I2aKG!{Mh4HcQzDb>V0#%XAIxO= z5DY#QpK@PHF1+QLVXbzuFHeJ_DlM6%9WEYc@hAq|NVDE=)91MZWTrSPn`>dVJ?`$3 zL{i^etAMe1H^Kxr^7<{$!ijO39j;HKUO5$;a+}DH_+ zTDaeUO$G&&cA_^6PdF@OWO14xe`dePs9n3k!&<*-BEr9SX(~!-*UFx9d!4uY=<)HK z?Z5Br$IWhEyp*CyuvbUddA>5ktC{{pkw&V#e}1>`%xq27Bq$I)$lU{~SN7k@AMeH_ zMU@Jilt_Dgdx0a|SO3bMsyj1nvRX32>a5QZ0}9kWG1~F@_BP>Yvlp9&q>$(1Zq^a0 z_bv|kf2IAFiTG%>FQk3>-tLE_AlJ6Wbj|{`bddi=N`Aziazacv<&BPCw<|^zY7m`q z6jYH_qq%3FP3nljUu|4>C3M4pDDmfZUO_yU04cAny*0x8p*Etn=Cyt>?S@zLXEj#a zJV>07SH`A=la&>G#-E%+F~+aQM8-WEAI&IJTd!L97Q2$R%>>aX@hT>jUy4G0Z7F`_ zB%P#kojw>VUj>hB%)_RnM{G^9P5D?pNpp!edi>s>s2d?t=K1#AC!!2kfSF5=uHwBT z1=V=xpWL5lhHPGq9y8;$&-@+@DaQzTir>U8=VnQECM9Xm8mOlNzPG3BAoUzWFAK;; z`nXem7N}Hd`pbQC!^eY0mxi{=??C;_PtVe3&R>VWf8~QNN1U>%OU&pBf6GOmmx^EI z*w&?)sq)OZOx^fdwsBV5i{t49D`(Av`!TNRt;^T2+0&sUKPlO7jxk49HSc;3y$D$@ z3dnn-+e%raUhtDqi7!&gyKZDzL!6heA0nEgZ5xSUo`$L_<&EC63BO(}gVw9WqR=u5 z>OPq*(d*Y5@7a>pji&MjWSu=Cm}#9T*x^P{E-|q8)07!YSe%>e9LjmD;l$Sgct9;( zH|n|G9?%m%h|n)F@6!?>5)8t>9=5nSqyoP>q=f(7sHL!#&XY!t=c$=e^1EPRAs3d7 zVf}CeVerUp1y77TpiMfhn%$}4IvD_8h};xKt?+54=CpmmOjpsKq#*T$ul;zD8(rKO zWJ2~J+N{RE`+PAh*+-T-!TYS+{*YXAJLE8^0vt`smGYjM-B*->OkOb2ADs^>@k+-YfxDghN@5k_^UqAp4@vCl zPzq@%TDUc~*WFCw?l`Iq`uhagTKR!b_bW84Iw2bs{{8AguT|7X1-f?xmU2hp-Y~0| z$Y;;(&l2Qoh;4ECJ4}?d(6yBhR6pqp4G-#fL+6x-5tJW6KH0ht1YUl*oAs>)RCl-e zb93K=7V7>%k+2zTs{$R(7gw}8+cMD0n(cpL*nIi;Zt%#KL@K}dpXViKgm>Gw6T{ng zYcjWf9HLpl<61hmA2J`F%oW~kgu(YDS|PdH5+iqX#~0ilP`Q%o|9jFFX&*|-AMK7i zb}v8(A1_zENX1$B&eV51$e8BG8IsXep$C5=^c`*33^})oBao#nlwK#K@fF+Fhwn98 zHSgmuA_!GuW|$s6L2T`H_YlQ8&mLFDeos?d5-HxopXl1CNo$hKGa!w0nOgWw6*V?5 z*0r8TXO_v|mtyDSN2}i$_jE$x4jnGBJu{Lzo?2sb5Dm9*i{G^HDkg+S2=zQu7=?>8 z>vSl5Km1rG|IUc)5KN5}XKa0pmUG0-Vh=YlaZA{Zf_%6*^%`FSAtt)pDe6GhQkWzc z5*r?y%+k75x%~mHBq6i0q)qJtN1Rk4tQk$ib>aDu zr_0Z3@(x*^lYGfj5~lv#Y#g;eLy2JZ@zCl>Q!UIb{;uILt+T&K7i$-J^Zr=Km$*7l zWNY4JuRXWS9iqj(k&sveH9fUw6Y~0`fy5o5it^=!UKqmobL>De1s-c`OZunMuD|$# z$3@aqoZLfDb>ii#u5+a-_9-Z{$}(@8)gI7X)8bbh+osw@GC`n;ZsAWp=4Cs*^dR?+ zk3`x$#pc=h98o1{iyC3Fuu?7yhq^VJC-lb58{N{ajHbc%213?BeQJ-&$!?gCSb;^g zxw+3%GetaKvwBV|6>M+q3wU`hV(Q*y{SJ1BFa@2mKhqO+!X{AtljyC1iCwdS?yJX*`r`8<&x3ETg8^R%59Qp~;}r8IdI_!<@7THK`G!&6=%|r7P zB$dfh23cpqX_Ktkmx+wZ&U-)4`=;K$p7(do`QVFte)l}r_582@a!u6kT|1>ER!NA6 zh)9zxE%u6tELjfyEfWR*nK)juO+-Y&o@8NWduGXpEAL~m254sgT$(>~7KKqYpl6q8 zaV+`EX-gdC(P)L1xg`GeXD6_yC&v04(vFmd1TW0bwQxKaCackJ8C|9p>qIfSQF%eL zvr`X5jPnrwBE;oRQx^u#*#x;D1*YDr<-$GaU@B4|;>TrVyqQE8?pE@m-qA3=k9f5N z8F8>%Mqmm~Zl_K@L+?gn6;56>$wRzeg3Jo+T4`tsK6>k?c`n)(iDeH(+5EpQIVav% zeMzM>bRl+O<#?!xN{>eRv`Tr1t&qqFQo%Nrb?p{s-Vp6^>ctcGiP8vXqA`O?De)7R z=Q`r5*H#%vq~RjO`*8R@mVsMQ{*uG9Byp>}wp@=R2p^XnXudTHnN=jH%}%xaBdODl zGyKqyaIfw5b60$n#v1;lFXfv8T-*m^o=w7K4??^^rq!+fnA-N;i z>kuMZr|HP%P{!-~36QKquC( zGIr(hbg`XFYbxrbVkP1tllH%5kXC2#a?!FQvWB}w?z(@rCjYL_(TEbqRB$K5^t+Q2UeIkqR;@hfn1lZqf5clLOaECx}%m#WfT+ zy$&X7<=`5Wz_n+@g?U0bxY&~IsIxnf>enuRtE1nGpM=oArW*Q-b9wyZ93#5Cem(9} z=Fw_Yct?jjQhoJwqufFN0rH!bG1@?%cxE9_`GfCSAYQNOqX;qPSFQXHu=UYS(Jx+ zMy5p=zPPWI;Z5CfVaNrmL7)c1b4ctl3yBu6xNdvIJ5uPcke=Qh`KOr5Th!3x0$~RJ zp}_6^Av2r4$a|$Y0OOf-Z*TT98m?>%+oSWD)f@aaKRYtIGWiz>$#IB;db4hJ00tXi zYCL+_e!_{DtDLLcpBvAAa;Azg^^Dl3n|hLO&>SoWaU)Tvb~SBAy#y2MXs-&?zB$T71uwcEQ;CLcuhMMJKX<-s<GLF%%PO z`VhaISjSH^Rg{ViV9c_>Sb^3%g4WNo^>BH5OeOZ!K|7tyTnNvmRse|EE%Onzo2(o! zcfOIc0&gJyRK1b{(lz|nt}1U)#2chO4v?O?au27*1I3r4eY}>kGx($21ipClK!$l2 z*F{_9-8NpmT)pfkmypS6N*DmM%qsav(jhwxw!v(EujhgGNPKbNV~3z-TYzU71HvG7 zQ!HI$v4;hMq4z`HBU-hs(Z{fCn;7SIvt-4^_Cs8iN4w{)XoytbdO3m=_LX6LdaPR$YvGUPT ze`~7RCABlr8GEz0Dfi%u--&L${nlIb{X{?uW32Skr9YCYiol#lSbTcO`xCCz=J+WLP2!froYHA!a-`lQj-6t&7ItsKG7lzC)k z@9Msc!wz5MtBb(AZrU9N$q`K)W@;?~H}hg6?TswTu2?ks_eQ^^0K5e`1-<_~@K*Hx z)!_XP(R`bOGW?wH%PBbH; zkKOML0A_{T#rvEr9S%T!dd%&B9T>e!X6!Kv6KgaZ$kc4J)#xTN zmJBmt6SYQ0NO9qaxkl?v*7I)|!`5TH^U@?vy_ z+D2_uY`<#gZO(?#3gA-?ZFuSi(QqV|?TcPjU6QcaLyG@rug}DQB$ts&eKcRAN_RA! zC-t@cK!XA8vWzE)#^ z_37o3HENE$WaoirvT3i3@j-oM;$j>jekT~x-M9CH3W4clq}l=t^>L>719$SsboGFh za~lvT^R?51`^vRotXgbVIc~Mu3HH7vl0BS~8&KZ|-|3MjsuMLCNs500!UaEEHCw{{ zRM=n?FO}4Z9|3Ke%`kr%@po8hmk-HmGRdP=izP}!n}?;yy0F3&F| zz5#|moq@s1f)t?-Gqs%)Qr$g#;{#xA-pM)`ozK;V3@Ysr{%?V83!^r|_~CihHT+fW zf!f)RpE+V2vA+F(v|87xVj~~C6&B0Ho}!NRPV>Z}Bvp8(V1qUoy=qndBt8$(9XhBo zUPNQF%F3EkOiH<@f+zt9)y#X*bK7I(R?tOsdWrUovenO+GKt}H97;PU! z!#&(gpGIQewZ&`#ne6Q9!fcQgQoX6hy0LVmBY61#hC-P3SWgOQ0xbKJ#VYkhYWK2r zuue4uabZmA``G;@7ERu=?prnW@7dZ&*DYrAzg30fybsWY8(ptjFETNnaoH%o6CYu9 zSArhL5uB9#7Iq*p++n|J7>JZ2<`0`Rj9oK`7gcKcQ>OdgErY#px1(xluM*tgCYDo? zNfw;Hkm|>ZUQ2?&3dlGvo`Go{Dkyc*y9h}8@uTzc7*v7aHy_B(t+|uneU*Fea~bRy z6)P*|!pem_`sk{u>Q}1}7=H zHJ_qX)iX#onNIPG)V}llqn<)$k|MSNxSe)UxMa?HCyIxwB4Uz55&byjE2+4tuC5oSvKniX@OG@X** zR<6Awv+3r=|HQZUr9ukO1kiV1cg3t3=Q?P4@FyVL^21djhpE%8$CODCR&a0$oNrun zVk&}uOP)1Xc|R8ze(6LwiKoC!oiAZXII+$*1)%G7F1Jh5*U=1s7oY9^4+O85!CwvD zALgL5e`!`jaNlK@TVhb^&hD2{if4JUBLkh9k6?#ET5U8%!3!isn5nb#5DA;NB_FU? zbNC>!A$41mEM!pg-?9D{*nZ%;nE#MoDWcO`)^r0ePD4dl}R7v%x~JAH>v@; zz4U8F2ZjrX;SKGJen6z8q6U8dy5qz&2c>Jehr`}?Y-3!9Fcxm`w-qPAntt2Rd9ZCM z5UjGSHAQ*&wR8|tc6_&Gd|n9Ix#h3H7M05Vep`xXG+&o`sy}{92C^Pa;#++!v23?q zbIS4N=JLX@RKCoSP;2l7U(C9@2vIz%ux^8zBSUx|oJHiLb*?}(yaw#un7}K4+X_J@ zdu#pwZD-oLoZV4sJ|$fDBvlMVGFaEgHDI~dVb1s4NG+K3^fRw6jCWUGO6FVM(y;;xXGbsP?4Fqz4IQ7EY5Tx!Qn~=|_Q!{1*bfn*4-hL@V j3&$-?*Q&2zEi4(W;LW>it^E!B(jY?GzRMza8#V0T3!=|; literal 8587 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 OvvI@b_2nV71OErRDuB@d diff --git a/anyplotlib/tests/baselines/imshow_gradient.png b/anyplotlib/tests/baselines/imshow_gradient.png index aa9f6c303a9b3aefc6b4215348710d21cb1ef8cc..419f23e13f26129487a56ee01f09d0d83bedc667 100644 GIT binary patch literal 4863 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 Wdq+V(6@iN_$ir>NrqYeUC;kV(yK(*i literal 4843 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 b{Jc;P{NyDmS`WA&Nd(r=Q!i0{9QOYJrD$+P literal 14685 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 F{|7x1sQUl_ diff --git a/anyplotlib/tests/test_plot2d/test_plot2d_api.py b/anyplotlib/tests/test_plot2d/test_plot2d_api.py index b00fbc02..fa197c42 100644 --- a/anyplotlib/tests/test_plot2d/test_plot2d_api.py +++ b/anyplotlib/tests/test_plot2d/test_plot2d_api.py @@ -276,3 +276,122 @@ def test_get_color_cycle_returns_copy(self): def test_get_color_cycle_nonempty(self): import anyplotlib as apl assert len(apl.get_color_cycle()) > 0 + + +# =========================================================================== +# Figure resize — Plot2D correctness +# =========================================================================== + +class TestFigureResizePlot2D: + """Figure resize correctly propagates to layout_json and Plot2D panel state. + + The _on_resize observer calls _push_layout() (which recomputes panel pixel + dimensions from the new fig_width/fig_height) then re-pushes every panel's + JSON. For Plot2D panels the panel JSON must still carry the full axis state + so the JS renderer can correctly position tick labels and scale the image. + """ + + def test_resize_updates_layout_fig_size(self): + """layout_json reflects the new fig_width and fig_height after resize.""" + import json + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.imshow(np.zeros((32, 32))) + + fig.fig_width = 800 + fig.fig_height = 600 + + layout = json.loads(fig.layout_json) + assert layout["fig_width"] == 800 + assert layout["fig_height"] == 600 + + def test_resize_updates_single_panel_dimensions(self): + """Panel width/height in layout_json match the new figure size (1×1 grid).""" + import json + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow(np.zeros((32, 32))) + + fig.fig_width = 800 + fig.fig_height = 600 + + layout = json.loads(fig.layout_json) + spec = next(s for s in layout["panel_specs"] if s["id"] == plot._id) + assert spec["panel_width"] == 800 + assert spec["panel_height"] == 600 + + def test_resize_plot2d_with_axes_preserves_axis_state(self): + """Plot2D with physical axes keeps has_axes, x_axis, y_axis, and units after resize.""" + import json + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + x_axis = np.linspace(0.0, 10.0, 32) + y_axis = np.linspace(0.0, 20.0, 32) + plot = ax.imshow(np.zeros((32, 32)), axes=[x_axis, y_axis], units="nm") + + panel_before = json.loads(getattr(fig, f"panel_{plot._id}_json")) + + fig.fig_width = 800 + fig.fig_height = 600 + + panel_after = json.loads(getattr(fig, f"panel_{plot._id}_json")) + assert panel_after["has_axes"] is True + assert panel_after["x_axis"] == panel_before["x_axis"] + assert panel_after["y_axis"] == panel_before["y_axis"] + assert panel_after["units"] == "nm" + + def test_resize_does_not_alter_data_scale(self): + """Resizing the figure must not change Plot2D scale_x/scale_y (data-space quantities).""" + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + x_axis = np.linspace(0.0, 10.0, 32) + y_axis = np.linspace(0.0, 20.0, 32) + plot = ax.imshow(np.zeros((32, 32)), axes=[x_axis, y_axis], units="nm") + + scale_x_before = plot._state["scale_x"] + scale_y_before = plot._state["scale_y"] + + fig.fig_width = 800 + fig.fig_height = 600 + + assert plot._state["scale_x"] == pytest.approx(scale_x_before) + assert plot._state["scale_y"] == pytest.approx(scale_y_before) + + def test_resize_plot2d_with_axes_layout_kind(self): + """layout_json marks a Plot2D with axes as kind='2d' after resize.""" + import json + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow(np.zeros((32, 32)), axes=[np.arange(32), np.arange(32)]) + + fig.fig_width = 640 + fig.fig_height = 480 + + layout = json.loads(fig.layout_json) + spec = next(s for s in layout["panel_specs"] if s["id"] == plot._id) + assert spec["kind"] == "2d" + + def test_resize_two_panel_splits_width_evenly(self): + """Both Plot2D panels in a 1×2 grid each get half the new figure width.""" + import json + fig, axs = apl.subplots(1, 2, figsize=(400, 200)) + plot_l = axs[0].imshow(np.zeros((16, 16))) + plot_r = axs[1].imshow(np.zeros((16, 16))) + + fig.fig_width = 800 + + layout = json.loads(fig.layout_json) + specs = {s["id"]: s for s in layout["panel_specs"]} + assert specs[plot_l._id]["panel_width"] == pytest.approx(400, abs=1) + assert specs[plot_r._id]["panel_width"] == pytest.approx(400, abs=1) + + def test_resize_with_height_ratios_scales_proportionally(self): + """GridSpec height_ratios [3, 1] scale correctly when fig_height changes.""" + import json + gs = apl.GridSpec(2, 1, height_ratios=[3, 1]) + fig = apl.Figure(figsize=(400, 400)) + plot_top = fig.add_subplot(gs[0, 0]).imshow(np.zeros((32, 32))) + plot_bot = fig.add_subplot(gs[1, 0]).imshow(np.zeros((16, 16))) + + fig.fig_height = 800 + + layout = json.loads(fig.layout_json) + specs = {s["id"]: s for s in layout["panel_specs"]} + # top: 3/4 × 800 = 600 px; bottom: 1/4 × 800 = 200 px + assert specs[plot_top._id]["panel_height"] == pytest.approx(600, abs=1) + assert specs[plot_bot._id]["panel_height"] == pytest.approx(200, abs=1) From 578c63cd80822dbcf7d9e347afb711a973af0c53 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 22 May 2026 17:26:16 -0500 Subject: [PATCH 06/11] Refactor: Enhance Plot1D and Plot2D with new axis label, title, and visibility controls; add get_xlim method --- anyplotlib/figure/_figure.py | 8 +- anyplotlib/figure_esm.js | 535 +++++++----------- anyplotlib/markers.py | 2 +- anyplotlib/plot1d/_plot1d.py | 22 +- anyplotlib/plot1d/_plotbar.py | 79 +++ anyplotlib/plot2d/_plot2d.py | 4 + anyplotlib/plot3d/_plot3d.py | 2 +- .../tests/test_layouts/test_gridspec.py | 26 + anyplotlib/tests/test_markers/test_markers.py | 106 ++++ anyplotlib/tests/test_plot1d/test_plot1d.py | 132 +++++ anyplotlib/tests/test_plot1d/test_plotbar.py | 133 +++++ .../tests/test_plot2d/test_plot2d_api.py | 30 + anyplotlib/tests/test_plot3d/test_plot3d.py | 38 ++ 13 files changed, 792 insertions(+), 325 deletions(-) diff --git a/anyplotlib/figure/_figure.py b/anyplotlib/figure/_figure.py index 67358fbd..c9532420 100644 --- a/anyplotlib/figure/_figure.py +++ b/anyplotlib/figure/_figure.py @@ -66,8 +66,6 @@ class Figure(anywidget.AnyWidget): # Figure-level help text shown in a '?' badge overlay in JS. # Empty string means no badge. Gated by apl.show_help at the Python level. help_text = traitlets.Unicode("").tag(sync=True) - # When True JS shows drag handles on all panels so they can be reordered. - drag_mode = traitlets.Bool(False).tag(sync=True) _esm = _ESM_SOURCE # Static CSS injected by anywidget alongside _esm. # .apl-scale-wrap — outer container; width:100% means it always fills @@ -161,10 +159,12 @@ def subplots_adjust(self, hspace: float = 0.0, wspace: float = 0.0) -> None: hspace : float, optional Fraction of the average row height to use as vertical gap between panels. ``0.1`` adds a gap of 10 % of the mean row height. - Default ``0.0`` (no gap). + Default ``0.0`` (no gap). Before ``subplots_adjust`` is called, + figures use a 4 px browser default gap. wspace : float, optional Fraction of the average column width to use as horizontal gap. - Default ``0.0`` (no gap). + Default ``0.0`` (no gap). Before ``subplots_adjust`` is called, + figures use a 4 px browser default gap. """ self._hspace = float(hspace) self._wspace = float(wspace) diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 0f18cba5..70d6f669 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -323,9 +323,6 @@ function render({ model, el }) { let _suppressLayoutUpdate = false; // block re-entry during live resize // ── layout application ─────────────────────────────────────────────────── - let _colPx = []; // current column widths in CSS px - let _rowPx = []; // current row heights in CSS px - function applyLayout() { if (_suppressLayoutUpdate) return; let layout; @@ -345,9 +342,6 @@ function render({ model, el }) { for (let r = spec.row_start; r < spec.row_stop; r++) rowPx[r] = Math.max(rowPx[r], perRow); } - _colPx = colPx.slice(); - _rowPx = rowPx.slice(); - gridDiv.style.gridTemplateColumns = colPx.map(px => px + 'px').join(' '); gridDiv.style.gridTemplateRows = rowPx.map(px => px + 'px').join(' '); gridDiv.style.width = ''; @@ -364,8 +358,6 @@ function render({ model, el }) { if (!panels.has(spec.id)) { _createPanelDOM(spec.id, spec.kind, spec.panel_width, spec.panel_height, spec); } else { - const existingPanel = panels.get(spec.id); - if (existingPanel) existingPanel.spec = spec; _resizePanelDOM(spec.id, spec.panel_width, spec.panel_height); } } @@ -392,20 +384,6 @@ function render({ model, el }) { if (insetSpecs.length) _applyAllInsetStates(layout); } - function _applyTrackSizes() { - gridDiv.style.gridTemplateColumns = _colPx.map(px => px + 'px').join(' '); - gridDiv.style.gridTemplateRows = _rowPx.map(px => px + 'px').join(' '); - for (const [id, p] of panels) { - if (!p.spec) continue; - const { row_start, row_stop, col_start, col_stop } = p.spec; - const newPw = Math.max(40, Math.round(_colPx.slice(col_start, col_stop).reduce((a,b)=>a+b,0))); - const newPh = Math.max(40, Math.round(_rowPx.slice(row_start, row_stop).reduce((a,b)=>a+b,0))); - p.pw = newPw; p.ph = newPh; - _resizePanelDOM(id, newPw, newPh); - _redrawPanel(p); - } - } - // ── _buildCanvasStack ───────────────────────────────────────────────────── // Creates the canvas/element stack for one panel kind and appends the // top-level wrapper to `outerContainer`. Returns all canvas/element refs. @@ -548,7 +526,6 @@ function render({ model, el }) { const p = { id, kind, cell, pw, ph, - spec, plotCanvas: stack.plotCanvas, overlayCanvas: stack.overlayCanvas, markersCanvas: stack.markersCanvas, @@ -593,6 +570,9 @@ function render({ model, el }) { newState.zoom = p2.state.zoom; newState.center_x = p2.state.center_x; newState.center_y = p2.state.center_y; + } else if (p2.state && (p2.kind === '1d' || p2.kind === 'bar') && !newState._view_from_python) { + newState.view_x0 = p2.state.view_x0; + newState.view_x1 = p2.state.view_x1; } p2.state = newState; } @@ -715,6 +695,9 @@ function render({ model, el }) { newState.zoom = p2.state.zoom; newState.center_x = p2.state.center_x; newState.center_y = p2.state.center_y; + } else if (p2.state && (p2.kind === '1d' || p2.kind === 'bar') && !newState._view_from_python) { + newState.view_x0 = p2.state.view_x0; + newState.view_x1 = p2.state.view_x1; } p2.state = newState; } @@ -2011,7 +1994,8 @@ function render({ model, el }) { const yData = p._1dDArr; // Float64Array (or plain array fallback) const x0=st.view_x0||0, x1=st.view_x1||1; - const dMin=st.data_min, dMax=st.data_max; + let dMin=st.data_min, dMax=st.data_max; + if (st.y_range && st.y_range.length === 2) { dMin = st.y_range[0]; dMax = st.y_range[1]; } const units=st.units||'', yUnits=st.y_units||''; const isLog = st.yscale === 'log'; @@ -2400,7 +2384,8 @@ function render({ model, el }) { const xArr = p._1dXArr || (st.x_axis_b64 ? _decodeF64(st.x_axis_b64) : (st.x_axis||[])); const yData = p._1dDArr || (st.data_b64 ? _decodeF64(st.data_b64) : (st.data||[])); const x0=st.view_x0||0, x1=st.view_x1||1; - const dMin=st.data_min, dMax=st.data_max; + let dMin=st.data_min, dMax=st.data_max; + if (st.y_range && st.y_range.length === 2) { dMin = st.y_range[0]; dMax = st.y_range[1]; } mkCtx.clearRect(0,0,pw,ph); const sets=st.markers||[]; if(!sets.length) return; @@ -2469,6 +2454,63 @@ function render({ model, el }) { const [x2c,y2c]= tfm==='data' ? _offToCanvas(seg[1]) : _tc2d(seg[1][0],seg[1][1]); mkCtx.beginPath();mkCtx.moveTo(x1c,y1c);mkCtx.lineTo(x2c,y2c);mkCtx.stroke(); } + } else if(type==='ellipses'){ + for(let i=0;i dMax) continue; - const px = g.xToPx(v); - if (px < r.x || px > r.x + r.w) continue; - ctx.strokeStyle = theme.axisStroke; - ctx.beginPath(); ctx.moveTo(px, r.y + r.h); ctx.lineTo(px, r.y + r.h + 4); ctx.stroke(); - ctx.fillStyle = theme.tickText; - ctx.fillText(_fmtLogTick(v), px, r.y + r.h + 7); - } - } else { - const valRange = (dMax - dMin) || 1; - const valStep = findNice(valRange / Math.max(2, Math.floor(r.w / 40))); - for (let v = Math.ceil(dMin/valStep)*valStep; v <= dMax+valStep*0.01; v += valStep) { - const px = g.xToPx(v); - if (px < r.x || px > r.x + r.w) continue; - ctx.strokeStyle = theme.axisStroke; - ctx.beginPath(); ctx.moveTo(px, r.y + r.h); ctx.lineTo(px, r.y + r.h + 4); ctx.stroke(); - ctx.fillStyle = theme.tickText; - ctx.fillText(fmtVal(v), px, r.y + r.h + 7); + if (orient === 'h') { + // Value axis → X ticks at bottom + if (xTicksVis) { + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + if (logScale) { + const lMin = Math.log10(Math.max(LC, dMin)); + const lMax = Math.log10(Math.max(LC, dMax)); + for (let exp = Math.floor(lMin); exp <= Math.ceil(lMax); exp++) { + const v = Math.pow(10, exp); + if (v < dMin || v > dMax) continue; + const px = g.xToPx(v); + if (px < r.x || px > r.x + r.w) continue; + ctx.strokeStyle = theme.axisStroke; + ctx.beginPath(); ctx.moveTo(px, r.y + r.h); ctx.lineTo(px, r.y + r.h + 4); ctx.stroke(); + ctx.fillStyle = theme.tickText; + ctx.fillText(_fmtLogTick(v), px, r.y + r.h + 7); + } + } else { + const valRange = (dMax - dMin) || 1; + const valStep = findNice(valRange / Math.max(2, Math.floor(r.w / 40))); + for (let v = Math.ceil(dMin/valStep)*valStep; v <= dMax+valStep*0.01; v += valStep) { + const px = g.xToPx(v); + if (px < r.x || px > r.x + r.w) continue; + ctx.strokeStyle = theme.axisStroke; + ctx.beginPath(); ctx.moveTo(px, r.y + r.h); ctx.lineTo(px, r.y + r.h + 4); ctx.stroke(); + ctx.fillStyle = theme.tickText; + ctx.fillText(fmtVal(v), px, r.y + r.h + 7); + } + } + if (st.y_units) { + ctx.textAlign='right'; ctx.textBaseline='top'; ctx.font='9px monospace'; + ctx.fillStyle=theme.unitText; + ctx.fillText(st.y_units, r.x + r.w, r.y + r.h + 24); + ctx.font='10px monospace'; + } } - } - // Category axis → Y labels on left - ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; - const maxCatLabels = Math.max(1, Math.floor(r.h / 14)); - const catStep = Math.max(1, Math.ceil(g.n / maxCatLabels)); - for (let i = 0; i < g.n; i += catStep) { - const cy = g.yToPx(i); - const label = xLabels[i] !== undefined ? String(xLabels[i]) : fmtVal(xCenters[i]); - ctx.strokeStyle = theme.axisStroke; - ctx.beginPath(); ctx.moveTo(r.x, cy); ctx.lineTo(r.x - 4, cy); ctx.stroke(); - ctx.fillStyle = theme.tickText; - ctx.fillText(label, r.x - 7, cy); - } - if (st.y_units) { - ctx.textAlign='right'; ctx.textBaseline='top'; ctx.font='9px monospace'; - ctx.fillStyle=theme.unitText; - ctx.fillText(st.y_units, r.x + r.w, r.y + r.h + 24); - ctx.font='10px monospace'; - } - if (st.units) { - ctx.save(); - ctx.translate(Math.round(PAD_L * 0.28), r.y + r.h / 2); ctx.rotate(-Math.PI/2); - ctx.textAlign='center'; ctx.textBaseline='middle'; - ctx.fillStyle=theme.unitText; ctx.font='9px monospace'; - ctx.fillText(st.units, 0, 0); - ctx.restore(); - } - } else { - // Category axis → X ticks at bottom - ctx.textAlign = 'center'; ctx.textBaseline = 'top'; - const maxCatLabels = Math.max(1, Math.floor(r.w / 42)); - const catStep = Math.max(1, Math.ceil(g.n / maxCatLabels)); - for (let i = 0; i < g.n; i += catStep) { - const cx = g.xToPx(i); - const label = xLabels[i] !== undefined ? String(xLabels[i]) : fmtVal(xCenters[i]); - ctx.strokeStyle = theme.axisStroke; - ctx.beginPath(); ctx.moveTo(cx, r.y + r.h); ctx.lineTo(cx, r.y + r.h + 4); ctx.stroke(); - ctx.fillStyle = theme.tickText; - ctx.fillText(label, cx, r.y + r.h + 7); - } - if (st.units && st.units !== 'px') { - ctx.textAlign='right'; ctx.textBaseline='top'; ctx.font='9px monospace'; - ctx.fillStyle=theme.unitText; - ctx.fillText(st.units, r.x + r.w, r.y + r.h + 24); - ctx.font='10px monospace'; - } - // Value axis → Y ticks on left - ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; - if (logScale) { - const lMin = Math.log10(Math.max(LC, dMin)); - const lMax = Math.log10(Math.max(LC, dMax)); - for (let exp = Math.floor(lMin); exp <= Math.ceil(lMax); exp++) { - const v = Math.pow(10, exp); - if (v < dMin || v > dMax) continue; - const py = g.yToPx(v); - if (py < r.y || py > r.y + r.h) continue; - ctx.strokeStyle = theme.axisStroke; - ctx.beginPath(); ctx.moveTo(r.x, py); ctx.lineTo(r.x - 5, py); ctx.stroke(); - ctx.fillStyle = theme.tickText; - ctx.fillText(_fmtLogTick(v), r.x - 8, py); + // Category axis → Y labels on left + if (yTicksVis) { + ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; + const maxCatLabels = Math.max(1, Math.floor(r.h / 14)); + const catStep = Math.max(1, Math.ceil(g.n / maxCatLabels)); + for (let i = 0; i < g.n; i += catStep) { + const cy = g.yToPx(i); + const label = xLabels[i] !== undefined ? String(xLabels[i]) : fmtVal(xCenters[i]); + ctx.strokeStyle = theme.axisStroke; + ctx.beginPath(); ctx.moveTo(r.x, cy); ctx.lineTo(r.x - 4, cy); ctx.stroke(); + ctx.fillStyle = theme.tickText; + ctx.fillText(label, r.x - 7, cy); + } + if (st.units) { + ctx.save(); + ctx.translate(Math.round(PAD_L * 0.28), r.y + r.h / 2); ctx.rotate(-Math.PI/2); + ctx.textAlign='center'; ctx.textBaseline='middle'; + ctx.fillStyle=theme.unitText; ctx.font='9px monospace'; + ctx.fillText(st.units, 0, 0); + ctx.restore(); + } } } else { - const valRange = (dMax - dMin) || 1; - const valStep = findNice(valRange / Math.max(2, Math.floor(r.h / 40))); - for (let v = Math.ceil(dMin/valStep)*valStep; v <= dMax+valStep*0.01; v += valStep) { - const py = g.yToPx(v); - if (py < r.y || py > r.y + r.h) continue; - ctx.strokeStyle = theme.axisStroke; - ctx.beginPath(); ctx.moveTo(r.x, py); ctx.lineTo(r.x - 5, py); ctx.stroke(); - ctx.fillStyle = theme.tickText; - ctx.fillText(fmtVal(v), r.x - 8, py); + // Category axis → X ticks at bottom + if (xTicksVis) { + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + const maxCatLabels = Math.max(1, Math.floor(r.w / 42)); + const catStep = Math.max(1, Math.ceil(g.n / maxCatLabels)); + for (let i = 0; i < g.n; i += catStep) { + const cx = g.xToPx(i); + const label = xLabels[i] !== undefined ? String(xLabels[i]) : fmtVal(xCenters[i]); + ctx.strokeStyle = theme.axisStroke; + ctx.beginPath(); ctx.moveTo(cx, r.y + r.h); ctx.lineTo(cx, r.y + r.h + 4); ctx.stroke(); + ctx.fillStyle = theme.tickText; + ctx.fillText(label, cx, r.y + r.h + 7); + } + if (st.units && st.units !== 'px') { + ctx.textAlign='right'; ctx.textBaseline='top'; ctx.font='9px monospace'; + ctx.fillStyle=theme.unitText; + ctx.fillText(st.units, r.x + r.w, r.y + r.h + 24); + ctx.font='10px monospace'; + } + } + // Value axis → Y ticks on left + if (yTicksVis) { + ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; + if (logScale) { + const lMin = Math.log10(Math.max(LC, dMin)); + const lMax = Math.log10(Math.max(LC, dMax)); + for (let exp = Math.floor(lMin); exp <= Math.ceil(lMax); exp++) { + const v = Math.pow(10, exp); + if (v < dMin || v > dMax) continue; + const py = g.yToPx(v); + if (py < r.y || py > r.y + r.h) continue; + ctx.strokeStyle = theme.axisStroke; + ctx.beginPath(); ctx.moveTo(r.x, py); ctx.lineTo(r.x - 5, py); ctx.stroke(); + ctx.fillStyle = theme.tickText; + ctx.fillText(_fmtLogTick(v), r.x - 8, py); + } + } else { + const valRange = (dMax - dMin) || 1; + const valStep = findNice(valRange / Math.max(2, Math.floor(r.h / 40))); + for (let v = Math.ceil(dMin/valStep)*valStep; v <= dMax+valStep*0.01; v += valStep) { + const py = g.yToPx(v); + if (py < r.y || py > r.y + r.h) continue; + ctx.strokeStyle = theme.axisStroke; + ctx.beginPath(); ctx.moveTo(r.x, py); ctx.lineTo(r.x - 5, py); ctx.stroke(); + ctx.fillStyle = theme.tickText; + ctx.fillText(fmtVal(v), r.x - 8, py); + } + } + if (st.y_units) { + ctx.save(); + ctx.translate(Math.round(PAD_L * 0.28), r.y + r.h / 2); ctx.rotate(-Math.PI/2); + ctx.textAlign='center'; ctx.textBaseline='middle'; + ctx.fillStyle=theme.unitText; ctx.font='9px monospace'; + ctx.fillText(st.y_units, 0, 0); + ctx.restore(); + } } } - if (st.y_units) { - ctx.save(); - ctx.translate(Math.round(PAD_L * 0.28), r.y + r.h / 2); ctx.rotate(-Math.PI/2); - ctx.textAlign='center'; ctx.textBaseline='middle'; - ctx.fillStyle=theme.unitText; ctx.font='9px monospace'; - ctx.fillText(st.y_units, 0, 0); - ctx.restore(); - } - } + } // end axisVis // ── group legend (only when group_labels are provided) ──────────────── if (g.groups > 1 && groupLabels.length > 0) { @@ -4085,6 +4144,32 @@ function render({ model, el }) { } } + // ── title ───────────────────────────────────────────────────────────── + const titleBar = st.title || ''; + if (titleBar) { + ctx.fillStyle = theme.tickText; + ctx.font = 'bold 11px sans-serif'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(titleBar, r.x + r.w / 2, PAD_T / 2); + } + + // ── axis labels ─────────────────────────────────────────────────────── + const xLabelBar = st.x_label || ''; + const yLabelBar = st.y_label || ''; + if (xLabelBar) { + ctx.fillStyle = theme.tickText; ctx.font = '10px sans-serif'; + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + ctx.fillText(xLabelBar, r.x + r.w / 2, r.y + r.h + 26); + } + if (yLabelBar) { + ctx.save(); + ctx.translate(Math.round(PAD_L * 0.1), r.y + r.h / 2); ctx.rotate(-Math.PI / 2); + ctx.fillStyle = theme.tickText; ctx.font = '10px sans-serif'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(yLabelBar, 0, 0); + ctx.restore(); + } + // Overlay widgets (vlines, hlines) drawn on overlay canvas drawOverlay1d(p); } @@ -4373,190 +4458,6 @@ function render({ model, el }) { model.on('change:layout_json', () => { applyLayout(); redrawAll(); requestAnimationFrame(_applyScale); }); model.on('change:fig_width change:fig_height', () => { applyLayout(); redrawAll(); requestAnimationFrame(_applyScale); }); - // ── Panel drag / resize / gap-adjust (drag mode) ────────────────────────── - // When fig.drag_mode = True, each panel shows: - // • A translucent drag handle (centre) → drag to swap panels - // • Resize handles on the right edge and bottom edge → drag to resize - // Grid gaps (rowGap / columnGap) are also draggable via invisible bands. - const _editOverlays = new Map(); - - function _setEditMode(active) { - for (const [id, p] of panels) { - let ov = _editOverlays.get(id); - if (active && !ov) { - // ── outer wrapper appended to p.cell ────────────────────────────── - ov = document.createElement('div'); - ov.style.cssText = - 'position:absolute;inset:0;z-index:50;pointer-events:none;'; - p.cell.appendChild(ov); - _editOverlays.set(id, ov); - - // ── drag handle (covers top ~60% of panel, pointer-events:all) ──── - const dragHandle = document.createElement('div'); - dragHandle.style.cssText = - 'position:absolute;top:0;left:0;right:0;bottom:30%;' + - 'cursor:grab;pointer-events:all;z-index:51;' + - 'border:2px dashed rgba(79,195,247,0.75);' + - 'background:rgba(79,195,247,0.06);border-radius:4px;' + - 'display:flex;align-items:center;justify-content:center;' + - 'user-select:none;'; - const badge = document.createElement('div'); - badge.style.cssText = - 'background:rgba(0,0,0,0.55);color:#4fc3f7;padding:3px 10px;' + - 'border-radius:12px;font-size:11px;font-family:monospace;' + - 'pointer-events:none;letter-spacing:0.04em;'; - badge.textContent = '⋮ drag'; - dragHandle.appendChild(badge); - ov.appendChild(dragHandle); - - // ── drag handle logic ────────────────────────────────────────────── - let dragging = false, startX = 0, startY = 0, ghost = null; - - dragHandle.addEventListener('pointerdown', (e) => { - if (e.button !== 0) return; - dragging = true; - startX = e.clientX; startY = e.clientY; - dragHandle.style.cursor = 'grabbing'; - const r = p.cell.getBoundingClientRect(); - ghost = document.createElement('div'); - ghost.style.cssText = - 'position:fixed;pointer-events:none;z-index:9999;' + - 'border:2px solid #4fc3f7;background:rgba(79,195,247,0.12);' + - 'border-radius:4px;opacity:0.85;' + - `width:${r.width}px;height:${r.height}px;` + - `left:${r.left}px;top:${r.top}px;`; - document.body.appendChild(ghost); - dragHandle.setPointerCapture(e.pointerId); - e.stopPropagation(); e.preventDefault(); - }); - - dragHandle.addEventListener('pointermove', (e) => { - if (!dragging || !ghost) return; - const dx = e.clientX - startX, dy = e.clientY - startY; - const r = p.cell.getBoundingClientRect(); - ghost.style.left = (r.left + dx) + 'px'; - ghost.style.top = (r.top + dy) + 'px'; - for (const [oid, op] of panels) { - if (oid === id) continue; - const tr = op.cell.getBoundingClientRect(); - const over = e.clientX >= tr.left && e.clientX <= tr.right && - e.clientY >= tr.top && e.clientY <= tr.bottom; - const ovEl = _editOverlays.get(oid); - const dh = ovEl && ovEl.querySelector('[data-role=drag]'); - if (dh) dh.style.borderColor = over ? '#ff7043' : 'rgba(79,195,247,0.75)'; - } - e.stopPropagation(); - }); - - dragHandle.addEventListener('pointerup', (e) => { - if (!dragging) return; - dragging = false; - if (ghost) { ghost.remove(); ghost = null; } - dragHandle.style.cursor = 'grab'; - for (const [oid, op] of panels) { - const ovEl = _editOverlays.get(oid); - const dh = ovEl && ovEl.querySelector('[data-role=drag]'); - if (dh) dh.style.borderColor = 'rgba(79,195,247,0.75)'; - if (oid === id) continue; - const tr = op.cell.getBoundingClientRect(); - if (e.clientX >= tr.left && e.clientX <= tr.right && - e.clientY >= tr.top && e.clientY <= tr.bottom) { - const srcRow = p.cell.style.gridRow; - const srcCol = p.cell.style.gridColumn; - p.cell.style.gridRow = op.cell.style.gridRow; - p.cell.style.gridColumn = op.cell.style.gridColumn; - op.cell.style.gridRow = srcRow; - op.cell.style.gridColumn = srcCol; - // Swap stored specs - const tmpSpec = p.spec; - p.spec = op.spec; - op.spec = tmpSpec; - } - } - e.stopPropagation(); - }); - - dragHandle.dataset.role = 'drag'; - - // ── right-edge resize handle ───────────────────────────────────── - const rHandle = document.createElement('div'); - rHandle.style.cssText = - 'position:absolute;top:10%;right:0;width:12px;bottom:30%;' + - 'cursor:ew-resize;pointer-events:all;z-index:52;' + - 'background:rgba(79,195,247,0.25);border-radius:0 4px 4px 0;' + - 'display:flex;align-items:center;justify-content:center;'; - rHandle.title = 'Drag to resize width'; - ov.appendChild(rHandle); - - let rDragging = false, rStartX = 0, rStartCols = []; - - rHandle.addEventListener('pointerdown', (e) => { - if (e.button !== 0 || !p.spec) return; - rDragging = true; - rStartX = e.clientX; - rStartCols = _colPx.slice(); - rHandle.setPointerCapture(e.pointerId); - e.stopPropagation(); e.preventDefault(); - }); - rHandle.addEventListener('pointermove', (e) => { - if (!rDragging || !p.spec) return; - const dx = e.clientX - rStartX; - const c = p.spec.col_stop - 1; // rightmost column of this panel - const nc = _colPx.length; - if (c >= nc - 1) return; // can't resize last column - const newW = Math.max(80, rStartCols[c] + dx); - const delta = newW - rStartCols[c]; - _colPx[c] = newW; - _colPx[c+1] = Math.max(80, rStartCols[c+1] - delta); - _applyTrackSizes(); - e.stopPropagation(); - }); - rHandle.addEventListener('pointerup', (e) => { rDragging = false; e.stopPropagation(); }); - - // ── bottom-edge resize handle ──────────────────────────────────── - const bHandle = document.createElement('div'); - bHandle.style.cssText = - 'position:absolute;bottom:0;left:10%;right:0;height:12px;' + - 'cursor:ns-resize;pointer-events:all;z-index:52;' + - 'background:rgba(79,195,247,0.25);border-radius:0 0 4px 4px;' + - 'display:flex;align-items:center;justify-content:center;'; - bHandle.title = 'Drag to resize height / adjust spacing'; - ov.appendChild(bHandle); - - let bDragging = false, bStartY = 0, bStartRows = []; - - bHandle.addEventListener('pointerdown', (e) => { - if (e.button !== 0 || !p.spec) return; - bDragging = true; - bStartY = e.clientY; - bStartRows = _rowPx.slice(); - bHandle.setPointerCapture(e.pointerId); - e.stopPropagation(); e.preventDefault(); - }); - bHandle.addEventListener('pointermove', (e) => { - if (!bDragging || !p.spec) return; - const dy = e.clientY - bStartY; - const r = p.spec.row_stop - 1; // bottommost row of this panel - const nr = _rowPx.length; - if (r >= nr - 1) return; // can't resize last row - const newH = Math.max(80, bStartRows[r] + dy); - const delta = newH - bStartRows[r]; - _rowPx[r] = newH; - _rowPx[r+1] = Math.max(80, bStartRows[r+1] - delta); - _applyTrackSizes(); - e.stopPropagation(); - }); - bHandle.addEventListener('pointerup', (e) => { bDragging = false; e.stopPropagation(); }); - - } else if (!active && ov) { - ov.remove(); - _editOverlays.delete(id); - } - } - } - - model.on('change:drag_mode', () => { _setEditMode(model.get('drag_mode')); }); - // Toggle the per-panel stats overlay when display_stats changes. // Hiding is immediate; showing waits for the next natural redraw to // populate the overlay text — but we also call redrawAll() here so the diff --git a/anyplotlib/markers.py b/anyplotlib/markers.py index 66dba65a..3d764a09 100644 --- a/anyplotlib/markers.py +++ b/anyplotlib/markers.py @@ -541,7 +541,7 @@ class MarkerRegistry: }) _KNOWN_1D = frozenset({ "points", "vlines", "hlines", "lines", "rectangles", - "ellipses", "polygons", "texts", + "ellipses", "polygons", "texts", "arrows", "squares", }) # pcolormesh panels only support points (circles) and line segments _KNOWN_MESH = frozenset({"circles", "lines"}) diff --git a/anyplotlib/plot1d/_plot1d.py b/anyplotlib/plot1d/_plot1d.py index 9ab8c130..4010597f 100644 --- a/anyplotlib/plot1d/_plot1d.py +++ b/anyplotlib/plot1d/_plot1d.py @@ -297,6 +297,7 @@ def __init__(self, data: np.ndarray, "axis_visible": True, "x_ticks_visible": True, "y_ticks_visible": True, + "_view_from_python": False, } self.markers = MarkerRegistry(self._push_markers, @@ -431,7 +432,7 @@ def _recompute_data_range(self) -> None: # Extra lines # ------------------------------------------------------------------ def add_line(self, data: np.ndarray, x_axis=None, - color: str = "#ffffff", linewidth: float = 1.5, + color: str = "#4fc3f7", linewidth: float = 1.5, linestyle: str = "solid", ls: str | None = None, alpha: float = 1.0, marker: str = "none", markersize: float = 4.0, @@ -448,7 +449,7 @@ def add_line(self, data: np.ndarray, x_axis=None, x_axis : array-like, shape (N,), optional X coordinates. Defaults to the primary line's x-axis. color : str, optional - CSS colour string. Default ``"#ffffff"``. + CSS colour string. Default ``"#4fc3f7"``. linewidth : float, optional Stroke width in pixels. Default ``1.5``. linestyle : str, optional @@ -779,13 +780,17 @@ def set_view(self, x0: float | None = None, x1: float | None = None) -> None: f1 = 1.0 if x1 is None else max(0.0, min(1.0, (float(x1)-xmin)/span)) self._state["view_x0"] = f0 self._state["view_x1"] = f1 + self._state["_view_from_python"] = True self._push() + self._state["_view_from_python"] = False def reset_view(self) -> None: """Reset the view to show the full x range of the primary line.""" self._state["view_x0"] = 0.0 self._state["view_x1"] = 1.0 + self._state["_view_from_python"] = True self._push() + self._state["_view_from_python"] = False # ------------------------------------------------------------------ # Primary-line property setters @@ -884,8 +889,21 @@ def set_ylim(self, ymin: float, ymax: float) -> None: self._push() def get_ylim(self) -> tuple: + yr = self._state.get("y_range") + if yr is not None: + return (float(yr[0]), float(yr[1])) return (float(self._state["data_min"]), float(self._state["data_max"])) + def get_xlim(self) -> tuple: + xarr = np.asarray(self._state["x_axis"]) + if len(xarr) < 2: + return (0.0, 1.0) + xmin, xmax = float(xarr[0]), float(xarr[-1]) + span = xmax - xmin or 1.0 + x0 = xmin + self._state["view_x0"] * span + x1 = xmin + self._state["view_x1"] * span + return (x0, x1) + def get_xbound(self) -> tuple: xarr = np.asarray(self._state["x_axis"]) return (float(xarr.min()), float(xarr.max())) diff --git a/anyplotlib/plot1d/_plotbar.py b/anyplotlib/plot1d/_plotbar.py index fefeafc4..f73b940a 100644 --- a/anyplotlib/plot1d/_plotbar.py +++ b/anyplotlib/plot1d/_plotbar.py @@ -185,14 +185,22 @@ def __init__(self, x, height=None, width: float = 0.8, bottom: float = 0.0, *, "group_labels": list(group_labels) if group_labels is not None else [], "group_colors": gc_list, "bar_width": float(width), + "align": align, "orient": orient, "baseline": float(bottom), "log_scale": bool(log_scale), "show_values": bool(show_values), "data_min": dmin, "data_max": dmax, + "y_range": None, "units": units, "y_units": y_units, + "title": "", + "x_label": "", + "y_label": "", + "axis_visible": True, + "x_ticks_visible": True, + "y_ticks_visible": True, # overlay-widget coordinate system (mirrors Plot1D) "x_axis": x_axis, "view_x0": 0.0, @@ -200,6 +208,7 @@ def __init__(self, x, height=None, width: float = 0.8, bottom: float = 0.0, *, "overlay_widgets": [], "pointer_settled_ms": 0, "pointer_settled_delta": 4, + "_view_from_python": False, } self.callbacks = CallbackRegistry() self._widgets: dict[str, Widget] = {} @@ -306,6 +315,76 @@ def set_log_scale(self, log_scale: bool) -> None: self._state["data_max"] = dmax self._push() + # ------------------------------------------------------------------ + # Display control + # ------------------------------------------------------------------ + def set_title(self, text: str) -> None: + """Set the panel title.""" + self._state["title"] = str(text) + self._push() + + def set_xlabel(self, text: str) -> None: + """Set the x-axis label.""" + self._state["x_label"] = str(text) + self._push() + + def set_ylabel(self, text: str) -> None: + """Set the y-axis / value-axis label.""" + self._state["y_label"] = str(text) + self._push() + + def set_axis_off(self) -> None: + """Hide axes, ticks, and labels.""" + self._state["axis_visible"] = False + self._push() + + def set_axis_on(self) -> None: + """Show axes, ticks, and labels.""" + self._state["axis_visible"] = True + self._push() + + def set_ticks_visible(self, x: bool = True, y: bool = True) -> None: + """Show or hide x/y tick marks independently.""" + self._state["x_ticks_visible"] = bool(x) + self._state["y_ticks_visible"] = bool(y) + self._push() + + # ------------------------------------------------------------------ + # View (xlim / ylim) + # ------------------------------------------------------------------ + def set_xlim(self, x_min: float, x_max: float) -> None: + """Pan/zoom the x-axis to [x_min, x_max] in data coordinates.""" + x_axis = self._state["x_axis"] + span = x_axis[1] - x_axis[0] + if span == 0: + return + self._state["view_x0"] = (x_min - x_axis[0]) / span + self._state["view_x1"] = (x_max - x_axis[0]) / span + self._state["_view_from_python"] = True + self._push() + self._state["_view_from_python"] = False + + def set_ylim(self, y_min: float, y_max: float) -> None: + """Fix the value-axis range to [y_min, y_max].""" + self._state["y_range"] = [float(y_min), float(y_max)] + self._push() + + def get_ylim(self) -> tuple: + """Return the current value-axis range as ``(y_min, y_max)``.""" + yr = self._state.get("y_range") + if yr is not None: + return (float(yr[0]), float(yr[1])) + return (float(self._state["data_min"]), float(self._state["data_max"])) + + def reset_view(self) -> None: + """Reset pan/zoom to show all bars.""" + self._state["view_x0"] = 0.0 + self._state["view_x1"] = 1.0 + self._state["y_range"] = None + self._state["_view_from_python"] = True + self._push() + self._state["_view_from_python"] = False + # ------------------------------------------------------------------ # Overlay Widgets # ------------------------------------------------------------------ diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py index 55a9b81f..4d00475c 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -334,6 +334,10 @@ def set_xlim(self, xmin: float, xmax: float) -> None: def set_ylim(self, ymin: float, ymax: float) -> None: self.set_view(y0=ymin, y1=ymax) + def get_xlim(self) -> tuple: + xarr = np.asarray(self._state["x_axis"]) + return (float(xarr.min()), float(xarr.max())) + def get_ylim(self) -> tuple: yarr = np.asarray(self._state["y_axis"]) return (float(yarr.min()), float(yarr.max())) diff --git a/anyplotlib/plot3d/_plot3d.py b/anyplotlib/plot3d/_plot3d.py index 48638c7b..8356700e 100644 --- a/anyplotlib/plot3d/_plot3d.py +++ b/anyplotlib/plot3d/_plot3d.py @@ -211,5 +211,5 @@ def set_data(self, x, y, z) -> None: def __repr__(self) -> str: geom = self._state.get("geom_type", "?") - n = len(self._state.get("vertices", [])) + n = self._state.get("vertices_count", 0) return f"Plot3D(geom={geom!r}, n_vertices={n})" diff --git a/anyplotlib/tests/test_layouts/test_gridspec.py b/anyplotlib/tests/test_layouts/test_gridspec.py index d5179c9a..96cc051b 100644 --- a/anyplotlib/tests/test_layouts/test_gridspec.py +++ b/anyplotlib/tests/test_layouts/test_gridspec.py @@ -1113,3 +1113,29 @@ def test_retriggers_layout_push(self): assert fig.layout_json != before +# =========================================================================== +# hspace / wspace initial-value contract +# =========================================================================== + +class TestHspaceWspaceInitialState: + def test_initial_hspace_is_none(self): + """Before subplots_adjust the internal value is None (browser 4px default).""" + fig, _ = vw.subplots(2, 2) + assert fig._hspace is None + assert fig._wspace is None + + def test_subplots_adjust_zero_stores_zero(self): + """subplots_adjust(hspace=0.0) must store 0.0, not None.""" + fig, _ = vw.subplots(2, 1) + fig.subplots_adjust(hspace=0.0, wspace=0.0) + assert fig._hspace == 0.0 + assert fig._wspace == 0.0 + + def test_subplots_adjust_zero_appears_in_layout(self): + fig, _ = vw.subplots(2, 2) + fig.subplots_adjust(hspace=0.0, wspace=0.0) + layout = json.loads(fig.layout_json) + assert layout["hspace"] == pytest.approx(0.0) + assert layout["wspace"] == pytest.approx(0.0) + + diff --git a/anyplotlib/tests/test_markers/test_markers.py b/anyplotlib/tests/test_markers/test_markers.py index 1333e249..8678f244 100644 --- a/anyplotlib/tests/test_markers/test_markers.py +++ b/anyplotlib/tests/test_markers/test_markers.py @@ -587,3 +587,109 @@ def test_remove_1d_group(self): g.remove() assert "marks" not in p.markers["vlines"] + +# =========================================================================== +# _KNOWN_1D completeness — arrows and squares +# =========================================================================== + +class TestKnown1dArrowsSquares: + def test_arrows_in_known_1d(self): + assert "arrows" in MarkerRegistry._KNOWN_1D + + def test_squares_in_known_1d(self): + assert "squares" in MarkerRegistry._KNOWN_1D + + def test_add_arrows_does_not_raise(self): + p = _make_plot1d() + offsets = np.column_stack([np.linspace(0, 1, 5), np.zeros(5)]) + p.add_arrows(offsets, U=0.05, V=0.1) + + def test_add_squares_does_not_raise(self): + p = _make_plot1d() + offsets = np.column_stack([np.linspace(0, 1, 3), np.zeros(3)]) + p.add_squares(offsets, widths=0.05) + + def test_add_arrows_wire_format(self): + p = _make_plot1d() + offsets = np.array([[0.1, 0.2], [0.5, 0.6]]) + p.add_arrows(offsets, U=0.1, V=0.2, name="arr") + wires = [m for m in p._state["markers"] if m["type"] == "arrows"] + assert len(wires) == 1 + w = wires[0] + assert "U" in w and "V" in w + assert len(w["U"]) == 2 + assert len(w["offsets"]) == 2 + + def test_add_squares_wire_format(self): + p = _make_plot1d() + offsets = np.array([[0.1, 0.2], [0.5, 0.6]]) + p.add_squares(offsets, widths=0.1, name="sq") + wires = [m for m in p._state["markers"] if m["type"] == "squares"] + assert len(wires) == 1 + w = wires[0] + assert "widths" in w + assert len(w["widths"]) == 2 + + +# =========================================================================== +# drawMarkers1d new types — wire format correctness +# =========================================================================== + +class TestMarkers1dNewTypes: + """add_rectangles/ellipses/polygons/arrows/squares on Plot1D produce + correct wire-format dicts that the JS drawMarkers1d handler will receive.""" + + def _plot(self): + x = np.linspace(0, 2 * np.pi, 64) + fig, ax = apl.subplots(1, 1) + return ax.plot(np.sin(x), axes=[x]) + + def _wire(self, p, type_): + return [m for m in p._state["markers"] if m["type"] == type_] + + def test_add_rectangles_wire(self): + p = self._plot() + offsets = np.array([[1.0, 0.5], [3.0, -0.5]]) + p.add_rectangles(offsets, widths=0.2, heights=0.1, name="rects") + ws = self._wire(p, "rectangles") + assert len(ws) == 1 + w = ws[0] + assert "widths" in w and "heights" in w + assert len(w["offsets"]) == 2 + + def test_add_squares_wire(self): + p = self._plot() + offsets = np.array([[1.0, 0.5], [3.0, -0.5]]) + p.add_squares(offsets, widths=0.1, name="sq") + ws = self._wire(p, "squares") + assert len(ws) == 1 + assert "widths" in ws[0] + + def test_add_ellipses_wire(self): + p = self._plot() + offsets = np.array([[1.0, 0.5], [4.0, 0.0]]) + p.add_ellipses(offsets, widths=0.3, heights=0.15, name="ellip") + ws = self._wire(p, "ellipses") + assert len(ws) == 1 + w = ws[0] + assert "widths" in w and "heights" in w and "angles" in w + + def test_add_polygons_wire(self): + p = self._plot() + tri = np.array([[0.5, 0.0], [1.0, 0.5], [1.5, 0.0]]) + p.add_polygons([tri], name="poly") + ws = self._wire(p, "polygons") + assert len(ws) == 1 + assert "vertices_list" in ws[0] + assert len(ws[0]["vertices_list"]) == 1 + + def test_add_arrows_wire(self): + p = self._plot() + offsets = np.array([[1.0, 0.0], [3.0, 0.5]]) + p.add_arrows(offsets, U=0.2, V=0.1, name="arrows") + ws = self._wire(p, "arrows") + assert len(ws) == 1 + w = ws[0] + assert "U" in w and "V" in w + assert len(w["U"]) == 2 + diff --git a/anyplotlib/tests/test_plot1d/test_plot1d.py b/anyplotlib/tests/test_plot1d/test_plot1d.py index c26b50b1..b0eb27ca 100644 --- a/anyplotlib/tests/test_plot1d/test_plot1d.py +++ b/anyplotlib/tests/test_plot1d/test_plot1d.py @@ -805,3 +805,135 @@ def test_semilogy_passes_kwargs(self): assert p._state["line_color"] == "#ff0000" assert p._state["yscale"] == "log" + +# =========================================================================== +# set_ylim / get_ylim +# =========================================================================== + +class TestSetGetYlim: + def test_get_ylim_default_returns_data_bounds(self): + p = _plot() + lo, hi = p.get_ylim() + assert lo == pytest.approx(p._state["data_min"]) + assert hi == pytest.approx(p._state["data_max"]) + + def test_set_ylim_stored_in_state(self): + p = _plot() + p.set_ylim(-2.0, 5.0) + assert p._state["y_range"] == [-2.0, 5.0] + + def test_get_ylim_after_set_ylim(self): + p = _plot() + p.set_ylim(-1.5, 3.0) + lo, hi = p.get_ylim() + assert lo == pytest.approx(-1.5) + assert hi == pytest.approx(3.0) + + def test_y_range_not_cleared_by_reset_view(self): + p = _plot() + p.set_ylim(-1.0, 1.0) + p.reset_view() + lo, hi = p.get_ylim() + assert lo == pytest.approx(-1.0) + assert hi == pytest.approx(1.0) + + def test_y_range_in_state_dict(self): + p = _plot() + p.set_ylim(0.0, 10.0) + assert p.to_state_dict()["y_range"] == [0.0, 10.0] + + def test_y_range_none_by_default(self): + assert _plot()._state["y_range"] is None + + def test_y_range_propagated_to_state_dict(self): + p = _plot() + p.set_ylim(-5.0, 5.0) + assert p.to_state_dict()["y_range"] == [-5.0, 5.0] + + def test_markers_state_dict_contains_y_range(self): + p = _plot() + p.set_ylim(0.0, 10.0) + assert p.to_state_dict()["y_range"] == [0.0, 10.0] + + +# =========================================================================== +# get_xlim +# =========================================================================== + +class TestGetXlim: + def test_get_xlim_full_view(self): + fig, ax = apl.subplots(1, 1) + x = np.linspace(0.0, 10.0, 64) + p = ax.plot(np.sin(x), axes=[x]) + lo, hi = p.get_xlim() + assert lo == pytest.approx(0.0, abs=0.01) + assert hi == pytest.approx(10.0, abs=0.01) + + def test_get_xlim_after_set_xlim(self): + fig, ax = apl.subplots(1, 1) + x = np.linspace(0.0, 10.0, 64) + p = ax.plot(np.sin(x), axes=[x]) + p.set_xlim(2.0, 8.0) + lo, hi = p.get_xlim() + assert lo == pytest.approx(2.0, abs=0.1) + assert hi == pytest.approx(8.0, abs=0.1) + + def test_get_xlim_default_x_axis(self): + p = _plot_lin(n=100) + lo, hi = p.get_xlim() + assert lo == pytest.approx(0.0, abs=0.01) + assert hi == pytest.approx(99.0, abs=0.01) + + +# =========================================================================== +# _view_from_python flag +# =========================================================================== + +class TestViewFromPython: + def test_initial_view_from_python_false(self): + assert _plot()._state["_view_from_python"] is False + + def test_set_view_clears_flag_after_push(self): + p = _plot() + p.set_view(x0=0.2, x1=0.8) + assert p._state["_view_from_python"] is False + + def test_reset_view_clears_flag_after_push(self): + p = _plot() + p.set_view(x0=0.2, x1=0.8) + p.reset_view() + assert p._state["_view_from_python"] is False + + def test_set_xlim_clears_flag_after_push(self): + fig, ax = apl.subplots(1, 1) + x = np.linspace(0, 10, 64) + p = ax.plot(np.sin(x), axes=[x]) + p.set_xlim(2.0, 8.0) + assert p._state["_view_from_python"] is False + assert p._state["view_x0"] != 0.0 or p._state["view_x1"] != 1.0 + + def test_view_from_python_present_in_state_dict(self): + p = _plot() + p.set_view(x0=0.1, x1=0.9) + sd = p.to_state_dict() + assert "_view_from_python" in sd + assert sd["_view_from_python"] is False + + +# =========================================================================== +# add_line default color +# =========================================================================== + +class TestAddLineDefaultColor: + def test_default_color_is_not_white(self): + import inspect + p = _plot() + default = inspect.signature(p.add_line).parameters["color"].default + assert default != "#ffffff" + assert default == "#4fc3f7" + + def test_add_line_uses_default_color_in_state(self): + p = _plot() + p.add_line(np.linspace(-1, 1, 128)) + assert p._state["extra_lines"][-1]["color"] == "#4fc3f7" + diff --git a/anyplotlib/tests/test_plot1d/test_plotbar.py b/anyplotlib/tests/test_plot1d/test_plotbar.py index 11fd9246..dec83a7b 100644 --- a/anyplotlib/tests/test_plot1d/test_plotbar.py +++ b/anyplotlib/tests/test_plot1d/test_plotbar.py @@ -718,3 +718,136 @@ def test_repr_grouped_shows_groups(self): def test_repr_contains_plotbar(self): assert "PlotBar" in repr(_bar([1, 2, 3], [10, 20, 30])) + +# =========================================================================== +# New state keys added in audit fix +# =========================================================================== + +class TestPlotBarNewStateKeys: + def test_title_default_empty(self): + assert _make_bar()._state["title"] == "" + + def test_x_label_in_state(self): + assert "x_label" in _make_bar()._state + + def test_y_label_in_state(self): + assert "y_label" in _make_bar()._state + + def test_axis_visible_true_by_default(self): + assert _make_bar()._state["axis_visible"] is True + + def test_x_ticks_visible_true_by_default(self): + assert _make_bar()._state["x_ticks_visible"] is True + + def test_y_ticks_visible_true_by_default(self): + assert _make_bar()._state["y_ticks_visible"] is True + + def test_align_stored(self): + assert _make_bar(align="edge")._state["align"] == "edge" + + def test_align_center_by_default(self): + assert _make_bar()._state["align"] == "center" + + def test_y_range_none_by_default(self): + p = _make_bar() + assert "y_range" in p._state + assert p._state["y_range"] is None + + def test_view_from_python_false_by_default(self): + assert _make_bar()._state["_view_from_python"] is False + + +# =========================================================================== +# New display-control methods added in audit fix +# =========================================================================== + +class TestPlotBarDisplayMethods: + def test_set_title(self): + p = _make_bar() + p.set_title("My Chart") + assert p._state["title"] == "My Chart" + + def test_set_xlabel(self): + p = _make_bar() + p.set_xlabel("Category") + assert p._state["x_label"] == "Category" + + def test_set_ylabel(self): + p = _make_bar() + p.set_ylabel("Value") + assert p._state["y_label"] == "Value" + + def test_set_axis_off(self): + p = _make_bar() + p.set_axis_off() + assert p._state["axis_visible"] is False + + def test_set_axis_on_restores(self): + p = _make_bar() + p.set_axis_off() + p.set_axis_on() + assert p._state["axis_visible"] is True + + def test_set_ticks_visible_both_false(self): + p = _make_bar() + p.set_ticks_visible(x=False, y=False) + assert p._state["x_ticks_visible"] is False + assert p._state["y_ticks_visible"] is False + + def test_set_ticks_visible_x_only(self): + p = _make_bar() + p.set_ticks_visible(x=True, y=False) + assert p._state["x_ticks_visible"] is True + assert p._state["y_ticks_visible"] is False + + def test_set_ylim(self): + p = _make_bar() + p.set_ylim(0.0, 10.0) + assert p._state["y_range"] == [0.0, 10.0] + + def test_get_ylim_default(self): + p = _make_bar() + lo, hi = p.get_ylim() + assert lo == pytest.approx(p._state["data_min"]) + assert hi == pytest.approx(p._state["data_max"]) + + def test_get_ylim_after_set_ylim(self): + p = _make_bar() + p.set_ylim(-1.0, 20.0) + lo, hi = p.get_ylim() + assert lo == pytest.approx(-1.0) + assert hi == pytest.approx(20.0) + + def test_set_xlim_changes_view(self): + fig, ax = apl.subplots(1, 1) + p = ax.bar(np.arange(10), np.ones(10)) + p.set_xlim(2.0, 7.0) + assert p._state["view_x0"] != 0.0 or p._state["view_x1"] != 1.0 + + def test_reset_view(self): + fig, ax = apl.subplots(1, 1) + p = ax.bar(np.arange(10), np.ones(10)) + p.set_xlim(2.0, 7.0) + p.set_ylim(0.0, 5.0) + p.reset_view() + assert p._state["view_x0"] == pytest.approx(0.0) + assert p._state["view_x1"] == pytest.approx(1.0) + assert p._state["y_range"] is None + + +# =========================================================================== +# _view_from_python flag on PlotBar +# =========================================================================== + +class TestPlotBarViewFromPython: + def test_set_xlim_clears_flag(self): + fig, ax = apl.subplots(1, 1) + p = ax.bar(np.arange(10), np.ones(10)) + p.set_xlim(2.0, 7.0) + assert p._state["_view_from_python"] is False + + def test_reset_view_clears_flag(self): + p = _make_bar() + p.reset_view() + assert p._state["_view_from_python"] is False + diff --git a/anyplotlib/tests/test_plot2d/test_plot2d_api.py b/anyplotlib/tests/test_plot2d/test_plot2d_api.py index fa197c42..3929e7ed 100644 --- a/anyplotlib/tests/test_plot2d/test_plot2d_api.py +++ b/anyplotlib/tests/test_plot2d/test_plot2d_api.py @@ -395,3 +395,33 @@ def test_resize_with_height_ratios_scales_proportionally(self): # top: 3/4 × 800 = 600 px; bottom: 1/4 × 800 = 200 px assert specs[plot_top._id]["panel_height"] == pytest.approx(600, abs=1) assert specs[plot_bot._id]["panel_height"] == pytest.approx(200, abs=1) + + +# =========================================================================== +# Plot2D.get_xlim +# =========================================================================== + +class TestPlot2DGetXlim: + def test_get_xlim_exists(self): + p = _make_plot2d() + assert hasattr(p, "get_xlim") + + def test_get_xlim_with_physical_axes(self): + fig, ax = apl.subplots(1, 1) + x = np.linspace(0.0, 10.0, 16) + p = ax.imshow(np.zeros((16, 16)), axes=[x, np.linspace(0, 5, 16)], units="nm") + lo, hi = p.get_xlim() + assert lo == pytest.approx(0.0) + assert hi == pytest.approx(10.0) + + def test_get_xlim_and_get_ylim_match_axes(self): + fig, ax = apl.subplots(1, 1) + x = np.linspace(1.0, 5.0, 16) + y = np.linspace(2.0, 8.0, 16) + p = ax.imshow(np.zeros((16, 16)), axes=[x, y], units="m") + xlo, xhi = p.get_xlim() + ylo, yhi = p.get_ylim() + assert xlo == pytest.approx(1.0) + assert xhi == pytest.approx(5.0) + assert ylo == pytest.approx(2.0) + assert yhi == pytest.approx(8.0) diff --git a/anyplotlib/tests/test_plot3d/test_plot3d.py b/anyplotlib/tests/test_plot3d/test_plot3d.py index 2316ecc0..01f68ae6 100644 --- a/anyplotlib/tests/test_plot3d/test_plot3d.py +++ b/anyplotlib/tests/test_plot3d/test_plot3d.py @@ -196,3 +196,41 @@ def test_set_data_surface_bad_shape(self): surf.set_data(x, x, x) +# =========================================================================== +# repr() uses vertices_count, not len(vertices) +# =========================================================================== + +class TestPlot3DRepr: + def test_repr_uses_vertices_count(self): + """repr() must read vertices_count, not len(state['vertices']).""" + + class _FakePlot3D(Plot3D): + def __init__(self): + self._state = {"geom_type": "mesh", "vertices_count": 42} + self._id = "" + self._fig = None + + assert "n_vertices=42" in repr(_FakePlot3D()) + + def test_repr_zero_when_count_zero(self): + class _FakePlot3D(Plot3D): + def __init__(self): + self._state = {"geom_type": "scatter", "vertices_count": 0} + self._id = "" + self._fig = None + + assert "n_vertices=0" in repr(_FakePlot3D()) + + def test_repr_on_real_line(self): + _, x, y, z = _line() + # _line() creates a Plot3D via plot3d(); repr must not raise and must + # show the correct vertex count. + from anyplotlib.plot3d._plot3d import Plot3D as _P3D + # find the plot object returned by _line + ln, *_ = _line() + r = repr(ln) + assert "n_vertices=" in r + # vertex count must equal len(x), not 0 + assert f"n_vertices={len(x)}" in r + + From c16b826fa8093da499830e569636d96f28fb61f2 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 22 May 2026 20:03:52 -0500 Subject: [PATCH 07/11] Refactor: Add axis visibility controls and state management methods for Plot1D, Plot2D, and Plot3D; enhance PlotBar with get_xlim method --- anyplotlib/__init__.py | 4 ++ anyplotlib/figure_esm.js | 8 +++ anyplotlib/plot1d/_plot1d.py | 5 +- anyplotlib/plot1d/_plotbar.py | 21 ++++++-- anyplotlib/plot2d/_plot2d.py | 5 +- anyplotlib/plot2d/_plotmesh.py | 8 +++ anyplotlib/plot3d/_plot3d.py | 33 ++++++++++++ anyplotlib/tests/test_markers/test_markers.py | 31 +++++++++++ anyplotlib/tests/test_plot1d/test_plot1d.py | 18 +++++++ anyplotlib/tests/test_plot1d/test_plotbar.py | 52 +++++++++++++++++- .../tests/test_plot2d/test_plot2d_api.py | 36 +++++++++++++ anyplotlib/tests/test_plot3d/test_plot3d.py | 53 +++++++++++++++++++ 12 files changed, 267 insertions(+), 7 deletions(-) diff --git a/anyplotlib/__init__.py b/anyplotlib/__init__.py index c897ddce..dbb4f476 100644 --- a/anyplotlib/__init__.py +++ b/anyplotlib/__init__.py @@ -1,9 +1,11 @@ from anyplotlib.figure import Figure, GridSpec, SubplotSpec, subplots from anyplotlib.axes import Axes, InsetAxes from anyplotlib.plot1d import Plot1D, PlotBar +from anyplotlib.plot1d._plot1d import Line1D from anyplotlib.plot2d import Plot2D, PlotMesh from anyplotlib.plot3d import Plot3D from anyplotlib.callbacks import CallbackRegistry, Event +from anyplotlib.markers import MarkerRegistry, MarkerGroup from anyplotlib.widgets import ( Widget, RectangleWidget, CircleWidget, AnnularWidget, CrosshairWidget, PolygonWidget, LabelWidget, @@ -30,7 +32,9 @@ def get_color_cycle() -> list[str]: __all__ = [ "Figure", "GridSpec", "SubplotSpec", "subplots", "Axes", "InsetAxes", "Plot1D", "Plot2D", "PlotMesh", "Plot3D", "PlotBar", + "Line1D", "CallbackRegistry", "Event", + "MarkerRegistry", "MarkerGroup", "Widget", "RectangleWidget", "CircleWidget", "AnnularWidget", "CrosshairWidget", "PolygonWidget", "LabelWidget", "VLineWidget", "HLineWidget", "RangeWidget", diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 70d6f669..b524da6d 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -573,6 +573,10 @@ function render({ model, el }) { } else if (p2.state && (p2.kind === '1d' || p2.kind === 'bar') && !newState._view_from_python) { newState.view_x0 = p2.state.view_x0; newState.view_x1 = p2.state.view_x1; + } else if (p2.state && p2.kind === '3d' && !newState._view_from_python) { + newState.azimuth = p2.state.azimuth; + newState.elevation = p2.state.elevation; + newState.zoom = p2.state.zoom; } p2.state = newState; } @@ -698,6 +702,10 @@ function render({ model, el }) { } else if (p2.state && (p2.kind === '1d' || p2.kind === 'bar') && !newState._view_from_python) { newState.view_x0 = p2.state.view_x0; newState.view_x1 = p2.state.view_x1; + } else if (p2.state && p2.kind === '3d' && !newState._view_from_python) { + newState.azimuth = p2.state.azimuth; + newState.elevation = p2.state.elevation; + newState.zoom = p2.state.zoom; } p2.state = newState; } diff --git a/anyplotlib/plot1d/_plot1d.py b/anyplotlib/plot1d/_plot1d.py index 4010597f..5915479e 100644 --- a/anyplotlib/plot1d/_plot1d.py +++ b/anyplotlib/plot1d/_plot1d.py @@ -327,7 +327,6 @@ def to_state_dict(self) -> dict: x_arr = d.pop("x_axis") d["data_b64"] = _arr_to_b64(data_arr, np.float64) d["x_axis_b64"] = _arr_to_b64(x_arr, np.float64) - d["data_length"] = len(data_arr) # Encode extra-line arrays too new_extra = [] for ex in d["extra_lines"]: @@ -912,6 +911,10 @@ def set_axis_off(self) -> None: self._state["axis_visible"] = False self._push() + def set_axis_on(self) -> None: + self._state["axis_visible"] = True + self._push() + def set_ticks_visible(self, visible: bool, *, x: bool | None = None, y: bool | None = None) -> None: if x is None and y is None: diff --git a/anyplotlib/plot1d/_plotbar.py b/anyplotlib/plot1d/_plotbar.py index f73b940a..7801f59c 100644 --- a/anyplotlib/plot1d/_plotbar.py +++ b/anyplotlib/plot1d/_plotbar.py @@ -343,10 +343,17 @@ def set_axis_on(self) -> None: self._state["axis_visible"] = True self._push() - def set_ticks_visible(self, x: bool = True, y: bool = True) -> None: + def set_ticks_visible(self, visible: bool, *, x: bool | None = None, + y: bool | None = None) -> None: """Show or hide x/y tick marks independently.""" - self._state["x_ticks_visible"] = bool(x) - self._state["y_ticks_visible"] = bool(y) + if x is None and y is None: + self._state["x_ticks_visible"] = bool(visible) + self._state["y_ticks_visible"] = bool(visible) + else: + if x is not None: + self._state["x_ticks_visible"] = bool(x) + if y is not None: + self._state["y_ticks_visible"] = bool(y) self._push() # ------------------------------------------------------------------ @@ -376,6 +383,14 @@ def get_ylim(self) -> tuple: return (float(yr[0]), float(yr[1])) return (float(self._state["data_min"]), float(self._state["data_max"])) + def get_xlim(self) -> tuple: + """Return the current x-axis view range in data coordinates.""" + x_axis = self._state["x_axis"] + span = x_axis[1] - x_axis[0] + x0 = x_axis[0] + self._state["view_x0"] * span + x1 = x_axis[0] + self._state["view_x1"] * span + return (float(x0), float(x1)) + def reset_view(self) -> None: """Reset pan/zoom to show all bars.""" self._state["view_x0"] = 0.0 diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py index 4d00475c..24f95721 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -108,7 +108,6 @@ def __init__(self, data: np.ndarray, "raw_min": raw_vmin, "raw_max": raw_vmax, "show_colorbar": False, - "log_scale": False, "scale_mode": "linear", "colormap_name": cmap_name, "colormap_data": cmap_lut, @@ -377,6 +376,10 @@ def set_axis_off(self) -> None: self._state["axis_visible"] = False self._push() + def set_axis_on(self) -> None: + self._state["axis_visible"] = True + self._push() + def set_ticks_visible(self, visible: bool, *, x: bool | None = None, y: bool | None = None) -> None: if x is None and y is None: diff --git a/anyplotlib/plot2d/_plotmesh.py b/anyplotlib/plot2d/_plotmesh.py index cad16dad..0a8184c9 100644 --- a/anyplotlib/plot2d/_plotmesh.py +++ b/anyplotlib/plot2d/_plotmesh.py @@ -106,3 +106,11 @@ def set_data(self, data: np.ndarray, if units is not None: self._state["units"] = units self._push() + + def __repr__(self) -> str: + xe = self._state.get("x_axis", []) + ye = self._state.get("y_axis", []) + cols = max(0, len(xe) - 1) + rows = max(0, len(ye) - 1) + cmap = self._state.get("colormap_name", "?") + return f"PlotMesh({rows}×{cols}, cmap={cmap!r})" diff --git a/anyplotlib/plot3d/_plot3d.py b/anyplotlib/plot3d/_plot3d.py index 8356700e..198ca258 100644 --- a/anyplotlib/plot3d/_plot3d.py +++ b/anyplotlib/plot3d/_plot3d.py @@ -124,6 +124,10 @@ def __init__(self, geom_type: str, "azimuth": float(azimuth), "elevation": float(elevation), "zoom": float(zoom), + "_default_azimuth": float(azimuth), + "_default_elevation": float(elevation), + "_default_zoom": float(zoom), + "_view_from_python": False, "data_bounds": data_bounds, "pointer_settled_ms": 0, "pointer_settled_delta": 4, @@ -158,10 +162,39 @@ def set_view(self, azimuth: float | None = None, """Set the camera azimuth (°) and/or elevation (°).""" if azimuth is not None: self._state["azimuth"] = float(azimuth) if elevation is not None: self._state["elevation"] = float(elevation) + self._state["_view_from_python"] = True self._push() + self._state["_view_from_python"] = False def set_zoom(self, zoom: float) -> None: self._state["zoom"] = float(zoom) + self._state["_view_from_python"] = True + self._push() + self._state["_view_from_python"] = False + + def reset_view(self) -> None: + """Restore the camera to the angles/zoom set at construction time.""" + self._state["azimuth"] = self._state["_default_azimuth"] + self._state["elevation"] = self._state["_default_elevation"] + self._state["zoom"] = self._state["_default_zoom"] + self._state["_view_from_python"] = True + self._push() + self._state["_view_from_python"] = False + + def set_xlabel(self, label: str) -> None: + self._state["x_label"] = label + self._push() + + def set_ylabel(self, label: str) -> None: + self._state["y_label"] = label + self._push() + + def set_zlabel(self, label: str) -> None: + self._state["z_label"] = label + self._push() + + def set_title(self, title: str) -> None: + self._state["title"] = title self._push() def set_data(self, x, y, z) -> None: diff --git a/anyplotlib/tests/test_markers/test_markers.py b/anyplotlib/tests/test_markers/test_markers.py index 8678f244..1971cb57 100644 --- a/anyplotlib/tests/test_markers/test_markers.py +++ b/anyplotlib/tests/test_markers/test_markers.py @@ -693,3 +693,34 @@ def test_add_arrows_wire(self): assert "U" in w and "V" in w assert len(w["U"]) == 2 + + +# =========================================================================== +# Top-level exports +# =========================================================================== + +class TestTopLevelExports: + def test_line1d_exported(self): + import anyplotlib as apl + assert hasattr(apl, "Line1D") + from anyplotlib import Line1D + assert Line1D is not None + + def test_marker_registry_exported(self): + import anyplotlib as apl + assert hasattr(apl, "MarkerRegistry") + from anyplotlib import MarkerRegistry + assert MarkerRegistry is not None + + def test_marker_group_exported(self): + import anyplotlib as apl + assert hasattr(apl, "MarkerGroup") + from anyplotlib import MarkerGroup + assert MarkerGroup is not None + + def test_line1d_data_length_not_in_wire(self): + """data_length must not appear in to_state_dict() wire output.""" + fig, ax = apl.subplots(1, 1) + p = ax.plot(np.linspace(0, 1, 64)) + wire = p.to_state_dict() + assert "data_length" not in wire diff --git a/anyplotlib/tests/test_plot1d/test_plot1d.py b/anyplotlib/tests/test_plot1d/test_plot1d.py index b0eb27ca..b8ffb9df 100644 --- a/anyplotlib/tests/test_plot1d/test_plot1d.py +++ b/anyplotlib/tests/test_plot1d/test_plot1d.py @@ -937,3 +937,21 @@ def test_add_line_uses_default_color_in_state(self): p.add_line(np.linspace(-1, 1, 128)) assert p._state["extra_lines"][-1]["color"] == "#4fc3f7" + + +# =========================================================================== +# set_axis_on (Plot1D) +# =========================================================================== + +class TestSetAxisOnPlot1D: + def test_set_axis_on_restores(self): + p = _plot() + p.set_axis_off() + assert p._state["axis_visible"] is False + p.set_axis_on() + assert p._state["axis_visible"] is True + + def test_set_axis_on_default_state(self): + p = _plot() + p.set_axis_on() + assert p._state["axis_visible"] is True diff --git a/anyplotlib/tests/test_plot1d/test_plotbar.py b/anyplotlib/tests/test_plot1d/test_plotbar.py index dec83a7b..46290fd4 100644 --- a/anyplotlib/tests/test_plot1d/test_plotbar.py +++ b/anyplotlib/tests/test_plot1d/test_plotbar.py @@ -790,13 +790,13 @@ def test_set_axis_on_restores(self): def test_set_ticks_visible_both_false(self): p = _make_bar() - p.set_ticks_visible(x=False, y=False) + p.set_ticks_visible(False) assert p._state["x_ticks_visible"] is False assert p._state["y_ticks_visible"] is False def test_set_ticks_visible_x_only(self): p = _make_bar() - p.set_ticks_visible(x=True, y=False) + p.set_ticks_visible(True, x=True, y=False) assert p._state["x_ticks_visible"] is True assert p._state["y_ticks_visible"] is False @@ -851,3 +851,51 @@ def test_reset_view_clears_flag(self): p.reset_view() assert p._state["_view_from_python"] is False + + +# =========================================================================== +# PlotBar: get_xlim and fixed set_ticks_visible signature +# =========================================================================== + +class TestPlotBarGetXlim: + def test_get_xlim_default(self): + p = _make_bar() + x_axis = p._state["x_axis"] + lo, hi = p.get_xlim() + assert lo == pytest.approx(x_axis[0]) + assert hi == pytest.approx(x_axis[-1]) + + def test_get_xlim_after_set_xlim(self): + fig, ax = apl.subplots(1, 1) + p = ax.bar(np.arange(10), np.ones(10)) + p.set_xlim(2.0, 7.0) + lo, hi = p.get_xlim() + assert lo == pytest.approx(2.0, abs=0.5) + assert hi == pytest.approx(7.0, abs=0.5) + + +class TestPlotBarSetTicksVisibleSignature: + def test_positional_visible_both(self): + p = _make_bar() + p.set_ticks_visible(False) + assert p._state["x_ticks_visible"] is False + assert p._state["y_ticks_visible"] is False + + def test_positional_visible_true(self): + p = _make_bar() + p.set_ticks_visible(False) + p.set_ticks_visible(True) + assert p._state["x_ticks_visible"] is True + assert p._state["y_ticks_visible"] is True + + def test_keyword_x_only(self): + p = _make_bar() + p.set_ticks_visible(True, x=False) + assert p._state["x_ticks_visible"] is False + assert p._state["y_ticks_visible"] is True + + def test_keyword_y_only(self): + p = _make_bar() + p.set_ticks_visible(True, y=False) + assert p._state["x_ticks_visible"] is True + assert p._state["y_ticks_visible"] is False diff --git a/anyplotlib/tests/test_plot2d/test_plot2d_api.py b/anyplotlib/tests/test_plot2d/test_plot2d_api.py index 3929e7ed..b66560e3 100644 --- a/anyplotlib/tests/test_plot2d/test_plot2d_api.py +++ b/anyplotlib/tests/test_plot2d/test_plot2d_api.py @@ -425,3 +425,39 @@ def test_get_xlim_and_get_ylim_match_axes(self): assert xhi == pytest.approx(5.0) assert ylo == pytest.approx(2.0) assert yhi == pytest.approx(8.0) + + +# =========================================================================== +# Plot2D: set_axis_on and no log_scale key +# =========================================================================== + +class TestPlot2DSetAxisOn: + def test_set_axis_on_restores(self): + fig, ax = apl.subplots(1, 1) + p = ax.imshow(np.zeros((8, 8)), units="px") + p.set_axis_off() + assert p._state["axis_visible"] is False + p.set_axis_on() + assert p._state["axis_visible"] is True + + def test_no_log_scale_key(self): + fig, ax = apl.subplots(1, 1) + p = ax.imshow(np.zeros((8, 8)), units="px") + assert "log_scale" not in p._state + + +class TestPlotMeshRepr: + def test_repr_is_plotmesh(self): + from anyplotlib.plot2d import PlotMesh + fig, ax = apl.subplots(1, 1) + p = ax.pcolormesh(np.ones((4, 6))) + r = repr(p) + assert r.startswith("PlotMesh(") + assert "4" in r + assert "6" in r + + def test_repr_not_plot2d(self): + from anyplotlib.plot2d import PlotMesh + fig, ax = apl.subplots(1, 1) + p = ax.pcolormesh(np.ones((3, 5))) + assert not repr(p).startswith("Plot2D(") diff --git a/anyplotlib/tests/test_plot3d/test_plot3d.py b/anyplotlib/tests/test_plot3d/test_plot3d.py index 01f68ae6..4a351adf 100644 --- a/anyplotlib/tests/test_plot3d/test_plot3d.py +++ b/anyplotlib/tests/test_plot3d/test_plot3d.py @@ -195,6 +195,59 @@ def test_set_data_surface_bad_shape(self): with pytest.raises(ValueError): surf.set_data(x, x, x) + def test_set_view_clears_view_from_python(self): + surf, *_ = _surface() + surf.set_view(azimuth=10.0) + assert surf._state["_view_from_python"] is False + + def test_set_zoom_clears_view_from_python(self): + surf, *_ = _surface() + surf.set_zoom(1.5) + assert surf._state["_view_from_python"] is False + + def test_reset_view_restores_defaults(self): + surf, *_ = _surface() + surf.set_view(azimuth=90.0, elevation=10.0) + surf.set_zoom(3.0) + surf.reset_view() + assert surf._state["azimuth"] == pytest.approx(-60.0) + assert surf._state["elevation"] == pytest.approx(30.0) + assert surf._state["zoom"] == pytest.approx(1.0) + assert surf._state["_view_from_python"] is False + + def test_reset_view_uses_constructor_angles(self): + x = np.linspace(-1, 1, 5) + y = np.linspace(-1, 1, 5) + XX, YY = np.meshgrid(x, y) + ZZ = XX * YY + fig, ax = apl.subplots(1, 1) + surf = ax.plot_surface(XX, YY, ZZ, azimuth=15.0, elevation=45.0, zoom=2.0) + surf.set_view(azimuth=0.0, elevation=0.0) + surf.reset_view() + assert surf._state["azimuth"] == pytest.approx(15.0) + assert surf._state["elevation"] == pytest.approx(45.0) + assert surf._state["zoom"] == pytest.approx(2.0) + + def test_set_xlabel(self): + surf, *_ = _surface() + surf.set_xlabel("time") + assert surf._state["x_label"] == "time" + + def test_set_ylabel(self): + surf, *_ = _surface() + surf.set_ylabel("depth") + assert surf._state["y_label"] == "depth" + + def test_set_zlabel(self): + surf, *_ = _surface() + surf.set_zlabel("intensity") + assert surf._state["z_label"] == "intensity" + + def test_set_title(self): + surf, *_ = _surface() + surf.set_title("My Surface") + assert surf._state["title"] == "My Surface" + # =========================================================================== # repr() uses vertices_count, not len(vertices) From 8c275182e847c76933cc4bebacba0782b59ad336 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 22 May 2026 20:17:22 -0500 Subject: [PATCH 08/11] Refactor: Standardize method names and enhance axis visibility controls for Plot1D, Plot2D, and Plot3D; add y-axis scale configuration and data bounds getters --- anyplotlib/plot1d/_plot1d.py | 12 ++- anyplotlib/plot1d/_plotbar.py | 49 +++++++--- anyplotlib/plot2d/_plot2d.py | 5 +- anyplotlib/plot3d/_plot3d.py | 36 ++++++- anyplotlib/tests/test_plot1d/test_plot1d.py | 60 ++++++++++++ anyplotlib/tests/test_plot1d/test_plotbar.py | 94 +++++++++++++++++++ .../tests/test_plot2d/test_plot2d_api.py | 43 +++++++++ anyplotlib/tests/test_plot3d/test_plot3d.py | 72 ++++++++++++++ 8 files changed, 354 insertions(+), 17 deletions(-) diff --git a/anyplotlib/plot1d/_plot1d.py b/anyplotlib/plot1d/_plot1d.py index 5915479e..ef208e3a 100644 --- a/anyplotlib/plot1d/_plot1d.py +++ b/anyplotlib/plot1d/_plot1d.py @@ -305,11 +305,14 @@ def __init__(self, data: np.ndarray, self.callbacks = CallbackRegistry() self._widgets: dict[str, Widget] = {} - def _configure_pointer_settled(self, ms: int, delta: float) -> None: + def configure_pointer_settled(self, ms: int, delta: float = 4) -> None: + """Configure the pointer-settled event threshold (ms and pixel delta).""" self._state["pointer_settled_ms"] = ms self._state["pointer_settled_delta"] = delta self._push() + _configure_pointer_settled = configure_pointer_settled # backward compat + def _push(self) -> None: if self._fig is None: return @@ -880,6 +883,13 @@ def set_title(self, label: str) -> None: self._state["title"] = str(label) self._push() + def set_yscale(self, scale: str) -> None: + """Set the y-axis scale: ``'linear'`` or ``'log'``.""" + if scale not in ("linear", "log"): + raise ValueError("scale must be 'linear' or 'log'") + self._state["yscale"] = scale + self._push() + def set_xlim(self, xmin: float, xmax: float) -> None: self.set_view(x0=xmin, x1=xmax) diff --git a/anyplotlib/plot1d/_plotbar.py b/anyplotlib/plot1d/_plotbar.py index 7801f59c..4e8f84f6 100644 --- a/anyplotlib/plot1d/_plotbar.py +++ b/anyplotlib/plot1d/_plotbar.py @@ -213,11 +213,14 @@ def __init__(self, x, height=None, width: float = 0.8, bottom: float = 0.0, *, self.callbacks = CallbackRegistry() self._widgets: dict[str, Widget] = {} - def _configure_pointer_settled(self, ms: int, delta: float) -> None: + def configure_pointer_settled(self, ms: int, delta: float = 4) -> None: + """Configure the pointer-settled event threshold (ms and pixel delta).""" self._state["pointer_settled_ms"] = ms self._state["pointer_settled_delta"] = delta self._push() + _configure_pointer_settled = configure_pointer_settled # backward compat + # ------------------------------------------------------------------ def _push(self) -> None: if self._fig is None: @@ -318,19 +321,43 @@ def set_log_scale(self, log_scale: bool) -> None: # ------------------------------------------------------------------ # Display control # ------------------------------------------------------------------ - def set_title(self, text: str) -> None: + def set_title(self, label: str) -> None: """Set the panel title.""" - self._state["title"] = str(text) + self._state["title"] = str(label) self._push() - def set_xlabel(self, text: str) -> None: + def set_xlabel(self, label: str) -> None: """Set the x-axis label.""" - self._state["x_label"] = str(text) + self._state["x_label"] = str(label) self._push() - def set_ylabel(self, text: str) -> None: + def set_ylabel(self, label: str) -> None: """Set the y-axis / value-axis label.""" - self._state["y_label"] = str(text) + self._state["y_label"] = str(label) + self._push() + + def set_bar_width(self, width: float) -> None: + """Set the bar width.""" + self._state["bar_width"] = float(width) + self._push() + + def set_align(self, align: str) -> None: + """Set bar alignment: ``'center'`` or ``'edge'``.""" + if align not in ("center", "edge"): + raise ValueError("align must be 'center' or 'edge'") + self._state["align"] = align + self._push() + + def set_orient(self, orient: str) -> None: + """Set bar orientation: ``'v'`` (vertical) or ``'h'`` (horizontal).""" + if orient not in ("v", "h"): + raise ValueError("orient must be 'v' or 'h'") + self._state["orient"] = orient + self._push() + + def set_group_labels(self, labels) -> None: + """Replace the category labels on the category axis.""" + self._state["group_labels"] = list(labels) self._push() def set_axis_off(self) -> None: @@ -359,14 +386,14 @@ def set_ticks_visible(self, visible: bool, *, x: bool | None = None, # ------------------------------------------------------------------ # View (xlim / ylim) # ------------------------------------------------------------------ - def set_xlim(self, x_min: float, x_max: float) -> None: - """Pan/zoom the x-axis to [x_min, x_max] in data coordinates.""" + def set_xlim(self, xmin: float, xmax: float) -> None: + """Pan/zoom the x-axis to [xmin, xmax] in data coordinates.""" x_axis = self._state["x_axis"] span = x_axis[1] - x_axis[0] if span == 0: return - self._state["view_x0"] = (x_min - x_axis[0]) / span - self._state["view_x1"] = (x_max - x_axis[0]) / span + self._state["view_x0"] = (xmin - x_axis[0]) / span + self._state["view_x1"] = (xmax - x_axis[0]) / span self._state["_view_from_python"] = True self._push() self._state["_view_from_python"] = False diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py index 24f95721..28e6030a 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -143,11 +143,14 @@ def __init__(self, data: np.ndarray, self.callbacks = CallbackRegistry() self._widgets: dict[str, Widget] = {} - def _configure_pointer_settled(self, ms: int, delta: float) -> None: + def configure_pointer_settled(self, ms: int, delta: float = 4) -> None: + """Configure the pointer-settled event threshold (ms and pixel delta).""" self._state["pointer_settled_ms"] = ms self._state["pointer_settled_delta"] = delta self._push() + _configure_pointer_settled = configure_pointer_settled # backward compat + @staticmethod def _encode_bytes(arr: np.ndarray) -> str: import base64 diff --git a/anyplotlib/plot3d/_plot3d.py b/anyplotlib/plot3d/_plot3d.py index 198ca258..2df937e6 100644 --- a/anyplotlib/plot3d/_plot3d.py +++ b/anyplotlib/plot3d/_plot3d.py @@ -118,9 +118,11 @@ def __init__(self, geom_type: str, "color": color, "point_size": float(point_size), "linewidth": float(linewidth), + "title": "", "x_label": x_label, "y_label": y_label, "z_label": z_label, + "axis_visible": True, "azimuth": float(azimuth), "elevation": float(elevation), "zoom": float(zoom), @@ -134,11 +136,14 @@ def __init__(self, geom_type: str, } self.callbacks = CallbackRegistry() - def _configure_pointer_settled(self, ms: int, delta: float) -> None: + def configure_pointer_settled(self, ms: int, delta: float = 4) -> None: + """Configure the pointer-settled event threshold (ms and pixel delta).""" self._state["pointer_settled_ms"] = ms self._state["pointer_settled_delta"] = delta self._push() + _configure_pointer_settled = configure_pointer_settled # backward compat + # ------------------------------------------------------------------ def _push(self) -> None: if self._fig is None: @@ -181,6 +186,18 @@ def reset_view(self) -> None: self._push() self._state["_view_from_python"] = False + def set_axis_off(self) -> None: + self._state["axis_visible"] = False + self._push() + + def set_axis_on(self) -> None: + self._state["axis_visible"] = True + self._push() + + def set_title(self, label: str) -> None: + self._state["title"] = str(label) + self._push() + def set_xlabel(self, label: str) -> None: self._state["x_label"] = label self._push() @@ -193,9 +210,20 @@ def set_zlabel(self, label: str) -> None: self._state["z_label"] = label self._push() - def set_title(self, title: str) -> None: - self._state["title"] = title - self._push() + def get_xlim(self) -> tuple: + """Return the data x range as ``(xmin, xmax)``.""" + b = self._state["data_bounds"] + return (b["xmin"], b["xmax"]) + + def get_ylim(self) -> tuple: + """Return the data y range as ``(ymin, ymax)``.""" + b = self._state["data_bounds"] + return (b["ymin"], b["ymax"]) + + def get_zlim(self) -> tuple: + """Return the data z range as ``(zmin, zmax)``.""" + b = self._state["data_bounds"] + return (b["zmin"], b["zmax"]) def set_data(self, x, y, z) -> None: """Replace the geometry data.""" diff --git a/anyplotlib/tests/test_plot1d/test_plot1d.py b/anyplotlib/tests/test_plot1d/test_plot1d.py index b8ffb9df..fedd5313 100644 --- a/anyplotlib/tests/test_plot1d/test_plot1d.py +++ b/anyplotlib/tests/test_plot1d/test_plot1d.py @@ -955,3 +955,63 @@ def test_set_axis_on_default_state(self): p = _plot() p.set_axis_on() assert p._state["axis_visible"] is True + + +# =========================================================================== +# M4: set_yscale on Plot1D +# =========================================================================== + +class TestSetYscale: + def test_set_yscale_log(self): + p = _plot() + p.set_yscale("log") + assert p._state["yscale"] == "log" + + def test_set_yscale_linear(self): + p = _plot() + p.set_yscale("log") + p.set_yscale("linear") + assert p._state["yscale"] == "linear" + + def test_set_yscale_invalid(self): + p = _plot() + with pytest.raises(ValueError): + p.set_yscale("symlog") + + +# =========================================================================== +# m2: configure_pointer_settled public on Plot1D +# =========================================================================== + +class TestPlot1DConfigurePointerSettled: + def test_public_method_exists(self): + p = _plot() + assert hasattr(p, "configure_pointer_settled") + assert callable(p.configure_pointer_settled) + + def test_sets_state(self): + p = _plot() + p.configure_pointer_settled(200, 5) + assert p._state["pointer_settled_ms"] == 200 + assert p._state["pointer_settled_delta"] == 5 + + +# =========================================================================== +# m3: direct tests for set_title/xlabel/ylabel and set_axis_on on Plot1D +# =========================================================================== + +class TestPlot1DDisplayMethods: + def test_set_title(self): + p = _plot() + p.set_title("My Plot") + assert p._state["title"] == "My Plot" + + def test_set_xlabel(self): + p = _plot() + p.set_xlabel("Time (s)") + assert p._state["units"] == "Time (s)" + + def test_set_ylabel(self): + p = _plot() + p.set_ylabel("Amplitude") + assert p._state["y_units"] == "Amplitude" diff --git a/anyplotlib/tests/test_plot1d/test_plotbar.py b/anyplotlib/tests/test_plot1d/test_plotbar.py index 46290fd4..52e66eed 100644 --- a/anyplotlib/tests/test_plot1d/test_plotbar.py +++ b/anyplotlib/tests/test_plot1d/test_plotbar.py @@ -899,3 +899,97 @@ def test_keyword_y_only(self): p.set_ticks_visible(True, y=False) assert p._state["x_ticks_visible"] is True assert p._state["y_ticks_visible"] is False + + +# =========================================================================== +# M3: PlotBar constructor-only setters +# =========================================================================== + +class TestPlotBarNewSetters: + def test_set_bar_width(self): + p = _make_bar() + p.set_bar_width(0.5) + assert p._state["bar_width"] == pytest.approx(0.5) + + def test_set_align_center(self): + p = _make_bar() + p.set_align("center") + assert p._state["align"] == "center" + + def test_set_align_edge(self): + p = _make_bar() + p.set_align("edge") + assert p._state["align"] == "edge" + + def test_set_align_invalid(self): + p = _make_bar() + with pytest.raises(ValueError): + p.set_align("left") + + def test_set_orient_h(self): + p = _make_bar() + p.set_orient("h") + assert p._state["orient"] == "h" + + def test_set_orient_v(self): + p = _make_bar() + p.set_orient("v") + assert p._state["orient"] == "v" + + def test_set_orient_invalid(self): + p = _make_bar() + with pytest.raises(ValueError): + p.set_orient("diagonal") + + def test_set_group_labels(self): + p = _make_bar() + p.set_group_labels(["a", "b", "c"]) + assert p._state["group_labels"] == ["a", "b", "c"] + + +# =========================================================================== +# M1/M2: standardized parameter names +# =========================================================================== + +class TestPlotBarParameterNames: + def test_set_title_uses_label_param(self): + import inspect + p = _make_bar() + sig = inspect.signature(p.set_title) + assert "label" in sig.parameters + + def test_set_xlabel_uses_label_param(self): + import inspect + p = _make_bar() + sig = inspect.signature(p.set_xlabel) + assert "label" in sig.parameters + + def test_set_xlim_uses_xmin_xmax(self): + import inspect + p = _make_bar() + sig = inspect.signature(p.set_xlim) + params = list(sig.parameters) + assert params[0] == "xmin" + assert params[1] == "xmax" + + def test_set_title_works(self): + p = _make_bar() + p.set_title(label="My Bar Chart") + assert p._state["title"] == "My Bar Chart" + + +# =========================================================================== +# m2: configure_pointer_settled public on PlotBar +# =========================================================================== + +class TestPlotBarConfigurePointerSettled: + def test_public_method_exists(self): + p = _make_bar() + assert hasattr(p, "configure_pointer_settled") + assert callable(p.configure_pointer_settled) + + def test_sets_state(self): + p = _make_bar() + p.configure_pointer_settled(300, 6) + assert p._state["pointer_settled_ms"] == 300 + assert p._state["pointer_settled_delta"] == 6 diff --git a/anyplotlib/tests/test_plot2d/test_plot2d_api.py b/anyplotlib/tests/test_plot2d/test_plot2d_api.py index b66560e3..5276cf76 100644 --- a/anyplotlib/tests/test_plot2d/test_plot2d_api.py +++ b/anyplotlib/tests/test_plot2d/test_plot2d_api.py @@ -461,3 +461,46 @@ def test_repr_not_plot2d(self): fig, ax = apl.subplots(1, 1) p = ax.pcolormesh(np.ones((3, 5))) assert not repr(p).startswith("Plot2D(") + + +# =========================================================================== +# m2: configure_pointer_settled public on Plot2D +# =========================================================================== + +class TestPlot2DConfigurePointerSettled: + def test_public_method_exists(self): + fig, ax = apl.subplots(1, 1) + p = ax.imshow(np.zeros((8, 8)), units="px") + assert hasattr(p, "configure_pointer_settled") + assert callable(p.configure_pointer_settled) + + def test_sets_state(self): + fig, ax = apl.subplots(1, 1) + p = ax.imshow(np.zeros((8, 8)), units="px") + p.configure_pointer_settled(150, 3) + assert p._state["pointer_settled_ms"] == 150 + assert p._state["pointer_settled_delta"] == 3 + + +# =========================================================================== +# m3: set_title / set_xlabel / set_ylabel direct tests on Plot2D +# =========================================================================== + +class TestPlot2DDisplayMethods: + def test_set_title(self): + fig, ax = apl.subplots(1, 1) + p = ax.imshow(np.zeros((8, 8)), units="px") + p.set_title("My Image") + assert p._state["title"] == "My Image" + + def test_set_xlabel(self): + fig, ax = apl.subplots(1, 1) + p = ax.imshow(np.zeros((8, 8)), units="px") + p.set_xlabel("x (nm)") + assert p._state["x_label"] == "x (nm)" + + def test_set_ylabel(self): + fig, ax = apl.subplots(1, 1) + p = ax.imshow(np.zeros((8, 8)), units="px") + p.set_ylabel("y (nm)") + assert p._state["y_label"] == "y (nm)" diff --git a/anyplotlib/tests/test_plot3d/test_plot3d.py b/anyplotlib/tests/test_plot3d/test_plot3d.py index 4a351adf..5dcb8f4f 100644 --- a/anyplotlib/tests/test_plot3d/test_plot3d.py +++ b/anyplotlib/tests/test_plot3d/test_plot3d.py @@ -287,3 +287,75 @@ def test_repr_on_real_line(self): assert f"n_vertices={len(x)}" in r + + +# =========================================================================== +# C1: title initialized in _state +# =========================================================================== + +class TestPlot3DTitle: + def test_title_initialized_empty(self): + surf, *_ = _surface() + assert "title" in surf._state + assert surf._state["title"] == "" + + def test_set_title_label_param(self): + surf, *_ = _surface() + surf.set_title("My Plot") + assert surf._state["title"] == "My Plot" + + def test_set_title_in_wire(self): + surf, *_ = _surface() + surf.set_title("Wire Test") + assert surf.to_state_dict()["title"] == "Wire Test" + + +# =========================================================================== +# C2: axis_on / axis_off on Plot3D +# =========================================================================== + +class TestPlot3DAxisVisibility: + def test_axis_visible_initialized_true(self): + surf, *_ = _surface() + assert surf._state["axis_visible"] is True + + def test_set_axis_off(self): + surf, *_ = _surface() + surf.set_axis_off() + assert surf._state["axis_visible"] is False + + def test_set_axis_on_restores(self): + surf, *_ = _surface() + surf.set_axis_off() + surf.set_axis_on() + assert surf._state["axis_visible"] is True + + +# =========================================================================== +# m1: data-bounds getters on Plot3D +# =========================================================================== + +class TestPlot3DLimGetters: + def test_get_xlim(self): + surf, XX, YY, ZZ = _surface() + lo, hi = surf.get_xlim() + assert lo == pytest.approx(float(XX.min())) + assert hi == pytest.approx(float(XX.max())) + + def test_get_ylim(self): + surf, XX, YY, ZZ = _surface() + lo, hi = surf.get_ylim() + assert lo == pytest.approx(float(YY.min())) + assert hi == pytest.approx(float(YY.max())) + + def test_get_zlim(self): + surf, XX, YY, ZZ = _surface() + lo, hi = surf.get_zlim() + assert lo == pytest.approx(float(ZZ.min())) + assert hi == pytest.approx(float(ZZ.max())) + + def test_get_xlim_scatter(self): + sc, x, y, z = _scatter() + lo, hi = sc.get_xlim() + assert lo == pytest.approx(float(x.min())) + assert hi == pytest.approx(float(x.max())) From a05043b85a727da8ee47a55c47f634e390c80250 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 22 May 2026 20:18:26 -0500 Subject: [PATCH 09/11] Refactor: add png baselines --- anyplotlib/tests/baselines/imshow_axis_off.png | Bin 0 -> 4284 bytes anyplotlib/tests/baselines/imshow_labels.png | Bin 0 -> 26376 bytes anyplotlib/tests/baselines/plot1d_axis_off.png | Bin 0 -> 8604 bytes anyplotlib/tests/baselines/plot1d_title.png | Bin 0 -> 11606 bytes 4 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 anyplotlib/tests/baselines/imshow_axis_off.png create mode 100644 anyplotlib/tests/baselines/imshow_labels.png create mode 100644 anyplotlib/tests/baselines/plot1d_axis_off.png create mode 100644 anyplotlib/tests/baselines/plot1d_title.png diff --git a/anyplotlib/tests/baselines/imshow_axis_off.png b/anyplotlib/tests/baselines/imshow_axis_off.png new file mode 100644 index 0000000000000000000000000000000000000000..38ef84c0f7cf8af13eb574a5d267105b166a133a GIT binary patch literal 4284 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 literal 0 HcmV?d00001 diff --git a/anyplotlib/tests/baselines/plot1d_axis_off.png b/anyplotlib/tests/baselines/plot1d_axis_off.png new file mode 100644 index 0000000000000000000000000000000000000000..b053784cd6402d1b177ca5702a3220ccf76fc67b GIT binary patch literal 8604 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 literal 0 HcmV?d00001 From abec7ecb28392078ad71ad22eb3cd096cf6a588c Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 22 May 2026 21:13:11 -0500 Subject: [PATCH 10/11] Refactor: Improve docstring for vertices parameter in polygon function --- anyplotlib/widgets/_widgets2d.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/anyplotlib/widgets/_widgets2d.py b/anyplotlib/widgets/_widgets2d.py index 04537bfe..5a2456dc 100644 --- a/anyplotlib/widgets/_widgets2d.py +++ b/anyplotlib/widgets/_widgets2d.py @@ -100,8 +100,8 @@ class PolygonWidget(Widget): ---------- push_fn : Callable Update callback. - vertices : list of (x, y) tuples - Polygon vertices in pixel/data coordinates. + vertices : list of tuple + Polygon vertices ``[(x0, y0), (x1, y1), ...]`` in pixel/data coordinates. Must have at least 3 vertices. color : str, optional CSS colour for the polygon outline. Default ``"#00e5ff"``. From 020fb9227ed73d4d10150eb0a460bb1bf425bb5e Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 22 May 2026 21:19:01 -0500 Subject: [PATCH 11/11] Refactor: Enhance subplot adjustment method to allow optional spacing parameters and improve error handling for axis scale settings --- anyplotlib/_utils.py | 2 +- anyplotlib/figure/_figure.py | 20 ++++++++++++-------- anyplotlib/figure_esm.js | 8 ++++---- anyplotlib/plot1d/_plot1d.py | 3 +++ anyplotlib/plot1d/_plotbar.py | 5 +++++ anyplotlib/plot3d/_plot3d.py | 6 +++--- 6 files changed, 28 insertions(+), 16 deletions(-) diff --git a/anyplotlib/_utils.py b/anyplotlib/_utils.py index 404cfaf5..c9996b4b 100644 --- a/anyplotlib/_utils.py +++ b/anyplotlib/_utils.py @@ -51,7 +51,7 @@ 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' " + "'solid', 'dashed', 'dotted', 'dashdot', 'step-mid' (alias: 'steps-mid') " "or shorthands '-', '--', ':', '-.'." ) return canonical diff --git a/anyplotlib/figure/_figure.py b/anyplotlib/figure/_figure.py index c9532420..0fe30df5 100644 --- a/anyplotlib/figure/_figure.py +++ b/anyplotlib/figure/_figure.py @@ -151,23 +151,27 @@ def set_help(self, text: str) -> None: """ self.help_text = self._resolve_help(text) - def subplots_adjust(self, hspace: float = 0.0, wspace: float = 0.0) -> None: + def subplots_adjust(self, hspace: float | None = None, + wspace: float | None = None) -> None: """Set the spacing between subplot panels. + Only the arguments that are explicitly provided are updated; omitting + an argument leaves the current value unchanged. + Parameters ---------- hspace : float, optional Fraction of the average row height to use as vertical gap between panels. ``0.1`` adds a gap of 10 % of the mean row height. - Default ``0.0`` (no gap). Before ``subplots_adjust`` is called, - figures use a 4 px browser default gap. + ``None`` (default) leaves the current hspace unchanged. wspace : float, optional Fraction of the average column width to use as horizontal gap. - Default ``0.0`` (no gap). Before ``subplots_adjust`` is called, - figures use a 4 px browser default gap. + ``None`` (default) leaves the current wspace unchanged. """ - self._hspace = float(hspace) - self._wspace = float(wspace) + if hspace is not None: + self._hspace = float(hspace) + if wspace is not None: + self._wspace = float(wspace) self._push_layout() # ── subplot creation ────────────────────────────────────────────────────── @@ -502,7 +506,7 @@ def close(self) -> None: if hasattr(plot, "callbacks"): plot.callbacks.fire(close_event) try: - self.layout = {"display": "none"} + self.layout.display = "none" except Exception: pass diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index b524da6d..8c3c1ec2 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -2468,8 +2468,8 @@ function render({ model, el }) { const [cx,cy]= tfm==='data' ? _offToCanvas(off) : _tc2d(off[0],off[1]!=null?off[1]:0); const wd=ms.widths[i]!=null?ms.widths[i]:(ms.widths[0]||10); const hd=ms.heights[i]!=null?ms.heights[i]:(ms.heights[0]||10); - const rw=Math.max(1,Math.abs(_xPx(off[0]+wd/2)-_xPx(off[0]-wd/2))/2); - const rh=Math.max(1,Math.abs(_yPx((off[1]||0)-hd/2)-_yPx((off[1]||0)+hd/2))/2); + const rw=Math.max(1, tfm==='data' ? Math.abs(_xPx(off[0]+wd/2)-_xPx(off[0]-wd/2))/2 : wd/2); + 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();} @@ -2482,8 +2482,8 @@ function render({ model, el }) { const [cx,cy]= tfm==='data' ? _offToCanvas(off) : _tc2d(off[0],off[1]!=null?off[1]:0); const wd=ms.widths[i]!=null?ms.widths[i]:(ms.widths[0]||10); const hd=heights[i]!=null?heights[i]:(heights[0]||10); - const rw=Math.max(1,Math.abs(_xPx(off[0]+wd/2)-_xPx(off[0]-wd/2))); - const rh=Math.max(1,Math.abs(_yPx((off[1]||0)-hd/2)-_yPx((off[1]||0)+hd/2))); + const rw=Math.max(1, tfm==='data' ? Math.abs(_xPx(off[0]+wd/2)-_xPx(off[0]-wd/2)) : wd); + 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();} diff --git a/anyplotlib/plot1d/_plot1d.py b/anyplotlib/plot1d/_plot1d.py index ef208e3a..2ef35c99 100644 --- a/anyplotlib/plot1d/_plot1d.py +++ b/anyplotlib/plot1d/_plot1d.py @@ -250,6 +250,9 @@ def __init__(self, data: np.ndarray, self._id: str = "" self._fig: object = None + if yscale not in ("linear", "log"): + raise ValueError("yscale must be 'linear' or 'log'") + data = np.asarray(data, dtype=float) if data.ndim != 1: raise ValueError(f"data must be 1-D, got {data.shape}") diff --git a/anyplotlib/plot1d/_plotbar.py b/anyplotlib/plot1d/_plotbar.py index 4e8f84f6..f4f87832 100644 --- a/anyplotlib/plot1d/_plotbar.py +++ b/anyplotlib/plot1d/_plotbar.py @@ -107,6 +107,11 @@ def __init__(self, x, height=None, width: float = 0.8, bottom: float = 0.0, *, self._id: str = "" self._fig: object = None + if align not in ("center", "edge"): + raise ValueError("align must be 'center' or 'edge'") + if orient not in ("v", "h"): + raise ValueError("orient must be 'v' or 'h'") + # ── legacy resolution ────────────────────────────────────────── if height is None: if values is not None: diff --git a/anyplotlib/plot3d/_plot3d.py b/anyplotlib/plot3d/_plot3d.py index 2df937e6..dc9d4b53 100644 --- a/anyplotlib/plot3d/_plot3d.py +++ b/anyplotlib/plot3d/_plot3d.py @@ -199,15 +199,15 @@ def set_title(self, label: str) -> None: self._push() def set_xlabel(self, label: str) -> None: - self._state["x_label"] = label + self._state["x_label"] = str(label) self._push() def set_ylabel(self, label: str) -> None: - self._state["y_label"] = label + self._state["y_label"] = str(label) self._push() def set_zlabel(self, label: str) -> None: - self._state["z_label"] = label + self._state["z_label"] = str(label) self._push() def get_xlim(self) -> tuple: