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/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('"', "&quot;")
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]