Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions anyplotlib/FIGURE_ESM.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
37 changes: 31 additions & 6 deletions anyplotlib/figure_esm.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -3926,7 +3951,7 @@ function render({ model, el, onResize, onReadout }) {
for(let i=0;i<ms.offsets.length;i++){
const [cx,cy]=_tc(ms.offsets[i][0],ms.offsets[i][1]);
if(_ecArr) mkCtx.fillStyle=_ecAt(i);
_drawMarkerText(mkCtx,String(ms.texts[i]||''),cx,cy,outlined);
_drawMarkerText(mkCtx,ms,String(ms.texts[i]||''),cx,cy,outlined);
}
}
mkCtx.restore();
Expand Down Expand Up @@ -7881,7 +7906,7 @@ fn fs(in : VsOut) -> @location(0) vec4<f32> {
const outlined=_markerTextStyle(mkCtx,ms);
for(let i=0;i<ms.offsets.length;i++){
const [px,py]= tfm==='data' ? _offToCanvas(ms.offsets[i]) : _tc2d(ms.offsets[i][0],ms.offsets[i][1]!=null?ms.offsets[i][1]:0);
_drawMarkerText(mkCtx,String((ms.texts&&ms.texts[i])||''),px,py,outlined);
_drawMarkerText(mkCtx,ms,String((ms.texts&&ms.texts[i])||''),px,py,outlined);
}
}
mkCtx.restore();
Expand Down
25 changes: 25 additions & 0 deletions anyplotlib/markers.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,25 @@ def _check_text_style(kwargs: dict) -> 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:
Expand Down Expand Up @@ -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:
Expand Down
14 changes: 14 additions & 0 deletions anyplotlib/plot1d/_plot1d.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions anyplotlib/plot2d/_plot2d.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
65 changes: 65 additions & 0 deletions anyplotlib/tests/test_markers/test_text_style.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions upcoming_changes/+texts-tex-rotation.new_feature.rst
Original file line number Diff line number Diff line change
@@ -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.
Loading