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.
@@ -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)