Skip to content

Commit 60d03d3

Browse files
authored
Merge pull request #33 from CSSFrancis/feat/hyperspy-parity-0.3.0
Feat/hyperspy parity 0.3.0
2 parents 8597490 + 9b0878f commit 60d03d3

9 files changed

Lines changed: 941 additions & 18 deletions

File tree

‎anyplotlib/_base_plot.py‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,37 @@ def clear_widgets(self) -> None:
179179
self._push()
180180

181181

182+
class _TextHandle:
183+
"""Handle for a single text annotation created by :meth:`add_text`.
184+
185+
Wraps the underlying single-text :class:`~anyplotlib.markers.MarkerGroup`
186+
and exposes label-oriented mutators (``set_text`` / ``set_color`` /
187+
``remove``) so callers do not need to know it is backed by a texts
188+
marker collection.
189+
"""
190+
191+
__slots__ = ("_group",)
192+
193+
def __init__(self, group):
194+
self._group = group
195+
196+
def set_text(self, s: str) -> None:
197+
"""Replace the displayed string."""
198+
self._group.set(texts=[s])
199+
200+
def set_color(self, color: str) -> None:
201+
"""Change the text colour."""
202+
self._group.set(color=color)
203+
204+
def remove(self) -> None:
205+
"""Remove the annotation from its panel."""
206+
self._group.remove()
207+
208+
def __repr__(self) -> str: # pragma: no cover
209+
texts = self._group._data.get("texts") or [""]
210+
return f"_TextHandle(text={texts[0]!r})"
211+
212+
182213
class _MarkerMixin:
183214
"""Mixin for panels that support static marker collections.
184215
@@ -197,6 +228,44 @@ def _push_markers(self) -> None:
197228
def _add_marker(self, mtype: str, name, **kwargs):
198229
return self.markers.add(mtype, name, **kwargs)
199230

231+
def add_text(self, x, y, s, name=None, *, color="#ff0000",
232+
fontsize=12, transform="data", **kwargs) -> "_TextHandle":
233+
"""Add a single text annotation at ``(x, y)``.
234+
235+
A convenience wrapper over :meth:`add_texts` for the common
236+
single-label case (e.g. a navigation index or scale-bar label).
237+
The returned handle exposes ``set_text``, ``set_color`` and
238+
``remove`` so callers can mutate the label after creation.
239+
240+
Parameters
241+
----------
242+
x, y : float
243+
Anchor position. Interpreted in the coordinate system named
244+
by ``transform`` (``"data"``, ``"axes"``, or ``"display"``).
245+
s : str
246+
The text to display.
247+
name : str, optional
248+
Registry key. Auto-generated if omitted.
249+
color : str, optional
250+
Text colour. Default ``"#ff0000"``.
251+
fontsize : int, optional
252+
Font size in pixels. Default ``12``.
253+
transform : str, optional
254+
Coordinate system for ``(x, y)``. Default ``"data"``.
255+
**kwargs : dict
256+
Forwarded to :meth:`add_texts` (e.g. ``clip_display``).
257+
258+
Returns
259+
-------
260+
_TextHandle
261+
Handle wrapping the underlying single-text marker group.
262+
"""
263+
group = self.add_texts(
264+
offsets=[(x, y)], texts=[s], name=name,
265+
color=color, fontsize=fontsize, transform=transform, **kwargs,
266+
)
267+
return _TextHandle(group)
268+
200269
def remove_marker(self, marker_type: str, name: str) -> None:
201270
"""Remove a named marker collection by type and name.
202271

‎anyplotlib/figure_esm.js‎

