eagleplot 3.0.0__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.
Files changed (73) hide show
  1. eagleplot/Home.py +362 -0
  2. eagleplot/__init__.py +19 -0
  3. eagleplot/annotations.py +215 -0
  4. eagleplot/assets/logo.ico +0 -0
  5. eagleplot/assets/logo.png +0 -0
  6. eagleplot/assets/nccu-horiz-logo.png +0 -0
  7. eagleplot/assets/nccu-wings.png +0 -0
  8. eagleplot/batch.py +446 -0
  9. eagleplot/cli.py +719 -0
  10. eagleplot/data_tools.py +849 -0
  11. eagleplot/example-data/Artificial/bimodal_distribution.csv +501 -0
  12. eagleplot/example-data/Artificial/correlation_matrix.npy +0 -0
  13. eagleplot/example-data/Artificial/gaussian_surface.npy +0 -0
  14. eagleplot/example-data/Artificial/grouped_categorical.csv +241 -0
  15. eagleplot/example-data/Artificial/ir_spectrum.csv +1802 -0
  16. eagleplot/example-data/Artificial/molecular_properties.csv +121 -0
  17. eagleplot/example-data/Artificial/normal_distribution.csv +401 -0
  18. eagleplot/example-data/Artificial/reaction_kinetics.csv +121 -0
  19. eagleplot/example-data/Artificial/scatter_correlation.csv +201 -0
  20. eagleplot/example-data/Artificial/sine_cosine_waves.csv +301 -0
  21. eagleplot/gallery/box_plot.png +0 -0
  22. eagleplot/gallery/contour_plot.png +0 -0
  23. eagleplot/gallery/distribution_plot.png +0 -0
  24. eagleplot/gallery/heatmap.png +0 -0
  25. eagleplot/gallery/histogram.png +0 -0
  26. eagleplot/gallery/interactive_3d_surface.png +0 -0
  27. eagleplot/gallery/interactive_heatmap.png +0 -0
  28. eagleplot/gallery/interactive_histogram.png +0 -0
  29. eagleplot/gallery/interactive_line.png +0 -0
  30. eagleplot/gallery/interactive_scatter.png +0 -0
  31. eagleplot/gallery/line_plot.png +0 -0
  32. eagleplot/gallery/manifest.json +196 -0
  33. eagleplot/gallery/regression_plot.png +0 -0
  34. eagleplot/gallery/scatter_plot.png +0 -0
  35. eagleplot/io.py +1008 -0
  36. eagleplot/math.py +1210 -0
  37. eagleplot/molecular/__init__.py +21 -0
  38. eagleplot/molecular/atom_data.py +287 -0
  39. eagleplot/molecular/parsers.py +747 -0
  40. eagleplot/nist.py +241 -0
  41. eagleplot/pages/10_Molecular_Viz.py +225 -0
  42. eagleplot/pages/11_Batch_Analysis.py +325 -0
  43. eagleplot/pages/12_Help.py +199 -0
  44. eagleplot/pages/13_Settings.py +388 -0
  45. eagleplot/pages/1_Data_Upload.py +521 -0
  46. eagleplot/pages/2_Quick_Plot.py +1693 -0
  47. eagleplot/pages/3_Analysis_Tools.py +2510 -0
  48. eagleplot/pages/4_Multi_Plot_Dashboard.py +791 -0
  49. eagleplot/pages/5_Advanced_Plotting.py +691 -0
  50. eagleplot/pages/6_Export_Results.py +442 -0
  51. eagleplot/pages/7_Gallery.py +201 -0
  52. eagleplot/pages/8_Data_Tools.py +434 -0
  53. eagleplot/pages/9_Spectroscopy.py +821 -0
  54. eagleplot/pages/__init__.py +8 -0
  55. eagleplot/peaks.py +513 -0
  56. eagleplot/plotting.py +2630 -0
  57. eagleplot/plugin_loader.py +208 -0
  58. eagleplot/report.py +448 -0
  59. eagleplot/signal.py +494 -0
  60. eagleplot/spectroscopy.py +830 -0
  61. eagleplot/utils/__init__.py +76 -0
  62. eagleplot/utils/access.py +77 -0
  63. eagleplot/utils/data_preview.py +352 -0
  64. eagleplot/utils/plot_config.py +2721 -0
  65. eagleplot/utils/session_state.py +535 -0
  66. eagleplot/utils/theming.py +312 -0
  67. eagleplot/utils/user_settings.py +294 -0
  68. eagleplot-3.0.0.dist-info/METADATA +304 -0
  69. eagleplot-3.0.0.dist-info/RECORD +73 -0
  70. eagleplot-3.0.0.dist-info/WHEEL +5 -0
  71. eagleplot-3.0.0.dist-info/entry_points.txt +2 -0
  72. eagleplot-3.0.0.dist-info/licenses/LICENSE +21 -0
  73. eagleplot-3.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,791 @@
