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,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})"