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/__init__.py +108 -0
- plotpress/_interactive.py +2849 -0
- plotpress/_spectral.py +154 -0
- plotpress/_version.py +1 -0
- plotpress/artists.py +1382 -0
- plotpress/axes.py +3221 -0
- plotpress/colors.py +498 -0
- plotpress/figure.py +3084 -0
- plotpress/fonts/__init__.py +51 -0
- plotpress/fonts/families.py +192 -0
- plotpress/fonts/installed.py +82 -0
- plotpress/fonts/metrics.py +265 -0
- plotpress/png.py +93 -0
- plotpress/polar.py +240 -0
- plotpress/primitives.py +335 -0
- plotpress/qt.py +427 -0
- plotpress/raster.py +1316 -0
- plotpress/style.py +91 -0
- plotpress/svg.py +2589 -0
- plotpress/ticker.py +212 -0
- plotpress/transform.py +85 -0
- plotpress/vega.py +1324 -0
- plotpress/vega_lite.py +1199 -0
- plotpress-0.23.2.dist-info/METADATA +378 -0
- plotpress-0.23.2.dist-info/RECORD +28 -0
- plotpress-0.23.2.dist-info/WHEEL +5 -0
- plotpress-0.23.2.dist-info/licenses/LICENSE +21 -0
- plotpress-0.23.2.dist-info/top_level.txt +1 -0
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("&", "&")
|
|
49
|
+
.replace("<", "<")
|
|
50
|
+
.replace(">", ">")
|
|
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))
|