scistackplot 0.1.26__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.
- scistackplot/__init__.py +239 -0
- scistackplot/capability.py +656 -0
- scistackplot/codegen.py +886 -0
- scistackplot/groups.py +110 -0
- scistackplot/reduce.py +1475 -0
- scistackplot/render/__init__.py +26 -0
- scistackplot/render/base.py +262 -0
- scistackplot/render/mpl.py +458 -0
- scistackplot/render/plotly_.py +537 -0
- scistackplot/resolved.py +240 -0
- scistackplot/roles.py +413 -0
- scistackplot/shape.py +106 -0
- scistackplot/sources/__init__.py +7 -0
- scistackplot/sources/base.py +256 -0
- scistackplot/sources/csv.py +67 -0
- scistackplot/sources/frame.py +45 -0
- scistackplot/spec.py +698 -0
- scistackplot/table.py +328 -0
- scistackplot/variants.py +743 -0
- scistackplot/xaxis.py +183 -0
- scistackplot/ylimits.py +451 -0
- scistackplot-0.1.26.dist-info/METADATA +212 -0
- scistackplot-0.1.26.dist-info/RECORD +24 -0
- scistackplot-0.1.26.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
"""
|
|
2
|
+
matplotlib renderer — the export and pipeline path.
|
|
3
|
+
|
|
4
|
+
Returns a ``matplotlib.figure.Figure``, which is exactly what a scidb
|
|
5
|
+
``plot_`` endpoint must return (the framework saves and closes it). This is
|
|
6
|
+
also the renderer whose output the generated seaborn/matplotlib code is
|
|
7
|
+
expected to reproduce.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import pandas as pd
|
|
16
|
+
from scistacklog import Log
|
|
17
|
+
|
|
18
|
+
from ..resolved import ResolvedPlot
|
|
19
|
+
from ..spec import PlotKind
|
|
20
|
+
from .base import (
|
|
21
|
+
color_groups,
|
|
22
|
+
grid_shape,
|
|
23
|
+
is_categorical_x,
|
|
24
|
+
legend_levels,
|
|
25
|
+
palette_for,
|
|
26
|
+
panel_position,
|
|
27
|
+
panel_y_limits,
|
|
28
|
+
panel_y_title,
|
|
29
|
+
shares_y_axis,
|
|
30
|
+
shows_legend,
|
|
31
|
+
shows_x_labels,
|
|
32
|
+
shows_y_labels,
|
|
33
|
+
x_positions,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
LAYER = "scistackplot"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def render(resolved: ResolvedPlot):
|
|
40
|
+
"""Draw ``resolved`` and return the Figure (caller owns closing it)."""
|
|
41
|
+
import matplotlib
|
|
42
|
+
|
|
43
|
+
if matplotlib.get_backend().lower() not in ("agg", "template"):
|
|
44
|
+
# Rendering happens inside a server process and inside for_each; a GUI
|
|
45
|
+
# backend there either warns or blocks. Agg is the only safe default.
|
|
46
|
+
matplotlib.use("Agg", force=False)
|
|
47
|
+
import matplotlib.pyplot as plt
|
|
48
|
+
|
|
49
|
+
with Log.timer("render_mpl", layer=LAYER, extra=str(resolved.kind)):
|
|
50
|
+
n_rows, n_cols = grid_shape(resolved)
|
|
51
|
+
style = resolved.spec.style
|
|
52
|
+
fig, axes = plt.subplots(
|
|
53
|
+
n_rows,
|
|
54
|
+
n_cols,
|
|
55
|
+
figsize=(style.width, style.height),
|
|
56
|
+
squeeze=False,
|
|
57
|
+
sharex=resolved.spec.facet.share_x,
|
|
58
|
+
# DERIVED, not configured. matplotlib's sharey ties the axes
|
|
59
|
+
# together, so one panel's autoscale drags every other panel with
|
|
60
|
+
# it — exactly wrong once `y_axis.scope` asks for per-panel ranges,
|
|
61
|
+
# and the set_ylim below would be silently overruled by whichever
|
|
62
|
+
# panel was drawn last.
|
|
63
|
+
sharey=shares_y_axis(resolved),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
used: set[tuple[int, int]] = set()
|
|
67
|
+
# (row, col) -> panel, so the cosmetics pass can ask a CELL for its
|
|
68
|
+
# panel's limits. It walks the grid rather than the panel list (blank
|
|
69
|
+
# cells need hiding too), and the two orders are not the same.
|
|
70
|
+
at_cell: dict[tuple[int, int], Any] = {}
|
|
71
|
+
for index, panel in enumerate(resolved.panels):
|
|
72
|
+
row, col = panel_position(resolved, index)
|
|
73
|
+
if (row, col) in used or not (0 <= row < n_rows and 0 <= col < n_cols):
|
|
74
|
+
# reduce._assign_grid guarantees one panel per cell inside the
|
|
75
|
+
# reported grid. If that ever breaks, say so — the old silent
|
|
76
|
+
# clamp drew two panels onto one axes, which looks like bad data
|
|
77
|
+
# rather than a layout bug.
|
|
78
|
+
Log.warn(
|
|
79
|
+
"panel %r wants cell (%d,%d) in a %dx%d grid, which is "
|
|
80
|
+
"occupied or out of range — clamping",
|
|
81
|
+
panel.title,
|
|
82
|
+
row,
|
|
83
|
+
col,
|
|
84
|
+
n_rows,
|
|
85
|
+
n_cols,
|
|
86
|
+
layer=LAYER,
|
|
87
|
+
)
|
|
88
|
+
row = min(max(row, 0), n_rows - 1)
|
|
89
|
+
col = min(max(col, 0), n_cols - 1)
|
|
90
|
+
ax = axes[row][col]
|
|
91
|
+
used.add((row, col))
|
|
92
|
+
at_cell[(row, col)] = panel
|
|
93
|
+
_draw_panel(ax, panel.frame, resolved)
|
|
94
|
+
# No subplot caption: a faceted panel is named by its y-axis title
|
|
95
|
+
# instead (base.panel_y_title), which buys back the row of vertical
|
|
96
|
+
# space a title costs in every row of the grid.
|
|
97
|
+
|
|
98
|
+
# Blank out grid cells no panel landed in (a wrapped grid's remainder).
|
|
99
|
+
for row in range(n_rows):
|
|
100
|
+
for col in range(n_cols):
|
|
101
|
+
if (row, col) not in used:
|
|
102
|
+
axes[row][col].set_visible(False)
|
|
103
|
+
|
|
104
|
+
_apply_axes_cosmetics(fig, axes, resolved, n_rows, n_cols, at_cell)
|
|
105
|
+
|
|
106
|
+
if resolved.labels.title:
|
|
107
|
+
fig.suptitle(resolved.labels.title)
|
|
108
|
+
# tight_layout is told how much width the legend took. A FIGURE legend
|
|
109
|
+
# is invisible to tight_layout, so laying the axes out across the whole
|
|
110
|
+
# width put the legend on top of the rightmost panels in the exported
|
|
111
|
+
# PNG while the interactive plotly view kept it outside — the same
|
|
112
|
+
# figure reading two different ways depending on how you looked at it.
|
|
113
|
+
reserved = _apply_legend(fig, resolved)
|
|
114
|
+
fig.tight_layout(rect=(0.0, 0.0, 1.0 - reserved, 1.0))
|
|
115
|
+
return fig
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _draw_panel(ax, frame: pd.DataFrame, resolved: ResolvedPlot) -> None:
|
|
122
|
+
if frame.empty:
|
|
123
|
+
ax.text(
|
|
124
|
+
0.5,
|
|
125
|
+
0.5,
|
|
126
|
+
"no data",
|
|
127
|
+
ha="center",
|
|
128
|
+
va="center",
|
|
129
|
+
transform=ax.transAxes,
|
|
130
|
+
color="#888888",
|
|
131
|
+
)
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
kind = resolved.kind
|
|
135
|
+
if kind is PlotKind.HEATMAP:
|
|
136
|
+
_draw_heatmap(ax, frame, resolved)
|
|
137
|
+
elif kind in (PlotKind.SCATTER, PlotKind.STRIP):
|
|
138
|
+
_draw_points(ax, frame, resolved, jitter=kind is PlotKind.STRIP)
|
|
139
|
+
elif kind is PlotKind.LINE:
|
|
140
|
+
_draw_lines(ax, frame, resolved)
|
|
141
|
+
elif kind is PlotKind.BAND:
|
|
142
|
+
_draw_band(ax, frame, resolved)
|
|
143
|
+
elif kind is PlotKind.BAR:
|
|
144
|
+
_draw_bars(ax, frame, resolved)
|
|
145
|
+
elif kind in (PlotKind.BOX, PlotKind.VIOLIN):
|
|
146
|
+
_draw_distribution(ax, frame, resolved, violin=kind is PlotKind.VIOLIN)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _draw_points(ax, frame, resolved, *, jitter: bool) -> None:
|
|
150
|
+
style = resolved.spec.style
|
|
151
|
+
for index, (level, subset) in enumerate(color_groups(frame, resolved)):
|
|
152
|
+
positions, _ = x_positions(subset[resolved.encoding.x], resolved)
|
|
153
|
+
if jitter and is_categorical_x(resolved):
|
|
154
|
+
rng = np.random.default_rng(abs(hash(str(level))) % (2**32))
|
|
155
|
+
positions = positions + rng.uniform(-0.15, 0.15, size=len(positions))
|
|
156
|
+
ax.scatter(
|
|
157
|
+
positions,
|
|
158
|
+
subset[resolved.encoding.y].to_numpy(dtype=float),
|
|
159
|
+
s=style.marker_size,
|
|
160
|
+
alpha=style.alpha,
|
|
161
|
+
color=palette_for(resolved, level, index),
|
|
162
|
+
label=str(level) if level is not None else None,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _draw_lines(ax, frame, resolved) -> None:
|
|
167
|
+
style = resolved.spec.style
|
|
168
|
+
series_column = resolved.encoding.series
|
|
169
|
+
for index, (level, subset) in enumerate(color_groups(frame, resolved)):
|
|
170
|
+
color = palette_for(resolved, level, index)
|
|
171
|
+
if series_column and series_column in subset.columns:
|
|
172
|
+
series_groups = list(subset.groupby(series_column, sort=False))
|
|
173
|
+
else:
|
|
174
|
+
series_groups = [(None, subset)]
|
|
175
|
+
for position, (_, line_rows) in enumerate(series_groups):
|
|
176
|
+
positions, _ = x_positions(line_rows[resolved.encoding.x], resolved)
|
|
177
|
+
ax.plot(
|
|
178
|
+
positions,
|
|
179
|
+
line_rows[resolved.encoding.y].to_numpy(dtype=float),
|
|
180
|
+
color=color,
|
|
181
|
+
alpha=style.alpha,
|
|
182
|
+
linewidth=1.4,
|
|
183
|
+
# Only the first line of a colour group carries the legend entry,
|
|
184
|
+
# otherwise a 200-trial plot produces a 200-entry legend.
|
|
185
|
+
label=str(level) if (level is not None and position == 0) else None,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _draw_band(ax, frame, resolved) -> None:
|
|
190
|
+
encoding = resolved.encoding
|
|
191
|
+
for index, (level, subset) in enumerate(color_groups(frame, resolved)):
|
|
192
|
+
color = palette_for(resolved, level, index)
|
|
193
|
+
positions, _ = x_positions(subset[encoding.x], resolved)
|
|
194
|
+
centre = subset[encoding.y].to_numpy(dtype=float)
|
|
195
|
+
ax.plot(
|
|
196
|
+
positions,
|
|
197
|
+
centre,
|
|
198
|
+
color=color,
|
|
199
|
+
linewidth=1.8,
|
|
200
|
+
label=str(level) if level is not None else None,
|
|
201
|
+
)
|
|
202
|
+
if encoding.has_error:
|
|
203
|
+
ax.fill_between(
|
|
204
|
+
positions,
|
|
205
|
+
subset[encoding.y_low].to_numpy(dtype=float),
|
|
206
|
+
subset[encoding.y_high].to_numpy(dtype=float),
|
|
207
|
+
color=color,
|
|
208
|
+
alpha=0.22,
|
|
209
|
+
linewidth=0,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _draw_bars(ax, frame, resolved) -> None:
|
|
214
|
+
encoding = resolved.encoding
|
|
215
|
+
groups = color_groups(frame, resolved)
|
|
216
|
+
n_groups = max(len(groups), 1)
|
|
217
|
+
width = 0.8 / n_groups
|
|
218
|
+
|
|
219
|
+
for index, (level, subset) in enumerate(groups):
|
|
220
|
+
positions, ticks = x_positions(subset[encoding.x], resolved)
|
|
221
|
+
offset = (index - (n_groups - 1) / 2) * width
|
|
222
|
+
centre = subset[encoding.y].to_numpy(dtype=float)
|
|
223
|
+
error = None
|
|
224
|
+
if encoding.has_error:
|
|
225
|
+
low = subset[encoding.y_low].to_numpy(dtype=float)
|
|
226
|
+
high = subset[encoding.y_high].to_numpy(dtype=float)
|
|
227
|
+
error = np.vstack([centre - low, high - centre])
|
|
228
|
+
ax.bar(
|
|
229
|
+
positions + offset,
|
|
230
|
+
centre,
|
|
231
|
+
width=width,
|
|
232
|
+
yerr=error,
|
|
233
|
+
capsize=3,
|
|
234
|
+
color=palette_for(resolved, level, index),
|
|
235
|
+
alpha=resolved.spec.style.alpha,
|
|
236
|
+
label=str(level) if level is not None else None,
|
|
237
|
+
)
|
|
238
|
+
if ticks is not None:
|
|
239
|
+
ax.set_xticks(range(len(ticks)))
|
|
240
|
+
ax.set_xticklabels(ticks)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _draw_distribution(ax, frame, resolved, *, violin: bool) -> None:
|
|
244
|
+
"""Box or violin, dodged by colour level when one is assigned."""
|
|
245
|
+
encoding = resolved.encoding
|
|
246
|
+
groups = color_groups(frame, resolved)
|
|
247
|
+
n_groups = max(len(groups), 1)
|
|
248
|
+
width = 0.8 / n_groups
|
|
249
|
+
order = resolved.x_order or sorted(frame[encoding.x].dropna().unique().tolist(), key=str)
|
|
250
|
+
|
|
251
|
+
for index, (level, subset) in enumerate(groups):
|
|
252
|
+
offset = (index - (n_groups - 1) / 2) * width
|
|
253
|
+
datasets: list[np.ndarray] = []
|
|
254
|
+
positions: list[float] = []
|
|
255
|
+
for slot, level_value in enumerate(order):
|
|
256
|
+
values = subset[subset[encoding.x].astype(str) == str(level_value)][
|
|
257
|
+
encoding.y
|
|
258
|
+
].to_numpy(dtype=float)
|
|
259
|
+
values = values[~np.isnan(values)]
|
|
260
|
+
if values.size:
|
|
261
|
+
datasets.append(values)
|
|
262
|
+
positions.append(slot + offset)
|
|
263
|
+
if not datasets:
|
|
264
|
+
continue
|
|
265
|
+
|
|
266
|
+
color = palette_for(resolved, level, index)
|
|
267
|
+
if violin:
|
|
268
|
+
parts = ax.violinplot(
|
|
269
|
+
datasets, positions=positions, widths=width * 0.9, showmeans=True
|
|
270
|
+
)
|
|
271
|
+
for body in parts["bodies"]:
|
|
272
|
+
body.set_facecolor(color)
|
|
273
|
+
body.set_alpha(0.55)
|
|
274
|
+
else:
|
|
275
|
+
drawn = ax.boxplot(
|
|
276
|
+
datasets,
|
|
277
|
+
positions=positions,
|
|
278
|
+
widths=width * 0.85,
|
|
279
|
+
patch_artist=True,
|
|
280
|
+
manage_ticks=False,
|
|
281
|
+
)
|
|
282
|
+
for box in drawn["boxes"]:
|
|
283
|
+
box.set_facecolor(color)
|
|
284
|
+
box.set_alpha(0.6)
|
|
285
|
+
if level is not None:
|
|
286
|
+
# Boxes carry no legend handle of their own; a proxy patch does.
|
|
287
|
+
ax.plot([], [], color=color, linewidth=6, label=str(level))
|
|
288
|
+
|
|
289
|
+
ax.set_xticks(range(len(order)))
|
|
290
|
+
ax.set_xticklabels([str(v) for v in order])
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _draw_heatmap(ax, frame, resolved) -> None:
|
|
294
|
+
matrix = frame[resolved.encoding.z].iloc[0]
|
|
295
|
+
image = ax.imshow(np.asarray(matrix, dtype=float), aspect="auto", origin="lower")
|
|
296
|
+
ax.figure.colorbar(image, ax=ax, fraction=0.046, pad=0.04)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _apply_axes_cosmetics(fig, axes, resolved: ResolvedPlot, n_rows, n_cols, at_cell) -> None:
|
|
300
|
+
style = resolved.spec.style
|
|
301
|
+
for row in range(n_rows):
|
|
302
|
+
for col in range(n_cols):
|
|
303
|
+
ax = axes[row][col]
|
|
304
|
+
if not ax.get_visible():
|
|
305
|
+
continue
|
|
306
|
+
# ONE rule decides both the axis title and the tick labels (see
|
|
307
|
+
# base.shows_x_labels). They used to drift: the title followed
|
|
308
|
+
# "nothing below" while the categorical block further down
|
|
309
|
+
# re-applied set_xticklabels to every panel, so tick labels came
|
|
310
|
+
# back everywhere.
|
|
311
|
+
bottom = shows_x_labels(resolved, row, col)
|
|
312
|
+
leftmost = shows_y_labels(resolved, row, col)
|
|
313
|
+
panel = at_cell.get((row, col))
|
|
314
|
+
ax.set_xlabel(resolved.labels.x if bottom else "")
|
|
315
|
+
# The facet values, when this is a facet — see base.panel_y_title.
|
|
316
|
+
ax.set_ylabel(panel_y_title(resolved, panel, leftmost=leftmost))
|
|
317
|
+
if style.log_x:
|
|
318
|
+
ax.set_xscale("log")
|
|
319
|
+
if style.log_y:
|
|
320
|
+
ax.set_yscale("log")
|
|
321
|
+
limits = panel_y_limits(resolved, panel) if panel else resolved.y_limits
|
|
322
|
+
if limits and resolved.kind is not PlotKind.HEATMAP:
|
|
323
|
+
ax.set_ylim(*limits)
|
|
324
|
+
if resolved.x_plan:
|
|
325
|
+
# A nested axis is keyed by composed leaf keys the user must
|
|
326
|
+
# never see: ticks show the innermost layer, and the layers
|
|
327
|
+
# above it become brackets under the axis.
|
|
328
|
+
plan = resolved.x_plan
|
|
329
|
+
ax.set_xticks(range(len(plan.order)))
|
|
330
|
+
ax.set_xticklabels(plan.tick_labels)
|
|
331
|
+
if bottom:
|
|
332
|
+
_draw_x_groups(ax, plan)
|
|
333
|
+
elif is_categorical_x(resolved) and resolved.kind in (
|
|
334
|
+
PlotKind.SCATTER,
|
|
335
|
+
PlotKind.STRIP,
|
|
336
|
+
):
|
|
337
|
+
order = [str(v) for v in (resolved.x_order or [])]
|
|
338
|
+
ax.set_xticks(range(len(order)))
|
|
339
|
+
ax.set_xticklabels(order)
|
|
340
|
+
|
|
341
|
+
# tick_params, NOT set_visible() on the Text objects: with
|
|
342
|
+
# sharex/sharey, get_xticklabels() regenerates the tick list and the
|
|
343
|
+
# new labels take their visibility from the axis's labelbottom
|
|
344
|
+
# param — so per-Text visibility silently reverts. This must also
|
|
345
|
+
# come LAST, after every set_xticklabels above (here and in the
|
|
346
|
+
# _draw_* helpers), so the rule wins rather than being overwritten.
|
|
347
|
+
# rotation=0 alongside it: panel content stays upright at every grid
|
|
348
|
+
# size, matching the plotly path's tickangle (a figure must not read
|
|
349
|
+
# differently just because it gained a facet).
|
|
350
|
+
ax.tick_params(labelbottom=bottom, labelleft=leftmost)
|
|
351
|
+
ax.tick_params(axis="x", rotation=0)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
#: Height of one nested-group label row, as a fraction of the axes height.
|
|
355
|
+
X_GROUP_ROW = 0.07
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _draw_x_groups(ax, plan) -> None:
|
|
359
|
+
"""Label and bracket each higher x layer beneath the tick labels.
|
|
360
|
+
|
|
361
|
+
Blended coordinates — x in DATA space (leaf positions are data positions on
|
|
362
|
+
a categorical axis) and y in AXES space (a fixed distance below the axis
|
|
363
|
+
regardless of the measure's range). The alternative, data coordinates for
|
|
364
|
+
both, would put the brackets at a y that moves with the data.
|
|
365
|
+
"""
|
|
366
|
+
from matplotlib.transforms import blended_transform_factory
|
|
367
|
+
|
|
368
|
+
transform = blended_transform_factory(ax.transData, ax.transAxes)
|
|
369
|
+
for group in plan.groups:
|
|
370
|
+
rows_below = plan.depth - group.depth
|
|
371
|
+
y = -0.10 - X_GROUP_ROW * rows_below
|
|
372
|
+
ax.plot(
|
|
373
|
+
[group.start - 0.35, group.end + 0.35],
|
|
374
|
+
[y + 0.02, y + 0.02],
|
|
375
|
+
transform=transform,
|
|
376
|
+
color="#888888",
|
|
377
|
+
linewidth=0.8,
|
|
378
|
+
clip_on=False,
|
|
379
|
+
)
|
|
380
|
+
ax.text(
|
|
381
|
+
group.centre,
|
|
382
|
+
y,
|
|
383
|
+
group.label,
|
|
384
|
+
transform=transform,
|
|
385
|
+
ha="center",
|
|
386
|
+
va="top",
|
|
387
|
+
fontsize=9,
|
|
388
|
+
clip_on=False,
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
#: Breathing room between the panels and the legend strip, as a fraction of the
|
|
393
|
+
#: figure width.
|
|
394
|
+
LEGEND_PAD = 0.02
|
|
395
|
+
|
|
396
|
+
#: Widest the legend strip may get, however long the level names are: past this
|
|
397
|
+
#: the labels have eaten the figure, and truncating the panels is worse than
|
|
398
|
+
#: truncating the legend.
|
|
399
|
+
MAX_LEGEND_FRACTION = 0.4
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _apply_legend(fig, resolved: ResolvedPlot) -> float:
|
|
403
|
+
"""
|
|
404
|
+
Draw the legend to the right of the panels; return the width it claimed.
|
|
405
|
+
|
|
406
|
+
The return value is a fraction of the figure width, for ``tight_layout``'s
|
|
407
|
+
``rect`` — see the call site. Zero means no legend was drawn, which is the
|
|
408
|
+
answer for a single colour level (``base.shows_legend``) as well as for no
|
|
409
|
+
colour at all.
|
|
410
|
+
"""
|
|
411
|
+
if not shows_legend(resolved):
|
|
412
|
+
Log.debug(
|
|
413
|
+
"legend omitted: %d colour level(s) drawn for %r",
|
|
414
|
+
len(legend_levels(resolved)),
|
|
415
|
+
resolved.labels.color,
|
|
416
|
+
layer=LAYER,
|
|
417
|
+
)
|
|
418
|
+
return 0.0
|
|
419
|
+
# Every VISIBLE axes, not just the first: with facets, a level can be
|
|
420
|
+
# absent from panel 1 and present in panel 5, and reading one panel's
|
|
421
|
+
# handles would drop it from the legend of a figure that draws it.
|
|
422
|
+
unique: dict[str, Any] = {}
|
|
423
|
+
for ax in fig.axes:
|
|
424
|
+
if not ax.get_visible():
|
|
425
|
+
continue
|
|
426
|
+
handles, labels = ax.get_legend_handles_labels()
|
|
427
|
+
for handle, label in zip(handles, labels, strict=False):
|
|
428
|
+
unique.setdefault(label, handle)
|
|
429
|
+
if not unique:
|
|
430
|
+
return 0.0
|
|
431
|
+
|
|
432
|
+
legend = fig.legend(
|
|
433
|
+
unique.values(),
|
|
434
|
+
unique.keys(),
|
|
435
|
+
title=resolved.labels.color,
|
|
436
|
+
loc="center right",
|
|
437
|
+
frameon=False,
|
|
438
|
+
)
|
|
439
|
+
return _legend_width_fraction(fig, legend, unique.keys(), resolved.labels.color)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _legend_width_fraction(fig, legend, labels, title) -> float:
|
|
443
|
+
"""How much of the figure's width the legend needs, measured if possible."""
|
|
444
|
+
figure_width = fig.get_size_inches()[0] or 1.0
|
|
445
|
+
try:
|
|
446
|
+
inches = legend.get_window_extent(fig.canvas.get_renderer()).width / fig.dpi
|
|
447
|
+
except Exception: # a backend without a usable renderer
|
|
448
|
+
# Estimate rather than reserve nothing: a wrong-by-a-little strip still
|
|
449
|
+
# keeps the legend off the panels, an unmeasured one does not.
|
|
450
|
+
longest = max((len(str(text)) for text in [*labels, title or ""]), default=0)
|
|
451
|
+
inches = 0.55 + 0.085 * longest
|
|
452
|
+
Log.debug(
|
|
453
|
+
"legend width not measurable; estimating %.2fin from %d labels",
|
|
454
|
+
inches,
|
|
455
|
+
len(list(labels)),
|
|
456
|
+
layer=LAYER,
|
|
457
|
+
)
|
|
458
|
+
return min(MAX_LEGEND_FRACTION, inches / figure_width + LEGEND_PAD)
|