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,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Renderers.
|
|
3
|
+
|
|
4
|
+
Both take a fully reduced :class:`~scistackplot.resolved.ResolvedPlot` and only
|
|
5
|
+
translate it. matplotlib is the export and pipeline path (it returns the
|
|
6
|
+
``Figure`` a scidb ``plot_`` endpoint must return); plotly is the interactive
|
|
7
|
+
path (it returns a plotly.js figure dict for the webview).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .base import Renderer
|
|
11
|
+
|
|
12
|
+
__all__ = ["Renderer", "render_matplotlib", "render_plotly"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def render_matplotlib(resolved):
|
|
16
|
+
"""Draw with matplotlib; returns a ``matplotlib.figure.Figure``."""
|
|
17
|
+
from .mpl import render
|
|
18
|
+
|
|
19
|
+
return render(resolved)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def render_plotly(resolved) -> dict:
|
|
23
|
+
"""Build a plotly.js figure dict (no plotly package required)."""
|
|
24
|
+
from .plotly_ import render
|
|
25
|
+
|
|
26
|
+
return render(resolved)
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Renderer protocol and the layout maths both renderers share.
|
|
3
|
+
|
|
4
|
+
A renderer translates a :class:`~scistackplot.resolved.ResolvedPlot` into a
|
|
5
|
+
backend figure. It performs **no** data reduction — every aggregation, ordering
|
|
6
|
+
and error band was decided in ``reduce.resolve``. Keeping renderers dumb is
|
|
7
|
+
what stops the interactive view and the exported figure from disagreeing.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import math
|
|
13
|
+
from typing import Any, Protocol
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
import pandas as pd
|
|
17
|
+
|
|
18
|
+
from ..resolved import ResolvedPlot
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Renderer(Protocol):
|
|
22
|
+
"""Anything that can draw a ResolvedPlot."""
|
|
23
|
+
|
|
24
|
+
def render(self, resolved: ResolvedPlot) -> Any: # pragma: no cover - protocol
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def grid_shape(resolved: ResolvedPlot) -> tuple[int, int]:
|
|
29
|
+
"""Rows and columns of the subplot grid, as decided in ``reduce``."""
|
|
30
|
+
return max(1, resolved.grid_rows), max(1, resolved.grid_cols)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def panel_position(resolved: ResolvedPlot, panel_index: int) -> tuple[int, int]:
|
|
34
|
+
"""Zero-based (row, column) of a panel. Layout is not the renderer's job."""
|
|
35
|
+
panel = resolved.panels[panel_index]
|
|
36
|
+
return panel.grid_row, panel.grid_col
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def occupied_cells(resolved: ResolvedPlot) -> set[tuple[int, int]]:
|
|
40
|
+
"""Every grid cell that holds a panel — the basis for the axis rules."""
|
|
41
|
+
return {(p.grid_row, p.grid_col) for p in resolved.panels}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def shows_x_labels(resolved: ResolvedPlot, row: int, col: int) -> bool:
|
|
45
|
+
"""
|
|
46
|
+
Whether this cell carries the x tick labels AND the x axis title.
|
|
47
|
+
|
|
48
|
+
ONE rule for both, deliberately: "nothing directly below" rather than
|
|
49
|
+
"bottom row", because a wrapped grid's last row is usually partial and the
|
|
50
|
+
panels above those empty cells are the bottom of their own column. The two
|
|
51
|
+
used to drift — the title followed this rule while the tick labels were
|
|
52
|
+
re-applied to every panel.
|
|
53
|
+
"""
|
|
54
|
+
return (row + 1, col) not in occupied_cells(resolved)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def shows_y_labels(resolved: ResolvedPlot, row: int, col: int) -> bool:
|
|
58
|
+
"""Same idea on the other axis: nothing directly to the left.
|
|
59
|
+
|
|
60
|
+
**Unless the panels are on different scales**, in which case every panel
|
|
61
|
+
keeps its numbers. Hiding them is only honest when the hidden numbers would
|
|
62
|
+
have been identical; a grid of independently-scaled panels labelled down the
|
|
63
|
+
left column only reads as one shared scale, which is precisely the misread
|
|
64
|
+
that per-panel limits exist to enable in the first place.
|
|
65
|
+
"""
|
|
66
|
+
if not shares_y_axis(resolved):
|
|
67
|
+
return True
|
|
68
|
+
return (row, col - 1) not in occupied_cells(resolved)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def panel_y_title(resolved: ResolvedPlot, panel, *, leftmost: bool) -> str:
|
|
72
|
+
"""The y-axis title one panel carries.
|
|
73
|
+
|
|
74
|
+
Keyed off the panel's own facet KEY rather than off the spec's roles, so a
|
|
75
|
+
FACET factor that resolved to a single keyless panel reads as the ordinary
|
|
76
|
+
plot it is instead of as a grid of one.
|
|
77
|
+
|
|
78
|
+
ONE rule for both renderers, and the reason a faceted figure has no subplot
|
|
79
|
+
captions: **the facet values ARE the y-axis title**. A caption above every
|
|
80
|
+
panel costs a strip of vertical room in each row of the grid — the axis
|
|
81
|
+
title is room the panel was already spending, so the same information
|
|
82
|
+
arrives for free and the panels get the height back.
|
|
83
|
+
|
|
84
|
+
It follows that a faceted panel labels its axis wherever it sits: the text
|
|
85
|
+
identifies THIS panel, so the "leftmost only" rule (which exists to stop a
|
|
86
|
+
shared label being repeated) does not apply to it.
|
|
87
|
+
"""
|
|
88
|
+
if panel is not None and panel.key:
|
|
89
|
+
return panel.title
|
|
90
|
+
return resolved.labels.y if leftmost else ""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def is_categorical_x(resolved: ResolvedPlot) -> bool:
|
|
94
|
+
"""
|
|
95
|
+
Whether the x axis is a set of discrete positions rather than a number line.
|
|
96
|
+
|
|
97
|
+
A factor on x is categorical; a 1-D index or a second measure is numeric.
|
|
98
|
+
"""
|
|
99
|
+
if resolved.x_order is None:
|
|
100
|
+
return False
|
|
101
|
+
return not all(_is_number(value) for value in resolved.x_order)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _is_number(value: Any) -> bool:
|
|
105
|
+
return isinstance(value, (int, float, np.integer, np.floating)) and not isinstance(
|
|
106
|
+
value, bool
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def x_positions(
|
|
111
|
+
values: pd.Series, resolved: ResolvedPlot
|
|
112
|
+
) -> tuple[np.ndarray, list[str] | None]:
|
|
113
|
+
"""
|
|
114
|
+
Map x values to plotting positions.
|
|
115
|
+
|
|
116
|
+
Returns ``(positions, tick_labels)``. ``tick_labels`` is None for a numeric
|
|
117
|
+
axis; for a categorical axis it is the ordered level labels, and positions
|
|
118
|
+
are their indices — which is what puts "01, 02, … 10" in the right order
|
|
119
|
+
instead of pandas' lexicographic 1, 10, 2.
|
|
120
|
+
"""
|
|
121
|
+
if not is_categorical_x(resolved):
|
|
122
|
+
return pd.to_numeric(values, errors="coerce").to_numpy(dtype=float), None
|
|
123
|
+
|
|
124
|
+
order = [str(v) for v in (resolved.x_order or [])]
|
|
125
|
+
lookup = {label: position for position, label in enumerate(order)}
|
|
126
|
+
positions = np.array(
|
|
127
|
+
[lookup.get(str(v), np.nan) for v in values], dtype=float
|
|
128
|
+
)
|
|
129
|
+
return positions, order
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def color_groups(
|
|
133
|
+
frame: pd.DataFrame, resolved: ResolvedPlot
|
|
134
|
+
) -> list[tuple[Any, pd.DataFrame]]:
|
|
135
|
+
"""Split a panel frame into colour series, in declared level order."""
|
|
136
|
+
color_column = resolved.encoding.color
|
|
137
|
+
if not color_column or color_column not in frame.columns:
|
|
138
|
+
return [(None, frame)]
|
|
139
|
+
|
|
140
|
+
order = resolved.color_order or []
|
|
141
|
+
groups: list[tuple[Any, pd.DataFrame]] = []
|
|
142
|
+
seen = set()
|
|
143
|
+
for level in order:
|
|
144
|
+
subset = frame[frame[color_column].astype(str) == str(level)]
|
|
145
|
+
if len(subset):
|
|
146
|
+
groups.append((level, subset))
|
|
147
|
+
seen.add(str(level))
|
|
148
|
+
# Anything the declared order missed (shouldn't happen, but never drop data).
|
|
149
|
+
for level in frame[color_column].dropna().unique():
|
|
150
|
+
if str(level) not in seen:
|
|
151
|
+
groups.append((level, frame[frame[color_column] == level]))
|
|
152
|
+
return groups
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def legend_levels(resolved: ResolvedPlot) -> list[Any]:
|
|
156
|
+
"""
|
|
157
|
+
The colour levels a legend would list, in drawn order.
|
|
158
|
+
|
|
159
|
+
Read off the PANELS rather than off ``color_order`` because a legend must
|
|
160
|
+
describe what is actually on the figure: a colour factor can arrive with
|
|
161
|
+
levels that this figure never draws (a filter removed them, or an ITERATE
|
|
162
|
+
slice only contains one of them), and those must not be counted.
|
|
163
|
+
"""
|
|
164
|
+
color_column = resolved.encoding.color
|
|
165
|
+
if not color_column:
|
|
166
|
+
return []
|
|
167
|
+
present: dict[str, Any] = {}
|
|
168
|
+
for panel in resolved.panels:
|
|
169
|
+
if color_column not in panel.frame.columns:
|
|
170
|
+
continue
|
|
171
|
+
# unique(), not color_groups(): this is only a level count, and masking
|
|
172
|
+
# every panel once per declared level would walk the whole figure's data
|
|
173
|
+
# a second time on the export path (which is not downsampled).
|
|
174
|
+
for value in panel.frame[color_column].dropna().unique():
|
|
175
|
+
present.setdefault(str(value), value)
|
|
176
|
+
|
|
177
|
+
# Declared order first, then anything it missed — the same ordering rule
|
|
178
|
+
# ``color_groups`` uses, so the legend lists the series in drawn order.
|
|
179
|
+
ordered = [
|
|
180
|
+
present[str(level)]
|
|
181
|
+
for level in (resolved.color_order or [])
|
|
182
|
+
if str(level) in present
|
|
183
|
+
]
|
|
184
|
+
seen = {str(level) for level in ordered}
|
|
185
|
+
ordered.extend(value for key, value in present.items() if key not in seen)
|
|
186
|
+
return ordered
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def shares_y_axis(resolved: ResolvedPlot) -> bool:
|
|
190
|
+
"""Whether every panel draws the same y range, so one axis can serve them.
|
|
191
|
+
|
|
192
|
+
ONE rule, shared by both renderers — the same bargain as
|
|
193
|
+
:func:`shows_legend`. It is *derived*, not configured: the user says which
|
|
194
|
+
factors separate limits (``PlotSpec.y_axis.scope``) and whether the panels
|
|
195
|
+
end up agreeing follows from that plus the data.
|
|
196
|
+
|
|
197
|
+
It matters beyond the range itself, which is why it is a function rather
|
|
198
|
+
than an inline check. A shared axis also hides the inner panels' tick
|
|
199
|
+
labels: right when they genuinely share a scale, and a silent lie the moment
|
|
200
|
+
they do not — a grid of panels at different scales with numbers on only the
|
|
201
|
+
left column reads as one scale.
|
|
202
|
+
"""
|
|
203
|
+
return resolved.y_limits is not None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def panel_y_limits(resolved: ResolvedPlot, panel) -> tuple[float, float] | None:
|
|
207
|
+
"""The range one panel draws, falling back to the figure's.
|
|
208
|
+
|
|
209
|
+
The fallback matters for a HEATMAP, whose panels carry no y limits at all,
|
|
210
|
+
and for any panel a scope left without a group of its own.
|
|
211
|
+
"""
|
|
212
|
+
return panel.y_limits or resolved.y_limits
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def shows_legend(resolved: ResolvedPlot) -> bool:
|
|
216
|
+
"""
|
|
217
|
+
Whether this figure gets a legend at all.
|
|
218
|
+
|
|
219
|
+
ONE rule, shared by both renderers and mirrored by the generated seaborn
|
|
220
|
+
code (``codegen``): the legend exists only to tell colour series apart, so
|
|
221
|
+
a single level makes it pure noise — it restates the one thing every mark
|
|
222
|
+
on the figure already has in common, and it costs the panels width.
|
|
223
|
+
"""
|
|
224
|
+
return len(legend_levels(resolved)) > 1
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
#: A colour-blind-safe qualitative palette, used when the spec names none.
|
|
228
|
+
#: Okabe–Ito, which stays distinguishable in greyscale print.
|
|
229
|
+
DEFAULT_PALETTE = (
|
|
230
|
+
"#0072B2",
|
|
231
|
+
"#D55E00",
|
|
232
|
+
"#009E73",
|
|
233
|
+
"#CC79A7",
|
|
234
|
+
"#E69F00",
|
|
235
|
+
"#56B4E9",
|
|
236
|
+
"#F0E442",
|
|
237
|
+
"#000000",
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def palette_color(index: int, palette: tuple[str, ...] = DEFAULT_PALETTE) -> str:
|
|
242
|
+
return palette[index % len(palette)]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def palette_for(resolved: ResolvedPlot, level: Any, fallback: int) -> str:
|
|
246
|
+
"""The colour a level carries — the same one in every panel.
|
|
247
|
+
|
|
248
|
+
Indexed by the level's position in the **declared** order, never by how many
|
|
249
|
+
groups this particular panel happened to draw. :func:`color_groups` omits a
|
|
250
|
+
level with no rows in the panel it is splitting, so enumerating its output
|
|
251
|
+
handed the *next* level that level's colour: one series drawn in two colours
|
|
252
|
+
across a facet grid, with the legend agreeing with only some of the panels.
|
|
253
|
+
A figure wrong in a way that looks like data.
|
|
254
|
+
|
|
255
|
+
``fallback`` covers a level the declared order never mentioned — the same
|
|
256
|
+
"never drop data" case ``color_groups`` ends with.
|
|
257
|
+
"""
|
|
258
|
+
order = resolved.color_order or []
|
|
259
|
+
for position, candidate in enumerate(order):
|
|
260
|
+
if str(candidate) == str(level):
|
|
261
|
+
return palette_color(position)
|
|
262
|
+
return palette_color(fallback)
|