1
+ """Multi-Plot Dashboard Page.
2
+
3
+ Configure a grid of independent plots (up to 3 × 2 = 6 cells) for
4
+ side-by-side visual comparison across datasets or plot types.
5
+ Each cell has its own dataset, plot type, and column selection.
6
+ """
7
+
8
+ import traceback
9
+ from pathlib import Path
10
+ import sys
11
+
12
+ import streamlit as st
13
+ import numpy as np
14
+ import pandas as pd
15
+
16
+ sys.path.insert(0, str(Path(__file__).parent.parent.parent))
17
+
18
+ from eagleplot.plotting import (
19
+ histogram,
20
+ line_plot,
21
+ scatter_plot,
22
+ heatmap,
23
+ contour_plot,
24
+ distribution_plot,
25
+ box_plot,
26
+ regression_plot,
27
+ interactive_histogram,
28
+ interactive_scatter,
29
+ interactive_line,
30
+ interactive_heatmap,
31
+ interactive_3d_surface,
32
+ )
33
+ from eagleplot.utils import (
34
+ initialize_session_state,
35
+ get_dataset,
36
+ get_session_summary,
37
+ )
38
+ from eagleplot.utils.plot_config import PLOT_TYPES
39
+
40
+ initialize_session_state()
41
+
42
+
43
+ # ── Constants ─────────────────────────────────────────────────────────────────
44
+
45
+ LAYOUTS: dict = {
46
+ "1 × 1": (1, 1),
47
+ "1 × 2": (1, 2),
48
+ "2 × 1": (2, 1),
49
+ "2 × 2": (2, 2),
50
+ "2 × 3": (2, 3),
51
+ "3 × 2": (3, 2),
52
+ "3 × 3": (3, 3),
53
+ "3 × 4": (3, 4),
54
+ "4 × 3": (4, 3),
55
+ "4 × 4": (4, 4),
56
+ }
57
+
58
+ # Categorise plot types by what data they need
59
+ _NEEDS_1COL = frozenset({"histogram", "distribution_plot", "interactive_histogram"})
60
+ _NEEDS_XY = frozenset(
61
+ {
62
+ "scatter_plot",
63
+ "line_plot",
64
+ "regression_plot",
65
+ "interactive_scatter",
66
+ "interactive_line",
67
+ }
68
+ )
69
+ _BOX_TYPES = frozenset({"box_plot"})
70
+ _MATRIX_TYPES = frozenset(
71
+ {"heatmap", "contour_plot", "interactive_heatmap", "interactive_3d_surface"}
72
+ )
73
+
74
+ # Matplotlib types that accept the figsize kwarg
75
+ _MPL_TYPES = frozenset({"histogram", "line_plot", "scatter_plot", "heatmap", "contour_plot"})
76
+
77
+ _CELL_FIGSIZE = (5, 4)
78
+
79
+
80
+ # ── Helpers ───────────────────────────────────────────────────────────────────
81
+
82
+
83
+ def _numeric_cols(data) -> list:
84
+ if isinstance(data, pd.DataFrame):
85
+ return data.select_dtypes(include=[np.number]).columns.tolist()
86
+ return []
87
+
88
+
89
+ def _col_options(data):
90
+ """Return (options_list, format_func) for a column selector."""
91
+ if isinstance(data, pd.DataFrame):
92
+ return _numeric_cols(data), str
93
+ arr = np.asarray(data)
94
+ if arr.ndim < 2:
95
+ return [], str
96
+ return list(range(arr.shape[1])), lambda i: f"Column {i}"
97
+
98
+
99
+ def _to_1d(data, col_ref) -> np.ndarray:
100
+ if isinstance(data, pd.DataFrame):
101
+ return data[col_ref].values.astype(float)
102
+ arr = np.asarray(data, dtype=float)
103
+ if col_ref is None:
104
+ return arr.flatten()
105
+ return arr[:, int(col_ref)]
106
+
107
+
108
+ def _auto_1d(data) -> np.ndarray:
109
+ """Return first numeric column (DataFrame) or flatten the array."""
110
+ if isinstance(data, pd.DataFrame):
111
+ cols = _numeric_cols(data)
112
+ return data[cols[0]].values.astype(float) if cols else np.array([])
113
+ return np.asarray(data, dtype=float).flatten()
114
+
115
+
116
+ def _cell_col_widgets(cell_idx: int, plot_type: str, data):
117
+ """Render column pickers appropriate for *plot_type*. Returns (x_col, y_col)."""
118
+ x_col = y_col = None
119
+ options, fmt = _col_options(data)
120
+
121
+ if plot_type in _NEEDS_1COL:
122
+ if options:
123
+ x_col = st.selectbox(
124
+ "Data column",
125
+ options,
126
+ format_func=fmt,
127
+ key=f"cell_{cell_idx}_xcol",
128
+ )
129
+ else:
130
+ st.caption("1-D array — all values used.")
131
+
132
+ elif plot_type in _NEEDS_XY:
133
+ if len(options) >= 2:
134
+ c1, c2 = st.columns(2)
135
+ with c1:
136
+ x_col = st.selectbox(
137
+ "X column",
138
+ options,
139
+ format_func=fmt,
140
+ key=f"cell_{cell_idx}_xcol",
141
+ )
142
+ with c2:
143
+ y_col = st.selectbox(
144
+ "Y column",
145
+ options,
146
+ index=min(1, len(options) - 1),
147
+ format_func=fmt,
148
+ key=f"cell_{cell_idx}_ycol",
149
+ )
150
+ elif options:
151
+ x_col = y_col = options[0]
152
+ st.caption("Only one column available — used for both axes.")
153
+ else:
154
+ st.caption("1-D array — auto-indexed on x axis.")
155
+
156
+ elif plot_type in _BOX_TYPES:
157
+ if isinstance(data, pd.DataFrame):
158
+ cols = _numeric_cols(data)
159
+ st.caption(f"Box plot will show all {len(cols)} numeric column(s).")
160
+ # no column picker — uses whole dataset
161
+
162
+ else: # _MATRIX_TYPES
163
+ st.caption("Uses the full dataset matrix — no column selection needed.")
164
+
165
+ return x_col, y_col
166
+
167
+
168
+ def _render_cell_plot(cfg: dict) -> None:
169
+ """Dispatch to the correct plotting function and display inside the current column.
170
+
171
+ Parameters
172
+ ----------
173
+ cfg : dict
174
+ Keys: 'dataset', 'plot_type', 'x_col', 'y_col', 'cell_title'
175
+ """
176
+ data = get_dataset(cfg["dataset"])
177
+ if data is None:
178
+ st.error(f"Dataset '{cfg['dataset']}' not found.")
179
+ return
180
+
181
+ pt = cfg["plot_type"]
182
+ x_col = cfg.get("x_col")
183
+ y_col = cfg.get("y_col")
184
+ title = cfg.get("cell_title") or f"{PLOT_TYPES[pt]['label']} — {cfg['dataset']}"
185
+
186
+ try:
187
+ # ── Histogram ────────────────────────────────────────────────────────
188
+ if pt == "histogram":
189
+ arr = _to_1d(data, x_col) if x_col is not None else _auto_1d(data)
190
+ fig, _, __ = histogram(arr, title=title, figsize=_CELL_FIGSIZE)
191
+ st.pyplot(fig)
192
+
193
+ # ── Line Plot ────────────────────────────────────────────────────────
194
+ elif pt == "line_plot":
195
+ if x_col is not None and y_col is not None:
196
+ x = _to_1d(data, x_col)
197
+ y = [_to_1d(data, y_col)]
198
+ lbl = [str(y_col)]
199
+ xlab = str(x_col)
200
+ else:
201
+ arr = _auto_1d(data)
202
+ x = np.arange(len(arr))
203
+ y = [arr]
204
+ lbl = ["Data"]
205
+ xlab = "Index"
206
+ fig, _ = line_plot(x, y, labels=lbl, xlabel=xlab, title=title, figsize=_CELL_FIGSIZE)
207
+ st.pyplot(fig)
208
+
209
+ # ── Scatter Plot ─────────────────────────────────────────────────────
210
+ elif pt == "scatter_plot":
211
+ x = _to_1d(data, x_col) if x_col is not None else _auto_1d(data)
212
+ y = _to_1d(data, y_col) if y_col is not None else _auto_1d(data)
213
+ fig, _ = scatter_plot(
214
+ x,
215
+ y,
216
+ xlabel=str(x_col or "x"),
217
+ ylabel=str(y_col or "y"),
218
+ title=title,
219
+ figsize=_CELL_FIGSIZE,
220
+ )
221
+ st.pyplot(fig)
222
+
223
+ # ── Heatmap ──────────────────────────────────────────────────────────
224
+ elif pt == "heatmap":
225
+ if isinstance(data, pd.DataFrame):
226
+ cols = _numeric_cols(data)
227
+ mat = data[cols].values.astype(float)
228
+ else:
229
+ mat = np.asarray(data, dtype=float)
230
+ if mat.ndim != 2:
231
+ st.error("Heatmap requires a 2-D array.")
232
+ return
233
+ fig, _ = heatmap(mat, title=title, figsize=_CELL_FIGSIZE)
234
+ st.pyplot(fig)
235
+
236
+ # ── Contour Plot ─────────────────────────────────────────────────────
237
+ elif pt == "contour_plot":
238
+ Z = np.asarray(data, dtype=float)
239
+ if Z.ndim != 2:
240
+ st.error("Contour plot requires a 2-D array.")
241
+ return
242
+ nrows_z, ncols_z = Z.shape
243
+ X, Y = np.meshgrid(np.arange(ncols_z), np.arange(nrows_z))
244
+ fig, _ = contour_plot(X, Y, Z, title=title, figsize=_CELL_FIGSIZE)
245
+ st.pyplot(fig)
246
+
247
+ # ── Distribution Plot (Seaborn) ───────────────────────────────────────
248
+ elif pt == "distribution_plot":
249
+ arr = _to_1d(data, x_col) if x_col is not None else _auto_1d(data)
250
+ fig, _ = distribution_plot(arr, title=title)
251
+ st.pyplot(fig)
252
+
253
+ # ── Box Plot (Seaborn) ────────────────────────────────────────────────
254
+ elif pt == "box_plot":
255
+ if isinstance(data, pd.DataFrame):
256
+ fig, _ = box_plot(data, title=title)
257
+ else:
258
+ fig, _ = box_plot(np.asarray(data, dtype=float).flatten(), title=title)
259
+ st.pyplot(fig)
260
+
261
+ # ── Regression Plot (Seaborn) ─────────────────────────────────────────
262
+ elif pt == "regression_plot":
263
+ if isinstance(data, pd.DataFrame) and isinstance(x_col, str):
264
+ fig, _ = regression_plot(x=x_col, y=y_col, data=data, title=title)
265
+ else:
266
+ x = _to_1d(data, x_col) if x_col is not None else _auto_1d(data)
267
+ y = _to_1d(data, y_col) if y_col is not None else _auto_1d(data)
268
+ fig, _ = regression_plot(x=x, y=y, title=title)
269
+ st.pyplot(fig)
270
+
271
+ # ── Interactive Histogram (Plotly) ────────────────────────────────────
272
+ elif pt == "interactive_histogram":
273
+ arr = _to_1d(data, x_col) if x_col is not None else _auto_1d(data)
274
+ fig = interactive_histogram(arr, title=title)
275
+ st.plotly_chart(fig, width="stretch")
276
+
277
+ # ── Interactive Scatter (Plotly) ──────────────────────────────────────
278
+ elif pt == "interactive_scatter":
279
+ x = _to_1d(data, x_col) if x_col is not None else _auto_1d(data)
280
+ y = _to_1d(data, y_col) if y_col is not None else _auto_1d(data)
281
+ fig = interactive_scatter(
282
+ x, y, xlabel=str(x_col or "x"), ylabel=str(y_col or "y"), title=title
283
+ )
284
+ st.plotly_chart(fig, width="stretch")
285
+
286
+ # ── Interactive Line (Plotly) ─────────────────────────────────────────
287
+ elif pt == "interactive_line":
288
+ if x_col is not None and y_col is not None:
289
+ x = _to_1d(data, x_col)
290
+ y = [_to_1d(data, y_col)]
291
+ lbl = [str(y_col)]
292
+ xlab = str(x_col)
293
+ else:
294
+ arr = _auto_1d(data)
295
+ x = np.arange(len(arr))
296
+ y = [arr]
297
+ lbl = ["Data"]
298
+ xlab = "Index"
299
+ fig = interactive_line(x, y, labels=lbl, xlabel=xlab, title=title)
300
+ st.plotly_chart(fig, width="stretch")
301
+
302
+ # ── Interactive Heatmap (Plotly) ──────────────────────────────────────
303
+ elif pt == "interactive_heatmap":
304
+ if isinstance(data, pd.DataFrame):
305
+ cols = _numeric_cols(data)
306
+ mat = data[cols].values.astype(float)
307
+ else:
308
+ mat = np.asarray(data, dtype=float)
309
+ if mat.ndim != 2:
310
+ st.error("Interactive heatmap requires a 2-D array.")
311
+ return
312
+ fig = interactive_heatmap(mat, title=title)
313
+ st.plotly_chart(fig, width="stretch")
314
+
315
+ # ── 3D Surface (Plotly) ───────────────────────────────────────────────
316
+ elif pt == "interactive_3d_surface":
317
+ Z = np.asarray(data, dtype=float)
318
+ if Z.ndim != 2:
319
+ st.error("3D Surface requires a 2-D array.")
320
+ return
321
+ nrows_z, ncols_z = Z.shape
322
+ X, Y = np.meshgrid(np.arange(ncols_z), np.arange(nrows_z))
323
+ fig = interactive_3d_surface(X, Y, Z, title=title)
324
+ st.plotly_chart(fig, width="stretch")
325
+
326
+ else:
327
+ st.error(f"Unhandled plot type: {pt}")
328
+
329
+ except Exception as exc:
330
+ st.error(f"Error: {exc}")
331
+ with st.expander("Details"):
332
+ st.code(traceback.format_exc())
333
+
334
+
335
+ # ── M16 helpers ───────────────────────────────────────────────────────────────
336
+
337
+
338
+ def _generate_cell_fig(cfg: dict):
339
+ """Return ``(fig, is_plotly)`` for a cell config without displaying.
340
+
341
+ Used by the combined export and the axis-sharing pass.
342
+ Returns ``(None, False)`` on error or unsupported type.
343
+ """
344
+ data = get_dataset(cfg["dataset"])
345
+ if data is None:
346
+ return None, False
347
+
348
+ pt = cfg["plot_type"]
349
+ x_col = cfg.get("x_col")
350
+ y_col = cfg.get("y_col")
351
+ title = cfg.get("cell_title") or f"{PLOT_TYPES[pt]['label']} — {cfg['dataset']}"
352
+
353
+ try:
354
+ if pt == "histogram":
355
+ arr = _to_1d(data, x_col) if x_col is not None else _auto_1d(data)
356
+ fig, _, __ = histogram(arr, title=title, figsize=_CELL_FIGSIZE)
357
+ return fig, False
358
+
359
+ elif pt == "line_plot":
360
+ if x_col is not None and y_col is not None:
361
+ x = _to_1d(data, x_col)
362
+ y = [_to_1d(data, y_col)]
363
+ lbl = [str(y_col)]
364
+ xlab = str(x_col)
365
+ else:
366
+ arr = _auto_1d(data)
367
+ x = np.arange(len(arr))
368
+ y = [arr]
369
+ lbl = ["Data"]
370
+ xlab = "Index"
371
+ fig, _ = line_plot(
372
+ x,
373
+ y,
374
+ labels=lbl,
375
+ xlabel=xlab,
376
+ title=title,
377
+ figsize=_CELL_FIGSIZE,
378
+ )
379
+ return fig, False
380
+
381
+ elif pt == "scatter_plot":
382
+ x = _to_1d(data, x_col) if x_col is not None else _auto_1d(data)
383
+ y = _to_1d(data, y_col) if y_col is not None else _auto_1d(data)
384
+ fig, _ = scatter_plot(
385
+ x,
386
+ y,
387
+ xlabel=str(x_col or "x"),
388
+ ylabel=str(y_col or "y"),
389
+ title=title,
390
+ figsize=_CELL_FIGSIZE,
391
+ )
392
+ return fig, False
393
+
394
+ elif pt == "heatmap":
395
+ if isinstance(data, pd.DataFrame):
396
+ cols = _numeric_cols(data)
397
+ mat = data[cols].values.astype(float)
398
+ else:
399
+ mat = np.asarray(data, dtype=float)
400
+ if mat.ndim != 2:
401
+ return None, False
402
+ fig, _ = heatmap(mat, title=title, figsize=_CELL_FIGSIZE)
403
+ return fig, False
404
+
405
+ elif pt == "contour_plot":
406
+ Z = np.asarray(data, dtype=float)
407
+ if Z.ndim != 2:
408
+ return None, False
409
+ nrows_z, ncols_z = Z.shape
410
+ X, Y = np.meshgrid(np.arange(ncols_z), np.arange(nrows_z))
411
+ fig, _ = contour_plot(X, Y, Z, title=title, figsize=_CELL_FIGSIZE)
412
+ return fig, False
413
+
414
+ elif pt == "distribution_plot":
415
+ arr = _to_1d(data, x_col) if x_col is not None else _auto_1d(data)
416
+ fig, _ = distribution_plot(arr, title=title)
417
+ return fig, False
418
+
419
+ elif pt == "box_plot":
420
+ if isinstance(data, pd.DataFrame):
421
+ fig, _ = box_plot(data, title=title)
422
+ else:
423
+ fig, _ = box_plot(np.asarray(data, dtype=float).flatten(), title=title)
424
+ return fig, False
425
+
426
+ elif pt == "regression_plot":
427
+ if isinstance(data, pd.DataFrame) and isinstance(x_col, str):
428
+ fig, _ = regression_plot(x=x_col, y=y_col, data=data, title=title)
429
+ else:
430
+ x = _to_1d(data, x_col) if x_col is not None else _auto_1d(data)
431
+ y = _to_1d(data, y_col) if y_col is not None else _auto_1d(data)
432
+ fig, _ = regression_plot(x=x, y=y, title=title)
433
+ return fig, False
434
+
435
+ elif pt in {
436
+ "interactive_histogram",
437
+ "interactive_scatter",
438
+ "interactive_line",
439
+ "interactive_heatmap",
440
+ "interactive_3d_surface",
441
+ }:
442
+ return None, True # Plotly — not included in combined export
443
+
444
+ return None, False
445
+
446
+ except Exception: # noqa: BLE001
447
+ return None, False
448
+
449
+
450
+ def _make_combined_figure(
451
+ cell_figs: list,
452
+ cell_labels: list,
453
+ nrows: int,
454
+ ncols: int,
455
+ fig_title: str = "",
456
+ ):
457
+ """Create a combined grid matplotlib figure from individual cell figures.
458
+
459
+ Each cell is rendered to an RGBA image and placed into a subplot using
460
+ ``imshow``. Plotly cells (None entries) are left blank.
461
+ """
462
+ import io as _io
463
+ import matplotlib.pyplot as plt
464
+
465
+ combined_w = ncols * 5.0
466
+ combined_h = nrows * 4.0 + (0.6 if fig_title else 0.0)
467
+ combined_fig, axes_grid = plt.subplots(nrows, ncols, figsize=(combined_w, combined_h))
468
+
469
+ # Normalise axes_grid to always be a 2-D array
470
+ if nrows == 1 and ncols == 1:
471
+ axes_grid = np.array([[axes_grid]])
472
+ elif nrows == 1:
473
+ axes_grid = axes_grid.reshape(1, -1)
474
+ elif ncols == 1:
475
+ axes_grid = axes_grid.reshape(-1, 1)
476
+
477
+ for idx in range(nrows * ncols):
478
+ row = idx // ncols
479
+ col = idx % ncols
480
+ ax = axes_grid[row, col]
481
+
482
+ fig = cell_figs[idx] if idx < len(cell_figs) else None
483
+ lbl = cell_labels[idx] if idx < len(cell_labels) else ""
484
+
485
+ if fig is None:
486
+ ax.set_visible(False)
487
+ continue
488
+
489
+ # Render cell figure to RGBA bytes
490
+ buf = _io.BytesIO()
491
+ fig.savefig(buf, format="png", dpi=150, bbox_inches="tight")
492
+ buf.seek(0)
493
+ img = plt.imread(buf)
494
+
495
+ ax.imshow(img)
496
+ ax.axis("off")
497
+ if lbl:
498
+ ax.set_title(lbl, fontsize=9, pad=3)
499
+
500
+ if fig_title:
501
+ combined_fig.suptitle(fig_title, fontsize=14, fontweight="bold", y=1.01)
502
+
503
+ combined_fig.tight_layout()
504
+ return combined_fig
505
+
506
+
507
+ def _apply_axis_sharing(
508
+ cell_figs: list,
509
+ nrows: int,
510
+ ncols: int,
511
+ sharex_row: bool,
512
+ sharey_col: bool,
513
+ ) -> None:
514
+ """Sync x-axis limits across each row and/or y-axis limits down each column.
515
+
516
+ Operates in-place on the figures stored in *cell_figs*.
517
+ Only matplotlib figures are affected; None / Plotly entries are skipped.
518
+ """
519
+ if not (sharex_row or sharey_col):
520
+ return
521
+
522
+ def _get_ax(fig):
523
+ return fig.axes[0] if (fig is not None and fig.axes) else None
524
+
525
+ if sharex_row:
526
+ for r in range(nrows):
527
+ row_axes = [
528
+ _get_ax(cell_figs[r * ncols + c])
529
+ for c in range(ncols)
530
+ if r * ncols + c < len(cell_figs)
531
+ ]
532
+ row_axes = [a for a in row_axes if a is not None]
533
+ if len(row_axes) < 2:
534
+ continue
535
+ xlims = [a.get_xlim() for a in row_axes]
536
+ xmin = min(lo for lo, _ in xlims)
537
+ xmax = max(hi for _, hi in xlims)
538
+ for a in row_axes:
539
+ a.set_xlim(xmin, xmax)
540
+
541
+ if sharey_col:
542
+ for c in range(ncols):
543
+ col_axes = [
544
+ _get_ax(cell_figs[r * ncols + c])
545
+ for r in range(nrows)
546
+ if r * ncols + c < len(cell_figs)
547
+ ]
548
+ col_axes = [a for a in col_axes if a is not None]
549
+ if len(col_axes) < 2:
550
+ continue
551
+ ylims = [a.get_ylim() for a in col_axes]
552
+ ymin = min(lo for lo, _ in ylims)
553
+ ymax = max(hi for _, hi in ylims)
554
+ for a in col_axes:
555
+ a.set_ylim(ymin, ymax)
556
+
557
+
558
+ # ── Page layout ───────────────────────────────────────────────────────────────
559
+
560
+ st.title("Multi-Plot Dashboard")
561
+ st.caption(
562
+ "Arrange multiple independent plots in a grid for side-by-side comparison. "
563
+ "Each cell can use a different dataset, plot type, and column selection."
564
+ )
565
+
566
+ # Guard: need at least one dataset
567
+ summary = get_session_summary()
568
+ if summary["num_datasets"] == 0:
569
+ st.warning("No datasets loaded. Go to **Data Upload** first.")
570
+ st.stop()
571
+
572
+ # ── Layout selection ──────────────────────────────────────────────────────────
573
+ st.markdown("---")
574
+ st.markdown("## 1. Layout")
575
+
576
+ layout_choice = st.radio(
577
+ "Grid dimensions (rows × columns)",
578
+ list(LAYOUTS.keys()),
579
+ index=2, # default: 2 × 1
580
+ horizontal=True,
581
+ key="dash_layout",
582
+ )
583
+ nrows, ncols = LAYOUTS[layout_choice]
584
+ n_cells = nrows * ncols
585
+ st.caption(f"{n_cells} plot cell(s) — {nrows} row(s) × {ncols} column(s).")
586
+
587
+ # ── Figure-level settings (M16) ───────────────────────────────────────────────
588
+ with st.expander("Figure settings", expanded=False):
589
+ fig_title = st.text_input(
590
+ "Figure title (optional — shown on combined export)",
591
+ key="dash_fig_title",
592
+ )
593
+ _sc1, _sc2 = st.columns(2)
594
+ sharex_row = _sc1.checkbox(
595
+ "Share X-axis across each row",
596
+ value=False,
597
+ key="dash_sharex",
598
+ help="Sync x-axis limits for all Matplotlib panels in the same row.",
599
+ )
600
+ sharey_col = _sc2.checkbox(
601
+ "Share Y-axis down each column",
602
+ value=False,
603
+ key="dash_sharey",
604
+ help="Sync y-axis limits for all Matplotlib panels in the same column.",
605
+ )
606
+
607
+ # ── Cell configuration ────────────────────────────────────────────────────────
608
+ st.markdown("---")
609
+ st.markdown("## 2. Cell Configuration")
610
+ st.markdown("Configure each cell below. Cells are numbered left-to-right, top-to-bottom.")
611
+
612
+ dataset_names = list(st.session_state.datasets.keys())
613
+
614
+ # Lay out cell config expanders in a 2-column UI grid (unrelated to plot grid)
615
+ cell_cfgs: list = []
616
+ ui_cols = st.columns(min(n_cells, 2))
617
+
618
+ for i in range(n_cells):
619
+ with ui_cols[i % 2]:
620
+ with st.expander(f"**Cell {i + 1}**", expanded=True):
621
+ # Dataset selector — default cycles through loaded datasets
622
+ default_ds_idx = min(i, len(dataset_names) - 1)
623
+ ds_name = st.selectbox(
624
+ "Dataset",
625
+ dataset_names,
626
+ index=default_ds_idx,
627
+ key=f"cell_{i}_ds",
628
+ )
629
+ cell_data = get_dataset(ds_name)
630
+
631
+ # Plot type selector
632
+ pt = st.selectbox(
633
+ "Plot type",
634
+ list(PLOT_TYPES.keys()),
635
+ format_func=lambda k: f"{PLOT_TYPES[k]['label']} ({PLOT_TYPES[k]['category']})",
636
+ key=f"cell_{i}_pt",
637
+ )
638
+
639
+ # Column selectors (context-aware)
640
+ x_col, y_col = _cell_col_widgets(i, pt, cell_data)
641
+
642
+ # Optional cell title
643
+ cell_title = st.text_input(
644
+ "Title (optional)",
645
+ placeholder=f"{PLOT_TYPES[pt]['label']} — {ds_name}",
646
+ key=f"cell_{i}_title",
647
+ )
648
+
649
+ cell_cfgs.append(
650
+ {
651
+ "dataset": ds_name,
652
+ "plot_type": pt,
653
+ "x_col": x_col,
654
+ "y_col": y_col,
655
+ "cell_title": cell_title,
656
+ }
657
+ )
658
+
659
+ # ── Generate ──────────────────────────────────────────────────────────────────
660
+ st.markdown("---")
661
+ if st.button("Generate Dashboard", type="primary", key="dash_generate"):
662
+ st.markdown("## Dashboard")
663
+ with st.spinner("Generating…"):
664
+ # 1. Generate all cell figures first (needed for axis sharing + export)
665
+ all_figs: list = []
666
+ all_is_plotly: list = []
667
+ for cfg in cell_cfgs:
668
+ fig, is_plotly = _generate_cell_fig(cfg)
669
+ all_figs.append(fig)
670
+ all_is_plotly.append(is_plotly)
671
+
672
+ # 2. Apply axis sharing across matplotlib figures
673
+ _apply_axis_sharing(all_figs, nrows, ncols, sharex_row, sharey_col)
674
+
675
+ # 3. Display in grid
676
+ cell_idx = 0
677
+ for row_idx in range(nrows):
678
+ grid_cols = st.columns(ncols)
679
+ for col_idx in range(ncols):
680
+ with grid_cols[col_idx]:
681
+ cfg = cell_cfgs[cell_idx]
682
+ label = cfg["cell_title"] or (
683
+ f"{PLOT_TYPES[cfg['plot_type']]['label']} — {cfg['dataset']}"
684
+ )
685
+ st.caption(f"**Cell {cell_idx + 1}** · {label}")
686
+ fig = all_figs[cell_idx]
687
+ is_plotly = all_is_plotly[cell_idx]
688
+ if fig is None:
689
+ # Fallback: let _render_cell_plot show the error
690
+ _render_cell_plot(cfg)
691
+ elif is_plotly:
692
+ _render_cell_plot(cfg)
693
+ else:
694
+ st.pyplot(fig)
695
+ cell_idx += 1
696
+
697
+ # 4. Combined export (M16) ────────────────────────────────────────────────
698
+ mpl_figs = [f for f, ip in zip(all_figs, all_is_plotly) if f is not None and not ip]
699
+ if mpl_figs:
700
+ st.markdown("---")
701
+ with st.expander("Export combined figure"):
702
+ import io as _io
703
+ import matplotlib.pyplot as plt
704
+
705
+ cell_labels_export = [
706
+ (cfg["cell_title"] or f"{PLOT_TYPES[cfg['plot_type']]['label']} — {cfg['dataset']}")
707
+ for cfg, ip in zip(cell_cfgs, all_is_plotly)
708
+ if not ip and _generate_cell_fig(cfg)[0] is not None
709
+ ]
710
+
711
+ _exp_title = st.session_state.get("dash_fig_title", "")
712
+
713
+ _ecol1, _ecol2 = st.columns(2)
714
+
715
+ # ── Combined PNG (grid image) ────────────────────────────────────
716
+ with _ecol1:
717
+ st.markdown("**Combined PNG (grid image)**")
718
+ _dpi = st.select_slider(
719
+ "Resolution",
720
+ options=[150, 200, 300],
721
+ value=200,
722
+ key="dash_export_dpi",
723
+ )
724
+ if st.button("Generate combined PNG", key="dash_gen_png"):
725
+ with st.spinner("Composing grid…"):
726
+ _combined = _make_combined_figure(
727
+ all_figs,
728
+ [
729
+ cfg["cell_title"]
730
+ or (f"{PLOT_TYPES[cfg['plot_type']]['label']} — {cfg['dataset']}")
731
+ for cfg in cell_cfgs
732
+ ],
733
+ nrows,
734
+ ncols,
735
+ fig_title=_exp_title,
736
+ )
737
+ _png_buf = _io.BytesIO()
738
+ _combined.savefig(
739
+ _png_buf,
740
+ format="png",
741
+ dpi=_dpi,
742
+ bbox_inches="tight",
743
+ )
744
+ plt.close(_combined)
745
+ _png_buf.seek(0)
746
+ st.download_button(
747
+ f"Download PNG ({_dpi} dpi)",
748
+ data=_png_buf,
749
+ file_name="dashboard.png",
750
+ mime="image/png",
751
+ key="dash_dl_png",
752
+ )
753
+
754
+ # ── Multi-page PDF ───────────────────────────────────────────────
755
+ with _ecol2:
756
+ st.markdown("**Multi-page PDF (one panel per page)**")
757
+ if st.button("Generate PDF", key="dash_gen_pdf"):
758
+ with st.spinner("Building PDF…"):
759
+ from matplotlib.backends.backend_pdf import PdfPages
760
+
761
+ _pdf_buf = _io.BytesIO()
762
+ with PdfPages(_pdf_buf) as _pdf:
763
+ if _exp_title:
764
+ _title_fig = plt.figure(figsize=(8, 2))
765
+ _title_fig.text(
766
+ 0.5,
767
+ 0.5,
768
+ _exp_title,
769
+ ha="center",
770
+ va="center",
771
+ fontsize=18,
772
+ fontweight="bold",
773
+ )
774
+ _pdf.savefig(_title_fig, bbox_inches="tight")
775
+ plt.close(_title_fig)
776
+ for cfg, fig, is_plotly in zip(cell_cfgs, all_figs, all_is_plotly):
777
+ if fig is None or is_plotly:
778
+ continue
779
+ cell_lbl = cfg["cell_title"] or (
780
+ f"{PLOT_TYPES[cfg['plot_type']]['label']} — {cfg['dataset']}"
781
+ )
782
+ fig.suptitle(cell_lbl, fontsize=11)
783
+ _pdf.savefig(fig, bbox_inches="tight")
784
+ _pdf_buf.seek(0)
785
+ st.download_button(
786
+ "Download PDF",
787
+ data=_pdf_buf,
788
+ file_name="dashboard.pdf",
789
+ mime="application/pdf",
790
+ key="dash_dl_pdf",
791
+ )