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,1693 @@
1
+ """Quick Plot Page.
2
+
3
+ Select a loaded dataset, choose a plot type, configure parameters,
4
+ and generate a publication-ready or interactive visualization.
5
+ All 13 plot types from eagleplot.plotting are accessible here.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import traceback
11
+ from pathlib import Path
12
+ import sys
13
+
14
+ import streamlit as st
15
+ import numpy as np
16
+ import pandas as pd
17
+
18
+ sys.path.insert(0, str(Path(__file__).parent.parent.parent))
19
+
20
+ from eagleplot.plotting import (
21
+ histogram,
22
+ line_plot,
23
+ scatter_plot,
24
+ bar_chart,
25
+ heatmap,
26
+ contour_plot,
27
+ waterfall_plot,
28
+ dual_axis_plot,
29
+ broken_axis_plot,
30
+ distribution_plot,
31
+ box_plot,
32
+ regression_plot,
33
+ interactive_histogram,
34
+ interactive_scatter,
35
+ interactive_line,
36
+ interactive_heatmap,
37
+ interactive_3d_surface,
38
+ z_colored_scatter,
39
+ bubble_chart,
40
+ polar_plot,
41
+ histogram_2d,
42
+ pair_plot,
43
+ interactive_3d_scatter,
44
+ scatter_with_regression,
45
+ residual_plot,
46
+ interactive_ternary,
47
+ inset_plot,
48
+ )
49
+ from eagleplot.utils import (
50
+ initialize_session_state,
51
+ get_dataset,
52
+ add_plot_to_history,
53
+ get_session_summary,
54
+ get_defaults,
55
+ list_presets,
56
+ save_preset,
57
+ load_preset,
58
+ delete_preset,
59
+ )
60
+ import matplotlib as mpl
61
+
62
+ from eagleplot.utils.plot_config import (
63
+ PLOT_TYPES,
64
+ COLOR_PALETTES,
65
+ get_plot_config_widgets,
66
+ get_plot_kwargs,
67
+ selected_points,
68
+ format_marker_label,
69
+ )
70
+
71
+ initialize_session_state()
72
+
73
+ # Map config.json default keys → shared widget keys.
74
+ # config.json uses plain names (e.g. "fontfamily"); widgets now use
75
+ # "_shared_*" keys so they persist across plot-type switches.
76
+ _DEFAULTS_KEY_MAP = {
77
+ "fontfamily": "_shared_fontfamily",
78
+ "fontcolor": "_shared_fontcolor",
79
+ "linewidth": "_shared_lw",
80
+ "grid": "_shared_grid",
81
+ "grid_linestyle": "_shared_grid_ls",
82
+ "grid_which": "_shared_grid_which",
83
+ "legend_frameon": "_shared_leg_frame",
84
+ "legend_framealpha": "_shared_leg_bg",
85
+ "legend_position": "_shared_leg_pos",
86
+ "color_palette": "_shared_palette",
87
+ "fontsize": "_shared_ply_fontsize",
88
+ # Per-element sizes: saved with new keys directly if user updated them
89
+ "fontsize_label": "_shared_fs_label",
90
+ "fontsize_tick": "_shared_fs_tick",
91
+ "fontsize_title": "_shared_fs_title",
92
+ "fontsize_legend": "_shared_fs_legend",
93
+ }
94
+
95
+ # Seed session state with persisted user defaults (first run only).
96
+ if "user_defaults_loaded" not in st.session_state:
97
+ _ud = get_defaults()
98
+ for _k, _v in _ud.items():
99
+ _mapped = _DEFAULTS_KEY_MAP.get(_k, _k)
100
+ if _mapped not in st.session_state:
101
+ st.session_state[_mapped] = _v
102
+ st.session_state["user_defaults_loaded"] = True
103
+
104
+ # Persistent plot storage for click-to-annotate.
105
+ if "qp_fig_dict" not in st.session_state:
106
+ st.session_state.qp_fig_dict = None # Plotly figure as dict
107
+ if "qp_mpl_fig" not in st.session_state:
108
+ st.session_state.qp_mpl_fig = None # Matplotlib figure object
109
+ if "qp_is_plotly" not in st.session_state:
110
+ st.session_state.qp_is_plotly = False
111
+ if "qp_config" not in st.session_state:
112
+ st.session_state.qp_config = {}
113
+ if "qp_annotations" not in st.session_state:
114
+ st.session_state.qp_annotations = [] # [{x, y, label}]
115
+ if "qp_ann_overlays" not in st.session_state:
116
+ st.session_state.qp_ann_overlays = [] # M16 annotation overlays
117
+ if "qp_current_plot_type" not in st.session_state:
118
+ st.session_state.qp_current_plot_type = "" # M16: used by _regen_with_overlays
119
+ if "qp_show_plotly_version" not in st.session_state:
120
+ st.session_state.qp_show_plotly_version = False # zoom/pan Plotly conversion
121
+ if "qp_mpl_png" not in st.session_state:
122
+ st.session_state.qp_mpl_png = None # PNG bytes — avoids stale MediaFileStorage hashes
123
+ if "qp_zoom_xlim" not in st.session_state:
124
+ st.session_state.qp_zoom_xlim = None # (xmin, xmax) or None
125
+ if "qp_zoom_ylim" not in st.session_state:
126
+ st.session_state.qp_zoom_ylim = None # (ymin, ymax) or None
127
+ if "qp_last_smart_default_for" not in st.session_state:
128
+ st.session_state.qp_last_smart_default_for = None # dataset name last auto-defaulted
129
+
130
+
131
+ def _suggest_plot_type(data) -> tuple:
132
+ """Return (category, plot_label) suited to the data's shape and type."""
133
+ if isinstance(data, np.ndarray):
134
+ return ("Matplotlib", "Heatmap") if data.ndim >= 2 else ("Matplotlib", "Histogram")
135
+ if isinstance(data, pd.DataFrame):
136
+ n_numeric = len(data.select_dtypes(include=[np.number]).columns)
137
+ n_rows = len(data)
138
+ if n_numeric >= 2 and n_rows > 100:
139
+ return "Matplotlib", "Line Plot"
140
+ if n_numeric >= 2:
141
+ return "Matplotlib", "Scatter Plot"
142
+ return "Matplotlib", "Histogram"
143
+
144
+
145
+ # ── Style post-processing ────────────────────────────────────────────────────
146
+
147
+ _MPL_LS_TO_PLOTLY = {
148
+ "--": "dash",
149
+ "-": "solid",
150
+ ":": "dot",
151
+ "-.": "dashdot",
152
+ }
153
+
154
+
155
+ def _set_mpl_color_cycle(config: dict) -> None:
156
+ """Apply the selected color palette to the matplotlib color cycle."""
157
+ palette_name = config.get("color_palette", "Default")
158
+ if palette_name == "Custom":
159
+ colors = config.get("_custom_colors") or COLOR_PALETTES["Default"]
160
+ else:
161
+ colors = COLOR_PALETTES.get(palette_name, COLOR_PALETTES["Default"])
162
+ mpl.rcParams["axes.prop_cycle"] = mpl.cycler(color=colors)
163
+
164
+
165
+ def _apply_plotly_palette(fig, config: dict) -> None:
166
+ """Re-color Plotly line/scatter traces using the selected palette."""
167
+ palette_name = config.get("color_palette", "Default")
168
+ if palette_name == "Custom":
169
+ colors = config.get("_custom_colors") or COLOR_PALETTES["Default"]
170
+ else:
171
+ colors = COLOR_PALETTES.get(palette_name, COLOR_PALETTES["Default"])
172
+ for i, trace in enumerate(fig.data):
173
+ c = colors[i % len(colors)]
174
+ try:
175
+ trace.update(line=dict(color=c))
176
+ except Exception:
177
+ pass
178
+
179
+
180
+ def _apply_transform(arr: np.ndarray, transform: str, scale_value: float) -> np.ndarray:
181
+ """Apply a named data transform to *arr*."""
182
+ if transform == "normalize_max":
183
+ mx = np.max(np.abs(arr))
184
+ return arr / mx if mx != 0 else arr
185
+ if transform == "normalize_01":
186
+ mn, mx = arr.min(), arr.max()
187
+ return (arr - mn) / (mx - mn) if mx != mn else arr
188
+ if transform == "scale_by":
189
+ return arr * scale_value
190
+ return arr
191
+
192
+
193
+ def _apply_y_transform(y: np.ndarray, config: dict) -> np.ndarray:
194
+ return _apply_transform(
195
+ y, config.get("y_transform", "none"), config.get("y_transform_value", 1.0)
196
+ )
197
+
198
+
199
+ def _apply_x_transform(x: np.ndarray, config: dict) -> np.ndarray:
200
+ return _apply_transform(
201
+ x, config.get("x_transform", "none"), config.get("x_transform_value", 1.0)
202
+ )
203
+
204
+
205
+ def _apply_z_transform(z: np.ndarray, config: dict) -> np.ndarray:
206
+ return _apply_transform(
207
+ z, config.get("z_transform", "none"), config.get("z_transform_value", 1.0)
208
+ )
209
+
210
+
211
+ def _apply_mpl_style(fig, config: dict) -> None:
212
+ """Apply GUI appearance settings to a Matplotlib figure."""
213
+ import matplotlib.ticker as ticker
214
+
215
+ if not fig.axes:
216
+ return
217
+ ax = fig.axes[0]
218
+
219
+ # Axis limits
220
+ xmin, xmax = config.get("xlim_min"), config.get("xlim_max")
221
+ if xmin is not None and xmax is not None:
222
+ ax.set_xlim(xmin, xmax)
223
+ ymin, ymax = config.get("ylim_min"), config.get("ylim_max")
224
+ if ymin is not None and ymax is not None:
225
+ ax.set_ylim(ymin, ymax)
226
+
227
+ # Axis scale
228
+ if config.get("x_scale") == "log":
229
+ ax.set_xscale("log")
230
+ if config.get("y_scale") == "log":
231
+ ax.set_yscale("log")
232
+
233
+ # Axis notation formatters
234
+ x_notation = config.get("x_notation", "default")
235
+ if x_notation == "scientific":
236
+ ax.xaxis.set_major_formatter(ticker.ScientificFormatter())
237
+ elif x_notation == "engineering":
238
+ ax.xaxis.set_major_formatter(ticker.EngFormatter())
239
+
240
+ y_notation = config.get("y_notation", "default")
241
+ if y_notation == "scientific":
242
+ ax.yaxis.set_major_formatter(ticker.ScientificFormatter())
243
+ elif y_notation == "engineering":
244
+ ax.yaxis.set_major_formatter(ticker.EngFormatter())
245
+
246
+ # Colorbar (Z) notation — applied to any axes that is not the main axes
247
+ z_notation = config.get("z_notation", "default")
248
+ if z_notation != "default":
249
+ _z_fmt = (
250
+ ticker.ScientificFormatter() if z_notation == "scientific" else ticker.EngFormatter()
251
+ )
252
+ for _cax in fig.axes:
253
+ if _cax is not ax:
254
+ _cax.yaxis.set_major_formatter(_z_fmt)
255
+ break
256
+
257
+ # Grid
258
+ show_grid = config.get("grid", True)
259
+ if show_grid:
260
+ grid_ls = config.get("grid_linestyle", "--")
261
+ grid_which = config.get("grid_which", "major")
262
+ ax.grid(True, linestyle=grid_ls, which=grid_which, alpha=0.45)
263
+ else:
264
+ ax.grid(False)
265
+
266
+ # Font — per-element sizes with fallback to legacy "fontsize" key
267
+ fontfamily = config.get("fontfamily", "sans-serif")
268
+ fontcolor = config.get("fontcolor", "#262730")
269
+ _fs = config.get("fontsize", 11) # legacy single-size fallback
270
+ fs_label = config.get("fontsize_label", _fs)
271
+ fs_tick = config.get("fontsize_tick", max(_fs - 1, 6))
272
+ fs_title = config.get("fontsize_title", _fs + 2)
273
+ fs_legend = config.get("fontsize_legend", max(_fs - 1, 6))
274
+
275
+ lp = {"family": fontfamily, "color": fontcolor}
276
+ if ax.get_xlabel():
277
+ ax.xaxis.label.set(size=fs_label, **lp)
278
+ if ax.get_ylabel():
279
+ ax.yaxis.label.set(size=fs_label, **lp)
280
+ if ax.get_title():
281
+ ax.title.set(size=fs_title, **lp)
282
+ ax.tick_params(labelsize=fs_tick, labelcolor=fontcolor)
283
+
284
+ # Line width (post-hoc on existing line objects)
285
+ lw = config.get("linewidth")
286
+ if lw is not None:
287
+ for line in ax.lines:
288
+ line.set_linewidth(lw)
289
+
290
+ # Legend
291
+ leg = ax.get_legend()
292
+ if leg:
293
+ frameon = config.get("legend_frameon", True)
294
+ leg.set_frame_on(frameon)
295
+ if frameon:
296
+ leg.get_frame().set_alpha(
297
+ config.get("legend_framealpha", 0.8),
298
+ )
299
+ pos = config.get("legend_position", "best")
300
+ if pos == "outside right":
301
+ leg = ax.legend(loc="upper left", bbox_to_anchor=(1, 1))
302
+ else:
303
+ leg.set_loc(pos)
304
+ for text in leg.get_texts():
305
+ text.set_fontsize(fs_legend)
306
+ text.set_color(fontcolor)
307
+
308
+ # Figure caption (below plot as figure text)
309
+ caption = config.get("figure_caption")
310
+ if caption:
311
+ fig.text(
312
+ 0.5,
313
+ -0.02,
314
+ caption,
315
+ ha="center",
316
+ va="top",
317
+ fontsize=max(fs_label - 2, 8),
318
+ color=fontcolor,
319
+ style="italic",
320
+ wrap=True,
321
+ )
322
+
323
+ fig.tight_layout()
324
+
325
+
326
+ def _apply_plotly_style(fig, config: dict) -> None:
327
+ """Apply GUI appearance settings to a Plotly figure."""
328
+ layout: dict = {}
329
+
330
+ # Font
331
+ font_dict: dict = {}
332
+ if config.get("fontsize"):
333
+ font_dict["size"] = config["fontsize"]
334
+ if config.get("fontfamily"):
335
+ font_dict["family"] = config["fontfamily"]
336
+ if config.get("fontcolor"):
337
+ font_dict["color"] = config["fontcolor"]
338
+ if font_dict:
339
+ layout["font"] = font_dict
340
+
341
+ # Chart height
342
+ if config.get("plotly_height"):
343
+ layout["height"] = config["plotly_height"]
344
+
345
+ # Grid
346
+ show_grid = config.get("grid", True)
347
+ grid_ls = _MPL_LS_TO_PLOTLY.get(
348
+ config.get("grid_linestyle", "--"),
349
+ "dash",
350
+ )
351
+ axis_grid = dict(
352
+ showgrid=show_grid,
353
+ griddash=grid_ls if show_grid else "dash",
354
+ )
355
+ layout["xaxis"] = axis_grid
356
+ layout["yaxis"] = axis_grid.copy()
357
+
358
+ # Axis limits
359
+ xmin, xmax = config.get("xlim_min"), config.get("xlim_max")
360
+ if xmin is not None and xmax is not None:
361
+ layout.setdefault("xaxis", {})["range"] = [xmin, xmax]
362
+ ymin, ymax = config.get("ylim_min"), config.get("ylim_max")
363
+ if ymin is not None and ymax is not None:
364
+ layout.setdefault("yaxis", {})["range"] = [ymin, ymax]
365
+
366
+ # Legend
367
+ legend_upd: dict = {}
368
+ frameon = config.get("legend_frameon")
369
+ if frameon is not None:
370
+ legend_upd["borderwidth"] = 1 if frameon else 0
371
+ alpha = config.get("legend_framealpha")
372
+ if alpha is not None:
373
+ if alpha < 0.05:
374
+ legend_upd["bgcolor"] = "rgba(0,0,0,0)"
375
+ else:
376
+ legend_upd["bgcolor"] = f"rgba(255,255,255,{alpha:.2f})"
377
+ if legend_upd:
378
+ layout["legend"] = legend_upd
379
+
380
+ # Y-axis scale
381
+ y_scale = config.get("y_scale", "linear")
382
+ if y_scale == "log":
383
+ layout.setdefault("yaxis", {})["type"] = "log"
384
+
385
+ # Y-axis notation
386
+ y_notation = config.get("y_notation", "default")
387
+ if y_notation == "scientific":
388
+ layout.setdefault("yaxis", {})["exponentformat"] = "e"
389
+ elif y_notation == "engineering":
390
+ layout.setdefault("yaxis", {})["exponentformat"] = "SI"
391
+
392
+ if layout:
393
+ fig.update_layout(**layout)
394
+
395
+ # Line width (trace-level — must come after update_layout)
396
+ lw = config.get("linewidth")
397
+ if lw is not None:
398
+ fig.update_traces(
399
+ line=dict(width=lw),
400
+ selector=dict(mode="lines"),
401
+ )
402
+ fig.update_traces(
403
+ line=dict(width=lw),
404
+ selector=dict(mode="lines+markers"),
405
+ )
406
+
407
+
408
+ # ── Helpers ──────────────────────────────────────────────────────────────────
409
+
410
+
411
+ def _extract_col(data: any, col_key) -> np.ndarray:
412
+ """Extract a single column/series from *data* as a 1-D numpy array.
413
+
414
+ Parameters
415
+ ----------
416
+ data : DataFrame or ndarray
417
+ Source dataset.
418
+ col_key : str or None
419
+ Column name (DataFrame), string column index (2-D ndarray), or
420
+ ``None`` to return the full array flattened.
421
+ """
422
+ if isinstance(data, pd.DataFrame):
423
+ return data[col_key].values
424
+ arr = np.asarray(data)
425
+ if col_key is None:
426
+ return arr.flatten()
427
+ return arr[:, int(col_key)]
428
+
429
+
430
+ def _numeric_cols(data: any):
431
+ if isinstance(data, pd.DataFrame):
432
+ return data.select_dtypes(include=[np.number]).columns.tolist()
433
+ return []
434
+
435
+
436
+ def _grid_from_xyz(
437
+ x: np.ndarray,
438
+ y: np.ndarray,
439
+ z: np.ndarray,
440
+ grid_n: int = 100,
441
+ method: str = "cubic",
442
+ ) -> tuple:
443
+ """Return (X, Y, Z) meshgrids suitable for contour_plot.
444
+
445
+ If *x* and *y* already describe a structured (regular) grid the data is
446
+ pivoted directly without interpolation. Otherwise
447
+ ``scipy.interpolate.griddata`` is used to interpolate onto an N×N grid.
448
+ """
449
+ from scipy.interpolate import griddata
450
+
451
+ x_unique = np.unique(x)
452
+ y_unique = np.unique(y)
453
+ n_expected = len(x_unique) * len(y_unique)
454
+
455
+ # --- attempt structured-grid reshape ---------------------------------
456
+ if n_expected == len(x):
457
+ try:
458
+ xi_sorted = np.sort(x_unique)
459
+ yi_sorted = np.sort(y_unique)
460
+ Xi, Yi = np.meshgrid(xi_sorted, yi_sorted)
461
+ Zi = np.full(Xi.shape, np.nan)
462
+ ix_map = {v: i for i, v in enumerate(xi_sorted)}
463
+ iy_map = {v: i for i, v in enumerate(yi_sorted)}
464
+ for xv, yv, zv in zip(x, y, z):
465
+ Zi[iy_map[yv], ix_map[xv]] = zv
466
+ if not np.any(np.isnan(Zi)):
467
+ return Xi, Yi, Zi
468
+ except Exception:
469
+ pass # fall through to griddata
470
+
471
+ # --- scattered data: interpolate onto a regular grid -----------------
472
+ xi = np.linspace(x.min(), x.max(), grid_n)
473
+ yi = np.linspace(y.min(), y.max(), grid_n)
474
+ Xi, Yi = np.meshgrid(xi, yi)
475
+ Zi = griddata((x, y), z, (Xi, Yi), method=method)
476
+ return Xi, Yi, Zi
477
+
478
+
479
+ def _generate_plot(plot_type: str, data: any, config: dict):
480
+ """Dispatch to the correct plotting function.
481
+
482
+ Returns
483
+ -------
484
+ fig : Figure or plotly Figure
485
+ is_plotly : bool
486
+ """
487
+ kwargs = get_plot_kwargs(config)
488
+
489
+ # ── Matplotlib ──────────────────────────────────────────────────────────
490
+ if plot_type == "histogram":
491
+ arr = _extract_col(data, config.get("_data_col"))
492
+ arr = _apply_y_transform(arr, config)
493
+ fig, ax, _ = histogram(arr, **kwargs)
494
+ return fig, False
495
+
496
+ if plot_type == "line_plot":
497
+ x_col = config.get("_x_col")
498
+ y_cols = config.get("_y_cols")
499
+
500
+ if x_col is None and y_cols is None:
501
+ # 1-D array
502
+ x = np.arange(len(np.asarray(data).flatten()))
503
+ y = [_apply_y_transform(np.asarray(data).flatten(), config)]
504
+ labels = None
505
+ elif isinstance(data, pd.DataFrame):
506
+ x = data[x_col].values
507
+ y = [_apply_y_transform(data[c].values, config) for c in y_cols]
508
+ labels = list(y_cols)
509
+ else:
510
+ x = data[:, int(x_col)]
511
+ y = [_apply_y_transform(data[:, int(c)], config) for c in y_cols]
512
+ labels = [f"Col {c}" for c in y_cols]
513
+ x = _apply_x_transform(x, config)
514
+
515
+ # Custom legend labels override column names
516
+ custom_labels = config.get("_custom_labels")
517
+ if custom_labels and labels:
518
+ labels = custom_labels[: len(labels)]
519
+
520
+ if labels:
521
+ kwargs["labels"] = labels
522
+ _set_mpl_color_cycle(config)
523
+ fig, ax = line_plot(x, y, **kwargs)
524
+
525
+ # Secondary Y axis
526
+ sec_y_cols = config.get("_secondary_y_cols")
527
+ if sec_y_cols and isinstance(data, pd.DataFrame):
528
+ palette = COLOR_PALETTES.get(
529
+ config.get("color_palette", "Default"),
530
+ COLOR_PALETTES["Default"],
531
+ )
532
+ n_primary = len(y)
533
+ ax2 = ax.twinx()
534
+ lw = config.get("linewidth", 1.5)
535
+ for i, col in enumerate(sec_y_cols):
536
+ y_sec = _apply_y_transform(data[col].values, config)
537
+ color = palette[(n_primary + i) % len(palette)]
538
+ sec_lbl = col
539
+ ax2.plot(
540
+ x,
541
+ y_sec,
542
+ color=color,
543
+ linestyle="--",
544
+ linewidth=lw,
545
+ label=str(sec_lbl),
546
+ )
547
+ sec_label = config.get("_secondary_y_label", "")
548
+ if sec_label:
549
+ ax2.set_ylabel(sec_label)
550
+ if config.get("_secondary_y_scale") == "log":
551
+ ax2.set_yscale("log")
552
+ # Merge legends from both axes
553
+ h1, l1 = ax.get_legend_handles_labels()
554
+ h2, l2 = ax2.get_legend_handles_labels()
555
+ ax.legend(h1 + h2, l1 + l2, loc="best")
556
+
557
+ return fig, False
558
+
559
+ if plot_type == "scatter_plot":
560
+ x = _apply_x_transform(_extract_col(data, config.get("_x_col")), config)
561
+ y = _apply_y_transform(_extract_col(data, config.get("_y_col")), config)
562
+ fig, ax = scatter_plot(x, y, **kwargs)
563
+ return fig, False
564
+
565
+ if plot_type == "heatmap":
566
+ if config.get("_use_numeric_df"):
567
+ mat = data[_numeric_cols(data)].values
568
+ else:
569
+ mat = np.asarray(data)
570
+ mat = _apply_z_transform(mat, config)
571
+ if config.get("cmap_reversed"):
572
+ cmap_val = kwargs.get("cmap", "viridis")
573
+ if not str(cmap_val).endswith("_r"):
574
+ kwargs["cmap"] = str(cmap_val) + "_r"
575
+ if config.get("z_scale") == "log":
576
+ from matplotlib.colors import LogNorm
577
+
578
+ vmin = kwargs.pop("vmin", None)
579
+ vmax = kwargs.pop("vmax", None)
580
+ kwargs["norm"] = LogNorm(
581
+ vmin=vmin if (vmin and vmin > 0) else None,
582
+ vmax=vmax if (vmax and vmax > 0) else None,
583
+ )
584
+ fig, ax = heatmap(mat, **kwargs)
585
+ return fig, False
586
+
587
+ if plot_type == "contour_plot":
588
+ if config.get("_mode") == "dataframe":
589
+ x_arr = _apply_x_transform(data[config["_x_col"]].values.astype(float), config)
590
+ y_arr = data[config["_y_col"]].values.astype(float)
591
+ z_arr = data[config["_z_col"]].values.astype(float)
592
+ grid_n = int(config.get("_grid_n", 100))
593
+ interp = config.get("_interp_method", "cubic")
594
+ X, Y, Z = _grid_from_xyz(x_arr, y_arr, z_arr, grid_n, interp)
595
+ else:
596
+ Z = np.asarray(data)
597
+ nrows, ncols = Z.shape
598
+ X, Y = np.meshgrid(np.arange(ncols), np.arange(nrows))
599
+ Z = _apply_z_transform(Z, config)
600
+ if config.get("cmap_reversed"):
601
+ cmap_val = kwargs.get("cmap", "viridis")
602
+ if not str(cmap_val).endswith("_r"):
603
+ kwargs["cmap"] = str(cmap_val) + "_r"
604
+ if config.get("z_scale") == "log":
605
+ from matplotlib.colors import LogNorm
606
+
607
+ vmin = kwargs.pop("vmin", None)
608
+ vmax = kwargs.pop("vmax", None)
609
+ kwargs["norm"] = LogNorm(
610
+ vmin=vmin if (vmin and vmin > 0) else None,
611
+ vmax=vmax if (vmax and vmax > 0) else None,
612
+ )
613
+ fig, ax = contour_plot(X, Y, Z, **kwargs)
614
+ return fig, False
615
+
616
+ # ── Seaborn ─────────────────────────────────────────────────────────────
617
+ if plot_type == "distribution_plot":
618
+ arr = _extract_col(data, config.get("_data_col"))
619
+ fig, ax = distribution_plot(arr, **kwargs)
620
+ return fig, False
621
+
622
+ if plot_type == "box_plot":
623
+ if isinstance(data, pd.DataFrame):
624
+ fig, ax = box_plot(data, **kwargs)
625
+ else:
626
+ fig, ax = box_plot(np.asarray(data).flatten(), **kwargs)
627
+ return fig, False
628
+
629
+ if plot_type == "regression_plot":
630
+ x_col = config.get("_x_col")
631
+ y_col = config.get("_y_col")
632
+ if config.get("_use_df", False):
633
+ fig, ax = regression_plot(x=x_col, y=y_col, data=data, **kwargs)
634
+ else:
635
+ x = _extract_col(data, x_col)
636
+ y = _extract_col(data, y_col)
637
+ fig, ax = regression_plot(x=x, y=y, **kwargs)
638
+ return fig, False
639
+
640
+ # ── Plotly ───────────────────────────────────────────────────────────────
641
+ if plot_type == "interactive_histogram":
642
+ arr = _apply_y_transform(_extract_col(data, config.get("_data_col")), config)
643
+ fig = interactive_histogram(arr, **kwargs)
644
+ return fig, True
645
+
646
+ if plot_type == "interactive_scatter":
647
+ x = _extract_col(data, config.get("_x_col"))
648
+ y = _apply_y_transform(_extract_col(data, config.get("_y_col")), config)
649
+ fig = interactive_scatter(x, y, **kwargs)
650
+ return fig, True
651
+
652
+ if plot_type == "interactive_line":
653
+ x_col = config.get("_x_col")
654
+ y_cols = config.get("_y_cols")
655
+
656
+ if x_col is None and y_cols is None:
657
+ arr = np.asarray(data).flatten()
658
+ x = np.arange(len(arr))
659
+ y = [_apply_y_transform(arr, config)]
660
+ labels = ["Data"]
661
+ elif isinstance(data, pd.DataFrame):
662
+ x = data[x_col].values
663
+ y = [_apply_y_transform(data[c].values, config) for c in y_cols]
664
+ labels = list(y_cols)
665
+ else:
666
+ x = data[:, int(x_col)]
667
+ y = [_apply_y_transform(data[:, int(c)], config) for c in y_cols]
668
+ labels = [f"Col {c}" for c in y_cols]
669
+
670
+ fig = interactive_line(x, y, labels=labels, **kwargs)
671
+ _apply_plotly_palette(fig, config)
672
+ return fig, True
673
+
674
+ if plot_type == "interactive_heatmap":
675
+ if config.get("_use_numeric_df"):
676
+ mat = data[_numeric_cols(data)].values
677
+ else:
678
+ mat = np.asarray(data)
679
+ fig = interactive_heatmap(mat, **kwargs)
680
+ return fig, True
681
+
682
+ if plot_type == "interactive_3d_surface":
683
+ Z = np.asarray(data)
684
+ nrows, ncols = Z.shape
685
+ X, Y = np.meshgrid(np.arange(ncols), np.arange(nrows))
686
+ fig = interactive_3d_surface(X, Y, Z, **kwargs)
687
+ return fig, True
688
+
689
+ # ── M14 Extended Plot Types ──────────────────────────────────────────────
690
+ if plot_type == "bar_chart":
691
+ cat_col = config.get("_cat_col")
692
+ val_col = config.get("_val_col")
693
+ yerr_col = config.get("_yerr_col")
694
+ hue_cols = config.get("_hue_cols")
695
+
696
+ if isinstance(data, pd.DataFrame):
697
+ if cat_col is not None:
698
+ cats = data[cat_col].astype(str).tolist()
699
+ else:
700
+ cats = [str(i) for i in range(len(data))]
701
+
702
+ if hue_cols:
703
+ # Grouped / stacked: each hue column is one group
704
+ vals = np.array([_apply_y_transform(data[c].values, config) for c in hue_cols])
705
+ kwargs["hue"] = hue_cols
706
+ else:
707
+ vals = _apply_y_transform(
708
+ data[val_col].values if val_col else data.iloc[:, 0].values,
709
+ config,
710
+ )
711
+ yerr = data[yerr_col].values if (yerr_col and yerr_col in data.columns) else None
712
+ elif isinstance(data, np.ndarray):
713
+ arr = np.asarray(data)
714
+ if arr.ndim == 2 and cat_col is not None:
715
+ cats = [str(v) for v in arr[:, int(cat_col)]]
716
+ raw = arr[:, int(val_col)] if val_col else arr[:, 1]
717
+ vals = _apply_y_transform(raw, config)
718
+ else:
719
+ arr_flat = arr.flatten()
720
+ cats = [str(i) for i in range(len(arr_flat))]
721
+ vals = _apply_y_transform(arr_flat, config)
722
+ yerr = None
723
+ else:
724
+ cats = [str(i) for i in range(len(data))]
725
+ vals = np.asarray(data).flatten()
726
+ yerr = None
727
+
728
+ _set_mpl_color_cycle(config)
729
+ fig, ax, _ = bar_chart(cats, vals, yerr=yerr, **kwargs)
730
+ return fig, False
731
+
732
+ if plot_type == "waterfall_plot":
733
+ x_col = config.get("_x_col")
734
+ y_cols = config.get("_y_cols")
735
+
736
+ if isinstance(data, pd.DataFrame) and x_col and y_cols:
737
+ x = _apply_x_transform(data[x_col].values, config)
738
+ y_mat = np.array([_apply_y_transform(data[c].values, config) for c in y_cols])
739
+ labels_wf = list(y_cols)
740
+ elif isinstance(data, np.ndarray) and data.ndim == 2:
741
+ if x_col is not None and y_cols:
742
+ x = _apply_x_transform(data[:, int(x_col)], config)
743
+ y_mat = np.array([_apply_y_transform(data[:, int(c)], config) for c in y_cols])
744
+ labels_wf = [f"Col {c}" for c in y_cols]
745
+ else:
746
+ x = np.arange(data.shape[1])
747
+ y_mat = _apply_y_transform(data, config)
748
+ labels_wf = None
749
+ else:
750
+ arr = np.asarray(data)
751
+ x = np.arange(arr.shape[-1] if arr.ndim > 1 else len(arr))
752
+ y_mat = arr.reshape(-1, x.shape[0]) if arr.ndim > 1 else arr.reshape(1, -1)
753
+ labels_wf = None
754
+
755
+ fig, ax, _ = waterfall_plot(x, y_mat, labels=labels_wf, **kwargs)
756
+ return fig, False
757
+
758
+ if plot_type == "dual_axis_plot":
759
+ x_col = config.get("_x_col")
760
+ y1_col = config.get("_y1_col")
761
+ y2_col = config.get("_y2_col")
762
+
763
+ if isinstance(data, pd.DataFrame):
764
+ x = _apply_x_transform(data[x_col].values, config)
765
+ y1 = _apply_y_transform(data[y1_col].values, config)
766
+ y2 = _apply_y_transform(data[y2_col].values, config)
767
+ kwargs.setdefault("label1", y1_col)
768
+ kwargs.setdefault("label2", y2_col)
769
+ kwargs.setdefault("ylabel1", y1_col)
770
+ kwargs.setdefault("ylabel2", y2_col)
771
+ elif isinstance(data, np.ndarray) and data.ndim == 2 and x_col is not None:
772
+ x = _apply_x_transform(data[:, int(x_col)], config)
773
+ y1 = _apply_y_transform(data[:, int(y1_col)], config)
774
+ y2 = _apply_y_transform(data[:, int(y2_col)], config)
775
+ else:
776
+ raise ValueError("Dual Y-axis plot requires a DataFrame or 2-D array.")
777
+
778
+ fig, ax, _ = dual_axis_plot(x, y1, y2, **kwargs)
779
+ return fig, False
780
+
781
+ if plot_type == "z_colored_scatter":
782
+ x = _apply_x_transform(_extract_col(data, config.get("_x_col")), config)
783
+ y = _apply_y_transform(_extract_col(data, config.get("_y_col")), config)
784
+ z = _extract_col(data, config.get("_z_col"))
785
+ fig, ax, _ = z_colored_scatter(x, y, z, **kwargs)
786
+ return fig, False
787
+
788
+ if plot_type == "bubble_chart":
789
+ x = _apply_x_transform(_extract_col(data, config.get("_x_col")), config)
790
+ y = _apply_y_transform(_extract_col(data, config.get("_y_col")), config)
791
+ sizes = _extract_col(data, config.get("_size_col"))
792
+ z_col = config.get("_z_col")
793
+ z = _extract_col(data, z_col) if z_col else None
794
+ if z is not None:
795
+ kwargs["z"] = z
796
+ fig, ax, _ = bubble_chart(x, y, sizes, **kwargs)
797
+ return fig, False
798
+
799
+ if plot_type == "polar_plot":
800
+ theta_col = config.get("_theta_col")
801
+ r_col = config.get("_r_col")
802
+ if theta_col is None and r_col is None:
803
+ arr = np.asarray(data).flatten()
804
+ theta = np.linspace(0, 2 * np.pi, len(arr))
805
+ r = arr
806
+ else:
807
+ theta = _extract_col(data, theta_col)
808
+ r = _extract_col(data, r_col)
809
+ fig, ax, _ = polar_plot(theta, r, **kwargs)
810
+ return fig, False
811
+
812
+ if plot_type == "histogram_2d":
813
+ x = _apply_x_transform(_extract_col(data, config.get("_x_col")), config)
814
+ y = _apply_y_transform(_extract_col(data, config.get("_y_col")), config)
815
+ fig, ax, _ = histogram_2d(x, y, **kwargs)
816
+ return fig, False
817
+
818
+ if plot_type == "pair_plot":
819
+ if not isinstance(data, pd.DataFrame):
820
+ raise ValueError("Pair plot requires a DataFrame.")
821
+ vars_sel = config.get("vars")
822
+ hue_sel = config.get("hue")
823
+ pk = {k: v for k, v in kwargs.items() if k not in ("xlabel", "ylabel", "figsize")}
824
+ if vars_sel:
825
+ pk["vars"] = vars_sel
826
+ if hue_sel:
827
+ pk["hue"] = hue_sel
828
+ fig, ax, _ = pair_plot(data, **pk)
829
+ return fig, False
830
+
831
+ if plot_type == "interactive_3d_scatter":
832
+ x = _extract_col(data, config.get("_x_col"))
833
+ y = _extract_col(data, config.get("_y_col"))
834
+ z = _extract_col(data, config.get("_z_col"))
835
+ color_col = config.get("_color_col")
836
+ size_col = config.get("_size_col")
837
+ if color_col is not None:
838
+ kwargs["color"] = _extract_col(data, color_col)
839
+ if size_col is not None:
840
+ kwargs["size"] = _extract_col(data, size_col)
841
+ fig, _ = interactive_3d_scatter(x, y, z, **kwargs)
842
+ return fig, True
843
+
844
+ if plot_type == "scatter_with_regression":
845
+ x = _apply_x_transform(_extract_col(data, config.get("_x_col")), config)
846
+ y = _apply_y_transform(_extract_col(data, config.get("_y_col")), config)
847
+ fig, ax, _ = scatter_with_regression(x, y, **kwargs)
848
+ return fig, False
849
+
850
+ if plot_type == "residual_plot":
851
+ x = _apply_x_transform(_extract_col(data, config.get("_x_col")), config)
852
+ y_actual = _extract_col(data, config.get("_y_actual_col"))
853
+ y_fitted = _extract_col(data, config.get("_y_fitted_col"))
854
+ fig, ax, _ = residual_plot(x, y_actual, y_fitted, **kwargs)
855
+ return fig, False
856
+
857
+ if plot_type == "interactive_ternary":
858
+ a = _extract_col(data, config.get("_a_col"))
859
+ b = _extract_col(data, config.get("_b_col"))
860
+ c = _extract_col(data, config.get("_c_col"))
861
+ color_col = config.get("_color_col")
862
+ if color_col is not None:
863
+ kwargs["color"] = _extract_col(data, color_col)
864
+ fig, _ = interactive_ternary(a, b, c, **kwargs)
865
+ return fig, True
866
+
867
+ if plot_type == "broken_axis_plot":
868
+ x = _apply_x_transform(_extract_col(data, config.get("_x_col")), config)
869
+ y = _apply_y_transform(_extract_col(data, config.get("_y_col")), config)
870
+ breaks = config.get("_breaks", [(0.0, 0.0)])
871
+ axis = config.get("axis", "x")
872
+ lw = config.get("linewidth", 1.5)
873
+ color = kwargs.get("color", "steelblue")
874
+ title = kwargs.get("title")
875
+ xlabel = kwargs.get("xlabel")
876
+ ylabel = kwargs.get("ylabel")
877
+ fig, ax, _ = broken_axis_plot(
878
+ x,
879
+ y,
880
+ breaks=breaks,
881
+ axis=axis,
882
+ linewidth=lw,
883
+ color=color,
884
+ title=title,
885
+ xlabel=xlabel,
886
+ ylabel=ylabel,
887
+ )
888
+ return fig, False
889
+
890
+ if plot_type == "inset_plot":
891
+ x = _apply_x_transform(_extract_col(data, config.get("_x_col")), config)
892
+ y = _apply_y_transform(_extract_col(data, config.get("_y_col")), config)
893
+ x_inset = _apply_x_transform(_extract_col(data, config.get("_xi_col")), config)
894
+ y_inset = _apply_y_transform(_extract_col(data, config.get("_yi_col")), config)
895
+ ib = [
896
+ float(config.get("_ib_x0", 0.55)),
897
+ float(config.get("_ib_y0", 0.55)),
898
+ float(config.get("_ib_w", 0.4)),
899
+ float(config.get("_ib_h", 0.35)),
900
+ ]
901
+ region_start = config.get("_region_start")
902
+ region_end = config.get("_region_end")
903
+ indicate_region = None
904
+ if region_start is not None and region_end is not None:
905
+ indicate_region = (float(region_start), float(region_end))
906
+ fig, ax, _ = inset_plot(
907
+ x,
908
+ y,
909
+ x_inset,
910
+ y_inset,
911
+ inset_bounds=ib,
912
+ indicate_region=indicate_region,
913
+ title=kwargs.get("title"),
914
+ xlabel=kwargs.get("xlabel"),
915
+ ylabel=kwargs.get("ylabel"),
916
+ inset_xlabel=config.get("inset_xlabel"),
917
+ inset_ylabel=config.get("inset_ylabel"),
918
+ linewidth=float(config.get("linewidth", 1.5)),
919
+ figsize=config.get("figsize", (8, 6)),
920
+ )
921
+ return fig, False
922
+
923
+ raise ValueError(f"Unhandled plot type: {plot_type}")
924
+
925
+
926
+ # ── M16: Annotation helpers ───────────────────────────────────────────────────
927
+
928
+
929
+ def _fig_to_png_bytes(fig) -> bytes:
930
+ """Render a matplotlib figure to PNG bytes.
931
+
932
+ Storing bytes (not the figure object) in session state avoids the
933
+ ``MediaFileHandler: Missing file`` error that occurs when Streamlit's
934
+ in-memory media store is cleared between reruns.
935
+ """
936
+ import io as _io
937
+
938
+ buf = _io.BytesIO()
939
+ fig.savefig(buf, format="png", dpi=150, bbox_inches="tight")
940
+ return buf.getvalue()
941
+
942
+
943
+ def _regen_with_overlays() -> None:
944
+ """Re-generate the current matplotlib figure and re-apply all overlays.
945
+
946
+ Called after any change to ``qp_ann_overlays`` so the display stays in sync.
947
+ Silently no-ops if the required state keys are missing or generation fails.
948
+ """
949
+ pt = st.session_state.get("qp_current_plot_type", "")
950
+ ds = st.session_state.get("qp_dataset", "")
951
+ cfg = st.session_state.get("qp_config", {})
952
+ if not (pt and ds and cfg):
953
+ return
954
+ d = get_dataset(ds)
955
+ if d is None:
956
+ return
957
+ try:
958
+ fig, is_plotly = _generate_plot(pt, d, cfg)
959
+ if not is_plotly:
960
+ _apply_mpl_style(fig, cfg)
961
+ overlays = st.session_state.get("qp_ann_overlays", [])
962
+ if overlays and fig.axes:
963
+ from eagleplot.annotations import apply_annotations
964
+
965
+ apply_annotations(fig.axes[0], overlays)
966
+ st.session_state.qp_mpl_fig = fig
967
+ st.session_state.qp_mpl_png = _fig_to_png_bytes(fig)
968
+ except Exception: # noqa: BLE001
969
+ pass # preserve the last good figure; user will see the error on next generate
970
+
971
+
972
+ def _annotation_input_widgets(ann_type: str) -> dict | None:
973
+ """Render input widgets for *ann_type* and return the overlay dict (or None).
974
+
975
+ All widget keys are prefixed with ``ann_new_`` to avoid collision with other
976
+ widgets on the page.
977
+ """
978
+ from eagleplot.annotations import ANNOTATION_COLORS, ANNOTATION_LINESTYLES
979
+
980
+ color = st.selectbox(
981
+ "Color",
982
+ ANNOTATION_COLORS,
983
+ key="ann_new_color",
984
+ )
985
+ label = st.text_input("Legend label (optional)", key="ann_new_label")
986
+
987
+ if ann_type == "Horizontal line":
988
+ y_val = st.number_input("y value", value=0.0, key="ann_new_y")
989
+ ls = st.selectbox("Line style", ANNOTATION_LINESTYLES, key="ann_new_ls")
990
+ lw = st.slider("Line width", 0.5, 4.0, 1.5, 0.25, key="ann_new_lw")
991
+ return {
992
+ "type": "hline",
993
+ "y": y_val,
994
+ "color": color,
995
+ "linestyle": ls,
996
+ "lw": lw,
997
+ "label": label or "",
998
+ }
999
+
1000
+ if ann_type == "Vertical line":
1001
+ x_val = st.number_input("x value", value=0.0, key="ann_new_x")
1002
+ ls = st.selectbox("Line style", ANNOTATION_LINESTYLES, key="ann_new_ls")
1003
+ lw = st.slider("Line width", 0.5, 4.0, 1.5, 0.25, key="ann_new_lw")
1004
+ return {
1005
+ "type": "vline",
1006
+ "x": x_val,
1007
+ "color": color,
1008
+ "linestyle": ls,
1009
+ "lw": lw,
1010
+ "label": label or "",
1011
+ }
1012
+
1013
+ if ann_type == "Horizontal span":
1014
+ c1, c2 = st.columns(2)
1015
+ y1 = c1.number_input("y1", value=0.0, key="ann_new_y1")
1016
+ y2 = c2.number_input("y2", value=1.0, key="ann_new_y2")
1017
+ alpha = st.slider("Opacity", 0.05, 1.0, 0.25, 0.05, key="ann_new_alpha")
1018
+ return {
1019
+ "type": "hspan",
1020
+ "y1": y1,
1021
+ "y2": y2,
1022
+ "color": color,
1023
+ "alpha": alpha,
1024
+ "label": label or "",
1025
+ }
1026
+
1027
+ if ann_type == "Vertical span":
1028
+ c1, c2 = st.columns(2)
1029
+ x1 = c1.number_input("x1", value=0.0, key="ann_new_x1")
1030
+ x2 = c2.number_input("x2", value=1.0, key="ann_new_x2")
1031
+ alpha = st.slider("Opacity", 0.05, 1.0, 0.25, 0.05, key="ann_new_alpha")
1032
+ return {
1033
+ "type": "vspan",
1034
+ "x1": x1,
1035
+ "x2": x2,
1036
+ "color": color,
1037
+ "alpha": alpha,
1038
+ "label": label or "",
1039
+ }
1040
+
1041
+ if ann_type == "Text":
1042
+ text_val = st.text_input("Text", value="annotation", key="ann_new_text")
1043
+ c1, c2 = st.columns(2)
1044
+ x_pos = c1.number_input("x", value=0.0, key="ann_new_x")
1045
+ y_pos = c2.number_input("y", value=0.0, key="ann_new_y")
1046
+ fontsize = st.slider("Font size", 6, 24, 10, 1, key="ann_new_fs")
1047
+ use_arrow = st.checkbox("Add arrow", value=False, key="ann_new_arrow")
1048
+ tx, ty = x_pos, y_pos
1049
+ if use_arrow:
1050
+ ca, cb = st.columns(2)
1051
+ tx = ca.number_input("Arrow tail x", value=float(x_pos) + 0.5, key="ann_new_tx")
1052
+ ty = cb.number_input("Arrow tail y", value=float(y_pos) + 0.5, key="ann_new_ty")
1053
+ return {
1054
+ "type": "text",
1055
+ "x": x_pos,
1056
+ "y": y_pos,
1057
+ "text": text_val,
1058
+ "color": color,
1059
+ "fontsize": fontsize,
1060
+ "arrow": use_arrow,
1061
+ "tx": tx,
1062
+ "ty": ty,
1063
+ "label": label or "",
1064
+ }
1065
+
1066
+ if ann_type == "Rectangle":
1067
+ c1, c2 = st.columns(2)
1068
+ x1 = c1.number_input("x1", value=0.0, key="ann_new_x1")
1069
+ y1 = c2.number_input("y1", value=0.0, key="ann_new_y1")
1070
+ c3, c4 = st.columns(2)
1071
+ x2 = c3.number_input("x2", value=1.0, key="ann_new_x2")
1072
+ y2 = c4.number_input("y2", value=1.0, key="ann_new_y2")
1073
+ alpha = st.slider("Opacity", 0.05, 1.0, 0.25, 0.05, key="ann_new_alpha")
1074
+ face = st.selectbox("Fill color", ["none"] + ANNOTATION_COLORS, key="ann_new_face")
1075
+ return {
1076
+ "type": "rectangle",
1077
+ "x1": x1,
1078
+ "y1": y1,
1079
+ "x2": x2,
1080
+ "y2": y2,
1081
+ "color": color,
1082
+ "facecolor": face,
1083
+ "alpha": alpha,
1084
+ "label": label or "",
1085
+ }
1086
+
1087
+ if ann_type == "Ellipse":
1088
+ c1, c2 = st.columns(2)
1089
+ cx = c1.number_input("Center x", value=0.0, key="ann_new_cx")
1090
+ cy = c2.number_input("Center y", value=0.0, key="ann_new_cy")
1091
+ c3, c4 = st.columns(2)
1092
+ w = c3.number_input("Width", value=1.0, min_value=0.001, key="ann_new_w")
1093
+ h = c4.number_input("Height", value=1.0, min_value=0.001, key="ann_new_h")
1094
+ alpha = st.slider("Opacity", 0.05, 1.0, 0.25, 0.05, key="ann_new_alpha")
1095
+ face = st.selectbox("Fill color", ["none"] + ANNOTATION_COLORS, key="ann_new_face")
1096
+ return {
1097
+ "type": "ellipse",
1098
+ "cx": cx,
1099
+ "cy": cy,
1100
+ "width": w,
1101
+ "height": h,
1102
+ "color": color,
1103
+ "facecolor": face,
1104
+ "alpha": alpha,
1105
+ "label": label or "",
1106
+ }
1107
+
1108
+ return None
1109
+
1110
+
1111
+ def _render_annotation_panel() -> None:
1112
+ """Render the full annotations expander panel for the current matplotlib figure."""
1113
+ from eagleplot.annotations import describe_overlay
1114
+
1115
+ overlays: list = st.session_state.qp_ann_overlays
1116
+
1117
+ # ── Existing annotations ──────────────────────────────────────────────────
1118
+ if overlays:
1119
+ st.markdown("**Current annotations**")
1120
+ for i, ov in enumerate(overlays):
1121
+ c_desc, c_del = st.columns([5, 1])
1122
+ c_desc.caption(describe_overlay(ov))
1123
+ if c_del.button("✕", key=f"ann_del_{i}", help="Remove"):
1124
+ st.session_state.qp_ann_overlays.pop(i)
1125
+ _regen_with_overlays()
1126
+ st.rerun()
1127
+
1128
+ if st.button("Clear all annotations", key="ann_clear_all"):
1129
+ st.session_state.qp_ann_overlays = []
1130
+ _regen_with_overlays()
1131
+ st.rerun()
1132
+
1133
+ st.markdown("---")
1134
+
1135
+ # ── Add new annotation ────────────────────────────────────────────────────
1136
+ st.markdown("**Add annotation**")
1137
+ _ANN_TYPES = [
1138
+ "Horizontal line",
1139
+ "Vertical line",
1140
+ "Horizontal span",
1141
+ "Vertical span",
1142
+ "Text",
1143
+ "Rectangle",
1144
+ "Ellipse",
1145
+ ]
1146
+ ann_type = st.selectbox("Annotation type", _ANN_TYPES, key="ann_type_sel")
1147
+ new_overlay = _annotation_input_widgets(ann_type)
1148
+
1149
+ if new_overlay is not None:
1150
+ if st.button("Add annotation", type="primary", key="ann_add_btn"):
1151
+ st.session_state.qp_ann_overlays.append(new_overlay)
1152
+ _regen_with_overlays()
1153
+ st.rerun()
1154
+
1155
+
1156
+ # ── Page layout ──────────────────────────────────────────────────────────────
1157
+
1158
+ st.title("Quick Plot")
1159
+ st.caption(
1160
+ "Choose a dataset, select a plot type, configure parameters, and generate your visualization."
1161
+ )
1162
+
1163
+ # ── Style Presets panel ───────────────────────────────────────────────────────
1164
+ # Helpers to move values between _shared_* widget keys and config.json presets.
1165
+
1166
+
1167
+ def _collect_preset_settings() -> dict:
1168
+ """Read all current shared widget values into a plain config dict."""
1169
+ settings: dict = {}
1170
+ for cfg_key, widget_key in _DEFAULTS_KEY_MAP.items():
1171
+ val = st.session_state.get(widget_key)
1172
+ if val is not None:
1173
+ settings[cfg_key] = val
1174
+ # Custom palette colours are not in _DEFAULTS_KEY_MAP
1175
+ custom = st.session_state.get("_custom_colors")
1176
+ if custom:
1177
+ settings["_custom_colors"] = custom
1178
+ # Save current plot type so it can be restored on load
1179
+ if st.session_state.get("qp_category"):
1180
+ settings["_preset_category"] = st.session_state["qp_category"]
1181
+ if st.session_state.get("qp_plottype"):
1182
+ settings["_preset_plot_label"] = st.session_state["qp_plottype"]
1183
+ return settings
1184
+
1185
+
1186
+ def _apply_preset_to_widgets(settings: dict) -> None:
1187
+ """Write a preset dict back into the shared widget session-state keys."""
1188
+ for cfg_key, widget_key in _DEFAULTS_KEY_MAP.items():
1189
+ if cfg_key in settings:
1190
+ st.session_state[widget_key] = settings[cfg_key]
1191
+ if "_custom_colors" in settings:
1192
+ st.session_state["_custom_colors"] = settings["_custom_colors"]
1193
+
1194
+ # Restore saved plot type (category + label)
1195
+ if "_preset_category" in settings:
1196
+ st.session_state["qp_category"] = settings["_preset_category"]
1197
+ if "_preset_plot_label" in settings:
1198
+ st.session_state["qp_plottype"] = settings["_preset_plot_label"]
1199
+ # Derive the plot-type key so _regen_with_overlays uses the right type
1200
+ _cat = settings.get("_preset_category", st.session_state.get("qp_category", ""))
1201
+ _label = settings["_preset_plot_label"]
1202
+ _key = next(
1203
+ (k for k, v in PLOT_TYPES.items() if v["label"] == _label and v["category"] == _cat),
1204
+ None,
1205
+ )
1206
+ if _key:
1207
+ st.session_state["qp_current_plot_type"] = _key
1208
+ # Prevent smart-default from immediately overriding the restored type
1209
+ st.session_state["qp_last_smart_default_for"] = None
1210
+
1211
+ # Merge style values into qp_config so _regen_with_overlays picks them up
1212
+ _style_keys = {k for k in settings if not k.startswith("_")}
1213
+ if _style_keys and st.session_state.get("qp_config"):
1214
+ merged = dict(st.session_state["qp_config"])
1215
+ merged.update({k: settings[k] for k in _style_keys})
1216
+ st.session_state["qp_config"] = merged
1217
+
1218
+
1219
+ with st.expander("Style Presets", expanded=False):
1220
+ _tab_save, _tab_load, _tab_delete = st.tabs(["Save", "Load", "Delete"])
1221
+
1222
+ with _tab_save:
1223
+ st.caption(
1224
+ "Snapshot the current appearance settings under a name. "
1225
+ "Existing presets with the same name are overwritten."
1226
+ )
1227
+ _save_name = st.text_input(
1228
+ "Preset name",
1229
+ placeholder="e.g. Publication, Poster, Dark BG",
1230
+ key="qp_preset_save_name",
1231
+ )
1232
+ if st.button("Save preset", type="primary", key="qp_preset_save_btn"):
1233
+ _name = _save_name.strip()
1234
+ if not _name:
1235
+ st.warning("Enter a preset name first.")
1236
+ else:
1237
+ save_preset(_name, _collect_preset_settings())
1238
+ st.success(f"Preset **{_name}** saved.")
1239
+
1240
+ with _tab_load:
1241
+ _presets = list_presets()
1242
+ if not _presets:
1243
+ st.info("No presets saved yet — use the **Save** tab to create one.")
1244
+ else:
1245
+ _sel_preset = st.selectbox(
1246
+ "Choose preset",
1247
+ _presets,
1248
+ key="qp_preset_sel",
1249
+ )
1250
+ if st.button("Apply preset", type="primary", key="qp_preset_apply"):
1251
+ _apply_preset_to_widgets(load_preset(_sel_preset))
1252
+ _regen_with_overlays() # redraw existing figure with new style
1253
+ st.success(f"Preset **{_sel_preset}** applied.")
1254
+ st.rerun()
1255
+ with st.expander("Preview values"):
1256
+ st.json(load_preset(_sel_preset))
1257
+
1258
+ with _tab_delete:
1259
+ _presets_del = list_presets()
1260
+ if not _presets_del:
1261
+ st.info("No presets saved yet.")
1262
+ else:
1263
+ _del_preset = st.selectbox(
1264
+ "Choose preset to delete",
1265
+ _presets_del,
1266
+ key="qp_preset_del_sel",
1267
+ )
1268
+ if st.button("Delete preset", type="primary", key="qp_preset_del_btn"):
1269
+ if delete_preset(_del_preset):
1270
+ st.success(f"Preset **{_del_preset}** deleted.")
1271
+ st.rerun()
1272
+ else:
1273
+ st.error("Preset not found.")
1274
+
1275
+ # Guard: need at least one dataset
1276
+ summary = get_session_summary()
1277
+ if summary["num_datasets"] == 0:
1278
+ st.warning("No datasets loaded. Go to **Data Upload** first.")
1279
+ st.stop()
1280
+
1281
+ # ── Dataset selector ─────────────────────────────────────────────────────────
1282
+ st.markdown("---")
1283
+ st.markdown("## 1. Dataset")
1284
+
1285
+ dataset_names = list(st.session_state.datasets.keys())
1286
+ default_idx = (
1287
+ dataset_names.index(summary["current_dataset"])
1288
+ if summary["current_dataset"] in dataset_names
1289
+ else 0
1290
+ )
1291
+
1292
+ col1, col2 = st.columns([3, 1])
1293
+ with col1:
1294
+ dataset_name = st.selectbox(
1295
+ "Select dataset",
1296
+ dataset_names,
1297
+ index=default_idx,
1298
+ key="qp_dataset",
1299
+ )
1300
+ with col2:
1301
+ st.write("")
1302
+ st.write("")
1303
+ st.metric("Loaded", summary["num_datasets"])
1304
+
1305
+ if dataset_name:
1306
+ st.session_state.current_dataset = dataset_name
1307
+
1308
+ data = get_dataset(dataset_name)
1309
+
1310
+ # Data type summary
1311
+ if isinstance(data, pd.DataFrame):
1312
+ st.caption(f"DataFrame — {data.shape[0]} rows × {data.shape[1]} cols")
1313
+ elif isinstance(data, np.ndarray):
1314
+ st.caption(f"NumPy array — shape {data.shape}, dtype {data.dtype}")
1315
+
1316
+ # ── Plot type selector ────────────────────────────────────────────────────────
1317
+ st.markdown("---")
1318
+ st.markdown("## 2. Plot Type")
1319
+
1320
+ # Group by category for a two-step selector
1321
+ categories: dict = {}
1322
+ for key, info in PLOT_TYPES.items():
1323
+ cat = info["category"]
1324
+ categories.setdefault(cat, {})[info["label"]] = key
1325
+
1326
+ # Auto-suggest a sensible default when the selected dataset changes
1327
+ if dataset_name and dataset_name != st.session_state.qp_last_smart_default_for:
1328
+ _sug_cat, _sug_label = _suggest_plot_type(data)
1329
+ st.session_state.qp_category = _sug_cat
1330
+ st.session_state.qp_plottype = _sug_label
1331
+ st.session_state.qp_last_smart_default_for = dataset_name
1332
+
1333
+ col1, col2 = st.columns(2)
1334
+ with col1:
1335
+ category = st.selectbox("Library", list(categories.keys()), key="qp_category")
1336
+ with col2:
1337
+ label_to_key = categories[category]
1338
+ # Guard: if stored label doesn't exist in this category, fall back to first
1339
+ if st.session_state.get("qp_plottype") not in label_to_key:
1340
+ st.session_state.qp_plottype = next(iter(label_to_key))
1341
+ plot_label = st.selectbox("Plot type", list(label_to_key.keys()), key="qp_plottype")
1342
+
1343
+ plot_type = label_to_key[plot_label]
1344
+
1345
+ # Brief description
1346
+ _DESCRIPTIONS = {
1347
+ "histogram": "Distribution of a single numeric variable.",
1348
+ "line_plot": "One or more lines over a shared X axis.",
1349
+ "scatter_plot": "X vs Y scatter with optional color/size mapping.",
1350
+ "heatmap": "2-D matrix as a color-encoded grid.",
1351
+ "contour_plot": "Iso-value contours over a 2-D scalar field.",
1352
+ "distribution_plot": "Seaborn histogram, KDE, or ECDF.",
1353
+ "box_plot": "Seaborn box, violin, or boxen plot.",
1354
+ "regression_plot": "Seaborn scatter with regression line and CI.",
1355
+ "interactive_histogram": "Plotly interactive histogram (zoom / hover).",
1356
+ "interactive_scatter": "Plotly interactive scatter (zoom / hover).",
1357
+ "interactive_line": "Plotly interactive line (multiple traces).",
1358
+ "interactive_heatmap": "Plotly interactive heatmap (hover values).",
1359
+ "interactive_3d_surface": "Plotly 3-D surface (rotate / zoom).",
1360
+ }
1361
+ st.caption(_DESCRIPTIONS.get(plot_type, ""))
1362
+
1363
+ # ── Configuration widgets ─────────────────────────────────────────────────────
1364
+ st.markdown("---")
1365
+ st.markdown("## 3. Configuration")
1366
+
1367
+ config = get_plot_config_widgets(plot_type, data)
1368
+
1369
+ # ── Generate ──────────────────────────────────────────────────────────────────
1370
+ st.markdown("---")
1371
+
1372
+ if not config:
1373
+ st.info("Adjust the configuration above to enable plot generation.")
1374
+ else:
1375
+ _btn_col, _clear_col = st.columns([3, 1])
1376
+ with _btn_col:
1377
+ _do_generate = st.button(
1378
+ "Generate Plot",
1379
+ type="primary",
1380
+ key="qp_generate",
1381
+ )
1382
+ with _clear_col:
1383
+ if st.session_state.qp_is_plotly and st.session_state.qp_annotations:
1384
+ if st.button("Clear markers", key="qp_clear_ann"):
1385
+ st.session_state.qp_annotations = []
1386
+ st.rerun()
1387
+
1388
+ if _do_generate:
1389
+ try:
1390
+ with st.spinner("Generating…"):
1391
+ fig, is_plotly = _generate_plot(plot_type, data, config)
1392
+ if is_plotly:
1393
+ _apply_plotly_style(fig, config)
1394
+ st.session_state.qp_fig_dict = fig.to_dict()
1395
+ st.session_state.qp_mpl_fig = None
1396
+ else:
1397
+ _apply_mpl_style(fig, config)
1398
+ # Re-apply any existing overlays to the fresh figure
1399
+ _overlays = st.session_state.get("qp_ann_overlays", [])
1400
+ if _overlays and fig.axes:
1401
+ from eagleplot.annotations import apply_annotations
1402
+
1403
+ apply_annotations(fig.axes[0], _overlays)
1404
+ st.session_state.qp_fig_dict = None
1405
+ st.session_state.qp_mpl_fig = fig
1406
+ st.session_state.qp_mpl_png = _fig_to_png_bytes(fig)
1407
+ st.session_state.qp_is_plotly = is_plotly
1408
+ st.session_state.qp_config = config
1409
+ st.session_state.qp_current_plot_type = plot_type # M16
1410
+ st.session_state.qp_annotations = [] # reset Plotly click markers
1411
+ st.session_state.qp_show_plotly_version = False # reset conversion
1412
+ st.session_state.qp_zoom_xlim = None # reset zoom on new plot
1413
+ st.session_state.qp_zoom_ylim = None
1414
+
1415
+ add_plot_to_history(
1416
+ {
1417
+ "type": plot_type,
1418
+ "dataset": dataset_name,
1419
+ "config": {k: str(v) for k, v in config.items()},
1420
+ "figure": fig if not is_plotly else None,
1421
+ }
1422
+ )
1423
+ st.success(f"Saved to plot history (total: {len(st.session_state.plot_history)})")
1424
+
1425
+ except Exception as exc:
1426
+ st.error(f"Error generating plot: {exc}")
1427
+ with st.expander("Error details"):
1428
+ st.code(traceback.format_exc())
1429
+
1430
+ # ── Persistent plot display with click-to-annotate ────────────────────────────
1431
+ if st.session_state.qp_is_plotly and st.session_state.qp_fig_dict:
1432
+ import plotly.graph_objects as go
1433
+
1434
+ _fig = go.Figure(st.session_state.qp_fig_dict)
1435
+
1436
+ # Overlay annotation markers from session state
1437
+ if st.session_state.qp_annotations:
1438
+ _ax = [a["x"] for a in st.session_state.qp_annotations]
1439
+ _ay = [a["y"] for a in st.session_state.qp_annotations]
1440
+ _al = [format_marker_label(a["x"], a["y"]) for a in st.session_state.qp_annotations]
1441
+ _fig.add_trace(
1442
+ go.Scatter(
1443
+ x=_ax,
1444
+ y=_ay,
1445
+ mode="markers+text",
1446
+ text=_al,
1447
+ textposition="top center",
1448
+ marker=dict(symbol="x-open", size=14, color="red", line=dict(width=2)),
1449
+ name="Markers",
1450
+ showlegend=True,
1451
+ )
1452
+ )
1453
+
1454
+ st.markdown("### Plot")
1455
+ st.caption(
1456
+ "Click any data point to mark it. Use **Clear markers** above to remove all marks."
1457
+ if not st.session_state.qp_annotations
1458
+ else f"{len(st.session_state.qp_annotations)} point(s) marked."
1459
+ )
1460
+
1461
+ try:
1462
+ _event = st.plotly_chart(
1463
+ _fig,
1464
+ width="stretch",
1465
+ on_select="rerun",
1466
+ selection_mode="points",
1467
+ key="qp_chart",
1468
+ )
1469
+ # Handle newly selected points (Streamlit returns each point as a dict)
1470
+ for _ann in selected_points(_event):
1471
+ if _ann not in st.session_state.qp_annotations:
1472
+ st.session_state.qp_annotations.append(_ann)
1473
+ except TypeError:
1474
+ # on_select not supported in this Streamlit version
1475
+ st.plotly_chart(_fig, width="stretch")
1476
+ st.info(
1477
+ "Upgrade Streamlit (≥ 1.33) to enable click-to-annotate.",
1478
+ )
1479
+
1480
+ # Marked-points table
1481
+ if st.session_state.qp_annotations:
1482
+ with st.expander(f"Marked points ({len(st.session_state.qp_annotations)})"):
1483
+ _ann_df = pd.DataFrame(st.session_state.qp_annotations)
1484
+ st.dataframe(_ann_df, width="stretch")
1485
+
1486
+ # ── Download / export ────────────────────────────────────────────────
1487
+ with st.expander("Export plot & data"):
1488
+ import io as _io
1489
+
1490
+ _ec1, _ec2 = st.columns(2)
1491
+ with _ec1:
1492
+ st.markdown("**Interactive plot (HTML)**")
1493
+ try:
1494
+ _html_bytes = _fig.to_html(include_plotlyjs="cdn").encode("utf-8")
1495
+ st.download_button(
1496
+ "Download HTML",
1497
+ data=_html_bytes,
1498
+ file_name="plot.html",
1499
+ mime="text/html",
1500
+ key="qp_dl_html",
1501
+ )
1502
+ except Exception as _e:
1503
+ st.warning(f"HTML export failed: {_e}")
1504
+ with _ec2:
1505
+ if isinstance(data, pd.DataFrame):
1506
+ st.markdown("**Dataset (CSV)**")
1507
+ _csv_buf = _io.StringIO()
1508
+ data.to_csv(_csv_buf, index=False)
1509
+ st.download_button(
1510
+ "Download CSV",
1511
+ data=_csv_buf.getvalue(),
1512
+ file_name=f"{dataset_name}.csv",
1513
+ mime="text/csv",
1514
+ key="qp_dl_csv_plotly",
1515
+ )
1516
+
1517
+ elif st.session_state.qp_mpl_fig is not None:
1518
+ st.markdown("### Plot")
1519
+
1520
+ # ── Convert to Plotly (zoom/pan) toggle ───────────────────────────────
1521
+ _ctp_col1, _ctp_col2 = st.columns([3, 1])
1522
+ with _ctp_col1:
1523
+ if not st.session_state.qp_show_plotly_version:
1524
+ if st.button(
1525
+ "Convert to Plotly (zoom/pan)",
1526
+ key="qp_convert_to_plotly",
1527
+ help=(
1528
+ "Convert the current matplotlib figure to an interactive "
1529
+ "Plotly figure for zooming and panning."
1530
+ ),
1531
+ ):
1532
+ st.session_state.qp_show_plotly_version = True
1533
+ st.rerun()
1534
+ else:
1535
+ if st.button(
1536
+ "Back to static",
1537
+ key="qp_back_to_static",
1538
+ ):
1539
+ st.session_state.qp_show_plotly_version = False
1540
+ st.rerun()
1541
+
1542
+ if st.session_state.qp_show_plotly_version:
1543
+ try:
1544
+ import plotly.tools as _ply_tools
1545
+
1546
+ _mpl_fig_for_conv = st.session_state.qp_mpl_fig
1547
+ _ply_fig = _ply_tools.mpl_to_plotly(_mpl_fig_for_conv)
1548
+ _apply_plotly_style(_ply_fig, st.session_state.qp_config)
1549
+ st.info(
1550
+ "Interactive Plotly version — use mouse to zoom and pan. "
1551
+ "Note: mpl_to_plotly is a best-effort conversion; "
1552
+ "some styling may differ."
1553
+ )
1554
+ st.plotly_chart(_ply_fig, width="stretch", key="qp_plotly_converted")
1555
+ except Exception as _conv_err:
1556
+ st.warning(
1557
+ f"Could not convert figure to Plotly: {_conv_err}. Showing static version instead."
1558
+ )
1559
+ st.image(st.session_state.qp_mpl_png, width="stretch")
1560
+ else:
1561
+ st.image(st.session_state.qp_mpl_png, width="stretch")
1562
+
1563
+ # ── Zoom / Axis Range ─────────────────────────────────────────────────
1564
+ if not st.session_state.qp_show_plotly_version:
1565
+ _zoom_fig = st.session_state.qp_mpl_fig
1566
+ if _zoom_fig is not None and _zoom_fig.axes:
1567
+ _zoom_ax = _zoom_fig.axes[0]
1568
+ _zxlim = _zoom_ax.get_xlim()
1569
+ _zylim = _zoom_ax.get_ylim()
1570
+ with st.expander("Zoom / Axis Range"):
1571
+ _zc1, _zc2, _zc3, _zc4 = st.columns(4)
1572
+ with _zc1:
1573
+ _zxmin = st.number_input(
1574
+ "X min", value=float(_zxlim[0]), key="qp_zoom_xmin", format="%g"
1575
+ )
1576
+ with _zc2:
1577
+ _zxmax = st.number_input(
1578
+ "X max", value=float(_zxlim[1]), key="qp_zoom_xmax", format="%g"
1579
+ )
1580
+ with _zc3:
1581
+ _zymin = st.number_input(
1582
+ "Y min", value=float(_zylim[0]), key="qp_zoom_ymin", format="%g"
1583
+ )
1584
+ with _zc4:
1585
+ _zymax = st.number_input(
1586
+ "Y max", value=float(_zylim[1]), key="qp_zoom_ymax", format="%g"
1587
+ )
1588
+ _zbtn1, _zbtn2 = st.columns(2)
1589
+ with _zbtn1:
1590
+ if st.button("Apply zoom", key="qp_apply_zoom"):
1591
+ if _zxmin < _zxmax and _zymin < _zymax:
1592
+ _zoom_ax.set_xlim(_zxmin, _zxmax)
1593
+ _zoom_ax.set_ylim(_zymin, _zymax)
1594
+ st.session_state.qp_zoom_xlim = (_zxmin, _zxmax)
1595
+ st.session_state.qp_zoom_ylim = (_zymin, _zymax)
1596
+ st.session_state.qp_mpl_png = _fig_to_png_bytes(_zoom_fig)
1597
+ st.rerun()
1598
+ else:
1599
+ st.error("Min must be less than max for both axes.")
1600
+ with _zbtn2:
1601
+ if st.button("Reset zoom", key="qp_reset_zoom"):
1602
+ st.session_state.qp_zoom_xlim = None
1603
+ st.session_state.qp_zoom_ylim = None
1604
+ _regen_with_overlays()
1605
+ st.rerun()
1606
+
1607
+ # ── M16: Annotations panel ────────────────────────────────────────────
1608
+ _ann_label = (
1609
+ f"Annotations ({len(st.session_state.qp_ann_overlays)} active)"
1610
+ if st.session_state.qp_ann_overlays
1611
+ else "Annotations"
1612
+ )
1613
+ with st.expander(_ann_label, expanded=bool(st.session_state.qp_ann_overlays)):
1614
+ _render_annotation_panel()
1615
+
1616
+ # ── Download / export ────────────────────────────────────────────────
1617
+ with st.expander("Export plot & data"):
1618
+ import io as _io
1619
+
1620
+ _mfig = st.session_state.qp_mpl_fig
1621
+ _dl_col1, _dl_col2, _dl_col3, _dl_col4, _dl_col5 = st.columns(5)
1622
+
1623
+ with _dl_col1:
1624
+ _png_buf = _io.BytesIO()
1625
+ _mfig.savefig(_png_buf, format="png", dpi=150, bbox_inches="tight")
1626
+ _png_buf.seek(0)
1627
+ st.download_button(
1628
+ "PNG (150 dpi)",
1629
+ data=_png_buf,
1630
+ file_name="plot.png",
1631
+ mime="image/png",
1632
+ key="qp_dl_png",
1633
+ )
1634
+
1635
+ with _dl_col2:
1636
+ _png300_buf = _io.BytesIO()
1637
+ _mfig.savefig(_png300_buf, format="png", dpi=300, bbox_inches="tight")
1638
+ _png300_buf.seek(0)
1639
+ st.download_button(
1640
+ "PNG (300 dpi)",
1641
+ data=_png300_buf,
1642
+ file_name="plot_300dpi.png",
1643
+ mime="image/png",
1644
+ key="qp_dl_png300",
1645
+ )
1646
+
1647
+ with _dl_col3:
1648
+ _png600_buf = _io.BytesIO()
1649
+ _mfig.savefig(_png600_buf, format="png", dpi=600, bbox_inches="tight")
1650
+ _png600_buf.seek(0)
1651
+ st.download_button(
1652
+ "PNG (600 dpi)",
1653
+ data=_png600_buf,
1654
+ file_name="plot_600dpi.png",
1655
+ mime="image/png",
1656
+ key="qp_dl_png600",
1657
+ )
1658
+
1659
+ with _dl_col4:
1660
+ _svg_buf = _io.BytesIO()
1661
+ _mfig.savefig(_svg_buf, format="svg", bbox_inches="tight")
1662
+ _svg_buf.seek(0)
1663
+ st.download_button(
1664
+ "SVG (vector)",
1665
+ data=_svg_buf,
1666
+ file_name="plot.svg",
1667
+ mime="image/svg+xml",
1668
+ key="qp_dl_svg",
1669
+ )
1670
+
1671
+ with _dl_col5:
1672
+ _pdf_buf = _io.BytesIO()
1673
+ _mfig.savefig(_pdf_buf, format="pdf", bbox_inches="tight")
1674
+ _pdf_buf.seek(0)
1675
+ st.download_button(
1676
+ "PDF (vector)",
1677
+ data=_pdf_buf,
1678
+ file_name="plot.pdf",
1679
+ mime="application/pdf",
1680
+ key="qp_dl_pdf",
1681
+ )
1682
+
1683
+ if isinstance(data, pd.DataFrame):
1684
+ st.markdown("---")
1685
+ _csv_buf2 = _io.StringIO()
1686
+ data.to_csv(_csv_buf2, index=False)
1687
+ st.download_button(
1688
+ "Download dataset as CSV",
1689
+ data=_csv_buf2.getvalue(),
1690
+ file_name=f"{dataset_name}.csv",
1691
+ mime="text/csv",
1692
+ key="qp_dl_csv_mpl",
1693
+ )