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.
- eagleplot/Home.py +362 -0
- eagleplot/__init__.py +19 -0
- eagleplot/annotations.py +215 -0
- eagleplot/assets/logo.ico +0 -0
- eagleplot/assets/logo.png +0 -0
- eagleplot/assets/nccu-horiz-logo.png +0 -0
- eagleplot/assets/nccu-wings.png +0 -0
- eagleplot/batch.py +446 -0
- eagleplot/cli.py +719 -0
- eagleplot/data_tools.py +849 -0
- eagleplot/example-data/Artificial/bimodal_distribution.csv +501 -0
- eagleplot/example-data/Artificial/correlation_matrix.npy +0 -0
- eagleplot/example-data/Artificial/gaussian_surface.npy +0 -0
- eagleplot/example-data/Artificial/grouped_categorical.csv +241 -0
- eagleplot/example-data/Artificial/ir_spectrum.csv +1802 -0
- eagleplot/example-data/Artificial/molecular_properties.csv +121 -0
- eagleplot/example-data/Artificial/normal_distribution.csv +401 -0
- eagleplot/example-data/Artificial/reaction_kinetics.csv +121 -0
- eagleplot/example-data/Artificial/scatter_correlation.csv +201 -0
- eagleplot/example-data/Artificial/sine_cosine_waves.csv +301 -0
- eagleplot/gallery/box_plot.png +0 -0
- eagleplot/gallery/contour_plot.png +0 -0
- eagleplot/gallery/distribution_plot.png +0 -0
- eagleplot/gallery/heatmap.png +0 -0
- eagleplot/gallery/histogram.png +0 -0
- eagleplot/gallery/interactive_3d_surface.png +0 -0
- eagleplot/gallery/interactive_heatmap.png +0 -0
- eagleplot/gallery/interactive_histogram.png +0 -0
- eagleplot/gallery/interactive_line.png +0 -0
- eagleplot/gallery/interactive_scatter.png +0 -0
- eagleplot/gallery/line_plot.png +0 -0
- eagleplot/gallery/manifest.json +196 -0
- eagleplot/gallery/regression_plot.png +0 -0
- eagleplot/gallery/scatter_plot.png +0 -0
- eagleplot/io.py +1008 -0
- eagleplot/math.py +1210 -0
- eagleplot/molecular/__init__.py +21 -0
- eagleplot/molecular/atom_data.py +287 -0
- eagleplot/molecular/parsers.py +747 -0
- eagleplot/nist.py +241 -0
- eagleplot/pages/10_Molecular_Viz.py +225 -0
- eagleplot/pages/11_Batch_Analysis.py +325 -0
- eagleplot/pages/12_Help.py +199 -0
- eagleplot/pages/13_Settings.py +388 -0
- eagleplot/pages/1_Data_Upload.py +521 -0
- eagleplot/pages/2_Quick_Plot.py +1693 -0
- eagleplot/pages/3_Analysis_Tools.py +2510 -0
- eagleplot/pages/4_Multi_Plot_Dashboard.py +791 -0
- eagleplot/pages/5_Advanced_Plotting.py +691 -0
- eagleplot/pages/6_Export_Results.py +442 -0
- eagleplot/pages/7_Gallery.py +201 -0
- eagleplot/pages/8_Data_Tools.py +434 -0
- eagleplot/pages/9_Spectroscopy.py +821 -0
- eagleplot/pages/__init__.py +8 -0
- eagleplot/peaks.py +513 -0
- eagleplot/plotting.py +2630 -0
- eagleplot/plugin_loader.py +208 -0
- eagleplot/report.py +448 -0
- eagleplot/signal.py +494 -0
- eagleplot/spectroscopy.py +830 -0
- eagleplot/utils/__init__.py +76 -0
- eagleplot/utils/access.py +77 -0
- eagleplot/utils/data_preview.py +352 -0
- eagleplot/utils/plot_config.py +2721 -0
- eagleplot/utils/session_state.py +535 -0
- eagleplot/utils/theming.py +312 -0
- eagleplot/utils/user_settings.py +294 -0
- eagleplot-3.0.0.dist-info/METADATA +304 -0
- eagleplot-3.0.0.dist-info/RECORD +73 -0
- eagleplot-3.0.0.dist-info/WHEEL +5 -0
- eagleplot-3.0.0.dist-info/entry_points.txt +2 -0
- eagleplot-3.0.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
)
|