diff --git a/anyplotlib/FIGURE_ESM.md b/anyplotlib/FIGURE_ESM.md index feb545944..67fa4554d 100644 --- a/anyplotlib/FIGURE_ESM.md +++ b/anyplotlib/FIGURE_ESM.md @@ -311,9 +311,15 @@ range is the gutter's two ends through `_canvasToImg2d`, clamped to the outer pixel edges. Python's `Plot2D._axis_extent_frac` (behind `set_view` / `set_xlim`) mirrors `_axisValToImg2d` — keep the two in step. -`texts` markers (1-D and 2-D) set their font and optional outline through -`_markerTextStyle(ctx, ms)` (`fontweight`, `outline_color`, `outline_width`) -and draw via `_drawMarkerText`, which strokes the halo under the fill. +`texts` markers (1-D and 2-D) set their font, alignment and optional outline +through `_markerTextStyle(ctx, ms)` (`fontweight`, `halign`, `valign`, +`outline_color`, `outline_width`; matplotlib vertical words map to canvas's +`middle`/`alphabetic`) and draw via `_drawMarkerText`, which strokes the halo +under the fill. `rotation` (radians, wire field) translates to the offset +point and rotates about it before drawing; strings containing `$...$` markup +go through `_drawTex` — the axis-label TeX engine — so scripts and +`\mathrm{}` typeset instead of rendering raw markup (the halo strokes each +run under the fill). ### Hover readout diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 1f2685b1b..9dd11b84b 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -126,16 +126,32 @@ function render({ model, el, onResize, onReadout }) { // UNDER the fill: a halo that keeps a label legible on light and dark ground. function _markerTextStyle(ctx, ms) { ctx.font=`${ms.fontweight||'normal'} ${ms.fontsize||12}px sans-serif`; - ctx.textAlign='left'; ctx.textBaseline='top'; + ctx.textAlign = ms.halign || 'left'; + // matplotlib vertical-alignment words differ from canvas's for two + // anchors: center→middle, baseline→alphabetic. + const va = ms.valign || 'top'; + ctx.textBaseline = va==='center' ? 'middle' : va==='baseline' ? 'alphabetic' : va; const ow=ms.outline_color ? (ms.outline_width!=null ? ms.outline_width : 3) : 0; if(!(ow>0)) return false; // Round joins: a thick stroke on a mitred glyph corner spikes. ctx.strokeStyle=ms.outline_color; ctx.lineWidth=ow; ctx.lineJoin='round'; return true; } - function _drawMarkerText(ctx, s, x, y, outlined) { - if(outlined) ctx.strokeText(s, x, y); - ctx.fillText(s, x, y); + function _drawMarkerText(ctx, ms, s, x, y, outlined) { + // Anchor at the offset point first, then rotate about it (matplotlib + // semantics: a positive rotation reads counter-clockwise). Canvas's + // y axis points down, so the sign flips. + const rot = ms.rotation || 0; + if (rot) { ctx.save(); ctx.translate(x, y); ctx.rotate(-rot); x = 0; y = 0; } + if (s.indexOf('$') === -1) { + if(outlined) ctx.strokeText(s, x, y); + ctx.fillText(s, x, y); + } else { + // $...$ markup typesets through the axis-label TeX engine instead + // of showing raw dollars. + _drawTex(ctx, s, x, y, ms.fontsize||12, {align: ms.halign||'left', outlined}); + } + if (rot) ctx.restore(); } // ── b64 array decode helpers ───────────────────────────────────────────── @@ -289,6 +305,7 @@ function render({ model, el, onResize, onReadout }) { if (t.indexOf('$') === -1) { // fast path — no math segments ctx.font = (weight ? weight + ' ' : '') + px + 'px ' + family; ctx.textAlign = o.align || 'center'; + if (o.outlined) ctx.strokeText(t, x, y); ctx.fillText(t, x, y); ctx.restore(); return; @@ -319,6 +336,14 @@ function render({ model, el, onResize, onReadout }) { ctx.textBaseline = 'alphabetic'; } ctx.textAlign = 'left'; + if (o.outlined) { + // Halo: stroke every run first so a later stroke never cuts across + // an earlier fill. + for (const r of lay.runs) { + ctx.font = r.font; + ctx.strokeText(r.t, x0 + r.x, yb + r.dy); + } + } for (const r of lay.runs) { ctx.font = r.font; ctx.fillText(r.t, x0 + r.x, yb + r.dy); @@ -3926,7 +3951,7 @@ function render({ model, el, onResize, onReadout }) { for(let i=0;i @location(0) vec4 { const outlined=_markerTextStyle(mkCtx,ms); for(let i=0;i None: if (isinstance(ow, bool) or not isinstance(ow, (int, float)) or not ow >= 0): raise ValueError(f"outline_width must be a number >= 0, got {ow!r}") + if "rotation" in kwargs: + rot = kwargs["rotation"] + ok = (isinstance(rot, (int, float, np.integer, np.floating)) + and not isinstance(rot, bool) and bool(np.isfinite(rot))) + if not ok: + raise ValueError( + f"rotation must be a finite number (radians), got {rot!r}") + if "horizontalalignment" in kwargs: + ha = kwargs["horizontalalignment"] + if ha not in ("left", "center", "right"): + raise ValueError( + "horizontalalignment must be 'left', 'center' or " + f"'right', got {ha!r}") + if "verticalalignment" in kwargs: + va = kwargs["verticalalignment"] + if va not in ("top", "center", "bottom", "baseline"): + raise ValueError( + "verticalalignment must be 'top', 'center', 'bottom' or " + f"'baseline', got {va!r}") def _apply_fill_color(wire: dict, d: dict) -> None: @@ -344,6 +363,12 @@ def to_wire(self, group_id: str) -> dict: "color": d.get("color", d.get("edgecolors", "#ff0000")), "fontsize": int(d.get("fontsize", 12)), "fontweight": d.get("fontweight", "normal"), + # Rotation is in radians (matplotlib's TextCollection + # convention): the text anchors at the offset point, then + # rotates about it. + "rotation": float(d.get("rotation", 0.0)), + "halign": d.get("horizontalalignment", "left"), + "valign": d.get("verticalalignment", "top"), } # Outline (a halo stroked under the fill): only on the wire when set. if d.get("outline_color") is not None: diff --git a/anyplotlib/plot1d/_plot1d.py b/anyplotlib/plot1d/_plot1d.py index 1cc21e586..cb159af54 100644 --- a/anyplotlib/plot1d/_plot1d.py +++ b/anyplotlib/plot1d/_plot1d.py @@ -1583,6 +1583,8 @@ def add_raster(self, rgba, *, extent, name=None, clip_path=None, def add_texts(self, offsets, texts, name=None, *, color="#ff0000", fontsize=12, fontweight="normal", outline_color=None, outline_width=3.0, + rotation=0.0, horizontalalignment="left", + verticalalignment="top", hover_edgecolors=None, labels=None, label=None, transform: str = "data", @@ -1612,6 +1614,15 @@ def add_texts(self, offsets, texts, name=None, *, Stroke width of the outline in pixels; about half of it shows outside the glyphs. Default ``3.0``. Ignored without ``outline_color``. + rotation : float, optional + Rotation of the text about its anchor point, in radians + (matplotlib's ``TextCollection`` convention). Default ``0.0``. + horizontalalignment : {"left", "center", "right"}, optional + Which side of the anchor point the text extends from. + Default ``"left"``. + verticalalignment : {"top", "center", "bottom", "baseline"}, optional + How the text sits relative to the anchor vertically. + Default ``"top"``. hover_edgecolors : str, optional Colour override applied on mouse-hover. labels : list of str, optional @@ -1628,6 +1639,9 @@ def add_texts(self, offsets, texts, name=None, *, fontweight=fontweight, outline_color=outline_color, outline_width=outline_width, + rotation=rotation, + horizontalalignment=horizontalalignment, + verticalalignment=verticalalignment, hover_edgecolors=hover_edgecolors, labels=labels, label=label, transform=transform, diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py index 72f703c1c..2561ecb14 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -2567,6 +2567,8 @@ def add_polygons(self, vertices_list, name=None, *, def add_texts(self, offsets, texts, name=None, *, color="#ff0000", fontsize=12, fontweight="normal", outline_color=None, outline_width=3.0, + rotation=0.0, horizontalalignment="left", + verticalalignment="top", hover_edgecolors=None, labels=None, label=None, transform: str = "data", @@ -2597,6 +2599,15 @@ def add_texts(self, offsets, texts, name=None, *, Stroke width of the outline in pixels; about half of it shows outside the glyphs. Default ``3.0``. Ignored without ``outline_color``. + rotation : float, optional + Rotation of the text about its anchor point, in radians + (matplotlib's ``TextCollection`` convention). Default ``0.0``. + horizontalalignment : {"left", "center", "right"}, optional + Which side of the anchor point the text extends from. + Default ``"left"``. + verticalalignment : {"top", "center", "bottom", "baseline"}, optional + How the text sits relative to the anchor vertically. + Default ``"top"``. hover_edgecolors : str, optional Colour override applied on mouse-hover. labels : list of str, optional @@ -2624,6 +2635,9 @@ def add_texts(self, offsets, texts, name=None, *, fontweight=fontweight, outline_color=outline_color, outline_width=outline_width, + rotation=rotation, + horizontalalignment=horizontalalignment, + verticalalignment=verticalalignment, hover_edgecolors=hover_edgecolors, labels=labels, label=label, transform=transform, diff --git a/anyplotlib/tests/test_markers/test_text_style.py b/anyplotlib/tests/test_markers/test_text_style.py index 91fc26afd..3942220c3 100644 --- a/anyplotlib/tests/test_markers/test_text_style.py +++ b/anyplotlib/tests/test_markers/test_text_style.py @@ -6,6 +6,11 @@ was only as legible as its size allowed. ``fontweight`` threads through to the canvas font the way ``fontsize`` does; ``outline_color`` / ``outline_width`` stroke a halo under the fill. + +Rotation, anchor alignment and ``$...$`` TeX routing for ``texts`` markers +extend the same thread: wire fields ``rotation`` (radians), +``halign``/``valign``, and the JS renderer typesets math segments instead of +showing raw markup. """ from __future__ import annotations @@ -84,6 +89,48 @@ def test_set_updates_live(self): g.set(outline_color=None) assert "outline_color" not in plot._state["markers"][0] + @pytest.mark.parametrize("make", [_plot2d, _plot1d]) + def test_defaults_are_unrotated_left_top(self, make): + _, plot = make() + w = plot.add_texts([[5, 5]], ["a"]).to_wire("gid") + assert w["rotation"] == 0.0 + assert (w["halign"], w["valign"]) == ("left", "top") + + @pytest.mark.parametrize("make", [_plot2d, _plot1d]) + def test_rotation_and_alignment_reach_the_wire(self, make): + _, plot = make() + w = plot.add_texts( + [[5, 5]], ["a"], rotation=1.5707963, + horizontalalignment="right", verticalalignment="bottom", + ).to_wire("gid") + assert w["rotation"] == pytest.approx(1.5707963) + assert (w["halign"], w["valign"]) == ("right", "bottom") + + def test_mathtext_string_passes_through_the_wire(self): + _, plot = _plot2d() + w = plot.add_texts( + [[5, 5]], [r"$\mathrm{Fe}_{\mathrm{Ka}}$"] + ).to_wire("gid") + assert w["texts"] == [r"$\mathrm{Fe}_{\mathrm{Ka}}$"] + + def test_registry_path_accepts_the_fields(self): + # The registry API (what non-plot callers such as the hyperspy + # backend use) takes the same style kwargs into the group data. + _, plot = _plot2d() + g = plot.markers.add("texts", offsets=[[5, 5]], texts=["a"], + rotation=0.25, horizontalalignment="center") + w = g.to_wire("gid") + assert w["rotation"] == pytest.approx(0.25) + assert w["halign"] == "center" + + def test_set_updates_rotation_live(self): + _, plot = _plot2d() + g = plot.add_texts([[5, 5]], ["a"]) + g.set(rotation=0.5, horizontalalignment="center") + w = plot._state["markers"][0] + assert w["rotation"] == pytest.approx(0.5) + assert w["halign"] == "center" + class TestValidation: @pytest.mark.parametrize("bad", ["heavy", "", 0, 1001, True, None]) @@ -111,6 +158,24 @@ def test_set_refuses_before_touching_the_group(self): g.set(fontweight="extra-bold") assert g._data["fontweight"] == "normal" + @pytest.mark.parametrize("bad", ["90", None, float("nan"), float("inf"), True]) + def test_bad_rotation_is_refused(self, bad): + _, plot = _plot2d() + with pytest.raises(ValueError, match="rotation"): + plot.add_texts([[5, 5]], ["a"], rotation=bad) + + @pytest.mark.parametrize("bad", ["centre", "top", None]) + def test_bad_horizontalalignment_is_refused(self, bad): + _, plot = _plot2d() + with pytest.raises(ValueError, match="horizontalalignment"): + plot.add_texts([[5, 5]], ["a"], horizontalalignment=bad) + + @pytest.mark.parametrize("bad", ["middle", "left", None]) + def test_bad_verticalalignment_is_refused(self, bad): + _, plot = _plot2d() + with pytest.raises(ValueError, match="verticalalignment"): + plot.add_texts([[5, 5]], ["a"], verticalalignment=bad) + # ══════════════════════════════════════════════════════════════════════════════ # Rendering diff --git a/upcoming_changes/+texts-tex-rotation.new_feature.rst b/upcoming_changes/+texts-tex-rotation.new_feature.rst new file mode 100644 index 000000000..7fdfca539 --- /dev/null +++ b/upcoming_changes/+texts-tex-rotation.new_feature.rst @@ -0,0 +1,5 @@ +Added rotation, anchor alignment and ``$...$`` TeX typesetting to ``texts`` +markers (wire fields ``rotation`` in radians, ``halign``, ``valign``): +labels anchor at their offset point, rotate counter-clockwise like +matplotlib's, and math markup renders through the existing axis-label TeX +engine instead of showing raw dollars.