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,691 @@
1
+ """Advanced Plotting Page.
2
+
3
+ Provides visualizations that go beyond the 13 core eagleplot.plotting functions
4
+ by using seaborn, plotly.graph_objects, and matplotlib directly.
5
+
6
+ Tabs
7
+ ----
8
+ - Correlation Heatmap: annotated correlation matrix (DataFrame only)
9
+ - Overlaid Distributions: histogram or KDE overlay for multiple columns
10
+ - Grouped Categorical: box / violin / swarm with optional hue grouping
11
+ - 3D Scatter: Plotly interactive 3-D scatter with color encoding
12
+ - HTML Export: generate any interactive Plotly chart + download HTML
13
+ """
14
+
15
+ import traceback
16
+ from pathlib import Path
17
+ import sys
18
+
19
+ import streamlit as st
20
+ import numpy as np
21
+ import pandas as pd
22
+ import matplotlib.pyplot as plt
23
+ import seaborn as sns
24
+ import plotly.graph_objects as go
25
+
26
+ sys.path.insert(0, str(Path(__file__).parent.parent.parent))
27
+
28
+ from eagleplot.plotting import (
29
+ interactive_histogram,
30
+ interactive_scatter,
31
+ interactive_line,
32
+ interactive_heatmap,
33
+ interactive_3d_surface,
34
+ )
35
+ from eagleplot.utils import (
36
+ initialize_session_state,
37
+ get_dataset,
38
+ get_session_summary,
39
+ )
40
+
41
+ initialize_session_state()
42
+
43
+
44
+ # ── Constants ─────────────────────────────────────────────────────────────────
45
+
46
+ _CMAPS_DIV = ["RdBu_r", "coolwarm", "PiYG", "BrBG", "seismic"]
47
+ _CMAPS_SEQ = ["viridis", "plasma", "magma", "cividis", "Blues", "Greens"]
48
+ _CORR_METHODS = ["pearson", "spearman", "kendall"]
49
+
50
+ # Interactive plot types available for HTML export
51
+ _INTERACTIVE_TYPES = {
52
+ "interactive_histogram": "Interactive Histogram",
53
+ "interactive_scatter": "Interactive Scatter",
54
+ "interactive_line": "Interactive Line",
55
+ "interactive_heatmap": "Interactive Heatmap",
56
+ "interactive_3d_surface": "3D Surface",
57
+ }
58
+
59
+
60
+ # ── Helpers ───────────────────────────────────────────────────────────────────
61
+
62
+
63
+ def _numeric_cols(data) -> list:
64
+ if isinstance(data, pd.DataFrame):
65
+ return data.select_dtypes(include=[np.number]).columns.tolist()
66
+ return []
67
+
68
+
69
+ def _all_cols(data) -> list:
70
+ """All column names (DataFrame) or column indices (2-D array)."""
71
+ if isinstance(data, pd.DataFrame):
72
+ return list(data.columns)
73
+ arr = np.asarray(data)
74
+ if arr.ndim == 2:
75
+ return list(range(arr.shape[1]))
76
+ return []
77
+
78
+
79
+ def _to_1d(data, col_ref) -> np.ndarray:
80
+ if isinstance(data, pd.DataFrame):
81
+ return data[col_ref].dropna().values.astype(float)
82
+ arr = np.asarray(data, dtype=float)
83
+ if col_ref is None:
84
+ return arr.flatten()
85
+ return arr[:, int(col_ref)]
86
+
87
+
88
+ def _df_required(data) -> bool:
89
+ """Return True and show error if data is not a DataFrame."""
90
+ if not isinstance(data, pd.DataFrame):
91
+ st.warning(
92
+ "This feature requires a **DataFrame** (CSV, Excel, JSON, etc.). "
93
+ "The current dataset is a NumPy array."
94
+ )
95
+ return False
96
+ return True
97
+
98
+
99
+ def _html_download(fig, filename: str = "interactive_plot.html") -> None:
100
+ """Render an HTML download button for a Plotly figure."""
101
+ html_str = fig.to_html(full_html=True, include_plotlyjs=True)
102
+ st.download_button(
103
+ label="Download as HTML",
104
+ data=html_str,
105
+ file_name=filename,
106
+ mime="text/html",
107
+ help="Standalone HTML file — opens in any browser without Python.",
108
+ )
109
+
110
+
111
+ # ── Page layout ───────────────────────────────────────────────────────────────
112
+
113
+ st.title("Advanced Plotting")
114
+ st.caption(
115
+ "Annotated correlation matrices, overlaid distributions, grouped categoricals, "
116
+ "3D scatter, and HTML export."
117
+ )
118
+
119
+ # Guard: need at least one dataset
120
+ summary = get_session_summary()
121
+ if summary["num_datasets"] == 0:
122
+ st.warning("No datasets loaded. Go to **Data Upload** first.")
123
+ st.stop()
124
+
125
+ # ── Dataset selector ──────────────────────────────────────────────────────────
126
+ st.markdown("---")
127
+ st.markdown("## Dataset")
128
+
129
+ dataset_names = list(st.session_state.datasets.keys())
130
+ default_idx = (
131
+ dataset_names.index(summary["current_dataset"])
132
+ if summary["current_dataset"] in dataset_names
133
+ else 0
134
+ )
135
+
136
+ col1, col2 = st.columns([3, 1])
137
+ with col1:
138
+ dataset_name = st.selectbox(
139
+ "Select dataset",
140
+ dataset_names,
141
+ index=default_idx,
142
+ key="adv_dataset",
143
+ )
144
+ with col2:
145
+ st.write("")
146
+ st.write("")
147
+ st.metric("Loaded", summary["num_datasets"])
148
+
149
+ if dataset_name:
150
+ st.session_state.current_dataset = dataset_name
151
+
152
+ data = get_dataset(dataset_name)
153
+
154
+ if isinstance(data, pd.DataFrame):
155
+ st.caption(f"DataFrame — {data.shape[0]} rows × {data.shape[1]} cols")
156
+ elif isinstance(data, np.ndarray):
157
+ st.caption(f"NumPy array — shape {data.shape}, dtype {data.dtype}")
158
+
159
+ # ── Tabs ──────────────────────────────────────────────────────────────────────
160
+ st.markdown("---")
161
+
162
+ tab_corr, tab_dist, tab_cat, tab_3d, tab_export = st.tabs(
163
+ [
164
+ "Correlation Heatmap",
165
+ "Overlaid Distributions",
166
+ "Grouped Categorical",
167
+ "3D Scatter",
168
+ "HTML Export",
169
+ ]
170
+ )
171
+
172
+ # ══════════════════════════════════════════════════════════════════════════════
173
+ # Tab 1 — Correlation Heatmap
174
+ # ══════════════════════════════════════════════════════════════════════════════
175
+ with tab_corr:
176
+ st.markdown("### Annotated Correlation Heatmap")
177
+
178
+ if _df_required(data):
179
+ num_cols = _numeric_cols(data)
180
+ if len(num_cols) < 2:
181
+ st.warning("Need at least 2 numeric columns for a correlation matrix.")
182
+ else:
183
+ # Column subset
184
+ selected_corr_cols = st.multiselect(
185
+ "Columns to include",
186
+ num_cols,
187
+ default=num_cols,
188
+ key="corr_cols",
189
+ )
190
+ if len(selected_corr_cols) < 2:
191
+ st.info("Select at least 2 columns.")
192
+ else:
193
+ c1, c2, c3 = st.columns(3)
194
+ with c1:
195
+ corr_method = st.selectbox(
196
+ "Method",
197
+ _CORR_METHODS,
198
+ key="corr_method",
199
+ )
200
+ with c2:
201
+ corr_cmap = st.selectbox(
202
+ "Color map",
203
+ _CMAPS_DIV,
204
+ key="corr_cmap",
205
+ )
206
+ with c3:
207
+ corr_annot = st.checkbox("Show values", value=True, key="corr_annot")
208
+
209
+ figw = st.slider("Figure width", 4, 16, 8, key="corr_figw")
210
+
211
+ if st.button("Generate Correlation Heatmap", type="primary", key="corr_run"):
212
+ try:
213
+ corr = data[selected_corr_cols].corr(method=corr_method)
214
+ n = len(selected_corr_cols)
215
+ fig, ax = plt.subplots(figsize=(figw, max(4, figw * n // 8)))
216
+ sns.heatmap(
217
+ corr,
218
+ annot=corr_annot,
219
+ fmt=".2f",
220
+ cmap=corr_cmap,
221
+ center=0,
222
+ square=True,
223
+ linewidths=0.5,
224
+ ax=ax,
225
+ vmin=-1,
226
+ vmax=1,
227
+ )
228
+ ax.set_title(
229
+ f"{corr_method.capitalize()} Correlation Matrix — {dataset_name}",
230
+ pad=12,
231
+ )
232
+ fig.tight_layout()
233
+ st.pyplot(fig)
234
+ plt.close(fig)
235
+ except Exception as exc:
236
+ st.error(f"Error: {exc}")
237
+ with st.expander("Details"):
238
+ st.code(traceback.format_exc())
239
+
240
+ # ══════════════════════════════════════════════════════════════════════════════
241
+ # Tab 2 — Overlaid Distributions
242
+ # ══════════════════════════════════════════════════════════════════════════════
243
+ with tab_dist:
244
+ st.markdown("### Overlaid Distributions")
245
+ st.markdown("Compare multiple variables on the same axes.")
246
+
247
+ # Build column options for both DataFrame and 2-D array
248
+ if isinstance(data, pd.DataFrame):
249
+ dist_col_options = _numeric_cols(data)
250
+ dist_col_fmt = str
251
+ elif isinstance(data, np.ndarray) and data.ndim == 2:
252
+ dist_col_options = list(range(data.shape[1]))
253
+ dist_col_fmt = lambda i: f"Column {i}" # noqa: E731
254
+ else:
255
+ dist_col_options = []
256
+ dist_col_fmt = str
257
+
258
+ if len(dist_col_options) < 2:
259
+ st.warning("Need at least 2 columns. Load a DataFrame or a 2-D NumPy array.")
260
+ else:
261
+ dist_cols_sel = st.multiselect(
262
+ "Select columns to overlay",
263
+ dist_col_options,
264
+ default=dist_col_options[: min(4, len(dist_col_options))],
265
+ format_func=dist_col_fmt,
266
+ key="dist_cols",
267
+ )
268
+
269
+ if dist_cols_sel:
270
+ c1, c2, c3 = st.columns(3)
271
+ with c1:
272
+ dist_kind = st.radio(
273
+ "Type",
274
+ ["Histogram", "KDE"],
275
+ horizontal=True,
276
+ key="dist_kind",
277
+ )
278
+ with c2:
279
+ dist_density = st.checkbox("Normalize (density)", value=True, key="dist_density")
280
+ with c3:
281
+ dist_bins = (
282
+ st.slider("Bins", 5, 100, 20, key="dist_bins")
283
+ if dist_kind == "Histogram"
284
+ else None
285
+ )
286
+
287
+ if st.button("Generate Overlaid Distributions", type="primary", key="dist_run"):
288
+ try:
289
+ fig, ax = plt.subplots(figsize=(8, 5))
290
+ palette = sns.color_palette("tab10", n_colors=len(dist_cols_sel))
291
+
292
+ for col, color in zip(dist_cols_sel, palette):
293
+ arr = _to_1d(data, col)
294
+ label = str(col) if not isinstance(col, int) else f"Column {col}"
295
+ if dist_kind == "Histogram":
296
+ ax.hist(
297
+ arr,
298
+ bins=dist_bins,
299
+ density=dist_density,
300
+ alpha=0.55,
301
+ color=color,
302
+ label=label,
303
+ edgecolor="white",
304
+ linewidth=0.5,
305
+ )
306
+ else:
307
+ sns.kdeplot(
308
+ arr,
309
+ ax=ax,
310
+ fill=True,
311
+ alpha=0.35,
312
+ color=color,
313
+ label=label,
314
+ linewidth=2,
315
+ )
316
+
317
+ ax.set_ylabel("Density" if dist_density else "Count")
318
+ ax.set_title(f"Overlaid {dist_kind}s — {dataset_name}", pad=10)
319
+ ax.legend(framealpha=0.8)
320
+ fig.tight_layout()
321
+ st.pyplot(fig)
322
+ plt.close(fig)
323
+ except Exception as exc:
324
+ st.error(f"Error: {exc}")
325
+ with st.expander("Details"):
326
+ st.code(traceback.format_exc())
327
+
328
+ # ══════════════════════════════════════════════════════════════════════════════
329
+ # Tab 3 — Grouped Categorical Plot
330
+ # ══════════════════════════════════════════════════════════════════════════════
331
+ with tab_cat:
332
+ st.markdown("### Grouped Categorical Plot")
333
+ st.markdown(
334
+ "Box, violin, or swarm plot with optional colour grouping (hue). Requires a **DataFrame**."
335
+ )
336
+
337
+ if _df_required(data):
338
+ all_df_cols = list(data.columns)
339
+ num_df_cols = _numeric_cols(data)
340
+
341
+ if not num_df_cols:
342
+ st.warning("No numeric columns available.")
343
+ else:
344
+ c1, c2 = st.columns(2)
345
+ with c1:
346
+ cat_kind = st.selectbox(
347
+ "Plot kind",
348
+ ["Box", "Violin", "Swarm"],
349
+ key="cat_kind",
350
+ )
351
+ with c2:
352
+ cat_palette = st.selectbox(
353
+ "Palette",
354
+ ["tab10", "Set2", "Set3", "pastel", "muted", "deep"],
355
+ key="cat_palette",
356
+ )
357
+
358
+ c1, c2, c3 = st.columns(3)
359
+ with c1:
360
+ cat_x = st.selectbox(
361
+ "X axis (grouping)",
362
+ [None] + all_df_cols,
363
+ format_func=lambda v: "— (none)" if v is None else str(v),
364
+ key="cat_x",
365
+ )
366
+ with c2:
367
+ cat_y = st.selectbox("Y axis (numeric)", num_df_cols, key="cat_y")
368
+ with c3:
369
+ cat_hue = st.selectbox(
370
+ "Hue (optional)",
371
+ [None] + all_df_cols,
372
+ format_func=lambda v: "— (none)" if v is None else str(v),
373
+ key="cat_hue",
374
+ )
375
+
376
+ if cat_kind == "Swarm" and len(data) > 500:
377
+ st.warning(
378
+ f"Dataset has {len(data):,} rows — swarm plot may be slow. "
379
+ "Consider filtering to ≤ 500 rows first."
380
+ )
381
+
382
+ if st.button("Generate Categorical Plot", type="primary", key="cat_run"):
383
+ try:
384
+ fig, ax = plt.subplots(figsize=(9, 5))
385
+ shared_kw = dict(
386
+ data=data,
387
+ x=cat_x,
388
+ y=cat_y,
389
+ hue=cat_hue,
390
+ palette=cat_palette,
391
+ ax=ax,
392
+ )
393
+ if cat_kind == "Box":
394
+ sns.boxplot(**shared_kw)
395
+ elif cat_kind == "Violin":
396
+ sns.violinplot(**shared_kw)
397
+ else:
398
+ sns.swarmplot(**shared_kw, size=4)
399
+
400
+ title_parts = [cat_kind, "plot of", cat_y]
401
+ if cat_x:
402
+ title_parts += ["by", cat_x]
403
+ if cat_hue:
404
+ title_parts += ["(hue:", cat_hue + ")"]
405
+ ax.set_title(" ".join(title_parts), pad=10)
406
+ if cat_hue:
407
+ ax.legend(title=cat_hue, bbox_to_anchor=(1.01, 1), loc="upper left")
408
+ fig.tight_layout()
409
+ st.pyplot(fig)
410
+ plt.close(fig)
411
+ except Exception as exc:
412
+ st.error(f"Error: {exc}")
413
+ with st.expander("Details"):
414
+ st.code(traceback.format_exc())
415
+
416
+ # ══════════════════════════════════════════════════════════════════════════════
417
+ # Tab 4 — 3D Scatter (Plotly)
418
+ # ══════════════════════════════════════════════════════════════════════════════
419
+ with tab_3d:
420
+ st.markdown("### Interactive 3D Scatter Plot")
421
+
422
+ # Build column options
423
+ if isinstance(data, pd.DataFrame):
424
+ scatter3d_options = _numeric_cols(data)
425
+ scatter3d_fmt = str
426
+ all_scatter_opts = list(data.columns)
427
+ elif isinstance(data, np.ndarray) and data.ndim == 2 and data.shape[1] >= 3:
428
+ scatter3d_options = list(range(data.shape[1]))
429
+ scatter3d_fmt = lambda i: f"Column {i}" # noqa: E731
430
+ all_scatter_opts = scatter3d_options
431
+ else:
432
+ scatter3d_options = []
433
+ scatter3d_fmt = str
434
+ all_scatter_opts = []
435
+
436
+ if len(scatter3d_options) < 3:
437
+ st.warning(
438
+ "3D scatter requires at least 3 numeric columns. "
439
+ "Load a DataFrame or a 2-D NumPy array with ≥ 3 columns."
440
+ )
441
+ else:
442
+ c1, c2, c3 = st.columns(3)
443
+ with c1:
444
+ s3d_x = st.selectbox(
445
+ "X axis",
446
+ scatter3d_options,
447
+ index=0,
448
+ format_func=scatter3d_fmt,
449
+ key="s3d_x",
450
+ )
451
+ with c2:
452
+ s3d_y = st.selectbox(
453
+ "Y axis",
454
+ scatter3d_options,
455
+ index=min(1, len(scatter3d_options) - 1),
456
+ format_func=scatter3d_fmt,
457
+ key="s3d_y",
458
+ )
459
+ with c3:
460
+ s3d_z = st.selectbox(
461
+ "Z axis",
462
+ scatter3d_options,
463
+ index=min(2, len(scatter3d_options) - 1),
464
+ format_func=scatter3d_fmt,
465
+ key="s3d_z",
466
+ )
467
+
468
+ c1, c2 = st.columns(2)
469
+ with c1:
470
+ s3d_color = st.selectbox(
471
+ "Color encoding (optional)",
472
+ [None] + list(all_scatter_opts),
473
+ format_func=lambda v: (
474
+ "— (none)"
475
+ if v is None
476
+ else (str(v) if not isinstance(v, int) else f"Column {v}")
477
+ ),
478
+ key="s3d_color",
479
+ )
480
+ with c2:
481
+ s3d_colorscale = st.selectbox(
482
+ "Color scale",
483
+ ["Viridis", "Plasma", "Cividis", "RdBu", "Turbo"],
484
+ key="s3d_colorscale",
485
+ )
486
+
487
+ c1, c2 = st.columns(2)
488
+ with c1:
489
+ s3d_size = st.slider("Marker size", 2, 20, 5, key="s3d_size")
490
+ with c2:
491
+ s3d_opacity = st.slider("Opacity", 0.1, 1.0, 0.8, 0.05, key="s3d_opacity")
492
+
493
+ s3d_title = st.text_input("Title (optional)", key="s3d_title", placeholder="3D Scatter")
494
+
495
+ if st.button("Generate 3D Scatter", type="primary", key="s3d_run"):
496
+ try:
497
+ x_arr = _to_1d(data, s3d_x)
498
+ y_arr = _to_1d(data, s3d_y)
499
+ z_arr = _to_1d(data, s3d_z)
500
+
501
+ color_arr = _to_1d(data, s3d_color) if s3d_color is not None else None
502
+
503
+ x_lbl = str(s3d_x) if not isinstance(s3d_x, int) else f"Column {s3d_x}"
504
+ y_lbl = str(s3d_y) if not isinstance(s3d_y, int) else f"Column {s3d_y}"
505
+ z_lbl = str(s3d_z) if not isinstance(s3d_z, int) else f"Column {s3d_z}"
506
+
507
+ marker = dict(
508
+ size=s3d_size,
509
+ opacity=s3d_opacity,
510
+ colorscale=s3d_colorscale,
511
+ )
512
+ if color_arr is not None:
513
+ marker["color"] = color_arr
514
+ marker["showscale"] = True
515
+ c_lbl = (
516
+ str(s3d_color) if not isinstance(s3d_color, int) else f"Column {s3d_color}"
517
+ )
518
+ marker["colorbar"] = dict(title=c_lbl)
519
+ else:
520
+ marker["color"] = "#636EFA"
521
+
522
+ fig = go.Figure(
523
+ data=[
524
+ go.Scatter3d(
525
+ x=x_arr,
526
+ y=y_arr,
527
+ z=z_arr,
528
+ mode="markers",
529
+ marker=marker,
530
+ )
531
+ ]
532
+ )
533
+ fig.update_layout(
534
+ title=s3d_title or f"3D Scatter — {dataset_name}",
535
+ scene=dict(
536
+ xaxis_title=x_lbl,
537
+ yaxis_title=y_lbl,
538
+ zaxis_title=z_lbl,
539
+ ),
540
+ margin=dict(l=0, r=0, t=40, b=0),
541
+ )
542
+ st.plotly_chart(fig, width="stretch")
543
+ _html_download(fig, filename="3d_scatter.html")
544
+
545
+ except Exception as exc:
546
+ st.error(f"Error: {exc}")
547
+ with st.expander("Details"):
548
+ st.code(traceback.format_exc())
549
+
550
+ # ══════════════════════════════════════════════════════════════════════════════
551
+ # Tab 5 — HTML Export
552
+ # ══════════════════════════════════════════════════════════════════════════════
553
+ with tab_export:
554
+ st.markdown("### Export Interactive Plot as HTML")
555
+ st.markdown(
556
+ "Generate any Plotly interactive chart and download it as a "
557
+ "**standalone HTML file** that works in any browser — no Python required."
558
+ )
559
+
560
+ # Build column options (reused from the current dataset)
561
+ if isinstance(data, pd.DataFrame):
562
+ exp_num_cols = _numeric_cols(data)
563
+ exp_col_fmt = str
564
+ elif isinstance(data, np.ndarray) and data.ndim >= 2:
565
+ exp_num_cols = list(range(data.shape[1]))
566
+ exp_col_fmt = lambda i: f"Column {i}" # noqa: E731
567
+ else:
568
+ exp_num_cols = []
569
+ exp_col_fmt = str
570
+
571
+ exp_type = st.selectbox(
572
+ "Interactive plot type",
573
+ list(_INTERACTIVE_TYPES.keys()),
574
+ format_func=lambda k: _INTERACTIVE_TYPES[k],
575
+ key="exp_type",
576
+ )
577
+
578
+ exp_title = st.text_input(
579
+ "Plot title", key="exp_title", placeholder=_INTERACTIVE_TYPES[exp_type]
580
+ )
581
+
582
+ # Column selectors — shown conditionally
583
+ exp_x = exp_y = exp_data_col = None
584
+ _1d_types = {"interactive_histogram"}
585
+ _xy_types = {"interactive_scatter", "interactive_line"}
586
+ _mat_types = {"interactive_heatmap", "interactive_3d_surface"}
587
+
588
+ if exp_type in _1d_types:
589
+ if exp_num_cols:
590
+ exp_data_col = st.selectbox(
591
+ "Data column",
592
+ exp_num_cols,
593
+ format_func=exp_col_fmt,
594
+ key="exp_dcol",
595
+ )
596
+ elif exp_type in _xy_types:
597
+ if len(exp_num_cols) >= 2:
598
+ c1, c2 = st.columns(2)
599
+ with c1:
600
+ exp_x = st.selectbox(
601
+ "X column",
602
+ exp_num_cols,
603
+ format_func=exp_col_fmt,
604
+ key="exp_xcol",
605
+ )
606
+ with c2:
607
+ exp_y = st.selectbox(
608
+ "Y column",
609
+ exp_num_cols,
610
+ index=min(1, len(exp_num_cols) - 1),
611
+ format_func=exp_col_fmt,
612
+ key="exp_ycol",
613
+ )
614
+ else:
615
+ st.caption("Uses the full dataset matrix (no column selection needed).")
616
+
617
+ if st.button("Generate & Export", type="primary", key="exp_run"):
618
+ if not exp_num_cols and exp_type not in _mat_types:
619
+ st.error("No numeric columns available for this dataset.")
620
+ else:
621
+ try:
622
+ title = exp_title or _INTERACTIVE_TYPES[exp_type]
623
+
624
+ if exp_type == "interactive_histogram":
625
+ arr = (
626
+ _to_1d(data, exp_data_col)
627
+ if exp_data_col is not None
628
+ else (np.asarray(data, dtype=float).flatten())
629
+ )
630
+ fig = interactive_histogram(arr, title=title)
631
+
632
+ elif exp_type == "interactive_scatter":
633
+ x = _to_1d(data, exp_x) if exp_x is not None else np.array([])
634
+ y = _to_1d(data, exp_y) if exp_y is not None else np.array([])
635
+ fig = interactive_scatter(
636
+ x,
637
+ y,
638
+ xlabel=str(exp_x or "x"),
639
+ ylabel=str(exp_y or "y"),
640
+ title=title,
641
+ )
642
+
643
+ elif exp_type == "interactive_line":
644
+ if exp_x is not None and exp_y is not None:
645
+ x = _to_1d(data, exp_x)
646
+ y = [_to_1d(data, exp_y)]
647
+ lbl = [str(exp_y)]
648
+ else:
649
+ arr = np.asarray(data, dtype=float).flatten()
650
+ x = np.arange(len(arr))
651
+ y = [arr]
652
+ lbl = ["Data"]
653
+ fig = interactive_line(x, y, labels=lbl, title=title)
654
+
655
+ elif exp_type == "interactive_heatmap":
656
+ if isinstance(data, pd.DataFrame):
657
+ cols = _numeric_cols(data)
658
+ mat = data[cols].values.astype(float)
659
+ else:
660
+ mat = np.asarray(data, dtype=float)
661
+ if mat.ndim != 2:
662
+ st.error("Heatmap requires a 2-D array.")
663
+ st.stop()
664
+ fig = interactive_heatmap(mat, title=title)
665
+
666
+ else: # interactive_3d_surface
667
+ Z = np.asarray(data, dtype=float)
668
+ if Z.ndim != 2:
669
+ st.error("3D surface requires a 2-D array.")
670
+ st.stop()
671
+ nr, nc = Z.shape
672
+ X, Y = np.meshgrid(np.arange(nc), np.arange(nr))
673
+ fig = interactive_3d_surface(X, Y, Z, title=title)
674
+
675
+ st.plotly_chart(fig, width="stretch")
676
+ _html_download(fig, filename=f"{exp_type}.html")
677
+ st.success("Plot ready — click the button above to download.")
678
+
679
+ except Exception as exc:
680
+ st.error(f"Error: {exc}")
681
+ with st.expander("Details"):
682
+ st.code(traceback.format_exc())
683
+
684
+ with st.expander("About HTML export"):
685
+ st.markdown("""
686
+ The downloaded HTML file:
687
+ - Contains the full Plotly chart with zoom, pan, hover, and download toolbar
688
+ - Includes the Plotly.js library (~3 MB) — no internet connection needed to view
689
+ - Can be embedded in HTML reports or shared with collaborators who don't have Python
690
+ - Opens in Chrome, Firefox, Safari, and Edge
691
+ """)