diff --git a/anyplotlib/__init__.py b/anyplotlib/__init__.py index e215e53c..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, @@ -15,12 +17,26 @@ # 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", + "Line1D", "CallbackRegistry", "Event", + "MarkerRegistry", "MarkerGroup", "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..c9996b4b 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' (alias: 'steps-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..0fe30df5 100644 --- a/anyplotlib/figure/_figure.py +++ b/anyplotlib/figure/_figure.py @@ -114,6 +114,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 +151,29 @@ def set_help(self, text: str) -> None: """ self.help_text = self._resolve_help(text) + 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. + ``None`` (default) leaves the current hspace unchanged. + wspace : float, optional + Fraction of the average column width to use as horizontal gap. + ``None`` (default) leaves the current wspace unchanged. + """ + if hspace is not None: + self._hspace = float(hspace) + if wspace is not None: + 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 +328,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 ──────────────────────────────────────────────────────── @@ -464,6 +491,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 a77874a6..8c3c1ec2 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) { @@ -388,6 +393,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'); @@ -421,6 +427,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); @@ -429,6 +438,7 @@ function render({ model, el }) { plotWrap.appendChild(cbCanvas); plotWrap.appendChild(scaleBar); plotWrap.appendChild(statusBar); + plotWrap.appendChild(titleCanvas); outerContainer.appendChild(plotWrap); wrapNode = plotWrap; @@ -483,7 +493,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) { @@ -502,6 +512,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 }; @@ -522,6 +533,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, @@ -557,6 +570,13 @@ 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; + } 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; } @@ -679,6 +699,13 @@ 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; + } 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; } @@ -789,13 +816,32 @@ 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; - const 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); + 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; + // 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'; @@ -864,13 +910,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 +1207,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 +1221,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 +1240,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 +1267,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 +1326,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 +1363,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 (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); + } } } @@ -1375,13 +1467,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;ir.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 @@ -1929,7 +2054,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); } } @@ -1993,31 +2118,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; @@ -2073,45 +2214,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 @@ -2142,6 +2302,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); } @@ -2224,7 +2392,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; @@ -2254,11 +2423,24 @@ function render({ model, el }) { const ec = isHov && ms.hover_color ? ms.hover_color : color; const fch = isHov && ms.hover_facecolor ? ms.hover_facecolor : fc; const dlw = isHov && (ms.hover_color || ms.hover_facecolor) ? lw+1 : lw; + + // Coordinate transform: "axes" and "display" map 2-D offsets to panel + // space independently of data values; vlines/hlines stay in data coords. + const tfm = ms.transform || 'data'; + let _tc2d; + if(tfm==='axes'){ + _tc2d=(fx,fy)=>[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 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) { @@ -3895,6 +4152,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); } diff --git a/anyplotlib/markers.py b/anyplotlib/markers.py index dd0e31c4..3d764a09 100644 --- a/anyplotlib/markers.py +++ b/anyplotlib/markers.py @@ -59,6 +59,9 @@ def _offsets_2d(offsets) -> 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) @@ -91,11 +94,18 @@ 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") + 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 + self._parent: "MarkerTypeDict | None" = parent # ------------------------------------------------------------------ def set(self, **kwargs) -> None: @@ -107,9 +117,20 @@ 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() + 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()})" @@ -322,6 +343,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: @@ -483,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 @@ -517,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 259e4327..2ef35c99 100644 --- a/anyplotlib/plot1d/_plot1d.py +++ b/anyplotlib/plot1d/_plot1d.py @@ -245,10 +245,14 @@ 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 + 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}") @@ -287,6 +291,16 @@ 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) + "y_range": None, + # Visibility toggles + "axis_visible": True, + "x_ticks_visible": True, + "y_ticks_visible": True, + "_view_from_python": False, } self.markers = MarkerRegistry(self._push_markers, @@ -294,11 +308,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 @@ -316,7 +333,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"]: @@ -421,7 +437,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, @@ -438,7 +454,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 @@ -769,13 +785,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 @@ -842,6 +862,84 @@ 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_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) + + def set_ylim(self, ymin: float, ymax: float) -> None: + self._state["y_range"] = [float(ymin), float(ymax)] + 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())) + + 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: + 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) # ------------------------------------------------------------------ @@ -852,7 +950,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 +992,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 +1035,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 +1069,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 +1103,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 +1139,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 +1185,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 +1219,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 +1265,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 +1311,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 +1353,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 +1389,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/plot1d/_plotbar.py b/anyplotlib/plot1d/_plotbar.py index fefeafc4..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: @@ -185,14 +190,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,15 +213,19 @@ 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] = {} - 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: @@ -306,6 +323,115 @@ def set_log_scale(self, log_scale: bool) -> None: self._state["data_max"] = dmax self._push() + # ------------------------------------------------------------------ + # Display control + # ------------------------------------------------------------------ + def set_title(self, label: str) -> None: + """Set the panel title.""" + self._state["title"] = str(label) + self._push() + + def set_xlabel(self, label: str) -> None: + """Set the x-axis label.""" + self._state["x_label"] = str(label) + self._push() + + def set_ylabel(self, label: str) -> None: + """Set the y-axis / value-axis label.""" + 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: + """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, visible: bool, *, x: bool | None = None, + y: bool | None = None) -> None: + """Show or hide x/y tick marks independently.""" + 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() + + # ------------------------------------------------------------------ + # View (xlim / ylim) + # ------------------------------------------------------------------ + 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"] = (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 + + 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 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 + 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 280af9f8..28e6030a 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, @@ -126,6 +125,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, @@ -133,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 @@ -305,6 +318,83 @@ 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_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())) + + 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_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: + 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 # ------------------------------------------------------------------ @@ -458,125 +548,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. 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 48638c7b..dc9d4b53 100644 --- a/anyplotlib/plot3d/_plot3d.py +++ b/anyplotlib/plot3d/_plot3d.py @@ -118,23 +118,32 @@ 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), + "_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, } 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: @@ -158,11 +167,63 @@ 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_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"] = str(label) + self._push() + + def set_ylabel(self, label: str) -> None: + self._state["y_label"] = str(label) + self._push() + + def set_zlabel(self, label: str) -> None: + self._state["z_label"] = str(label) + 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.""" @@ -211,5 +272,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/baselines/gridspec_height_ratio_image_histogram.png b/anyplotlib/tests/baselines/gridspec_height_ratio_image_histogram.png index b65992f6..b23a8d43 100644 Binary files a/anyplotlib/tests/baselines/gridspec_height_ratio_image_histogram.png and b/anyplotlib/tests/baselines/gridspec_height_ratio_image_histogram.png differ diff --git a/anyplotlib/tests/baselines/gridspec_image_two_spectra.png b/anyplotlib/tests/baselines/gridspec_image_two_spectra.png index abb8d4e3..9bcdd255 100644 Binary files a/anyplotlib/tests/baselines/gridspec_image_two_spectra.png and b/anyplotlib/tests/baselines/gridspec_image_two_spectra.png differ diff --git a/anyplotlib/tests/baselines/imshow_axis_off.png b/anyplotlib/tests/baselines/imshow_axis_off.png new file mode 100644 index 00000000..38ef84c0 Binary files /dev/null and b/anyplotlib/tests/baselines/imshow_axis_off.png differ diff --git a/anyplotlib/tests/baselines/imshow_checkerboard.png b/anyplotlib/tests/baselines/imshow_checkerboard.png index 73403c29..e047d36f 100644 Binary files a/anyplotlib/tests/baselines/imshow_checkerboard.png and b/anyplotlib/tests/baselines/imshow_checkerboard.png differ diff --git a/anyplotlib/tests/baselines/imshow_gradient.png b/anyplotlib/tests/baselines/imshow_gradient.png index aa9f6c30..419f23e1 100644 Binary files a/anyplotlib/tests/baselines/imshow_gradient.png and b/anyplotlib/tests/baselines/imshow_gradient.png differ diff --git a/anyplotlib/tests/baselines/imshow_labels.png b/anyplotlib/tests/baselines/imshow_labels.png new file mode 100644 index 00000000..985e4760 Binary files /dev/null and b/anyplotlib/tests/baselines/imshow_labels.png differ diff --git a/anyplotlib/tests/baselines/imshow_viridis.png b/anyplotlib/tests/baselines/imshow_viridis.png index c6f7923f..8bd00f45 100644 Binary files a/anyplotlib/tests/baselines/imshow_viridis.png and b/anyplotlib/tests/baselines/imshow_viridis.png differ diff --git a/anyplotlib/tests/baselines/plot1d_axis_off.png b/anyplotlib/tests/baselines/plot1d_axis_off.png new file mode 100644 index 00000000..b053784c Binary files /dev/null and b/anyplotlib/tests/baselines/plot1d_axis_off.png differ diff --git a/anyplotlib/tests/baselines/plot1d_title.png b/anyplotlib/tests/baselines/plot1d_title.png new file mode 100644 index 00000000..729e0363 Binary files /dev/null and b/anyplotlib/tests/baselines/plot1d_title.png differ diff --git a/anyplotlib/tests/baselines/subplots_2x1.png b/anyplotlib/tests/baselines/subplots_2x1.png index 6cf720c3..bd8c1607 100644 Binary files a/anyplotlib/tests/baselines/subplots_2x1.png and b/anyplotlib/tests/baselines/subplots_2x1.png differ 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_gridspec.py b/anyplotlib/tests/test_layouts/test_gridspec.py index 572f6414..96cc051b 100644 --- a/anyplotlib/tests/test_layouts/test_gridspec.py +++ b/anyplotlib/tests/test_layouts/test_gridspec.py @@ -1075,3 +1075,67 @@ 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 + + +# =========================================================================== +# 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_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..1971cb57 100644 --- a/anyplotlib/tests/test_markers/test_markers.py +++ b/anyplotlib/tests/test_markers/test_markers.py @@ -553,3 +553,174 @@ 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"] + + +# =========================================================================== +# _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 + + + +# =========================================================================== +# 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 abe21e32..fedd5313 100644 --- a/anyplotlib/tests/test_plot1d/test_plot1d.py +++ b/anyplotlib/tests/test_plot1d/test_plot1d.py @@ -661,3 +661,357 @@ 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 + + +# =========================================================================== +# 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" + + +# =========================================================================== +# 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" + + + +# =========================================================================== +# 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 + + +# =========================================================================== +# 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 11fd9246..52e66eed 100644 --- a/anyplotlib/tests/test_plot1d/test_plotbar.py +++ b/anyplotlib/tests/test_plot1d/test_plotbar.py @@ -718,3 +718,278 @@ 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(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(True, 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 + + + +# =========================================================================== +# 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 + + +# =========================================================================== +# 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 2203c621..5276cf76 100644 --- a/anyplotlib/tests/test_plot2d/test_plot2d_api.py +++ b/anyplotlib/tests/test_plot2d/test_plot2d_api.py @@ -115,3 +115,392 @@ 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 + + +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 + + +# =========================================================================== +# 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) + + +# =========================================================================== +# 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) + + +# =========================================================================== +# 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(") + + +# =========================================================================== +# 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 2316ecc0..5dcb8f4f 100644 --- a/anyplotlib/tests/test_plot3d/test_plot3d.py +++ b/anyplotlib/tests/test_plot3d/test_plot3d.py @@ -195,4 +195,167 @@ 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) +# =========================================================================== + +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 + + + +# =========================================================================== +# 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())) 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"``.