plotpress 0.23.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
plotpress/svg.py ADDED
@@ -0,0 +1,2589 @@
1
+ """SVG serialization: turn a Figure's scene into an SVG document string.
2
+
3
+ This is the whole rendering pipeline, in pure Python + NumPy: transforms are
4
+ vectorized, each series becomes a single ``<path>`` (not one node per point),
5
+ huge lines are min/max-decimated before serialization, and each ``pcolormesh``
6
+ becomes one embedded ``<image>``. Coordinate formatting is vectorized with
7
+ ``numpy.char``. Pure Python and NumPy are the whole story -- no compiled
8
+ extension; the library installs everywhere pip does.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import math
14
+ import warnings
15
+
16
+ import numpy as np
17
+
18
+ from .artists import (
19
+ Annotation, Barbs, Bars, BoxPlot, Contour, ErrorBar, EventPlot, FillBetween,
20
+ FrameLine2D, FrameQuadMesh, Image, Line2D, LineCollection, Pie, Polygon,
21
+ PolyCollection, QuadMesh, Quiver, ScatterCollection, Span, Stem, Table, Text,
22
+ Violin, _edges_from,
23
+ )
24
+ from .colors import apply_colormap, colorbar_ticks, to_hex
25
+ from .png import png_data_uri
26
+ from .primitives import artist_to_prims
27
+ from .primitives import ImagePrim as PImage
28
+ from .primitives import Line as PLine
29
+ from .primitives import Markers as PMarkers
30
+ from .primitives import Path as PPath
31
+ from .primitives import PolygonBatch as PPolyBatch
32
+ from .primitives import Rect as PRect
33
+ from .primitives import Segments as PSegments
34
+ from .ticker import format_ticks, log_ticks, minor_ticks, nice_ticks
35
+ from .transform import LinearTransform
36
+
37
+ _DASH = {"-": None, "--": "6,4", ":": "1,3", "-.": "6,3,1,3"}
38
+
39
+
40
+ def _fmt(v: float) -> str:
41
+ """Compact fixed-precision coordinate (2 dp), trimming trailing zeros."""
42
+ return f"{v:.2f}".rstrip("0").rstrip(".")
43
+
44
+
45
+ def _esc(text) -> str:
46
+ return (
47
+ str(text)
48
+ .replace("&", "&amp;")
49
+ .replace("<", "&lt;")
50
+ .replace(">", "&gt;")
51
+ )
52
+
53
+
54
+ def figure_to_svg(fig, interactive: bool = False) -> str:
55
+ fig._settle_layout()
56
+ dpi = fig.style.dpi
57
+ if not (dpi > 0):
58
+ # figsize itself is validated at Figure() construction, but dpi is
59
+ # a plain, freely-mutable Style attribute -- fig.style.dpi = 0 (or
60
+ # negative) reaches this same width/height product and produces the
61
+ # identical invalid, unrenderable SVG (width="0"/negative) that
62
+ # fix was written to prevent, just through a different door.
63
+ raise ValueError(f"Figure.style.dpi must be > 0, got {dpi!r}")
64
+ W = fig.figsize[0] * dpi
65
+ H = fig.figsize[1] * dpi
66
+
67
+ defs: list[str] = []
68
+ body: list[str] = []
69
+
70
+ for i, ax in enumerate(fig.axes):
71
+ _render_axes(ax, fig, W, H, i, defs, body)
72
+
73
+ _render_figtexts(fig, W, H, body)
74
+ _render_figure_legend(fig, fig.style, W, H, body)
75
+ _render_groups(fig, W, H, body)
76
+
77
+ header = (
78
+ f'<svg xmlns="http://www.w3.org/2000/svg" '
79
+ f'width="{_fmt(W)}" height="{_fmt(H)}" '
80
+ f'viewBox="0 0 {_fmt(W)} {_fmt(H)}" '
81
+ f'font-family="{fig.style.font_family}">'
82
+ )
83
+ bg = f'<rect x="0" y="0" width="{_fmt(W)}" height="{_fmt(H)}" fill="{fig.style.facecolor}"/>'
84
+ defs_block = f"<defs>{''.join(defs)}</defs>" if defs else ""
85
+ return header + defs_block + bg + "".join(body) + "</svg>"
86
+
87
+
88
+ def _pixel_rect(ax, W, H):
89
+ left, bottom, w, h = ax._rect
90
+ return (left * W, (1.0 - (bottom + h)) * H, w * W, h * H)
91
+
92
+
93
+ def _render_figtexts(fig, W, H, body):
94
+ """Figure-level (global) title and shared x/y labels spanning all subplots."""
95
+ st = fig.style
96
+ if fig._suptitle:
97
+ t = fig._suptitle
98
+ size = t.get("size") or st.title_size * 1.5
99
+ body.append(
100
+ f'<text x="{_fmt(W / 2)}" y="{_fmt(size + 6)}" text-anchor="middle" '
101
+ f'font-size="{size}" font-weight="bold" fill="{st.text_color}">'
102
+ f'{_esc(t["text"])}</text>'
103
+ )
104
+ if fig._supxlabel:
105
+ t = fig._supxlabel
106
+ size = t.get("size") or st.label_size * 1.2
107
+ body.append(
108
+ f'<text x="{_fmt(W / 2)}" y="{_fmt(H - 6)}" text-anchor="middle" '
109
+ f'font-size="{size}" fill="{st.text_color}">{_esc(t["text"])}</text>'
110
+ )
111
+ if fig._supylabel:
112
+ t = fig._supylabel
113
+ size = t.get("size") or st.label_size * 1.2
114
+ x, y = size + 4, H / 2
115
+ body.append(
116
+ f'<text x="{_fmt(x)}" y="{_fmt(y)}" text-anchor="middle" '
117
+ f'font-size="{size}" fill="{st.text_color}" '
118
+ f'transform="rotate(-90 {_fmt(x)} {_fmt(y)})">{_esc(t["text"])}</text>'
119
+ )
120
+ for t in fig._fig_texts:
121
+ _render_fig_text(t, st, W, H, body)
122
+
123
+
124
+ _HA_ANCHOR = {"left": "start", "center": "middle", "right": "end"}
125
+ # Baseline offsets approximating each va, matching the vertical-centering trick
126
+ # already used for tick labels (y + fs*0.35) rather than true font metrics.
127
+ _VA_DY = {"top": 0.8, "center": 0.35, "bottom": 0.0, "baseline": 0.0}
128
+
129
+
130
+ def _render_fig_text(t, st, W, H, body):
131
+ """One ``fig.text()`` entry, at figure-fraction coordinates."""
132
+ size = t["size"] or st.font_size
133
+ color = t["color"] or st.text_color
134
+ x, y = t["x"] * W, (1.0 - t["y"]) * H + _VA_DY.get(t["va"], 0.0) * size
135
+ anchor = _HA_ANCHOR.get(t["ha"], "start")
136
+ alpha = t.get("alpha", 1.0)
137
+ bbox = t.get("bbox")
138
+ if bbox is not None:
139
+ box = _bbox_pad(text_box(x, y, t["s"], size, t["ha"], t["va"], st), bbox)
140
+ body.append(_bbox_svg(box, bbox))
141
+ op = f' fill-opacity="{alpha}"' if alpha < 1 else ""
142
+ body.append(
143
+ f'<text x="{_fmt(x)}" y="{_fmt(y)}" text-anchor="{anchor}" '
144
+ f'font-size="{size}" fill="{color}"{op}>{_esc(t["s"])}</text>'
145
+ )
146
+
147
+
148
+ def _group_top_clearance(ax, st):
149
+ """Extra space above an axes' own rect that Figure.group()'s box must not
150
+ cut through: a twiny()/secondary_xaxis('top') overlay's ticks, and, above
151
+ that, this axes' own title -- mirroring exactly where _render_axes draws
152
+ each (twiny_headroom, then the ax._title block right after it). Wrapping
153
+ just ax._rect (the plot box itself) would otherwise draw the group's top
154
+ edge straight through the top row's own titles.
155
+ """
156
+ extra = twiny_headroom(ax, st)
157
+ if ax._title:
158
+ size = ax._title_size or st.title_size
159
+ extra += 8 + size * 0.8 # matches the title's own baseline offset/ascent
160
+ return extra
161
+
162
+
163
+ def _group_axes_extra(ax, st):
164
+ """(top, bottom, left, right) clearance beyond an axes' own rect that
165
+ Figure.group()'s box must not cut through -- this axes' own title/twiny
166
+ overlay (see _group_top_clearance) above it, and its tick labels plus
167
+ axis label on whichever side they're actually drawn below/beside it.
168
+ Wrapping just ax._rect (the bare plot box) would otherwise draw the
169
+ group's edge straight through the outermost row's/column's own tick
170
+ numbers and x/y axis labels, not just its title.
171
+ """
172
+ top = _group_top_clearance(ax, st)
173
+ bottom = left = right = 0.0
174
+ if not ax._axis_off:
175
+ xdec = st.tick_size + st.tick_label_size + 4
176
+ if ax._xlabel:
177
+ xdec += st.label_size + 6
178
+ if ax._xtick_side == "top":
179
+ top += xdec
180
+ else:
181
+ bottom += xdec
182
+ ydec = st.tick_size + _max_ytick_width(ax, st) + 4
183
+ if ax._ylabel:
184
+ ydec += st.label_size + 6
185
+ if ax._ytick_side == "right":
186
+ right += ydec
187
+ else:
188
+ left += ydec
189
+ return top, bottom, left, right
190
+
191
+
192
+ def _group_colorbar_extra(cax, st):
193
+ """(top, bottom, left, right) clearance beyond a colorbar axes' own rect
194
+ that Figure.group()'s box must not cut through: its own title, if any,
195
+ plus its tick numbers -- _render_colorbar always draws those to the
196
+ right, regardless of any tick-side setting a plain axes would have.
197
+ """
198
+ top = _group_top_clearance(cax, st)
199
+ _, _, tlabels = colorbar_ticks(cax._cbar_source.norm)
200
+ width = max((st.text_width(l, st.tick_label_size) for l in tlabels), default=0.0)
201
+ return top, 0.0, 0.0, st.tick_size + width + 4
202
+
203
+
204
+ def _group_colorbars(g_axes, fig):
205
+ """Colorbar axes belonging entirely to this group's own axes.
206
+
207
+ A colorbar attached to a grouped axes (``fig.colorbar(mesh, ax=ax)``, one
208
+ per panel or shared across several) steals its space from right next to
209
+ that axes, not from some independent spot -- the group's box has to wrap
210
+ it too, or it juts out past the edge that's supposed to enclose it. A
211
+ colorbar shared with an axes *outside* the group is left alone: pulling
212
+ the box out to wrap it would misrepresent what the group actually is.
213
+ """
214
+ axset = set(id(a) for a in g_axes)
215
+ return [cax for cax in fig.axes
216
+ if cax._is_colorbar and cax._cbar_parents
217
+ and all(id(p) in axset for p in cax._cbar_parents)]
218
+
219
+
220
+ def _render_groups(fig, W, H, body):
221
+ """``Figure.group()``'s labeled boxes -- one dashed (by default) rect per
222
+ group, tightly wrapping the union of its axes' own allocated rects (each
223
+ expanded for its own title/tick labels/axis labels -- see
224
+ _group_axes_extra) plus ``pad`` px of clearance per side (already
225
+ normalized to a (left, right, top, bottom) 4-tuple by
226
+ figure._normalize_pad, whether the caller passed one number or four),
227
+ with the title just outside whichever edge ``title_position`` names.
228
+ Any colorbar belonging entirely to the group's own axes (see
229
+ _group_colorbars) is wrapped too.
230
+ """
231
+ st = fig.style
232
+ for g in fig._groups:
233
+ members = g["axes"] + _group_colorbars(g["axes"], fig)
234
+ rects = [_pixel_rect(ax, W, H) for ax in members]
235
+ extras = [_group_colorbar_extra(ax, st) if ax._is_colorbar
236
+ else _group_axes_extra(ax, st) for ax in members]
237
+ pad_l, pad_r, pad_t, pad_b = g["pad"]
238
+ x0 = min(r[0] - e[2] for r, e in zip(rects, extras)) - pad_l
239
+ y0 = min(r[1] - e[0] for r, e in zip(rects, extras)) - pad_t
240
+ x1 = max(r[0] + r[2] + e[3] for r, e in zip(rects, extras)) + pad_r
241
+ y1 = max(r[1] + r[3] + e[1] for r, e in zip(rects, extras)) + pad_b
242
+ # linestyle="none" means an invisible box (title only, still placed
243
+ # the same) -- like every other line-drawing method, not a solid
244
+ # border because "none" fell through _DASH.get() unmatched.
245
+ if g["linestyle"] == "none":
246
+ stroke_attr = 'stroke="none"'
247
+ else:
248
+ dash = _DASH.get(g["linestyle"])
249
+ dash_attr = f' stroke-dasharray="{dash}"' if dash else ""
250
+ stroke_attr = f'stroke="{g["color"]}" stroke-width="{g["linewidth"]}"{dash_attr}'
251
+ body.append(
252
+ f'<rect x="{_fmt(x0)}" y="{_fmt(y0)}" width="{_fmt(x1 - x0)}" '
253
+ f'height="{_fmt(y1 - y0)}" fill="none" {stroke_attr}/>'
254
+ )
255
+ size = g["fontsize"] or fig.style.title_size
256
+ pos = g["title_position"]
257
+ if pos == "top":
258
+ tx, ty, anchor = (x0 + x1) / 2, y0 - 6, "middle"
259
+ elif pos == "bottom":
260
+ tx, ty, anchor = (x0 + x1) / 2, y1 + size + 2, "middle"
261
+ elif pos == "left":
262
+ tx, ty, anchor = x0 - 6, (y0 + y1) / 2 + 0.35 * size, "end"
263
+ else:
264
+ tx, ty, anchor = x1 + 6, (y0 + y1) / 2 + 0.35 * size, "start"
265
+ body.append(
266
+ f'<text x="{_fmt(tx)}" y="{_fmt(ty)}" text-anchor="{anchor}" '
267
+ f'font-size="{size}" font-weight="bold" fill="{g["color"]}">'
268
+ f'{_esc(g["title"])}</text>'
269
+ )
270
+
271
+
272
+ def _colorbar_label(ax, fig):
273
+ """The title of any colorbar attached to ``ax``, or ``""`` if none.
274
+
275
+ This library's own convention for labeling what a colorbar's scale means
276
+ is ``fig.colorbar(mesh, ax=ax).set_title("units")`` (there is no separate
277
+ ``set_label``) -- reused here so a mesh/image/scatter pick can report what
278
+ its color-encoded value actually means downstream, not just a bare number.
279
+ A colorbar shared across several axes (``fig.colorbar(mesh, ax=[a, b])``)
280
+ reports the same label for each of its parents.
281
+ """
282
+ for cax in fig.axes:
283
+ if cax._is_colorbar and cax._cbar_parents and ax in cax._cbar_parents:
284
+ return cax._title or ""
285
+ return ""
286
+
287
+
288
+ def axes_metadata(fig, idx_of=None):
289
+ """Per-axes pixel rect + data limits, for client-side point picking.
290
+
291
+ Keyed by the axes index (matching the ``s<index>_<k>`` ids on rendered
292
+ series). Colorbar axes are excluded -- they are not data plots. So is a
293
+ 3-D axes: pan/zoom/point-pick all reason about one affine map between a
294
+ *fixed* data range and pixels, but a 3-D axes' "data" is already a
295
+ camera-projected snapshot at a specific elev/azim -- zooming it stretches
296
+ the projection into a shape no real camera angle produces, and a picked
297
+ point reports meaningless projected coordinates instead of the original
298
+ (x, y, z). Leaving it out of this payload is what makes ``axesAt()`` (the
299
+ JS hit-test) treat the whole 3-D panel as outside any interactive axes,
300
+ so the toolbar simply does nothing there instead of producing a wrong
301
+ answer. The panel itself still renders fully -- this only affects
302
+ interactivity in the HTML export.
303
+
304
+ ``idx_of`` (an ``id(axes) -> index`` map) is accepted rather than always
305
+ rebuilt -- see :func:`layout_metadata`, which needs the identical map and
306
+ would otherwise redo this same O(axes) dict build a second time in the
307
+ same ``to_html()`` call.
308
+ """
309
+ dpi = fig.style.dpi
310
+ W = fig.figsize[0] * dpi
311
+ H = fig.figsize[1] * dpi
312
+ if idx_of is None:
313
+ idx_of = {id(a): i for i, a in enumerate(fig.axes)}
314
+ meta = {}
315
+ for i, ax in enumerate(fig.axes):
316
+ if ax._is_colorbar or not ax._visible:
317
+ continue
318
+ (xmin, xmax), (ymin, ymax) = ax._resolved_limits()
319
+ px_left, px_top, px_w, px_h = _effective_rect(
320
+ ax, *_pixel_rect(ax, W, H), (xmin, xmax), (ymin, ymax))
321
+ meta[i] = {
322
+ "x": round(px_left, 3), "y": round(px_top, 3),
323
+ "w": round(px_w, 3), "h": round(px_h, 3),
324
+ "xmin": round(float(xmin), 6), "xmax": round(float(xmax), 6),
325
+ "ymin": round(float(ymin), 6), "ymax": round(float(ymax), 6),
326
+ "grid": bool(ax._grid), "axis_off": bool(ax._axis_off),
327
+ # None (omitted from the client's perspective via the JS ?? below)
328
+ # unless grid(alpha=...) actually overrode the figure-wide
329
+ # default, mirroring tick_style's "only present when overridden"
330
+ # convention -- see the tick_params() regression this pattern
331
+ # already fixed: a per-axes style that only applied to the
332
+ # initial render, then silently reverted on the client's own
333
+ # pan/zoom rebuild, which reads only the figure-wide style.
334
+ "grid_alpha": ax._grid_alpha,
335
+ "xscale": ax._xscale, "yscale": ax._yscale,
336
+ # Axis direction, so the client maps data<->pixels the same way
337
+ # _render_axes does (it swaps the limits it feeds the transform).
338
+ "xinv": bool(ax._xinverted), "yinv": bool(ax._yinverted),
339
+ # Whether ticks are user-fixed (don't auto-recompute on zoom) --
340
+ # explicit *minor* ticks count too: without this, an explicit
341
+ # set_xticks(vals, minor=True) would render correctly here but
342
+ # silently revert to the auto minor-tick algorithm the moment a
343
+ # reader zoomed, the same regression class already fixed once for
344
+ # tick_params() and once for grid(alpha=) (see grid_alpha above).
345
+ "xfixed": ax._xticks is not None or ax._xticks_minor is not None,
346
+ "yfixed": ax._yticks is not None or ax._yticks_minor is not None,
347
+ "xside": ax._xtick_side, "yside": ax._ytick_side,
348
+ "minor": bool(ax._minor_ticks_on),
349
+ # Raw tick_params() overrides (Style field -> value), so the
350
+ # client's pan/zoom tick-rebuild can reproduce a per-axis style
351
+ # instead of always falling back to the figure-wide default --
352
+ # only present when this axes actually has an override, to keep
353
+ # the common (unstyled) case's payload as small as before.
354
+ "tick_style": {
355
+ "x": ax._tick_overrides["x"] or None,
356
+ "y": ax._tick_overrides["y"] or None,
357
+ "xminor": ax._minor_tick_overrides["x"] or None,
358
+ "yminor": ax._minor_tick_overrides["y"] or None,
359
+ },
360
+ # Surfaced on extracted points as axes_title (falling back to a
361
+ # generated "axes N" when untitled), so a multi-panel export
362
+ # always identifies which panel a marker came from by name
363
+ # instead of just a bare index.
364
+ "title": ax._title,
365
+ # Also surfaced on every extracted record, so a value pulled out
366
+ # of context (a CSV row, a JSON dict) still carries what its x/y
367
+ # and any color-encoded value actually mean, not just bare numbers.
368
+ "xlabel": ax._xlabel, "ylabel": ax._ylabel,
369
+ "zlabel": _colorbar_label(ax, fig),
370
+ # Which fig.group() box(es) this axes belongs to, if any -- joined
371
+ # with ", " on the rare figure where an axes was added to more
372
+ # than one group, empty when it belongs to none. Lets a picked
373
+ # record from a clustered panel say which cluster it came from,
374
+ # the same way axes_title says which panel.
375
+ "group": ", ".join(g["title"] for g in fig._groups if ax in g["axes"]),
376
+ # False excludes this axes from Point Picking --
377
+ # see Axes.set_pickable.
378
+ "pickable": bool(ax._pickable),
379
+ # Arbitrary user-supplied key/value pairs merged onto every pick
380
+ # record from this axes -- see Axes.set_pick_context.
381
+ "context": dict(ax._pick_context),
382
+ # A twin/secondary axes fully overlaps its parent's pixel rect, so
383
+ # they can never both be reached by a click -- the client instead
384
+ # resolves one and propagates the limit change to the other(s)
385
+ # here, keeping their views in sync. `None` when there is no link,
386
+ # or when the linked axes isn't itself in this payload (e.g. it
387
+ # was hidden) -- see `_interactive.py`'s `syncLinked`.
388
+ "twin_of": idx_of.get(id(ax._twin_of)) if ax._twin_of is not None else None,
389
+ "twin_shared": ax._twin_shared,
390
+ "secondary_of": (idx_of.get(id(ax._secondary_of))
391
+ if ax._secondary_of is not None else None),
392
+ "secondary_dim": ax._secondary_dim,
393
+ }
394
+ return meta
395
+
396
+
397
+ def layout_metadata(fig, idx_of=None):
398
+ """Grid shape/position of every subplot-grid axes, its own decorations
399
+ (title, labels, limits, scale, ...), plus ``fig.group()`` boxes and the
400
+ figure's own sup-title/label -- everything ``load_data()``'s
401
+ ``"layout"`` needs to rebuild an equivalent, already-labeled figure
402
+ with :func:`plotpress.subplots_from_layout`, independent of
403
+ ``axes_metadata()``'s per-axes pixel/style payload above (built for the
404
+ live interactive view, not for reconstruction -- the two overlap in a
405
+ few fields, e.g. title, by coincidence of both needing it, not because
406
+ one is derived from the other).
407
+
408
+ ``idx_of`` (an ``id(axes) -> index`` map) is accepted rather than always
409
+ rebuilt, so a caller that already has one -- ``to_html()`` builds one for
410
+ :func:`axes_metadata` moments before calling this -- doesn't pay for the
411
+ same O(axes) dict twice in the same save.
412
+
413
+ Only axes placed via :meth:`Figure.add_subplot`/:meth:`Figure.subplots`
414
+ (``ax._subplotspec is not None``) end up in ``"axes"`` -- a freeform
415
+ :meth:`Figure.add_axes` rect has no grid cell to recover, so it is
416
+ simply absent from the payload rather than guessed at; its index is
417
+ still recorded in ``"omitted_axes"`` (colorbars excluded -- they were
418
+ never expected to round-trip) so :func:`plotpress.subplots_from_layout`
419
+ can warn that a real, once-visible axes won't come back, instead of the
420
+ drop passing without any signal beyond the payload simply being smaller.
421
+
422
+ Deliberately NOT captured (real, currently unrecoverable gaps -- a
423
+ caller that needs one of these still has to re-apply it by hand):
424
+ colorbars (need their mappable, which doesn't exist until the data is
425
+ replotted), a custom :class:`~plotpress.Style` (colors/fonts/dpi -- a
426
+ figure-wide concern, not a per-axes one), tick_params()/explicit tick
427
+ overrides, and twin/secondary/inset axes (each needs its *parent*
428
+ axes to already exist, so they can't be grid cells of their own).
429
+ ``"legend"`` is captured but never auto-applied here either, for a
430
+ narrower reason: :meth:`Axes.legend` draws from already-plotted,
431
+ labeled artists, none of which exist yet on a freshly rebuilt axes --
432
+ call ``ax.legend(**entry["legend"])`` yourself once you've replotted
433
+ the recovered data into it.
434
+ """
435
+ if idx_of is None:
436
+ idx_of = {id(a): i for i, a in enumerate(fig.axes)}
437
+ axes = {}
438
+ omitted = []
439
+ for i, ax in enumerate(fig.axes):
440
+ spec = ax._subplotspec
441
+ if spec is None:
442
+ if not ax._is_colorbar:
443
+ omitted.append(i)
444
+ continue
445
+ (xmin, xmax), (ymin, ymax) = ax._resolved_limits()
446
+ axes[i] = {
447
+ "nrows": spec.nrows, "ncols": spec.ncols,
448
+ "row0": spec.row0, "row1": spec.row1,
449
+ "col0": spec.col0, "col1": spec.col1,
450
+ # None for a plain Cartesian axes, so a round trip through
451
+ # add_subplot(..., projection=...) reproduces it exactly.
452
+ "projection": "polar" if getattr(ax, "_is_polar", False) else None,
453
+ "title": ax._title or None, "title_size": ax._title_size,
454
+ "xlabel": ax._xlabel or None, "ylabel": ax._ylabel or None,
455
+ # Always explicit, even for an originally auto-scaled axes --
456
+ # "the same figure back" means the same rendered extent, not
457
+ # whatever autoscale happens to recompute from however much of
458
+ # the original data the caller chooses to replot.
459
+ "xlim": [round(float(xmin), 6), round(float(xmax), 6)],
460
+ "ylim": [round(float(ymin), 6), round(float(ymax), 6)],
461
+ "xscale": ax._xscale, "yscale": ax._yscale,
462
+ "xinverted": bool(ax._xinverted), "yinverted": bool(ax._yinverted),
463
+ "grid": bool(ax._grid), "grid_alpha": ax._grid_alpha,
464
+ "aspect": ax._aspect, "box_aspect": ax._box_aspect,
465
+ "axis_off": bool(ax._axis_off),
466
+ "facecolor": ax._facecolor,
467
+ "legend": ({
468
+ "loc": ax._legend_loc, "ncol": ax._legend_ncol,
469
+ "title": ax._legend_title, "fontsize": ax._legend_fontsize,
470
+ "framealpha": ax._legend_framealpha,
471
+ } if ax._show_legend else None),
472
+ }
473
+ groups = [
474
+ {
475
+ "title": g["title"],
476
+ # Only members that are themselves recoverable (present in
477
+ # `axes` above) -- a freeform add_axes() member is real
478
+ # (`id(a) in idx_of`) but has no grid cell of its own, the same
479
+ # reason it's absent from `axes`; leaving it in here would have
480
+ # `subplots_from_layout()` try to look it up among axes it was
481
+ # never going to rebuild. `n_members` -- the ORIGINAL count,
482
+ # before this filter -- is what lets that function tell a
483
+ # group apart that lost a member from one that didn't.
484
+ "axes": [idx_of[id(a)] for a in g["axes"]
485
+ if id(a) in idx_of and idx_of[id(a)] in axes],
486
+ "n_members": len(g["axes"]),
487
+ "linestyle": g["linestyle"], "color": g["color"],
488
+ "linewidth": g["linewidth"], "title_position": g["title_position"],
489
+ "pad": list(g["pad"]), "fontsize": g["fontsize"],
490
+ }
491
+ for g in fig._groups
492
+ ]
493
+ return {"figsize": list(fig.figsize), "axes": axes, "groups": groups,
494
+ "omitted_axes": omitted,
495
+ "suptitle": fig._suptitle, "supxlabel": fig._supxlabel,
496
+ "supylabel": fig._supylabel, "facecolor": fig.style.facecolor}
497
+
498
+
499
+ def style_payload(fig):
500
+ """Style constants the client tick-rebuilder needs during per-axes zoom."""
501
+ st = fig.style
502
+ return {
503
+ "spine": st.spine_color, "spine_width": st.spine_width,
504
+ "grid_color": st.grid_color, "grid_width": st.grid_width,
505
+ "grid_alpha": st.grid_alpha, "tick_size": st.tick_size,
506
+ "tick_width": st.tick_width, "tick_label_size": st.tick_label_size,
507
+ "text": st.text_color,
508
+ }
509
+
510
+
511
+ def _rl(a, nd=6):
512
+ """Flatten to a rounded Python-float list (vectorized: NumPy does the work).
513
+
514
+ Much faster than a per-element ``round(float(v), nd)`` comprehension on the
515
+ large arrays embedded for point picking (e.g. mesh z grids).
516
+ """
517
+ return np.round(np.asarray(a, dtype=float).ravel(), nd).tolist()
518
+
519
+
520
+ def _round_list(a):
521
+ return _rl(a, 6)
522
+
523
+
524
+ def _downsample_grid(z, max_cells):
525
+ """Block-average ``z`` down to at most ``max_cells`` cells.
526
+
527
+ A mesh/contour too large to embed at full resolution used to be dropped
528
+ from the pick payload entirely, so a click reported bare x/y with no data
529
+ value -- exactly the case a "third dimension" plot type exists for.
530
+ Block-averaging keeps every pick answerable (a real, spatially
531
+ representative value) while still bounding the embedded HTML size,
532
+ mirroring how huge line series are min/max-decimated before embedding
533
+ rather than dropped (see primitives._decimate_minmax).
534
+
535
+ What this actually costs, precisely, since it's easy to read "still
536
+ answers with a real value" as "still answers with *the* value": a click
537
+ on a downsampled cell reads the **mean** of every original cell folded
538
+ into it, not the exact value at the point clicked, and that cell's own
539
+ x/y is the wider block's center, not the original grid's -- both real,
540
+ silent precision losses, not just a coarser click radius. The rendered
541
+ image (never downsampled -- only the pick payload is) gives no visual
542
+ hint that this happened; :func:`pick_data`/:func:`frame_data` warn about
543
+ it instead, once per affected mesh, whenever it actually does.
544
+ """
545
+ ny, nx = z.shape
546
+ if ny * nx <= max_cells:
547
+ return z
548
+ factor = math.ceil(math.sqrt((ny * nx) / max_cells))
549
+ new_ny = max(1, math.ceil(ny / factor))
550
+ new_nx = max(1, math.ceil(nx / factor))
551
+ pad_ny, pad_nx = new_ny * factor - ny, new_nx * factor - nx
552
+ zp = np.pad(z, ((0, pad_ny), (0, pad_nx)), mode="edge")
553
+ blocked = zp.reshape(new_ny, factor, new_nx, factor)
554
+ # A block that's entirely NaN (masked/missing data, e.g. land in an ocean
555
+ # field) is a real, expected input -- nanmean's "Mean of empty slice"
556
+ # warning about it is noise, not a bug to surface on every such figure.
557
+ with warnings.catch_warnings():
558
+ warnings.simplefilter("ignore", category=RuntimeWarning)
559
+ return np.nanmean(blocked, axis=(1, 3))
560
+
561
+
562
+ def _curvilinear_centers(X, Y, ny, nx):
563
+ """Each cell's center: the average of its 4 corner nodes.
564
+
565
+ ``X``/``Y`` are the ``(ny+1, nx+1)``-ish node grid a curvilinear mesh
566
+ scan-converts from (see ``QuadMesh._rgba_curvilinear``); a warped mesh has
567
+ no separable 1-D edge vectors the way a rectilinear one does, so picking
568
+ it needs an explicit per-cell coordinate instead.
569
+ """
570
+ cx = (X[:ny, :nx] + X[:ny, 1:nx + 1] + X[1:ny + 1, :nx] + X[1:ny + 1, 1:nx + 1]) / 4.0
571
+ cy = (Y[:ny, :nx] + Y[:ny, 1:nx + 1] + Y[1:ny + 1, :nx] + Y[1:ny + 1, 1:nx + 1]) / 4.0
572
+ return cx, cy
573
+
574
+
575
+ def _quadmesh_pick_entry(art, max_mesh_cells, precision):
576
+ """The geometry + z data pick_data() embeds for one plain QuadMesh.
577
+
578
+ Factored out so a FrameQuadMesh can reuse it once per frame (see
579
+ frame_data()) instead of duplicating this branch -- every frame shares
580
+ one X/Y grid, so only C differs, but each frame still needs its own
581
+ downsampled z at whatever cell the click lands in.
582
+ """
583
+ def _round_list(a):
584
+ return _rl(a, precision)
585
+
586
+ grid = art.C
587
+ curvilinear = art.curvilinear
588
+ if curvilinear:
589
+ ny0 = min(grid.shape[0], art.X.shape[0] - 1)
590
+ nx0 = min(grid.shape[1], art.X.shape[1] - 1)
591
+ grid = grid[:ny0, :nx0]
592
+ else:
593
+ ny0, nx0 = grid.shape
594
+ xmin, xmax, ymin, ymax = art.extent()
595
+ z = _downsample_grid(grid, max_mesh_cells)
596
+ ny, nx = z.shape
597
+ entry = {
598
+ "extent": [round(xmin, 6), round(xmax, 6), round(ymin, 6), round(ymax, 6)],
599
+ "shape": [int(ny), int(nx)],
600
+ "z": _round_list(z),
601
+ "name": "z",
602
+ "curvilinear": bool(curvilinear),
603
+ }
604
+ if (ny, nx) != (ny0, nx0):
605
+ # Internal-only -- frame_data() (the one caller) pops this back off
606
+ # before the entry becomes part of the embedded payload; it's how
607
+ # that caller learns downsampling happened without recomputing
608
+ # ny0/nx0 (and the curvilinear clamping above) itself.
609
+ entry["_downsampled_from"] = (ny0, nx0)
610
+ if curvilinear:
611
+ cx, cy = _curvilinear_centers(art.X, art.Y, ny0, nx0)
612
+ if (ny, nx) != (ny0, nx0):
613
+ cx, cy = _downsample_grid(cx, max_mesh_cells), _downsample_grid(cy, max_mesh_cells)
614
+ entry["xc"], entry["yc"] = _round_list(cx), _round_list(cy)
615
+ else:
616
+ if (ny, nx) == (ny0, nx0):
617
+ xe, ye = art.cell_edges()
618
+ else:
619
+ xe = np.linspace(xmin, xmax, nx + 1)
620
+ ye = np.linspace(ymin, ymax, ny + 1)
621
+ entry["xedges"], entry["yedges"] = _round_list(xe), _round_list(ye)
622
+ return entry
623
+
624
+
625
+ def pick_data(fig, max_points=20000, max_mesh_cells=250000, precision=6):
626
+ """Per-axes data payload for point picking (values incl. z and beyond).
627
+
628
+ For point series (line/scatter) embeds x, y and any extra named dimensions
629
+ (``pick_values`` such as ``c`` or ``z``). For meshes/contours embeds the z
630
+ grid so a clicked cell reports its value -- block-averaged down to
631
+ ``max_mesh_cells`` for a grid over the cap (see :func:`_downsample_grid`
632
+ for exactly what that costs: a click's z becomes the *mean* of the
633
+ original cells folded into whichever coarser one it landed in, not the
634
+ exact value at that point, and that cell's own x/y coarsens the same
635
+ way), so even a huge mesh always answers a pick with a real value
636
+ instead of falling back to a bare x/y readout. Emits one consolidated
637
+ ``UserWarning`` naming every axes this actually happened to (shape
638
+ before/after, and how to raise the cap) when it does -- the rendered
639
+ image itself never downsamples, so there is otherwise no visual sign
640
+ that a click's precision is coarser than what's drawn. Point series over
641
+ ``max_points`` are still omitted outright (that fallback --
642
+ nearest-vertex geometry -- has no missing-value problem to solve), so
643
+ the HTML stays lean.
644
+
645
+ ``precision`` sets the decimal places the embedded arrays are rounded to.
646
+ Lower values shrink the payload (the mesh z grids dominate it); 6 keeps
647
+ full readout fidelity.
648
+ """
649
+ # Local shadow so every _round_list(...) call below honors `precision`
650
+ # without threading it through ~20 call sites.
651
+ def _round_list(a):
652
+ return _rl(a, precision)
653
+
654
+ # (axes index, axes title or None, original (ny, nx), downsampled
655
+ # (ny, nx)) for every mesh/contour that actually needed
656
+ # _downsample_grid -- collected instead of warning inline so a figure
657
+ # with several oversized meshes gets one summary, not one warning per
658
+ # mesh (see the warnings.warn call at the end of this function).
659
+ downsampled = []
660
+ data = {}
661
+ for i, ax in enumerate(fig.axes):
662
+ if ax._is_colorbar or not ax._visible:
663
+ continue
664
+ series, meshes, pies = [], [], []
665
+ for art in ax.artists:
666
+ if isinstance(art, (Line2D, ScatterCollection)):
667
+ if art.x.size == 0 or art.x.size > max_points:
668
+ continue
669
+ vals = {k: _round_list(v) for k, v in art.pick_values.items()
670
+ if np.asarray(v).size == art.x.size}
671
+ series.append({
672
+ "kind": "scatter" if isinstance(art, ScatterCollection) else "line",
673
+ "x": _round_list(art.x), "y": _round_list(art.y),
674
+ "vals": vals,
675
+ })
676
+ elif isinstance(art, Stem):
677
+ series.append({"kind": "stem", "x": _round_list(art.x),
678
+ "y": _round_list(art.y), "vals": {}})
679
+ elif isinstance(art, ErrorBar):
680
+ vals = {}
681
+ if art.yerr is not None:
682
+ vals["yerr"] = _round_list(art.yerr)
683
+ if art.xerr is not None:
684
+ vals["xerr"] = _round_list(art.xerr)
685
+ series.append({"kind": "errorbar", "x": _round_list(art.x),
686
+ "y": _round_list(art.y), "vals": vals})
687
+ elif isinstance(art, Bars):
688
+ if art.orientation == "vertical":
689
+ xs, ys = art.pos, art.base + art.length
690
+ else:
691
+ xs, ys = art.base + art.length, art.pos
692
+ series.append({"kind": "bar", "x": _round_list(xs),
693
+ "y": _round_list(ys),
694
+ "vals": {"value": _round_list(art.length)}})
695
+ elif isinstance(art, Quiver):
696
+ series.append({"kind": "quiver", "x": _round_list(art.X),
697
+ "y": _round_list(art.Y),
698
+ "vals": {"u": _round_list(art.U),
699
+ "v": _round_list(art.V),
700
+ "mag": _round_list(np.hypot(art.U, art.V))}})
701
+ elif isinstance(art, EventPlot):
702
+ xs, ys = [], []
703
+ for row, off in zip(art.rows, art.offsets):
704
+ xs.extend(row.tolist())
705
+ ys.extend([float(off)] * row.size)
706
+ if art.orientation != "horizontal":
707
+ xs, ys = ys, xs
708
+ if 0 < len(xs) <= max_points:
709
+ series.append({"kind": "event", "x": [round(v, 6) for v in xs],
710
+ "y": [round(v, 6) for v in ys], "vals": {}})
711
+ elif isinstance(art, BoxPlot):
712
+ # One pickable point per box at its median, carrying all stats.
713
+ xs, ys = [], []
714
+ q1s, q3s, los, his = [], [], [], []
715
+ for pos, s in zip(art.positions, art.stats):
716
+ if art.orientation == "vertical":
717
+ xs.append(float(pos)); ys.append(float(s["med"]))
718
+ else:
719
+ xs.append(float(s["med"])); ys.append(float(pos))
720
+ q1s.append(round(float(s["q1"]), 6)); q3s.append(round(float(s["q3"]), 6))
721
+ los.append(round(float(s["lo"]), 6)); his.append(round(float(s["hi"]), 6))
722
+ series.append({"kind": "box", "x": [round(v, 6) for v in xs],
723
+ "y": [round(v, 6) for v in ys],
724
+ "vals": {"q1": q1s, "q3": q3s,
725
+ "whislo": los, "whishi": his}})
726
+ elif isinstance(art, Violin):
727
+ # Centerline points per violin (value + normalized width).
728
+ for pos, grid, hw in zip(art.positions, art.grids, art.halfwidths):
729
+ if grid.size == 0 or grid.size > max_points:
730
+ continue
731
+ if art.orientation == "vertical":
732
+ vx = [round(float(pos), 6)] * grid.size
733
+ vy = _round_list(grid)
734
+ else:
735
+ vx = _round_list(grid)
736
+ vy = [round(float(pos), 6)] * grid.size
737
+ series.append({"kind": "violin", "x": vx, "y": vy,
738
+ "vals": {"width": _round_list(hw * 2.0)}})
739
+ elif isinstance(art, Contour):
740
+ # Pick like a pcolormesh: report the field value z at the grid
741
+ # cell under the cursor (arrow keys step cell-by-cell). A grid
742
+ # over the cap is downsampled, not dropped -- see
743
+ # _downsample_grid. `art.x`/`art.y` are sample coordinates
744
+ # (matplotlib contour explicitly allows non-uniform spacing),
745
+ # not necessarily evenly spaced, so the client needs the real
746
+ # cell boundaries -- not "shape cells spanning the extent
747
+ # evenly", which was silently wrong for any non-uniform grid
748
+ # (and subtly off even for a uniform one, by treating point
749
+ # samples as if they were cells).
750
+ ny0, nx0 = art.Z.shape
751
+ z = _downsample_grid(art.Z, max_mesh_cells)
752
+ ny, nx = z.shape
753
+ if (ny, nx) != (ny0, nx0):
754
+ downsampled.append((i, ax.get_title() or None, (ny0, nx0), (ny, nx)))
755
+ xmin, xmax = float(art.x.min()), float(art.x.max())
756
+ ymin, ymax = float(art.y.min()), float(art.y.max())
757
+ entry = {
758
+ "extent": [round(xmin, 6), round(xmax, 6),
759
+ round(ymin, 6), round(ymax, 6)],
760
+ "shape": [int(ny), int(nx)],
761
+ "z": _round_list(z), # row 0 = ymin, like QuadMesh
762
+ "name": "z",
763
+ }
764
+ if (ny, nx) == (ny0, nx0):
765
+ # Edges (for bucketing a click into the right sample's
766
+ # Voronoi-like span) and the exact sample coordinates
767
+ # (for display) are different things here: unlike a true
768
+ # mesh cell, a contour sample's own coordinate generally
769
+ # isn't the midpoint between its implied edges once the
770
+ # spacing is non-uniform, so reporting the edge midpoint
771
+ # would label the point with a value that isn't in the
772
+ # data.
773
+ entry["xedges"] = _round_list(_edges_from(art.x, nx))
774
+ entry["yedges"] = _round_list(_edges_from(art.y, ny))
775
+ entry["xcoord"] = _round_list(art.x)
776
+ entry["ycoord"] = _round_list(art.y)
777
+ else:
778
+ entry["xedges"] = _round_list(np.linspace(xmin, xmax, nx + 1))
779
+ entry["yedges"] = _round_list(np.linspace(ymin, ymax, ny + 1))
780
+ meshes.append(entry)
781
+ elif isinstance(art, FillBetween):
782
+ if 0 < art.x.size <= max_points:
783
+ hi = np.maximum(art.y1, art.y2)
784
+ lo = np.minimum(art.y1, art.y2)
785
+ series.append({"kind": "fill", "x": _round_list(art.x),
786
+ "y": _round_list(hi), # snap to band top
787
+ "vals": {"lower": _round_list(lo)}})
788
+ elif isinstance(art, Polygon):
789
+ if 0 < art.x.size <= max_points:
790
+ series.append({"kind": "polygon", "x": _round_list(art.x),
791
+ "y": _round_list(art.y), "vals": {}})
792
+ elif isinstance(art, LineCollection):
793
+ segs = art.segments
794
+ if 0 < len(segs) <= max_points:
795
+ # One pickable point per segment, at its midpoint -- vals
796
+ # carry the full span so hlines/vlines report where the
797
+ # line actually starts and ends, not just where it was
798
+ # clicked along its length.
799
+ x0, y0, x1, y1 = segs[:, 0], segs[:, 1], segs[:, 2], segs[:, 3]
800
+ series.append({"kind": "lines",
801
+ "x": _round_list((x0 + x1) / 2.0),
802
+ "y": _round_list((y0 + y1) / 2.0),
803
+ "vals": {"x0": _round_list(x0), "x1": _round_list(x1),
804
+ "y0": _round_list(y0), "y1": _round_list(y1)}})
805
+ elif isinstance(art, PolyCollection):
806
+ n = len(art.verts)
807
+ if 0 < n <= max_points:
808
+ # One pickable point per polygon, at its centroid -- vals
809
+ # carry its bounding box (broken_barh's rectangles) and,
810
+ # when present, the raw per-polygon value a colormap was
811
+ # built from (hexbin's counts -- the facecolors array
812
+ # alone has already thrown that number away).
813
+ cx = np.array([v[:, 0].mean() for v in art.verts])
814
+ cy = np.array([v[:, 1].mean() for v in art.verts])
815
+ vals = {
816
+ "xmin": _round_list([v[:, 0].min() for v in art.verts]),
817
+ "xmax": _round_list([v[:, 0].max() for v in art.verts]),
818
+ "ymin": _round_list([v[:, 1].min() for v in art.verts]),
819
+ "ymax": _round_list([v[:, 1].max() for v in art.verts]),
820
+ }
821
+ counts = getattr(art, "counts", None)
822
+ if counts is not None and len(counts) == n:
823
+ vals["count"] = _round_list(counts)
824
+ series.append({"kind": "poly", "x": _round_list(cx),
825
+ "y": _round_list(cy), "vals": vals})
826
+ elif isinstance(art, (QuadMesh, Image)):
827
+ is_img = isinstance(art, Image)
828
+ if is_img and art.A.ndim != 2:
829
+ continue # RGB image: no scalar to report
830
+ grid = art.A if is_img else art.C
831
+ curvilinear = isinstance(art, QuadMesh) and art.curvilinear
832
+ if curvilinear:
833
+ # A curvilinear mesh's node arrays have no fixed size
834
+ # contract with C beyond "at least as large" -- X/Y the
835
+ # same shape as C (centers, not corners) is common and
836
+ # valid. _rgba_curvilinear clamps to however many whole
837
+ # cells the two actually provide together; picking has to
838
+ # match that exactly, or _curvilinear_centers indexes
839
+ # X/Y past their real width and numpy's elementwise add
840
+ # raises a shape-mismatch error building the centers.
841
+ ny0 = min(grid.shape[0], art.X.shape[0] - 1)
842
+ nx0 = min(grid.shape[1], art.X.shape[1] - 1)
843
+ grid = grid[:ny0, :nx0]
844
+ else:
845
+ ny0, nx0 = grid.shape
846
+ xmin, xmax, ymin, ymax = art.extent()
847
+ # Store z row-major with row 0 = ymin so a clicked cell maps back.
848
+ z0 = np.flipud(grid) if (is_img and art.origin == "upper") else grid
849
+ # A grid over the cap is downsampled, not dropped -- a click
850
+ # still answers with a real (if coarser) value instead of
851
+ # falling back to a bare x/y readout. See _downsample_grid.
852
+ z = _downsample_grid(z0, max_mesh_cells)
853
+ ny, nx = z.shape
854
+ if (ny, nx) != (ny0, nx0):
855
+ downsampled.append((i, ax.get_title() or None, (ny0, nx0), (ny, nx)))
856
+ entry = {
857
+ "extent": [round(xmin, 6), round(xmax, 6),
858
+ round(ymin, 6), round(ymax, 6)],
859
+ "shape": [int(ny), int(nx)],
860
+ "z": _round_list(z),
861
+ "name": "z",
862
+ "curvilinear": bool(curvilinear),
863
+ }
864
+ if curvilinear:
865
+ # No separable 1-D edges on a warped grid -- picking
866
+ # matches the click to the nearest cell *center* instead
867
+ # of bucketing it into a rectangular extent division
868
+ # (which was wrong: it reported whichever cell the click
869
+ # fell into on a *uniform* grid overlaid on the extent,
870
+ # unrelated to where the warped cells actually are).
871
+ cx, cy = _curvilinear_centers(art.X, art.Y, ny0, nx0)
872
+ if (ny, nx) != (ny0, nx0):
873
+ cx, cy = _downsample_grid(cx, max_mesh_cells), _downsample_grid(cy, max_mesh_cells)
874
+ entry["xc"], entry["yc"] = _round_list(cx), _round_list(cy)
875
+ else:
876
+ # Non-uniform rectilinear spacing (matplotlib explicitly
877
+ # allows uneven pcolormesh edges) needs the real
878
+ # boundaries too -- an evenly-divided extent silently
879
+ # picked the wrong cell for anything but a uniform grid.
880
+ if (ny, nx) == (ny0, nx0) and not is_img:
881
+ xe, ye = art.cell_edges()
882
+ else:
883
+ # Downsampling coarsens to a uniform block grid, and a
884
+ # plain Image is already a uniform raster over its
885
+ # extent -- an evenly spaced division is exact here,
886
+ # not an approximation.
887
+ xe = np.linspace(xmin, xmax, nx + 1)
888
+ ye = np.linspace(ymin, ymax, ny + 1)
889
+ entry["xedges"], entry["yedges"] = _round_list(xe), _round_list(ye)
890
+ meshes.append(entry)
891
+ elif isinstance(art, Pie):
892
+ pies.append({
893
+ "startangle": float(art.startangle),
894
+ "radius": float(art.radius),
895
+ "fracs": _round_list(art.fracs),
896
+ "values": _round_list(art.values),
897
+ "labels": list(art.labels) if art.labels is not None else None,
898
+ })
899
+ if series or meshes or pies:
900
+ data[i] = {"series": series, "meshes": meshes, "pies": pies}
901
+ if downsampled:
902
+ _warn_downsampled(downsampled, max_mesh_cells)
903
+ return data
904
+
905
+
906
+ def _warn_downsampled(downsampled, max_mesh_cells):
907
+ """One consolidated UserWarning for every mesh/contour pick_data() (or
908
+ frame_data()) had to block-average down to max_mesh_cells -- a caller
909
+ with several oversized meshes gets one summary naming each of them, not
910
+ one warning per mesh.
911
+
912
+ Block-averaging (see _downsample_grid) means a click's z reads as the
913
+ *mean* of every original cell folded into whichever coarser cell it
914
+ landed in, not the exact value at the point clicked -- and that
915
+ coarser cell's own x/y is wider too, so nearby clicks can resolve to
916
+ the same pick, or skip past an original cell entirely, well before the
917
+ rendered image (still full resolution) visually suggests either.
918
+ Real, silent precision loss, not just a coarser click radius -- worth a
919
+ warning on every save it happens on, not just a line in a docstring
920
+ nobody reads until they already suspect something is off.
921
+ """
922
+ lines = []
923
+ for i, title, (ny0, nx0), (ny, nx) in downsampled:
924
+ label = f"axes {i}" + (f" ({title!r})" if title else "")
925
+ lines.append(f" {label}: {ny0}x{nx0} ({ny0 * nx0:,} cells) "
926
+ f"-> {ny}x{nx} ({ny * nx:,} cells)")
927
+ warnings.warn(
928
+ "Point Picking's embedded data is coarser than what's drawn for "
929
+ f"{len(downsampled)} mesh/contour "
930
+ f"{'panel' if len(downsampled) == 1 else 'panels'} over "
931
+ f"pick_max_mesh_cells={max_mesh_cells:,} -- each was block-averaged "
932
+ "down (a click reads the *mean* of the original cells folded into "
933
+ "the one it landed in, not the exact value at that point):\n"
934
+ + "\n".join(lines) +
935
+ "\nPass a higher pick_max_mesh_cells= to Figure.save()/to_html() "
936
+ "for full-resolution picking, at the cost of a larger embedded "
937
+ "payload.",
938
+ UserWarning, stacklevel=3)
939
+
940
+
941
+ def _effective_rect(ax, px_left, px_top, px_w, px_h, xlim, ylim):
942
+ """Shrink the drawn box to honor ``set_aspect``/``set_box_aspect``, centered."""
943
+ if ax._box_aspect is not None:
944
+ # A fixed physical height/width ratio, independent of the data range
945
+ # entirely -- unlike set_aspect (which shrinks to keep a *data* unit
946
+ # the same size in x and y), this never looks at xlim/ylim at all.
947
+ a = ax._box_aspect
948
+ s = min(px_w, px_h / a)
949
+ used_w, used_h = s, a * s
950
+ return (px_left + (px_w - used_w) / 2, px_top + (px_h - used_h) / 2,
951
+ used_w, used_h)
952
+ if ax._aspect is None:
953
+ return px_left, px_top, px_w, px_h
954
+ fx = math.log10 if ax._xscale == "log" else (lambda v: v)
955
+ fy = math.log10 if ax._yscale == "log" else (lambda v: v)
956
+ xspan = abs(fx(xlim[1]) - fx(xlim[0])) or 1.0
957
+ yspan = abs(fy(ylim[1]) - fy(ylim[0])) or 1.0
958
+ a = ax._aspect
959
+ s = min(px_w / xspan, px_h / (a * yspan))
960
+ used_w, used_h = s * xspan, a * s * yspan
961
+ return (px_left + (px_w - used_w) / 2, px_top + (px_h - used_h) / 2,
962
+ used_w, used_h)
963
+
964
+
965
+ def _render_axes(ax, fig, W, H, index, defs, body):
966
+ st = ax.style
967
+ alloc = _pixel_rect(ax, W, H)
968
+ (xmin, xmax), (ymin, ymax) = ax._resolved_limits()
969
+ px_left, px_top, px_w, px_h = _effective_rect(ax, *alloc, (xmin, xmax), (ymin, ymax))
970
+ xlim_t = (xmax, xmin) if ax._xinverted else (xmin, xmax)
971
+ ylim_t = (ymax, ymin) if ax._yinverted else (ymin, ymax)
972
+ tr = LinearTransform(xlim_t, ylim_t, (px_left, px_top, px_w, px_h),
973
+ xscale=ax._xscale, yscale=ax._yscale)
974
+
975
+ clip_id = f"clip{index}"
976
+ defs.append(
977
+ f'<clipPath id="{clip_id}"><rect x="{_fmt(px_left)}" y="{_fmt(px_top)}" '
978
+ f'width="{_fmt(px_w)}" height="{_fmt(px_h)}"/></clipPath>'
979
+ )
980
+
981
+ if not ax._visible:
982
+ return
983
+
984
+ if ax._is_colorbar:
985
+ _render_colorbar(ax, tr, *alloc, clip_id, body)
986
+ _render_labels(ax, st, *alloc, body) # title only, by convention: set_title() labels a colorbar's scale
987
+ return
988
+
989
+ is_twin = ax._twin_of is not None
990
+ is_secondary = ax._secondary_of is not None
991
+ overlay = is_twin or is_secondary
992
+ # Axes background (twins/secondaries overlay their parent, so neither
993
+ # draws one).
994
+ if not overlay:
995
+ body.append(
996
+ f'<rect x="{_fmt(px_left)}" y="{_fmt(px_top)}" width="{_fmt(px_w)}" '
997
+ f'height="{_fmt(px_h)}" fill="{ax.get_facecolor()}"/>'
998
+ )
999
+
1000
+ xticks = (ax._xticks if ax._xticks is not None else
1001
+ (log_ticks(xmin, xmax) if ax._xscale == "log" else nice_ticks(xmin, xmax)))
1002
+ yticks = (ax._yticks if ax._yticks is not None else
1003
+ (log_ticks(ymin, ymax) if ax._yscale == "log" else nice_ticks(ymin, ymax)))
1004
+
1005
+ # Grid + ticks live in one group so client-side per-axes zoom can rebuild
1006
+ # them from new limits (see _interactive.py).
1007
+ body.append(f'<g id="ticks{index}">')
1008
+ if ax._grid and not ax._axis_off and not overlay:
1009
+ grid_alpha = ax._grid_alpha if ax._grid_alpha is not None else st.grid_alpha
1010
+ _render_grid(st, tr, xticks, yticks, px_left, px_top, px_w, px_h, body,
1011
+ grid_alpha)
1012
+ if not ax._axis_off:
1013
+ if is_twin:
1014
+ _render_twin_ticks(ax, st, tr, xticks, yticks,
1015
+ px_left, px_top, px_w, px_h, body)
1016
+ elif is_secondary:
1017
+ # No data of its own -- draw only the mirrored dimension's ticks,
1018
+ # on whichever side tick_top()/tick_right() (reused here) picked.
1019
+ xst = st.copy(**ax._tick_overrides["x"]) if ax._tick_overrides["x"] else st
1020
+ yst = st.copy(**ax._tick_overrides["y"]) if ax._tick_overrides["y"] else st
1021
+ is_x = ax._secondary_dim == "x"
1022
+ xlabels = _resolve_tick_labels(ax._xticklabels, xticks) if is_x else []
1023
+ ylabels = _resolve_tick_labels(ax._yticklabels, yticks) if not is_x else []
1024
+ _render_ticks(xst, yst, tr, xticks if is_x else [], yticks if not is_x else [],
1025
+ xlabels, ylabels, px_left, px_top, px_w, px_h, body,
1026
+ xside=ax._xtick_side, yside=ax._ytick_side)
1027
+ else:
1028
+ xlabels = _resolve_tick_labels(ax._xticklabels, xticks)
1029
+ ylabels = _resolve_tick_labels(ax._yticklabels, yticks)
1030
+ xst = st.copy(**ax._tick_overrides["x"]) if ax._tick_overrides["x"] else st
1031
+ yst = st.copy(**ax._tick_overrides["y"]) if ax._tick_overrides["y"] else st
1032
+ _render_ticks(xst, yst, tr, xticks, yticks, xlabels, ylabels,
1033
+ px_left, px_top, px_w, px_h, body,
1034
+ xside=ax._xtick_side, yside=ax._ytick_side)
1035
+ if ax._minor_ticks_on:
1036
+ mxst = (xst.copy(**ax._minor_tick_overrides["x"])
1037
+ if ax._minor_tick_overrides["x"] else xst)
1038
+ myst = (yst.copy(**ax._minor_tick_overrides["y"])
1039
+ if ax._minor_tick_overrides["y"] else yst)
1040
+ xminor = (ax._xticks_minor if ax._xticks_minor is not None
1041
+ else minor_ticks(xticks, xmin, xmax, ax._xscale))
1042
+ yminor = (ax._yticks_minor if ax._yticks_minor is not None
1043
+ else minor_ticks(yticks, ymin, ymax, ax._yscale))
1044
+ _render_minor_ticks(mxst, myst, tr, xminor, yminor,
1045
+ px_left, px_top, px_w, px_h, body,
1046
+ xside=ax._xtick_side, yside=ax._ytick_side)
1047
+ body.append("</g>")
1048
+
1049
+ # Artists: fixed clip to the axes rect, then a transformable zoom group that
1050
+ # per-axes data zoom remaps via one affine (old limits -> new limits).
1051
+ body.append(f'<g clip-path="url(#{clip_id})"><g id="zoom{index}" class="plotpress-zoom">')
1052
+ # Draw order follows zorder (ties keep call order), but k stays each
1053
+ # artist's own call-order index -- pick/series ids and legend order must
1054
+ # stay stable regardless of what zorder does to the visual stacking.
1055
+ draw_order = sorted(enumerate(ax.artists), key=lambda ka: (ka[1].zorder, ka[0]))
1056
+ axes_fraction_artists = []
1057
+ for k, artist in draw_order:
1058
+ if isinstance(artist, QuadMesh) and artist.vectorized:
1059
+ _render_mesh_vector(artist, tr, index, k, body)
1060
+ continue
1061
+ prims = artist_to_prims(artist, tr, index, k, size_scale=st.dpi / 72.0)
1062
+ if prims is not None:
1063
+ body.extend(_emit_prim(p) for p in prims)
1064
+ continue
1065
+ if isinstance(artist, FrameLine2D):
1066
+ _render_frameline(artist, tr, index, k, body)
1067
+ elif isinstance(artist, FrameQuadMesh):
1068
+ _render_framequadmesh(artist, tr, index, k, body)
1069
+ elif isinstance(artist, Bars):
1070
+ _render_bars(artist, tr, index, k, body)
1071
+ elif isinstance(artist, Stem):
1072
+ _render_stem(artist, tr, st, fig, body)
1073
+ elif isinstance(artist, ErrorBar):
1074
+ _render_errorbar(artist, tr, st, fig, body)
1075
+ elif isinstance(artist, Pie):
1076
+ # Drawn in axes-*pixel* space (see _render_pie) so it stays
1077
+ # circular regardless of xlim/ylim -- it has no data-space
1078
+ # geometry to begin with, so it belongs outside the zoom group
1079
+ # entirely, the same as table()/transform=ax.transAxes text.
1080
+ # Left inside it, a per-axes data zoom's matrix(sx,sy,...)
1081
+ # stretched the whole pie into a rectangle instead of leaving it
1082
+ # alone, since non-uniform sx/sy has nothing to do with a pie's
1083
+ # own (data-independent) circular shape.
1084
+ axes_fraction_artists.append(artist)
1085
+ elif isinstance(artist, BoxPlot):
1086
+ _render_boxplot(artist, tr, st, body)
1087
+ elif isinstance(artist, Violin):
1088
+ _render_violin(artist, tr, body)
1089
+ elif isinstance(artist, EventPlot):
1090
+ _render_eventplot(artist, tr, body)
1091
+ elif isinstance(artist, Quiver):
1092
+ _render_quiver(artist, tr, body)
1093
+ elif isinstance(artist, Barbs):
1094
+ _render_barbs(artist, tr, st, body)
1095
+ elif isinstance(artist, Contour):
1096
+ _render_contour(artist, tr, body)
1097
+ elif isinstance(artist, Text):
1098
+ if artist.axes_fraction:
1099
+ axes_fraction_artists.append(artist)
1100
+ else:
1101
+ _render_text(artist, tr, st, body, index=index)
1102
+ elif isinstance(artist, Annotation):
1103
+ if artist.axes_fraction:
1104
+ axes_fraction_artists.append(artist)
1105
+ else:
1106
+ _render_annotation(artist, tr, st, body, index=index)
1107
+ elif isinstance(artist, Table):
1108
+ axes_fraction_artists.append(artist) # always axes-fraction, like a table() bbox
1109
+ body.append("</g>") # close the zoom group only -- axes-fraction text is next
1110
+ # transform=ax.transAxes text/annotate (and table(), always axes-fraction)
1111
+ # sit at a fixed spot on the axes *frame*, not the data -- rendered
1112
+ # outside the zoom group so a per-axes data zoom/pan leaves them alone,
1113
+ # still inside the clip group so they can't spill past the axes rect the
1114
+ # way a data-anchored label already can't.
1115
+ for artist in axes_fraction_artists:
1116
+ if isinstance(artist, Text):
1117
+ _render_text(artist, tr, st, body)
1118
+ elif isinstance(artist, Annotation):
1119
+ _render_annotation(artist, tr, st, body)
1120
+ elif isinstance(artist, Pie):
1121
+ _render_pie(artist, tr, body)
1122
+ else:
1123
+ _render_table(artist, tr, st, body)
1124
+ body.append("</g>") # close the clip group
1125
+
1126
+ if not ax._axis_off and not overlay:
1127
+ _render_spines(ax, px_left, px_top, px_w, px_h, body)
1128
+ # A twin's axis label is drawn inline by _render_twin_ticks; a secondary
1129
+ # axis has no such bespoke renderer, so it goes through the generic (now
1130
+ # tick-side-aware) label placement below, same as an ordinary axes.
1131
+ if not is_twin:
1132
+ _render_labels(ax, st, px_left, px_top, px_w, px_h, body)
1133
+
1134
+ if ax._show_legend:
1135
+ _render_legend(ax, st, px_left, px_top, px_w, px_h, body)
1136
+
1137
+
1138
+ # -- artists ---------------------------------------------------------------
1139
+ def _seg_to_path(seg: np.ndarray) -> str:
1140
+ """Serialize one contiguous run of points to ``M x,y L x,y ...``.
1141
+
1142
+ Uses vectorized ``numpy.char`` formatting instead of per-point Python
1143
+ f-strings. Combined with min/max decimation of huge lines (see
1144
+ :func:`_decimate_minmax`), this keeps large-series serialization fast in
1145
+ pure NumPy.
1146
+ """
1147
+ xs = np.char.mod("%.2f", seg[:, 0])
1148
+ ys = np.char.mod("%.2f", seg[:, 1])
1149
+ coords = np.char.add(np.char.add(xs, ","), ys)
1150
+ return "M" + "L".join(coords.tolist())
1151
+
1152
+
1153
+ def _line_path_d(pts: np.ndarray) -> str:
1154
+ """Build an SVG path ``d`` string, splitting on non-finite points."""
1155
+ mask = np.isfinite(pts).all(axis=1)
1156
+ if mask.all():
1157
+ return _seg_to_path(pts) if len(pts) else ""
1158
+ n = len(pts)
1159
+ out = []
1160
+ i = 0
1161
+ while i < n:
1162
+ if not mask[i]:
1163
+ i += 1
1164
+ continue
1165
+ j = i
1166
+ while j < n and mask[j]:
1167
+ j += 1
1168
+ out.append(_seg_to_path(pts[i:j]))
1169
+ i = j
1170
+ return "".join(out)
1171
+
1172
+
1173
+ def _path_d(subpaths, closed):
1174
+ d = "".join(_seg_to_path(s) for s in subpaths if len(s))
1175
+ return (d + "Z") if (closed and d) else d
1176
+
1177
+
1178
+ def _prim_color(c):
1179
+ return c if isinstance(c, str) else "#%02x%02x%02x" % (int(c[0]), int(c[1]), int(c[2]))
1180
+
1181
+
1182
+ def _emit_markers(p) -> str:
1183
+ """Markers as zero-length round-capped strokes -> circular dots.
1184
+
1185
+ Tagged ``plotpress-marker`` so the interactive CSS (see _interactive.py)
1186
+ can single them out of the zoom group's usual non-scaling-stroke rule --
1187
+ a marker represents a footprint on the *data*, so it should grow or
1188
+ shrink with a per-axes zoom the same way the axis itself does, unlike a
1189
+ line's stroke width (still constant screen size, deliberately, so a thin
1190
+ line doesn't vanish when zoomed out) or a point-pick pin (its own
1191
+ separate constant-size mechanism -- see layoutPin).
1192
+ """
1193
+ pts, diam = p.points, p.diameters
1194
+ finite = np.isfinite(pts).all(axis=1)
1195
+ op = f' stroke-opacity="{p.alpha}"' if p.alpha < 1 else ""
1196
+ idattr = f' id="{p.series_id}"' if p.series_id else ""
1197
+
1198
+ def dot(cx, cy):
1199
+ return f"M{_fmt(cx)},{_fmt(cy)}L{_fmt(cx)},{_fmt(cy)}"
1200
+
1201
+ parts = []
1202
+ same_size = diam.size and float(np.ptp(diam)) < 1e-9
1203
+ edged = getattr(p, "edgecolor", None) and getattr(p, "edgewidth", 0) > 0
1204
+ if edged:
1205
+ # An outline drawn as *wider* dots underneath the face dots, not an
1206
+ # actual stroke -- the face/edge dots are each their own zero-length
1207
+ # round-capped stroke (see the docstring above), so stacking a wider
1208
+ # one in the edge color behind each keeps both a constant pixel size
1209
+ # under zoom, the same property a real <circle stroke> would lose.
1210
+ if same_size:
1211
+ edge_d = "".join(dot(cx, cy) for (cx, cy), ok in zip(pts, finite) if ok)
1212
+ parts.append(
1213
+ f'<path d="{edge_d}" fill="none" stroke="{p.edgecolor}" '
1214
+ f'stroke-width="{_fmt(float(diam[0]) + 2 * p.edgewidth if diam.size else 0)}" '
1215
+ f'stroke-linecap="round"/>')
1216
+ else:
1217
+ for (cx, cy), dm, ok in zip(pts, diam, finite):
1218
+ if ok:
1219
+ parts.append(
1220
+ f'<path d="{dot(cx, cy)}" fill="none" stroke="{p.edgecolor}" '
1221
+ f'stroke-width="{_fmt(dm + 2 * p.edgewidth)}" stroke-linecap="round"/>')
1222
+ if p.single_color and same_size:
1223
+ d = "".join(dot(cx, cy) for (cx, cy), ok in zip(pts, finite) if ok)
1224
+ parts.append(
1225
+ f'<path d="{d}" fill="none" stroke="{p.colors[0]}" '
1226
+ f'stroke-width="{_fmt(float(diam[0]) if diam.size else 0)}" '
1227
+ f'stroke-linecap="round"/>')
1228
+ else:
1229
+ for (cx, cy), dm, col, ok in zip(pts, diam, p.colors, finite):
1230
+ if ok:
1231
+ parts.append(
1232
+ f'<path d="{dot(cx, cy)}" fill="none" stroke="{col}" '
1233
+ f'stroke-width="{_fmt(dm)}" stroke-linecap="round"/>')
1234
+ return (f'<g class="plotpress-series plotpress-marker"{idattr} '
1235
+ f'data-label="{_esc(p.label)}"{op}>{"".join(parts)}</g>')
1236
+
1237
+
1238
+ def _emit_prim(p) -> str:
1239
+ """Serialize one backend-agnostic primitive to an SVG element."""
1240
+ if isinstance(p, PImage):
1241
+ uri = png_data_uri(p.rgba)
1242
+ style = "" if p.smooth else ' style="image-rendering:pixelated"'
1243
+ # class/data-label match every other series (see _emit_prim's PLine/PRect
1244
+ # branches below) so the legend's click-to-hide toggle -- which matches
1245
+ # on .plotpress-series + data-label -- can find a raster mesh/image the
1246
+ # same way it already finds a vectorized one.
1247
+ return (f'<image class="plotpress-series" data-label="{_esc(p.label)}" '
1248
+ f'x="{_fmt(p.x)}" y="{_fmt(p.y)}" width="{_fmt(p.w)}" '
1249
+ f'height="{_fmt(p.h)}" preserveAspectRatio="none"'
1250
+ f'{style} href="{uri}"/>')
1251
+ if isinstance(p, PMarkers):
1252
+ return _emit_markers(p)
1253
+ lbl = _esc(p.label) if p.label else ""
1254
+ if isinstance(p, PLine):
1255
+ attrs = f'stroke="{p.stroke}" stroke-width="{p.stroke_width}"'
1256
+ dash = _DASH.get(p.linestyle)
1257
+ if dash:
1258
+ attrs += f' stroke-dasharray="{dash}"'
1259
+ if p.stroke_opacity < 1:
1260
+ attrs += f' stroke-opacity="{p.stroke_opacity}"'
1261
+ return (f'<line class="plotpress-series" data-label="{lbl}" '
1262
+ f'x1="{_fmt(p.p0[0])}" y1="{_fmt(p.p0[1])}" x2="{_fmt(p.p1[0])}" '
1263
+ f'y2="{_fmt(p.p1[1])}" {attrs}/>')
1264
+ if isinstance(p, PRect):
1265
+ return (f'<rect class="plotpress-series" data-label="{lbl}" '
1266
+ f'x="{_fmt(p.x)}" y="{_fmt(p.y)}" width="{_fmt(p.w)}" '
1267
+ f'height="{_fmt(p.h)}" fill="{p.fill}" fill-opacity="{p.fill_opacity}"/>')
1268
+ if isinstance(p, PSegments):
1269
+ dash = _DASH.get(p.linestyle)
1270
+ lines = "".join(
1271
+ f'<line x1="{_fmt(a)}" y1="{_fmt(b)}" x2="{_fmt(c)}" y2="{_fmt(d)}"/>'
1272
+ for a, b, c, d in p.segs)
1273
+ attrs = f'stroke="{p.stroke}" stroke-width="{p.stroke_width}"'
1274
+ if dash:
1275
+ attrs += f' stroke-dasharray="{dash}"'
1276
+ if p.stroke_opacity < 1:
1277
+ attrs += f' stroke-opacity="{p.stroke_opacity}"'
1278
+ return f'<g class="plotpress-series" data-label="{lbl}" {attrs}>{lines}</g>'
1279
+ if isinstance(p, PPolyBatch):
1280
+ edge = f'stroke="{p.edge}"' if p.edge else 'stroke="none"'
1281
+ op = f' fill-opacity="{p.alpha}"' if p.alpha < 1 else ""
1282
+ out = [f'<g class="plotpress-series" {edge} stroke-width="{p.edge_width}">']
1283
+ for verts, fc in zip(p.polys, p.fills):
1284
+ coords = " ".join(f"{_fmt(x)},{_fmt(y)}" for x, y in verts)
1285
+ out.append(f'<polygon points="{coords}" fill="{_prim_color(fc)}"{op}/>')
1286
+ out.append("</g>")
1287
+ return "".join(out)
1288
+ if isinstance(p, PPath):
1289
+ idattr = f' id="{p.series_id}"' if p.series_id else ""
1290
+ if p.element == "polygon":
1291
+ pts = p.subpaths[0]
1292
+ coords = " ".join(f"{_fmt(x)},{_fmt(y)}" for x, y in pts
1293
+ if np.isfinite([x, y]).all())
1294
+ stroke = (f'stroke="{p.stroke}" stroke-width="{p.stroke_width}"'
1295
+ if p.stroke else 'stroke="none"')
1296
+ return (f'<polygon class="plotpress-series"{idattr} data-label="{lbl}" '
1297
+ f'points="{coords}" fill="{p.fill}" '
1298
+ f'fill-opacity="{p.fill_opacity}" {stroke}/>')
1299
+ d = _path_d(p.subpaths, p.closed)
1300
+ if p.fill and not p.stroke:
1301
+ return (f'<path class="plotpress-series"{idattr} data-label="{lbl}" '
1302
+ f'd="{d}" fill="{p.fill}" fill-opacity="{p.fill_opacity}" '
1303
+ f'stroke="none"/>')
1304
+ attrs = (f'fill="none" stroke="{p.stroke}" stroke-width="{p.stroke_width}" '
1305
+ f'stroke-linejoin="round" stroke-linecap="round"')
1306
+ dash = _DASH.get(p.linestyle)
1307
+ if dash:
1308
+ attrs += f' stroke-dasharray="{dash}"'
1309
+ if p.stroke_opacity < 1:
1310
+ attrs += f' stroke-opacity="{p.stroke_opacity}"'
1311
+ return (f'<path class="plotpress-series"{idattr} data-label="{lbl}" '
1312
+ f'd="{d}" {attrs}/>')
1313
+ raise TypeError(f"unknown primitive {type(p).__name__}")
1314
+
1315
+
1316
+ def _render_frameline(art: FrameLine2D, tr, ai, k, body):
1317
+ """Render frame 0 statically; the slider JS rewrites ``d`` for other frames."""
1318
+ x0, y0 = art.frame_xy(0)
1319
+ d = _line_path_d(tr.xy(x0, y0))
1320
+ # linestyle="none" means invisible, not "no <path> at all" -- unlike
1321
+ # plain plot(), plot_frames() has no marker to fall back to, and the
1322
+ # slider JS needs this element's id to keep existing across every frame
1323
+ # it scrubs to (it rewrites `d` in place, it doesn't recreate the node).
1324
+ if art.linestyle == "none":
1325
+ attrs = 'fill="none" stroke="none"'
1326
+ else:
1327
+ dash = _DASH.get(art.linestyle)
1328
+ attrs = (
1329
+ f'fill="none" stroke="{art.color}" stroke-width="{art.linewidth}" '
1330
+ f'stroke-linejoin="round" stroke-linecap="round"'
1331
+ )
1332
+ if dash:
1333
+ attrs += f' stroke-dasharray="{dash}"'
1334
+ if art.alpha < 1:
1335
+ attrs += f' stroke-opacity="{art.alpha}"'
1336
+ label = _esc(art.label) if art.label else ""
1337
+ body.append(
1338
+ f'<path class="plotpress-series plotpress-frameline" id="s{ai}_{k}" '
1339
+ f'data-label="{label}" d="{d}" {attrs}/>'
1340
+ )
1341
+
1342
+
1343
+ def _render_framequadmesh(art: FrameQuadMesh, tr, ai, k, body):
1344
+ """Render frame 0 statically; the slider JS swaps ``href`` for other frames.
1345
+
1346
+ Unlike a frame line's ``d``, the image's ``x``/``y``/``width``/``height``
1347
+ never need to be recomputed on scrub: every frame shares one X/Y grid, so
1348
+ only the pixel content -- which frame's colours -- changes.
1349
+ """
1350
+ prims = artist_to_prims(art.frame_mesh(0), tr, ai, k)
1351
+ if not prims:
1352
+ return
1353
+ p = prims[0]
1354
+ uri = png_data_uri(p.rgba)
1355
+ label = _esc(art.label) if art.label else ""
1356
+ body.append(
1357
+ f'<image class="plotpress-series plotpress-framemesh" id="s{ai}_{k}" '
1358
+ f'data-label="{label}" x="{_fmt(p.x)}" y="{_fmt(p.y)}" '
1359
+ f'width="{_fmt(p.w)}" height="{_fmt(p.h)}" preserveAspectRatio="none" '
1360
+ f'style="image-rendering:pixelated" href="{uri}"/>'
1361
+ )
1362
+
1363
+
1364
+ def frame_data(fig, max_mesh_cells=250000):
1365
+ """Per-axes slider-frame data for JS to redraw on scrub: all frames' x/Y
1366
+ for a line, or every frame's rendered image (for JS to swap in) plus its
1367
+ z grid (for picking) for a mesh.
1368
+
1369
+ A mesh's z grid is block-averaged down to ``max_mesh_cells`` the same
1370
+ way :func:`pick_data`'s does when it's over the cap -- see that
1371
+ function's own docstring, and :func:`_downsample_grid`, for exactly
1372
+ what a downsampled pick costs. Every frame shares one grid, so whether
1373
+ downsampling happened is identical frame to frame; the ``UserWarning``
1374
+ that names it fires once per animated mesh here, not once per frame.
1375
+ """
1376
+ downsampled = [] # see the matching list in pick_data() above
1377
+ frames = {}
1378
+ for i, ax in enumerate(fig.axes):
1379
+ if ax._is_colorbar:
1380
+ continue
1381
+ entries = []
1382
+ tr = None # built lazily: only a FrameQuadMesh needs it, and every
1383
+ # frame of one shares an X/Y grid, so once per axes suffices.
1384
+ for k, art in enumerate(ax.artists):
1385
+ if isinstance(art, FrameLine2D):
1386
+ shared = art.X.ndim == 1
1387
+ entry = {"id": f"s{i}_{k}", "unit": art.slider_unit,
1388
+ "shared_x": bool(shared)}
1389
+ if shared:
1390
+ entry["x"] = _round_list(art.X)
1391
+ else:
1392
+ entry["x"] = [_round_list(art.X[f]) for f in range(art.n_frames)]
1393
+ entry["Y"] = [_round_list(art.Y[f]) for f in range(art.n_frames)]
1394
+ entries.append(entry)
1395
+ elif isinstance(art, FrameQuadMesh):
1396
+ if tr is None:
1397
+ W, H = fig.figsize[0] * fig.style.dpi, fig.figsize[1] * fig.style.dpi
1398
+ (xmin, xmax), (ymin, ymax) = ax._resolved_limits()
1399
+ px_left, px_top, px_w, px_h = _effective_rect(
1400
+ ax, *_pixel_rect(ax, W, H), (xmin, xmax), (ymin, ymax))
1401
+ xlim_t = (xmax, xmin) if ax._xinverted else (xmin, xmax)
1402
+ ylim_t = (ymax, ymin) if ax._yinverted else (ymin, ymax)
1403
+ tr = LinearTransform(xlim_t, ylim_t, (px_left, px_top, px_w, px_h),
1404
+ xscale=ax._xscale, yscale=ax._yscale)
1405
+ hrefs, zs, geom = [], [], None
1406
+ for f in range(art.n_frames):
1407
+ fm = art.frame_mesh(f)
1408
+ mesh_prims = artist_to_prims(fm, tr, i, k)
1409
+ hrefs.append(png_data_uri(mesh_prims[0].rgba) if mesh_prims else "")
1410
+ # Every frame shares one X/Y grid (see FrameQuadMesh's own
1411
+ # docstring), so the geometry half of the pick entry --
1412
+ # extent/shape/edges or curvilinear centers -- is identical
1413
+ # frame to frame; keep it once instead of repeating it
1414
+ # n_frames times, and collect only the part that actually
1415
+ # varies (z) into its own per-frame list.
1416
+ entry = _quadmesh_pick_entry(fm, max_mesh_cells, precision=6)
1417
+ zs.append(entry.pop("z"))
1418
+ if geom is None:
1419
+ # Every frame shares one grid, so downsampling (if
1420
+ # any) is identical frame to frame too -- check once,
1421
+ # on frame 0, rather than once per frame.
1422
+ orig_shape = entry.pop("_downsampled_from", None)
1423
+ if orig_shape is not None:
1424
+ downsampled.append(
1425
+ (i, ax.get_title() or None, orig_shape, tuple(entry["shape"])))
1426
+ geom = entry
1427
+ mesh_entry = {"id": f"s{i}_{k}", "unit": art.slider_unit,
1428
+ "hrefs": hrefs, "z": zs}
1429
+ if geom is not None:
1430
+ mesh_entry.update(geom)
1431
+ entries.append(mesh_entry)
1432
+ if entries:
1433
+ frames[i] = entries
1434
+ if downsampled:
1435
+ _warn_downsampled(downsampled, max_mesh_cells)
1436
+ return frames
1437
+
1438
+
1439
+ def _render_mesh_vector(art: QuadMesh, tr, ai, k, body):
1440
+ """One ``<rect>`` per cell, in exact data-edge positions -- no resampling.
1441
+
1442
+ Reached only when ``art.vectorized`` (see ``artists._resolve_mesh_render``),
1443
+ i.e. a non-uniform, non-curvilinear grid small enough that per-cell rects
1444
+ stay cheap. Unlike the raster path, there is no pixel grid here for a thin
1445
+ cell to fall between: every cell gets its own rect, at its own true edges,
1446
+ however narrow. A NaN cell (alpha 0) is simply skipped rather than drawn
1447
+ transparent -- an absent rect and a fully transparent one look identical
1448
+ but the absent one costs nothing.
1449
+
1450
+ Coordinates and colors are batch-formatted with vectorized ``numpy.char``
1451
+ calls (the same approach ``_seg_to_path`` uses for a huge line's path
1452
+ string) rather than one Python format call per cell -- up to
1453
+ ``_VECTOR_CELL_LIMIT`` of them.
1454
+ """
1455
+ xe, ye = art.cell_edges()
1456
+ xpix = tr.x(xe)
1457
+ ypix = tr.y(ye)
1458
+ rgba = apply_colormap(art.C, art.lut, art.norm)
1459
+ ny, nx = art.C.shape
1460
+ label = _esc(art.label) if art.label else ""
1461
+ op = f' fill-opacity="{art.alpha}"' if art.alpha < 1 else ""
1462
+
1463
+ x0 = np.minimum(xpix[:-1], xpix[1:])
1464
+ w = np.abs(np.diff(xpix))
1465
+ y0 = np.minimum(ypix[:-1], ypix[1:])
1466
+ h = np.abs(np.diff(ypix))
1467
+ # Broadcast each axis's per-cell geometry across the other axis, then
1468
+ # flatten row-major (y, x) to match rgba's own (ny, nx, 4) layout.
1469
+ X0 = np.broadcast_to(x0, (ny, nx)).ravel()
1470
+ W = np.broadcast_to(w, (ny, nx)).ravel()
1471
+ Y0 = np.broadcast_to(y0[:, None], (ny, nx)).ravel()
1472
+ H = np.broadcast_to(h[:, None], (ny, nx)).ravel()
1473
+
1474
+ visible = (rgba[..., 3] != 0).ravel()
1475
+ if not visible.any():
1476
+ return
1477
+ X0, Y0, W, H = X0[visible], Y0[visible], W[visible], H[visible]
1478
+ rgb = rgba[..., :3].reshape(-1, 3)[visible]
1479
+
1480
+ fmt = lambda v: np.char.mod("%.2f", v) # noqa: E731 -- local, used 4x below
1481
+ hexcolor = np.char.add(np.char.add(np.char.add(
1482
+ "#", np.char.mod("%02x", rgb[:, 0].astype(int))),
1483
+ np.char.mod("%02x", rgb[:, 1].astype(int))),
1484
+ np.char.mod("%02x", rgb[:, 2].astype(int)))
1485
+
1486
+ rects = '<rect x="'
1487
+ for piece in (fmt(X0), '" y="', fmt(Y0), '" width="', fmt(W),
1488
+ '" height="', fmt(H), '" fill="', hexcolor, '"/>'):
1489
+ rects = np.char.add(rects, piece)
1490
+
1491
+ body.append(
1492
+ f'<g class="plotpress-series" id="s{ai}_{k}" data-label="{label}"{op}>'
1493
+ f'{"".join(rects.tolist())}</g>'
1494
+ )
1495
+
1496
+
1497
+ def _render_bars(bars: Bars, tr, ai, k, body):
1498
+ label = _esc(bars.label) if bars.label else ""
1499
+ op = f' fill-opacity="{bars.alpha}"' if bars.alpha < 1 else ""
1500
+ edge = (f' stroke="{bars.edgecolor}" stroke-width="{bars.linewidth}"'
1501
+ if bars.edgecolor else "")
1502
+ rects = []
1503
+ for i in range(len(bars.pos)):
1504
+ p, ln, th, ba = bars.pos[i], bars.length[i], bars.thickness[i], bars.base[i]
1505
+ if bars.orientation == "vertical":
1506
+ x0, x1 = tr.x(p - th / 2), tr.x(p + th / 2)
1507
+ y0, y1 = tr.y_base(ba), tr.y_base(ba + ln)
1508
+ else:
1509
+ y0, y1 = tr.y(p - th / 2), tr.y(p + th / 2)
1510
+ x0, x1 = tr.x_base(ba), tr.x_base(ba + ln)
1511
+ rx, ry = min(x0, x1), min(y0, y1)
1512
+ rects.append(
1513
+ f'<rect x="{_fmt(rx)}" y="{_fmt(ry)}" width="{_fmt(abs(x1 - x0))}" '
1514
+ f'height="{_fmt(abs(y1 - y0))}" fill="{bars.colors[i]}"{edge}/>'
1515
+ )
1516
+ body.append(
1517
+ f'<g class="plotpress-series" id="s{ai}_{k}" data-label="{label}"{op}>'
1518
+ f'{"".join(rects)}</g>'
1519
+ )
1520
+
1521
+
1522
+ def _render_stem(stem: Stem, tr, st, fig, body):
1523
+ if stem.x.size == 0:
1524
+ return
1525
+ xb = tr.x(stem.x)
1526
+ yb = tr.y(stem.y)
1527
+ y0 = tr.y_base(stem.baseline)
1528
+ lines = [f'<line x1="{_fmt(x)}" y1="{_fmt(y0)}" x2="{_fmt(x)}" y2="{_fmt(y)}"/>'
1529
+ for x, y in zip(xb, yb)]
1530
+ body.append(
1531
+ f'<g stroke="{stem.linecolor}" stroke-width="1.2">{"".join(lines)}</g>'
1532
+ )
1533
+ x0, x1 = tr.x(stem.x.min()), tr.x(stem.x.max())
1534
+ body.append(
1535
+ f'<line x1="{_fmt(x0)}" y1="{_fmt(y0)}" x2="{_fmt(x1)}" y2="{_fmt(y0)}" '
1536
+ f'stroke="{st.spine_color}" stroke-width="0.8"/>'
1537
+ )
1538
+ r = st.marker_size / 2.0 * st.dpi / 72.0
1539
+ dots = [f'<circle cx="{_fmt(x)}" cy="{_fmt(y)}" r="{_fmt(r)}" fill="{stem.markercolor}"/>'
1540
+ for x, y in zip(xb, yb)]
1541
+ body.append("".join(dots))
1542
+
1543
+
1544
+ def _render_errorbar(eb: ErrorBar, tr, st, fig, body):
1545
+ xb = tr.x(eb.x)
1546
+ yb = tr.y(eb.y)
1547
+ if eb.linestyle and eb.linestyle != "none":
1548
+ d = _line_path_d(np.column_stack([xb, yb]))
1549
+ if d:
1550
+ body.append(
1551
+ f'<path fill="none" stroke="{eb.color}" '
1552
+ f'stroke-width="{eb.linewidth}" d="{d}"/>'
1553
+ )
1554
+ whiskers, caps, cap = [], [], eb.capsize
1555
+ if eb.yerr is not None:
1556
+ # An error bar reaching below zero on a log axis has no pixel to land
1557
+ # on; clamp the whisker to the frame rather than emitting NaN, which
1558
+ # drops the whole bar and quietly understates the uncertainty.
1559
+ ylo, yhi = tr.y_base(eb.y - eb.yerr), tr.y_base(eb.y + eb.yerr)
1560
+ for x, a, b in zip(xb, ylo, yhi):
1561
+ whiskers.append(f'<line x1="{_fmt(x)}" y1="{_fmt(a)}" x2="{_fmt(x)}" y2="{_fmt(b)}"/>')
1562
+ caps.append(f'<line x1="{_fmt(x - cap)}" y1="{_fmt(a)}" x2="{_fmt(x + cap)}" y2="{_fmt(a)}"/>')
1563
+ caps.append(f'<line x1="{_fmt(x - cap)}" y1="{_fmt(b)}" x2="{_fmt(x + cap)}" y2="{_fmt(b)}"/>')
1564
+ if eb.xerr is not None:
1565
+ xlo, xhi = tr.x_base(eb.x - eb.xerr), tr.x_base(eb.x + eb.xerr)
1566
+ for y, a, b in zip(yb, xlo, xhi):
1567
+ whiskers.append(f'<line x1="{_fmt(a)}" y1="{_fmt(y)}" x2="{_fmt(b)}" y2="{_fmt(y)}"/>')
1568
+ caps.append(f'<line x1="{_fmt(a)}" y1="{_fmt(y - cap)}" x2="{_fmt(a)}" y2="{_fmt(y + cap)}"/>')
1569
+ caps.append(f'<line x1="{_fmt(b)}" y1="{_fmt(y - cap)}" x2="{_fmt(b)}" y2="{_fmt(y + cap)}"/>')
1570
+ if whiskers:
1571
+ body.append(f'<g stroke="{eb.ecolor}" stroke-width="{_fmt(eb.elinewidth)}">{"".join(whiskers)}</g>')
1572
+ if caps:
1573
+ body.append(f'<g stroke="{eb.ecolor}" stroke-width="{_fmt(eb.capthick)}">{"".join(caps)}</g>')
1574
+ r = eb.markersize / 2.0 * st.dpi / 72.0
1575
+ # Skip points that do not map to a pixel -- a value at or below zero on a
1576
+ # log axis, most often. Emitting cx/cy="nan" produces invalid SVG that some
1577
+ # renderers reject outright rather than merely skipping the one marker.
1578
+ dots = [f'<circle cx="{_fmt(x)}" cy="{_fmt(y)}" r="{_fmt(r)}" fill="{eb.color}"/>'
1579
+ for x, y in zip(xb, yb) if np.isfinite(x) and np.isfinite(y)]
1580
+ body.append("".join(dots))
1581
+
1582
+
1583
+ def _render_pie(pie: Pie, tr, body):
1584
+ """Draw wedges in axes-pixel space so the pie stays circular."""
1585
+ cx = tr.px_left + tr.px_w / 2.0
1586
+ cy = tr.px_top + tr.px_h / 2.0
1587
+ R = 0.42 * min(tr.px_w, tr.px_h) * pie.radius
1588
+ ang = math.radians(pie.startangle)
1589
+ op = f' fill-opacity="{pie.alpha}"' if pie.alpha < 1 else ""
1590
+ parts = []
1591
+ labels = []
1592
+ for i, frac in enumerate(pie.fracs):
1593
+ sweep = frac * 2 * math.pi
1594
+ a0, a1 = ang, ang - sweep # clockwise, matplotlib default
1595
+ x0, y0 = cx + R * math.cos(a0), cy - R * math.sin(a0)
1596
+ x1, y1 = cx + R * math.cos(a1), cy - R * math.sin(a1)
1597
+ large = 1 if sweep > math.pi else 0
1598
+ parts.append(
1599
+ f'<path d="M{_fmt(cx)},{_fmt(cy)} L{_fmt(x0)},{_fmt(y0)} '
1600
+ f'A{_fmt(R)},{_fmt(R)} 0 {large} 1 {_fmt(x1)},{_fmt(y1)} Z" '
1601
+ f'fill="{pie.colors[i]}" stroke="#ffffff" stroke-width="1.5"{op}/>'
1602
+ )
1603
+ am = (a0 + a1) / 2.0
1604
+ if pie.labels is not None:
1605
+ lx, ly = cx + 1.15 * R * math.cos(am), cy - 1.15 * R * math.sin(am)
1606
+ anchor = "start" if math.cos(am) >= 0 else "end"
1607
+ labels.append(
1608
+ f'<text x="{_fmt(lx)}" y="{_fmt(ly)}" text-anchor="{anchor}" '
1609
+ f'font-size="10" dominant-baseline="middle">{_esc(pie.labels[i])}</text>'
1610
+ )
1611
+ pct = pie.pct_text(frac)
1612
+ if pct is not None:
1613
+ px, py = cx + 0.6 * R * math.cos(am), cy - 0.6 * R * math.sin(am)
1614
+ labels.append(
1615
+ f'<text x="{_fmt(px)}" y="{_fmt(py)}" text-anchor="middle" '
1616
+ f'font-size="10" dominant-baseline="middle">{_esc(pct)}</text>'
1617
+ )
1618
+ ang = a1
1619
+ body.append("".join(parts) + "".join(labels))
1620
+
1621
+
1622
+ _HA = {"left": "start", "center": "middle", "right": "end"}
1623
+ _VA = {"baseline": "alphabetic", "bottom": "text-after-edge",
1624
+ "center": "central", "top": "hanging"}
1625
+
1626
+
1627
+ #: How far a text anchor sits from the box corner, as a fraction of the box.
1628
+ _HA_FRAC = {"left": 0.0, "center": -0.5, "right": -1.0}
1629
+ _VA_FRAC = {"baseline": -0.78, "bottom": -1.0, "center": -0.5, "top": 0.0}
1630
+
1631
+ #: Per-line spacing for multi-line text -- text_box()'s block-height math and
1632
+ #: _text_svg()'s tspan stepping both key off this; keep the two in sync.
1633
+ _LINE_HEIGHT_FRAC = 1.25
1634
+
1635
+
1636
+ def text_box(x, y, text, size, ha, va, st, bold=False, italic=False):
1637
+ """Pixel bounding box ``(x0, y0, x1, y1)`` of a label drawn at ``(x, y)``.
1638
+
1639
+ Measured with the same font metrics layout uses, so the box the leader
1640
+ attaches to is the box the glyphs actually occupy.
1641
+ """
1642
+ lines = text.split("\n")
1643
+ w = max((st.text_width(ln, size, bold=bold, italic=italic) for ln in lines),
1644
+ default=0.0)
1645
+ h = size * _LINE_HEIGHT_FRAC * len(lines)
1646
+ x0 = x + _HA_FRAC.get(ha, 0.0) * w
1647
+ y0 = y + _VA_FRAC.get(va, -0.78) * h
1648
+ return x0, y0, x0 + w, y0 + h
1649
+
1650
+
1651
+ def leader_anchor(box, target, pad=3.0):
1652
+ """Where a leader line should meet a label box on its way to ``target``.
1653
+
1654
+ Edge midpoints first, corners only as a fallback: a line that arrives at the
1655
+ middle of the top edge reads as belonging to the whole label, while one that
1656
+ stops at the text anchor -- which is what happens without this -- is drawn
1657
+ straight through the words it is pointing away from.
1658
+ """
1659
+ x0, y0, x1, y1 = box
1660
+ cx, cy = (x0 + x1) / 2.0, (y0 + y1) / 2.0
1661
+ tx, ty = target
1662
+ edges = [((cx, y0 - pad), 1.0), # top centre
1663
+ ((cx, y1 + pad), 1.0), # bottom centre
1664
+ ((x0 - pad, cy), 1.0), # left centre
1665
+ ((x1 + pad, cy), 1.0)] # right centre
1666
+ corners = [((x0 - pad, y0 - pad), 1.25), ((x1 + pad, y0 - pad), 1.25),
1667
+ ((x0 - pad, y1 + pad), 1.25), ((x1 + pad, y1 + pad), 1.25)]
1668
+ # The weight makes a corner win only when it is clearly nearer, so a target
1669
+ # roughly above the label still gets the top-centre attachment.
1670
+ return min(edges + corners,
1671
+ key=lambda c: math.hypot(c[0][0] - tx, c[0][1] - ty) * c[1])[0]
1672
+
1673
+
1674
+ #: How far a multi-line block's *first* line needs shifting from the anchor
1675
+ #: point so the block as a whole (not just line one) lands where ``va`` says --
1676
+ #: "top" already puts the block's top at the anchor via dominant-baseline
1677
+ #: alone (line one just hangs from it, rest cascade below), and "baseline"
1678
+ #: has no natural multi-line convention beyond "first line sits at the
1679
+ #: anchor", so both are 0. "bottom"/"center" need the first line pulled up so
1680
+ #: the *last* line's bottom, or the block's midpoint, lands on the anchor.
1681
+ def _multiline_shift(va, n, line_height):
1682
+ if va == "bottom":
1683
+ return (n - 1) * line_height
1684
+ if va == "center":
1685
+ return (n - 1) * line_height / 2.0
1686
+ return 0.0
1687
+
1688
+
1689
+ def _text_svg(x, y, text, color, size, ha, va, rotation=0.0, outline=None, alpha=1.0,
1690
+ bold=False, italic=False):
1691
+ anchor = _HA.get(ha, "start")
1692
+ baseline = _VA.get(va, "alphabetic")
1693
+ rot = (f' transform="rotate({_fmt(-rotation)} {_fmt(x)} {_fmt(y)})"'
1694
+ if rotation else "")
1695
+ # paint-order puts the halo stroke *under* the fill, so the glyph keeps its
1696
+ # shape and only gains a rim. Without it the stroke thickens every letter.
1697
+ halo = ("" if not outline else
1698
+ f' stroke="{outline}" stroke-width="{_fmt(size * 0.30)}" '
1699
+ 'stroke-linejoin="round" paint-order="stroke"')
1700
+ op = f' fill-opacity="{alpha}"' if alpha < 1 else ""
1701
+ weight = ' font-weight="bold"' if bold else ""
1702
+ style = ' font-style="italic"' if italic else ""
1703
+ lines = text.split("\n")
1704
+ if len(lines) == 1:
1705
+ return (f'<text x="{_fmt(x)}" y="{_fmt(y)}" text-anchor="{anchor}" '
1706
+ f'dominant-baseline="{baseline}" font-size="{size}" '
1707
+ f'fill="{color}"{halo}{op}{weight}{style}{rot}>{_esc(text)}</text>')
1708
+ # Multi-line: dominant-baseline positions line one exactly as it would a
1709
+ # single line, then each further line is a sibling tspan stepped down by
1710
+ # one line height -- an explicit x= on every tspan starts a fresh "text
1711
+ # chunk" so text-anchor re-centers/re-rights each line independently
1712
+ # (matplotlib's default multialignment, which follows ha).
1713
+ line_height = size * _LINE_HEIGHT_FRAC
1714
+ y0 = y - _multiline_shift(va, len(lines), line_height)
1715
+ def _tspan(i, ln):
1716
+ # A backslash inside an f-string's {} expression needs 3.12+ (PEP
1717
+ # 701); this project supports 3.9+, so the nested dy="..." literal
1718
+ # is built as a plain variable first rather than escaped inline.
1719
+ dy = "" if i == 0 else f' dy="{_fmt(line_height)}"'
1720
+ return f'<tspan x="{_fmt(x)}"{dy}>{_esc(ln)}</tspan>'
1721
+
1722
+ tspans = "".join(_tspan(i, ln) for i, ln in enumerate(lines))
1723
+ return (f'<text x="{_fmt(x)}" y="{_fmt(y0)}" text-anchor="{anchor}" '
1724
+ f'dominant-baseline="{baseline}" font-size="{size}" '
1725
+ f'fill="{color}"{halo}{op}{weight}{style}{rot}>{tspans}</text>')
1726
+
1727
+
1728
+ def _bbox_pad(box, bbox):
1729
+ """Expand a tight ``text_box()`` rect by ``bbox['pad']``."""
1730
+ x0, y0, x1, y1 = box
1731
+ pad = bbox["pad"]
1732
+ return x0 - pad, y0 - pad, x1 + pad, y1 + pad
1733
+
1734
+
1735
+ def _bbox_svg(padded_box, bbox):
1736
+ """The ``<rect>`` a ``bbox=`` dict draws behind a label.
1737
+
1738
+ ``padded_box`` is already expanded by ``pad`` (see :func:`_bbox_pad`) --
1739
+ callers that also need a leader-line anchor point (``annotate()``) use the
1740
+ same padded rect for both, so the arrow visibly touches the box instead of
1741
+ stopping short of it.
1742
+ """
1743
+ x0, y0, x1, y1 = padded_box
1744
+ rx = min(8.0, (x1 - x0) / 2.0, (y1 - y0) / 2.0) if bbox["boxstyle"] == "round" else 0.0
1745
+ edge = (f' stroke="{bbox["edgecolor"]}" stroke-width="{bbox["linewidth"]}"'
1746
+ if bbox["edgecolor"] not in (None, "none") else "")
1747
+ op = f' fill-opacity="{bbox["alpha"]}"' if bbox["alpha"] < 1 else ""
1748
+ return (f'<rect x="{_fmt(x0)}" y="{_fmt(y0)}" width="{_fmt(x1 - x0)}" '
1749
+ f'height="{_fmt(y1 - y0)}" rx="{_fmt(rx)}" fill="{bbox["facecolor"]}"'
1750
+ f'{op}{edge}/>')
1751
+
1752
+
1753
+ def _axes_fraction_xy(tr, fx, fy):
1754
+ """``transform=ax.transAxes`` fraction -> pixels, independent of data limits.
1755
+
1756
+ ``(0, 0)`` is the axes' bottom-left, ``(1, 1)`` its top-right -- matplotlib's
1757
+ own convention -- mapped straight off the axes' own pixel rect rather than
1758
+ through the data-space affine, so it holds regardless of xlim/ylim/scale.
1759
+ """
1760
+ return tr.px_left + fx * tr.px_w, tr.px_top + (1.0 - fy) * tr.px_h
1761
+
1762
+
1763
+ def _render_table(t: Table, tr, st, body):
1764
+ """``ax.table()`` -- a grid of cells at an axes-fraction ``bbox``, in the
1765
+ same pixel space :func:`_axes_fraction_xy` maps text/annotate labels
1766
+ through (only the corners are needed here, not a single point)."""
1767
+ x0, y0, w, h = t.bbox
1768
+ left, bottom = _axes_fraction_xy(tr, x0, y0)
1769
+ right, top = _axes_fraction_xy(tr, x0 + w, y0 + h)
1770
+ rect_w, rect_h = right - left, bottom - top
1771
+
1772
+ has_col_header = t.col_labels is not None
1773
+ has_row_header = t.row_labels is not None
1774
+ body_rows = t.cell_text
1775
+ n_data_rows = len(body_rows)
1776
+ n_data_cols = len(body_rows[0]) if body_rows else (len(t.col_labels) if has_col_header else 0)
1777
+ n_rows = n_data_rows + (1 if has_col_header else 0)
1778
+ n_cols = n_data_cols + (1 if has_row_header else 0)
1779
+ if n_rows == 0 or n_cols == 0:
1780
+ return
1781
+ cell_w, cell_h = rect_w / n_cols, rect_h / n_rows
1782
+ fs = t.fontsize if t.fontsize is not None else st.tick_label_size
1783
+ op = f' fill-opacity="{t.alpha}"' if t.alpha < 1 else ""
1784
+ row0 = 1 if has_col_header else 0
1785
+ col0 = 1 if has_row_header else 0
1786
+
1787
+ def cell_fill(r, c):
1788
+ if has_col_header and r == 0 and c >= col0 and t.col_colors:
1789
+ i = c - col0
1790
+ if i < len(t.col_colors):
1791
+ return t.col_colors[i]
1792
+ if has_row_header and c == 0 and r >= row0 and t.row_colors:
1793
+ i = r - row0
1794
+ if i < len(t.row_colors):
1795
+ return t.row_colors[i]
1796
+ if r >= row0 and c >= col0 and t.cell_colors:
1797
+ ri, ci = r - row0, c - col0
1798
+ if ri < len(t.cell_colors) and ci < len(t.cell_colors[ri]):
1799
+ return t.cell_colors[ri][ci]
1800
+ return "#ffffff"
1801
+
1802
+ def cell_text(r, c):
1803
+ if has_col_header and r == 0:
1804
+ return "" if (c == 0 and has_row_header) else t.col_labels[c - col0]
1805
+ if has_row_header and c == 0:
1806
+ return t.row_labels[r - row0]
1807
+ return body_rows[r - row0][c - col0]
1808
+
1809
+ for r in range(n_rows):
1810
+ for c in range(n_cols):
1811
+ cx0, cy0 = left + c * cell_w, top + r * cell_h
1812
+ body.append(
1813
+ f'<rect x="{_fmt(cx0)}" y="{_fmt(cy0)}" width="{_fmt(cell_w)}" '
1814
+ f'height="{_fmt(cell_h)}" fill="{cell_fill(r, c)}"{op} '
1815
+ f'stroke="#888888" stroke-width="0.75"/>')
1816
+ text = cell_text(r, c)
1817
+ if text:
1818
+ tx, ty = cx0 + cell_w / 2.0, cy0 + cell_h / 2.0
1819
+ weight = ' font-weight="bold"' if (r < row0 or c < col0) else ""
1820
+ body.append(
1821
+ f'<text x="{_fmt(tx)}" y="{_fmt(ty)}" text-anchor="middle" '
1822
+ f'dominant-baseline="central" font-size="{fs}" '
1823
+ f'fill="{st.text_color}"{weight}>{_esc(text)}</text>')
1824
+
1825
+
1826
+ def _cscale_open(index, x, y):
1827
+ """Open a counter-scale group: a data-anchored label's glyphs/box must
1828
+ stay a constant screen size under a per-axes interactive zoom (see
1829
+ _interactive.py's relayoutTextCounterScale) the same way a title, tick
1830
+ label, or point-pick pin already does -- unlike a marker (whose size
1831
+ represents a footprint *on the data*, deliberately scaling with the
1832
+ axis -- see the marker-scaling fix), a text label exists to be read, so
1833
+ its legibility shouldn't depend on how far zoomed in the reader is.
1834
+
1835
+ A bare CSS transform on the label alone can't do this: only *client-side
1836
+ JS*, recomputing the counter-scale on every zoom from the live
1837
+ zoomAffine(), can -- the group starts with no transform (identity) since
1838
+ nothing has zoomed yet at render time. ``(x, y)`` is the anchor JS holds
1839
+ fixed while everything else around it counter-scales; passing the
1840
+ label's own text/box anchor keeps that point pinned exactly where plain
1841
+ ancestor scaling would already put it, so only the *size* around it
1842
+ changes, not its tracked position. Only for a *data*-anchored label
1843
+ (never call this for axes_fraction text -- already immune, being
1844
+ outside the zoom group's scaling entirely).
1845
+ """
1846
+ return (f'<g class="plotpress-cscale" data-axes="{index}" '
1847
+ f'data-x0="{_fmt(x)}" data-y0="{_fmt(y)}">')
1848
+
1849
+
1850
+ def _render_text(t: Text, tr, st, body, index=None):
1851
+ if t.axes_fraction:
1852
+ x, y = _axes_fraction_xy(tr, t.x, t.y)
1853
+ else:
1854
+ x, y = float(tr.x(t.x)), float(tr.y(t.y))
1855
+ cscale = index is not None and not t.axes_fraction
1856
+ if cscale:
1857
+ body.append(_cscale_open(index, x, y))
1858
+ # A boxed label is a "text box" the toolbar's Hide Annotations toggle
1859
+ # (see _interactive.py's .plotpress-textbox rule) can hide alongside
1860
+ # every Annotation note -- a plain unboxed label has no comparable "hide
1861
+ # the callout" reading, so it stays outside the group and always shows.
1862
+ if t.bbox is not None:
1863
+ body.append('<g class="plotpress-textbox">')
1864
+ box = _bbox_pad(text_box(x, y, t.text, t.size, t.ha, t.va, st,
1865
+ bold=t.bold, italic=t.italic), t.bbox)
1866
+ body.append(_bbox_svg(box, t.bbox))
1867
+ body.append(_text_svg(x, y, t.text, t.color, t.size, t.ha, t.va, t.rotation,
1868
+ t.outline, t.alpha, bold=t.bold, italic=t.italic))
1869
+ if t.bbox is not None:
1870
+ body.append("</g>")
1871
+ if cscale:
1872
+ body.append("</g>")
1873
+
1874
+
1875
+ def _render_annotation(an: Annotation, tr, st, body, index=None):
1876
+ if an.axes_fraction:
1877
+ tx, ty = _axes_fraction_xy(tr, an.xytext[0], an.xytext[1])
1878
+ else:
1879
+ tx, ty = float(tr.x(an.xytext[0])), float(tr.y(an.xytext[1]))
1880
+ box = text_box(tx, ty, an.text, an.size, an.ha, an.va, st,
1881
+ bold=an.bold, italic=an.italic)
1882
+ if an.bbox is not None:
1883
+ box = _bbox_pad(box, an.bbox) # the leader below anchors to this, padded, edge
1884
+ if an.arrowprops is not None:
1885
+ px, py = float(tr.x(an.xy[0])), float(tr.y(an.xy[1]))
1886
+ arrow_color = to_hex(an.arrowprops.get("color", an.color)
1887
+ if isinstance(an.arrowprops, dict) else an.color)
1888
+ arrow_alpha = (an.arrowprops.get("alpha", 1.0)
1889
+ if isinstance(an.arrowprops, dict) else 1.0)
1890
+ # Start the leader at the edge of the text (or bbox) nearest the
1891
+ # target, not at the text anchor -- from the anchor the line sets off
1892
+ # across its own label whenever the target is up and to the left of it.
1893
+ # Left outside the counter-scale group below on purpose: the leader
1894
+ # tracks the *data* point `xy` at one end, which should scale with a
1895
+ # zoom same as any other data-anchored geometry, and matching the box
1896
+ # exactly at the other end after a large zoom is a minor, accepted
1897
+ # cosmetic gap next to the alternative (a giant or unreadable label).
1898
+ sx, sy = leader_anchor(box, (px, py))
1899
+ ang = math.atan2(py - sy, px - sx)
1900
+ hl = 7.0
1901
+ h1 = (px - hl * math.cos(ang - 0.4), py - hl * math.sin(ang - 0.4))
1902
+ h2 = (px - hl * math.cos(ang + 0.4), py - hl * math.sin(ang + 0.4))
1903
+ op = f' stroke-opacity="{arrow_alpha}"' if arrow_alpha < 1 else ""
1904
+ body.append(
1905
+ f'<path d="M{_fmt(sx)},{_fmt(sy)} L{_fmt(px)},{_fmt(py)} '
1906
+ f'M{_fmt(px)},{_fmt(py)} L{_fmt(h1[0])},{_fmt(h1[1])} '
1907
+ f'M{_fmt(px)},{_fmt(py)} L{_fmt(h2[0])},{_fmt(h2[1])}" '
1908
+ f'fill="none" stroke="{arrow_color}" stroke-width="1.2"{op}/>'
1909
+ )
1910
+ cscale = index is not None and not an.axes_fraction
1911
+ if cscale:
1912
+ body.append(_cscale_open(index, tx, ty))
1913
+ # See _render_text: a boxed callout -- box and text together -- is what
1914
+ # Hide Annotations can toggle off.
1915
+ if an.bbox is not None:
1916
+ body.append('<g class="plotpress-textbox">')
1917
+ body.append(_bbox_svg(box, an.bbox))
1918
+ body.append(_text_svg(tx, ty, an.text, an.color, an.size, an.ha, an.va,
1919
+ 0.0, an.outline, an.alpha, bold=an.bold, italic=an.italic))
1920
+ if an.bbox is not None:
1921
+ body.append("</g>")
1922
+ if cscale:
1923
+ body.append("</g>")
1924
+
1925
+
1926
+ def _render_boxplot(bp: BoxPlot, tr, st, body):
1927
+ vert = bp.orientation == "vertical"
1928
+ parts = []
1929
+ r = st.marker_size / 2.0 * st.dpi / 72.0
1930
+ for pos, s in zip(bp.positions, bp.stats):
1931
+ c0, c1 = pos - bp.width / 2, pos + bp.width / 2
1932
+ if vert:
1933
+ x0, x1 = tr.x(c0), tr.x(c1)
1934
+ yq1, yq3, ym = tr.y(s["q1"]), tr.y(s["q3"]), tr.y(s["med"])
1935
+ ylo, yhi = tr.y(s["lo"]), tr.y(s["hi"])
1936
+ xc = tr.x(pos)
1937
+ parts.append(f'<rect x="{_fmt(min(x0, x1))}" y="{_fmt(min(yq1, yq3))}" '
1938
+ f'width="{_fmt(abs(x1 - x0))}" height="{_fmt(abs(yq3 - yq1))}" '
1939
+ f'fill="none" stroke="{bp.color}" stroke-width="1.3"/>')
1940
+ parts.append(f'<line x1="{_fmt(x0)}" y1="{_fmt(ym)}" x2="{_fmt(x1)}" y2="{_fmt(ym)}" stroke="{bp.color}" stroke-width="1.8"/>')
1941
+ parts.append(f'<line x1="{_fmt(xc)}" y1="{_fmt(yq1)}" x2="{_fmt(xc)}" y2="{_fmt(ylo)}" stroke="{bp.color}" stroke-width="1"/>')
1942
+ parts.append(f'<line x1="{_fmt(xc)}" y1="{_fmt(yq3)}" x2="{_fmt(xc)}" y2="{_fmt(yhi)}" stroke="{bp.color}" stroke-width="1"/>')
1943
+ parts.append(f'<line x1="{_fmt(x0)}" y1="{_fmt(ylo)}" x2="{_fmt(x1)}" y2="{_fmt(ylo)}" stroke="{bp.color}" stroke-width="1"/>')
1944
+ parts.append(f'<line x1="{_fmt(x0)}" y1="{_fmt(yhi)}" x2="{_fmt(x1)}" y2="{_fmt(yhi)}" stroke="{bp.color}" stroke-width="1"/>')
1945
+ for fx in s["fliers"]:
1946
+ parts.append(f'<circle cx="{_fmt(xc)}" cy="{_fmt(tr.y(fx))}" r="{_fmt(r)}" fill="none" stroke="{bp.color}"/>')
1947
+ else:
1948
+ y0, y1 = tr.y(c0), tr.y(c1)
1949
+ xq1, xq3, xm = tr.x(s["q1"]), tr.x(s["q3"]), tr.x(s["med"])
1950
+ xlo, xhi = tr.x(s["lo"]), tr.x(s["hi"])
1951
+ yc = tr.y(pos)
1952
+ parts.append(f'<rect x="{_fmt(min(xq1, xq3))}" y="{_fmt(min(y0, y1))}" '
1953
+ f'width="{_fmt(abs(xq3 - xq1))}" height="{_fmt(abs(y1 - y0))}" '
1954
+ f'fill="none" stroke="{bp.color}" stroke-width="1.3"/>')
1955
+ parts.append(f'<line x1="{_fmt(xm)}" y1="{_fmt(y0)}" x2="{_fmt(xm)}" y2="{_fmt(y1)}" stroke="{bp.color}" stroke-width="1.8"/>')
1956
+ parts.append(f'<line x1="{_fmt(xq1)}" y1="{_fmt(yc)}" x2="{_fmt(xlo)}" y2="{_fmt(yc)}" stroke="{bp.color}" stroke-width="1"/>')
1957
+ parts.append(f'<line x1="{_fmt(xq3)}" y1="{_fmt(yc)}" x2="{_fmt(xhi)}" y2="{_fmt(yc)}" stroke="{bp.color}" stroke-width="1"/>')
1958
+ parts.append(f'<line x1="{_fmt(xlo)}" y1="{_fmt(y0)}" x2="{_fmt(xlo)}" y2="{_fmt(y1)}" stroke="{bp.color}" stroke-width="1"/>')
1959
+ parts.append(f'<line x1="{_fmt(xhi)}" y1="{_fmt(y0)}" x2="{_fmt(xhi)}" y2="{_fmt(y1)}" stroke="{bp.color}" stroke-width="1"/>')
1960
+ for fx in s["fliers"]:
1961
+ parts.append(f'<circle cx="{_fmt(tr.x(fx))}" cy="{_fmt(yc)}" r="{_fmt(r)}" fill="none" stroke="{bp.color}"/>')
1962
+ # Every element here is stroke-only, so one wrapping group's stroke-opacity
1963
+ # covers the whole box-and-whiskers at once rather than repeating it per line.
1964
+ if bp.alpha < 1:
1965
+ body.append(f'<g stroke-opacity="{bp.alpha}">{"".join(parts)}</g>')
1966
+ else:
1967
+ body.append("".join(parts))
1968
+
1969
+
1970
+ def _render_violin(v: Violin, tr, body):
1971
+ vert = v.orientation == "vertical"
1972
+ parts = []
1973
+ for pos, grid, hw in zip(v.positions, v.grids, v.halfwidths):
1974
+ if vert:
1975
+ left = np.column_stack([tr.x(pos - hw), tr.y(grid)])
1976
+ right = np.column_stack([tr.x(pos + hw)[::-1], tr.y(grid)[::-1]])
1977
+ else:
1978
+ left = np.column_stack([tr.x(grid), tr.y(pos - hw)])
1979
+ right = np.column_stack([tr.x(grid)[::-1], tr.y(pos + hw)[::-1]])
1980
+ pts = np.vstack([left, right])
1981
+ coords = [f"{_fmt(px)},{_fmt(py)}" for px, py in pts]
1982
+ d = "M" + coords[0] + "".join("L" + c for c in coords[1:]) + "Z"
1983
+ parts.append(f'<path d="{d}" fill="{v.color}" fill-opacity="{v.alpha}" '
1984
+ f'stroke="{v.color}" stroke-width="1"/>')
1985
+ body.append("".join(parts))
1986
+
1987
+
1988
+ def _render_eventplot(ev: EventPlot, tr, body):
1989
+ horiz = ev.orientation == "horizontal"
1990
+ half = ev.linelength / 2.0
1991
+ lines = []
1992
+ for row, off in zip(ev.rows, ev.offsets):
1993
+ if horiz:
1994
+ y0, y1 = tr.y(off - half), tr.y(off + half)
1995
+ for e in row:
1996
+ x = tr.x(e)
1997
+ lines.append(f'<line x1="{_fmt(x)}" y1="{_fmt(y0)}" x2="{_fmt(x)}" y2="{_fmt(y1)}"/>')
1998
+ else:
1999
+ x0, x1 = tr.x(off - half), tr.x(off + half)
2000
+ for e in row:
2001
+ y = tr.y(e)
2002
+ lines.append(f'<line x1="{_fmt(x0)}" y1="{_fmt(y)}" x2="{_fmt(x1)}" y2="{_fmt(y)}"/>')
2003
+ op = f' stroke-opacity="{ev.alpha}"' if ev.alpha < 1 else ""
2004
+ body.append(f'<g stroke="{ev.color}" stroke-width="1.2"{op}>{"".join(lines)}</g>')
2005
+
2006
+
2007
+ def _render_quiver(q: Quiver, tr, body):
2008
+ tx, ty = q.tips()
2009
+ x0, y0 = tr.x(q.X), tr.y(q.Y)
2010
+ x1, y1 = tr.x(tx), tr.y(ty)
2011
+ hl = 5.0 # arrowhead length in px
2012
+ parts = []
2013
+ for bx, by, ex, ey in zip(x0, y0, x1, y1):
2014
+ ang = math.atan2(ey - by, ex - bx)
2015
+ h1 = (ex - hl * math.cos(ang - math.radians(25)),
2016
+ ey - hl * math.sin(ang - math.radians(25)))
2017
+ h2 = (ex - hl * math.cos(ang + math.radians(25)),
2018
+ ey - hl * math.sin(ang + math.radians(25)))
2019
+ parts.append(f'<path d="M{_fmt(bx)},{_fmt(by)} L{_fmt(ex)},{_fmt(ey)} '
2020
+ f'M{_fmt(ex)},{_fmt(ey)} L{_fmt(h1[0])},{_fmt(h1[1])} '
2021
+ f'M{_fmt(ex)},{_fmt(ey)} L{_fmt(h2[0])},{_fmt(h2[1])}"/>')
2022
+ op = f' stroke-opacity="{q.alpha}"' if q.alpha < 1 else ""
2023
+ body.append(f'<g fill="none" stroke="{q.color}" stroke-width="1.2" '
2024
+ f'stroke-linecap="round"{op}>{"".join(parts)}</g>')
2025
+
2026
+
2027
+ def _barb_geometry(cx, cy, angle, speed, L):
2028
+ """One wind barb's pixel-space geometry: ``(lines, polygons, calm)``.
2029
+
2030
+ ``lines`` is ``[(x0, y0, x1, y1), ...]`` -- the shaft plus its full/half
2031
+ ticks; ``polygons`` is ``[[(x, y), ...], ...]`` -- its 50-unit pennant
2032
+ triangles. ``speed`` rounds to the nearest 5 first, then decomposes into
2033
+ a pennant per 50, a full tick per 10, and a half tick for a remaining 5
2034
+ -- the usual meteorological convention. ``calm`` is true when that rounds
2035
+ to 0 (matplotlib draws a bare circle there instead of an empty shaft).
2036
+ ``angle`` is the shaft direction in screen-space radians (``atan2``
2037
+ convention); the barb is built shaft-along-+x in a local frame, ticks on
2038
+ the local +y side, then rotated by ``angle`` and placed at ``(cx, cy)``.
2039
+ """
2040
+ speed5 = round(speed / 5.0) * 5.0
2041
+ if speed5 <= 0:
2042
+ return [], [], True
2043
+ n_pennant = int(speed5 // 50)
2044
+ rem = speed5 - n_pennant * 50
2045
+ n_full = int(rem // 10)
2046
+ half = (rem - n_full * 10) >= 5
2047
+
2048
+ spacing = 0.16 * L
2049
+ tick_len = 0.38 * L
2050
+ ca, sa = math.cos(math.radians(60)), math.sin(math.radians(60))
2051
+
2052
+ local_lines = [(0.0, 0.0, L, 0.0)] # the shaft itself
2053
+ local_polys = []
2054
+ pos = L
2055
+ for _ in range(n_pennant):
2056
+ local_polys.append([(pos, 0.0), (pos - spacing, 0.0),
2057
+ (pos - spacing / 2.0, tick_len)])
2058
+ pos -= spacing
2059
+ for _ in range(n_full):
2060
+ local_lines.append((pos, 0.0, pos - tick_len * ca, tick_len * sa))
2061
+ pos -= spacing
2062
+ if half:
2063
+ local_lines.append((pos, 0.0, pos - (tick_len / 2) * ca, (tick_len / 2) * sa))
2064
+
2065
+ def rot(x, y):
2066
+ return (cx + x * math.cos(angle) - y * math.sin(angle),
2067
+ cy + x * math.sin(angle) + y * math.cos(angle))
2068
+
2069
+ lines = [(*rot(x0, y0), *rot(x1, y1)) for x0, y0, x1, y1 in local_lines]
2070
+ polygons = [[rot(px, py) for px, py in poly] for poly in local_polys]
2071
+ return lines, polygons, False
2072
+
2073
+
2074
+ def _barb_angles(b, tr):
2075
+ """Screen-space direction (radians, ``atan2`` convention) for every barb
2076
+ in ``b`` -- transforms a unit step in ``(U, V)``'s own data-space
2077
+ direction through ``tr``, the same way :func:`_render_quiver` derives its
2078
+ arrow angle, so an unequal x/y data scale (or a non-1:1 ``set_aspect``)
2079
+ still points each barb where it visually should, not where a raw
2080
+ ``atan2(V, U)`` on the untransformed data would."""
2081
+ mag = np.hypot(b.U, b.V)
2082
+ mag_safe = np.where(mag == 0, 1.0, mag)
2083
+ ux, uy = b.U / mag_safe, b.V / mag_safe
2084
+ x0, y0 = tr.x(b.X), tr.y(b.Y)
2085
+ x1, y1 = tr.x(b.X + ux), tr.y(b.Y + uy)
2086
+ return mag, np.arctan2(y1 - y0, x1 - x0)
2087
+
2088
+
2089
+ def _render_barbs(b: Barbs, tr, st, body):
2090
+ L = b.length * st.dpi / 72.0 # points -> px, same conversion markers use
2091
+ cx, cy = tr.x(b.X), tr.y(b.Y)
2092
+ mag, ang = _barb_angles(b, tr)
2093
+ op = f' stroke-opacity="{b.alpha}"' if b.alpha < 1 else ""
2094
+ fop = f' fill-opacity="{b.alpha}"' if b.alpha < 1 else ""
2095
+ lines, polys = [], []
2096
+ calm_pts = []
2097
+ r = 0.12 * L
2098
+ for x, y, spd, a in zip(cx, cy, mag, ang):
2099
+ ls, ps, calm = _barb_geometry(float(x), float(y), float(a), float(spd), L)
2100
+ if calm:
2101
+ calm_pts.append((x, y))
2102
+ else:
2103
+ lines.extend(ls)
2104
+ polys.extend(ps)
2105
+ parts = []
2106
+ if lines:
2107
+ d = "".join(f"M{_fmt(x0)},{_fmt(y0)}L{_fmt(x1)},{_fmt(y1)}" for x0, y0, x1, y1 in lines)
2108
+ parts.append(f'<path d="{d}" fill="none" stroke="{b.color}" '
2109
+ f'stroke-width="1.2" stroke-linecap="round"{op}/>')
2110
+ for poly in polys:
2111
+ coords = " ".join(f"{_fmt(x)},{_fmt(y)}" for x, y in poly)
2112
+ parts.append(f'<polygon points="{coords}" fill="{b.color}"{fop}/>')
2113
+ for x, y in calm_pts:
2114
+ parts.append(f'<circle cx="{_fmt(x)}" cy="{_fmt(y)}" r="{_fmt(r)}" '
2115
+ f'fill="none" stroke="{b.color}" stroke-width="1.2"{op}/>')
2116
+ body.append("".join(parts))
2117
+
2118
+
2119
+ def _render_contour(ct: Contour, tr, body):
2120
+ op = f' stroke-opacity="{ct.alpha}"' if ct.alpha < 1 else ""
2121
+ for lvl, color, segs in ct.line_segments:
2122
+ if not segs:
2123
+ continue
2124
+ d = "".join(
2125
+ f"M{_fmt(tr.x(a))},{_fmt(tr.y(b))}L{_fmt(tr.x(c))},{_fmt(tr.y(e))}"
2126
+ for a, b, c, e in segs
2127
+ )
2128
+ body.append(f'<path d="{d}" fill="none" stroke="{color}" stroke-width="1.2"{op}/>')
2129
+
2130
+
2131
+ # -- axes furniture --------------------------------------------------------
2132
+ def _render_grid(st, tr, xticks, yticks, px_left, px_top, px_w, px_h, body,
2133
+ alpha=None):
2134
+ lines = []
2135
+ for xt in xticks:
2136
+ x = tr.x(xt)
2137
+ lines.append(f'<line x1="{_fmt(x)}" y1="{_fmt(px_top)}" x2="{_fmt(x)}" y2="{_fmt(px_top + px_h)}"/>')
2138
+ for yt in yticks:
2139
+ y = tr.y(yt)
2140
+ lines.append(f'<line x1="{_fmt(px_left)}" y1="{_fmt(y)}" x2="{_fmt(px_left + px_w)}" y2="{_fmt(y)}"/>')
2141
+ body.append(
2142
+ f'<g stroke="{st.grid_color}" stroke-width="{st.grid_width}" '
2143
+ f'stroke-opacity="{st.grid_alpha if alpha is None else alpha}">'
2144
+ f'{"".join(lines)}</g>'
2145
+ )
2146
+
2147
+
2148
+ def _resolve_tick_labels(custom, ticks):
2149
+ """Explicit tick-label strings if set, else formatted tick values."""
2150
+ if custom is None:
2151
+ return format_ticks(ticks)
2152
+ labs = list(custom)[:len(ticks)]
2153
+ return labs + [""] * (len(ticks) - len(labs))
2154
+
2155
+
2156
+ def _render_twin_ticks(ax, st, tr, xticks, yticks, px_left, px_top, px_w, px_h, body):
2157
+ """Draw a twin overlay's independent axis on the side opposite the parent."""
2158
+ ts, tw, fs = st.tick_size, st.tick_width, st.tick_label_size
2159
+ marks, labels = [], []
2160
+ if ax._twin_shared == "x": # twinx: y-axis on the RIGHT
2161
+ xr = px_left + px_w
2162
+ for yt, lab in zip(yticks, _resolve_tick_labels(ax._yticklabels, yticks)):
2163
+ y = tr.y(yt)
2164
+ marks.append(f'<line x1="{_fmt(xr)}" y1="{_fmt(y)}" x2="{_fmt(xr + ts)}" y2="{_fmt(y)}"/>')
2165
+ labels.append(
2166
+ f'<text x="{_fmt(xr + ts + 2)}" y="{_fmt(y + fs * 0.35)}" '
2167
+ f'text-anchor="start" font-size="{fs}" fill="{st.text_color}">{_esc(lab)}</text>'
2168
+ )
2169
+ if ax._ylabel:
2170
+ lx = xr + ts + _max_ytick_width(ax, st) + st.label_size + 4
2171
+ cy = px_top + px_h / 2.0
2172
+ body.append(
2173
+ f'<text x="{_fmt(lx)}" y="{_fmt(cy)}" text-anchor="middle" '
2174
+ f'font-size="{st.label_size}" fill="{st.text_color}" '
2175
+ f'transform="rotate(90 {_fmt(lx)} {_fmt(cy)})">{_esc(ax._ylabel)}</text>'
2176
+ )
2177
+ else: # twiny: x-axis on the TOP
2178
+ for xt, lab in zip(xticks, _resolve_tick_labels(ax._xticklabels, xticks)):
2179
+ x = tr.x(xt)
2180
+ marks.append(f'<line x1="{_fmt(x)}" y1="{_fmt(px_top)}" x2="{_fmt(x)}" y2="{_fmt(px_top - ts)}"/>')
2181
+ labels.append(
2182
+ f'<text x="{_fmt(x)}" y="{_fmt(px_top - ts - 3)}" text-anchor="middle" '
2183
+ f'font-size="{fs}" fill="{st.text_color}">{_esc(lab)}</text>'
2184
+ )
2185
+ if ax._xlabel:
2186
+ body.append(
2187
+ f'<text x="{_fmt(px_left + px_w / 2)}" y="{_fmt(px_top - ts - fs - st.label_size)}" '
2188
+ f'text-anchor="middle" font-size="{st.label_size}" '
2189
+ f'fill="{st.text_color}">{_esc(ax._xlabel)}</text>'
2190
+ )
2191
+ body.append(f'<g stroke="{st.spine_color}" stroke-width="{tw}">{"".join(marks)}</g>')
2192
+ body.append("".join(labels))
2193
+
2194
+
2195
+ def _render_ticks(xst, yst, tr, xticks, yticks, xlabels, ylabels,
2196
+ px_left, px_top, px_w, px_h, body,
2197
+ xside="bottom", yside="left"):
2198
+ xts, xfs = xst.tick_size, xst.tick_label_size
2199
+ yts, yfs = yst.tick_size, yst.tick_label_size
2200
+ xmarks, ymarks, labels = [], [], []
2201
+ x_axis = px_top if xside == "top" else px_top + px_h
2202
+ xsign = -1 if xside == "top" else 1
2203
+ y_axis = px_left if yside == "left" else px_left + px_w
2204
+ ysign = -1 if yside == "left" else 1
2205
+
2206
+ for xt, lab in zip(xticks, xlabels):
2207
+ x = tr.x(xt)
2208
+ xmarks.append(f'<line x1="{_fmt(x)}" y1="{_fmt(x_axis)}" x2="{_fmt(x)}" '
2209
+ f'y2="{_fmt(x_axis + xsign * xts)}"/>')
2210
+ ly = x_axis + xsign * xts + (xfs if xside == "bottom" else -3)
2211
+ labels.append(
2212
+ f'<text x="{_fmt(x)}" y="{_fmt(ly)}" text-anchor="middle" '
2213
+ f'font-size="{xfs}" fill="{xst.text_color}">{_esc(lab)}</text>'
2214
+ )
2215
+ for yt, lab in zip(yticks, ylabels):
2216
+ y = tr.y(yt)
2217
+ ymarks.append(f'<line x1="{_fmt(y_axis)}" y1="{_fmt(y)}" '
2218
+ f'x2="{_fmt(y_axis + ysign * yts)}" y2="{_fmt(y)}"/>')
2219
+ anchor = "end" if yside == "left" else "start"
2220
+ lx = y_axis + ysign * yts + (-2 if yside == "left" else 2)
2221
+ labels.append(
2222
+ f'<text x="{_fmt(lx)}" y="{_fmt(y + yfs * 0.35)}" text-anchor="{anchor}" '
2223
+ f'font-size="{yfs}" fill="{yst.text_color}">{_esc(lab)}</text>'
2224
+ )
2225
+ body.append(f'<g stroke="{xst.spine_color}" stroke-width="{xst.tick_width}">{"".join(xmarks)}</g>')
2226
+ body.append(f'<g stroke="{yst.spine_color}" stroke-width="{yst.tick_width}">{"".join(ymarks)}</g>')
2227
+ body.append("".join(labels))
2228
+
2229
+
2230
+ def _render_minor_ticks(xst, yst, tr, xticks, yticks, px_left, px_top, px_w, px_h, body,
2231
+ xside="bottom", yside="left"):
2232
+ """Unlabeled minor tick marks, drawn shorter than the major ones."""
2233
+ xts = xst.tick_size * 0.6
2234
+ yts = yst.tick_size * 0.6
2235
+ xmarks, ymarks = [], []
2236
+ x_axis = px_top if xside == "top" else px_top + px_h
2237
+ xsign = -1 if xside == "top" else 1
2238
+ y_axis = px_left if yside == "left" else px_left + px_w
2239
+ ysign = -1 if yside == "left" else 1
2240
+
2241
+ for xt in xticks:
2242
+ x = tr.x(xt)
2243
+ xmarks.append(f'<line x1="{_fmt(x)}" y1="{_fmt(x_axis)}" x2="{_fmt(x)}" '
2244
+ f'y2="{_fmt(x_axis + xsign * xts)}"/>')
2245
+ for yt in yticks:
2246
+ y = tr.y(yt)
2247
+ ymarks.append(f'<line x1="{_fmt(y_axis)}" y1="{_fmt(y)}" '
2248
+ f'x2="{_fmt(y_axis + ysign * yts)}" y2="{_fmt(y)}"/>')
2249
+ body.append(f'<g stroke="{xst.spine_color}" stroke-width="{xst.tick_width}">{"".join(xmarks)}</g>')
2250
+ body.append(f'<g stroke="{yst.spine_color}" stroke-width="{yst.tick_width}">{"".join(ymarks)}</g>')
2251
+
2252
+
2253
+ def _render_spines(ax, px_left, px_top, px_w, px_h, body):
2254
+ """Draw the axes box outline, one ``<line>`` per visible side.
2255
+
2256
+ Each :class:`~plotpress.axes.Spine` resolves its own color/width (falling
2257
+ back to the figure style), independent of the other three sides.
2258
+ """
2259
+ st = ax.style
2260
+ x0, y0, x1, y1 = px_left, px_top, px_left + px_w, px_top + px_h
2261
+ edges = {
2262
+ "top": (x0, y0, x1, y0), "bottom": (x0, y1, x1, y1),
2263
+ "left": (x0, y0, x0, y1), "right": (x1, y0, x1, y1),
2264
+ }
2265
+ for side, (ex0, ey0, ex1, ey1) in edges.items():
2266
+ spine = ax.spines[side]
2267
+ if not spine.get_visible():
2268
+ continue
2269
+ color = spine._color if spine._color is not None else st.spine_color
2270
+ width = spine._linewidth if spine._linewidth is not None else st.spine_width
2271
+ op = f' stroke-opacity="{spine._alpha}"' if spine._alpha is not None else ""
2272
+ body.append(
2273
+ f'<line x1="{_fmt(ex0)}" y1="{_fmt(ey0)}" x2="{_fmt(ex1)}" y2="{_fmt(ey1)}" '
2274
+ f'stroke="{color}" stroke-width="{width}"{op}/>'
2275
+ )
2276
+
2277
+
2278
+ def _render_labels(ax, st, px_left, px_top, px_w, px_h, body):
2279
+ cx = px_left + px_w / 2.0
2280
+ ts, fs = st.tick_size, st.tick_label_size
2281
+ if ax._xlabel and not ax._axis_off:
2282
+ if ax._xlabel_y_override is not None:
2283
+ y = ax._xlabel_y_override
2284
+ elif ax._xtick_side == "top":
2285
+ y = px_top - ts - fs - st.label_size
2286
+ else:
2287
+ y = px_top + px_h + ts + fs + st.label_size + 4
2288
+ body.append(
2289
+ f'<text x="{_fmt(cx)}" y="{_fmt(y)}" text-anchor="middle" '
2290
+ f'font-size="{st.label_size}" fill="{st.text_color}">{_esc(ax._xlabel)}</text>'
2291
+ )
2292
+ if ax._ylabel and not ax._axis_off:
2293
+ cy = px_top + px_h / 2.0
2294
+ if ax._ylabel_x_override is not None:
2295
+ x, angle = ax._ylabel_x_override, -90
2296
+ elif ax._ytick_side == "right":
2297
+ x = px_left + px_w + ts + _max_ytick_width(ax, st) + st.label_size + 4
2298
+ angle = 90
2299
+ else:
2300
+ x = px_left - ts - _max_ytick_width(ax, st) - st.label_size - 4
2301
+ angle = -90
2302
+ body.append(
2303
+ f'<text x="{_fmt(x)}" y="{_fmt(cy)}" text-anchor="middle" '
2304
+ f'font-size="{st.label_size}" fill="{st.text_color}" '
2305
+ f'transform="rotate({angle} {_fmt(x)} {_fmt(cy)})">{_esc(ax._ylabel)}</text>'
2306
+ )
2307
+ if ax._title:
2308
+ size = ax._title_size or st.title_size
2309
+ body.append(
2310
+ f'<text x="{_fmt(cx)}" y="{_fmt(px_top - 8 - twiny_headroom(ax, st))}" '
2311
+ f'text-anchor="middle" font-size="{size}" '
2312
+ f'fill="{st.text_color}">{_esc(ax._title)}</text>'
2313
+ )
2314
+
2315
+
2316
+ def twiny_headroom(ax, st):
2317
+ """Pixels of tick decoration above the axes box that the title must clear.
2318
+
2319
+ Three sources draw there: a ``twiny`` overlay's ticks/label, a
2320
+ ``secondary_xaxis('top')``'s ticks/label, and this axes' own ticks after
2321
+ ``tick_top()`` -- all drawn on top, in the same band the title occupies.
2322
+ Without this the title lands on top of them, and any of the three is
2323
+ usually the *reason* the title is worth reading, so overlapping them is
2324
+ doubly unhelpful. ``tight_layout`` reserves the same band.
2325
+ """
2326
+ h = 0.0
2327
+ if ax._xtick_side == "top" and not ax._axis_off:
2328
+ h = st.tick_size + st.tick_label_size + 4
2329
+ if ax._xlabel:
2330
+ h += st.label_size + 6
2331
+ for other in ax.figure.axes:
2332
+ is_twiny = other._twin_of is ax and other._twin_shared == "y"
2333
+ is_secondary_top = (other._secondary_of is ax
2334
+ and other._secondary_dim == "x"
2335
+ and other._xtick_side == "top")
2336
+ if is_twiny or is_secondary_top:
2337
+ th = st.tick_size + st.tick_label_size + 4
2338
+ if other._xlabel:
2339
+ th += st.label_size + 6
2340
+ h = max(h, th)
2341
+ return h
2342
+
2343
+
2344
+ def _max_ytick_width(ax, st):
2345
+ """Width of the widest y tick label, as drawn.
2346
+
2347
+ Must mirror the tick selection the renderer uses -- explicit ``set_yticks``
2348
+ and ``set_yticklabels`` included -- or the y label gets placed on top of
2349
+ labels this never measured.
2350
+ """
2351
+ (_, _), (ymin, ymax) = ax._resolved_limits()
2352
+ ticks = (ax._yticks if ax._yticks is not None else
2353
+ (log_ticks(ymin, ymax) if ax._yscale == "log"
2354
+ else nice_ticks(ymin, ymax)))
2355
+ labels = _resolve_tick_labels(ax._yticklabels, ticks)
2356
+ return max((st.text_width(l, st.tick_label_size) for l in labels), default=0.0)
2357
+
2358
+
2359
+ # loc name -> (fx, fy) fractions of the free space inside the axes: 0 = left/top.
2360
+ _LEGEND_ANCHORS = {
2361
+ "upper right": (1.0, 0.0), "upper left": (0.0, 0.0),
2362
+ "lower left": (0.0, 1.0), "lower right": (1.0, 1.0),
2363
+ "upper center": (0.5, 0.0), "lower center": (0.5, 1.0),
2364
+ "center left": (0.0, 0.5), "center right": (1.0, 0.5),
2365
+ "right": (1.0, 0.5), "center": (0.5, 0.5), "best": (1.0, 0.0),
2366
+ }
2367
+
2368
+
2369
+ def legend_entries(sources):
2370
+ """Labelled artists across one or more axes, keeping the first of each label.
2371
+
2372
+ A figure-level legend usually spans panels that plot the *same* series, so
2373
+ without the de-duplication the shared legend would just repeat itself once
2374
+ per panel.
2375
+ """
2376
+ out, seen = [], set()
2377
+ for ax in sources:
2378
+ for a in ax.artists:
2379
+ label = getattr(a, "label", None)
2380
+ # Truthiness would silently drop label=0/0.0/False -- a legitimate
2381
+ # label matplotlib itself shows as "0", not an opt-out the way
2382
+ # None/"" is.
2383
+ if label is not None and label != "" and label not in seen:
2384
+ seen.add(label)
2385
+ out.append(a)
2386
+ return out
2387
+
2388
+
2389
+ def _legend_layout(ax, st):
2390
+ """Compute legend geometry for an axes' own legend.
2391
+
2392
+ ``ax._legend_handles`` (set by ``legend(handles=...)``) overrides which
2393
+ artists appear, in the order given, regardless of their own label --
2394
+ otherwise every labelled artist on this axes appears, call order.
2395
+ """
2396
+ source = (ax._legend_handles if ax._legend_handles is not None
2397
+ else ax.artists)
2398
+ return legend_box(
2399
+ [a for a in source if getattr(a, "label", None) not in (None, "")],
2400
+ st, ax._legend_ncol, ax._legend_title, fontsize=ax._legend_fontsize,
2401
+ framealpha=ax._legend_framealpha)
2402
+
2403
+
2404
+ def legend_box(entries, st, ncol, title, fontsize=None, framealpha=0.85):
2405
+ """Compute legend geometry: entries, columns, cell size, box size."""
2406
+ if not entries:
2407
+ return None
2408
+ fs = fontsize if fontsize is not None else st.tick_label_size
2409
+ line_h = fs + 6
2410
+ sample_w = 22
2411
+ pad = 6
2412
+ ncol = min(max(1, int(ncol)), len(entries))
2413
+ nrows = (len(entries) + ncol - 1) // ncol
2414
+ # label is whatever the caller passed to label= -- often a string, but
2415
+ # matplotlib accepts anything and str()s it for display, and a bare
2416
+ # loop-variable int/float is a common accident this must not crash on.
2417
+ text_w = max(st.text_width(str(a.label), fs) for a in entries)
2418
+ col_w = sample_w + text_w + pad * 2
2419
+ title_h = line_h if title else 0
2420
+ box_w = col_w * ncol + pad
2421
+ if title:
2422
+ # Drawn bold below, so it must be measured bold: Helvetica-Bold runs
2423
+ # 5-9% wider than regular on real label strings, which is enough to
2424
+ # push a title out through the side of its own box.
2425
+ box_w = max(box_w, st.text_width(title, fs, bold=True) + pad * 2)
2426
+ box_h = line_h * nrows + pad + title_h
2427
+ return {
2428
+ "entries": entries, "fs": fs, "line_h": line_h, "sample_w": sample_w,
2429
+ "pad": pad, "ncol": ncol, "col_w": col_w, "title": title,
2430
+ "title_h": title_h, "box_w": box_w, "box_h": box_h,
2431
+ "framealpha": framealpha,
2432
+ }
2433
+
2434
+
2435
+ # Which figure edge a figure-level legend reserves space against. "center" and
2436
+ # the corner placements overlay instead: there is no unambiguous edge to shrink
2437
+ # away from, and matplotlib's fig.legend overlays for those too.
2438
+ FIGURE_LEGEND_EDGE = {
2439
+ "lower center": "bottom", "upper center": "top",
2440
+ "right": "right", "center right": "right", "center left": "left",
2441
+ }
2442
+
2443
+
2444
+ def figure_legend_layout(fig):
2445
+ """Legend geometry for ``fig.legend()``, or ``None`` if nothing is labelled."""
2446
+ spec = fig._figure_legend
2447
+ if spec is None:
2448
+ return None
2449
+ sources = spec["axes"] or [a for a in fig.axes if not a._is_colorbar]
2450
+ return legend_box(legend_entries(sources), fig.style,
2451
+ spec["ncol"], spec["title"], fontsize=spec.get("fontsize"),
2452
+ framealpha=spec.get("framealpha", 0.85))
2453
+
2454
+
2455
+ def figure_legend_origin(spec, lay, W, H, pad_px):
2456
+ """Top-left corner of the figure legend, in figure pixels."""
2457
+ edge = FIGURE_LEGEND_EDGE.get(spec["loc"])
2458
+ box_w, box_h = lay["box_w"], lay["box_h"]
2459
+ if edge == "bottom":
2460
+ return (W - box_w) / 2.0, H - pad_px - box_h
2461
+ if edge == "top":
2462
+ return (W - box_w) / 2.0, pad_px
2463
+ if edge == "right":
2464
+ return W - pad_px - box_w, (H - box_h) / 2.0
2465
+ if edge == "left":
2466
+ return pad_px, (H - box_h) / 2.0
2467
+ # Overlaid: anchor inside the whole figure the way an axes legend anchors
2468
+ # inside its own rect.
2469
+ fx, fy = _LEGEND_ANCHORS.get(spec["loc"], (1.0, 0.0))
2470
+ return (pad_px + fx * max(0.0, W - box_w - 2 * pad_px),
2471
+ pad_px + fy * max(0.0, H - box_h - 2 * pad_px))
2472
+
2473
+
2474
+ def _render_figure_legend(fig, st, W, H, body):
2475
+ lay = figure_legend_layout(fig)
2476
+ if lay is None:
2477
+ return
2478
+ spec = fig._figure_legend
2479
+ pad_px = spec["pad"] * min(W, H) + 4
2480
+ bx, by = figure_legend_origin(spec, lay, W, H, pad_px)
2481
+ draw_legend(lay, st, bx, by, body)
2482
+
2483
+
2484
+ def _legend_origin(ax, lay, px_left, px_top, px_w, px_h):
2485
+ fx, fy = _LEGEND_ANCHORS.get(ax._legend_loc, (1.0, 0.0))
2486
+ if ax._legend_bbox_to_anchor is not None:
2487
+ # (x, y) in this axes' own fraction coordinates -- y-up, matplotlib's
2488
+ # own convention for it -- flipped to pixel space (y-down) here. The
2489
+ # loc corner (fx, fy) is which corner of the box sits at that point,
2490
+ # not an inset-space interpolation the way the plain-loc case below
2491
+ # is, so this is free to land outside the axes box entirely -- the
2492
+ # common reason to reach for bbox_to_anchor at all.
2493
+ ax_x, ax_y = ax._legend_bbox_to_anchor
2494
+ anchor_x = px_left + ax_x * px_w
2495
+ anchor_y = px_top + (1.0 - ax_y) * px_h
2496
+ return anchor_x - fx * lay["box_w"], anchor_y - fy * lay["box_h"]
2497
+ bx = px_left + 6 + fx * max(0.0, px_w - lay["box_w"] - 12)
2498
+ by = px_top + 6 + fy * max(0.0, px_h - lay["box_h"] - 12)
2499
+ return bx, by
2500
+
2501
+
2502
+ def _render_legend(ax, st, px_left, px_top, px_w, px_h, body):
2503
+ lay = _legend_layout(ax, st)
2504
+ if lay is None:
2505
+ return
2506
+ bx, by = _legend_origin(ax, lay, px_left, px_top, px_w, px_h)
2507
+ draw_legend(lay, st, bx, by, body)
2508
+
2509
+
2510
+ def draw_legend(lay, st, bx, by, body):
2511
+ """Emit a legend box with its top-left corner at ``(bx, by)``."""
2512
+ fs, line_h, sample_w, pad = lay["fs"], lay["line_h"], lay["sample_w"], lay["pad"]
2513
+ ncol, col_w, title_h = lay["ncol"], lay["col_w"], lay["title_h"]
2514
+ box_w, box_h = lay["box_w"], lay["box_h"]
2515
+
2516
+ body.append(
2517
+ f'<g class="plotpress-legend"><rect x="{_fmt(bx)}" y="{_fmt(by)}" '
2518
+ f'width="{_fmt(box_w)}" height="{_fmt(box_h)}" rx="3" fill="#ffffff" '
2519
+ f'fill-opacity="{lay["framealpha"]}" stroke="#cccccc" stroke-width="0.8"/>'
2520
+ )
2521
+ if lay["title"]:
2522
+ body.append(
2523
+ f'<text x="{_fmt(bx + box_w / 2)}" y="{_fmt(by + pad + fs)}" '
2524
+ f'text-anchor="middle" font-size="{fs}" font-weight="bold" '
2525
+ f'fill="{st.text_color}">{_esc(lay["title"])}</text>'
2526
+ )
2527
+ for i, a in enumerate(lay["entries"]):
2528
+ r, c = divmod(i, ncol)
2529
+ sx = bx + pad + c * col_w
2530
+ row_y = by + pad + title_h + line_h * r + line_h / 2.0
2531
+ if isinstance(a, Bars):
2532
+ color = a.colors[0] if a.colors else "#333333"
2533
+ else:
2534
+ color = getattr(a, "color", None) or getattr(a, "linecolor", None) or "#333333"
2535
+ if isinstance(a, ScatterCollection):
2536
+ body.append(f'<circle cx="{_fmt(sx + sample_w / 2)}" cy="{_fmt(row_y)}" r="4" fill="{color}"/>')
2537
+ elif isinstance(a, (Bars, FillBetween, Span, Polygon)):
2538
+ op = getattr(a, "alpha", 1.0) if isinstance(a, (FillBetween, Span, Polygon)) else 1.0
2539
+ body.append(
2540
+ f'<rect x="{_fmt(sx)}" y="{_fmt(row_y - 5)}" width="{_fmt(sample_w)}" '
2541
+ f'height="10" fill="{color}" fill-opacity="{op}"/>'
2542
+ )
2543
+ else:
2544
+ # Carry the artist's dash pattern into the swatch. Reference lines
2545
+ # -- control limits, thresholds, fitted asymptotes -- are dashed or
2546
+ # dotted precisely so they read as annotations rather than data, and
2547
+ # a legend that draws them all solid throws that distinction away
2548
+ # exactly where the reader goes to look it up.
2549
+ dash = _DASH.get(getattr(a, "linestyle", "-"))
2550
+ extra = f' stroke-dasharray="{dash}"' if dash else ""
2551
+ body.append(
2552
+ f'<line x1="{_fmt(sx)}" y1="{_fmt(row_y)}" x2="{_fmt(sx + sample_w)}" '
2553
+ f'y2="{_fmt(row_y)}" stroke="{color}" stroke-width="2"{extra}/>'
2554
+ )
2555
+ body.append(
2556
+ f'<text x="{_fmt(sx + sample_w + pad)}" y="{_fmt(row_y + fs * 0.35)}" '
2557
+ f'font-size="{fs}" fill="{st.text_color}">{_esc(str(a.label))}</text>'
2558
+ )
2559
+ body.append("</g>")
2560
+
2561
+
2562
+ def _render_colorbar(ax, tr, px_left, px_top, px_w, px_h, clip_id, body):
2563
+ """Vertical gradient strip + right-side ticks for a colorbar axes."""
2564
+ src = ax._cbar_source
2565
+ lut = src.lut
2566
+ norm = src.norm
2567
+ # 256x1 gradient, top = vmax.
2568
+ grad = np.flipud(lut).reshape(-1, 1, 3)
2569
+ alpha = np.full((grad.shape[0], 1, 1), 255, np.uint8)
2570
+ rgba = np.concatenate([grad, alpha], axis=2)
2571
+ uri = png_data_uri(rgba)
2572
+ body.append(
2573
+ f'<image x="{_fmt(px_left)}" y="{_fmt(px_top)}" width="{_fmt(px_w)}" '
2574
+ f'height="{_fmt(px_h)}" preserveAspectRatio="none" href="{uri}"/>'
2575
+ )
2576
+ _render_spines(ax, px_left, px_top, px_w, px_h, body)
2577
+
2578
+ st = ax.style
2579
+ _, fracs, tlabels = colorbar_ticks(norm)
2580
+ marks, labels = [], []
2581
+ for frac, lab in zip(fracs, tlabels):
2582
+ y = px_top + (1 - frac) * px_h
2583
+ marks.append(f'<line x1="{_fmt(px_left + px_w)}" y1="{_fmt(y)}" x2="{_fmt(px_left + px_w + st.tick_size)}" y2="{_fmt(y)}"/>')
2584
+ labels.append(
2585
+ f'<text x="{_fmt(px_left + px_w + st.tick_size + 2)}" y="{_fmt(y + st.tick_label_size * 0.35)}" '
2586
+ f'font-size="{st.tick_label_size}" fill="{st.text_color}">{_esc(lab)}</text>'
2587
+ )
2588
+ body.append(f'<g stroke="{st.spine_color}" stroke-width="{st.tick_width}">{"".join(marks)}</g>')
2589
+ body.append("".join(labels))