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/figure.py
ADDED
|
@@ -0,0 +1,3084 @@
|
|
|
1
|
+
"""The Figure: the root object that owns everything needed to render itself.
|
|
2
|
+
|
|
3
|
+
There is no global "current figure" or "current axes". A figure holds its own
|
|
4
|
+
axes, its own :class:`~plotpress.style.Style`, and knows how to serialize itself to
|
|
5
|
+
SVG/HTML or show itself in a native pop-up window. Two figures never share
|
|
6
|
+
mutable state.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import html
|
|
13
|
+
import json
|
|
14
|
+
import math
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import time
|
|
18
|
+
import warnings
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from .artists import normalize_bbox, normalize_linestyle
|
|
23
|
+
from .axes import Axes
|
|
24
|
+
from .polar import PolarAxes
|
|
25
|
+
from .style import Style
|
|
26
|
+
from .svg import figure_to_svg
|
|
27
|
+
|
|
28
|
+
# Distinguishes "align_xlabels/ylabels never called" from "called with the
|
|
29
|
+
# default axes=None" (meaning "all axes, re-resolved each time") -- both would
|
|
30
|
+
# otherwise collapse to the same falsy None and the re-apply on relayout
|
|
31
|
+
# below would never fire for the (most common) no-argument call.
|
|
32
|
+
_ALIGN_UNSET = object()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _normalize_pad(pad):
|
|
36
|
+
"""A single number (uniform clearance) or a 4-item ``(left, right, top,
|
|
37
|
+
bottom)`` sequence, always returned as that 4-tuple -- every consumer
|
|
38
|
+
(the tight_layout margin reservation in this module, the box geometry in
|
|
39
|
+
svg.py/raster.py) then indexes one side directly instead of each
|
|
40
|
+
re-checking "was this a scalar or a sequence" on its own.
|
|
41
|
+
|
|
42
|
+
Duck-typed on ``float(pad)`` succeeding, rather than ``isinstance(pad,
|
|
43
|
+
(int, float))`` -- a numpy scalar (``np.int64``, ``np.float32``, a 0-d
|
|
44
|
+
array) satisfies the former but not the latter (only ``np.float64``
|
|
45
|
+
happens to subclass Python's own ``float``), and a caller deriving pad
|
|
46
|
+
from other numpy computation is a realistic, not exotic, case. Strings
|
|
47
|
+
are excluded up front despite ``float()`` accepting one: ``"5"`` would
|
|
48
|
+
otherwise silently take the single-number branch instead of raising --
|
|
49
|
+
a caller error (pad built from a config/CLI value that came through as
|
|
50
|
+
text) worth surfacing clearly, not coercing quietly.
|
|
51
|
+
"""
|
|
52
|
+
if isinstance(pad, (str, bytes)):
|
|
53
|
+
raise TypeError(
|
|
54
|
+
f"pad must be a number or a 4-item (left, right, top, bottom) "
|
|
55
|
+
f"sequence, not a string: {pad!r}")
|
|
56
|
+
try:
|
|
57
|
+
p = float(pad)
|
|
58
|
+
except TypeError:
|
|
59
|
+
pass
|
|
60
|
+
else:
|
|
61
|
+
return (p, p, p, p)
|
|
62
|
+
pad = tuple(float(v) for v in pad)
|
|
63
|
+
if len(pad) != 4:
|
|
64
|
+
raise ValueError(
|
|
65
|
+
"pad must be a single number or a 4-item (left, right, top, "
|
|
66
|
+
f"bottom) sequence, got {len(pad)} values")
|
|
67
|
+
return pad
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class SubplotSpec:
|
|
71
|
+
"""A (possibly multi-cell) placement within an ``nrows`` x ``ncols`` grid.
|
|
72
|
+
|
|
73
|
+
``row0``/``row1``/``col0``/``col1`` are inclusive, 0-based cell bounds --
|
|
74
|
+
a single-cell spec (the ordinary ``add_subplot(nrows, ncols, index)``
|
|
75
|
+
case) has ``row0 == row1`` and ``col0 == col1``. This is the one
|
|
76
|
+
representation :meth:`Figure.tight_layout` and :meth:`Figure.subplots_adjust`
|
|
77
|
+
place from, whether the axes came from a plain grid or a :class:`GridSpec`
|
|
78
|
+
slice.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def __init__(self, nrows, ncols, row0, row1, col0, col1):
|
|
82
|
+
self.nrows, self.ncols = nrows, ncols
|
|
83
|
+
self.row0, self.row1, self.col0, self.col1 = row0, row1, col0, col1
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _cell_subplotspec(nrows, ncols, index) -> SubplotSpec:
|
|
87
|
+
"""``SubplotSpec`` for a single 1-based cell ``index`` (legacy add_subplot)."""
|
|
88
|
+
idx = index - 1
|
|
89
|
+
row, col = idx // ncols, idx % ncols
|
|
90
|
+
return SubplotSpec(nrows, ncols, row, row, col, col)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _slice_span(sel, n):
|
|
94
|
+
"""Inclusive 0-based ``(lo, hi)`` bounds from a ``GridSpec`` index or slice."""
|
|
95
|
+
if isinstance(sel, slice):
|
|
96
|
+
if sel.step not in (None, 1):
|
|
97
|
+
raise ValueError("GridSpec only supports contiguous spans (step=1)")
|
|
98
|
+
lo, hi, _ = sel.indices(n)
|
|
99
|
+
if hi <= lo:
|
|
100
|
+
raise ValueError("GridSpec slice selects no rows/columns")
|
|
101
|
+
return lo, hi - 1
|
|
102
|
+
idx = sel if sel >= 0 else sel + n
|
|
103
|
+
return idx, idx
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class GridSpec:
|
|
107
|
+
"""A grid layout descriptor supporting row/column spans.
|
|
108
|
+
|
|
109
|
+
``fig.add_gridspec(2, 3)[0, :2]`` returns a :class:`SubplotSpec` covering
|
|
110
|
+
the first two columns of row 0; pass that to :meth:`Figure.add_subplot` in
|
|
111
|
+
place of ``(nrows, ncols, index)``.
|
|
112
|
+
|
|
113
|
+
``left``/``right``/``top``/``bottom``/``wspace``/``hspace`` are accepted
|
|
114
|
+
for signature familiarity with matplotlib's ``GridSpec``. Since the figure
|
|
115
|
+
only ever sizes one uniform grid at a time, any given here are applied
|
|
116
|
+
immediately as the figure's own margins (like calling
|
|
117
|
+
:meth:`Figure.subplots_adjust` with the same values) -- a later
|
|
118
|
+
``tight_layout()``/``subplots_adjust()`` call still wins, same as it would
|
|
119
|
+
over an explicit ``subplots_adjust`` call.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def __init__(self, figure, nrows, ncols, left=None, right=None, top=None,
|
|
123
|
+
bottom=None, wspace=None, hspace=None):
|
|
124
|
+
self.figure = figure
|
|
125
|
+
self.nrows = nrows
|
|
126
|
+
self.ncols = ncols
|
|
127
|
+
self.left, self.right = left, right
|
|
128
|
+
self.top, self.bottom = top, bottom
|
|
129
|
+
self.wspace, self.hspace = wspace, hspace
|
|
130
|
+
sp = figure._subplot_params
|
|
131
|
+
for key, val in (("left", left), ("right", right), ("top", top),
|
|
132
|
+
("bottom", bottom), ("wspace", wspace), ("hspace", hspace)):
|
|
133
|
+
if val is not None:
|
|
134
|
+
sp[key] = float(val)
|
|
135
|
+
if any(v is not None for v in (left, right, top, bottom, wspace, hspace)):
|
|
136
|
+
figure._tight_pad = None
|
|
137
|
+
figure._layout_dirty = False
|
|
138
|
+
|
|
139
|
+
def __getitem__(self, key) -> SubplotSpec:
|
|
140
|
+
rows, cols = key if isinstance(key, tuple) else (key, slice(None))
|
|
141
|
+
r0, r1 = _slice_span(rows, self.nrows)
|
|
142
|
+
c0, c1 = _slice_span(cols, self.ncols)
|
|
143
|
+
return SubplotSpec(self.nrows, self.ncols, r0, r1, c0, c1)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _axes_class(projection):
|
|
147
|
+
"""Resolve a ``projection`` name to its Axes class."""
|
|
148
|
+
if projection in (None, "rectilinear"):
|
|
149
|
+
return Axes
|
|
150
|
+
if projection == "polar":
|
|
151
|
+
return PolarAxes
|
|
152
|
+
raise ValueError(
|
|
153
|
+
"unknown projection %r (use None or 'polar')" % projection)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class Figure:
|
|
157
|
+
def __init__(self, figsize=(6.4, 4.8), style: Style = None, facecolor=None):
|
|
158
|
+
w, h = figsize
|
|
159
|
+
if not (w > 0 and h > 0):
|
|
160
|
+
raise ValueError(
|
|
161
|
+
f"Figure(): figsize must be (width, height) with both > 0, "
|
|
162
|
+
f"got {tuple(figsize)!r} -- a non-positive size produces an "
|
|
163
|
+
"invalid, invisible SVG/PNG with no error anywhere downstream."
|
|
164
|
+
)
|
|
165
|
+
self.figsize = tuple(figsize)
|
|
166
|
+
# The size the *user* asked for, as opposed to ``self.figsize`` --
|
|
167
|
+
# which tight_layout() may grow beyond this to fit group_spacing()'s
|
|
168
|
+
# reservations without shrinking the axes. Kept separate so repeated
|
|
169
|
+
# tight_layout() calls recompute growth from a fixed starting point
|
|
170
|
+
# instead of compounding it onto an already-grown figsize.
|
|
171
|
+
self._base_figsize = tuple(figsize)
|
|
172
|
+
self.style = (style or Style()).copy()
|
|
173
|
+
if facecolor is not None:
|
|
174
|
+
self.style.facecolor = facecolor
|
|
175
|
+
self.axes: list[Axes] = []
|
|
176
|
+
# Slider "units" -- each is one control bar. The global unit "main" is a
|
|
177
|
+
# single bar driving all shared series; a docked unit ("ax<i>") sits
|
|
178
|
+
# under one axes. Docked units may share a connection *index* so the UI
|
|
179
|
+
# can offer a checkbox to link them.
|
|
180
|
+
self._sliders = {} # unit_id -> spec
|
|
181
|
+
self._slider_index_n = {} # connection index -> n_frames (validation)
|
|
182
|
+
|
|
183
|
+
# Temp file backing show()'s browser fallback, reused across calls.
|
|
184
|
+
self._show_path = None
|
|
185
|
+
|
|
186
|
+
# Figure-level (global) text spanning all subplots.
|
|
187
|
+
self._suptitle = None
|
|
188
|
+
self._figure_legend = None # set by Figure.legend()
|
|
189
|
+
self._supxlabel = None
|
|
190
|
+
self._supylabel = None
|
|
191
|
+
self._fig_texts = [] # set by Figure.text(); each a dict of kwargs
|
|
192
|
+
self._groups = [] # set by Figure.group(); each a dict of kwargs
|
|
193
|
+
self._group_wspace = None # set by Figure.group_spacing()
|
|
194
|
+
self._group_hspace = None
|
|
195
|
+
|
|
196
|
+
# tight_layout is re-applied at render time if anything it measured has
|
|
197
|
+
# changed since -- see _settle_layout.
|
|
198
|
+
self._tight_pad = None
|
|
199
|
+
self._layout_dirty = False
|
|
200
|
+
|
|
201
|
+
# subplots_adjust's own margins, applied instead of a measured
|
|
202
|
+
# tight_layout fit. Defaults match _subplot_rect's literals, so a
|
|
203
|
+
# partial subplots_adjust(wspace=...) call only changes what it names.
|
|
204
|
+
self._subplot_params = {"left": 0.125, "right": 0.9, "top": 0.88,
|
|
205
|
+
"bottom": 0.11, "wspace": 0.2, "hspace": 0.2}
|
|
206
|
+
|
|
207
|
+
# Axes lists last passed to align_xlabels/align_ylabels, so
|
|
208
|
+
# tight_layout/subplots_adjust can re-apply the alignment after they
|
|
209
|
+
# reflow the grid (the same staleness problem colorbars/legends solve).
|
|
210
|
+
self._align_x_axes = _ALIGN_UNSET
|
|
211
|
+
self._align_y_axes = _ALIGN_UNSET
|
|
212
|
+
|
|
213
|
+
def _settle_layout(self):
|
|
214
|
+
"""Re-fit the subplot grid if a measured decoration changed since.
|
|
215
|
+
|
|
216
|
+
``tight_layout`` sizes its margins from the titles and axis labels that
|
|
217
|
+
exist when it runs, so anything set afterwards got no space reserved and
|
|
218
|
+
was drawn over the axes -- which is exactly what a figure whose title
|
|
219
|
+
reports its own build time has to do, since the number does not exist
|
|
220
|
+
until the figure is built. Colorbars and figure legends already re-apply
|
|
221
|
+
their reservations for the same reason; this extends that to text.
|
|
222
|
+
|
|
223
|
+
Deferred to render rather than done eagerly on every setter: a grid of
|
|
224
|
+
several hundred panels would otherwise re-lay out once per
|
|
225
|
+
``set_title``.
|
|
226
|
+
"""
|
|
227
|
+
if self._layout_dirty and self._tight_pad is not None:
|
|
228
|
+
self._layout_dirty = False
|
|
229
|
+
self.tight_layout(self._tight_pad)
|
|
230
|
+
|
|
231
|
+
def suptitle(self, text, size=None):
|
|
232
|
+
"""Set a global title centered across the whole figure."""
|
|
233
|
+
self._suptitle = {"text": text, "size": size}
|
|
234
|
+
self._layout_dirty = True
|
|
235
|
+
|
|
236
|
+
def supxlabel(self, text, size=None):
|
|
237
|
+
"""Set a global x label centered along the bottom of the figure."""
|
|
238
|
+
self._supxlabel = {"text": text, "size": size}
|
|
239
|
+
self._layout_dirty = True
|
|
240
|
+
|
|
241
|
+
def supylabel(self, text, size=None):
|
|
242
|
+
"""Set a global y label centered along the left of the figure."""
|
|
243
|
+
self._supylabel = {"text": text, "size": size}
|
|
244
|
+
self._layout_dirty = True
|
|
245
|
+
|
|
246
|
+
def text(self, x, y, s, ha="left", va="baseline", fontsize=None, color=None,
|
|
247
|
+
alpha=1.0, bbox=None):
|
|
248
|
+
"""Draw text at figure-fraction coordinates ``(x, y)`` -- ``(0, 0)`` is
|
|
249
|
+
the bottom-left corner, ``(1, 1)`` the top-right, independent of any
|
|
250
|
+
axes' data coordinates.
|
|
251
|
+
|
|
252
|
+
``alpha``/``bbox`` match :meth:`Axes.text` -- ``bbox`` draws a filled/
|
|
253
|
+
bordered box behind the text (see there for its keys).
|
|
254
|
+
"""
|
|
255
|
+
self._fig_texts.append({
|
|
256
|
+
"x": float(x), "y": float(y), "s": s, "ha": ha, "va": va,
|
|
257
|
+
"size": fontsize, "color": color, "alpha": alpha,
|
|
258
|
+
"bbox": normalize_bbox(bbox),
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
def group(self, title, axes, linestyle="--", color="black", linewidth=1.5,
|
|
262
|
+
title_position="top", pad=8.0, fontsize=None):
|
|
263
|
+
"""Draw a labeled box around a set of axes -- e.g. a cluster of
|
|
264
|
+
related panels in a larger grid.
|
|
265
|
+
|
|
266
|
+
``axes`` is any subset of this figure's own axes, typically adjacent
|
|
267
|
+
cells in a subplot grid; the box is the tight bounding rectangle of
|
|
268
|
+
their individual positions (nothing about grid adjacency is
|
|
269
|
+
checked) -- expanded to also clear each axes' own tick labels, axis
|
|
270
|
+
labels, and title, not just its bare plot rect -- plus ``pad`` pixels
|
|
271
|
+
of clearance. ``pad`` is a single number for the same clearance on
|
|
272
|
+
all four sides, or a 4-item ``(left, right, top, bottom)`` sequence
|
|
273
|
+
for unequal padding -- e.g. tighter on the side that already butts
|
|
274
|
+
against a neighboring group, looser on the side carrying the title.
|
|
275
|
+
``title_position`` is one of ``"top"``/``"bottom"``/``"left"``/
|
|
276
|
+
``"right"``, placing ``title`` just outside that edge of the box.
|
|
277
|
+
Returns ``self`` for chaining; several groups may be added to one
|
|
278
|
+
figure.
|
|
279
|
+
"""
|
|
280
|
+
if not axes:
|
|
281
|
+
raise ValueError("group() needs at least one axes")
|
|
282
|
+
for ax in axes:
|
|
283
|
+
if ax not in self.axes:
|
|
284
|
+
raise ValueError("group() axes must belong to this figure")
|
|
285
|
+
if title_position not in ("top", "bottom", "left", "right"):
|
|
286
|
+
raise ValueError(
|
|
287
|
+
"title_position must be 'top', 'bottom', 'left', or 'right', "
|
|
288
|
+
f"got {title_position!r}")
|
|
289
|
+
self._groups.append({
|
|
290
|
+
"title": title, "axes": list(axes),
|
|
291
|
+
"linestyle": normalize_linestyle(linestyle, "group", stacklevel=3),
|
|
292
|
+
"color": color, "linewidth": float(linewidth),
|
|
293
|
+
"title_position": title_position, "pad": _normalize_pad(pad),
|
|
294
|
+
"fontsize": fontsize,
|
|
295
|
+
})
|
|
296
|
+
self._layout_dirty = True
|
|
297
|
+
return self
|
|
298
|
+
|
|
299
|
+
def group_spacing(self, wspace=None, hspace=None):
|
|
300
|
+
"""Reserve extra pixels between subplots for :meth:`group` boxes,
|
|
301
|
+
without touching anything else :meth:`tight_layout` already sizes.
|
|
302
|
+
|
|
303
|
+
Two groups facing each other across an *interior* grid boundary --
|
|
304
|
+
neither one's title touching that boundary, so neither gets the
|
|
305
|
+
outer-edge margin :meth:`tight_layout` reserves automatically -- can
|
|
306
|
+
collide there: each box still needs room for its own tick labels
|
|
307
|
+
and padding beyond its bare axes, and the ordinary column/row gap
|
|
308
|
+
(sized only from the axes' own decorations) is not guaranteed to be
|
|
309
|
+
enough. ``wspace``/``hspace`` (pixels, added on top of that gap, one
|
|
310
|
+
or both) fix exactly that, independent of the tick-label-driven
|
|
311
|
+
spacing itself -- unlike reaching for :meth:`subplots_adjust`,
|
|
312
|
+
which would also throw away every margin :meth:`tight_layout`
|
|
313
|
+
already computed (titles, tick labels, colorbars, a legend,
|
|
314
|
+
``suptitle``/``supxlabel``/``supylabel``) and require respecifying
|
|
315
|
+
all of them by hand just to widen one gap.
|
|
316
|
+
|
|
317
|
+
Applies only to the row/column boundaries that actually sit on the
|
|
318
|
+
edge of a group's bounding box -- not every interior gap alike. Two
|
|
319
|
+
rows paired inside the *same* group (a group spanning them both)
|
|
320
|
+
stay exactly as tight as :meth:`tight_layout` would put them; only
|
|
321
|
+
the boundary between that group and its neighbor -- where their two
|
|
322
|
+
boxes would otherwise collide -- grows. A group spanning several
|
|
323
|
+
rows/columns still only widens the boundaries at its own edges, not
|
|
324
|
+
every boundary it happens to pass through.
|
|
325
|
+
|
|
326
|
+
The figure grows to hold the extra room rather than shrinking the
|
|
327
|
+
axes to fit it: :meth:`tight_layout` adds exactly the reserved
|
|
328
|
+
pixels (each boundary that needs it, once) onto ``figsize`` itself,
|
|
329
|
+
so a plot's own size is the same with or without this call, and
|
|
330
|
+
calling it again with a different value re-derives the growth from
|
|
331
|
+
the size last given to the constructor or :meth:`set_size_inches`
|
|
332
|
+
rather than compounding onto an already-grown figure.
|
|
333
|
+
|
|
334
|
+
Only takes effect through :meth:`tight_layout`; has no effect after
|
|
335
|
+
a :meth:`subplots_adjust` call, which sets every margin manually.
|
|
336
|
+
"""
|
|
337
|
+
if wspace is not None:
|
|
338
|
+
self._group_wspace = float(wspace)
|
|
339
|
+
if hspace is not None:
|
|
340
|
+
self._group_hspace = float(hspace)
|
|
341
|
+
self._layout_dirty = True
|
|
342
|
+
return self
|
|
343
|
+
|
|
344
|
+
def set_size_inches(self, w, h=None):
|
|
345
|
+
"""Resize the figure. Accepts ``(w, h)`` or two separate arguments."""
|
|
346
|
+
if h is None:
|
|
347
|
+
w, h = w
|
|
348
|
+
self.figsize = (float(w), float(h))
|
|
349
|
+
self._base_figsize = self.figsize
|
|
350
|
+
if self._tight_pad is not None:
|
|
351
|
+
self._layout_dirty = True # re-fit: tight_layout bakes absolute pixels
|
|
352
|
+
|
|
353
|
+
def get_size_inches(self):
|
|
354
|
+
return self.figsize
|
|
355
|
+
|
|
356
|
+
def set_dpi(self, dpi):
|
|
357
|
+
self.style.dpi = float(dpi)
|
|
358
|
+
if self._tight_pad is not None:
|
|
359
|
+
self._layout_dirty = True
|
|
360
|
+
|
|
361
|
+
def get_dpi(self):
|
|
362
|
+
return self.style.dpi
|
|
363
|
+
|
|
364
|
+
def delaxes(self, ax):
|
|
365
|
+
"""Remove ``ax`` from this figure (delegates to :meth:`Axes.remove`)."""
|
|
366
|
+
ax.remove()
|
|
367
|
+
|
|
368
|
+
def clf(self):
|
|
369
|
+
"""Clear the figure: drop every axes and figure-level decoration.
|
|
370
|
+
|
|
371
|
+
Keeps ``figsize``/``style`` -- use a new :class:`Figure` for those.
|
|
372
|
+
"""
|
|
373
|
+
self.axes = []
|
|
374
|
+
self._sliders = {}
|
|
375
|
+
self._slider_index_n = {}
|
|
376
|
+
self._suptitle = None
|
|
377
|
+
self._figure_legend = None
|
|
378
|
+
self._supxlabel = None
|
|
379
|
+
self._supylabel = None
|
|
380
|
+
self._fig_texts = []
|
|
381
|
+
self._groups = []
|
|
382
|
+
self._group_wspace = None
|
|
383
|
+
self._group_hspace = None
|
|
384
|
+
self._tight_pad = None
|
|
385
|
+
self._layout_dirty = False
|
|
386
|
+
self._align_x_axes = _ALIGN_UNSET
|
|
387
|
+
self._align_y_axes = _ALIGN_UNSET
|
|
388
|
+
|
|
389
|
+
clear = clf
|
|
390
|
+
|
|
391
|
+
def _register_slider(self, unit, index, n, values, label, is_global, axes_key):
|
|
392
|
+
"""Register (or validate) a slider unit and its connection index."""
|
|
393
|
+
if unit in self._sliders:
|
|
394
|
+
if self._sliders[unit]["n"] != n:
|
|
395
|
+
raise ValueError(
|
|
396
|
+
f"plot_frames() series in slider unit {unit!r} must share "
|
|
397
|
+
f"n_frames (have {self._sliders[unit]['n']}, got {n})"
|
|
398
|
+
)
|
|
399
|
+
return
|
|
400
|
+
if index is not None:
|
|
401
|
+
if index in self._slider_index_n and self._slider_index_n[index] != n:
|
|
402
|
+
raise ValueError(
|
|
403
|
+
f"plot_frames() series sharing slider index {index!r} must "
|
|
404
|
+
f"have the same n_frames (have {self._slider_index_n[index]}, "
|
|
405
|
+
f"got {n})"
|
|
406
|
+
)
|
|
407
|
+
self._slider_index_n[index] = n
|
|
408
|
+
vals = ([float(v) for v in values] if values is not None
|
|
409
|
+
else list(range(n)))
|
|
410
|
+
if len(vals) != n:
|
|
411
|
+
raise ValueError("slider_values length must equal n_frames")
|
|
412
|
+
self._sliders[unit] = {
|
|
413
|
+
"n": int(n), "values": vals, "label": label,
|
|
414
|
+
"index": index, "global": bool(is_global), "axes": axes_key,
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
# -- axes construction --------------------------------------------------
|
|
418
|
+
def add_axes(self, rect, projection=None) -> Axes:
|
|
419
|
+
"""Add an axes at ``rect = (left, bottom, width, height)`` (fractions).
|
|
420
|
+
|
|
421
|
+
``projection='polar'`` makes it a :class:`~plotpress.polar.PolarAxes`.
|
|
422
|
+
"""
|
|
423
|
+
ax = _axes_class(projection)(self, rect)
|
|
424
|
+
self.axes.append(ax)
|
|
425
|
+
return ax
|
|
426
|
+
|
|
427
|
+
def add_subplot(self, nrows=1, ncols=1, index=1, projection=None) -> Axes:
|
|
428
|
+
"""Add the ``index``-th axes (1-based) of an ``nrows`` x ``ncols`` grid.
|
|
429
|
+
|
|
430
|
+
``nrows`` may instead be a :class:`SubplotSpec` from
|
|
431
|
+
``fig.add_gridspec(...)[...]``, for an axes spanning multiple rows/
|
|
432
|
+
columns -- its initial rect covers only the span's top-left cell;
|
|
433
|
+
call :meth:`tight_layout`/:meth:`subplots_adjust` afterward to size it
|
|
434
|
+
to the full span.
|
|
435
|
+
|
|
436
|
+
``projection`` accepts the same values as :meth:`add_axes`
|
|
437
|
+
(``'polar'``).
|
|
438
|
+
"""
|
|
439
|
+
if isinstance(nrows, SubplotSpec):
|
|
440
|
+
spec = nrows
|
|
441
|
+
placeholder = spec.row0 * spec.ncols + spec.col0 + 1
|
|
442
|
+
ax = self.add_axes(
|
|
443
|
+
_subplot_rect(spec.nrows, spec.ncols, placeholder, self._subplot_params),
|
|
444
|
+
projection=projection)
|
|
445
|
+
ax._subplotspec = spec
|
|
446
|
+
return ax
|
|
447
|
+
ax = self.add_axes(_subplot_rect(nrows, ncols, index, self._subplot_params),
|
|
448
|
+
projection=projection)
|
|
449
|
+
ax._subplotspec = _cell_subplotspec(nrows, ncols, index)
|
|
450
|
+
return ax
|
|
451
|
+
|
|
452
|
+
def add_gridspec(self, nrows=1, ncols=1, **kwargs) -> GridSpec:
|
|
453
|
+
"""Return a :class:`GridSpec` for slicing into row/column spans.
|
|
454
|
+
|
|
455
|
+
``fig.add_subplot(fig.add_gridspec(2, 2)[0, :])`` spans both columns
|
|
456
|
+
of the top row. Any ``left``/``right``/``top``/``bottom``/``wspace``/
|
|
457
|
+
``hspace`` kwargs become this figure's margins immediately -- see
|
|
458
|
+
:class:`GridSpec`.
|
|
459
|
+
"""
|
|
460
|
+
return GridSpec(self, nrows, ncols, **kwargs)
|
|
461
|
+
|
|
462
|
+
def adopt_axes(self, ax) -> Axes:
|
|
463
|
+
"""Merge an axes built standalone -- most commonly a copy that just
|
|
464
|
+
crossed a process boundary -- into this figure, in place of
|
|
465
|
+
whichever of this figure's own axes shares its grid position.
|
|
466
|
+
|
|
467
|
+
A process boundary always hands back a *copy*: pickling an axes to
|
|
468
|
+
send it into a ``joblib``/``multiprocessing`` worker and back never
|
|
469
|
+
preserves object identity, however it looks -- ``ax.figure`` on
|
|
470
|
+
what comes back is a copy of this figure too, not ``self``, and
|
|
471
|
+
that copy's own ``ax.axes`` list still has the *worker's* version
|
|
472
|
+
of everything, not this figure's. Passed straight to
|
|
473
|
+
``fig.axes.append(ax)``, it would render at the wrong position
|
|
474
|
+
(or not enter the layout at all) and leave ``ax.figure`` pointing
|
|
475
|
+
at that disconnected copy. ``adopt_axes`` fixes both: finds the
|
|
476
|
+
axes already in ``self.axes`` whose :class:`SubplotSpec` matches
|
|
477
|
+
``ax``'s (same grid shape and cell span) and replaces it there --
|
|
478
|
+
same list position, so :meth:`tight_layout`/:meth:`subplots_adjust`
|
|
479
|
+
keep placing it exactly where that slot always was -- and
|
|
480
|
+
reparents ``ax.figure`` to ``self``.
|
|
481
|
+
|
|
482
|
+
A colorbar axes (``ax._subplotspec is None``, since it was never
|
|
483
|
+
placed on the grid itself) has no slot to match -- it is appended
|
|
484
|
+
instead, since :meth:`colorbar` always creates one that never
|
|
485
|
+
existed in this figure to begin with. Adopt it and the axes it
|
|
486
|
+
belongs to from the *same* returned result (e.g. both elements of
|
|
487
|
+
a worker's ``return ax, cax``): pickling preserves the object
|
|
488
|
+
graph *within* one call, so ``cax``'s own reference to ``ax``
|
|
489
|
+
survives the round trip already pointing at the exact object this
|
|
490
|
+
adopts, without anything further to fix up here.
|
|
491
|
+
|
|
492
|
+
Only ever carries one axes' worth of state across that boundary --
|
|
493
|
+
anything that compares axes by identity across *more than one* of
|
|
494
|
+
them (:meth:`group`, a colorbar shared over several axes,
|
|
495
|
+
:meth:`align_xlabels`) has to run after every worker's result has
|
|
496
|
+
been adopted, against the real, adopted objects -- never before
|
|
497
|
+
dispatch, and never inside the worker itself.
|
|
498
|
+
"""
|
|
499
|
+
ax.figure = self
|
|
500
|
+
if ax._subplotspec is None:
|
|
501
|
+
self.axes.append(ax)
|
|
502
|
+
return ax
|
|
503
|
+
spec = ax._subplotspec
|
|
504
|
+
for i, existing in enumerate(self.axes):
|
|
505
|
+
s = existing._subplotspec
|
|
506
|
+
if (s is not None and s.nrows == spec.nrows and s.ncols == spec.ncols
|
|
507
|
+
and s.row0 == spec.row0 and s.row1 == spec.row1
|
|
508
|
+
and s.col0 == spec.col0 and s.col1 == spec.col1):
|
|
509
|
+
self.axes[i] = ax
|
|
510
|
+
return ax
|
|
511
|
+
raise ValueError(
|
|
512
|
+
"adopt_axes(): no existing axes in this figure occupies "
|
|
513
|
+
f"{spec.nrows}x{spec.ncols} rows {spec.row0}-{spec.row1}, "
|
|
514
|
+
f"cols {spec.col0}-{spec.col1} -- adopt_axes() replaces an "
|
|
515
|
+
"axes already on the grid, it does not create a new slot"
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
def subplots(self, nrows=1, ncols=1, squeeze=True, sharex=False, sharey=False,
|
|
519
|
+
projection=None):
|
|
520
|
+
"""Create a grid of axes; return a single Axes or a NumPy array of them.
|
|
521
|
+
|
|
522
|
+
``sharex``/``sharey`` link the grid so autoscaling spans every subplot
|
|
523
|
+
(shared limits) and inner tick labels are hidden, like matplotlib.
|
|
524
|
+
``projection='polar'`` makes every axes in the grid polar.
|
|
525
|
+
"""
|
|
526
|
+
grid = np.empty((nrows, ncols), dtype=object)
|
|
527
|
+
for r in range(nrows):
|
|
528
|
+
for c in range(ncols):
|
|
529
|
+
index = r * ncols + c + 1
|
|
530
|
+
ax = self.add_axes(_subplot_rect(nrows, ncols, index, self._subplot_params),
|
|
531
|
+
projection=projection)
|
|
532
|
+
ax._subplotspec = _cell_subplotspec(nrows, ncols, index)
|
|
533
|
+
grid[r, c] = ax
|
|
534
|
+
|
|
535
|
+
axlist = grid.ravel().tolist()
|
|
536
|
+
if sharex:
|
|
537
|
+
for r in range(nrows):
|
|
538
|
+
for c in range(ncols):
|
|
539
|
+
grid[r, c]._sharex_group = axlist
|
|
540
|
+
if r != nrows - 1: # hide labels off the bottom row
|
|
541
|
+
grid[r, c].set_xticklabels([])
|
|
542
|
+
if sharey:
|
|
543
|
+
for r in range(nrows):
|
|
544
|
+
for c in range(ncols):
|
|
545
|
+
grid[r, c]._sharey_group = axlist
|
|
546
|
+
if c != 0: # hide labels off the left column
|
|
547
|
+
grid[r, c].set_yticklabels([])
|
|
548
|
+
|
|
549
|
+
if not squeeze:
|
|
550
|
+
return grid
|
|
551
|
+
if nrows == 1 and ncols == 1:
|
|
552
|
+
return grid[0, 0]
|
|
553
|
+
if nrows == 1 or ncols == 1:
|
|
554
|
+
return grid.ravel()
|
|
555
|
+
return grid
|
|
556
|
+
|
|
557
|
+
def tight_layout(self, pad=0.02):
|
|
558
|
+
"""Auto-fit subplot margins so ticks/labels/titles never overflow.
|
|
559
|
+
|
|
560
|
+
Measures each axes' decorations with the bundled font metrics and
|
|
561
|
+
re-lays-out the subplot grid. Safe to call before or after
|
|
562
|
+
:meth:`colorbar`; any colorbar over this grid is re-fitted afterwards.
|
|
563
|
+
Also safe to call *before* the titles and axis labels exist: the fit is
|
|
564
|
+
re-applied at render time if any of them change (see
|
|
565
|
+
:meth:`_settle_layout`).
|
|
566
|
+
"""
|
|
567
|
+
self._tight_pad = float(pad)
|
|
568
|
+
self._layout_dirty = False
|
|
569
|
+
from .svg import _resolve_tick_labels
|
|
570
|
+
from .ticker import log_ticks, nice_ticks
|
|
571
|
+
|
|
572
|
+
st = self.style
|
|
573
|
+
# Base, un-grown pixel size -- group_spacing()'s reservations add to
|
|
574
|
+
# this fresh each call (see Wpx/Hpx below), rather than compounding
|
|
575
|
+
# onto whatever a previous tight_layout() call already grew figsize
|
|
576
|
+
# to.
|
|
577
|
+
Wpx0 = self._base_figsize[0] * st.dpi
|
|
578
|
+
Hpx0 = self._base_figsize[1] * st.dpi
|
|
579
|
+
specs = [ax for ax in self.axes
|
|
580
|
+
if ax._subplotspec is not None and not ax._is_colorbar]
|
|
581
|
+
if not specs:
|
|
582
|
+
return self
|
|
583
|
+
nrows, ncols = specs[0]._subplotspec.nrows, specs[0]._subplotspec.ncols
|
|
584
|
+
|
|
585
|
+
# The top band stacks: a twiny's ticks and label sit directly above the
|
|
586
|
+
# box, and the title goes above those. Taking the max of the two would
|
|
587
|
+
# reserve room for whichever is taller and then draw them on each other.
|
|
588
|
+
left_px = bottom_px = right_px = 0.0
|
|
589
|
+
title_px = twin_top_px = 0.0
|
|
590
|
+
for ax in specs:
|
|
591
|
+
if ax._title:
|
|
592
|
+
title_px = max(title_px, (ax._title_size or st.title_size) + 8)
|
|
593
|
+
if ax._axis_off:
|
|
594
|
+
continue
|
|
595
|
+
# tick_params(labelsize=...)/(length=...) overrides this axes' own
|
|
596
|
+
# tick style -- svg.py already resolves them the same way (see
|
|
597
|
+
# its own xst/yst) before drawing. Margin reservation has to
|
|
598
|
+
# match what actually gets drawn, or a grid whose panels shrink
|
|
599
|
+
# their tick labels to fit (a common move on small multiples)
|
|
600
|
+
# keeps reserving margin sized for the figure-wide default,
|
|
601
|
+
# over-widening every gap next to it.
|
|
602
|
+
xst = st.copy(**ax._tick_overrides["x"]) if ax._tick_overrides["x"] else st
|
|
603
|
+
yst = st.copy(**ax._tick_overrides["y"]) if ax._tick_overrides["y"] else st
|
|
604
|
+
(xmin, xmax), (ymin, ymax) = ax._resolved_limits()
|
|
605
|
+
yt = (ax._yticks if ax._yticks is not None else
|
|
606
|
+
(log_ticks(ymin, ymax) if ax._yscale == "log" else nice_ticks(ymin, ymax)))
|
|
607
|
+
# Measure the labels as drawn: explicit set_yticklabels strings are
|
|
608
|
+
# usually far wider than the numbers they replace (category names),
|
|
609
|
+
# and sizing the margin from the tick *values* clips them.
|
|
610
|
+
ylabels = _resolve_tick_labels(ax._yticklabels, yt)
|
|
611
|
+
ytw = max((yst.text_width(l, yst.tick_label_size) for l in ylabels),
|
|
612
|
+
default=0.0)
|
|
613
|
+
right_px = max(right_px, xst.tick_label_size * 0.6) # last x label overhang
|
|
614
|
+
|
|
615
|
+
# A twin draws its axis on the side *opposite* its parent, so its
|
|
616
|
+
# decorations belong to the other margin. Measuring them into the
|
|
617
|
+
# left/bottom bands padded the wrong side and left the twin's own
|
|
618
|
+
# tick labels and axis label to overflow -- off the canvas for a
|
|
619
|
+
# single axes, and into the next panel for a grid.
|
|
620
|
+
if ax._twin_of is not None:
|
|
621
|
+
if ax._twin_shared == "x": # twinx: y on the right
|
|
622
|
+
rdec = yst.tick_size + ytw + 4
|
|
623
|
+
if ax._ylabel:
|
|
624
|
+
rdec += st.label_size + 6
|
|
625
|
+
right_px = max(right_px, rdec)
|
|
626
|
+
else: # twiny: x on the top
|
|
627
|
+
tdec = xst.tick_size + xst.tick_label_size + 4
|
|
628
|
+
if ax._xlabel:
|
|
629
|
+
tdec += st.label_size + 6
|
|
630
|
+
twin_top_px = max(twin_top_px, tdec)
|
|
631
|
+
continue
|
|
632
|
+
|
|
633
|
+
# tick_top()/tick_right() move an axes' own ticks off the default
|
|
634
|
+
# bottom/left edge, so their decoration band moves with them --
|
|
635
|
+
# into the same top/right bands a twin's opposite-side ticks use,
|
|
636
|
+
# rather than the bottom/left band the default side would need.
|
|
637
|
+
ldec = yst.tick_size + ytw + 4
|
|
638
|
+
if ax._ylabel:
|
|
639
|
+
ldec += st.label_size + 6
|
|
640
|
+
if ax._ytick_side == "right":
|
|
641
|
+
right_px = max(right_px, ldec)
|
|
642
|
+
else:
|
|
643
|
+
left_px = max(left_px, ldec)
|
|
644
|
+
bdec = xst.tick_size + xst.tick_label_size + 4
|
|
645
|
+
if ax._xlabel:
|
|
646
|
+
bdec += st.label_size + 6
|
|
647
|
+
if ax._xtick_side == "top":
|
|
648
|
+
twin_top_px = max(twin_top_px, bdec)
|
|
649
|
+
else:
|
|
650
|
+
bottom_px = max(bottom_px, bdec)
|
|
651
|
+
|
|
652
|
+
top_px = title_px + twin_top_px
|
|
653
|
+
|
|
654
|
+
# Figure-level titles/labels reserve their own outer-margin band --
|
|
655
|
+
# kept apart from top_px/bottom_px/left_px themselves (which also
|
|
656
|
+
# seed gap_w/gap_h below, the *interior* row/col gap) since, unlike a
|
|
657
|
+
# per-axes title or tick label -- which can legitimately sit on any
|
|
658
|
+
# interior row/col boundary and so must widen every gap along with
|
|
659
|
+
# it -- a suptitle/supxlabel/supylabel draws once, outside the whole
|
|
660
|
+
# grid, and must never widen an interior gap it is nowhere near.
|
|
661
|
+
fig_top_px = fig_bottom_px = fig_left_px = 0.0
|
|
662
|
+
if self._suptitle:
|
|
663
|
+
fig_top_px += (self._suptitle.get("size") or st.title_size * 1.5) + 6
|
|
664
|
+
if self._supxlabel:
|
|
665
|
+
fig_bottom_px += (self._supxlabel.get("size") or st.label_size * 1.2) + 6
|
|
666
|
+
if self._supylabel:
|
|
667
|
+
fig_left_px += (self._supylabel.get("size") or st.label_size * 1.2) + 6
|
|
668
|
+
|
|
669
|
+
# A group's title, when it faces the grid's own outer edge, needs the
|
|
670
|
+
# same kind of band reserved -- otherwise it (or the box itself, for
|
|
671
|
+
# a top-facing title over a titled top row) draws off the canvas or
|
|
672
|
+
# over the outermost panels. A group that doesn't reach that edge
|
|
673
|
+
# (an interior cluster) has its title in a row/col gap instead, which
|
|
674
|
+
# this does not touch -- reserving hspace/wspace for one arbitrary
|
|
675
|
+
# interior group would grow it for every row/col, not just that one.
|
|
676
|
+
# Kept separate from left_px/top_px/etc. themselves: those also seed
|
|
677
|
+
# gap_w/gap_h below (the interior row/col gap), and unlike a twin's
|
|
678
|
+
# decorations -- which can genuinely sit on an interior boundary --
|
|
679
|
+
# a group's title only ever faces an *outer* edge (checked below), so
|
|
680
|
+
# it must never widen every interior gap along with it.
|
|
681
|
+
group_top_px = group_bottom_px = group_left_px = group_right_px = 0.0
|
|
682
|
+
# Which interior row/col boundaries actually border a group's own
|
|
683
|
+
# bounding box -- group_spacing() only widens *these*, not every
|
|
684
|
+
# boundary alike, so two rows paired inside the same group stay as
|
|
685
|
+
# tight as tight_layout() would put them; only the seam between that
|
|
686
|
+
# group and its neighbor grows. Every edge of the box counts here
|
|
687
|
+
# (not just the title-facing one above): the box itself carries
|
|
688
|
+
# ``pad`` clearance on all four sides regardless of where its title
|
|
689
|
+
# sits, and two boxes facing each other across a boundary neither
|
|
690
|
+
# title touches would otherwise collide with no reservation for
|
|
691
|
+
# either of them.
|
|
692
|
+
col_needs_wspace = [False] * (ncols - 1)
|
|
693
|
+
row_needs_hspace = [False] * (nrows - 1)
|
|
694
|
+
for g in self._groups:
|
|
695
|
+
g_specs = [ax for ax in g["axes"] if ax._subplotspec is not None]
|
|
696
|
+
if not g_specs:
|
|
697
|
+
continue
|
|
698
|
+
size = g["fontsize"] or st.title_size
|
|
699
|
+
pos = g["title_position"]
|
|
700
|
+
# pad is (left, right, top, bottom) -- the margin this title's
|
|
701
|
+
# own side needs to reserve is that side's own clearance, not
|
|
702
|
+
# some other edge's (an asymmetric pad -- tight on the side
|
|
703
|
+
# butting a neighboring group, loose on the title side -- would
|
|
704
|
+
# otherwise reserve the wrong amount here).
|
|
705
|
+
pad_l, pad_r, pad_t, pad_b = g["pad"]
|
|
706
|
+
if pos in ("top", "bottom"):
|
|
707
|
+
# 1.3x size -- not 1x -- for the same reason title_px above
|
|
708
|
+
# adds a flat +8 rather than measuring real glyph ascent:
|
|
709
|
+
# bundled font metrics only cover advance widths (see
|
|
710
|
+
# fonts/), not vertical extents, so this errs generous
|
|
711
|
+
# rather than risk the title's own glyphs clipping the
|
|
712
|
+
# canvas edge.
|
|
713
|
+
side_pad = pad_t if pos == "top" else pad_b
|
|
714
|
+
extent = side_pad + size * 1.3 + 10
|
|
715
|
+
else:
|
|
716
|
+
# A left/right title runs horizontally alongside the box, not
|
|
717
|
+
# centered over it -- its own rendered *width* is what has to
|
|
718
|
+
# fit in the reserved margin here, not a height allowance.
|
|
719
|
+
side_pad = pad_l if pos == "left" else pad_r
|
|
720
|
+
extent = side_pad + st.text_width(g["title"], size, bold=True) + 12
|
|
721
|
+
r0 = min(ax._subplotspec.row0 for ax in g_specs)
|
|
722
|
+
r1 = max(ax._subplotspec.row1 for ax in g_specs)
|
|
723
|
+
c0 = min(ax._subplotspec.col0 for ax in g_specs)
|
|
724
|
+
c1 = max(ax._subplotspec.col1 for ax in g_specs)
|
|
725
|
+
# "Touches that edge" -- the group's bounding box reaches row 0 /
|
|
726
|
+
# the last row / column 0 / the last column -- not "every one of
|
|
727
|
+
# its axes sits in that single row/col": a group spanning several
|
|
728
|
+
# rows in a column-band (say) still needs a top-margin band for
|
|
729
|
+
# its top-facing title even though most of its own axes are in
|
|
730
|
+
# rows 1+, same as one spanning a single row would.
|
|
731
|
+
if pos == "top" and r0 == 0:
|
|
732
|
+
group_top_px += extent
|
|
733
|
+
elif pos == "bottom" and r1 == nrows - 1:
|
|
734
|
+
group_bottom_px += extent
|
|
735
|
+
elif pos == "left" and c0 == 0:
|
|
736
|
+
group_left_px += extent
|
|
737
|
+
elif pos == "right" and c1 == ncols - 1:
|
|
738
|
+
group_right_px += extent
|
|
739
|
+
if r0 > 0:
|
|
740
|
+
row_needs_hspace[r0 - 1] = True
|
|
741
|
+
if r1 < nrows - 1:
|
|
742
|
+
row_needs_hspace[r1] = True
|
|
743
|
+
if c0 > 0:
|
|
744
|
+
col_needs_wspace[c0 - 1] = True
|
|
745
|
+
if c1 < ncols - 1:
|
|
746
|
+
col_needs_wspace[c1] = True
|
|
747
|
+
|
|
748
|
+
# group_spacing() grows the figure to hold its reservation instead of
|
|
749
|
+
# shrinking the axes to fit it -- each boundary that actually needs
|
|
750
|
+
# it (computed above) adds the requested pixels once, on top of the
|
|
751
|
+
# *base* size (the one last given to the constructor or
|
|
752
|
+
# set_size_inches()), so a repeated tight_layout() call re-derives
|
|
753
|
+
# this fresh rather than compounding growth onto an already-grown
|
|
754
|
+
# figsize.
|
|
755
|
+
extra_w_px = self._group_wspace * sum(col_needs_wspace) if self._group_wspace else 0.0
|
|
756
|
+
extra_h_px = self._group_hspace * sum(row_needs_hspace) if self._group_hspace else 0.0
|
|
757
|
+
Wpx = Wpx0 + extra_w_px
|
|
758
|
+
Hpx = Hpx0 + extra_h_px
|
|
759
|
+
self.figsize = (Wpx / st.dpi, Hpx / st.dpi)
|
|
760
|
+
|
|
761
|
+
# The outer edge pad is sized from the figure's *base* dimensions --
|
|
762
|
+
# group_spacing()'s growth is purely extra interior room, and must
|
|
763
|
+
# not also inflate this independent margin.
|
|
764
|
+
edge = pad * min(Wpx0, Hpx0) + 4
|
|
765
|
+
left = (left_px + group_left_px + fig_left_px + edge) / Wpx
|
|
766
|
+
right = 1 - (right_px + group_right_px + edge) / Wpx
|
|
767
|
+
bottom = (bottom_px + group_bottom_px + fig_bottom_px + edge) / Hpx
|
|
768
|
+
top = 1 - (top_px + group_top_px + fig_top_px + edge) / Hpx
|
|
769
|
+
# An interior column gap has to hold the right-hand decorations of the
|
|
770
|
+
# column to its left as well as the left-hand ones of the column to its
|
|
771
|
+
# right -- the row gap has always summed both bands, and a twinx in a
|
|
772
|
+
# grid is what makes the missing term visible. Groups are excluded
|
|
773
|
+
# (see above): they never contribute to an interior gap.
|
|
774
|
+
base_gap_w = (left_px + right_px) / Wpx # interior column gap
|
|
775
|
+
base_gap_h = (bottom_px + top_px) / Hpx # interior row gap
|
|
776
|
+
# group_spacing() is the one deliberate exception: an explicit ask
|
|
777
|
+
# for more room between subplots specifically for group boxes,
|
|
778
|
+
# independent of what their tick labels alone would need -- added
|
|
779
|
+
# only to the boundaries that actually border a group (computed
|
|
780
|
+
# above), not folded into left_px/etc. above, so it never touches
|
|
781
|
+
# the outer margin those also seed.
|
|
782
|
+
gap_w_list = [base_gap_w + (self._group_wspace / Wpx
|
|
783
|
+
if needs and self._group_wspace else 0.0)
|
|
784
|
+
for needs in col_needs_wspace]
|
|
785
|
+
gap_h_list = [base_gap_h + (self._group_hspace / Hpx
|
|
786
|
+
if needs and self._group_hspace else 0.0)
|
|
787
|
+
for needs in row_needs_hspace]
|
|
788
|
+
axw, gap_w_list = _fit_cells(right - left, ncols, gap_w_list)
|
|
789
|
+
axh, gap_h_list = _fit_cells(top - bottom, nrows, gap_h_list)
|
|
790
|
+
|
|
791
|
+
_place_spec_rects(specs, nrows, ncols, left, bottom, axw, axh, gap_w_list, gap_h_list)
|
|
792
|
+
self._finish_grid_relayout(specs)
|
|
793
|
+
return self
|
|
794
|
+
|
|
795
|
+
def _finish_grid_relayout(self, specs):
|
|
796
|
+
"""Shared tail of :meth:`tight_layout`/:meth:`subplots_adjust`.
|
|
797
|
+
|
|
798
|
+
Both rewrite every grid axes' ``_rect`` from scratch, which undoes
|
|
799
|
+
whatever a figure legend or colorbar had already stolen from it, and
|
|
800
|
+
leaves any ``align_xlabels``/``align_ylabels`` override pointing at
|
|
801
|
+
stale pixel offsets. Reapply all three, in this order: alignment must
|
|
802
|
+
measure the *final* (already-shrunk) boxes, so it runs last.
|
|
803
|
+
"""
|
|
804
|
+
# Take back the figure-legend band first, so a colorbar then fits
|
|
805
|
+
# inside what is actually left (same ordering colorbar needs below).
|
|
806
|
+
_layout_figure_legend(self)
|
|
807
|
+
|
|
808
|
+
# Colorbars over axes this pass did not touch are left alone, since
|
|
809
|
+
# their parents are still carrying the original steal.
|
|
810
|
+
for cax in self.axes:
|
|
811
|
+
if cax._is_colorbar and cax._cbar_parents:
|
|
812
|
+
if all(p in specs for p in cax._cbar_parents):
|
|
813
|
+
_layout_colorbar(cax)
|
|
814
|
+
|
|
815
|
+
# Insets are positioned as a fraction of their parent's rect, which
|
|
816
|
+
# the reflow above may have just moved -- re-derive rather than let
|
|
817
|
+
# them drift from where their parent ended up.
|
|
818
|
+
for iax in self.axes:
|
|
819
|
+
if iax._inset_parent is not None:
|
|
820
|
+
_layout_inset(iax)
|
|
821
|
+
|
|
822
|
+
if self._align_x_axes is not _ALIGN_UNSET:
|
|
823
|
+
self.align_xlabels(self._align_x_axes)
|
|
824
|
+
if self._align_y_axes is not _ALIGN_UNSET:
|
|
825
|
+
self.align_ylabels(self._align_y_axes)
|
|
826
|
+
|
|
827
|
+
def subplots_adjust(self, left=None, right=None, top=None, bottom=None,
|
|
828
|
+
wspace=None, hspace=None):
|
|
829
|
+
"""Directly set the subplot grid's margins (matplotlib's own knobs).
|
|
830
|
+
|
|
831
|
+
Only the given kwargs change; the others keep their last value
|
|
832
|
+
(initially matplotlib's own defaults). Mutually exclusive with
|
|
833
|
+
:meth:`tight_layout` -- both rewrite every grid axes' rect from
|
|
834
|
+
scratch, so whichever is called last wins; this also clears
|
|
835
|
+
``tight_layout``'s pending re-fit so :meth:`_settle_layout` doesn't
|
|
836
|
+
undo it on the next render.
|
|
837
|
+
"""
|
|
838
|
+
sp = self._subplot_params
|
|
839
|
+
for key, val in (("left", left), ("right", right), ("top", top),
|
|
840
|
+
("bottom", bottom), ("wspace", wspace), ("hspace", hspace)):
|
|
841
|
+
if val is not None:
|
|
842
|
+
sp[key] = float(val)
|
|
843
|
+
self._tight_pad = None
|
|
844
|
+
self._layout_dirty = False
|
|
845
|
+
|
|
846
|
+
specs = [ax for ax in self.axes
|
|
847
|
+
if ax._subplotspec is not None and not ax._is_colorbar]
|
|
848
|
+
if not specs:
|
|
849
|
+
return self
|
|
850
|
+
nrows, ncols = specs[0]._subplotspec.nrows, specs[0]._subplotspec.ncols
|
|
851
|
+
|
|
852
|
+
avail_w = sp["right"] - sp["left"]
|
|
853
|
+
avail_h = sp["top"] - sp["bottom"]
|
|
854
|
+
axw = avail_w / (ncols + sp["wspace"] * (ncols - 1))
|
|
855
|
+
axh = avail_h / (nrows + sp["hspace"] * (nrows - 1))
|
|
856
|
+
gap_w, gap_h = axw * sp["wspace"], axh * sp["hspace"]
|
|
857
|
+
|
|
858
|
+
_place_spec_rects(specs, nrows, ncols, sp["left"], sp["bottom"], axw, axh,
|
|
859
|
+
[gap_w] * (ncols - 1), [gap_h] * (nrows - 1))
|
|
860
|
+
self._finish_grid_relayout(specs)
|
|
861
|
+
return self
|
|
862
|
+
|
|
863
|
+
def align_xlabels(self, axes=None):
|
|
864
|
+
"""Align the x-axis labels of ``axes`` (default: all) to one baseline.
|
|
865
|
+
|
|
866
|
+
Panels with different tick-label widths otherwise put their x label at
|
|
867
|
+
different heights below the box. Only axes side by side in the same
|
|
868
|
+
*row* (matching ``SubplotSpec`` row span) are aligned with each other
|
|
869
|
+
-- like matplotlib, this does not pull together labels in different
|
|
870
|
+
rows, which sit under different boxes at different y positions and
|
|
871
|
+
have no shared "depth" worth matching. Axes with no ``_subplotspec``
|
|
872
|
+
(a custom ``add_axes`` layout) form one fallback group together.
|
|
873
|
+
Re-applied automatically after :meth:`tight_layout`/
|
|
874
|
+
:meth:`subplots_adjust` reflow the grid.
|
|
875
|
+
"""
|
|
876
|
+
from .svg import _effective_rect, _pixel_rect
|
|
877
|
+
|
|
878
|
+
self._align_x_axes = axes
|
|
879
|
+
axlist = [a for a in (axes if axes is not None else self.axes)
|
|
880
|
+
if a._xlabel and not a._axis_off]
|
|
881
|
+
if not axlist:
|
|
882
|
+
return self
|
|
883
|
+
st = self.style
|
|
884
|
+
W = self.figsize[0] * st.dpi
|
|
885
|
+
H = self.figsize[1] * st.dpi
|
|
886
|
+
|
|
887
|
+
def row_key(ax):
|
|
888
|
+
spec = ax._subplotspec
|
|
889
|
+
return None if spec is None else (spec.row0, spec.row1)
|
|
890
|
+
|
|
891
|
+
for group in _group_by(axlist, row_key):
|
|
892
|
+
ys = []
|
|
893
|
+
for ax in group:
|
|
894
|
+
(xmin, xmax), (ymin, ymax) = ax._resolved_limits()
|
|
895
|
+
_, px_top, _, px_h = _effective_rect(
|
|
896
|
+
ax, *_pixel_rect(ax, W, H), (xmin, xmax), (ymin, ymax))
|
|
897
|
+
ys.append(px_top + px_h + st.tick_size + st.tick_label_size
|
|
898
|
+
+ st.label_size + 4)
|
|
899
|
+
y = max(ys)
|
|
900
|
+
for ax in group:
|
|
901
|
+
ax._xlabel_y_override = y
|
|
902
|
+
return self
|
|
903
|
+
|
|
904
|
+
def align_ylabels(self, axes=None):
|
|
905
|
+
"""Align the y-axis labels of ``axes`` (default: all) to one column.
|
|
906
|
+
|
|
907
|
+
See :meth:`align_xlabels`: this aligns the *leftmost* position any
|
|
908
|
+
panel's y label needs, but only among axes stacked in the same
|
|
909
|
+
*column* (matching ``SubplotSpec`` column span) -- panels in
|
|
910
|
+
different columns sit under different boxes and are not pulled
|
|
911
|
+
together.
|
|
912
|
+
"""
|
|
913
|
+
from .svg import _effective_rect, _max_ytick_width, _pixel_rect
|
|
914
|
+
|
|
915
|
+
self._align_y_axes = axes
|
|
916
|
+
axlist = [a for a in (axes if axes is not None else self.axes)
|
|
917
|
+
if a._ylabel and not a._axis_off]
|
|
918
|
+
if not axlist:
|
|
919
|
+
return self
|
|
920
|
+
st = self.style
|
|
921
|
+
W = self.figsize[0] * st.dpi
|
|
922
|
+
H = self.figsize[1] * st.dpi
|
|
923
|
+
|
|
924
|
+
def col_key(ax):
|
|
925
|
+
spec = ax._subplotspec
|
|
926
|
+
return None if spec is None else (spec.col0, spec.col1)
|
|
927
|
+
|
|
928
|
+
for group in _group_by(axlist, col_key):
|
|
929
|
+
xs = []
|
|
930
|
+
for ax in group:
|
|
931
|
+
(xmin, xmax), (ymin, ymax) = ax._resolved_limits()
|
|
932
|
+
px_left, _, _, _ = _effective_rect(
|
|
933
|
+
ax, *_pixel_rect(ax, W, H), (xmin, xmax), (ymin, ymax))
|
|
934
|
+
xs.append(px_left - st.tick_size - _max_ytick_width(ax, st)
|
|
935
|
+
- st.label_size - 4)
|
|
936
|
+
x = min(xs)
|
|
937
|
+
for ax in group:
|
|
938
|
+
ax._ylabel_x_override = x
|
|
939
|
+
return self
|
|
940
|
+
|
|
941
|
+
def align_labels(self, axes=None):
|
|
942
|
+
"""Align both x and y axis labels; see :meth:`align_xlabels`/:meth:`align_ylabels`."""
|
|
943
|
+
self.align_xlabels(axes)
|
|
944
|
+
self.align_ylabels(axes)
|
|
945
|
+
return self
|
|
946
|
+
|
|
947
|
+
# -- figure-level legend ------------------------------------------------
|
|
948
|
+
def legend(self, ax=None, loc="lower center", ncol=1, title=None,
|
|
949
|
+
pad=0.01, fontsize=None, framealpha=0.85) -> "Figure":
|
|
950
|
+
"""One legend for the whole figure, drawn from labelled artists.
|
|
951
|
+
|
|
952
|
+
The counterpart to :meth:`colorbar` over a list of axes: a grid whose
|
|
953
|
+
panels all plot the same series wants one legend, not the same entries
|
|
954
|
+
repeated in every panel. Labels are de-duplicated across the axes, so
|
|
955
|
+
each series appears once however many panels draw it.
|
|
956
|
+
|
|
957
|
+
``ax`` selects which axes contribute (default: all of them).
|
|
958
|
+
``fontsize``/``framealpha`` match :meth:`Axes.legend`.
|
|
959
|
+
|
|
960
|
+
``loc`` names a placement in **figure** coordinates. The four outside
|
|
961
|
+
placements -- ``"lower center"``, ``"upper center"``, ``"right"`` and
|
|
962
|
+
``"center left"`` (also ``"center right"``) -- reserve a band at that
|
|
963
|
+
edge and shrink the subplot grid to fit, so the legend never lands on a
|
|
964
|
+
plot. Any other name overlays without reserving, matching how an axes
|
|
965
|
+
legend sits inside its own rect.
|
|
966
|
+
|
|
967
|
+
Order relative to :meth:`tight_layout` does not matter -- the reservation
|
|
968
|
+
is re-applied whenever the grid is reflowed.
|
|
969
|
+
"""
|
|
970
|
+
self._figure_legend = {
|
|
971
|
+
"axes": _flatten_axes(ax) if ax is not None else None,
|
|
972
|
+
"loc": loc,
|
|
973
|
+
"ncol": max(1, int(ncol)),
|
|
974
|
+
"title": title,
|
|
975
|
+
"pad": float(pad),
|
|
976
|
+
"fontsize": fontsize,
|
|
977
|
+
"framealpha": framealpha,
|
|
978
|
+
}
|
|
979
|
+
_layout_figure_legend(self)
|
|
980
|
+
return self
|
|
981
|
+
|
|
982
|
+
# -- colorbar -----------------------------------------------------------
|
|
983
|
+
def colorbar(self, mappable, ax, fraction=0.05, pad=0.02) -> Axes:
|
|
984
|
+
"""Add a colorbar for ``mappable``.
|
|
985
|
+
|
|
986
|
+
``ax`` may be a single :class:`~plotpress.axes.Axes` (the colorbar
|
|
987
|
+
steals space from it) or a list / array of axes (one **shared** colorbar
|
|
988
|
+
spanning them all, placed on their right -- the grid is squeezed to make
|
|
989
|
+
room). All the axes should share the mappable's ``vmin``/``vmax`` for the
|
|
990
|
+
shared bar to describe them accurately.
|
|
991
|
+
|
|
992
|
+
Order relative to :meth:`tight_layout` does not matter -- the steal is
|
|
993
|
+
recorded and re-applied whenever the grid is reflowed.
|
|
994
|
+
"""
|
|
995
|
+
if mappable is None or not hasattr(mappable, "norm"):
|
|
996
|
+
raise TypeError(
|
|
997
|
+
"colorbar(): mappable must be an artist with a color norm "
|
|
998
|
+
"-- what pcolormesh()/imshow()/hexbin()/scatter(c=...) "
|
|
999
|
+
f"returns -- got {mappable!r}"
|
|
1000
|
+
)
|
|
1001
|
+
if not (fraction > 0):
|
|
1002
|
+
raise ValueError(
|
|
1003
|
+
f"colorbar(): fraction must be > 0 (the share of the "
|
|
1004
|
+
f"parent axes' width the bar steals), got {fraction!r} -- "
|
|
1005
|
+
"zero/negative produces a colorbar axes with negative "
|
|
1006
|
+
"width, an invalid layout."
|
|
1007
|
+
)
|
|
1008
|
+
cax = self.add_axes((0.0, 0.0, 1.0, 1.0)) # rect set by _layout_colorbar
|
|
1009
|
+
cax._is_colorbar = True
|
|
1010
|
+
cax._cbar_source = mappable
|
|
1011
|
+
cax._cbar_parents = _flatten_axes(ax)
|
|
1012
|
+
cax._cbar_fraction = float(fraction)
|
|
1013
|
+
cax._cbar_pad = float(pad)
|
|
1014
|
+
_layout_colorbar(cax)
|
|
1015
|
+
return cax
|
|
1016
|
+
|
|
1017
|
+
# -- serialization ------------------------------------------------------
|
|
1018
|
+
def to_svg(self) -> str:
|
|
1019
|
+
return figure_to_svg(self)
|
|
1020
|
+
|
|
1021
|
+
def to_vega(self, mesh_data: bool = False) -> dict:
|
|
1022
|
+
"""A real Vega (not Vega-Lite) v5 JSON specification, as a plain
|
|
1023
|
+
``dict`` -- ``json.dumps(fig.to_vega(), indent=2)`` for the string,
|
|
1024
|
+
or hand the dict itself to a Vega runtime that already accepts a
|
|
1025
|
+
Python object.
|
|
1026
|
+
|
|
1027
|
+
Unlike ``to_svg()``/``to_html()``, the result needs a separate Vega
|
|
1028
|
+
renderer to actually draw (``vega-embed`` in a browser, the
|
|
1029
|
+
``vg2svg``/``vg2png`` CLI tools, an Observable notebook, IPython's
|
|
1030
|
+
own ``vega`` MIME renderer, ...) -- it is a real, standalone,
|
|
1031
|
+
portable specification, not a rendered artifact, so no plotpress or
|
|
1032
|
+
Python is needed at render time. One axes becomes one Vega
|
|
1033
|
+
``group`` mark with its own local scales/axes/marks, positioned at
|
|
1034
|
+
that axes' own resolved pixel rect. Line/scatter/bar charts use
|
|
1035
|
+
genuine ``field``/``scale``-encoded marks; everything else reuses
|
|
1036
|
+
the same pixel-space primitives ``to_svg()`` itself draws from
|
|
1037
|
+
(:mod:`plotpress.primitives`), so it is visually exact but frozen at
|
|
1038
|
+
this export's own size/limits -- not reactive to a Vega zoom/pan
|
|
1039
|
+
signal or a runtime domain change the way the line/scatter/bar
|
|
1040
|
+
marks are. See :mod:`plotpress.vega`'s own module docstring for the
|
|
1041
|
+
full design rationale, including what's skipped (box plots,
|
|
1042
|
+
violins, quiver, contour, event plots, wind barbs, tables -- each
|
|
1043
|
+
emits a ``UserWarning`` naming it and continues exporting the rest
|
|
1044
|
+
of the figure) and what never carries over regardless (plotpress's
|
|
1045
|
+
own interactive toolbar; Vega has its own separate interaction
|
|
1046
|
+
model instead, reachable by wiring up ``signals`` on the result).
|
|
1047
|
+
|
|
1048
|
+
``mesh_data=True`` opts a ``pcolormesh``/mesh-backed ``imshow``
|
|
1049
|
+
into real per-cell ``rect`` marks with a genuine field+scale color
|
|
1050
|
+
encoding, instead of the default rasterized ``image`` mark --
|
|
1051
|
+
reactive and queryable, but only for meshes small/simple enough to
|
|
1052
|
+
stay unambiguous (a rectilinear grid, a plain linear color norm, a
|
|
1053
|
+
colormap with a matching named Vega scheme, at most ~2000 cells --
|
|
1054
|
+
the same threshold ``pcolormesh(rasterized=None)``'s own auto-mode
|
|
1055
|
+
already uses). A mesh that doesn't qualify still gets the image
|
|
1056
|
+
mark, with a ``UserWarning`` naming why.
|
|
1057
|
+
"""
|
|
1058
|
+
from .vega import figure_to_vega
|
|
1059
|
+
return figure_to_vega(self, mesh_data=mesh_data)
|
|
1060
|
+
|
|
1061
|
+
def to_vega_lite(self, mesh_data: bool = False) -> tuple:
|
|
1062
|
+
"""A Vega-Lite v5 specification for this figure.
|
|
1063
|
+
|
|
1064
|
+
Unlike :meth:`to_svg`/:meth:`to_html`/:meth:`to_vega`, which all
|
|
1065
|
+
return one plain value, this returns ``(result, caveats)`` --
|
|
1066
|
+
a deliberate, documented departure from its siblings, not an
|
|
1067
|
+
oversight. ``result`` is ``{"grid": <spec> | None, "standalone":
|
|
1068
|
+
[<spec>, ...]}``: a combined spec for whatever axes compose
|
|
1069
|
+
cleanly into Vega-Lite's ``hconcat``/``vconcat`` grid, plus a list
|
|
1070
|
+
of independent specs for anything that doesn't (a single axes with
|
|
1071
|
+
nothing to grid against, a free-form ``add_axes()``/``inset_axes()``
|
|
1072
|
+
panel, a mismatched-shape multi-grid figure). ``caveats`` is a list
|
|
1073
|
+
of human-readable strings describing every structural compromise
|
|
1074
|
+
made building the result -- data for a caller deciding what to do
|
|
1075
|
+
with a partially-composed figure, not just console noise; every
|
|
1076
|
+
entry is also re-emitted as a ``UserWarning``, so a caller who
|
|
1077
|
+
ignores the tuple still sees the same warning.
|
|
1078
|
+
|
|
1079
|
+
Vega-Lite's mark vocabulary is closed (no raw path-per-datum mark
|
|
1080
|
+
the way Vega has) and its composition model is grid-like, not
|
|
1081
|
+
arbitrary-pixel-positioned, so this is a stricter target than
|
|
1082
|
+
:meth:`to_vega` in both what a single axes can draw and how several
|
|
1083
|
+
axes can be arranged together -- see :mod:`plotpress.vega_lite`'s
|
|
1084
|
+
own module docstring for the full fidelity-tier breakdown (what
|
|
1085
|
+
maps natively, what needs a layered workaround, and what has no
|
|
1086
|
+
Vega-Lite mapping at all and warns instead) and the exact
|
|
1087
|
+
figure-composition algorithm.
|
|
1088
|
+
|
|
1089
|
+
``mesh_data=True`` opts a ``pcolormesh``/mesh-backed ``imshow``
|
|
1090
|
+
into real per-cell ``rect`` marks with a genuine field+scale color
|
|
1091
|
+
encoding, instead of the default rasterized ``image`` mark -- the
|
|
1092
|
+
same opt-in, same eligibility rules (a rectilinear grid, a plain
|
|
1093
|
+
linear color norm, a colormap with a matching named Vega scheme,
|
|
1094
|
+
at most ~2000 cells), and same warn-and-fall-back-to-image
|
|
1095
|
+
behavior otherwise, as :meth:`to_vega`'s own ``mesh_data``.
|
|
1096
|
+
"""
|
|
1097
|
+
from .vega_lite import figure_to_vega_lite
|
|
1098
|
+
return figure_to_vega_lite(self, mesh_data=mesh_data)
|
|
1099
|
+
|
|
1100
|
+
def print_layout_summary(self) -> None:
|
|
1101
|
+
"""Print a plain-English orientation to this figure's layout --
|
|
1102
|
+
how many axes, how they're arranged (a grid, spans, twins,
|
|
1103
|
+
insets, colorbars, free-form panels), what's plotted on each one,
|
|
1104
|
+
and whether each would export cleanly to :meth:`to_vega`/
|
|
1105
|
+
:meth:`to_vega_lite`. Meant for a REPL/notebook, when a figure
|
|
1106
|
+
came from somewhere else (a saved layout, an imported HTML file,
|
|
1107
|
+
code you didn't write) and the fastest way to understand it is to
|
|
1108
|
+
just ask it -- not for programmatic use (nothing here is returned;
|
|
1109
|
+
see :meth:`~plotpress.axes.Axes.print_summary` for one axes at a
|
|
1110
|
+
time, or read ``fig.axes``/``ax.artists`` directly for that).
|
|
1111
|
+
|
|
1112
|
+
Named ``print_*`` (not e.g. ``layout_summary``) so it tab-completes
|
|
1113
|
+
alongside every other summary method this library adds -- see
|
|
1114
|
+
:meth:`~plotpress.axes.Axes.print_summary` for the per-axes one.
|
|
1115
|
+
"""
|
|
1116
|
+
visible = [ax for ax in self.axes if ax._visible]
|
|
1117
|
+
print(f"Figure: {len(self.axes)} axes ({len(visible)} visible), "
|
|
1118
|
+
f"figsize={self.figsize}")
|
|
1119
|
+
if self._groups:
|
|
1120
|
+
names = ", ".join(repr(g["title"]) for g in self._groups)
|
|
1121
|
+
print(f" Figure.group() boxes: {names}")
|
|
1122
|
+
gaps = _vega_compat_report(self)
|
|
1123
|
+
fig_level = gaps.get(None)
|
|
1124
|
+
if fig_level and (fig_level["vega"] or fig_level["vega_lite"]):
|
|
1125
|
+
print(" figure-level export notes:")
|
|
1126
|
+
for msg in fig_level["vega"]:
|
|
1127
|
+
print(f" - [vega] {msg}")
|
|
1128
|
+
for msg in fig_level["vega_lite"]:
|
|
1129
|
+
print(f" - [vega-lite] {msg}")
|
|
1130
|
+
for i, ax in enumerate(self.axes):
|
|
1131
|
+
print(f"\nAxes {i}:")
|
|
1132
|
+
for line in _axes_summary_lines(ax, gaps.get(i, {"vega": [], "vega_lite": []})):
|
|
1133
|
+
print(line)
|
|
1134
|
+
|
|
1135
|
+
def _repr_svg_(self) -> str:
|
|
1136
|
+
# Static inline SVG is the Jupyter default; use to_html for interactive.
|
|
1137
|
+
return figure_to_svg(self)
|
|
1138
|
+
|
|
1139
|
+
def to_html(self, interactive: bool = True, wait_extract: bool = False,
|
|
1140
|
+
pick_precision: int = 6, pick_max_mesh_cells: int = 250000,
|
|
1141
|
+
pick_max_points: int = 20000, binary_pick_data: bool = True,
|
|
1142
|
+
standalone: bool = True, include_default_js: bool = True,
|
|
1143
|
+
extra_js: str = None) -> str:
|
|
1144
|
+
"""Serialize to a self-contained HTML document.
|
|
1145
|
+
|
|
1146
|
+
``standalone`` (default) centers the figure at its natural pixel size
|
|
1147
|
+
on a full-height page -- right for a file opened directly in its own
|
|
1148
|
+
tab. Set it ``False`` when this HTML is going into a container you
|
|
1149
|
+
don't control the size of (an ``<iframe>`` embedding it, say, as
|
|
1150
|
+
:class:`Report` does): the SVG instead scales to fill whatever width
|
|
1151
|
+
it is given, and the page no longer forces itself to at least a full
|
|
1152
|
+
viewport tall, which centering a shorter figure inside would
|
|
1153
|
+
otherwise pad with empty space above and below it.
|
|
1154
|
+
|
|
1155
|
+
``pick_precision`` sets the decimal places of the embedded point-pick
|
|
1156
|
+
arrays (the mesh z grids dominate the file size for mesh-heavy figures);
|
|
1157
|
+
lower it to shrink the HTML at the cost of readout precision.
|
|
1158
|
+
|
|
1159
|
+
``pick_max_mesh_cells``/``pick_max_points`` cap how much of each
|
|
1160
|
+
mesh's/series' own data is embedded for picking, per artist -- so a
|
|
1161
|
+
figure with *many* mesh-bearing axes (a grid of pcolormeshes, say)
|
|
1162
|
+
does not multiply the default cap by the axes count. A mesh over the
|
|
1163
|
+
cap is block-averaged down to it rather than dropped -- a click still
|
|
1164
|
+
answers with a real value, but it's the *mean* of every original cell
|
|
1165
|
+
folded into whichever coarser one the click landed in, not the exact
|
|
1166
|
+
value at that point, and that cell's own x/y is the wider block's
|
|
1167
|
+
center, not the original grid's. The rendered mesh itself is never
|
|
1168
|
+
downsampled (only the pick payload is), so nothing about the image
|
|
1169
|
+
hints this happened -- a ``UserWarning`` naming every affected axes
|
|
1170
|
+
does instead, whenever a mesh actually crosses the cap. Raise
|
|
1171
|
+
``pick_max_mesh_cells`` for full-resolution picking on a mesh this
|
|
1172
|
+
large, at the cost of a bigger embedded payload. A series over the
|
|
1173
|
+
point cap falls back to a geometry-only x/y readout instead (dropped,
|
|
1174
|
+
not downsampled -- there's no missing-value problem an x/y-only click
|
|
1175
|
+
needs solving the way a mesh's z does).
|
|
1176
|
+
|
|
1177
|
+
``binary_pick_data`` embeds long numeric arrays (mesh z grids, animated
|
|
1178
|
+
line frames) as base64 float32/float16 bytes instead of JSON number
|
|
1179
|
+
text -- roughly half the size at effectively the same decode speed as
|
|
1180
|
+
JSON, benchmarked against gzip compressing the JSON instead (smaller,
|
|
1181
|
+
but 5-7x slower to decode: ``DecompressionStream`` overhead dominates
|
|
1182
|
+
at these payload sizes). It also restructures the per-axes metadata
|
|
1183
|
+
payload column-wise (one array per field instead of one object per
|
|
1184
|
+
axes), which matters once a figure has hundreds of axes: that
|
|
1185
|
+
payload has no long arrays of its own, so its cost is JSON key names
|
|
1186
|
+
repeated once per axes rather than a big number array -- columnar
|
|
1187
|
+
layout states each key once, and the numeric columns that leaves
|
|
1188
|
+
then get the same binary encoding. Set ``False`` for the exact
|
|
1189
|
+
plain-JSON payload, e.g. to inspect it by hand or diff it against an
|
|
1190
|
+
older plotpress version.
|
|
1191
|
+
|
|
1192
|
+
``include_default_js`` (default ``True``) controls whether
|
|
1193
|
+
plotpress's own toolbar/pan/zoom/pick JS (:data:`plotpress._interactive.INTERACTIVE_JS`)
|
|
1194
|
+
is included at all. Set it ``False`` to get the ``#plotpress-meta``/
|
|
1195
|
+
``#plotpress-pick``/``#plotpress-style`` JSON payloads (assuming
|
|
1196
|
+
``interactive=True``) with none of plotpress's own JS behavior
|
|
1197
|
+
layered on top -- for building interactivity entirely from scratch
|
|
1198
|
+
against that data and ``extra_js``, rather than extending what's
|
|
1199
|
+
already there. ``binary_pick_data=False`` is worth pairing with
|
|
1200
|
+
this: the default binary encoding needs plotpress's own decoder,
|
|
1201
|
+
which is exactly what this is turning off.
|
|
1202
|
+
|
|
1203
|
+
``extra_js`` is a raw JS string inlined as its own ``<script>``
|
|
1204
|
+
block, after plotpress's own (when ``include_default_js`` is
|
|
1205
|
+
``True``) so ``window.plotpressAddTool``/``plotpressGetMarkers``
|
|
1206
|
+
already exist by the time it runs. With ``include_default_js=True``
|
|
1207
|
+
(the default), use it to *add* to the existing toolbar --
|
|
1208
|
+
``window.plotpressAddTool({label, onClick})`` for an always-on
|
|
1209
|
+
action button, or ``{label, mode, onClick, onEnter, onExit,
|
|
1210
|
+
cursor}`` for one that joins the same single-selection group as
|
|
1211
|
+
Axis Span/Axis Zoom/Point Picking, called back with ``(event, userSpacePoint)``
|
|
1212
|
+
on a click the built-in modes don't already claim. With
|
|
1213
|
+
``include_default_js=False``, it's the *only* JS this page gets --
|
|
1214
|
+
write your own toolbar/interactivity entirely, working from
|
|
1215
|
+
``#plotpress-svg`` and the JSON payloads directly. Nothing about
|
|
1216
|
+
supplying this fetches anything external on its own -- it is
|
|
1217
|
+
inlined the same as plotpress's own JS, keeping the "no external
|
|
1218
|
+
requests" guarantee intact regardless of what it contains.
|
|
1219
|
+
"""
|
|
1220
|
+
svg = figure_to_svg(self)
|
|
1221
|
+
# Tag the root <svg> so the JS can grab it.
|
|
1222
|
+
svg = svg.replace("<svg ", '<svg id="plotpress-svg" ', 1)
|
|
1223
|
+
script = ""
|
|
1224
|
+
if interactive:
|
|
1225
|
+
from .svg import (
|
|
1226
|
+
axes_metadata, frame_data, layout_metadata, pick_data, style_payload,
|
|
1227
|
+
)
|
|
1228
|
+
|
|
1229
|
+
pick_dict = pick_data(self, max_points=pick_max_points,
|
|
1230
|
+
max_mesh_cells=pick_max_mesh_cells,
|
|
1231
|
+
precision=pick_precision)
|
|
1232
|
+
idx_of = {id(a): i for i, a in enumerate(self.axes)}
|
|
1233
|
+
meta_dict = axes_metadata(self, idx_of=idx_of)
|
|
1234
|
+
if binary_pick_data:
|
|
1235
|
+
pick_dict = _encode_binary_arrays(pick_dict, precision=pick_precision)
|
|
1236
|
+
# meta has no long arrays of its own to swap for bytes -- its
|
|
1237
|
+
# cost on a many-axes figure is ~25 JSON key names repeated
|
|
1238
|
+
# once per axes instead of once total. Columnarizing states
|
|
1239
|
+
# each key once; the numeric columns that leaves (x/y/w/h/
|
|
1240
|
+
# xmin/xmax/ymin/ymax) then qualify for the same binary
|
|
1241
|
+
# encoding pick data just got. Only "cols" goes through the
|
|
1242
|
+
# encoder -- "index" is a short run of small sequential axes
|
|
1243
|
+
# indices, cheaper as plain JSON text than as a base64-wrapped
|
|
1244
|
+
# buffer, and the client indexes it directly as object keys.
|
|
1245
|
+
meta_dict = _columnarize_meta(meta_dict)
|
|
1246
|
+
meta_dict["cols"] = _encode_binary_arrays(meta_dict["cols"],
|
|
1247
|
+
precision=pick_precision)
|
|
1248
|
+
meta = _json_payload(meta_dict)
|
|
1249
|
+
pick = _json_payload(pick_dict)
|
|
1250
|
+
styl = _json_payload(style_payload(self))
|
|
1251
|
+
layout = _json_payload(layout_metadata(self, idx_of=idx_of))
|
|
1252
|
+
payloads = (
|
|
1253
|
+
f'<script type="application/json" id="plotpress-meta">{meta}</script>'
|
|
1254
|
+
f'<script type="application/json" id="plotpress-pick">{pick}</script>'
|
|
1255
|
+
f'<script type="application/json" id="plotpress-style">{styl}</script>'
|
|
1256
|
+
f'<script type="application/json" id="plotpress-layout">{layout}</script>'
|
|
1257
|
+
)
|
|
1258
|
+
if self._sliders:
|
|
1259
|
+
frames_dict = frame_data(self, max_mesh_cells=pick_max_mesh_cells)
|
|
1260
|
+
if binary_pick_data:
|
|
1261
|
+
# frame_data() always rounds to 6 decimals (svg._round_list,
|
|
1262
|
+
# module-level -- unlike pick_data() it takes no precision
|
|
1263
|
+
# argument), so the float16 safety check has to match that,
|
|
1264
|
+
# not whatever pick_precision the caller passed.
|
|
1265
|
+
frames_dict = _encode_binary_arrays(frames_dict, precision=6)
|
|
1266
|
+
frames = _json_payload(frames_dict)
|
|
1267
|
+
sliders = _json_payload(self._sliders)
|
|
1268
|
+
payloads += (
|
|
1269
|
+
f'<script type="application/json" id="plotpress-frames">{frames}</script>'
|
|
1270
|
+
f'<script type="application/json" id="plotpress-sliders">{sliders}</script>'
|
|
1271
|
+
)
|
|
1272
|
+
config = ("<script>window.PLOTPRESS_WAIT_EXTRACT=true;</script>"
|
|
1273
|
+
if wait_extract else "")
|
|
1274
|
+
script = config + payloads
|
|
1275
|
+
if include_default_js:
|
|
1276
|
+
from ._interactive import INTERACTIVE_JS
|
|
1277
|
+
script += f"<script>{INTERACTIVE_JS}</script>"
|
|
1278
|
+
if extra_js:
|
|
1279
|
+
script += f"<script>{extra_js}</script>"
|
|
1280
|
+
# The toolbar and a docked slider strip are both position:fixed --
|
|
1281
|
+
# see _interactive.py's .plotpress-menubar/.plotpress-sliders -- so
|
|
1282
|
+
# nothing stops either from drawing over the SVG unless something
|
|
1283
|
+
# else reserves the room for them. A full viewport tall of
|
|
1284
|
+
# flex-centering slack makes that a non-issue for a standalone page
|
|
1285
|
+
# (the toolbar just floats over its own top-left corner, same as it
|
|
1286
|
+
# always has); embedded, the SVG sits flush against the body's
|
|
1287
|
+
# edges, so real top/bottom padding takes over that job instead.
|
|
1288
|
+
if standalone:
|
|
1289
|
+
body_style = ("body{margin:0;background:#f5f5f5;display:flex;"
|
|
1290
|
+
"justify-content:center;align-items:center;min-height:100vh}")
|
|
1291
|
+
wrap_display = "inline-block" # shrink-wrapped to the SVG's own
|
|
1292
|
+
# size, so centering centers the
|
|
1293
|
+
# figure, not an oversized box
|
|
1294
|
+
else:
|
|
1295
|
+
top_pad, bottom_pad = _toolbar_clearance(
|
|
1296
|
+
interactive and include_default_js, len(self._sliders or {}))
|
|
1297
|
+
body_style = f"body{{margin:0;padding:{top_pad}px 0 {bottom_pad}px}}"
|
|
1298
|
+
wrap_display = "block" # stretches to the container's full width
|
|
1299
|
+
# -- #plotpress-svg's own width:100% (below)
|
|
1300
|
+
# needs a definite (non-auto) containing
|
|
1301
|
+
# block to resolve against, or the browser
|
|
1302
|
+
# falls back to its fixed width/height
|
|
1303
|
+
# attributes instead, undoing the scaling
|
|
1304
|
+
svg_style = (
|
|
1305
|
+
"#plotpress-svg{cursor:default;box-shadow:0 1px 6px rgba(0,0,0,.2)}" if standalone
|
|
1306
|
+
else "#plotpress-svg{cursor:default;display:block;width:100%;height:auto}"
|
|
1307
|
+
)
|
|
1308
|
+
# A plot_frames()/pcolormesh_frames() figure wraps the SVG in a div
|
|
1309
|
+
# (for positioning docked sliders over it) -- position:relative in
|
|
1310
|
+
# both modes so a docked slider box (position:absolute inside it)
|
|
1311
|
+
# anchors correctly; only whether it shrink-wraps or stretches differs.
|
|
1312
|
+
wrap_style = f".plotpress-svg-wrap{{position:relative;line-height:0;display:{wrap_display}}}"
|
|
1313
|
+
return (
|
|
1314
|
+
"<!doctype html><html><head><meta charset='utf-8'>"
|
|
1315
|
+
f"<style>{body_style}{svg_style}{wrap_style}</style></head><body>"
|
|
1316
|
+
f"{svg}{script}</body></html>"
|
|
1317
|
+
)
|
|
1318
|
+
|
|
1319
|
+
# NB: intentionally *no* _repr_html_. Jupyter prefers text/html over
|
|
1320
|
+
# image/svg+xml, and returning a full interactive HTML document renders
|
|
1321
|
+
# messily in an output cell (and its scripts don't run there). Notebooks
|
|
1322
|
+
# therefore fall back to the clean static SVG above; for an interactive
|
|
1323
|
+
# figure in a notebook, embed to_html() in an <iframe> (see the docs).
|
|
1324
|
+
|
|
1325
|
+
def save(self, path: str, interactive: bool = False, scale: int = 2,
|
|
1326
|
+
pick_precision: int = 6, pick_max_mesh_cells: int = 250000,
|
|
1327
|
+
pick_max_points: int = 20000, binary_pick_data: bool = True,
|
|
1328
|
+
fps: int = 10, slider_unit: str = "main", label_frames: bool = True,
|
|
1329
|
+
include_default_js: bool = True, extra_js: str = None):
|
|
1330
|
+
"""Save by extension: ``.svg``, ``.html``, ``.png``, ``.pdf``, or ``.gif``.
|
|
1331
|
+
|
|
1332
|
+
All formats work with the standard install (PNG is a supersampled
|
|
1333
|
+
raster; PDF is vector). ``pick_precision``/``pick_max_mesh_cells``/
|
|
1334
|
+
``pick_max_points``/``binary_pick_data``/``include_default_js``/
|
|
1335
|
+
``extra_js`` apply only to interactive HTML (see :meth:`to_html`).
|
|
1336
|
+
``.gif`` needs at least one :meth:`Axes.plot_frames` or
|
|
1337
|
+
:meth:`Axes.pcolormesh_frames` series -- it animates through that
|
|
1338
|
+
series' frames at ``fps``, the same data an interactive HTML slider
|
|
1339
|
+
scrubs through, as a self-contained looping file; ``slider_unit``
|
|
1340
|
+
picks which slider drives the animation for figures with more than
|
|
1341
|
+
one, and ``label_frames`` stamps each frame with its slider value
|
|
1342
|
+
since a GIF has no slider to show it on (see
|
|
1343
|
+
:func:`plotpress.raster.save_gif`).
|
|
1344
|
+
"""
|
|
1345
|
+
lower = path.lower()
|
|
1346
|
+
if lower.endswith(".html") or lower.endswith(".htm"):
|
|
1347
|
+
content = self.to_html(interactive=interactive,
|
|
1348
|
+
pick_precision=pick_precision,
|
|
1349
|
+
pick_max_mesh_cells=pick_max_mesh_cells,
|
|
1350
|
+
pick_max_points=pick_max_points,
|
|
1351
|
+
binary_pick_data=binary_pick_data,
|
|
1352
|
+
include_default_js=include_default_js,
|
|
1353
|
+
extra_js=extra_js)
|
|
1354
|
+
elif lower.endswith(".svg"):
|
|
1355
|
+
content = self.to_svg()
|
|
1356
|
+
elif lower.endswith(".png"):
|
|
1357
|
+
from .raster import save_png
|
|
1358
|
+
return save_png(self, path, scale=scale)
|
|
1359
|
+
elif lower.endswith(".pdf"):
|
|
1360
|
+
from .raster import save_pdf
|
|
1361
|
+
return save_pdf(self, path)
|
|
1362
|
+
elif lower.endswith(".gif"):
|
|
1363
|
+
from .raster import save_gif
|
|
1364
|
+
return save_gif(self, path, fps=fps, scale=scale,
|
|
1365
|
+
slider_unit=slider_unit, label_frames=label_frames)
|
|
1366
|
+
else:
|
|
1367
|
+
raise ValueError(
|
|
1368
|
+
"save() supports .svg/.html/.png/.pdf/.gif (got %r)" % path)
|
|
1369
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
1370
|
+
f.write(content)
|
|
1371
|
+
return path
|
|
1372
|
+
|
|
1373
|
+
def savefig(self, path, **kwargs):
|
|
1374
|
+
"""Alias for :meth:`save` (matplotlib-compatible name)."""
|
|
1375
|
+
return self.save(path, **kwargs)
|
|
1376
|
+
|
|
1377
|
+
# -- display ------------------------------------------------------------
|
|
1378
|
+
def show(self, interactive: bool = True, wait_for_extract: bool = False):
|
|
1379
|
+
"""Display in a native pop-up window (via pywebview if installed).
|
|
1380
|
+
|
|
1381
|
+
Returns the list of markers the user extracted in the window (each a
|
|
1382
|
+
dict of values: ``x``, ``y``, any extra dims, ``axes`` (index),
|
|
1383
|
+
``axes_title`` (if that axes has one), ``kind``), or an empty list if
|
|
1384
|
+
none were extracted. Point Picking markers only -- Extract lives
|
|
1385
|
+
under the Point Picking menu and no longer includes Annotation
|
|
1386
|
+
notes, which have no export of their own.
|
|
1387
|
+
|
|
1388
|
+
With ``wait_for_extract=True`` the call becomes an interactive point-
|
|
1389
|
+
picking session: the kernel blocks, the user drops markers and clicks
|
|
1390
|
+
**Extract**, and *that* returns the markers to the kernel and closes the
|
|
1391
|
+
window (no manual close needed).
|
|
1392
|
+
|
|
1393
|
+
The native window needs the ``[gui]`` extra
|
|
1394
|
+
(``pip install plotpress[gui]``). Without it, this falls back to opening
|
|
1395
|
+
the figure in the default browser and returns ``None`` (use the in-page
|
|
1396
|
+
Extract panel to copy/download).
|
|
1397
|
+
"""
|
|
1398
|
+
html = self.to_html(interactive=interactive, wait_extract=wait_for_extract)
|
|
1399
|
+
w = int(self.figsize[0] * self.style.dpi) + 40
|
|
1400
|
+
h = int(self.figsize[1] * self.style.dpi) + 60
|
|
1401
|
+
try:
|
|
1402
|
+
import webview # provided by the [gui] extra (pywebview)
|
|
1403
|
+
except ImportError:
|
|
1404
|
+
if wait_for_extract:
|
|
1405
|
+
raise RuntimeError(
|
|
1406
|
+
"wait_for_extract=True needs the native window; install it "
|
|
1407
|
+
"with: pip install plotpress[gui]"
|
|
1408
|
+
)
|
|
1409
|
+
import tempfile
|
|
1410
|
+
import webbrowser
|
|
1411
|
+
|
|
1412
|
+
tmpdir = tempfile.gettempdir()
|
|
1413
|
+
_sweep_stale_tempfiles(tmpdir)
|
|
1414
|
+
if self._show_path is None:
|
|
1415
|
+
# One file per figure: re-showing overwrites it rather than
|
|
1416
|
+
# dropping another copy in the temp directory.
|
|
1417
|
+
fd, self._show_path = tempfile.mkstemp(
|
|
1418
|
+
suffix=".html", prefix=_TEMP_PREFIX, dir=tmpdir)
|
|
1419
|
+
os.close(fd)
|
|
1420
|
+
with open(self._show_path, "w", encoding="utf-8") as f:
|
|
1421
|
+
f.write(html)
|
|
1422
|
+
webbrowser.open("file://" + os.path.abspath(self._show_path))
|
|
1423
|
+
return None
|
|
1424
|
+
|
|
1425
|
+
api = _MarkerApi()
|
|
1426
|
+
window = webview.create_window("plotpress", html=html, js_api=api,
|
|
1427
|
+
width=w, height=h)
|
|
1428
|
+
if wait_for_extract:
|
|
1429
|
+
api._window = window # Extract closes the window -> unblocks below
|
|
1430
|
+
webview.start()
|
|
1431
|
+
return api.markers
|
|
1432
|
+
|
|
1433
|
+
def show_qt(self, title="plotpress", block=True, interactive=True,
|
|
1434
|
+
pick_precision=6):
|
|
1435
|
+
"""Display in a native Qt window (PyQt/PySide), for Qt-based apps.
|
|
1436
|
+
|
|
1437
|
+
Thin wrapper around ``plotpress.qt.view``. Needs a Qt binding with
|
|
1438
|
+
WebEngine (``pip install plotpress[qt]``). To embed the figure inside
|
|
1439
|
+
your own Qt layout instead of a standalone window, use
|
|
1440
|
+
``plotpress.qt.PlotPressWidget`` directly.
|
|
1441
|
+
"""
|
|
1442
|
+
from .qt import view
|
|
1443
|
+
return view(self, title=title, block=block, interactive=interactive,
|
|
1444
|
+
pick_precision=pick_precision)
|
|
1445
|
+
|
|
1446
|
+
def show_in_jupyter(self, width=None, height=None, interactive: bool = True,
|
|
1447
|
+
pick_precision: int = 6, pick_max_mesh_cells: int = 250000,
|
|
1448
|
+
pick_max_points: int = 20000, binary_pick_data: bool = True,
|
|
1449
|
+
include_default_js: bool = True, extra_js: str = None):
|
|
1450
|
+
"""Display inline in a notebook cell with the full interactive toolbar.
|
|
1451
|
+
|
|
1452
|
+
Evaluating a figure directly (``fig`` as a cell's last expression)
|
|
1453
|
+
renders it inline as static SVG via ``Figure._repr_svg_`` -- there is
|
|
1454
|
+
deliberately no ``_repr_html_``, since Jupyter prefers ``text/html``
|
|
1455
|
+
over ``image/svg+xml`` when a MIME bundle offers both, and a full
|
|
1456
|
+
interactive HTML document dropped into an output cell that way renders
|
|
1457
|
+
messily and its ``<script>`` doesn't run there regardless.
|
|
1458
|
+
|
|
1459
|
+
This instead wraps the same self-contained HTML ``to_html()``
|
|
1460
|
+
produces in an ``<iframe>``, which does isolate and run the inlined
|
|
1461
|
+
JS -- so the toolbar, pan/zoom, and point-picking all work exactly as
|
|
1462
|
+
they do in a saved ``.html`` file opened in a browser.
|
|
1463
|
+
|
|
1464
|
+
``width``/``height`` default to the figure's own pixel size
|
|
1465
|
+
(``figsize`` x ``style.dpi``); pass either to override. The rest of
|
|
1466
|
+
the keyword arguments are forwarded to ``to_html()`` (see there for
|
|
1467
|
+
what each controls).
|
|
1468
|
+
|
|
1469
|
+
Returns an ``IPython.display.HTML`` object -- return it as a cell's
|
|
1470
|
+
last expression, or pass it to ``IPython.display.display()``. Needs
|
|
1471
|
+
IPython (``pip install plotpress[jupyter]``), which any real Jupyter
|
|
1472
|
+
environment already has.
|
|
1473
|
+
"""
|
|
1474
|
+
try:
|
|
1475
|
+
from IPython.display import HTML
|
|
1476
|
+
except ImportError as e:
|
|
1477
|
+
raise ImportError(
|
|
1478
|
+
"show_in_jupyter() needs IPython -- install it with: pip "
|
|
1479
|
+
"install plotpress[jupyter] (any Jupyter environment "
|
|
1480
|
+
"already has it)"
|
|
1481
|
+
) from e
|
|
1482
|
+
if width is None:
|
|
1483
|
+
width = int(self.figsize[0] * self.style.dpi)
|
|
1484
|
+
if height is None:
|
|
1485
|
+
height = int(self.figsize[1] * self.style.dpi)
|
|
1486
|
+
# standalone=False: meant exactly for embedding in a container this
|
|
1487
|
+
# call doesn't control the size of, so the figure scales to fill the
|
|
1488
|
+
# iframe instead of sitting at a fixed pixel size with empty space
|
|
1489
|
+
# centered around it.
|
|
1490
|
+
html = self.to_html(
|
|
1491
|
+
interactive=interactive, standalone=False,
|
|
1492
|
+
pick_precision=pick_precision, pick_max_mesh_cells=pick_max_mesh_cells,
|
|
1493
|
+
pick_max_points=pick_max_points, binary_pick_data=binary_pick_data,
|
|
1494
|
+
include_default_js=include_default_js, extra_js=extra_js,
|
|
1495
|
+
).replace('"', """)
|
|
1496
|
+
return HTML(
|
|
1497
|
+
f'<iframe srcdoc="{html}" width="{width}" height="{height}" '
|
|
1498
|
+
f'style="border:0"></iframe>'
|
|
1499
|
+
)
|
|
1500
|
+
|
|
1501
|
+
|
|
1502
|
+
class _MarkerApi:
|
|
1503
|
+
"""pywebview bridge: the in-window Extract button pushes markers to Python."""
|
|
1504
|
+
|
|
1505
|
+
def __init__(self):
|
|
1506
|
+
self.markers = []
|
|
1507
|
+
self._window = None # set when Extract should also close the window
|
|
1508
|
+
|
|
1509
|
+
def extract(self, records):
|
|
1510
|
+
# Called from JS as window.pywebview.api.extract(records).
|
|
1511
|
+
self.markers = list(records) if records else []
|
|
1512
|
+
if self._window is not None:
|
|
1513
|
+
try:
|
|
1514
|
+
self._window.destroy()
|
|
1515
|
+
except Exception:
|
|
1516
|
+
pass
|
|
1517
|
+
return True
|
|
1518
|
+
|
|
1519
|
+
|
|
1520
|
+
def _cbar_label_width(cax) -> float:
|
|
1521
|
+
"""Figure-fraction width the colorbar's tick labels need to its right.
|
|
1522
|
+
|
|
1523
|
+
The renderer draws them outside the bar, so without this the labels spill
|
|
1524
|
+
past the space stolen from the parent -- into the next subplot, or off the
|
|
1525
|
+
figure edge. Measuring needs the mappable's ``vmin``/``vmax``, which every
|
|
1526
|
+
mappable resolves when it is constructed, so this is safe to call before
|
|
1527
|
+
anything has been drawn.
|
|
1528
|
+
"""
|
|
1529
|
+
from .colors import colorbar_ticks
|
|
1530
|
+
|
|
1531
|
+
st = cax.style
|
|
1532
|
+
_, _, labels = colorbar_ticks(cax._cbar_source.norm)
|
|
1533
|
+
text_px = max((st.text_width(t, st.tick_label_size) for t in labels),
|
|
1534
|
+
default=0.0)
|
|
1535
|
+
return (st.tick_size + 2 + text_px) / (cax.figure.figsize[0] * st.dpi)
|
|
1536
|
+
|
|
1537
|
+
|
|
1538
|
+
_TEMP_PREFIX = "plotpress-"
|
|
1539
|
+
_TEMP_MAX_AGE = 24 * 3600 # seconds
|
|
1540
|
+
|
|
1541
|
+
|
|
1542
|
+
def _sweep_stale_tempfiles(directory, max_age=_TEMP_MAX_AGE):
|
|
1543
|
+
"""Delete figures the browser fallback left behind in earlier sessions.
|
|
1544
|
+
|
|
1545
|
+
That fallback cannot clean up after itself on the way out: ``webbrowser``
|
|
1546
|
+
hands the file to another process and returns immediately, so unlinking it
|
|
1547
|
+
-- at exit or otherwise -- races a script that exits right after calling
|
|
1548
|
+
``show()``. Reaping by age sidesteps the race entirely, since a file this
|
|
1549
|
+
old belongs to a process that is long gone.
|
|
1550
|
+
"""
|
|
1551
|
+
cutoff = time.time() - max_age
|
|
1552
|
+
try:
|
|
1553
|
+
names = os.listdir(directory)
|
|
1554
|
+
except OSError:
|
|
1555
|
+
return
|
|
1556
|
+
for name in names:
|
|
1557
|
+
if not (name.startswith(_TEMP_PREFIX) and name.endswith(".html")):
|
|
1558
|
+
continue
|
|
1559
|
+
path = os.path.join(directory, name)
|
|
1560
|
+
try:
|
|
1561
|
+
if os.path.getmtime(path) < cutoff:
|
|
1562
|
+
os.unlink(path)
|
|
1563
|
+
except OSError:
|
|
1564
|
+
pass # vanished, or belongs to another user -- not ours to fix
|
|
1565
|
+
|
|
1566
|
+
|
|
1567
|
+
def _place_spec_rects(specs, nrows, ncols, left, bottom, axw, axh, gap_w, gap_h):
|
|
1568
|
+
"""Write each axes' ``_rect`` from its ``SubplotSpec`` span, a uniform
|
|
1569
|
+
cell size, and per-boundary gaps, shared by :meth:`Figure.tight_layout`
|
|
1570
|
+
and :meth:`Figure.subplots_adjust` (they differ only in how ``axw``/
|
|
1571
|
+
``axh``/``gap_w``/``gap_h`` were derived -- measured pixels vs.
|
|
1572
|
+
matplotlib's fraction-of-cell ``wspace``/``hspace``).
|
|
1573
|
+
|
|
1574
|
+
``gap_w``/``gap_h`` are lists of ``ncols - 1``/``nrows - 1`` values, one
|
|
1575
|
+
per interior boundary -- not necessarily uniform, since
|
|
1576
|
+
:meth:`Figure.group_spacing` only widens the boundaries that actually
|
|
1577
|
+
border a group's own bounding box, not every row/col gap alike.
|
|
1578
|
+
"""
|
|
1579
|
+
col_left = []
|
|
1580
|
+
x = left
|
|
1581
|
+
for c in range(ncols):
|
|
1582
|
+
col_left.append(x)
|
|
1583
|
+
x += axw + (gap_w[c] if c < ncols - 1 else 0.0)
|
|
1584
|
+
row_bottom = [0.0] * nrows
|
|
1585
|
+
y = bottom
|
|
1586
|
+
for r in range(nrows - 1, -1, -1):
|
|
1587
|
+
row_bottom[r] = y
|
|
1588
|
+
if r > 0:
|
|
1589
|
+
y += axh + gap_h[r - 1]
|
|
1590
|
+
for ax in specs:
|
|
1591
|
+
spec = ax._subplotspec
|
|
1592
|
+
x0 = col_left[spec.col0]
|
|
1593
|
+
x1 = col_left[spec.col1] + axw
|
|
1594
|
+
y0 = row_bottom[spec.row1]
|
|
1595
|
+
y1 = row_bottom[spec.row0] + axh
|
|
1596
|
+
ax._rect = (x0, y0, x1 - x0, y1 - y0)
|
|
1597
|
+
|
|
1598
|
+
|
|
1599
|
+
def _layout_figure_legend(fig):
|
|
1600
|
+
"""Shrink the subplot grid away from the edge a figure legend occupies.
|
|
1601
|
+
|
|
1602
|
+
Derived from the axes' *current* rects, like :func:`_layout_colorbar`, so
|
|
1603
|
+
tight_layout can re-run it after reflowing. Placements with no unambiguous
|
|
1604
|
+
edge overlay instead and reserve nothing.
|
|
1605
|
+
"""
|
|
1606
|
+
from .svg import FIGURE_LEGEND_EDGE, figure_legend_layout
|
|
1607
|
+
|
|
1608
|
+
spec = fig._figure_legend
|
|
1609
|
+
if spec is None:
|
|
1610
|
+
return
|
|
1611
|
+
edge = FIGURE_LEGEND_EDGE.get(spec["loc"])
|
|
1612
|
+
if edge is None:
|
|
1613
|
+
return
|
|
1614
|
+
lay = figure_legend_layout(fig)
|
|
1615
|
+
if lay is None:
|
|
1616
|
+
return
|
|
1617
|
+
specs = [ax for ax in fig.axes
|
|
1618
|
+
if ax._subplotspec is not None and not ax._is_colorbar]
|
|
1619
|
+
if not specs:
|
|
1620
|
+
return
|
|
1621
|
+
|
|
1622
|
+
W = fig.figsize[0] * fig.style.dpi
|
|
1623
|
+
H = fig.figsize[1] * fig.style.dpi
|
|
1624
|
+
pad_px = spec["pad"] * min(W, H) + 4
|
|
1625
|
+
if edge in ("bottom", "top"):
|
|
1626
|
+
band = min((lay["box_h"] + 2 * pad_px) / H, 0.6)
|
|
1627
|
+
else:
|
|
1628
|
+
band = min((lay["box_w"] + 2 * pad_px) / W, 0.6)
|
|
1629
|
+
keep = 1.0 - band
|
|
1630
|
+
|
|
1631
|
+
for ax in specs:
|
|
1632
|
+
left, bottom, w, h = ax._rect
|
|
1633
|
+
if edge == "bottom":
|
|
1634
|
+
ax._rect = (left, band + bottom * keep, w, h * keep)
|
|
1635
|
+
elif edge == "top":
|
|
1636
|
+
ax._rect = (left, bottom * keep, w, h * keep)
|
|
1637
|
+
elif edge == "right":
|
|
1638
|
+
ax._rect = (left * keep, bottom, w * keep, h)
|
|
1639
|
+
else: # left
|
|
1640
|
+
ax._rect = (band + left * keep, bottom, w * keep, h)
|
|
1641
|
+
|
|
1642
|
+
|
|
1643
|
+
def _layout_inset(iax):
|
|
1644
|
+
"""Re-derive an ``inset_axes``' rect from its parent's *current* rect.
|
|
1645
|
+
|
|
1646
|
+
Mirrors :func:`_layout_colorbar`'s reasoning: bounds are fractions of the
|
|
1647
|
+
parent's box, recorded once at ``inset_axes()`` time, but the parent's box
|
|
1648
|
+
moves whenever the grid reflows -- re-deriving here is what keeps the
|
|
1649
|
+
inset from drifting off it.
|
|
1650
|
+
"""
|
|
1651
|
+
x0, y0, w, h = iax._inset_bounds
|
|
1652
|
+
pl, pb, pw, ph = iax._inset_parent._rect
|
|
1653
|
+
iax._rect = (pl + x0 * pw, pb + y0 * ph, w * pw, h * ph)
|
|
1654
|
+
|
|
1655
|
+
|
|
1656
|
+
def _layout_colorbar(cax):
|
|
1657
|
+
"""Steal space from ``cax``'s parent axes and place the bar in the gap.
|
|
1658
|
+
|
|
1659
|
+
Derived from the parents' *current* rects rather than baked in at creation,
|
|
1660
|
+
so :meth:`Figure.tight_layout` can re-run it after reflowing the grid. Each
|
|
1661
|
+
call assumes the parents are at their full, un-stolen-from size -- which is
|
|
1662
|
+
exactly the state tight_layout leaves them in.
|
|
1663
|
+
|
|
1664
|
+
The steal covers the gap, the bar, *and* the tick labels to its right, so
|
|
1665
|
+
the whole assembly fits inside the parents' original footprint.
|
|
1666
|
+
"""
|
|
1667
|
+
axlist = cax._cbar_parents
|
|
1668
|
+
fraction, pad = cax._cbar_fraction, cax._cbar_pad
|
|
1669
|
+
label_w = _cbar_label_width(cax)
|
|
1670
|
+
if len(axlist) == 1:
|
|
1671
|
+
left, bottom, w, h = axlist[0]._rect
|
|
1672
|
+
bar_w = w * fraction
|
|
1673
|
+
plot_w = max(w - (w * pad + bar_w + label_w), w * 0.1)
|
|
1674
|
+
axlist[0]._rect = (left, bottom, plot_w, h)
|
|
1675
|
+
cax._rect = (left + plot_w + w * pad, bottom, bar_w, h)
|
|
1676
|
+
return
|
|
1677
|
+
rects = np.array([a._rect for a in axlist])
|
|
1678
|
+
gl, gb = rects[:, 0].min(), rects[:, 1].min()
|
|
1679
|
+
gr = (rects[:, 0] + rects[:, 2]).max()
|
|
1680
|
+
gt = (rects[:, 1] + rects[:, 3]).max()
|
|
1681
|
+
span_w = gr - gl
|
|
1682
|
+
bar_w = span_w * fraction
|
|
1683
|
+
keep = max(span_w - (span_w * pad + bar_w + label_w), span_w * 0.1)
|
|
1684
|
+
scale = keep / span_w
|
|
1685
|
+
for a in axlist: # squeeze the group leftward
|
|
1686
|
+
left, bottom, w, h = a._rect
|
|
1687
|
+
a._rect = (gl + (left - gl) * scale, bottom, w * scale, h)
|
|
1688
|
+
cax._rect = (gl + keep + span_w * pad, gb, bar_w, gt - gb)
|
|
1689
|
+
|
|
1690
|
+
|
|
1691
|
+
def _vega_compat_report(fig):
|
|
1692
|
+
"""``{axes_index_or_None: {"vega": [str, ...], "vega_lite": [str, ...]}}``
|
|
1693
|
+
-- built by actually calling ``fig.to_vega()``/``fig.to_vega_lite()``
|
|
1694
|
+
and reading their real warnings/caveats, not a separately-maintained
|
|
1695
|
+
list of "supported artist types" that could drift out of sync with
|
|
1696
|
+
what those two exporters actually do. ``None`` collects a gap that
|
|
1697
|
+
isn't attributable to one specific axes (a whole-figure legend, a
|
|
1698
|
+
grid-shape mismatch spanning several axes). Shared by
|
|
1699
|
+
:meth:`Figure.print_layout_summary` and :meth:`~plotpress.axes.Axes.print_summary`
|
|
1700
|
+
so both report the exact same thing for the same figure.
|
|
1701
|
+
"""
|
|
1702
|
+
import re
|
|
1703
|
+
|
|
1704
|
+
from .vega_lite import _STRUCTURAL_WARNING_PREFIX
|
|
1705
|
+
|
|
1706
|
+
report = {}
|
|
1707
|
+
|
|
1708
|
+
def add(msg, target):
|
|
1709
|
+
idxs = [int(m) for m in re.findall(r"axes (\d+)", msg)] or [None]
|
|
1710
|
+
for i in idxs:
|
|
1711
|
+
report.setdefault(i, {"vega": [], "vega_lite": []})[target].append(msg)
|
|
1712
|
+
|
|
1713
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
1714
|
+
warnings.simplefilter("always")
|
|
1715
|
+
try:
|
|
1716
|
+
fig.to_vega()
|
|
1717
|
+
except Exception:
|
|
1718
|
+
pass
|
|
1719
|
+
for w in caught:
|
|
1720
|
+
add(str(w.message), "vega")
|
|
1721
|
+
|
|
1722
|
+
with warnings.catch_warnings(record=True) as caught2:
|
|
1723
|
+
warnings.simplefilter("always")
|
|
1724
|
+
try:
|
|
1725
|
+
_, caveats = fig.to_vega_lite()
|
|
1726
|
+
except Exception:
|
|
1727
|
+
caveats = []
|
|
1728
|
+
# `caveats` already carries every structural gap once, deduplicated;
|
|
1729
|
+
# the aggregate warning built from `" ".join(caveats)` would otherwise
|
|
1730
|
+
# duplicate every one of them as a second, harder-to-parse blob (the
|
|
1731
|
+
# same problem docs/conf.py's own scraper already had to filter out).
|
|
1732
|
+
for msg in caveats:
|
|
1733
|
+
add(msg, "vega_lite")
|
|
1734
|
+
for w in caught2:
|
|
1735
|
+
msg = str(w.message)
|
|
1736
|
+
if not msg.startswith(_STRUCTURAL_WARNING_PREFIX):
|
|
1737
|
+
add(msg, "vega_lite")
|
|
1738
|
+
return report
|
|
1739
|
+
|
|
1740
|
+
|
|
1741
|
+
def _axes_position_desc(ax):
|
|
1742
|
+
"""One line describing where ``ax`` sits: a plain grid cell, a
|
|
1743
|
+
multi-cell span, or one of the ways an axes can be entangled with
|
|
1744
|
+
another (twin, secondary, inset, colorbar, free-form ``add_axes()``)
|
|
1745
|
+
-- the same classification :func:`plotpress.vega_lite._is_cleanly_composable`
|
|
1746
|
+
and its neighbors use to decide how a figure composes into Vega-Lite,
|
|
1747
|
+
reused here as plain English rather than re-derived.
|
|
1748
|
+
"""
|
|
1749
|
+
axlist = ax.figure.axes
|
|
1750
|
+
if ax._is_colorbar:
|
|
1751
|
+
parents = [p for p in (ax._cbar_parents or []) if p in axlist]
|
|
1752
|
+
if not parents:
|
|
1753
|
+
return "colorbar"
|
|
1754
|
+
names = ", ".join(f"axes {axlist.index(p)}" for p in parents)
|
|
1755
|
+
return f"colorbar for {names}"
|
|
1756
|
+
if ax._twin_of is not None:
|
|
1757
|
+
kind = "twinx()" if ax._twin_shared == "x" else "twiny()"
|
|
1758
|
+
return f"{kind} overlay of axes {axlist.index(ax._twin_of)}"
|
|
1759
|
+
if ax._secondary_of is not None:
|
|
1760
|
+
return f"secondary axis of axes {axlist.index(ax._secondary_of)}"
|
|
1761
|
+
if ax._inset_parent is not None:
|
|
1762
|
+
return f"inset of axes {axlist.index(ax._inset_parent)}"
|
|
1763
|
+
ss = ax._subplotspec
|
|
1764
|
+
if ss is None:
|
|
1765
|
+
return "free-form add_axes() rect"
|
|
1766
|
+
if ss.row0 == ss.row1 and ss.col0 == ss.col1:
|
|
1767
|
+
return f"row {ss.row0}, col {ss.col0} of a {ss.nrows}x{ss.ncols} grid"
|
|
1768
|
+
return (f"spans rows {ss.row0}-{ss.row1}, cols {ss.col0}-{ss.col1} "
|
|
1769
|
+
f"of a {ss.nrows}x{ss.ncols} grid")
|
|
1770
|
+
|
|
1771
|
+
|
|
1772
|
+
def _axes_summary_lines(ax, gaps=None):
|
|
1773
|
+
"""The lines :meth:`Figure.print_layout_summary` prints per axes and
|
|
1774
|
+
:meth:`~plotpress.axes.Axes.print_summary` prints for just one --
|
|
1775
|
+
shared so the two commands can never describe the same axes
|
|
1776
|
+
differently. ``gaps`` is one entry of :func:`_vega_compat_report`'s
|
|
1777
|
+
return value (``{"vega": [...], "vega_lite": [...]}``), or ``None`` to
|
|
1778
|
+
skip the export-compatibility lines entirely (a plain description,
|
|
1779
|
+
no exporters run).
|
|
1780
|
+
"""
|
|
1781
|
+
from collections import Counter
|
|
1782
|
+
|
|
1783
|
+
counts = Counter(type(a).__name__ for a in ax.artists)
|
|
1784
|
+
artists_desc = ", ".join(f"{n} {name}" for name, n in counts.items()) or "none"
|
|
1785
|
+
xlim, ylim = ax.get_xlim(), ax.get_ylim()
|
|
1786
|
+
# The axis actually renders reversed whenever an odd number of "flip"
|
|
1787
|
+
# sources apply: a raw hi-then-lo set_xlim()/set_ylim() call (matplotlib's
|
|
1788
|
+
# own common idiom for inverting without invert_xaxis()) XOR the explicit
|
|
1789
|
+
# _xinverted/_yinverted flag -- see svg.py's _render_axes, which combines
|
|
1790
|
+
# them the same way. Reporting the flag alone missed the set_xlim(hi, lo)
|
|
1791
|
+
# case entirely: the figure rendered inverted but the summary said nothing.
|
|
1792
|
+
x_inverted = (xlim[0] > xlim[1]) != ax._xinverted
|
|
1793
|
+
y_inverted = (ylim[0] > ylim[1]) != ax._yinverted
|
|
1794
|
+
lines = [
|
|
1795
|
+
f" position: {_axes_position_desc(ax)}",
|
|
1796
|
+
f" visible: {ax._visible}",
|
|
1797
|
+
f" x: {ax._xscale}, [{xlim[0]:.4g}, {xlim[1]:.4g}]"
|
|
1798
|
+
+ (" (inverted)" if x_inverted else ""),
|
|
1799
|
+
f" y: {ax._yscale}, [{ylim[0]:.4g}, {ylim[1]:.4g}]"
|
|
1800
|
+
+ (" (inverted)" if y_inverted else ""),
|
|
1801
|
+
f" artists: {artists_desc}",
|
|
1802
|
+
]
|
|
1803
|
+
if ax._title:
|
|
1804
|
+
lines.append(f" title: {ax._title!r}")
|
|
1805
|
+
if ax._xlabel or ax._ylabel:
|
|
1806
|
+
lines.append(f" labels: xlabel={ax._xlabel!r} ylabel={ax._ylabel!r}")
|
|
1807
|
+
if getattr(ax, "_is_polar", False):
|
|
1808
|
+
lines.append(" polar: yes")
|
|
1809
|
+
if gaps is not None:
|
|
1810
|
+
v = "OK" if not gaps["vega"] else f"{len(gaps['vega'])} gap(s)"
|
|
1811
|
+
vl = "OK" if not gaps["vega_lite"] else f"{len(gaps['vega_lite'])} gap(s)"
|
|
1812
|
+
lines.append(f" to_vega(): {v}")
|
|
1813
|
+
lines.append(f" to_vega_lite(): {vl}")
|
|
1814
|
+
for msg in gaps["vega"]:
|
|
1815
|
+
lines.append(f" - [vega] {msg}")
|
|
1816
|
+
for msg in gaps["vega_lite"]:
|
|
1817
|
+
lines.append(f" - [vega-lite] {msg}")
|
|
1818
|
+
return lines
|
|
1819
|
+
|
|
1820
|
+
|
|
1821
|
+
def _sanitize_nan(obj):
|
|
1822
|
+
"""Replace non-finite floats (NaN/Infinity/-Infinity) with ``None``.
|
|
1823
|
+
|
|
1824
|
+
``json.dumps``'s default ``allow_nan=True`` emits those as bare, unquoted
|
|
1825
|
+
tokens -- valid Python literals but not valid JSON -- so the browser's
|
|
1826
|
+
strict ``JSON.parse`` throws on the very first one and the whole payload
|
|
1827
|
+
(meta, pick data, style, everything in one script element) fails to load,
|
|
1828
|
+
silently disabling the entire interactive toolbar. A masked or missing
|
|
1829
|
+
measurement is an ordinary case for real data (a heatmap's saturated
|
|
1830
|
+
pixels, a masked land/ocean field, a scatter's dropped-out channel), not a
|
|
1831
|
+
rare one, so this has to hold for every payload, not just the common one.
|
|
1832
|
+
"""
|
|
1833
|
+
if isinstance(obj, float):
|
|
1834
|
+
return obj if math.isfinite(obj) else None
|
|
1835
|
+
if isinstance(obj, dict):
|
|
1836
|
+
return {k: _sanitize_nan(v) for k, v in obj.items()}
|
|
1837
|
+
if isinstance(obj, (list, tuple)):
|
|
1838
|
+
return [_sanitize_nan(v) for v in obj]
|
|
1839
|
+
return obj
|
|
1840
|
+
|
|
1841
|
+
|
|
1842
|
+
def _columnarize_meta(meta):
|
|
1843
|
+
"""``{axes_index: {field: value, ...}, ...}`` -> one array per field.
|
|
1844
|
+
|
|
1845
|
+
``axes_metadata()`` has no long arrays of its own -- every field is a
|
|
1846
|
+
single scalar per axes -- so on a figure with hundreds of axes its cost
|
|
1847
|
+
is ~25 JSON key names (``"tick_style"``, ``"secondary_dim"``, ...)
|
|
1848
|
+
repeated in full for every one of them, not a big number array
|
|
1849
|
+
:func:`_encode_binary_arrays` could shrink. Restructuring to one array
|
|
1850
|
+
per field states each key name once total; the client rebuilds the exact
|
|
1851
|
+
original per-axes shape from it (see ``_interactive.py``'s
|
|
1852
|
+
``expandColumnarMeta``), so nothing downstream that reads
|
|
1853
|
+
``META[axesIndex].field`` has to change. The axes index itself isn't
|
|
1854
|
+
contiguous (colorbar/3-D/hidden axes are excluded upstream), so it rides
|
|
1855
|
+
along as its own array rather than being assumed to be ``range(n)``.
|
|
1856
|
+
"""
|
|
1857
|
+
index = list(meta.keys())
|
|
1858
|
+
if not index:
|
|
1859
|
+
return {"keys": [], "index": [], "cols": {}}
|
|
1860
|
+
keys = list(next(iter(meta.values())).keys())
|
|
1861
|
+
cols = {k: [meta[i][k] for i in index] for k in keys}
|
|
1862
|
+
return {"keys": keys, "index": index, "cols": cols}
|
|
1863
|
+
|
|
1864
|
+
|
|
1865
|
+
_BINARY_ARRAY_MIN_LEN = 32 # below this, base64+wrapper overhead loses to plain JSON
|
|
1866
|
+
|
|
1867
|
+
|
|
1868
|
+
def _fits_float16(arr, precision):
|
|
1869
|
+
"""Whether ``arr`` (float64) survives a float16 round trip losing nothing
|
|
1870
|
+
beyond what rounding to ``precision`` decimals already gave up.
|
|
1871
|
+
|
|
1872
|
+
float16 has ~3 significant decimal digits and overflows past +-65504, so
|
|
1873
|
+
this can't be decided from ``precision`` alone -- a value in the
|
|
1874
|
+
thousands loses digits precision=6 promised to keep, and one past 65504
|
|
1875
|
+
overflows to Infinity outright. Casting down and back and comparing
|
|
1876
|
+
catches both: NaN/+Inf/-Inf must map to themselves exactly (an
|
|
1877
|
+
overflowing finite value shows up as a spurious Infinity here), and every
|
|
1878
|
+
finite value must still match to within half the last decimal place
|
|
1879
|
+
``precision`` rounded to.
|
|
1880
|
+
"""
|
|
1881
|
+
if arr.size == 0:
|
|
1882
|
+
return True
|
|
1883
|
+
nan, posinf, neginf = np.isnan(arr), np.isposinf(arr), np.isneginf(arr)
|
|
1884
|
+
finite = ~(nan | posinf | neginf)
|
|
1885
|
+
# A value past float16's range overflowing to Infinity here is expected
|
|
1886
|
+
# and handled below (it fails the mask comparison, so float32 is used
|
|
1887
|
+
# instead) -- not a bug to warn about on every large-magnitude figure,
|
|
1888
|
+
# which binary_pick_data's default-on status would otherwise do.
|
|
1889
|
+
with warnings.catch_warnings():
|
|
1890
|
+
warnings.simplefilter("ignore", category=RuntimeWarning)
|
|
1891
|
+
f16_as_f64 = arr.astype(np.float16).astype(np.float64)
|
|
1892
|
+
if not (np.array_equal(np.isnan(f16_as_f64), nan)
|
|
1893
|
+
and np.array_equal(np.isposinf(f16_as_f64), posinf)
|
|
1894
|
+
and np.array_equal(np.isneginf(f16_as_f64), neginf)):
|
|
1895
|
+
return False
|
|
1896
|
+
if not finite.any():
|
|
1897
|
+
return True
|
|
1898
|
+
tol = 0.5 * 10.0 ** -precision
|
|
1899
|
+
return np.allclose(f16_as_f64[finite], arr[finite], atol=tol, rtol=0)
|
|
1900
|
+
|
|
1901
|
+
|
|
1902
|
+
def _toolbar_clearance(interactive, n_sliders):
|
|
1903
|
+
"""(top, bottom) pixels of vertical space the toolbar and any docked
|
|
1904
|
+
slider strip need -- both are real reserved space for the same reason:
|
|
1905
|
+
the menu bar (``.plotpress-menubar``) and a docked slider strip
|
|
1906
|
+
(``.plotpress-sliders``) are both ``position:fixed`` overlays (the bar
|
|
1907
|
+
pinned to the top of the viewport so Pan/Zoom's own whole-figure zoom
|
|
1908
|
+
can never scroll it out of reach -- see the CSS comment on
|
|
1909
|
+
``.plotpress-menubar`` in ``_interactive.py``), so nothing else stops
|
|
1910
|
+
either from drawing over the figure unless this reserves the room for
|
|
1911
|
+
them. Used for a ``standalone=False`` document's own body padding
|
|
1912
|
+
(:meth:`Figure.to_html`) and for sizing an ``<iframe>`` around one
|
|
1913
|
+
(:meth:`Report.save`, and the docs build's own gallery/usage embeds in
|
|
1914
|
+
``docs/conf.py``), so a figure looks the same either way it ends up on
|
|
1915
|
+
a page. A standalone page needs neither: a full viewport tall of
|
|
1916
|
+
flex-centering slack already keeps both from overlapping the centered
|
|
1917
|
+
figure.
|
|
1918
|
+
|
|
1919
|
+
41px is the bar's own single row, measured live in a browser -- padding
|
|
1920
|
+
top/bottom, its 1px border-bottom, plus its button/label content's own
|
|
1921
|
+
line height, no separate group labels or stacked rows the old two-row
|
|
1922
|
+
toolbar needed. Does not budget for a caller's own ``plotpressAddTool()``
|
|
1923
|
+
menu (``extra_js=`` on :meth:`Figure.to_html`) -- it lands in the *same*
|
|
1924
|
+
row (a sixth menu, not an extra one), so it never changes the bar's own
|
|
1925
|
+
height regardless of how many custom tools it adds.
|
|
1926
|
+
|
|
1927
|
+
60px per slider matches each docked strip's own footprint
|
|
1928
|
+
(``.plotpress-slider``).
|
|
1929
|
+
"""
|
|
1930
|
+
if not interactive:
|
|
1931
|
+
return 0, 0
|
|
1932
|
+
return 41, 60 * n_sliders
|
|
1933
|
+
|
|
1934
|
+
|
|
1935
|
+
def _encode_binary_arrays(obj, precision=6):
|
|
1936
|
+
"""Replace long flat number lists with a base64 float16/float32 buffer.
|
|
1937
|
+
|
|
1938
|
+
A mesh z grid or a long line series embeds as JSON number *text*
|
|
1939
|
+
(``"0.707107,0.6,..."``) by default -- verbose, and every value has to be
|
|
1940
|
+
re-parsed digit by digit on the JS side. Swapping those arrays for
|
|
1941
|
+
``{"__f32__": "<base64>"}`` (or ``{"__f16__": ...}`` where that loses
|
|
1942
|
+
nothing -- see :func:`_fits_float16`) and reinterpreting the bytes
|
|
1943
|
+
client-side benchmarked at roughly half the embedded size and stayed
|
|
1944
|
+
close to ``JSON.parse``-level decode speed, where matching that size with
|
|
1945
|
+
gzip instead cost 5-7x the decode time -- ``DecompressionStream``'s
|
|
1946
|
+
per-call overhead dominates at these payload sizes. See the benchmark
|
|
1947
|
+
this was validated against for the numbers.
|
|
1948
|
+
|
|
1949
|
+
At the library's default ``precision=6``, float16's ~3 significant
|
|
1950
|
+
digits essentially never clears the round-trip check, so this only
|
|
1951
|
+
starts choosing float16 once a caller lowers ``pick_precision`` enough
|
|
1952
|
+
for it to matter -- consistent with what that parameter has always
|
|
1953
|
+
promised: lower precision, smaller file.
|
|
1954
|
+
|
|
1955
|
+
Float32/float16 both natively represent NaN/Infinity, so a masked mesh
|
|
1956
|
+
cell or dropped-out channel survives the round trip without the ``None``
|
|
1957
|
+
substitution :func:`_sanitize_nan` has to do for plain JSON numbers --
|
|
1958
|
+
this only ever touches arrays that go through this encoder, not
|
|
1959
|
+
everything else in the payload, so short arrays keep exact ``_sanitize_nan``
|
|
1960
|
+
behavior.
|
|
1961
|
+
"""
|
|
1962
|
+
if isinstance(obj, dict):
|
|
1963
|
+
return {k: _encode_binary_arrays(v, precision) for k, v in obj.items()}
|
|
1964
|
+
if isinstance(obj, list):
|
|
1965
|
+
if (len(obj) >= _BINARY_ARRAY_MIN_LEN
|
|
1966
|
+
and all(isinstance(v, (int, float)) and not isinstance(v, bool)
|
|
1967
|
+
for v in obj)):
|
|
1968
|
+
arr = np.asarray(obj, dtype=np.float64)
|
|
1969
|
+
if _fits_float16(arr, precision):
|
|
1970
|
+
return {"__f16__": base64.b64encode(
|
|
1971
|
+
arr.astype(np.float16).tobytes()).decode("ascii")}
|
|
1972
|
+
arr32 = arr.astype(np.float32)
|
|
1973
|
+
return {"__f32__": base64.b64encode(arr32.tobytes()).decode("ascii")}
|
|
1974
|
+
return [_encode_binary_arrays(v, precision) for v in obj]
|
|
1975
|
+
return obj
|
|
1976
|
+
|
|
1977
|
+
|
|
1978
|
+
def _json_payload(obj) -> str:
|
|
1979
|
+
"""JSON for embedding in an inline ``<script>`` block.
|
|
1980
|
+
|
|
1981
|
+
An HTML parser ends a script element at the first ``</script`` in its text,
|
|
1982
|
+
wherever it appears -- so a label or dimension name carrying that substring
|
|
1983
|
+
would close the payload early and turn whatever followed into live markup.
|
|
1984
|
+
``json.dumps`` does not escape ``<``, so escape it (plus ``>`` and ``&``) as
|
|
1985
|
+
``\\uXXXX``. These are valid JSON string escapes, so ``JSON.parse`` still
|
|
1986
|
+
yields the original characters.
|
|
1987
|
+
"""
|
|
1988
|
+
return (
|
|
1989
|
+
json.dumps(_sanitize_nan(obj))
|
|
1990
|
+
.replace("<", "\\u003c")
|
|
1991
|
+
.replace(">", "\\u003e")
|
|
1992
|
+
.replace("&", "\\u0026")
|
|
1993
|
+
)
|
|
1994
|
+
|
|
1995
|
+
|
|
1996
|
+
def _group_by(items, key):
|
|
1997
|
+
"""Partition ``items`` into groups sharing the same ``key(item)``, in
|
|
1998
|
+
first-seen order (plain equality grouping, not requiring sorted input).
|
|
1999
|
+
"""
|
|
2000
|
+
groups = {}
|
|
2001
|
+
order = []
|
|
2002
|
+
for item in items:
|
|
2003
|
+
k = key(item)
|
|
2004
|
+
if k not in groups:
|
|
2005
|
+
groups[k] = []
|
|
2006
|
+
order.append(k)
|
|
2007
|
+
groups[k].append(item)
|
|
2008
|
+
return [groups[k] for k in order]
|
|
2009
|
+
|
|
2010
|
+
|
|
2011
|
+
def _flatten_axes(ax):
|
|
2012
|
+
"""Normalize a single Axes / list / ndarray of axes to a flat list."""
|
|
2013
|
+
if isinstance(ax, Axes):
|
|
2014
|
+
return [ax]
|
|
2015
|
+
return [a for a in np.asarray(ax, dtype=object).ravel()]
|
|
2016
|
+
|
|
2017
|
+
|
|
2018
|
+
def subplots(nrows=1, ncols=1, figsize=(6.4, 4.8), style: Style = None,
|
|
2019
|
+
facecolor=None, squeeze=True, sharex=False, sharey=False,
|
|
2020
|
+
projection=None):
|
|
2021
|
+
"""Convenience constructor mirroring ``matplotlib.pyplot.subplots``.
|
|
2022
|
+
|
|
2023
|
+
Unlike matplotlib, this creates and returns a fresh, fully independent
|
|
2024
|
+
figure -- there is no global state touched. ``sharex``/``sharey`` link the
|
|
2025
|
+
grid's limits and hide inner tick labels. ``projection='polar'`` makes the
|
|
2026
|
+
axes polar.
|
|
2027
|
+
"""
|
|
2028
|
+
fig = Figure(figsize=figsize, style=style, facecolor=facecolor)
|
|
2029
|
+
axes = fig.subplots(nrows, ncols, squeeze=squeeze, sharex=sharex,
|
|
2030
|
+
sharey=sharey, projection=projection)
|
|
2031
|
+
return fig, axes
|
|
2032
|
+
|
|
2033
|
+
|
|
2034
|
+
def _apply_axes_decorations(ax, spec):
|
|
2035
|
+
"""Re-apply everything ``layout_metadata()`` captured about one axes'
|
|
2036
|
+
own decorations, so :func:`subplots_from_layout`'s caller never has to
|
|
2037
|
+
re-set a title/label/limit/scale by hand. ``.get(...)`` throughout,
|
|
2038
|
+
not direct indexing: a layout loaded from a file saved before these
|
|
2039
|
+
fields existed (see ``_load_layout``'s old-file fallback) simply has
|
|
2040
|
+
none of them, and every one is meant to no-op rather than raise then.
|
|
2041
|
+
|
|
2042
|
+
Routed through :meth:`Axes.set` where possible (the single-value
|
|
2043
|
+
setters it already validates and dispatches) rather than one direct
|
|
2044
|
+
call per property -- keeps this in sync with ``Axes.set()``'s own
|
|
2045
|
+
growing coverage instead of hand-duplicating its dispatch table one
|
|
2046
|
+
property at a time.
|
|
2047
|
+
"""
|
|
2048
|
+
bulk = {}
|
|
2049
|
+
for key in ("xlabel", "ylabel", "xscale", "yscale", "aspect", "box_aspect",
|
|
2050
|
+
"facecolor"):
|
|
2051
|
+
if spec.get(key) is not None:
|
|
2052
|
+
bulk[key] = spec[key]
|
|
2053
|
+
if spec.get("xlim") is not None:
|
|
2054
|
+
bulk["xlim"] = tuple(spec["xlim"])
|
|
2055
|
+
if spec.get("ylim") is not None:
|
|
2056
|
+
bulk["ylim"] = tuple(spec["ylim"])
|
|
2057
|
+
if bulk:
|
|
2058
|
+
ax.set(**bulk)
|
|
2059
|
+
# Not covered by .set() -- see its own docstring on the no-argument
|
|
2060
|
+
# toggles and multi-value setters it deliberately excludes.
|
|
2061
|
+
if spec.get("title") is not None:
|
|
2062
|
+
ax.set_title(spec["title"], size=spec.get("title_size"))
|
|
2063
|
+
if spec.get("axis_off"):
|
|
2064
|
+
ax.set_axis_off()
|
|
2065
|
+
if spec.get("xinverted"):
|
|
2066
|
+
ax.invert_xaxis()
|
|
2067
|
+
if spec.get("yinverted"):
|
|
2068
|
+
ax.invert_yaxis()
|
|
2069
|
+
if spec.get("grid"):
|
|
2070
|
+
ax.grid(True, alpha=spec.get("grid_alpha"))
|
|
2071
|
+
|
|
2072
|
+
|
|
2073
|
+
def subplots_from_layout(layout, figsize=None, style: Style = None, facecolor=None):
|
|
2074
|
+
"""Rebuild a figure with the exact axes grid and :meth:`Figure.group`
|
|
2075
|
+
boxes recorded in a ``load_data()`` ``"layout"`` dict.
|
|
2076
|
+
|
|
2077
|
+
Lets recovered data (:func:`load_data`'s ``"series"``/``"meshes"``/
|
|
2078
|
+
``"pies"``) be replotted into a figure structurally identical to the one
|
|
2079
|
+
it came from, without hand-matching ``nrows``/``ncols`` yourself the way
|
|
2080
|
+
:doc:`/auto_examples/data_roundtrip/index` used to before this existed.
|
|
2081
|
+
|
|
2082
|
+
Returns ``(fig, axes)``. When every recorded axes is a single,
|
|
2083
|
+
non-spanning cell that exactly tiles one ``nrows`` x ``ncols`` grid,
|
|
2084
|
+
``axes`` mirrors what ``plotpress.subplots(nrows, ncols)`` itself would
|
|
2085
|
+
hand back -- a bare ``Axes`` for a 1x1 grid, a 1-D array for a single
|
|
2086
|
+
row/column, otherwise a 2-D array indexed ``axes[row, col]``. Anything
|
|
2087
|
+
else (row/column spans from ``add_gridspec``, mismatched grids across
|
|
2088
|
+
axes, or no grid-placed axes at all) falls back to a flat list of axes
|
|
2089
|
+
in their original save order -- still fully usable, just not
|
|
2090
|
+
array-indexable by row/column.
|
|
2091
|
+
|
|
2092
|
+
``figsize`` overrides ``layout["figsize"]`` (falling back to plotpress's
|
|
2093
|
+
own default when a loaded layout predates that key and carries
|
|
2094
|
+
``None``). ``style`` is the same as :class:`Figure`'s own constructor --
|
|
2095
|
+
it doesn't round-trip through ``layout`` at all (a custom
|
|
2096
|
+
:class:`~plotpress.Style` -- colors, fonts, dpi -- is a bigger,
|
|
2097
|
+
separate concern this doesn't attempt). ``facecolor`` overrides
|
|
2098
|
+
``layout["facecolor"]`` the same way ``figsize`` overrides its own key.
|
|
2099
|
+
|
|
2100
|
+
Every axes comes back already carrying its own title, x/y labels,
|
|
2101
|
+
limits, scale, grid, aspect, and inverted-axis state -- exactly as
|
|
2102
|
+
:meth:`Figure.group` boxes and the grid shape itself already did --
|
|
2103
|
+
so a caller only has to replot the recovered data, never re-set a
|
|
2104
|
+
single label or title by hand. The figure's own :meth:`Figure.suptitle`/
|
|
2105
|
+
:meth:`~Figure.supxlabel`/:meth:`~Figure.supylabel` and background
|
|
2106
|
+
color come back the same way. Not carried over (real gaps, not
|
|
2107
|
+
oversights -- see :func:`plotpress.svg.layout_metadata`'s own
|
|
2108
|
+
docstring for why): colorbars, tick_params()/explicit tick overrides,
|
|
2109
|
+
twin/secondary/inset axes, and a custom ``Style``. An axes that had a
|
|
2110
|
+
:meth:`~Axes.legend` is recorded too, but never auto-applied -- a
|
|
2111
|
+
legend draws from already-plotted, labeled artists, none of which
|
|
2112
|
+
exist on a freshly rebuilt axes yet; call
|
|
2113
|
+
``ax.legend(**layout["axes"][i]["legend"])`` yourself once you've
|
|
2114
|
+
replotted into it.
|
|
2115
|
+
|
|
2116
|
+
Warns (``UserWarning``) when ``layout["omitted_axes"]`` is non-empty --
|
|
2117
|
+
axes the source figure placed with a freeform :meth:`Figure.add_axes`
|
|
2118
|
+
rect rather than a subplot grid cell have no recorded position to
|
|
2119
|
+
rebuild from, so they are simply missing from the returned figure; the
|
|
2120
|
+
warning is the only signal of that, since a caller with no other axes
|
|
2121
|
+
count to compare against would otherwise have no way to notice. A
|
|
2122
|
+
separate warning names any :meth:`Figure.group` whose own box lost a
|
|
2123
|
+
member to that same drop -- the group is still created around whichever
|
|
2124
|
+
of its axes did come back, just smaller than the original.
|
|
2125
|
+
"""
|
|
2126
|
+
omitted = layout.get("omitted_axes") or []
|
|
2127
|
+
if omitted:
|
|
2128
|
+
warnings.warn(
|
|
2129
|
+
f"subplots_from_layout(): {len(omitted)} axes from the saved "
|
|
2130
|
+
f"figure (index {omitted}) were placed with a freeform "
|
|
2131
|
+
"Figure.add_axes() rect, not a subplot grid cell, and could not "
|
|
2132
|
+
"be recovered -- they are simply absent from the rebuilt figure.",
|
|
2133
|
+
UserWarning, stacklevel=2)
|
|
2134
|
+
fig = Figure(figsize=figsize or tuple(layout.get("figsize") or (6.4, 4.8)),
|
|
2135
|
+
style=style,
|
|
2136
|
+
facecolor=facecolor if facecolor is not None else layout.get("facecolor"))
|
|
2137
|
+
axes_specs = layout.get("axes") or {}
|
|
2138
|
+
order = sorted(axes_specs, key=int)
|
|
2139
|
+
# Each spec is looked up from `order` several times below (grid-shape
|
|
2140
|
+
# checks, the fill loop) -- fetched once here into a plain list aligned
|
|
2141
|
+
# with `order`, rather than re-indexing the dict by (already-int) key
|
|
2142
|
+
# over and over.
|
|
2143
|
+
specs = [axes_specs[i] for i in order]
|
|
2144
|
+
by_index = {}
|
|
2145
|
+
for i, spec in zip(order, specs):
|
|
2146
|
+
ss = SubplotSpec(spec["nrows"], spec["ncols"], spec["row0"], spec["row1"],
|
|
2147
|
+
spec["col0"], spec["col1"])
|
|
2148
|
+
ax = fig.add_subplot(ss, projection=spec.get("projection"))
|
|
2149
|
+
_apply_axes_decorations(ax, spec)
|
|
2150
|
+
by_index[int(i)] = ax
|
|
2151
|
+
|
|
2152
|
+
sup = layout.get("suptitle")
|
|
2153
|
+
if sup:
|
|
2154
|
+
fig.suptitle(sup["text"], size=sup.get("size"))
|
|
2155
|
+
supx = layout.get("supxlabel")
|
|
2156
|
+
if supx:
|
|
2157
|
+
fig.supxlabel(supx["text"], size=supx.get("size"))
|
|
2158
|
+
supy = layout.get("supylabel")
|
|
2159
|
+
if supy:
|
|
2160
|
+
fig.supylabel(supy["text"], size=supy.get("size"))
|
|
2161
|
+
|
|
2162
|
+
for g in layout.get("groups") or []:
|
|
2163
|
+
members = [by_index[int(i)] for i in g["axes"] if int(i) in by_index]
|
|
2164
|
+
# n_members (the group's ORIGINAL size, before layout_metadata()
|
|
2165
|
+
# filtered out members it already knew were unrecoverable) is what
|
|
2166
|
+
# tells apart a group that lost one of its own axes -- the
|
|
2167
|
+
# top-level omitted_axes warning above only says *an* axes was
|
|
2168
|
+
# dropped, never which group that broke.
|
|
2169
|
+
n_original = g.get("n_members", len(g["axes"]))
|
|
2170
|
+
if members and len(members) < n_original:
|
|
2171
|
+
warnings.warn(
|
|
2172
|
+
f"subplots_from_layout(): group {g['title']!r} had "
|
|
2173
|
+
f"{n_original} axes in the saved figure but only "
|
|
2174
|
+
f"{len(members)} could be recovered -- the rebuilt group "
|
|
2175
|
+
"box wraps fewer axes than the original.",
|
|
2176
|
+
UserWarning, stacklevel=2)
|
|
2177
|
+
if members:
|
|
2178
|
+
fig.group(g["title"], members, linestyle=g.get("linestyle", "--"),
|
|
2179
|
+
color=g.get("color", "black"), linewidth=g.get("linewidth", 1.5),
|
|
2180
|
+
title_position=g.get("title_position", "top"),
|
|
2181
|
+
pad=tuple(g["pad"]) if g.get("pad") is not None else 8.0,
|
|
2182
|
+
fontsize=g.get("fontsize"))
|
|
2183
|
+
|
|
2184
|
+
ordered = [by_index[int(i)] for i in order]
|
|
2185
|
+
same_shape = len({(s["nrows"], s["ncols"]) for s in specs}) == 1
|
|
2186
|
+
single_cell = all(s["row0"] == s["row1"] and s["col0"] == s["col1"] for s in specs)
|
|
2187
|
+
if ordered and same_shape and single_cell:
|
|
2188
|
+
nrows, ncols = specs[0]["nrows"], specs[0]["ncols"]
|
|
2189
|
+
if len(ordered) == nrows * ncols:
|
|
2190
|
+
grid = np.empty((nrows, ncols), dtype=object)
|
|
2191
|
+
for i, s in zip(order, specs):
|
|
2192
|
+
grid[s["row0"], s["col0"]] = by_index[int(i)]
|
|
2193
|
+
if nrows == 1 and ncols == 1:
|
|
2194
|
+
return fig, grid[0, 0]
|
|
2195
|
+
if nrows == 1 or ncols == 1:
|
|
2196
|
+
return fig, grid.ravel()
|
|
2197
|
+
return fig, grid
|
|
2198
|
+
return fig, ordered
|
|
2199
|
+
|
|
2200
|
+
|
|
2201
|
+
def _fit_cells(avail, n, gaps, floor=0.02):
|
|
2202
|
+
"""Cell size and per-boundary gaps that fit ``n`` cells into ``avail``.
|
|
2203
|
+
|
|
2204
|
+
``gaps`` is a list of ``n - 1`` inter-cell gaps -- not necessarily
|
|
2205
|
+
uniform, since :meth:`Figure.group_spacing` only widens the boundaries
|
|
2206
|
+
that actually border a group. The gap is what the decorations need; the
|
|
2207
|
+
cell is what is left over. When a dense grid cannot afford both, the
|
|
2208
|
+
*gap* gives way first -- panels squeezed together are still readable,
|
|
2209
|
+
and the alternative was worse than ugly: the cell size alone was clamped
|
|
2210
|
+
to a floor while the gap kept its full width, so the rows ran past the
|
|
2211
|
+
top of the canvas and the first nine rows of a 30x30 grid were simply
|
|
2212
|
+
not on the figure.
|
|
2213
|
+
|
|
2214
|
+
If even the floor does not fit, the cells shrink below it rather than
|
|
2215
|
+
overflow. Tiny but present beats absent. A non-uniform ``gaps`` shrinks
|
|
2216
|
+
proportionally, keeping the ratio between a group boundary and a plain
|
|
2217
|
+
tick-label gap rather than collapsing both to the same value.
|
|
2218
|
+
"""
|
|
2219
|
+
if n <= 1:
|
|
2220
|
+
return max(avail, 1e-4), list(gaps)
|
|
2221
|
+
total_gap = sum(gaps)
|
|
2222
|
+
cell = (avail - total_gap) / n
|
|
2223
|
+
if cell >= floor:
|
|
2224
|
+
return cell, list(gaps)
|
|
2225
|
+
max_total_gap = max(0.0, avail - n * floor)
|
|
2226
|
+
scale = (max_total_gap / total_gap) if total_gap > 0 else 0.0
|
|
2227
|
+
new_gaps = [g * scale for g in gaps]
|
|
2228
|
+
return max((avail - max_total_gap) / n, 1e-4), new_gaps
|
|
2229
|
+
|
|
2230
|
+
|
|
2231
|
+
def _subplot_rect(nrows, ncols, index, sp=None):
|
|
2232
|
+
"""Compute an axes rect for a 1-based subplot ``index`` in an NxM grid.
|
|
2233
|
+
|
|
2234
|
+
``sp`` is a ``{left, right, top, bottom, wspace, hspace}`` dict (matching
|
|
2235
|
+
:attr:`Figure._subplot_params`); defaults to matplotlib's own margins when
|
|
2236
|
+
omitted.
|
|
2237
|
+
"""
|
|
2238
|
+
if sp is None:
|
|
2239
|
+
sp = {"left": 0.125, "right": 0.9, "top": 0.88, "bottom": 0.11,
|
|
2240
|
+
"wspace": 0.2, "hspace": 0.2}
|
|
2241
|
+
left, right, bottom, top = sp["left"], sp["right"], sp["bottom"], sp["top"]
|
|
2242
|
+
wspace, hspace = sp["wspace"], sp["hspace"]
|
|
2243
|
+
avail_w = right - left
|
|
2244
|
+
avail_h = top - bottom
|
|
2245
|
+
axw = avail_w / (ncols + wspace * (ncols - 1))
|
|
2246
|
+
axh = avail_h / (nrows + hspace * (nrows - 1))
|
|
2247
|
+
|
|
2248
|
+
idx = index - 1
|
|
2249
|
+
row = idx // ncols
|
|
2250
|
+
col = idx % ncols
|
|
2251
|
+
ax_left = left + col * axw * (1 + wspace)
|
|
2252
|
+
ax_bottom = bottom + (nrows - 1 - row) * axh * (1 + hspace)
|
|
2253
|
+
return (ax_left, ax_bottom, axw, axh)
|
|
2254
|
+
|
|
2255
|
+
|
|
2256
|
+
_REPORT_MAX_WIDTH = 1600 # .plotpress-report's own max-width, below --
|
|
2257
|
+
# Report.save() reuses this for its iframes'
|
|
2258
|
+
# starting height guess, so the two never drift
|
|
2259
|
+
# apart the way a second hardcoded number would.
|
|
2260
|
+
|
|
2261
|
+
_REPORT_STYLE = (
|
|
2262
|
+
"<style>"
|
|
2263
|
+
"body{margin:0;padding:24px 16px;background:#f5f5f5;"
|
|
2264
|
+
"font:14px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#1a1a1a}"
|
|
2265
|
+
f".plotpress-report{{max-width:{_REPORT_MAX_WIDTH}px;margin:0 auto}}"
|
|
2266
|
+
".plotpress-report>h1{font-size:24px;margin:0 0 6px}"
|
|
2267
|
+
".plotpress-report-description{color:#555;margin:0 0 32px;max-width:70ch}"
|
|
2268
|
+
".plotpress-report-entry{margin-bottom:44px}"
|
|
2269
|
+
".plotpress-report-label{font-size:11px;font-weight:600;letter-spacing:.04em;"
|
|
2270
|
+
"text-transform:uppercase;color:#888;margin-bottom:4px}"
|
|
2271
|
+
".plotpress-report-entry h2{font-size:18px;margin:0 0 4px}"
|
|
2272
|
+
".plotpress-report-details{color:#555;margin:0 0 14px;max-width:70ch;"
|
|
2273
|
+
"white-space:pre-wrap}"
|
|
2274
|
+
# width:100% (not max-width) -- this is what actually stretches each
|
|
2275
|
+
# figure to fill the report's own width instead of sitting at whatever
|
|
2276
|
+
# fixed pixel size the figure happened to be created at.
|
|
2277
|
+
".plotpress-report-entry iframe{border:1px solid #ddd;border-radius:6px;"
|
|
2278
|
+
"background:#fff;display:block;width:100%}"
|
|
2279
|
+
"</style>"
|
|
2280
|
+
)
|
|
2281
|
+
|
|
2282
|
+
# Resizes each report iframe to its actual rendered content height instead of
|
|
2283
|
+
# a fixed guess -- the SVG inside scales to fill whatever width the iframe is
|
|
2284
|
+
# given (see Figure.to_html's standalone=False), so a static height computed
|
|
2285
|
+
# once at save() time would either clip it (a narrower guess than the reader's
|
|
2286
|
+
# actual browser width lets the figure grow to) or leave empty space below it
|
|
2287
|
+
# (a wider one). srcdoc iframes share their parent's origin, so the page can
|
|
2288
|
+
# read contentDocument directly -- no postMessage handshake needed. Toolbar
|
|
2289
|
+
# and docked-slider clearance need no separate accounting here: they're real
|
|
2290
|
+
# body padding inside the embedded document itself (see Figure.to_html's
|
|
2291
|
+
# standalone=False branch), so scrollHeight already includes them.
|
|
2292
|
+
#
|
|
2293
|
+
# fit() no-ops until an iframe's own `load` marks it dataset.loaded -- a
|
|
2294
|
+
# below-the-fold entry (loading="lazy") can still receive the debounced
|
|
2295
|
+
# resize handler's sweep before it has ever loaded, and measuring an
|
|
2296
|
+
# unloaded/placeholder document's near-zero scrollHeight would collapse its
|
|
2297
|
+
# still-showing initial height guess for no reason. It also skips an iframe
|
|
2298
|
+
# whose rendered width hasn't changed since its last fit -- on a fixed
|
|
2299
|
+
# aspect ratio, that means its needed height hasn't either.
|
|
2300
|
+
_REPORT_RESIZE_JS = (
|
|
2301
|
+
"<script>(function(){"
|
|
2302
|
+
"function fit(f){"
|
|
2303
|
+
"var d=f.contentDocument;if(!d||!d.body||!f.dataset.loaded)return;"
|
|
2304
|
+
"var w=f.clientWidth;if(f.dataset.fitWidth===String(w))return;"
|
|
2305
|
+
"f.dataset.fitWidth=String(w);"
|
|
2306
|
+
"f.style.height=d.body.scrollHeight+'px';}"
|
|
2307
|
+
"var frames=document.querySelectorAll('.plotpress-report-entry iframe');"
|
|
2308
|
+
"frames.forEach(function(f){f.addEventListener('load',function(){"
|
|
2309
|
+
"f.dataset.loaded='1';fit(f);});});"
|
|
2310
|
+
"var t;window.addEventListener('resize',function(){"
|
|
2311
|
+
"clearTimeout(t);t=setTimeout(function(){frames.forEach(fit);},120);});"
|
|
2312
|
+
"})();</script>"
|
|
2313
|
+
)
|
|
2314
|
+
|
|
2315
|
+
|
|
2316
|
+
class Report:
|
|
2317
|
+
"""An ordered collection of figures combined into one self-contained HTML file.
|
|
2318
|
+
|
|
2319
|
+
Each figure keeps its own independent interactivity -- its own toolbar,
|
|
2320
|
+
pan/zoom, point-picking, annotations -- because it is embedded in its own
|
|
2321
|
+
``<iframe>`` rather than spliced directly into the page. An interactive
|
|
2322
|
+
figure's JS (:mod:`plotpress._interactive`) assumes it owns the page: fixed
|
|
2323
|
+
element ids (``plotpress-svg``, ``plotpress-meta``, ...) and a
|
|
2324
|
+
document-level toolbar, so several figures sharing one page directly would
|
|
2325
|
+
collide -- the same reason the docs gallery embeds every live figure this
|
|
2326
|
+
way (see ``docs/conf.py``'s ``_interactive_embed``). An iframe gives each
|
|
2327
|
+
figure its own document instead, at no real cost to "one file": each
|
|
2328
|
+
figure's already-self-contained HTML (see :meth:`Figure.to_html`) is
|
|
2329
|
+
inlined via the iframe's ``srcdoc`` attribute rather than referenced as a
|
|
2330
|
+
separate file, so the report is still a single, self-contained HTML
|
|
2331
|
+
document with no external requests.
|
|
2332
|
+
|
|
2333
|
+
Add figures with :meth:`add`, in the order they should appear, then write
|
|
2334
|
+
the combined file with :meth:`save`::
|
|
2335
|
+
|
|
2336
|
+
report = plotpress.Report(title="Weekly QA sweep",
|
|
2337
|
+
description="Four sensor batches, one figure each.")
|
|
2338
|
+
report.add(fig_a, title="Batch A", details="Baseline run, no anomalies.")
|
|
2339
|
+
report.add(fig_b, title="Batch B", details="Elevated noise floor after 14:00.")
|
|
2340
|
+
report.save("qa_sweep.html")
|
|
2341
|
+
"""
|
|
2342
|
+
|
|
2343
|
+
def __init__(self, title: str = None, description: str = None):
|
|
2344
|
+
self.title = title
|
|
2345
|
+
self.description = description
|
|
2346
|
+
self._entries = [] # [(figure, title, details)], in add() order
|
|
2347
|
+
|
|
2348
|
+
def add(self, figure: "Figure", title: str = None, details: str = None) -> "Report":
|
|
2349
|
+
"""Append ``figure`` to the report; returns ``self`` so calls can chain.
|
|
2350
|
+
|
|
2351
|
+
``title`` (a short heading) and ``details`` (a longer description) are
|
|
2352
|
+
optional per-figure annotations rendered above the embedded figure.
|
|
2353
|
+
Figures appear in the HTML in the order they were added -- there is no
|
|
2354
|
+
separate ordering mechanism to keep in sync.
|
|
2355
|
+
"""
|
|
2356
|
+
if not isinstance(figure, Figure):
|
|
2357
|
+
raise TypeError("Report.add() expects a Figure, got %r" % (figure,))
|
|
2358
|
+
self._entries.append((figure, title, details))
|
|
2359
|
+
return self
|
|
2360
|
+
|
|
2361
|
+
def save(self, path: str, interactive: bool = True,
|
|
2362
|
+
pick_precision: int = 6, pick_max_mesh_cells: int = 250000,
|
|
2363
|
+
pick_max_points: int = 20000, binary_pick_data: bool = True) -> str:
|
|
2364
|
+
"""Write every added figure, in order, to one self-contained HTML file.
|
|
2365
|
+
|
|
2366
|
+
``interactive`` and the ``pick_*``/``binary_pick_data`` arguments are
|
|
2367
|
+
forwarded to each figure's own :meth:`Figure.to_html` -- see there for
|
|
2368
|
+
what they mean. Every figure in the report shares the same settings;
|
|
2369
|
+
call :meth:`Figure.to_html` directly (and write the file yourself) for
|
|
2370
|
+
a mix of interactive and static figures on one page.
|
|
2371
|
+
"""
|
|
2372
|
+
if not self._entries:
|
|
2373
|
+
raise ValueError("Report has no figures -- call add() at least once")
|
|
2374
|
+
parts = [
|
|
2375
|
+
"<!doctype html><html><head><meta charset='utf-8'>",
|
|
2376
|
+
f"<title>{html.escape(self.title)}</title>" if self.title else "",
|
|
2377
|
+
_REPORT_STYLE,
|
|
2378
|
+
"</head><body><div class='plotpress-report'>",
|
|
2379
|
+
]
|
|
2380
|
+
if self.title:
|
|
2381
|
+
parts.append(f"<h1>{html.escape(self.title)}</h1>")
|
|
2382
|
+
if self.description:
|
|
2383
|
+
parts.append('<p class="plotpress-report-description">'
|
|
2384
|
+
f'{html.escape(self.description)}</p>')
|
|
2385
|
+
for n, (figure, title, details) in enumerate(self._entries, start=1):
|
|
2386
|
+
doc = figure.to_html(interactive=interactive,
|
|
2387
|
+
pick_precision=pick_precision,
|
|
2388
|
+
pick_max_mesh_cells=pick_max_mesh_cells,
|
|
2389
|
+
pick_max_points=pick_max_points,
|
|
2390
|
+
binary_pick_data=binary_pick_data,
|
|
2391
|
+
standalone=False)
|
|
2392
|
+
dpi = figure.style.dpi
|
|
2393
|
+
natural_w = figure.figsize[0] * dpi
|
|
2394
|
+
natural_h = figure.figsize[1] * dpi
|
|
2395
|
+
top_pad, bottom_pad = _toolbar_clearance(interactive, len(figure._sliders or {}))
|
|
2396
|
+
# A starting guess only -- the resize script (_REPORT_RESIZE_JS)
|
|
2397
|
+
# corrects this to the real rendered height right after the
|
|
2398
|
+
# iframe loads, once it knows how wide the reader's own browser
|
|
2399
|
+
# actually made it. Guessing at .plotpress-report's own max
|
|
2400
|
+
# rendered width (rather than the figure's own pixel size, often
|
|
2401
|
+
# much narrower) keeps that first correction small; toolbar/slider
|
|
2402
|
+
# clearance is exact, not guessed, since it's baked into the
|
|
2403
|
+
# embedded document's own body padding either way (Figure.to_html,
|
|
2404
|
+
# standalone=False) -- scrollHeight will already include it.
|
|
2405
|
+
guess_w = _REPORT_MAX_WIDTH - 2 * 16 - 2 * 1 # body padding, iframe border
|
|
2406
|
+
h = round(guess_w * natural_h / natural_w) + top_pad + bottom_pad
|
|
2407
|
+
iframe_title = html.escape(title) if title else "Figure %d" % n
|
|
2408
|
+
parts.append('<div class="plotpress-report-entry">')
|
|
2409
|
+
parts.append(f'<div class="plotpress-report-label">Figure {n}</div>')
|
|
2410
|
+
if title:
|
|
2411
|
+
parts.append(f"<h2>{html.escape(title)}</h2>")
|
|
2412
|
+
if details:
|
|
2413
|
+
parts.append('<p class="plotpress-report-details">'
|
|
2414
|
+
f'{html.escape(details)}</p>')
|
|
2415
|
+
parts.append(
|
|
2416
|
+
f'<iframe srcdoc="{html.escape(doc)}" height="{h}" '
|
|
2417
|
+
f'loading="lazy" title="{iframe_title}"></iframe>')
|
|
2418
|
+
parts.append("</div>")
|
|
2419
|
+
parts.append(_REPORT_RESIZE_JS)
|
|
2420
|
+
parts.append("</div></body></html>")
|
|
2421
|
+
content = "".join(parts)
|
|
2422
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
2423
|
+
f.write(content)
|
|
2424
|
+
return path
|
|
2425
|
+
|
|
2426
|
+
|
|
2427
|
+
def _decode_binary_arrays(obj):
|
|
2428
|
+
"""Reverse :func:`_encode_binary_arrays`: a ``{"__f32__": b64}``/
|
|
2429
|
+
``{"__f16__": b64}`` leaf becomes a real ``numpy`` array; everything else
|
|
2430
|
+
is walked unchanged. float16 decodes via ``numpy``'s native dtype (exact,
|
|
2431
|
+
unlike the JS side's hand-rolled ``halfToFloat`` -- there is no
|
|
2432
|
+
``Float16Array`` in a browser, but Python has no such gap).
|
|
2433
|
+
"""
|
|
2434
|
+
if isinstance(obj, dict):
|
|
2435
|
+
if set(obj) == {"__f32__"}:
|
|
2436
|
+
return np.frombuffer(base64.b64decode(obj["__f32__"]), dtype=np.float32)
|
|
2437
|
+
if set(obj) == {"__f16__"}:
|
|
2438
|
+
return np.frombuffer(base64.b64decode(obj["__f16__"]),
|
|
2439
|
+
dtype=np.float16).astype(np.float64)
|
|
2440
|
+
return {k: _decode_binary_arrays(v) for k, v in obj.items()}
|
|
2441
|
+
if isinstance(obj, list):
|
|
2442
|
+
return [_decode_binary_arrays(v) for v in obj]
|
|
2443
|
+
return obj
|
|
2444
|
+
|
|
2445
|
+
|
|
2446
|
+
def _expand_columnar_meta(payload):
|
|
2447
|
+
"""Reverse :func:`_columnarize_meta`: ``{"cols", "index", "keys"}`` (one
|
|
2448
|
+
array per field) back to ``{axes_index: {field: value, ...}, ...}``. A
|
|
2449
|
+
plain (non-columnarized) meta payload -- ``binary_pick_data=False`` never
|
|
2450
|
+
columnarizes -- is returned unchanged.
|
|
2451
|
+
"""
|
|
2452
|
+
if not (isinstance(payload, dict)
|
|
2453
|
+
and {"cols", "index", "keys"} <= set(payload)):
|
|
2454
|
+
return payload
|
|
2455
|
+
cols, index, keys = payload["cols"], payload["index"], payload["keys"]
|
|
2456
|
+
return {i: {k: cols[k][pos] for k in keys} for pos, i in enumerate(index)}
|
|
2457
|
+
|
|
2458
|
+
|
|
2459
|
+
def _extract_json_block(text, element_id):
|
|
2460
|
+
"""The parsed JSON body of ``<script type="application/json" id="...">``,
|
|
2461
|
+
or ``None`` if that element isn't in ``text`` at all."""
|
|
2462
|
+
m = re.search(
|
|
2463
|
+
r'<script type="application/json" id="%s">(.*?)</script>' % re.escape(element_id),
|
|
2464
|
+
text, re.DOTALL)
|
|
2465
|
+
return json.loads(m.group(1)) if m else None
|
|
2466
|
+
|
|
2467
|
+
|
|
2468
|
+
def _mesh_centers(mesh):
|
|
2469
|
+
"""1-D cell-center coordinate arrays for a ``pick_data()`` mesh entry, or
|
|
2470
|
+
``(None, None)`` for a curvilinear (warped) mesh, which has no separable
|
|
2471
|
+
per-axis coordinates -- only per-cell ``xc``/``yc`` centers.
|
|
2472
|
+
"""
|
|
2473
|
+
if mesh.get("curvilinear"):
|
|
2474
|
+
return None, None
|
|
2475
|
+
if "xcoord" in mesh:
|
|
2476
|
+
# A contour's samples: the exact coordinate, not an edge midpoint --
|
|
2477
|
+
# see pick_data()'s own contour branch for why those can differ.
|
|
2478
|
+
return (np.asarray(mesh["xcoord"], dtype=float),
|
|
2479
|
+
np.asarray(mesh["ycoord"], dtype=float))
|
|
2480
|
+
xe = np.asarray(mesh["xedges"], dtype=float)
|
|
2481
|
+
ye = np.asarray(mesh["yedges"], dtype=float)
|
|
2482
|
+
return (xe[:-1] + xe[1:]) / 2.0, (ye[:-1] + ye[1:]) / 2.0
|
|
2483
|
+
|
|
2484
|
+
|
|
2485
|
+
def _load_single_figure(text):
|
|
2486
|
+
"""Every plotted axes' data out of one figure's own interactive HTML."""
|
|
2487
|
+
pick = _extract_json_block(text, "plotpress-pick")
|
|
2488
|
+
if pick is None:
|
|
2489
|
+
raise ValueError(
|
|
2490
|
+
"no embedded plot data found -- load_data() only works on HTML "
|
|
2491
|
+
"saved with interactive=True (Figure.to_html()/save(..., "
|
|
2492
|
+
"interactive=True) or Report.save()); a static SVG or "
|
|
2493
|
+
"interactive=False HTML embeds only drawn shapes, nothing to "
|
|
2494
|
+
"read back")
|
|
2495
|
+
pick = {int(k): v for k, v in _decode_binary_arrays(pick).items()}
|
|
2496
|
+
meta_raw = _extract_json_block(text, "plotpress-meta") or {}
|
|
2497
|
+
meta = _expand_columnar_meta(_decode_binary_arrays(meta_raw))
|
|
2498
|
+
meta = {int(k): v for k, v in meta.items()}
|
|
2499
|
+
|
|
2500
|
+
axes = {}
|
|
2501
|
+
for i in sorted(set(pick) | set(meta)):
|
|
2502
|
+
entry = pick.get(i, {"series": [], "meshes": [], "pies": []})
|
|
2503
|
+
m = meta.get(i, {})
|
|
2504
|
+
series = []
|
|
2505
|
+
for s in entry.get("series", []):
|
|
2506
|
+
series.append({
|
|
2507
|
+
"kind": s.get("kind"),
|
|
2508
|
+
"x": np.asarray(s["x"], dtype=float),
|
|
2509
|
+
"y": np.asarray(s["y"], dtype=float),
|
|
2510
|
+
"vals": {k: np.asarray(v, dtype=float)
|
|
2511
|
+
for k, v in s.get("vals", {}).items()},
|
|
2512
|
+
})
|
|
2513
|
+
meshes = []
|
|
2514
|
+
for msh in entry.get("meshes", []):
|
|
2515
|
+
ny, nx = msh["shape"]
|
|
2516
|
+
z = np.asarray(msh["z"], dtype=float).reshape(ny, nx)
|
|
2517
|
+
xc, yc = _mesh_centers(msh)
|
|
2518
|
+
meshes.append({
|
|
2519
|
+
"x": xc, "y": yc, "z": z,
|
|
2520
|
+
"extent": tuple(msh["extent"]),
|
|
2521
|
+
"curvilinear": bool(msh.get("curvilinear", False)),
|
|
2522
|
+
})
|
|
2523
|
+
axes[i] = {
|
|
2524
|
+
"series": series, "meshes": meshes, "pies": entry.get("pies", []),
|
|
2525
|
+
"title": m.get("title"), "xlabel": m.get("xlabel"),
|
|
2526
|
+
"ylabel": m.get("ylabel"), "zlabel": m.get("zlabel"),
|
|
2527
|
+
"xlim": (m["xmin"], m["xmax"]) if "xmin" in m else None,
|
|
2528
|
+
"ylim": (m["ymin"], m["ymax"]) if "ymin" in m else None,
|
|
2529
|
+
"xscale": m.get("xscale"), "yscale": m.get("yscale"),
|
|
2530
|
+
}
|
|
2531
|
+
return axes
|
|
2532
|
+
|
|
2533
|
+
|
|
2534
|
+
def _load_layout(text):
|
|
2535
|
+
"""The ``plotpress-layout`` block (see ``svg.layout_metadata``), or the
|
|
2536
|
+
empty layout a figure with no grid-placed axes and no groups would embed
|
|
2537
|
+
-- older files saved before this block existed fall back to the same
|
|
2538
|
+
shape rather than raising, so ``load_data()`` keeps working on them.
|
|
2539
|
+
"""
|
|
2540
|
+
raw = _extract_json_block(text, "plotpress-layout")
|
|
2541
|
+
if raw is None:
|
|
2542
|
+
return {"figsize": None, "axes": {}, "groups": [], "omitted_axes": [],
|
|
2543
|
+
"suptitle": None, "supxlabel": None, "supylabel": None,
|
|
2544
|
+
"facecolor": None}
|
|
2545
|
+
return {**raw, "axes": {int(k): v for k, v in raw["axes"].items()}}
|
|
2546
|
+
|
|
2547
|
+
|
|
2548
|
+
def _split_report_entries(text):
|
|
2549
|
+
"""One chunk of HTML per :class:`Report` entry, each starting at its
|
|
2550
|
+
``plotpress-report-label`` div (always present, unlike the optional title/
|
|
2551
|
+
details) -- avoids needing to balance nested ``<div>`` tags with regex,
|
|
2552
|
+
which a proper (non-regular) HTML parse would need otherwise.
|
|
2553
|
+
"""
|
|
2554
|
+
return text.split('<div class="plotpress-report-label">')[1:]
|
|
2555
|
+
|
|
2556
|
+
|
|
2557
|
+
def _dedupe_keyed(pairs, noun, stacklevel):
|
|
2558
|
+
"""Build a dict from ``[(key, item), ...]`` pairs (already in the order
|
|
2559
|
+
they should be tried), disambiguating any collision with a
|
|
2560
|
+
``"<key> (2)"``, ``"<key> (3)"``, ... suffix -- rather than silently
|
|
2561
|
+
letting a later item overwrite, and lose, an earlier one that resolves
|
|
2562
|
+
to the identical key. Two axes (or two Report entries) sharing an
|
|
2563
|
+
explicit title is realistic authoring, not exotic input worth crashing
|
|
2564
|
+
or staying silent about -- a grid of identically-labeled panels, a
|
|
2565
|
+
report re-using a section name -- the same "accept it, don't crash,
|
|
2566
|
+
but don't stay silent" choice :func:`plotpress.artists.normalize_linestyle`
|
|
2567
|
+
already makes for an unrecognized linestyle. Warns once, naming every
|
|
2568
|
+
collision resolved, rather than the caller discovering a shorter dict
|
|
2569
|
+
than they expected with no signal why.
|
|
2570
|
+
|
|
2571
|
+
``noun`` is ``(singular, plural)`` (e.g. ``("figure", "figures")``),
|
|
2572
|
+
so the one-collision case reads naturally instead of always using the
|
|
2573
|
+
plural form regardless of count.
|
|
2574
|
+
"""
|
|
2575
|
+
keyed = {}
|
|
2576
|
+
collisions = []
|
|
2577
|
+
for base, item in pairs:
|
|
2578
|
+
key, n = base, 2
|
|
2579
|
+
while key in keyed:
|
|
2580
|
+
key = f"{base} ({n})"
|
|
2581
|
+
n += 1
|
|
2582
|
+
if key != base:
|
|
2583
|
+
collisions.append((base, key))
|
|
2584
|
+
keyed[key] = item
|
|
2585
|
+
if collisions:
|
|
2586
|
+
singular, plural = noun
|
|
2587
|
+
word = singular if len(collisions) == 1 else plural
|
|
2588
|
+
detail = ", ".join(f"{b!r} -> {k!r}" for b, k in collisions)
|
|
2589
|
+
warnings.warn(
|
|
2590
|
+
f"load_data(): {len(collisions)} {word} shared a title with "
|
|
2591
|
+
f"another already-keyed one -- disambiguated ({detail}) so every "
|
|
2592
|
+
"one stays recoverable instead of a later one silently "
|
|
2593
|
+
"overwriting an earlier one with the same key. Pass "
|
|
2594
|
+
"by_index=True for a stable, collision-free key instead.",
|
|
2595
|
+
UserWarning, stacklevel=stacklevel)
|
|
2596
|
+
return keyed
|
|
2597
|
+
|
|
2598
|
+
|
|
2599
|
+
def _title_keyed_axes(axes):
|
|
2600
|
+
"""Re-key an int-indexed axes dict by each axes' own title, falling back
|
|
2601
|
+
to ``"axes {i}"`` when it has none -- the same fallback a picked record's
|
|
2602
|
+
``axes_title`` already uses (see ``_interactive.py``'s
|
|
2603
|
+
``resolvePickTarget``), so both surfaces name an untitled axes the same
|
|
2604
|
+
way. Two axes sharing an explicit title is disambiguated, not silently
|
|
2605
|
+
collapsed to one -- see :func:`_dedupe_keyed`.
|
|
2606
|
+
"""
|
|
2607
|
+
pairs = [(axes[i].get("title") or f"axes {i}", axes[i]) for i in sorted(axes)]
|
|
2608
|
+
return _dedupe_keyed(pairs, ("axes", "axes"), stacklevel=4)
|
|
2609
|
+
|
|
2610
|
+
|
|
2611
|
+
def load_data(path: str, by_index: bool = False):
|
|
2612
|
+
"""Read back the plotted data embedded in a self-contained interactive
|
|
2613
|
+
HTML file written by :meth:`Figure.to_html`/:meth:`Figure.save` or
|
|
2614
|
+
:meth:`Report.save`.
|
|
2615
|
+
|
|
2616
|
+
By default, returns a dict keyed by each figure's own title (a
|
|
2617
|
+
:class:`Report` entry's :meth:`Report.add` title; a generated
|
|
2618
|
+
``"Figure N"`` -- 1-based, matching the label a :class:`Report` page
|
|
2619
|
+
itself shows -- for an entry with none, or for a bare :class:`Figure`'s
|
|
2620
|
+
HTML, which has no report-level title at all). Each figure's own value
|
|
2621
|
+
has ``"details"`` (a `Report` entry's longer description, or ``None``)
|
|
2622
|
+
``"axes"`` (itself a dict keyed by each axes' own title, falling back to
|
|
2623
|
+
``"axes {index}"`` -- matching a picked record's ``axes_title`` fallback
|
|
2624
|
+
-- for an untitled one), and ``"layout"``::
|
|
2625
|
+
|
|
2626
|
+
{"series": [{"kind": "line", "x": array, "y": array,
|
|
2627
|
+
"vals": {name: array, ...}}, ...],
|
|
2628
|
+
"meshes": [{"x": array, # 1-D cell centers (None if curvilinear)
|
|
2629
|
+
"y": array, # 1-D cell centers (None if curvilinear)
|
|
2630
|
+
"z": array, # 2-D, shape (ny, nx), row 0 = ymin
|
|
2631
|
+
"extent": (xmin, xmax, ymin, ymax),
|
|
2632
|
+
"curvilinear": bool}, ...],
|
|
2633
|
+
"pies": [...],
|
|
2634
|
+
"title": str | None, "xlabel": str | None, "ylabel": str | None,
|
|
2635
|
+
"zlabel": str | None, "xlim": (float, float) | None,
|
|
2636
|
+
"ylim": (float, float) | None, "xscale": str, "yscale": str}
|
|
2637
|
+
|
|
2638
|
+
``"layout"`` is the figure-level structure -- grid shape/position and
|
|
2639
|
+
every decoration (title, labels, limits, scale, ...) of each
|
|
2640
|
+
subplot-grid axes, plus any :meth:`Figure.group` boxes and the
|
|
2641
|
+
figure's own sup-title/label -- needed to rebuild an equivalent,
|
|
2642
|
+
already-labeled figure, independent of the per-axes data above::
|
|
2643
|
+
|
|
2644
|
+
{"figsize": [w, h],
|
|
2645
|
+
"axes": {index: {"nrows": int, "ncols": int, "row0": int, "row1": int,
|
|
2646
|
+
"col0": int, "col1": int,
|
|
2647
|
+
"projection": "polar" | None,
|
|
2648
|
+
"title": str | None, "title_size": float | None,
|
|
2649
|
+
"xlabel": str | None, "ylabel": str | None,
|
|
2650
|
+
"xlim": [float, float], "ylim": [float, float],
|
|
2651
|
+
"xscale": str, "yscale": str,
|
|
2652
|
+
"xinverted": bool, "yinverted": bool,
|
|
2653
|
+
"grid": bool, "grid_alpha": float | None,
|
|
2654
|
+
"aspect": float | None, "box_aspect": float | None,
|
|
2655
|
+
"axis_off": bool, "facecolor": str | None,
|
|
2656
|
+
"legend": {"loc": str, "ncol": int, "title": str | None,
|
|
2657
|
+
"fontsize": float | None,
|
|
2658
|
+
"framealpha": float} | None}, ...},
|
|
2659
|
+
"groups": [{"title": str, "axes": [index, ...], "n_members": int,
|
|
2660
|
+
"linestyle": str, "color": str, "linewidth": float,
|
|
2661
|
+
"title_position": str, "pad": [l, r, t, b],
|
|
2662
|
+
"fontsize": float | None}, ...],
|
|
2663
|
+
"omitted_axes": [index, ...],
|
|
2664
|
+
"suptitle": {"text": str, "size": float | None} | None,
|
|
2665
|
+
"supxlabel": {"text": str, "size": float | None} | None,
|
|
2666
|
+
"supylabel": {"text": str, "size": float | None} | None,
|
|
2667
|
+
"facecolor": str}
|
|
2668
|
+
|
|
2669
|
+
Pass ``"layout"`` straight to :func:`subplots_from_layout` to recreate
|
|
2670
|
+
the source figure's grid, every axes' own decorations, and its groups
|
|
2671
|
+
before replotting recovered data into it -- see
|
|
2672
|
+
:doc:`/auto_examples/data_roundtrip/index`. A file saved before 3-D
|
|
2673
|
+
support was removed can still report the literal ``"3d"`` here (this
|
|
2674
|
+
function only reads back whatever string was stored, it doesn't
|
|
2675
|
+
validate it) -- :func:`subplots_from_layout` raises a clear "unknown
|
|
2676
|
+
projection" for that one, since it cannot rebuild an axes kind that no
|
|
2677
|
+
longer exists. Axes placed with a
|
|
2678
|
+
freeform :meth:`Figure.add_axes` rect (no grid cell) and colorbar axes
|
|
2679
|
+
are absent from ``"axes"`` -- their indices are listed in
|
|
2680
|
+
``"omitted_axes"`` instead -- and a group's own ``"n_members"`` is its
|
|
2681
|
+
*original* member count, before any unrecoverable member was filtered
|
|
2682
|
+
out of its ``"axes"`` list, so a caller can tell a group that lost one
|
|
2683
|
+
apart from one that didn't. ``"legend"`` is recorded but not
|
|
2684
|
+
auto-applied by ``subplots_from_layout`` -- see that function's own
|
|
2685
|
+
docstring for why. A file saved before these keys existed loads as
|
|
2686
|
+
``{"figsize": None, "axes": {}, "groups": [], "omitted_axes": [],
|
|
2687
|
+
"suptitle": None, "supxlabel": None, "supylabel": None, "facecolor": None}``,
|
|
2688
|
+
and one saved by an in-between version has ``"axes"`` entries with the
|
|
2689
|
+
grid-shape keys above but none of the decoration ones (each simply
|
|
2690
|
+
absent, not ``None``).
|
|
2691
|
+
|
|
2692
|
+
Title keys are convenient but not guaranteed unique -- two figures (or
|
|
2693
|
+
two axes within one figure) sharing the same title no longer collide
|
|
2694
|
+
silently: the later one is disambiguated with a ``" (2)"``, ``" (3)"``,
|
|
2695
|
+
... suffix rather than overwriting (and losing) the earlier one, and a
|
|
2696
|
+
``UserWarning`` names every collision resolved this way. Pass
|
|
2697
|
+
``by_index=True`` when even that renaming matters, or when a stable,
|
|
2698
|
+
order-based key is simply more useful than a name: this returns a list
|
|
2699
|
+
of per-figure dicts instead (one per figure embedded in the file, in
|
|
2700
|
+
the order they appear -- a bare figure's HTML still comes back as a
|
|
2701
|
+
one-item list), each with the same ``"title"``/``"details"``/``"axes"``/
|
|
2702
|
+
``"layout"`` shape as above except ``"axes"`` is keyed by plain integer
|
|
2703
|
+
index rather than title -- and never renamed, since there is no title
|
|
2704
|
+
collision to resolve when the key is a position instead of a name.
|
|
2705
|
+
|
|
2706
|
+
Only works on HTML saved with ``interactive=True``: a static SVG or an
|
|
2707
|
+
``interactive=False`` HTML embeds no data to read back, only drawn
|
|
2708
|
+
shapes, and raises ``ValueError``. Recovered arrays reflect whatever
|
|
2709
|
+
precision/caps were in effect at save time (``pick_precision``,
|
|
2710
|
+
``pick_max_points``, ``pick_max_mesh_cells``) -- they are not guaranteed
|
|
2711
|
+
bit-exact copies of the original data for a series/mesh that was rounded
|
|
2712
|
+
or capped on the way out. A mesh that crossed ``pick_max_mesh_cells`` at
|
|
2713
|
+
save time comes back at that coarser, block-averaged resolution, not the
|
|
2714
|
+
original grid's -- see :meth:`Figure.to_html`'s own docstring for
|
|
2715
|
+
exactly what that averaging costs.
|
|
2716
|
+
"""
|
|
2717
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
2718
|
+
text = f.read()
|
|
2719
|
+
|
|
2720
|
+
if 'srcdoc="' not in text:
|
|
2721
|
+
figures = [{"title": None, "details": None, "axes": _load_single_figure(text),
|
|
2722
|
+
"layout": _load_layout(text)}]
|
|
2723
|
+
else:
|
|
2724
|
+
figures = []
|
|
2725
|
+
for chunk in _split_report_entries(text):
|
|
2726
|
+
srcdoc_m = re.search(r'srcdoc="(.*?)"', chunk, re.DOTALL)
|
|
2727
|
+
if not srcdoc_m:
|
|
2728
|
+
continue
|
|
2729
|
+
title_m = re.search(r"<h2>(.*?)</h2>", chunk, re.DOTALL)
|
|
2730
|
+
details_m = re.search(
|
|
2731
|
+
r'<p class="plotpress-report-details">(.*?)</p>', chunk, re.DOTALL)
|
|
2732
|
+
doc = html.unescape(srcdoc_m.group(1))
|
|
2733
|
+
figures.append({
|
|
2734
|
+
"title": html.unescape(title_m.group(1)) if title_m else None,
|
|
2735
|
+
"details": html.unescape(details_m.group(1)) if details_m else None,
|
|
2736
|
+
"axes": _load_single_figure(doc),
|
|
2737
|
+
"layout": _load_layout(doc),
|
|
2738
|
+
})
|
|
2739
|
+
|
|
2740
|
+
if by_index:
|
|
2741
|
+
return figures
|
|
2742
|
+
|
|
2743
|
+
pairs = [(entry["title"] or f"Figure {n}", entry)
|
|
2744
|
+
for n, entry in enumerate(figures, start=1)]
|
|
2745
|
+
keyed = _dedupe_keyed(pairs, ("figure", "figures"), stacklevel=3)
|
|
2746
|
+
return {key: {**entry, "axes": _title_keyed_axes(entry["axes"])}
|
|
2747
|
+
for key, entry in keyed.items()}
|
|
2748
|
+
|
|
2749
|
+
|
|
2750
|
+
def load_data_xarray(path: str, figure=None):
|
|
2751
|
+
"""Read one figure's plotted data back as a single ``xarray.Dataset``,
|
|
2752
|
+
dimensioned by the figure's own axes grid (``row``/``col``, from the
|
|
2753
|
+
same layout :func:`load_data` already returns) instead of
|
|
2754
|
+
:func:`load_data`'s title-keyed dict of dicts.
|
|
2755
|
+
|
|
2756
|
+
Needs the optional ``xarray`` dependency: ``pip install
|
|
2757
|
+
plotpress[xarray]``.
|
|
2758
|
+
|
|
2759
|
+
Built for the case :doc:`/auto_examples/data_roundtrip/index` already
|
|
2760
|
+
showcases -- a uniform grid of same-shaped scientific measurements
|
|
2761
|
+
(every panel its own ``pcolormesh``, or its own single line series) --
|
|
2762
|
+
where a title-keyed dict of dicts is the wrong tool entirely: a caller
|
|
2763
|
+
wanting "the z value at row 2, column 3" has to already know that
|
|
2764
|
+
panel's title (or fall back to :func:`load_data`'s own
|
|
2765
|
+
``by_index=True``, still just a flat list with no row/column
|
|
2766
|
+
structure of its own), loop over every panel by hand to stack them
|
|
2767
|
+
into one array, and hope no two panels happened to share a title --
|
|
2768
|
+
see :func:`load_data`'s own now-fixed collision handling, which this
|
|
2769
|
+
sidesteps structurally rather than by disambiguating: xarray indexes
|
|
2770
|
+
by integer row/column position, never by a string title, so there is
|
|
2771
|
+
no title to collide on in the first place.
|
|
2772
|
+
|
|
2773
|
+
Only supports a *uniform* rectangular grid -- every axes a single,
|
|
2774
|
+
non-spanning cell (as :func:`plotpress.subplots`/:meth:`Figure.add_subplot`
|
|
2775
|
+
place them, never a row/column span from ``add_gridspec``) -- where
|
|
2776
|
+
every axes with data carries **exactly one** mesh (all the same shape,
|
|
2777
|
+
non-curvilinear) or **exactly one** line series (all the same length),
|
|
2778
|
+
never a mix of the two kinds, and never more than one series/mesh on a
|
|
2779
|
+
single axes. A cell with no axes at all, or an axes nothing was ever
|
|
2780
|
+
plotted on, is fine -- it comes back NaN (its ``x``/``y`` too, in the
|
|
2781
|
+
per-panel-coordinate case), distinguished from a panel whose real data
|
|
2782
|
+
legitimately happened to be all-NaN by the ``has_data`` coordinate
|
|
2783
|
+
below. Raises ``ValueError``, naming exactly what about the figure
|
|
2784
|
+
didn't fit, for anything else -- a mixed grid, a span, multiple series
|
|
2785
|
+
per axes, differing mesh shapes -- pointing at :func:`load_data`
|
|
2786
|
+
(``by_index=True`` for the title-collision-proof form) as the fallback
|
|
2787
|
+
for a figure this doesn't cover.
|
|
2788
|
+
|
|
2789
|
+
The returned ``Dataset`` has ``row``/``col`` coordinates plus each
|
|
2790
|
+
panel's own ``title``/``xlabel``/``ylabel`` (``""`` for a missing
|
|
2791
|
+
panel) and ``has_data`` (``True`` for a grid cell an axes with plotted
|
|
2792
|
+
data actually occupies, ``False`` for one with no axes or nothing
|
|
2793
|
+
plotted) as ``(row, col)`` coordinates; a mesh grid's ``x``/``y`` are
|
|
2794
|
+
shared 1-D coordinates when every panel used the identical grid, else
|
|
2795
|
+
per-panel ``(row, col, x)``/``(row, col, y)`` arrays -- and its data
|
|
2796
|
+
variable is ``z``, dimensioned ``(row, col, y, x)``. A line grid's data
|
|
2797
|
+
variable is ``y``, dimensioned ``(row, col, point)``, with ``x`` the
|
|
2798
|
+
same shared-or-per-panel choice. ``.attrs`` carries the recovered
|
|
2799
|
+
figure's own ``figsize`` and title, plus ``"layout"`` -- the exact same
|
|
2800
|
+
dict :func:`load_data` returns under that key, ready to pass straight
|
|
2801
|
+
to :func:`subplots_from_layout` without a second, separate
|
|
2802
|
+
:func:`load_data` call just to get it -- ``ds.attrs["layout"]``, not a
|
|
2803
|
+
duplicate parse of the file.
|
|
2804
|
+
|
|
2805
|
+
``figure`` selects which figure to load from a multi-figure
|
|
2806
|
+
:class:`Report` file -- an int index (0-based, save order) or the
|
|
2807
|
+
exact string title a :class:`Report` entry was given. Left as
|
|
2808
|
+
``None`` (the default), the file must have exactly one figure, or
|
|
2809
|
+
this raises naming how many it actually found.
|
|
2810
|
+
"""
|
|
2811
|
+
try:
|
|
2812
|
+
import xarray as xr
|
|
2813
|
+
except ImportError as e:
|
|
2814
|
+
raise ImportError(
|
|
2815
|
+
"load_data_xarray() needs the optional xarray dependency -- "
|
|
2816
|
+
"install it with: pip install plotpress[xarray]"
|
|
2817
|
+
) from e
|
|
2818
|
+
|
|
2819
|
+
figures = load_data(path, by_index=True)
|
|
2820
|
+
if figure is None:
|
|
2821
|
+
if len(figures) != 1:
|
|
2822
|
+
raise ValueError(
|
|
2823
|
+
f"load_data_xarray(): this file has {len(figures)} figures, "
|
|
2824
|
+
"not 1 -- pass figure=<int index> or figure=<exact title "
|
|
2825
|
+
"str> to pick one (plotpress.load_data(path, by_index=True) "
|
|
2826
|
+
"lists every figure this file has, each with its own "
|
|
2827
|
+
"\"title\")."
|
|
2828
|
+
)
|
|
2829
|
+
entry = figures[0]
|
|
2830
|
+
elif isinstance(figure, int):
|
|
2831
|
+
try:
|
|
2832
|
+
entry = figures[figure]
|
|
2833
|
+
except IndexError:
|
|
2834
|
+
raise ValueError(
|
|
2835
|
+
f"load_data_xarray(): figure index {figure} out of range -- "
|
|
2836
|
+
f"this file has {len(figures)} figure(s)."
|
|
2837
|
+
) from None
|
|
2838
|
+
else:
|
|
2839
|
+
matches = [f for f in figures if f["title"] == figure]
|
|
2840
|
+
if not matches:
|
|
2841
|
+
raise ValueError(
|
|
2842
|
+
f"load_data_xarray(): no figure titled {figure!r} in this "
|
|
2843
|
+
f"file -- available titles: {[f['title'] for f in figures]!r}"
|
|
2844
|
+
)
|
|
2845
|
+
entry = matches[0]
|
|
2846
|
+
|
|
2847
|
+
axes = entry["axes"] # int-indexed (this came from by_index=True above)
|
|
2848
|
+
layout_axes = entry["layout"].get("axes") or {}
|
|
2849
|
+
order = sorted(axes)
|
|
2850
|
+
if not order:
|
|
2851
|
+
raise ValueError("load_data_xarray(): this figure has no plotted axes.")
|
|
2852
|
+
missing_layout = [i for i in order if i not in layout_axes]
|
|
2853
|
+
if missing_layout:
|
|
2854
|
+
raise ValueError(
|
|
2855
|
+
f"load_data_xarray(): axes {missing_layout} have plotted data "
|
|
2856
|
+
"but no recorded grid cell (a freeform Figure.add_axes() rect, "
|
|
2857
|
+
"not a subplot grid cell) -- a uniform subplot grid is required; "
|
|
2858
|
+
"use plotpress.load_data() instead for this figure."
|
|
2859
|
+
)
|
|
2860
|
+
|
|
2861
|
+
specs = [layout_axes[i] for i in order]
|
|
2862
|
+
if len({(s["nrows"], s["ncols"]) for s in specs}) != 1:
|
|
2863
|
+
raise ValueError(
|
|
2864
|
+
"load_data_xarray(): this figure's axes don't share one "
|
|
2865
|
+
"nrows x ncols grid shape -- not a uniform grid; use "
|
|
2866
|
+
"plotpress.load_data() instead."
|
|
2867
|
+
)
|
|
2868
|
+
if not all(s["row0"] == s["row1"] and s["col0"] == s["col1"] for s in specs):
|
|
2869
|
+
raise ValueError(
|
|
2870
|
+
"load_data_xarray(): a row/column span (from add_gridspec) is "
|
|
2871
|
+
"not a single grid cell -- not supported; use "
|
|
2872
|
+
"plotpress.load_data() instead."
|
|
2873
|
+
)
|
|
2874
|
+
nrows, ncols = specs[0]["nrows"], specs[0]["ncols"]
|
|
2875
|
+
|
|
2876
|
+
# `order` is every axes the grid actually has, whether or not anything
|
|
2877
|
+
# was ever plotted on it (an empty axes still reports "" series/meshes/
|
|
2878
|
+
# pies, not an absent entry) -- `filled` narrows that to the ones with
|
|
2879
|
+
# real data, which is what the kind/shape checks and every data array
|
|
2880
|
+
# below care about. title/xlabel/ylabel below still read from `order`,
|
|
2881
|
+
# not `filled` -- an otherwise-empty panel can carry a real title.
|
|
2882
|
+
kinds = set()
|
|
2883
|
+
filled = []
|
|
2884
|
+
for i in order:
|
|
2885
|
+
a = axes[i]
|
|
2886
|
+
n_series, n_meshes, n_pies = len(a["series"]), len(a["meshes"]), len(a["pies"])
|
|
2887
|
+
if n_meshes == 0 and n_series == 0 and n_pies == 0:
|
|
2888
|
+
continue # nothing plotted here -- a missing panel, not an error
|
|
2889
|
+
elif n_meshes == 1 and n_series == 0 and n_pies == 0:
|
|
2890
|
+
kinds.add("mesh"); filled.append(i)
|
|
2891
|
+
elif n_series == 1 and n_meshes == 0 and n_pies == 0:
|
|
2892
|
+
kinds.add("line"); filled.append(i)
|
|
2893
|
+
else:
|
|
2894
|
+
raise ValueError(
|
|
2895
|
+
f"load_data_xarray(): axes {i} ({a['title']!r}) has "
|
|
2896
|
+
f"{n_series} series, {n_meshes} mesh(es), {n_pies} pie(s) -- "
|
|
2897
|
+
"only a grid where every axes with data has exactly one "
|
|
2898
|
+
"mesh, or exactly one line series (never a mix, never more "
|
|
2899
|
+
"than one), is supported; use plotpress.load_data() instead."
|
|
2900
|
+
)
|
|
2901
|
+
if not filled:
|
|
2902
|
+
raise ValueError(
|
|
2903
|
+
"load_data_xarray(): this figure's grid has no plotted axes."
|
|
2904
|
+
)
|
|
2905
|
+
if len(kinds) != 1:
|
|
2906
|
+
raise ValueError(
|
|
2907
|
+
"load_data_xarray(): a mix of mesh axes and line-series axes "
|
|
2908
|
+
"in the same grid isn't supported; use plotpress.load_data() "
|
|
2909
|
+
"instead."
|
|
2910
|
+
)
|
|
2911
|
+
kind = kinds.pop()
|
|
2912
|
+
|
|
2913
|
+
def grid_of(items, default="", dtype=object):
|
|
2914
|
+
# A cell no `items` entry ever touches (an axes with nothing
|
|
2915
|
+
# plotted, or no axes at all) keeps `default` rather than whatever
|
|
2916
|
+
# an object array happens to default-initialize to (None) --
|
|
2917
|
+
# title/xlabel/ylabel stay uniformly str either way, empty or not,
|
|
2918
|
+
# never a mix of "" and None.
|
|
2919
|
+
g = np.full((nrows, ncols), default, dtype=dtype)
|
|
2920
|
+
for i, v in items:
|
|
2921
|
+
s = layout_axes[i]
|
|
2922
|
+
g[s["row0"], s["col0"]] = v
|
|
2923
|
+
return g
|
|
2924
|
+
|
|
2925
|
+
# True for every grid cell an axes with plotted data actually occupies,
|
|
2926
|
+
# False for one with no axes at all or an axes nothing was ever plotted
|
|
2927
|
+
# on. Missing cells stay NaN in every numeric array below too (they're
|
|
2928
|
+
# pre-filled with NaN, and only cells in `filled` are ever written into)
|
|
2929
|
+
# -- has_data is what lets a caller tell "this panel is genuinely empty"
|
|
2930
|
+
# apart from "this panel's own data legitimately happened to be
|
|
2931
|
+
# all-NaN".
|
|
2932
|
+
has_data = grid_of([(i, True) for i in filled], default=False, dtype=bool)
|
|
2933
|
+
|
|
2934
|
+
coords = {
|
|
2935
|
+
"title": (("row", "col"), grid_of([(i, axes[i]["title"] or "") for i in order])),
|
|
2936
|
+
"xlabel": (("row", "col"), grid_of([(i, axes[i]["xlabel"] or "") for i in order])),
|
|
2937
|
+
"ylabel": (("row", "col"), grid_of([(i, axes[i]["ylabel"] or "") for i in order])),
|
|
2938
|
+
"has_data": (("row", "col"), has_data),
|
|
2939
|
+
}
|
|
2940
|
+
attrs = {"figsize": entry["layout"].get("figsize"), "title": entry["title"],
|
|
2941
|
+
"layout": entry["layout"]}
|
|
2942
|
+
|
|
2943
|
+
if kind == "mesh":
|
|
2944
|
+
meshes = [axes[i]["meshes"][0] for i in filled]
|
|
2945
|
+
if any(m["curvilinear"] for m in meshes):
|
|
2946
|
+
raise ValueError(
|
|
2947
|
+
"load_data_xarray(): a curvilinear mesh (irregular per-cell "
|
|
2948
|
+
"x/y coordinates, no separable 1-D axes) isn't supported; "
|
|
2949
|
+
"use plotpress.load_data() instead."
|
|
2950
|
+
)
|
|
2951
|
+
shapes = {m["z"].shape for m in meshes}
|
|
2952
|
+
if len(shapes) != 1:
|
|
2953
|
+
raise ValueError(
|
|
2954
|
+
f"load_data_xarray(): meshes differ in shape across the "
|
|
2955
|
+
f"grid ({sorted(shapes)}) -- every panel must match; use "
|
|
2956
|
+
"plotpress.load_data() instead."
|
|
2957
|
+
)
|
|
2958
|
+
ny, nx = shapes.pop()
|
|
2959
|
+
# grid_of()'s own object-array shell doesn't fit here -- each cell
|
|
2960
|
+
# holds a whole 2-D mesh, not one scalar the way title/xlabel above
|
|
2961
|
+
# do -- so this one (nrows, ncols, ny, nx) float array is built
|
|
2962
|
+
# directly instead of routing through it.
|
|
2963
|
+
z = np.full((nrows, ncols, ny, nx), np.nan)
|
|
2964
|
+
for i, m in zip(filled, meshes):
|
|
2965
|
+
s = layout_axes[i]
|
|
2966
|
+
z[s["row0"], s["col0"], :, :] = m["z"]
|
|
2967
|
+
|
|
2968
|
+
x0, y0 = meshes[0]["x"], meshes[0]["y"]
|
|
2969
|
+
shared = all(np.array_equal(m["x"], x0) and np.array_equal(m["y"], y0)
|
|
2970
|
+
for m in meshes)
|
|
2971
|
+
if shared:
|
|
2972
|
+
coords["x"] = ("x", x0)
|
|
2973
|
+
coords["y"] = ("y", y0)
|
|
2974
|
+
data_vars = {"z": (("row", "col", "y", "x"), z)}
|
|
2975
|
+
else:
|
|
2976
|
+
# NaN-filled, not np.empty()'s uninitialized garbage -- a
|
|
2977
|
+
# missing cell's own x/y has no data to report either.
|
|
2978
|
+
X = np.full((nrows, ncols, nx), np.nan)
|
|
2979
|
+
Y = np.full((nrows, ncols, ny), np.nan)
|
|
2980
|
+
for i, m in zip(filled, meshes):
|
|
2981
|
+
s = layout_axes[i]
|
|
2982
|
+
X[s["row0"], s["col0"], :] = m["x"]
|
|
2983
|
+
Y[s["row0"], s["col0"], :] = m["y"]
|
|
2984
|
+
coords["x"] = (("row", "col", "x"), X)
|
|
2985
|
+
coords["y"] = (("row", "col", "y"), Y)
|
|
2986
|
+
data_vars = {"z": (("row", "col", "y", "x"), z)}
|
|
2987
|
+
else:
|
|
2988
|
+
series = [axes[i]["series"][0] for i in filled]
|
|
2989
|
+
lengths = {s["x"].size for s in series}
|
|
2990
|
+
if len(lengths) != 1:
|
|
2991
|
+
raise ValueError(
|
|
2992
|
+
f"load_data_xarray(): series differ in length across the "
|
|
2993
|
+
f"grid ({sorted(lengths)}) -- every panel must match; use "
|
|
2994
|
+
"plotpress.load_data() instead."
|
|
2995
|
+
)
|
|
2996
|
+
n = lengths.pop()
|
|
2997
|
+
y = np.full((nrows, ncols, n), np.nan)
|
|
2998
|
+
for i, s in zip(filled, series):
|
|
2999
|
+
spec = layout_axes[i]
|
|
3000
|
+
y[spec["row0"], spec["col0"], :] = s["y"]
|
|
3001
|
+
|
|
3002
|
+
x0 = series[0]["x"]
|
|
3003
|
+
shared = all(np.array_equal(s["x"], x0) for s in series)
|
|
3004
|
+
if shared:
|
|
3005
|
+
coords["point"] = ("point", x0)
|
|
3006
|
+
data_vars = {"y": (("row", "col", "point"), y)}
|
|
3007
|
+
else:
|
|
3008
|
+
# NaN-filled, not np.empty()'s uninitialized garbage -- see the
|
|
3009
|
+
# matching comment in the mesh branch above.
|
|
3010
|
+
X = np.full((nrows, ncols, n), np.nan)
|
|
3011
|
+
for i, s in zip(filled, series):
|
|
3012
|
+
spec = layout_axes[i]
|
|
3013
|
+
X[spec["row0"], spec["col0"], :] = s["x"]
|
|
3014
|
+
coords["x"] = (("row", "col", "point"), X)
|
|
3015
|
+
data_vars = {"y": (("row", "col", "point"), y)}
|
|
3016
|
+
|
|
3017
|
+
return xr.Dataset(data_vars, coords=coords, attrs=attrs)
|
|
3018
|
+
|
|
3019
|
+
|
|
3020
|
+
def select_panel(ds, title=None, row=None, col=None, multiple=False):
|
|
3021
|
+
"""Pull one panel out of a :func:`load_data_xarray` grid, dropping
|
|
3022
|
+
``row``/``col`` entirely instead of leaving them behind as length-1
|
|
3023
|
+
dimensions -- ``ds.isel(row=r, col=c)`` already does exactly that for a
|
|
3024
|
+
scalar ``r``/``c``, which is all this is: that call, plus resolving
|
|
3025
|
+
``title`` to the one ``(row, col)`` position it names.
|
|
3026
|
+
|
|
3027
|
+
Pass **either** ``title`` (matched against ``ds["title"]``, the same
|
|
3028
|
+
string :func:`load_data`/a panel's own ``ax.set_title()`` used) **or**
|
|
3029
|
+
both ``row``/``col`` (plain 0-based grid position) -- not a mix of the
|
|
3030
|
+
two, and not neither. Raises ``ValueError`` when ``title`` matches no
|
|
3031
|
+
panel at all. When ``title`` matches more than one panel (two panels
|
|
3032
|
+
sharing a title, so there is no name left to disambiguate by), this
|
|
3033
|
+
raises too *unless* ``multiple=True``, which returns every match as a
|
|
3034
|
+
list instead of picking one.
|
|
3035
|
+
|
|
3036
|
+
``multiple=True`` always returns a ``list`` of ``Dataset``\\ s -- one
|
|
3037
|
+
item for a unique ``title`` or an explicit ``row=``/``col=``, or one
|
|
3038
|
+
per match for a duplicated ``title`` -- rather than a list only
|
|
3039
|
+
*sometimes* and a bare ``Dataset`` otherwise, so a caller that always
|
|
3040
|
+
wants to loop over the result doesn't have to branch on how many
|
|
3041
|
+
panels actually matched.
|
|
3042
|
+
|
|
3043
|
+
Each returned ``Dataset`` keeps every data variable/coordinate
|
|
3044
|
+
:func:`load_data_xarray` built, just without ``row``/``col`` -- a mesh
|
|
3045
|
+
panel's ``z`` is ``(y, x)`` instead of ``(row, col, y, x)``, a line
|
|
3046
|
+
panel's ``y`` is ``(point,)`` instead of ``(row, col, point)``, and
|
|
3047
|
+
``title``/``xlabel``/``ylabel``/``has_data`` come back as plain scalar
|
|
3048
|
+
attributes of that one panel rather than ``(row, col)`` arrays.
|
|
3049
|
+
|
|
3050
|
+
::
|
|
3051
|
+
|
|
3052
|
+
ds = plotpress.load_data_xarray(path)
|
|
3053
|
+
panel = plotpress.select_panel(ds, title="panel 4")
|
|
3054
|
+
panel["z"].plot() # a plain (y, x) DataArray, xarray's own .plot()
|
|
3055
|
+
|
|
3056
|
+
# Two panels both titled "control" -- get both instead of raising.
|
|
3057
|
+
controls = plotpress.select_panel(ds, title="control", multiple=True)
|
|
3058
|
+
for p in controls:
|
|
3059
|
+
p["z"].plot()
|
|
3060
|
+
"""
|
|
3061
|
+
if title is not None:
|
|
3062
|
+
if row is not None or col is not None:
|
|
3063
|
+
raise ValueError(
|
|
3064
|
+
"select_panel(): pass title=, or row=/col=, not both.")
|
|
3065
|
+
matches = np.argwhere(ds["title"].values == title)
|
|
3066
|
+
if len(matches) == 0:
|
|
3067
|
+
raise ValueError(
|
|
3068
|
+
f"select_panel(): no panel titled {title!r} -- available: "
|
|
3069
|
+
f"{sorted(set(ds['title'].values.ravel()))!r}"
|
|
3070
|
+
)
|
|
3071
|
+
positions = [(int(r), int(c)) for r, c in matches]
|
|
3072
|
+
elif row is None or col is None:
|
|
3073
|
+
raise ValueError("select_panel(): pass title=, or both row= and col=.")
|
|
3074
|
+
else:
|
|
3075
|
+
positions = [(row, col)]
|
|
3076
|
+
|
|
3077
|
+
if len(positions) > 1 and not multiple:
|
|
3078
|
+
raise ValueError(
|
|
3079
|
+
f"select_panel(): {len(positions)} panels are titled {title!r} "
|
|
3080
|
+
"-- not unique, so title alone can't pick one; pass row=/col= "
|
|
3081
|
+
"for a specific one, or multiple=True for every match as a list."
|
|
3082
|
+
)
|
|
3083
|
+
panels = [ds.isel(row=r, col=c) for r, c in positions]
|
|
3084
|
+
return panels if multiple else panels[0]
|