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,442 @@
1
+ """Export Results Page.
2
+
3
+ Download plots, datasets, and analysis results generated during the session.
4
+
5
+ Sections
6
+ --------
7
+ 1. Plot History — download each saved plot as PNG (150/300 dpi), SVG, PDF, or HTML
8
+ 2. Dataset Export — download the current dataset as CSV or JSON
9
+ 3. Analysis Export — download stored analysis results as JSON
10
+ 4. Session Save/Load — persist the full session to a JSON file
11
+ """
12
+
13
+ import io
14
+ import json
15
+ import tempfile
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
+
23
+ sys.path.insert(0, str(Path(__file__).parent.parent.parent))
24
+
25
+ from eagleplot.utils import initialize_session_state
26
+ from eagleplot.utils.session_state import (
27
+ get_current_dataset,
28
+ get_session_summary,
29
+ save_session_to_file,
30
+ load_session_from_file,
31
+ )
32
+
33
+ initialize_session_state()
34
+
35
+ st.title("Export Results")
36
+ st.caption("Download plots, datasets, and analysis results from the current session.")
37
+
38
+ # ── Helper: render a Matplotlib figure to bytes ───────────────────────────────
39
+
40
+
41
+ def _mpl_bytes(fig, fmt: str, dpi: int = 150) -> bytes:
42
+ """Render a Matplotlib figure to bytes in the requested format."""
43
+ buf = io.BytesIO()
44
+ fig.savefig(buf, format=fmt, dpi=dpi, bbox_inches="tight")
45
+ buf.seek(0)
46
+ return buf.read()
47
+
48
+
49
+ def _plotly_html(fig) -> bytes:
50
+ """Render a Plotly figure to HTML bytes."""
51
+ return fig.to_html(include_plotlyjs="cdn").encode("utf-8")
52
+
53
+
54
+ def _is_mpl(fig) -> bool:
55
+ try:
56
+ from matplotlib.figure import Figure
57
+
58
+ return isinstance(fig, Figure)
59
+ except ImportError:
60
+ return False
61
+
62
+
63
+ def _is_plotly(fig) -> bool:
64
+ try:
65
+ import plotly.graph_objects as go
66
+
67
+ return isinstance(fig, go.Figure)
68
+ except ImportError:
69
+ return False
70
+
71
+
72
+ # ══════════════════════════════════════════════════════════════════════════════
73
+ # Section 1 — Plot History
74
+ # ══════════════════════════════════════════════════════════════════════════════
75
+ st.markdown("## Plot History")
76
+
77
+ plot_history = st.session_state.get("plot_history", [])
78
+
79
+ if not plot_history:
80
+ st.info(
81
+ "No plots saved yet. Generate plots on the Quick Plot, "
82
+ "Analysis Tools, Dashboard, or Advanced Plotting pages."
83
+ )
84
+ else:
85
+ st.markdown(f"**{len(plot_history)} plot(s) in session.**")
86
+
87
+ for i, entry in enumerate(plot_history):
88
+ plot_type = entry.get("type", "plot")
89
+ dataset = entry.get("dataset", "—")
90
+ timestamp = entry.get("timestamp", "")[:19].replace("T", " ")
91
+ fig = entry.get("figure")
92
+
93
+ label = f"Plot {i + 1} — {plot_type} | dataset: {dataset} | {timestamp}"
94
+
95
+ with st.expander(label, expanded=(i == len(plot_history) - 1)):
96
+ if fig is None:
97
+ st.warning(
98
+ "Figure object not available for this entry. "
99
+ "Re-generate the plot to enable downloads."
100
+ )
101
+ continue
102
+
103
+ if _is_mpl(fig):
104
+ c1, c2, c3, c4 = st.columns(4)
105
+ with c1:
106
+ st.download_button(
107
+ "PNG 150 dpi",
108
+ data=_mpl_bytes(fig, "png", dpi=150),
109
+ file_name=f"plot_{i + 1}.png",
110
+ mime="image/png",
111
+ key=f"dl_png150_{i}",
112
+ )
113
+ with c2:
114
+ st.download_button(
115
+ "PNG 300 dpi",
116
+ data=_mpl_bytes(fig, "png", dpi=300),
117
+ file_name=f"plot_{i + 1}_hires.png",
118
+ mime="image/png",
119
+ key=f"dl_png300_{i}",
120
+ )
121
+ with c3:
122
+ st.download_button(
123
+ "SVG",
124
+ data=_mpl_bytes(fig, "svg"),
125
+ file_name=f"plot_{i + 1}.svg",
126
+ mime="image/svg+xml",
127
+ key=f"dl_svg_{i}",
128
+ )
129
+ with c4:
130
+ st.download_button(
131
+ "PDF",
132
+ data=_mpl_bytes(fig, "pdf"),
133
+ file_name=f"plot_{i + 1}.pdf",
134
+ mime="application/pdf",
135
+ key=f"dl_pdf_{i}",
136
+ )
137
+
138
+ elif _is_plotly(fig):
139
+ st.download_button(
140
+ "HTML (interactive)",
141
+ data=_plotly_html(fig),
142
+ file_name=f"plot_{i + 1}.html",
143
+ mime="text/html",
144
+ key=f"dl_html_{i}",
145
+ )
146
+ st.caption(
147
+ "Plotly figures export as self-contained HTML. "
148
+ "PNG export requires the `kaleido` package."
149
+ )
150
+ try:
151
+ import kaleido # noqa: F401
152
+
153
+ png_bytes = fig.to_image(format="png", scale=2)
154
+ st.download_button(
155
+ "PNG (2x)",
156
+ data=png_bytes,
157
+ file_name=f"plot_{i + 1}.png",
158
+ mime="image/png",
159
+ key=f"dl_plotly_png_{i}",
160
+ )
161
+ except ImportError:
162
+ pass
163
+ else:
164
+ st.warning(f"Unknown figure type: {type(fig).__name__}")
165
+
166
+ st.markdown("---")
167
+
168
+ # ══════════════════════════════════════════════════════════════════════════════
169
+ # Section 2 — Dataset Export
170
+ # ══════════════════════════════════════════════════════════════════════════════
171
+ st.markdown("## Dataset Export")
172
+
173
+ current_data = get_current_dataset()
174
+ current_name = st.session_state.get("current_dataset", None)
175
+
176
+ if current_data is None:
177
+ st.info("No dataset loaded. Upload data on the Data Upload page.")
178
+ else:
179
+ st.markdown(f"**Current dataset:** `{current_name}`")
180
+
181
+ if isinstance(current_data, pd.DataFrame):
182
+ c1, c2 = st.columns(2)
183
+ with c1:
184
+ csv_bytes = current_data.to_csv(index=False).encode("utf-8")
185
+ st.download_button(
186
+ "Download as CSV",
187
+ data=csv_bytes,
188
+ file_name=f"{Path(current_name).stem}_export.csv",
189
+ mime="text/csv",
190
+ key="dl_dataset_csv",
191
+ )
192
+ with c2:
193
+ json_bytes = current_data.to_json(orient="records", indent=2).encode("utf-8")
194
+ st.download_button(
195
+ "Download as JSON",
196
+ data=json_bytes,
197
+ file_name=f"{Path(current_name).stem}_export.json",
198
+ mime="application/json",
199
+ key="dl_dataset_json",
200
+ )
201
+
202
+ elif isinstance(current_data, np.ndarray):
203
+ buf = io.BytesIO()
204
+ np.save(buf, current_data)
205
+ buf.seek(0)
206
+ st.download_button(
207
+ "Download as .npy",
208
+ data=buf.read(),
209
+ file_name=f"{Path(current_name).stem}_export.npy",
210
+ mime="application/octet-stream",
211
+ key="dl_dataset_npy",
212
+ )
213
+
214
+ else:
215
+ import pickle as _pickle
216
+
217
+ pkl_bytes = _pickle.dumps(current_data)
218
+ st.download_button(
219
+ "Download as .pkl",
220
+ data=pkl_bytes,
221
+ file_name=f"{Path(current_name).stem}_export.pkl",
222
+ mime="application/octet-stream",
223
+ key="dl_dataset_pkl",
224
+ )
225
+
226
+ st.markdown("---")
227
+
228
+ # ══════════════════════════════════════════════════════════════════════════════
229
+ # Section 3 — Analysis Results Export
230
+ # ══════════════════════════════════════════════════════════════════════════════
231
+ st.markdown("## Analysis Results")
232
+
233
+ analysis_results = st.session_state.get("analysis_results", [])
234
+
235
+ if not analysis_results:
236
+ st.info("No analysis results stored yet. Run analyses on the Analysis Tools page.")
237
+ else:
238
+ st.markdown(f"**{len(analysis_results)} result(s) stored.**")
239
+
240
+ # Build a JSON-serializable list (strip non-serializable items like arrays)
241
+ def _make_serializable(obj):
242
+ if isinstance(obj, np.ndarray):
243
+ return obj.tolist()
244
+ if isinstance(obj, np.integer):
245
+ return int(obj)
246
+ if isinstance(obj, np.floating):
247
+ return float(obj)
248
+ if isinstance(obj, dict):
249
+ return {k: _make_serializable(v) for k, v in obj.items()}
250
+ if isinstance(obj, (list, tuple)):
251
+ return [_make_serializable(x) for x in obj]
252
+ return obj
253
+
254
+ serializable = _make_serializable(analysis_results)
255
+ results_json = json.dumps(serializable, indent=2).encode("utf-8")
256
+
257
+ st.download_button(
258
+ "Download all results as JSON",
259
+ data=results_json,
260
+ file_name="analysis_results.json",
261
+ mime="application/json",
262
+ key="dl_analysis_json",
263
+ )
264
+
265
+ with st.expander("Preview results"):
266
+ for j, res in enumerate(analysis_results):
267
+ r_type = res.get("type", "result")
268
+ r_ts = res.get("timestamp", "")[:19].replace("T", " ")
269
+ st.markdown(f"**{j + 1}. {r_type}** — {r_ts}")
270
+
271
+ st.markdown("---")
272
+
273
+ # ══════════════════════════════════════════════════════════════════════════════
274
+ # Section 4 — PDF Report
275
+ # ══════════════════════════════════════════════════════════════════════════════
276
+ st.markdown("## PDF Report")
277
+ st.markdown(
278
+ "Generate a PDF report containing all matplotlib plots, dataset previews, "
279
+ "and analysis results from the current session."
280
+ )
281
+
282
+ # Import report generator lazily to avoid hard dependency at module load
283
+ try:
284
+ from eagleplot.report import generate_pdf_report as _gen_pdf
285
+
286
+ _report_available = True
287
+ except ImportError:
288
+ _report_available = False
289
+
290
+ if not _report_available:
291
+ st.warning("PDF report module not available.")
292
+ else:
293
+ _report_title = st.text_input(
294
+ "Report title",
295
+ value="EaglePlot Analysis Report",
296
+ key="pdf_report_title",
297
+ )
298
+ _report_author = st.text_input(
299
+ "Author (optional)",
300
+ value="",
301
+ key="pdf_report_author",
302
+ )
303
+ _report_dpi = st.select_slider(
304
+ "Figure resolution (DPI)",
305
+ options=[72, 100, 150, 200, 300],
306
+ value=150,
307
+ key="pdf_report_dpi",
308
+ )
309
+
310
+ if st.button("Generate PDF Report", type="primary", key="pdf_report_btn"):
311
+ _df_datasets = {
312
+ k: v
313
+ for k, v in st.session_state.get("datasets", {}).items()
314
+ if isinstance(v, pd.DataFrame)
315
+ }
316
+ with st.spinner("Generating PDF..."):
317
+ with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as _tmp:
318
+ _tmp_path = _tmp.name
319
+ try:
320
+ _gen_pdf(
321
+ filepath=_tmp_path,
322
+ title=_report_title,
323
+ plot_entries=st.session_state.get("plot_history", []),
324
+ datasets=_df_datasets,
325
+ analysis_results=st.session_state.get("analysis_results", []),
326
+ author=_report_author,
327
+ dpi=_report_dpi,
328
+ )
329
+ _pdf_bytes = Path(_tmp_path).read_bytes()
330
+ st.download_button(
331
+ "Download PDF Report",
332
+ data=_pdf_bytes,
333
+ file_name="eagleplot_report.pdf",
334
+ mime="application/pdf",
335
+ key="dl_pdf_report",
336
+ )
337
+ st.success("PDF ready for download.")
338
+ except Exception as _pdf_err:
339
+ st.error(f"Could not generate PDF: {_pdf_err}")
340
+ finally:
341
+ Path(_tmp_path).unlink(missing_ok=True)
342
+
343
+ st.markdown("---")
344
+
345
+ # ══════════════════════════════════════════════════════════════════════════════
346
+ # Section 5 — Session Save / Load
347
+ # ══════════════════════════════════════════════════════════════════════════════
348
+ st.markdown("## Session Save / Load")
349
+ st.markdown(
350
+ "Save the full session (datasets, plot history metadata, analysis results) "
351
+ "to a JSON file. Note: figure objects are **not** included — only metadata."
352
+ )
353
+
354
+ tab_save, tab_load = st.tabs(["Save session", "Load session"])
355
+
356
+ with tab_save:
357
+ summary = get_session_summary()
358
+ st.markdown(
359
+ f"- **Datasets:** {summary['num_datasets']} \n"
360
+ f"- **Plots in history:** {summary['num_plots']} \n"
361
+ f"- **Analysis results:** {summary['num_analyses']}"
362
+ )
363
+
364
+ # The download button used to be drawn inside `if st.button(...)`, so it existed
365
+ # for one rerun only: the next interaction anywhere on the page removed it. The
366
+ # generated bytes now live in session state and the download button is drawn on
367
+ # every rerun while they still match the session. Generation stays an explicit
368
+ # click because serialising large datasets on every rerun would be slow. A-32.
369
+ _session_fingerprint = (
370
+ tuple((name, id(obj)) for name, obj in st.session_state.datasets.items()),
371
+ summary["current_dataset"],
372
+ summary["num_plots"],
373
+ summary["num_analyses"],
374
+ )
375
+ _cached = st.session_state.get("_session_export")
376
+ if _cached is not None and _cached["fingerprint"] != _session_fingerprint:
377
+ _cached = None
378
+ st.session_state.pop("_session_export", None)
379
+
380
+ if st.button(
381
+ "Regenerate session file" if _cached else "Generate session file",
382
+ type="secondary" if _cached else "primary",
383
+ key="session_save_btn",
384
+ ):
385
+ with tempfile.NamedTemporaryFile(suffix=".json", delete=False, mode="w") as tmp:
386
+ tmp_path = tmp.name
387
+
388
+ try:
389
+ save_session_to_file(tmp_path)
390
+ _cached = {
391
+ "fingerprint": _session_fingerprint,
392
+ "data": Path(tmp_path).read_bytes(),
393
+ }
394
+ st.session_state["_session_export"] = _cached
395
+ except Exception as exc:
396
+ st.error(f"Could not save session: {exc}")
397
+ finally:
398
+ Path(tmp_path).unlink(missing_ok=True)
399
+
400
+ if _cached is not None:
401
+ st.download_button(
402
+ "Download session.json",
403
+ data=_cached["data"],
404
+ file_name="eagleplot_session.json",
405
+ mime="application/json",
406
+ type="primary",
407
+ key="dl_session_json",
408
+ )
409
+ st.caption(f"{len(_cached['data']) / 1024:,.1f} KB, matches the current session.")
410
+
411
+ with tab_load:
412
+ uploaded = st.file_uploader(
413
+ "Upload a session file (.json)",
414
+ type=["json"],
415
+ key="session_upload",
416
+ )
417
+ if uploaded is not None:
418
+ if st.button("Restore session", type="primary", key="session_load_btn"):
419
+ with tempfile.NamedTemporaryFile(suffix=".json", delete=False, mode="wb") as tmp:
420
+ tmp.write(uploaded.read())
421
+ tmp_path = tmp.name
422
+
423
+ try:
424
+ skipped = load_session_from_file(tmp_path)
425
+ if skipped:
426
+ # Deliberately no st.rerun() here — a rerun would discard this
427
+ # warning before the user ever sees it. The data is already in
428
+ # session state, so navigating to another page picks it up.
429
+ st.warning(
430
+ "Session restored, but some entries could not be loaded:\n\n"
431
+ + "\n".join(f"- {note}" for note in skipped)
432
+ + "\n\nSessions saved by Plottle 2.0.1 or earlier stored arrays in a "
433
+ "format that is no longer accepted. Re-upload the original data files "
434
+ "and save the session again."
435
+ )
436
+ else:
437
+ st.success("Session restored. Navigate to other pages to use the loaded data.")
438
+ st.rerun()
439
+ except Exception as exc:
440
+ st.error(f"Could not load session: {exc}")
441
+ finally:
442
+ Path(tmp_path).unlink(missing_ok=True)
@@ -0,0 +1,201 @@
1
+ """Gallery Page.
2
+
3
+ Displays pre-rendered example figures grouped by library (Matplotlib,
4
+ Seaborn, Plotly). Each card shows a thumbnail, title, description, and a
5
+ "Use this config" button that pre-fills Quick Plot with the figure's settings.
6
+
7
+ To populate the gallery run once from the repo root::
8
+
9
+ python generate_gallery.py
10
+
11
+ The script writes PNGs and ``docs/gallery/manifest.json``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ import streamlit as st
21
+
22
+ _APP_ROOT = Path(__file__).resolve().parent.parent.parent
23
+ # Packaged alongside the code so the Gallery is populated after `pip install`
24
+ # rather than only in a source checkout -- see audit A-09.
25
+ _GALLERY_DIR = Path(__file__).resolve().parent.parent / "gallery"
26
+ _MANIFEST_PATH = _GALLERY_DIR / "manifest.json"
27
+
28
+ sys.path.insert(0, str(_APP_ROOT))
29
+
30
+ from eagleplot.utils import initialize_session_state
31
+
32
+ initialize_session_state()
33
+
34
+ # ── Equal-height cards ────────────────────────────────────────────────────────
35
+ st.markdown(
36
+ """
37
+ <style>
38
+ [data-testid="stHorizontalBlock"] {
39
+ align-items: stretch;
40
+ }
41
+ [data-testid="stHorizontalBlock"] > [data-testid="column"] {
42
+ display: flex;
43
+ flex-direction: column;
44
+ }
45
+ [data-testid="stHorizontalBlock"] > [data-testid="column"]
46
+ > [data-testid="stVerticalBlock"] {
47
+ flex: 1;
48
+ display: flex;
49
+ flex-direction: column;
50
+ }
51
+ [data-testid="stHorizontalBlock"] > [data-testid="column"]
52
+ > [data-testid="stVerticalBlock"]
53
+ > [data-testid="stVerticalBlockBorderWrapper"] {
54
+ flex: 1;
55
+ display: flex;
56
+ flex-direction: column;
57
+ }
58
+ [data-testid="stVerticalBlockBorderWrapper"] > [data-testid="stVerticalBlock"] {
59
+ flex: 1;
60
+ display: flex;
61
+ flex-direction: column;
62
+ justify-content: space-between;
63
+ }
64
+ </style>
65
+ """,
66
+ unsafe_allow_html=True,
67
+ )
68
+
69
+ # ── Page header ───────────────────────────────────────────────────────────────
70
+ st.title("Gallery")
71
+ st.caption(
72
+ "Browse example figures. Click **Use this config** to pre-fill Quick Plot "
73
+ "with that figure's settings."
74
+ )
75
+
76
+ # ── Guard: manifest missing ───────────────────────────────────────────────────
77
+ if not _MANIFEST_PATH.exists():
78
+ st.warning(
79
+ "No gallery figures found. "
80
+ "Run **`python generate_gallery.py`** from the repo root to generate them."
81
+ )
82
+ st.code("python generate_gallery.py", language="bash")
83
+ st.stop()
84
+
85
+ # ── Load manifest ─────────────────────────────────────────────────────────────
86
+ with open(_MANIFEST_PATH, encoding="utf-8") as fh:
87
+ _manifest: list[dict] = json.load(fh)
88
+
89
+ # ── Group by library ──────────────────────────────────────────────────────────
90
+ _LIBRARY_ORDER = ["Matplotlib", "Seaborn", "Plotly"]
91
+
92
+ _by_library: dict[str, list[dict]] = {}
93
+ for entry in _manifest:
94
+ _by_library.setdefault(entry.get("library", "Other"), []).append(entry)
95
+
96
+ # Add any user-saved entries that live in the gallery dir but not in manifest
97
+ _user_entries: list[dict] = []
98
+ _manifest_keys = {e["key"] for e in _manifest}
99
+ for png in sorted(_GALLERY_DIR.glob("user_*.png")):
100
+ key = png.stem
101
+ meta_path = _GALLERY_DIR / f"{key}.json"
102
+ if meta_path.exists():
103
+ try:
104
+ with open(meta_path, encoding="utf-8") as fh:
105
+ meta = json.load(fh)
106
+ if key not in _manifest_keys:
107
+ _user_entries.append(meta)
108
+ except Exception:
109
+ pass
110
+
111
+ if _user_entries:
112
+ _by_library.setdefault("My Figures", _user_entries)
113
+
114
+ # ── Render sections ───────────────────────────────────────────────────────────
115
+ _lib_order = [lib for lib in _LIBRARY_ORDER if lib in _by_library]
116
+ _lib_order += [lib for lib in _by_library if lib not in _LIBRARY_ORDER]
117
+
118
+ # _DEFAULTS_KEY_MAP inverse — needed to restore settings into widget keys.
119
+ # Mirror of the map in 2_Quick_Plot.py.
120
+ _CFG_TO_WIDGET: dict[str, str] = {
121
+ "fontfamily": "_shared_fontfamily",
122
+ "fontcolor": "_shared_fontcolor",
123
+ "linewidth": "_shared_lw",
124
+ "grid": "_shared_grid",
125
+ "grid_linestyle": "_shared_grid_ls",
126
+ "grid_which": "_shared_grid_which",
127
+ "legend_frameon": "_shared_leg_frame",
128
+ "legend_framealpha": "_shared_leg_bg",
129
+ "legend_position": "_shared_leg_pos",
130
+ "color_palette": "_shared_palette",
131
+ "fontsize": "_shared_ply_fontsize",
132
+ "fontsize_label": "_shared_fs_label",
133
+ "fontsize_tick": "_shared_fs_tick",
134
+ "fontsize_title": "_shared_fs_title",
135
+ "fontsize_legend": "_shared_fs_legend",
136
+ }
137
+
138
+
139
+ def _apply_gallery_config(cfg: dict) -> None:
140
+ """Write a gallery config dict into the shared Quick Plot widget keys."""
141
+ for cfg_key, widget_key in _CFG_TO_WIDGET.items():
142
+ if cfg_key in cfg:
143
+ # Attempt type coercion — manifest stores everything as strings.
144
+ val = cfg[cfg_key]
145
+ current = st.session_state.get(widget_key)
146
+ if isinstance(current, bool):
147
+ st.session_state[widget_key] = str(val).lower() in ("true", "1")
148
+ elif isinstance(current, (int, float)) or current is None:
149
+ try:
150
+ st.session_state[widget_key] = (
151
+ type(current)(val) if current is not None else val
152
+ )
153
+ except (ValueError, TypeError):
154
+ st.session_state[widget_key] = val
155
+ else:
156
+ st.session_state[widget_key] = val
157
+
158
+
159
+ for lib in _lib_order:
160
+ entries = _by_library[lib]
161
+ st.markdown(f"## {lib}")
162
+
163
+ # 3-column grid — new st.columns() per row so cards align across columns
164
+ for row_start in range(0, len(entries), 3):
165
+ row_entries = entries[row_start : row_start + 3]
166
+ cols = st.columns(3)
167
+ for col, entry in zip(cols, row_entries):
168
+ with col:
169
+ with st.container(border=True):
170
+ img_path = _GALLERY_DIR / entry.get("filename", f"{entry['key']}.png")
171
+ if img_path.exists():
172
+ st.image(str(img_path), width="stretch")
173
+ else:
174
+ st.caption("_(image not generated yet)_")
175
+
176
+ st.markdown(
177
+ f"<div style='min-height:5.5em'>"
178
+ f"<strong>{entry['title']}</strong><br>"
179
+ f"<span style='font-size:0.82rem;color:inherit'>"
180
+ f"{entry.get('description', '')}</span><br><br>"
181
+ f"<span style='font-size:0.8rem'>"
182
+ f"<em>Best for:</em> {entry.get('best_for', '—')}</span>"
183
+ f"</div>"
184
+ f"<p style='font-size:0.78rem;margin-top:0.3rem'>"
185
+ f"Dataset: <code>{entry.get('dataset', '—')}</code></p>",
186
+ unsafe_allow_html=True,
187
+ )
188
+
189
+ if st.button(
190
+ "Use this config",
191
+ key=f"gallery_apply_{entry['key']}",
192
+ width="stretch",
193
+ ):
194
+ _apply_gallery_config(entry.get("config", {}))
195
+ st.session_state["qp_category"] = lib
196
+ st.success(
197
+ f"Config loaded. Go to **Quick Plot** and choose "
198
+ f"**{entry['title']}** to generate the figure."
199
+ )
200
+
201
+ st.markdown("---")