eagleplot 3.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- eagleplot/Home.py +362 -0
- eagleplot/__init__.py +19 -0
- eagleplot/annotations.py +215 -0
- eagleplot/assets/logo.ico +0 -0
- eagleplot/assets/logo.png +0 -0
- eagleplot/assets/nccu-horiz-logo.png +0 -0
- eagleplot/assets/nccu-wings.png +0 -0
- eagleplot/batch.py +446 -0
- eagleplot/cli.py +719 -0
- eagleplot/data_tools.py +849 -0
- eagleplot/example-data/Artificial/bimodal_distribution.csv +501 -0
- eagleplot/example-data/Artificial/correlation_matrix.npy +0 -0
- eagleplot/example-data/Artificial/gaussian_surface.npy +0 -0
- eagleplot/example-data/Artificial/grouped_categorical.csv +241 -0
- eagleplot/example-data/Artificial/ir_spectrum.csv +1802 -0
- eagleplot/example-data/Artificial/molecular_properties.csv +121 -0
- eagleplot/example-data/Artificial/normal_distribution.csv +401 -0
- eagleplot/example-data/Artificial/reaction_kinetics.csv +121 -0
- eagleplot/example-data/Artificial/scatter_correlation.csv +201 -0
- eagleplot/example-data/Artificial/sine_cosine_waves.csv +301 -0
- eagleplot/gallery/box_plot.png +0 -0
- eagleplot/gallery/contour_plot.png +0 -0
- eagleplot/gallery/distribution_plot.png +0 -0
- eagleplot/gallery/heatmap.png +0 -0
- eagleplot/gallery/histogram.png +0 -0
- eagleplot/gallery/interactive_3d_surface.png +0 -0
- eagleplot/gallery/interactive_heatmap.png +0 -0
- eagleplot/gallery/interactive_histogram.png +0 -0
- eagleplot/gallery/interactive_line.png +0 -0
- eagleplot/gallery/interactive_scatter.png +0 -0
- eagleplot/gallery/line_plot.png +0 -0
- eagleplot/gallery/manifest.json +196 -0
- eagleplot/gallery/regression_plot.png +0 -0
- eagleplot/gallery/scatter_plot.png +0 -0
- eagleplot/io.py +1008 -0
- eagleplot/math.py +1210 -0
- eagleplot/molecular/__init__.py +21 -0
- eagleplot/molecular/atom_data.py +287 -0
- eagleplot/molecular/parsers.py +747 -0
- eagleplot/nist.py +241 -0
- eagleplot/pages/10_Molecular_Viz.py +225 -0
- eagleplot/pages/11_Batch_Analysis.py +325 -0
- eagleplot/pages/12_Help.py +199 -0
- eagleplot/pages/13_Settings.py +388 -0
- eagleplot/pages/1_Data_Upload.py +521 -0
- eagleplot/pages/2_Quick_Plot.py +1693 -0
- eagleplot/pages/3_Analysis_Tools.py +2510 -0
- eagleplot/pages/4_Multi_Plot_Dashboard.py +791 -0
- eagleplot/pages/5_Advanced_Plotting.py +691 -0
- eagleplot/pages/6_Export_Results.py +442 -0
- eagleplot/pages/7_Gallery.py +201 -0
- eagleplot/pages/8_Data_Tools.py +434 -0
- eagleplot/pages/9_Spectroscopy.py +821 -0
- eagleplot/pages/__init__.py +8 -0
- eagleplot/peaks.py +513 -0
- eagleplot/plotting.py +2630 -0
- eagleplot/plugin_loader.py +208 -0
- eagleplot/report.py +448 -0
- eagleplot/signal.py +494 -0
- eagleplot/spectroscopy.py +830 -0
- eagleplot/utils/__init__.py +76 -0
- eagleplot/utils/access.py +77 -0
- eagleplot/utils/data_preview.py +352 -0
- eagleplot/utils/plot_config.py +2721 -0
- eagleplot/utils/session_state.py +535 -0
- eagleplot/utils/theming.py +312 -0
- eagleplot/utils/user_settings.py +294 -0
- eagleplot-3.0.0.dist-info/METADATA +304 -0
- eagleplot-3.0.0.dist-info/RECORD +73 -0
- eagleplot-3.0.0.dist-info/WHEEL +5 -0
- eagleplot-3.0.0.dist-info/entry_points.txt +2 -0
- eagleplot-3.0.0.dist-info/licenses/LICENSE +21 -0
- eagleplot-3.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|
+
)
|