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,537 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Plotly renderer — the interactive path.
|
|
3
|
+
|
|
4
|
+
Emits a plain ``{"data": [...], "layout": {...}}`` dict rather than a
|
|
5
|
+
``plotly.graph_objects.Figure``. That is deliberate: the consumer is plotly.js
|
|
6
|
+
running inside a VS Code webview, which wants JSON. Building it directly means
|
|
7
|
+
the interactive path needs no plotly Python package at all, keeps the payload
|
|
8
|
+
inspectable in tests, and avoids shipping a second figure object across the
|
|
9
|
+
JSON-RPC boundary only to serialize it anyway.
|
|
10
|
+
|
|
11
|
+
``plotly`` remains an optional extra for users who want a Figure in a notebook
|
|
12
|
+
(:func:`to_figure`).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
import pandas as pd
|
|
21
|
+
from scistacklog import Log
|
|
22
|
+
|
|
23
|
+
from ..resolved import ResolvedPlot
|
|
24
|
+
from ..spec import PlotKind
|
|
25
|
+
from .base import (
|
|
26
|
+
color_groups,
|
|
27
|
+
grid_shape,
|
|
28
|
+
legend_levels,
|
|
29
|
+
palette_for,
|
|
30
|
+
panel_position,
|
|
31
|
+
panel_y_limits,
|
|
32
|
+
panel_y_title,
|
|
33
|
+
shares_y_axis,
|
|
34
|
+
shows_legend,
|
|
35
|
+
shows_x_labels,
|
|
36
|
+
shows_y_labels,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
LAYER = "scistackplot"
|
|
40
|
+
|
|
41
|
+
#: Right margin with no legend in it — just room for the last x tick label.
|
|
42
|
+
BARE_RIGHT_MARGIN = 20
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def render(resolved: ResolvedPlot) -> dict:
|
|
46
|
+
"""Build a plotly.js figure dict."""
|
|
47
|
+
with Log.timer("render_plotly", layer=LAYER, extra=str(resolved.kind)):
|
|
48
|
+
n_rows, n_cols = grid_shape(resolved)
|
|
49
|
+
traces: list[dict] = []
|
|
50
|
+
legend_on = shows_legend(resolved)
|
|
51
|
+
if not legend_on and resolved.encoding.color:
|
|
52
|
+
Log.debug(
|
|
53
|
+
"legend omitted: %d colour level(s) drawn for %r",
|
|
54
|
+
len(legend_levels(resolved)),
|
|
55
|
+
resolved.labels.color,
|
|
56
|
+
layer=LAYER,
|
|
57
|
+
)
|
|
58
|
+
layout: dict[str, Any] = {
|
|
59
|
+
"showlegend": legend_on,
|
|
60
|
+
"legend": {
|
|
61
|
+
"title": {"text": resolved.labels.color or ""},
|
|
62
|
+
# Stated, not defaulted: outside the plotting area on the right
|
|
63
|
+
# and vertically centred, which is exactly where the matplotlib
|
|
64
|
+
# export puts it. The margin below reserves the room it sits in
|
|
65
|
+
# — at plotly's default right margin the legend was drawn into
|
|
66
|
+
# 20px of space and clipped.
|
|
67
|
+
"x": 1.02,
|
|
68
|
+
"xanchor": "left",
|
|
69
|
+
"y": 0.5,
|
|
70
|
+
"yanchor": "middle",
|
|
71
|
+
},
|
|
72
|
+
"margin": {
|
|
73
|
+
"l": 60,
|
|
74
|
+
"r": _right_margin(resolved) if legend_on else BARE_RIGHT_MARGIN,
|
|
75
|
+
"t": 40,
|
|
76
|
+
# Each nested group layer needs a label row below the ticks, or
|
|
77
|
+
# the brackets are drawn off the bottom of the figure.
|
|
78
|
+
"b": 50 + 28 * (resolved.x_plan.depth if resolved.x_plan else 0),
|
|
79
|
+
},
|
|
80
|
+
"hovermode": "closest",
|
|
81
|
+
"annotations": [],
|
|
82
|
+
# The grid shape travels with the figure so the panel can size it:
|
|
83
|
+
# 4 rows of subplots need more height than 1, and only the renderer
|
|
84
|
+
# knows how the panels were laid out. The GUI also reads `rows`/
|
|
85
|
+
# `cols` back as the EFFECTIVE grid — that is how "I set 2 columns"
|
|
86
|
+
# shows the computed row count — and `layout_notes` is how a spilled
|
|
87
|
+
# panel gets told to the user instead of just being logged.
|
|
88
|
+
"meta": {
|
|
89
|
+
"rows": n_rows,
|
|
90
|
+
"cols": n_cols,
|
|
91
|
+
"panels": len(resolved.panels),
|
|
92
|
+
"layout_notes": list(resolved.layout_notes),
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
if resolved.labels.title:
|
|
96
|
+
layout["title"] = {"text": resolved.labels.title}
|
|
97
|
+
|
|
98
|
+
positions = [
|
|
99
|
+
panel_position(resolved, index) for index in range(len(resolved.panels))
|
|
100
|
+
]
|
|
101
|
+
seen_legend: set[str] = set()
|
|
102
|
+
|
|
103
|
+
for index, panel in enumerate(resolved.panels):
|
|
104
|
+
row, col = positions[index]
|
|
105
|
+
slot = row * n_cols + col + 1
|
|
106
|
+
x_axis = "x" if slot == 1 else f"x{slot}"
|
|
107
|
+
y_axis = "y" if slot == 1 else f"y{slot}"
|
|
108
|
+
|
|
109
|
+
traces.extend(
|
|
110
|
+
_panel_traces(
|
|
111
|
+
panel.frame, resolved, x_axis, y_axis, seen_legend, legend_on
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
_add_axes(
|
|
115
|
+
layout,
|
|
116
|
+
resolved,
|
|
117
|
+
slot,
|
|
118
|
+
row,
|
|
119
|
+
col,
|
|
120
|
+
n_rows,
|
|
121
|
+
n_cols,
|
|
122
|
+
bottom=shows_x_labels(resolved, row, col),
|
|
123
|
+
leftmost=shows_y_labels(resolved, row, col),
|
|
124
|
+
y_limits=panel_y_limits(resolved, panel),
|
|
125
|
+
panel=panel,
|
|
126
|
+
)
|
|
127
|
+
# No panel-title annotation: a facet is named by its y-axis title
|
|
128
|
+
# (base.panel_y_title), which costs the grid no vertical room.
|
|
129
|
+
_add_x_groups(layout, resolved, row, col, n_rows, n_cols, slot)
|
|
130
|
+
|
|
131
|
+
return {"data": traces, "layout": layout}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
#: Vertical room, in paper fraction, for one row of nested group labels.
|
|
135
|
+
X_GROUP_ROW = 0.045
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _add_x_groups(layout, resolved, row, col, n_rows, n_cols, slot) -> None:
|
|
139
|
+
"""Label and bracket each higher x layer beneath the tick labels.
|
|
140
|
+
|
|
141
|
+
Drawn in PAPER coordinates from the cell's own domain, so the brackets sit
|
|
142
|
+
under the panel they describe in a facet grid — a data-coordinate
|
|
143
|
+
annotation would be clipped by the axis range and would move when the user
|
|
144
|
+
zooms.
|
|
145
|
+
|
|
146
|
+
Only under panels that show tick labels: repeating "stim | sham" under every
|
|
147
|
+
row of a grid is the same noise ``shows_x_labels`` already suppresses for
|
|
148
|
+
the ticks themselves.
|
|
149
|
+
"""
|
|
150
|
+
plan = resolved.x_plan
|
|
151
|
+
if not plan or not plan.groups or not shows_x_labels(resolved, row, col):
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
x0, y0, cell_width, _cell_height = _cell(
|
|
155
|
+
row, col, n_rows, n_cols, _x_depth(resolved)
|
|
156
|
+
)
|
|
157
|
+
positions = max(1, len(plan.order))
|
|
158
|
+
|
|
159
|
+
for group in plan.groups:
|
|
160
|
+
# Leaf index -> paper x. Centre of a leaf cell is (i + 0.5) / n.
|
|
161
|
+
left = x0 + cell_width * (group.start / positions)
|
|
162
|
+
right = x0 + cell_width * ((group.end + 1) / positions)
|
|
163
|
+
# Deeper layers sit closer to the axis; depth 0 is furthest below.
|
|
164
|
+
rows_below = plan.depth - group.depth
|
|
165
|
+
y = y0 - X_GROUP_ROW * rows_below - 0.03
|
|
166
|
+
|
|
167
|
+
layout["annotations"].append(
|
|
168
|
+
{
|
|
169
|
+
"text": group.label,
|
|
170
|
+
"x": (left + right) / 2.0,
|
|
171
|
+
"y": y,
|
|
172
|
+
"xref": "paper",
|
|
173
|
+
"yref": "paper",
|
|
174
|
+
"showarrow": False,
|
|
175
|
+
"font": {"size": 10},
|
|
176
|
+
"xanchor": "center",
|
|
177
|
+
"yanchor": "top",
|
|
178
|
+
}
|
|
179
|
+
)
|
|
180
|
+
layout.setdefault("shapes", []).append(
|
|
181
|
+
{
|
|
182
|
+
"type": "line",
|
|
183
|
+
"xref": "paper",
|
|
184
|
+
"yref": "paper",
|
|
185
|
+
# Inset slightly so adjacent brackets read as separate spans
|
|
186
|
+
# rather than one continuous rule.
|
|
187
|
+
"x0": left + cell_width * 0.004,
|
|
188
|
+
"x1": right - cell_width * 0.004,
|
|
189
|
+
"y0": y + 0.008,
|
|
190
|
+
"y1": y + 0.008,
|
|
191
|
+
"line": {"color": "#888888", "width": 1},
|
|
192
|
+
}
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def to_figure(resolved: ResolvedPlot):
|
|
197
|
+
"""Wrap :func:`render` in a ``plotly.graph_objects.Figure`` (optional extra)."""
|
|
198
|
+
try:
|
|
199
|
+
import plotly.graph_objects as go
|
|
200
|
+
except ImportError as exc: # pragma: no cover - environment dependent
|
|
201
|
+
raise ImportError(
|
|
202
|
+
"to_figure() needs plotly (pip install scistackplot[interactive]). "
|
|
203
|
+
"render() returns a plain figure dict with no such requirement."
|
|
204
|
+
) from exc
|
|
205
|
+
return go.Figure(render(resolved))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _panel_traces(
|
|
212
|
+
frame: pd.DataFrame,
|
|
213
|
+
resolved: ResolvedPlot,
|
|
214
|
+
x_axis: str,
|
|
215
|
+
y_axis: str,
|
|
216
|
+
seen_legend: set[str],
|
|
217
|
+
legend_on: bool = True,
|
|
218
|
+
) -> list[dict]:
|
|
219
|
+
if frame.empty:
|
|
220
|
+
return []
|
|
221
|
+
|
|
222
|
+
encoding = resolved.encoding
|
|
223
|
+
kind = resolved.kind
|
|
224
|
+
traces: list[dict] = []
|
|
225
|
+
|
|
226
|
+
if kind is PlotKind.HEATMAP:
|
|
227
|
+
matrix = np.asarray(frame[encoding.z].iloc[0], dtype=float)
|
|
228
|
+
return [
|
|
229
|
+
{
|
|
230
|
+
"type": "heatmap",
|
|
231
|
+
"z": matrix.tolist(),
|
|
232
|
+
"xaxis": x_axis.replace("x", "x"),
|
|
233
|
+
"yaxis": y_axis,
|
|
234
|
+
"colorscale": "Viridis",
|
|
235
|
+
}
|
|
236
|
+
]
|
|
237
|
+
|
|
238
|
+
for index, (level, subset) in enumerate(color_groups(frame, resolved)):
|
|
239
|
+
color = palette_for(resolved, level, index)
|
|
240
|
+
label = str(level) if level is not None else resolved.labels.y
|
|
241
|
+
show_legend = legend_on and level is not None and label not in seen_legend
|
|
242
|
+
if show_legend:
|
|
243
|
+
seen_legend.add(label)
|
|
244
|
+
|
|
245
|
+
base = {
|
|
246
|
+
"name": label,
|
|
247
|
+
"legendgroup": label,
|
|
248
|
+
"showlegend": show_legend,
|
|
249
|
+
"xaxis": x_axis,
|
|
250
|
+
"yaxis": y_axis,
|
|
251
|
+
}
|
|
252
|
+
x_values = _values(subset[encoding.x])
|
|
253
|
+
|
|
254
|
+
if kind in (PlotKind.SCATTER, PlotKind.STRIP):
|
|
255
|
+
traces.append(
|
|
256
|
+
{
|
|
257
|
+
**base,
|
|
258
|
+
"type": "scatter",
|
|
259
|
+
"mode": "markers",
|
|
260
|
+
"x": x_values,
|
|
261
|
+
"y": _values(subset[encoding.y]),
|
|
262
|
+
"marker": {"color": color, "size": 8, "opacity": resolved.spec.style.alpha},
|
|
263
|
+
}
|
|
264
|
+
)
|
|
265
|
+
elif kind is PlotKind.LINE:
|
|
266
|
+
traces.extend(_line_traces(subset, resolved, base, color))
|
|
267
|
+
elif kind is PlotKind.BAND:
|
|
268
|
+
traces.extend(_band_traces(subset, resolved, base, color))
|
|
269
|
+
elif kind is PlotKind.BAR:
|
|
270
|
+
error = None
|
|
271
|
+
if encoding.has_error:
|
|
272
|
+
centre = np.asarray(_values(subset[encoding.y]), dtype=float)
|
|
273
|
+
error = {
|
|
274
|
+
"type": "data",
|
|
275
|
+
"symmetric": False,
|
|
276
|
+
"array": (
|
|
277
|
+
np.asarray(_values(subset[encoding.y_high]), dtype=float) - centre
|
|
278
|
+
).tolist(),
|
|
279
|
+
"arrayminus": (
|
|
280
|
+
centre - np.asarray(_values(subset[encoding.y_low]), dtype=float)
|
|
281
|
+
).tolist(),
|
|
282
|
+
}
|
|
283
|
+
traces.append(
|
|
284
|
+
{
|
|
285
|
+
**base,
|
|
286
|
+
"type": "bar",
|
|
287
|
+
# Stated, never inferred: plotly picks an orientation from
|
|
288
|
+
# which of x/y it recognises, so a panel whose x came out
|
|
289
|
+
# numeric could silently draw sideways.
|
|
290
|
+
"orientation": "v",
|
|
291
|
+
"x": x_values,
|
|
292
|
+
"y": _values(subset[encoding.y]),
|
|
293
|
+
"marker": {"color": color},
|
|
294
|
+
**({"error_y": error} if error else {}),
|
|
295
|
+
}
|
|
296
|
+
)
|
|
297
|
+
elif kind in (PlotKind.BOX, PlotKind.VIOLIN):
|
|
298
|
+
traces.append(
|
|
299
|
+
{
|
|
300
|
+
**base,
|
|
301
|
+
"type": "box" if kind is PlotKind.BOX else "violin",
|
|
302
|
+
"orientation": "v", # see the bar trace above
|
|
303
|
+
"x": x_values,
|
|
304
|
+
"y": _values(subset[encoding.y]),
|
|
305
|
+
"marker": {"color": color},
|
|
306
|
+
"line": {"color": color},
|
|
307
|
+
"boxpoints": "outliers" if kind is PlotKind.BOX else None,
|
|
308
|
+
}
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
return traces
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _line_traces(subset, resolved, base, color) -> list[dict]:
|
|
315
|
+
"""One trace per polyline; only the first carries the legend entry."""
|
|
316
|
+
encoding = resolved.encoding
|
|
317
|
+
series_column = encoding.series
|
|
318
|
+
if series_column and series_column in subset.columns:
|
|
319
|
+
groups = list(subset.groupby(series_column, sort=False))
|
|
320
|
+
else:
|
|
321
|
+
groups = [(None, subset)]
|
|
322
|
+
|
|
323
|
+
traces = []
|
|
324
|
+
for position, (series_id, rows) in enumerate(groups):
|
|
325
|
+
traces.append(
|
|
326
|
+
{
|
|
327
|
+
**base,
|
|
328
|
+
"showlegend": base["showlegend"] and position == 0,
|
|
329
|
+
"type": "scatter",
|
|
330
|
+
"mode": "lines",
|
|
331
|
+
"x": _values(rows[encoding.x]),
|
|
332
|
+
"y": _values(rows[encoding.y]),
|
|
333
|
+
"line": {"color": color, "width": 1.5},
|
|
334
|
+
"opacity": resolved.spec.style.alpha,
|
|
335
|
+
"hovertext": str(series_id) if series_id is not None else None,
|
|
336
|
+
}
|
|
337
|
+
)
|
|
338
|
+
return traces
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _band_traces(subset, resolved, base, color) -> list[dict]:
|
|
342
|
+
encoding = resolved.encoding
|
|
343
|
+
x_values = _values(subset[encoding.x])
|
|
344
|
+
traces = []
|
|
345
|
+
if encoding.has_error:
|
|
346
|
+
traces.append(
|
|
347
|
+
{
|
|
348
|
+
**base,
|
|
349
|
+
"showlegend": False,
|
|
350
|
+
"type": "scatter",
|
|
351
|
+
"mode": "lines",
|
|
352
|
+
"x": x_values + x_values[::-1],
|
|
353
|
+
"y": _values(subset[encoding.y_high]) + _values(subset[encoding.y_low])[::-1],
|
|
354
|
+
"fill": "toself",
|
|
355
|
+
"fillcolor": _rgba(color, 0.22),
|
|
356
|
+
"line": {"width": 0},
|
|
357
|
+
"hoverinfo": "skip",
|
|
358
|
+
}
|
|
359
|
+
)
|
|
360
|
+
traces.append(
|
|
361
|
+
{
|
|
362
|
+
**base,
|
|
363
|
+
"type": "scatter",
|
|
364
|
+
"mode": "lines",
|
|
365
|
+
"x": x_values,
|
|
366
|
+
"y": _values(subset[encoding.y]),
|
|
367
|
+
"line": {"color": color, "width": 2},
|
|
368
|
+
}
|
|
369
|
+
)
|
|
370
|
+
return traces
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _add_axes(
|
|
374
|
+
layout,
|
|
375
|
+
resolved,
|
|
376
|
+
slot,
|
|
377
|
+
row,
|
|
378
|
+
col,
|
|
379
|
+
n_rows,
|
|
380
|
+
n_cols,
|
|
381
|
+
*,
|
|
382
|
+
bottom: bool = True,
|
|
383
|
+
leftmost: bool = True,
|
|
384
|
+
y_limits: tuple[float, float] | None = None,
|
|
385
|
+
panel=None,
|
|
386
|
+
) -> None:
|
|
387
|
+
x_key = "xaxis" if slot == 1 else f"xaxis{slot}"
|
|
388
|
+
y_key = "yaxis" if slot == 1 else f"yaxis{slot}"
|
|
389
|
+
x_anchor = "y" if slot == 1 else f"y{slot}"
|
|
390
|
+
y_anchor = "x" if slot == 1 else f"x{slot}"
|
|
391
|
+
|
|
392
|
+
x0, y0, cell_width, cell_height = _cell(
|
|
393
|
+
row, col, n_rows, n_cols, _x_depth(resolved)
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
layout[x_key] = {
|
|
397
|
+
"domain": [x0, x0 + cell_width],
|
|
398
|
+
"anchor": x_anchor,
|
|
399
|
+
# Tick labels and the axis title share ONE rule (base.shows_x_labels).
|
|
400
|
+
"showticklabels": bottom,
|
|
401
|
+
"title": {"text": resolved.labels.x if bottom else ""},
|
|
402
|
+
# A nested axis is keyed by composed leaf keys the user must never see;
|
|
403
|
+
# the ticks show the innermost layer's value, with the layers above it
|
|
404
|
+
# drawn as brackets (see _add_x_groups). Spacer positions get no tick.
|
|
405
|
+
**(
|
|
406
|
+
{
|
|
407
|
+
"tickmode": "array",
|
|
408
|
+
"tickvals": list(resolved.x_plan.order),
|
|
409
|
+
"ticktext": list(resolved.x_plan.tick_labels),
|
|
410
|
+
}
|
|
411
|
+
if resolved.x_plan
|
|
412
|
+
else {}
|
|
413
|
+
),
|
|
414
|
+
"type": "log" if resolved.spec.style.log_x else "-",
|
|
415
|
+
# Upright, always. Plotly rotates category tick labels towards vertical
|
|
416
|
+
# once a cell is too narrow for them, so the same figure reads
|
|
417
|
+
# differently at two facet counts. Fixed at 0; automargin buys the room.
|
|
418
|
+
"tickangle": 0,
|
|
419
|
+
"automargin": True,
|
|
420
|
+
}
|
|
421
|
+
layout[y_key] = {
|
|
422
|
+
"domain": [y0, y0 + cell_height],
|
|
423
|
+
"anchor": y_anchor,
|
|
424
|
+
"showticklabels": leftmost,
|
|
425
|
+
# Tick labels and the axis title do NOT share a rule here (unlike x):
|
|
426
|
+
# a faceted panel's title names that panel, so it is drawn even where
|
|
427
|
+
# the shared tick labels are suppressed. See base.panel_y_title.
|
|
428
|
+
"title": {"text": panel_y_title(resolved, panel, leftmost=leftmost)},
|
|
429
|
+
"type": "log" if resolved.spec.style.log_y else "-",
|
|
430
|
+
# No automargin here, deliberately, even though the x axes use it: an
|
|
431
|
+
# inner column's title is rotated text drawn into X_GAP (which is sized
|
|
432
|
+
# for it), and plotly's automargin answers a crowded subplot axis by
|
|
433
|
+
# growing the FIGURE's left margin — it would take back across the whole
|
|
434
|
+
# width the room this change just gave the panels.
|
|
435
|
+
}
|
|
436
|
+
# Link the axes when the spec asks for shared scales, so panning/zooming one
|
|
437
|
+
# subplot moves them all — and so hiding tick labels stays truthful.
|
|
438
|
+
if slot != 1:
|
|
439
|
+
if resolved.spec.facet.share_x:
|
|
440
|
+
layout[x_key]["matches"] = "x"
|
|
441
|
+
# `matches` on y is DERIVED, exactly as matplotlib's sharey is: linking
|
|
442
|
+
# panels that hold different ranges would make zooming one rescale the
|
|
443
|
+
# rest, silently discarding the per-panel limits below.
|
|
444
|
+
if shares_y_axis(resolved):
|
|
445
|
+
layout[y_key]["matches"] = "y"
|
|
446
|
+
if y_limits and resolved.kind is not PlotKind.HEATMAP:
|
|
447
|
+
layout[y_key]["range"] = list(y_limits)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
#: Approximate width of one legend character at the webview's font size, in px.
|
|
451
|
+
#: The renderer emits JSON and never measures text, so the strip is sized from
|
|
452
|
+
#: the label lengths — generously, because too wide only costs plot area while
|
|
453
|
+
#: too narrow clips the level names.
|
|
454
|
+
LEGEND_CHAR_PX = 8
|
|
455
|
+
#: Swatch, padding and the gap between the panels and the legend.
|
|
456
|
+
LEGEND_FIXED_PX = 48
|
|
457
|
+
#: Same cap as the matplotlib path (mpl.MAX_LEGEND_FRACTION), in pixels against
|
|
458
|
+
#: the default figure width.
|
|
459
|
+
MAX_LEGEND_PX = 320
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _right_margin(resolved: ResolvedPlot) -> int:
|
|
463
|
+
"""Room on the right for the legend, sized from the longest entry."""
|
|
464
|
+
entries = [str(level) for level in legend_levels(resolved)]
|
|
465
|
+
entries.append(resolved.labels.color or "")
|
|
466
|
+
longest = max((len(text) for text in entries), default=0)
|
|
467
|
+
return int(min(MAX_LEGEND_PX, LEGEND_FIXED_PX + LEGEND_CHAR_PX * longest))
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
#: Space between subplot cells, as a fraction of the figure. The vertical gap is
|
|
471
|
+
#: still the larger of the two — x tick labels hang below a cell — but it used
|
|
472
|
+
#: to be 0.14 because a panel TITLE also sat above the next row and at 0.06 the
|
|
473
|
+
#: two collided. Facet names moved onto the y axis (base.panel_y_title), so that
|
|
474
|
+
#: strip is no longer spent on captions and the panels keep the height.
|
|
475
|
+
X_GAP = 0.06
|
|
476
|
+
Y_GAP = 0.09
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _x_depth(resolved: ResolvedPlot) -> int:
|
|
480
|
+
"""Rows of nested x-group labels that hang below a cell (0 when flat)."""
|
|
481
|
+
return resolved.x_plan.depth if resolved.x_plan else 0
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _gaps(n_rows: int, n_cols: int, x_depth: int = 0) -> tuple[float, float]:
|
|
485
|
+
"""
|
|
486
|
+
Gap between cells, never more than half the figure in total.
|
|
487
|
+
|
|
488
|
+
One function so every consumer of the layout agrees about how much room
|
|
489
|
+
there is between two cells — the y-axis titles of the inner columns are
|
|
490
|
+
drawn into the horizontal gap, and the x tick labels into the vertical one.
|
|
491
|
+
|
|
492
|
+
``x_depth`` is why the vertical gap is not just a constant: a nested x axis
|
|
493
|
+
draws a row of group labels and brackets under each panel that shows tick
|
|
494
|
+
labels (:func:`_add_x_groups`), and in a grid that is INSIDE the gap rather
|
|
495
|
+
than in the figure's bottom margin. Sizing the gap from the same number the
|
|
496
|
+
brackets are placed with is what stops them landing on the row below.
|
|
497
|
+
"""
|
|
498
|
+
return (
|
|
499
|
+
min(X_GAP, 0.5 / (n_cols - 1)) if n_cols > 1 else 0.0,
|
|
500
|
+
min(Y_GAP + X_GROUP_ROW * x_depth, 0.5 / (n_rows - 1)) if n_rows > 1 else 0.0,
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _cell(
|
|
505
|
+
row, col, n_rows, n_cols, x_depth: int = 0
|
|
506
|
+
) -> tuple[float, float, float, float]:
|
|
507
|
+
"""
|
|
508
|
+
(x0, y0, width, height) of one grid cell, in paper coordinates.
|
|
509
|
+
|
|
510
|
+
The gaps are a fraction of the FIGURE, so they must be clamped against the
|
|
511
|
+
cell count: at a fixed Y_GAP of 0.14 a 9-row grid spends 1.12 of its 1.0 on
|
|
512
|
+
gaps, the cell height goes negative, and every panel's y domain runs
|
|
513
|
+
backwards — panels invert and overlap. Capping the total gap at half the
|
|
514
|
+
figure keeps every cell at least ``0.5 / n`` tall, and the extra rows are
|
|
515
|
+
absorbed by the figure's pixel height instead (the GUI sizes it from
|
|
516
|
+
``layout.meta.rows``).
|
|
517
|
+
"""
|
|
518
|
+
x_gap, y_gap = _gaps(n_rows, n_cols, x_depth)
|
|
519
|
+
cell_width = (1.0 - x_gap * (n_cols - 1)) / n_cols
|
|
520
|
+
cell_height = (1.0 - y_gap * (n_rows - 1)) / n_rows
|
|
521
|
+
# Plotly's y domain runs bottom-up; our rows run top-down.
|
|
522
|
+
x0 = col * (cell_width + x_gap)
|
|
523
|
+
y0 = (n_rows - row - 1) * (cell_height + y_gap)
|
|
524
|
+
# The last cell's edge is 1.0 by construction, but only exactly so in real
|
|
525
|
+
# arithmetic; plotly rejects a domain above 1, so trim the float residue.
|
|
526
|
+
return x0, y0, min(cell_width, 1.0 - x0), min(cell_height, 1.0 - y0)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _values(series: pd.Series) -> list:
|
|
530
|
+
"""Series -> a JSON-safe list (numpy scalars are not JSON serializable)."""
|
|
531
|
+
return [None if pd.isna(v) else (v.item() if hasattr(v, "item") else v) for v in series]
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _rgba(hex_color: str, alpha: float) -> str:
|
|
535
|
+
hex_color = hex_color.lstrip("#")
|
|
536
|
+
r, g, b = (int(hex_color[i : i + 2], 16) for i in (0, 2, 4))
|
|
537
|
+
return f"rgba({r},{g},{b},{alpha})"
|