Lines changed: 114 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4291,7 +4291,28 @@ fn fs(in : VsOut) -> @location(0) vec4<f32> {
42914291
const gpuGeomChanged = p._gpuObj && p._gpuObj.geom !== geom;
42924292
if (gpuGeomChanged) { _gpuDisposePanel(p); p._gpu = undefined; }
42934293
if (p.kind === '3d' && _gpuWanted(st)) {
4294-
if (p._gpu === undefined) {
4294+
// Zero-drawable-size guard: SpyDE (and any host) mounts a 3-D panel HIDDEN
4295+
// (display:none) and reveals it later, so the FIRST draw3d can run with
4296+
// pw/ph == 0 (a zero-size layout) and the gpuCanvas collapsed to 0 client
4297+
// px. _gpuInitPanel configures the WebGPU swapchain and every draw pass
4298+
// calls getCurrentTexture() against that canvas — on a zero-drawable-size
4299+
// surface this is undefined behaviour: some drivers throw (→ the mid-draw
4300+
// catch latches p._gpu='unavailable', TERMINAL) and others configure a
4301+
// dead 0×0 context that silently draws nothing. Either way the panel is
4302+
// then stuck on Canvas2D forever because the `_gpu === undefined` guard
4303+
// never re-runs. So DON'T init at zero size: leave _gpu === undefined and
4304+
// let the canvas path render (a hidden panel isn't visible anyway). When
4305+
// the panel is revealed/resized, the layout/fig-size change fires a
4306+
// redrawAll() → this draw3d re-runs at the real size → init re-attempts.
4307+
// Gate on pw/ph (the LOGICAL panel size that drives the gpuCanvas backing
4308+
// store in _gpuDrawPoints/_gpuEnsureSize and every getCurrentTexture()).
4309+
// NB: don't test the gpuCanvas' own client size — it starts display:none
4310+
// until GPU activates, so clientWidth is 0 even for a fully-visible panel;
4311+
// that would wrongly block the normal case. A panel revealed by a pure
4312+
// display toggle (pw/ph already non-zero, only client size was 0) is
4313+
// handled by the ResizeObserver reveal branch, which redraws it.
4314+
const hasDrawableSize = (pw || 0) > 0 && (ph || 0) > 0;
4315+
if (p._gpu === undefined && hasDrawableSize) {
42954316
p._gpu = 'pending';
42964317
const initGeom = geom;
42974318
_gpuDevice().then((device) => {
@@ -5077,9 +5098,15 @@ fn fs(in : VsOut) -> @location(0) vec4<f32> {
50775098
// unit spans equal pixels on x and y (an IPF triangle etc. must not stretch).
50785099
// Baked in here so EVERY consumer (draw1d / markers / overlay / hit-test) uses
50795100
// the identical box — matplotlib's transData derives from the adjusted axes box.
5101+
// Extra right-hand gutter reserved for a secondary (twinx) y-axis so its
5102+
// tick labels and rotated units clear the panel edge.
5103+
// Right gutter for a twinx axis: tick numbers (fmtVal caps them at ~6 chars,
5104+
// e.g. "1.0e+6") plus the rotated units label. ~58px clears both.
5105+
const PAD_R_TWIN=58;
50805106
function _plotRect1d(p){
50815107
const pw=p.pw, ph=p.ph, st=p.state;
5082-
const r={x:PAD_L,y:PAD_T,w:Math.max(1,pw-PAD_L-PAD_R),h:Math.max(1,ph-PAD_T-PAD_B)};
5108+
const padR=(st && st.right_axis) ? PAD_R_TWIN : PAD_R;
5109+
const r={x:PAD_L,y:PAD_T,w:Math.max(1,pw-PAD_L-padR),h:Math.max(1,ph-PAD_T-PAD_B)};
50835110
if(!st || st.aspect!=='equal') return r;
50845111
const xArr=p._1dXArr || (st.x_axis_b64 ? _decodeF64(st.x_axis_b64) : (st.x_axis||[]));
50855112
const x0=st.view_x0||0, x1=st.view_x1||1;
@@ -5135,6 +5162,21 @@ fn fs(in : VsOut) -> @location(0) vec4<f32> {
51355162
return _valToPy1d(isLog ? Math.log10(Math.max(_logEps, v)) : v, effDMin, effDMax, r);
51365163
}
51375164

5165+
// ── secondary (right-hand) y-axis scale ──
5166+
// A twinx line maps against right_y_range (explicit) or the auto range
5167+
// derived from the right-axis overlays. Always linear.
5168+
const hasRightAxis = st.right_axis === true;
5169+
let rMin = 0, rMax = 1;
5170+
if (hasRightAxis) {
5171+
if (st.right_y_range && st.right_y_range.length === 2) {
5172+
rMin = st.right_y_range[0]; rMax = st.right_y_range[1];
5173+
} else {
5174+
rMin = st.right_data_min != null ? st.right_data_min : 0;
5175+
rMax = st.right_data_max != null ? st.right_data_max : 1;
5176+
}
5177+
}
5178+
function _toRightY(v) { return _valToPy1d(v, rMin, rMax, r); }
5179+
51385180
ctx.clearRect(0,0,pw,ph);
51395181
ctx.fillStyle=theme.bg; ctx.fillRect(0,0,pw,ph);
51405182
ctx.fillStyle=theme.bgPlot; ctx.fillRect(r.x,r.y,r.w,r.h);
@@ -5236,8 +5278,9 @@ fn fs(in : VsOut) -> @location(0) vec4<f32> {
52365278
// Stroke-only markers (no meaningful fill area)
52375279
const _MARKER_STROKE_ONLY = new Set(['+', 'x']);
52385280

5239-
function _drawLine(yData, lineXArr, color, lw, linestyle, alpha, marker, markersize) {
5281+
function _drawLine(yData, lineXArr, color, lw, linestyle, alpha, marker, markersize, yMap) {
52405282
if (!yData || !yData.length) return;
5283+
const _yMap = yMap || _toPlotY; // left scale by default; right lines pass _toRightY
52415284
const n = yData.length;
52425285
const isStepMid = linestyle === 'step-mid';
52435286
const dash = isStepMid ? [] : (_LINESTYLE_DASH[linestyle || 'solid'] || []);
@@ -5252,7 +5295,7 @@ fn fs(in : VsOut) -> @location(0) vec4<f32> {
52525295
? (lineXArr[i] - lineXArr[0]) / ((lineXArr[lineXArr.length - 1] - lineXArr[0]) || 1)
52535296
: i / ((n - 1) || 1);
52545297
allPx[i] = _fracToPx1d(xFrac, x0, x1, r);
5255-
allPy[i] = _toPlotY(yData[i]);
5298+
allPy[i] = _yMap(yData[i]);
52565299
}
52575300

52585301
ctx.save();
@@ -5305,11 +5348,12 @@ fn fs(in : VsOut) -> @location(0) vec4<f32> {
53055348
for (const ex of (st.extra_lines || [])) {
53065349
const exY = ex.data_b64 ? _decodeF64(ex.data_b64) : (ex.data || []);
53075350
const exX = ex.x_axis_b64 ? _decodeF64(ex.x_axis_b64) : (ex.x_axis ? ex.x_axis : xArr);
5351+
const exMap = ex.axis === 'right' ? _toRightY : _toPlotY;
53085352
_drawLine(exY, exX,
53095353
ex.color || (theme.dark ? '#fff' : '#333'), ex.linewidth || 1.5,
53105354
ex.linestyle || 'solid',
53115355
ex.alpha != null ? ex.alpha : 1.0,
5312-
ex.marker || 'none', ex.markersize || 4);
5356+
ex.marker || 'none', ex.markersize || 4, exMap);
53135357
}
53145358
// ── hovered-line highlight: redraw on top with brightened colour + thicker stroke ──
53155359
const _hovId = p._lineHoverId;
@@ -5324,10 +5368,11 @@ fn fs(in : VsOut) -> @location(0) vec4<f32> {
53245368
if (ex.id === _hovId) {
53255369
const exY = ex.data_b64 ? _decodeF64(ex.data_b64) : (ex.data||[]);
53265370
const exX = ex.x_axis_b64 ? _decodeF64(ex.x_axis_b64) : (ex.x_axis?ex.x_axis:xArr);
5371+
const exMap = ex.axis === 'right' ? _toRightY : _toPlotY;
53275372
_drawLine(exY, exX,
53285373
_brightenColor(ex.color||(theme.dark?'#fff':'#333')), (ex.linewidth||1.5)+1,
53295374
ex.linestyle||'solid', ex.alpha!=null?ex.alpha:1.0,
5330-
ex.marker||'none', ex.markersize||4);
5375+
ex.marker||'none', ex.markersize||4, exMap);
53315376
break;
53325377
}
53335378
}
@@ -5399,6 +5444,46 @@ fn fs(in : VsOut) -> @location(0) vec4<f32> {
53995444
}
54005445
}
54015446

5447+
// ── secondary (right-hand) y-axis: spine, ticks, label ──
5448+
if(hasRightAxis&&axisVis1d&&yTicksVis1d){
5449+
const rColor=st.right_axis_color||theme.tickText;
5450+
const rx=r.x+r.w;
5451+
// Axis spine on the right edge of the plot rect.
5452+
ctx.strokeStyle=rColor; ctx.lineWidth=1;
5453+
ctx.beginPath();ctx.moveTo(rx,r.y);ctx.lineTo(rx,r.y+r.h);ctx.stroke();
5454+
ctx.font=(st.tick_size||10)+'px monospace';ctx.textAlign='left';ctx.textBaseline='middle';
5455+
// Iterate the ordered range so ticks render regardless of whether the
5456+
// caller pinned set_right_ylim(lo,hi) or an inverted (hi,lo); _valToPy1d
5457+
// still maps against the raw rMin/rMax so a deliberate flip is preserved.
5458+
const rLo=Math.min(rMin,rMax), rHi=Math.max(rMin,rMax);
5459+
const rRange=(rHi-rLo)||1;
5460+
const rStep=findNice(rRange/Math.max(2,Math.floor(r.h/40)));
5461+
let rMaxTW=0;
5462+
for(let v=Math.ceil(rLo/rStep)*rStep;v<=rHi+rStep*0.01;v+=rStep){
5463+
const tw=ctx.measureText(fmtVal(v)).width;if(tw>rMaxTW)rMaxTW=tw;
5464+
}
5465+
for(let v=Math.ceil(rLo/rStep)*rStep;v<=rHi+rStep*0.01;v+=rStep){
5466+
const py=_valToPy1d(v,rMin,rMax,r);
5467+
if(py<r.y||py>r.y+r.h) continue;
5468+
ctx.strokeStyle=rColor;ctx.beginPath();ctx.moveTo(rx,py);ctx.lineTo(rx+5,py);ctx.stroke();
5469+
ctx.fillStyle=rColor;ctx.fillText(fmtVal(v),rx+8,py);
5470+
}
5471+
const rUnits=st.right_y_units||'';
5472+
if(rUnits){
5473+
ctx.save();
5474+
const rylpx=st.y_label_size||9;
5475+
// Place the rotated units label just past the widest tick number, but
5476+
// never past the panel edge — clamp keeps it on-canvas even when the
5477+
// fixed right gutter is too narrow for very wide tick strings.
5478+
const rcx=Math.min(pw-Math.ceil(rylpx*0.62)-1, rx+8+rMaxTW+Math.ceil(rylpx*0.9));
5479+
ctx.translate(rcx, r.y+r.h/2); ctx.rotate(-Math.PI/2);
5480+
ctx.textBaseline='middle';
5481+
ctx.fillStyle=rColor;
5482+
_drawTex(ctx, rUnits, 0, 0, rylpx, {align:'center',family:'monospace'});
5483+
ctx.restore();
5484+
}
5485+
}
5486+
54025487
// Legend
54035488
const labels=[];
54045489
if(st.line_label) labels.push({
@@ -7959,9 +8044,32 @@ fn fs(in : VsOut) -> @location(0) vec4<f32> {
79598044
let _lastCellW = 0;
79608045
let _lastRoW = 0, _lastRoH = 0;
79618046
let _roTimer = null;
8047+
let _wasZeroSize = true;
79628048
new ResizeObserver(entries => {
79638049
const cr = entries[0].contentRect;
79648050
const cellW = cr.width, cellH = cr.height;
8051+
// (0) Reveal detection: a host (SpyDE) can mount a panel HIDDEN
8052+
// (display:none) and reveal it later by toggling display alone, WITHOUT
8053+
// any layout_json / fig_width change (so neither model listener below
8054+
// fires a redraw). A 3-D GPU panel that skipped init while zero-size
8055+
// (see the hasDrawableSize guard in draw3d) would then stay on Canvas2D
8056+
// forever. When the observed element transitions from zero-size to a
8057+
// real size, re-attempt GPU init on any 3-D panel still on the canvas
8058+
// path and redraw it, so the reveal actually flips it to WebGPU.
8059+
const _nowZero = !(cellW > 0 && cellH > 0);
8060+
if (_wasZeroSize && !_nowZero) {
8061+
_wasZeroSize = false;
8062+
requestAnimationFrame(() => {
8063+
let _need = false;
8064+
for (const p of panels.values()) {
8065+
if (p.kind === '3d' && p._gpu === undefined && p.state
8066+
&& _gpuWanted(p.state)) { _need = true; break; }
8067+
}
8068+
if (_need) redrawAll();
8069+
});
8070+
} else if (_nowZero) {
8071+
_wasZeroSize = true;
8072+
}
79658073
// (1) Jupyter fit-to-cell down-scale — width-only to avoid a feedback
79668074
// loop (our own scaleWrap height updates would re-trigger the observer).
79678075
if (cellW !== _lastCellW) {

‎anyplotlib/markers.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,9 @@ def to_wire(self, group_id: str) -> dict:
225225
segs = np.asarray(d["segments"], dtype=float)
226226
if segs.ndim == 2 and segs.shape == (2, 2):
227227
segs = segs[np.newaxis]
228+
elif segs.size == 0:
229+
# Empty collection (zero line segments) — valid, renders nothing.
230+
segs = segs.reshape(0, 2, 2)
228231
if segs.ndim != 3 or segs.shape[1:] != (2, 2):
229232
raise ValueError("segments must be shape (N,2,2)")
230233
wire = {

0 commit comments

Comments
 (0)