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,2721 @@
1
+ """Dynamic plot configuration widgets for Streamlit GUI.
2
+
3
+ Provides widget factories that render Streamlit controls for each plot type
4
+ and return configuration dictionaries ready to pass to plotting functions.
5
+
6
+ Main API
7
+ --------
8
+ get_plot_config_widgets(plot_type, data) -> dict
9
+ Render Streamlit widgets for the given plot type and dataset.
10
+ Returns a config dict with plot parameters and ``_``-prefixed
11
+ data-selection hints (column names or array indices).
12
+
13
+ get_plot_kwargs(config) -> dict
14
+ Strip ``_``-prefixed keys and None values from a config dict so the
15
+ result can be unpacked directly into a plotting function.
16
+
17
+ PLOT_TYPES : dict
18
+ Ordered catalogue of all supported plot types with display labels
19
+ and library categories.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import streamlit as st
25
+ import numpy as np
26
+ import pandas as pd
27
+ from typing import Any, Dict, List, Optional, Tuple
28
+
29
+ # ── Plot type catalogue ──────────────────────────────────────────────────────
30
+
31
+ PLOT_TYPES: Dict[str, Dict[str, str]] = {
32
+ # Matplotlib (static)
33
+ "histogram": {"label": "Histogram", "category": "Matplotlib"},
34
+ "line_plot": {"label": "Line Plot", "category": "Matplotlib"},
35
+ "scatter_plot": {"label": "Scatter Plot", "category": "Matplotlib"},
36
+ "bar_chart": {"label": "Bar Chart", "category": "Matplotlib"},
37
+ "heatmap": {"label": "Heatmap", "category": "Matplotlib"},
38
+ "contour_plot": {"label": "Contour Plot", "category": "Matplotlib"},
39
+ "waterfall_plot": {"label": "Waterfall Plot", "category": "Matplotlib"},
40
+ "dual_axis_plot": {"label": "Dual Y-Axis Plot", "category": "Matplotlib"},
41
+ "broken_axis_plot": {"label": "Broken Axis Plot", "category": "Matplotlib"},
42
+ "z_colored_scatter": {
43
+ "label": "Z-Colored Scatter",
44
+ "category": "Matplotlib",
45
+ },
46
+ "bubble_chart": {"label": "Bubble Chart", "category": "Matplotlib"},
47
+ "polar_plot": {"label": "Polar Plot", "category": "Matplotlib"},
48
+ "histogram_2d": {"label": "2D Histogram", "category": "Matplotlib"},
49
+ # Seaborn (statistical)
50
+ "distribution_plot": {"label": "Distribution", "category": "Seaborn"},
51
+ "box_plot": {"label": "Box / Violin Plot", "category": "Seaborn"},
52
+ "regression_plot": {"label": "Regression Plot", "category": "Seaborn"},
53
+ "pair_plot": {"label": "Pair Plot", "category": "Seaborn"},
54
+ # Plotly (interactive)
55
+ "interactive_histogram": {
56
+ "label": "Interactive Histogram",
57
+ "category": "Plotly",
58
+ },
59
+ "interactive_scatter": {"label": "Interactive Scatter", "category": "Plotly"},
60
+ "interactive_line": {"label": "Interactive Line", "category": "Plotly"},
61
+ "interactive_heatmap": {"label": "Interactive Heatmap", "category": "Plotly"},
62
+ "interactive_3d_surface": {"label": "3D Surface Plot", "category": "Plotly"},
63
+ "interactive_3d_scatter": {"label": "3D Scatter Plot", "category": "Plotly"},
64
+ # M backlog — new plot types
65
+ "scatter_with_regression": {"label": "Scatter + Regression", "category": "Matplotlib"},
66
+ "residual_plot": {"label": "Residual Plot", "category": "Matplotlib"},
67
+ "interactive_ternary": {"label": "Ternary Plot", "category": "Plotly"},
68
+ # Specialty
69
+ "inset_plot": {"label": "Inset Plot", "category": "Specialty"},
70
+ }
71
+
72
+
73
+ # ── Internal helpers ─────────────────────────────────────────────────────────
74
+
75
+ # Named discrete color palettes available in the GUI.
76
+ # Each entry: (display_label, list_of_hex_or_named_colors)
77
+ COLOR_PALETTES: Dict[str, List[str]] = {
78
+ "Default": [
79
+ "steelblue",
80
+ "tomato",
81
+ "seagreen",
82
+ "darkorange",
83
+ "mediumpurple",
84
+ "crimson",
85
+ "teal",
86
+ "slategray",
87
+ ],
88
+ # Wong (2011) — 8-color, safe for ~8% deuteranopia/protanopia population
89
+ "Color-Blind Safe (Wong)": [
90
+ "#000000",
91
+ "#E69F00",
92
+ "#56B4E9",
93
+ "#009E73",
94
+ "#F0E442",
95
+ "#0072B2",
96
+ "#D55E00",
97
+ "#CC79A7",
98
+ ],
99
+ # Okabe & Ito — widely recommended by Nature/Science style guides
100
+ "Color-Blind Safe (Okabe-Ito)": [
101
+ "#E69F00",
102
+ "#56B4E9",
103
+ "#009E73",
104
+ "#F0E442",
105
+ "#0072B2",
106
+ "#D55E00",
107
+ "#CC79A7",
108
+ "#000000",
109
+ ],
110
+ # Paul Tol muted — perceptually distinct, print-friendly
111
+ "Muted (Tol)": [
112
+ "#332288",
113
+ "#88CCEE",
114
+ "#44AA99",
115
+ "#117733",
116
+ "#999933",
117
+ "#DDCC77",
118
+ "#CC6677",
119
+ "#882255",
120
+ ],
121
+ "Pastel": [
122
+ "#AEC6CF",
123
+ "#FFD1DC",
124
+ "#B5EAD7",
125
+ "#FFDAC1",
126
+ "#C7CEEA",
127
+ "#FF9AA2",
128
+ "#E2F0CB",
129
+ "#F2C4CE",
130
+ ],
131
+ "Vibrant": [
132
+ "#E6194B",
133
+ "#3CB44B",
134
+ "#FFE119",
135
+ "#4363D8",
136
+ "#F58231",
137
+ "#911EB4",
138
+ "#42D4F4",
139
+ "#F032E6",
140
+ ],
141
+ }
142
+
143
+ # Convenience list for selectbox / dropdowns
144
+ COLOR_PALETTE_NAMES: List[str] = list(COLOR_PALETTES.keys())
145
+
146
+ # Default single-series color list (backward-compat alias)
147
+ _COLORS = COLOR_PALETTES["Default"]
148
+
149
+ _CMAPS = [
150
+ "viridis",
151
+ "plasma",
152
+ "inferno",
153
+ "magma",
154
+ "cividis",
155
+ "hot",
156
+ "cool",
157
+ "RdYlBu",
158
+ "seismic",
159
+ "bwr",
160
+ "coolwarm",
161
+ ]
162
+ _COLORSCALES = [
163
+ "Viridis",
164
+ "Plasma",
165
+ "Inferno",
166
+ "Magma",
167
+ "Cividis",
168
+ "Hot",
169
+ "RdBu",
170
+ "Seismic",
171
+ "Earth",
172
+ "Electric",
173
+ ]
174
+
175
+ _FONT_OPTIONS = [
176
+ "sans-serif",
177
+ "serif",
178
+ "monospace",
179
+ "Arial",
180
+ "Helvetica",
181
+ "Times New Roman",
182
+ "Courier New",
183
+ "DejaVu Sans",
184
+ "DejaVu Serif",
185
+ ]
186
+
187
+ # Keys that are GUI-only style controls — must be stripped from plotting kwargs
188
+ _STYLE_KEYS = frozenset(
189
+ {
190
+ "grid",
191
+ "grid_linestyle",
192
+ "grid_which",
193
+ "fontsize", # legacy fallback — kept for old presets
194
+ "fontsize_label",
195
+ "fontsize_tick",
196
+ "fontsize_title",
197
+ "fontsize_legend",
198
+ "fontfamily",
199
+ "fontcolor",
200
+ "linewidth",
201
+ "legend_frameon",
202
+ "legend_framealpha",
203
+ "legend_position",
204
+ "xlim_min",
205
+ "xlim_max",
206
+ "ylim_min",
207
+ "ylim_max",
208
+ "x_scale",
209
+ "x_notation",
210
+ "x_transform",
211
+ "x_transform_value",
212
+ "y_scale",
213
+ "y_notation",
214
+ "y_transform",
215
+ "y_transform_value",
216
+ "z_scale",
217
+ "z_notation",
218
+ "z_transform",
219
+ "z_transform_value",
220
+ "figure_caption",
221
+ "color_palette",
222
+ "cmap_reversed",
223
+ # Plotly-specific style
224
+ "plotly_height",
225
+ "plotly_line_width",
226
+ }
227
+ )
228
+
229
+
230
+ def _numeric_cols(data: Any) -> List[str]:
231
+ if isinstance(data, pd.DataFrame):
232
+ return data.select_dtypes(include=[np.number]).columns.tolist()
233
+ return []
234
+
235
+
236
+ def _all_cols(data: Any) -> List[str]:
237
+ if isinstance(data, pd.DataFrame):
238
+ return list(data.columns)
239
+ return []
240
+
241
+
242
+ def _is_2d_array(data: Any) -> bool:
243
+ return isinstance(data, np.ndarray) and data.ndim == 2
244
+
245
+
246
+ _LABEL_HELP = (
247
+ "Supports matplotlib math notation — wrap in $…$. "
248
+ "Examples: $\\alpha$, $x^{2}$, $x_{0}$, $\\mu$m, $\\AA$"
249
+ )
250
+
251
+
252
+ def _symbol_reference_expander() -> None:
253
+ """Collapsible symbol reference for axis/title label inputs."""
254
+ with st.expander("Symbol reference"):
255
+ st.caption(
256
+ "Copy any code below and paste it into a label field. "
257
+ "Wrap expressions in **$…$** — e.g. `$\\alpha$` renders as α."
258
+ )
259
+ col_a, col_b, col_c = st.columns(3)
260
+ with col_a:
261
+ st.markdown(
262
+ "**Greek — lowercase** \n"
263
+ "α `$\\alpha$` · β `$\\beta$` · γ `$\\gamma$` \n"
264
+ "δ `$\\delta$` · ε `$\\epsilon$` · ζ `$\\zeta$` \n"
265
+ "η `$\\eta$` · θ `$\\theta$` · κ `$\\kappa$` \n"
266
+ "λ `$\\lambda$` · μ `$\\mu$` · ν `$\\nu$` \n"
267
+ "ξ `$\\xi$` · π `$\\pi$` · ρ `$\\rho$` \n"
268
+ "σ `$\\sigma$` · τ `$\\tau$` · φ `$\\phi$` \n"
269
+ "χ `$\\chi$` · ψ `$\\psi$` · ω `$\\omega$`"
270
+ )
271
+ with col_b:
272
+ st.markdown(
273
+ "**Greek — uppercase** \n"
274
+ "Γ `$\\Gamma$` · Δ `$\\Delta$` · Θ `$\\Theta$` \n"
275
+ "Λ `$\\Lambda$` · Ξ `$\\Xi$` · Π `$\\Pi$` \n"
276
+ "Σ `$\\Sigma$` · Φ `$\\Phi$` · Ψ `$\\Psi$` \n"
277
+ "Ω `$\\Omega$` \n\n"
278
+ "**Accents** \n"
279
+ "x̄ `$\\bar{x}$` · ẋ `$\\dot{x}$` \n"
280
+ "x̃ `$\\tilde{x}$` · x̂ `$\\hat{x}$` \n"
281
+ "x⃗ `$\\vec{x}$`"
282
+ )
283
+ with col_c:
284
+ st.markdown(
285
+ "**Super / subscript** \n"
286
+ "x² `$x^{2}$` · xⁿ `$x^{n}$` \n"
287
+ "x₀ `$x_{0}$` · xₙ `$x_{n}$` \n\n"
288
+ "**Units & special** \n"
289
+ "Å `$\\AA$` · ° `$^{\\circ}$` \n"
290
+ "± `$\\pm$` · × `$\\times$` \n"
291
+ "≤ `$\\leq$` · ≥ `$\\geq$` · ≠ `$\\neq$` \n"
292
+ "∞ `$\\infty$` · ∂ `$\\partial$` · ∇ `$\\nabla$` \n"
293
+ "√ `$\\sqrt{x}$` · ∫ `$\\int$` \n"
294
+ "½ `$\\frac{1}{2}$`"
295
+ )
296
+
297
+
298
+ def _label_widgets(
299
+ prefix: str,
300
+ x_default: str = "",
301
+ y_default: str = "",
302
+ ) -> Dict[str, Optional[str]]:
303
+ """Render title + axis-label text inputs; return partial config dict.
304
+
305
+ x_default / y_default are shown as placeholder hints and used as
306
+ fallback values when the user leaves the field empty (so the
307
+ selected column name becomes the default axis label).
308
+ """
309
+ col1, col2, col3 = st.columns(3)
310
+ with col1:
311
+ title = st.text_input(
312
+ "Title",
313
+ key=f"{prefix}_title",
314
+ help=_LABEL_HELP,
315
+ )
316
+ with col2:
317
+ xlabel = st.text_input(
318
+ "X Label",
319
+ placeholder=f"Default: {x_default}" if x_default else "",
320
+ key=f"{prefix}_xlabel",
321
+ help=_LABEL_HELP,
322
+ )
323
+ with col3:
324
+ ylabel = st.text_input(
325
+ "Y Label",
326
+ placeholder=f"Default: {y_default}" if y_default else "",
327
+ key=f"{prefix}_ylabel",
328
+ help=_LABEL_HELP,
329
+ )
330
+ _symbol_reference_expander()
331
+ return {
332
+ "title": title or None,
333
+ "xlabel": xlabel or x_default or None,
334
+ "ylabel": ylabel or y_default or None,
335
+ }
336
+
337
+
338
+ def _figsize_widget(
339
+ prefix: str,
340
+ default: Tuple[float, float] = (8.0, 6.0),
341
+ ) -> Tuple[float, float]:
342
+ col1, col2 = st.columns(2)
343
+ with col1:
344
+ w = st.number_input(
345
+ "Width (in)",
346
+ min_value=4.0,
347
+ max_value=20.0,
348
+ value=float(default[0]),
349
+ step=0.5,
350
+ key=f"{prefix}_fw",
351
+ )
352
+ with col2:
353
+ h = st.number_input(
354
+ "Height (in)",
355
+ min_value=3.0,
356
+ max_value=16.0,
357
+ value=float(default[1]),
358
+ step=0.5,
359
+ key=f"{prefix}_fh",
360
+ )
361
+ return (w, h)
362
+
363
+
364
+ def _color_widget(prefix: str, default: str = "steelblue") -> str:
365
+ options = _COLORS + ["custom"]
366
+ idx = options.index(default) if default in options else 0
367
+ choice = st.selectbox(
368
+ "Color",
369
+ options,
370
+ index=idx,
371
+ key=f"{prefix}_color",
372
+ )
373
+ if choice == "custom":
374
+ choice = st.color_picker(
375
+ "Pick color",
376
+ "#1f77b4",
377
+ key=f"{prefix}_color_pick",
378
+ )
379
+ return choice
380
+
381
+
382
+ def _axis_limits_widgets(prefix: str) -> Dict:
383
+ """Optional manual X/Y axis limit overrides."""
384
+ config: Dict = {}
385
+ c1, c2 = st.columns(2)
386
+ with c1:
387
+ if st.checkbox("Override X limits", key=f"{prefix}_use_xlim"):
388
+ cc1, cc2 = st.columns(2)
389
+ with cc1:
390
+ config["xlim_min"] = st.number_input(
391
+ "X min",
392
+ value=0.0,
393
+ key=f"{prefix}_xmin",
394
+ )
395
+ with cc2:
396
+ config["xlim_max"] = st.number_input(
397
+ "X max",
398
+ value=1.0,
399
+ key=f"{prefix}_xmax",
400
+ )
401
+ with c2:
402
+ if st.checkbox("Override Y limits", key=f"{prefix}_use_ylim"):
403
+ cc1, cc2 = st.columns(2)
404
+ with cc1:
405
+ config["ylim_min"] = st.number_input(
406
+ "Y min",
407
+ value=0.0,
408
+ key=f"{prefix}_ymin",
409
+ )
410
+ with cc2:
411
+ config["ylim_max"] = st.number_input(
412
+ "Y max",
413
+ value=1.0,
414
+ key=f"{prefix}_ymax",
415
+ )
416
+ return config
417
+
418
+
419
+ def _grid_widgets(prefix: str) -> Dict:
420
+ """Grid line controls (Matplotlib).
421
+
422
+ Uses ``_shared_`` keys so the preference is consistent across all
423
+ plot types in the same session.
424
+ """
425
+ c1, c2, c3 = st.columns(3)
426
+ with c1:
427
+ show_grid = st.checkbox(
428
+ "Show grid",
429
+ value=True,
430
+ key="_shared_grid",
431
+ )
432
+ config: Dict = {"grid": show_grid}
433
+ if show_grid:
434
+ with c2:
435
+ config["grid_linestyle"] = st.selectbox(
436
+ "Grid style",
437
+ ["--", "-", ":", "-."],
438
+ key="_shared_grid_ls",
439
+ )
440
+ with c3:
441
+ config["grid_which"] = st.selectbox(
442
+ "Grid density",
443
+ ["major", "minor", "both"],
444
+ key="_shared_grid_which",
445
+ )
446
+ return config
447
+
448
+
449
+ def _appearance_widgets(
450
+ prefix: str,
451
+ include_linewidth: bool = False,
452
+ ) -> Dict:
453
+ """Font family, color, per-element font sizes, and optionally line width.
454
+
455
+ All widget keys use the ``_shared_`` prefix so that settings persist
456
+ when switching between plot types within the same session.
457
+ """
458
+ config: Dict = {}
459
+
460
+ # Row 1 — font style, color, and (optional) line width
461
+ n_cols = 3 if not include_linewidth else 4
462
+ cols = st.columns(n_cols)
463
+ with cols[0]:
464
+ config["fontfamily"] = st.selectbox(
465
+ "Font",
466
+ _FONT_OPTIONS,
467
+ key="_shared_fontfamily",
468
+ )
469
+ with cols[1]:
470
+ config["fontcolor"] = st.color_picker(
471
+ "Font color",
472
+ "#262730",
473
+ key="_shared_fontcolor",
474
+ )
475
+ if include_linewidth:
476
+ with cols[2]:
477
+ config["linewidth"] = st.slider(
478
+ "Line width",
479
+ 0.5,
480
+ 6.0,
481
+ 1.5,
482
+ 0.5,
483
+ key="_shared_lw",
484
+ )
485
+
486
+ # Row 2 — individual element font sizes
487
+ s1, s2, s3, s4 = st.columns(4)
488
+ with s1:
489
+ config["fontsize_label"] = st.number_input(
490
+ "Label size",
491
+ min_value=6,
492
+ max_value=32,
493
+ value=11,
494
+ key="_shared_fs_label",
495
+ )
496
+ with s2:
497
+ config["fontsize_tick"] = st.number_input(
498
+ "Tick size",
499
+ min_value=6,
500
+ max_value=32,
501
+ value=10,
502
+ key="_shared_fs_tick",
503
+ )
504
+ with s3:
505
+ config["fontsize_title"] = st.number_input(
506
+ "Title size",
507
+ min_value=6,
508
+ max_value=36,
509
+ value=13,
510
+ key="_shared_fs_title",
511
+ )
512
+ with s4:
513
+ config["fontsize_legend"] = st.number_input(
514
+ "Legend size",
515
+ min_value=6,
516
+ max_value=32,
517
+ value=10,
518
+ key="_shared_fs_legend",
519
+ )
520
+ return config
521
+
522
+
523
+ def _legend_style_widgets(prefix: str) -> Dict:
524
+ """Legend border, background, and position controls.
525
+
526
+ Uses ``_shared_`` keys so the preference is consistent across all
527
+ plot types in the same session.
528
+ """
529
+ c1, c2, c3 = st.columns(3)
530
+ with c1:
531
+ frameon = st.checkbox(
532
+ "Legend border",
533
+ value=True,
534
+ key="_shared_leg_frame",
535
+ )
536
+ with c2:
537
+ opaque_bg = st.checkbox(
538
+ "Opaque background",
539
+ value=True,
540
+ key="_shared_leg_bg",
541
+ )
542
+ with c3:
543
+ position = st.selectbox(
544
+ "Legend position",
545
+ [
546
+ "best",
547
+ "upper right",
548
+ "upper left",
549
+ "lower right",
550
+ "lower left",
551
+ "center right",
552
+ "outside right",
553
+ ],
554
+ key="_shared_leg_pos",
555
+ )
556
+ return {
557
+ "legend_frameon": frameon,
558
+ "legend_framealpha": 0.8 if opaque_bg else 0.0,
559
+ "legend_position": position,
560
+ }
561
+
562
+
563
+ def _custom_labels_widget(prefix: str, y_cols: List[str]) -> Dict:
564
+ """Optional per-series legend label overrides.
565
+
566
+ Renders one text-input per Y series. Empty inputs keep the column
567
+ name as the label. Returns ``{'_custom_labels': [str, ...]}``.
568
+ """
569
+ if not y_cols:
570
+ return {}
571
+ with st.expander("Custom legend labels (optional)"):
572
+ labels = []
573
+ cols_ui = st.columns(min(len(y_cols), 4))
574
+ for i, col in enumerate(y_cols):
575
+ with cols_ui[i % len(cols_ui)]:
576
+ lbl = st.text_input(
577
+ f"'{col}'",
578
+ placeholder=str(col),
579
+ key=f"{prefix}_clbl_{i}",
580
+ )
581
+ labels.append(lbl.strip() or str(col))
582
+ return {"_custom_labels": labels}
583
+
584
+
585
+ def _custom_palette_widget(prefix: str) -> Dict:
586
+ """Color pickers for a user-defined palette (2–8 colors).
587
+
588
+ Uses ``_shared_`` keys so the custom colors survive plot-type switches.
589
+ """
590
+ defaults = [
591
+ "#1f77b4",
592
+ "#ff7f0e",
593
+ "#2ca02c",
594
+ "#d62728",
595
+ "#9467bd",
596
+ "#8c564b",
597
+ "#e377c2",
598
+ "#7f7f7f",
599
+ ]
600
+ n_colors = int(
601
+ st.number_input(
602
+ "Number of colors",
603
+ min_value=2,
604
+ max_value=8,
605
+ value=4,
606
+ key="_shared_custom_n",
607
+ )
608
+ )
609
+ cols = st.columns(n_colors)
610
+ colors = []
611
+ for i in range(n_colors):
612
+ with cols[i]:
613
+ c = st.color_picker(
614
+ f"C{i + 1}",
615
+ defaults[i],
616
+ key=f"_shared_custom_c{i}",
617
+ )
618
+ colors.append(c)
619
+ return {"_custom_colors": colors}
620
+
621
+
622
+ def _palette_widget(prefix: str) -> Dict:
623
+ """Color palette selector for multi-series plots.
624
+
625
+ Uses a ``_shared_`` key so the selection persists across plot types.
626
+ """
627
+ options = COLOR_PALETTE_NAMES + ["Custom"]
628
+ palette = st.selectbox(
629
+ "Color palette",
630
+ options,
631
+ key="_shared_palette",
632
+ help="Choose a discrete color cycle for plot series.",
633
+ )
634
+ config: Dict = {"color_palette": palette}
635
+ if palette == "Custom":
636
+ config.update(_custom_palette_widget(prefix))
637
+ return config
638
+
639
+
640
+ _TRANSFORM_LABELS = {
641
+ "none": "None",
642
+ "normalize_max": "Normalize to max",
643
+ "normalize_01": "Normalize to [0, 1]",
644
+ "scale_by": "Scale by constant",
645
+ }
646
+
647
+
648
+ def _axis_scale_widgets(
649
+ prefix: str,
650
+ axis: str,
651
+ label: str,
652
+ ) -> Dict:
653
+ """Generic scale / notation / transform block for one axis.
654
+
655
+ Parameters
656
+ ----------
657
+ prefix : str
658
+ Widget key namespace.
659
+ axis : str
660
+ Short axis tag used in config keys and widget keys, e.g. ``'x'``,
661
+ ``'y'``, or ``'z'``.
662
+ label : str
663
+ Human-readable axis name shown in widget labels, e.g. ``'X'``,
664
+ ``'Y'``, or ``'Color'``.
665
+ """
666
+ config: Dict = {}
667
+ c1, c2, c3 = st.columns(3)
668
+ with c1:
669
+ config[f"{axis}_scale"] = st.selectbox(
670
+ f"{label} scale",
671
+ ["linear", "log"],
672
+ key=f"{prefix}_{axis}scale",
673
+ )
674
+ with c2:
675
+ config[f"{axis}_notation"] = st.selectbox(
676
+ f"{label} notation",
677
+ ["default", "scientific", "engineering"],
678
+ key=f"{prefix}_{axis}notation",
679
+ )
680
+ with c3:
681
+ transform = st.selectbox(
682
+ f"{label} transform",
683
+ ["none", "normalize_max", "normalize_01", "scale_by"],
684
+ format_func=lambda x: _TRANSFORM_LABELS[x],
685
+ key=f"{prefix}_{axis}transform",
686
+ )
687
+ config[f"{axis}_transform"] = transform
688
+ if transform == "scale_by":
689
+ config[f"{axis}_transform_value"] = st.number_input(
690
+ f"{label} scale factor",
691
+ value=1.0,
692
+ key=f"{prefix}_{axis}scaleval",
693
+ )
694
+ return config
695
+
696
+
697
+ def _x_axis_widgets(prefix: str) -> Dict:
698
+ """X-axis scale, notation, and data-transform controls."""
699
+ return _axis_scale_widgets(prefix, "x", "X")
700
+
701
+
702
+ def _y_axis_widgets(prefix: str) -> Dict:
703
+ """Y-axis scale, notation, and data-transform controls."""
704
+ return _axis_scale_widgets(prefix, "y", "Y")
705
+
706
+
707
+ def _z_axis_widgets(prefix: str) -> Dict:
708
+ """Color-axis (Z) scale, notation, and data-transform controls."""
709
+ return _axis_scale_widgets(prefix, "z", "Color")
710
+
711
+
712
+ def _caption_widget(prefix: str) -> Dict:
713
+ """Optional figure caption text field."""
714
+ caption = st.text_input(
715
+ "Figure caption (optional)",
716
+ key=f"{prefix}_caption",
717
+ )
718
+ return {"figure_caption": caption or None}
719
+
720
+
721
+ def _plotly_appearance_widgets(prefix: str) -> Dict:
722
+ """Appearance controls for Plotly interactive plots.
723
+
724
+ Uses ``_shared_ply_`` keys so settings persist across Plotly plot types.
725
+ """
726
+ config: Dict = {}
727
+ c1, c2, c3, c4 = st.columns(4)
728
+ with c1:
729
+ config["fontsize"] = st.slider(
730
+ "Font size",
731
+ 8,
732
+ 24,
733
+ 13,
734
+ key="_shared_ply_fontsize",
735
+ )
736
+ with c2:
737
+ config["fontfamily"] = st.selectbox(
738
+ "Font",
739
+ _FONT_OPTIONS,
740
+ key="_shared_ply_fontfamily",
741
+ )
742
+ with c3:
743
+ config["fontcolor"] = st.color_picker(
744
+ "Font color",
745
+ "#262730",
746
+ key="_shared_ply_fontcolor",
747
+ )
748
+ with c4:
749
+ config["plotly_height"] = st.slider(
750
+ "Chart height",
751
+ 300,
752
+ 900,
753
+ 450,
754
+ 50,
755
+ key="_shared_ply_height",
756
+ )
757
+ c1, c2, c3 = st.columns(3)
758
+ with c1:
759
+ show_grid = st.checkbox(
760
+ "Show grid",
761
+ value=True,
762
+ key="_shared_ply_grid",
763
+ )
764
+ config["grid"] = show_grid
765
+ with c2:
766
+ config["legend_frameon"] = st.checkbox(
767
+ "Legend border",
768
+ value=False,
769
+ key="_shared_ply_leg_frame",
770
+ )
771
+ with c3:
772
+ config["legend_framealpha"] = (
773
+ 0.8
774
+ if st.checkbox(
775
+ "Opaque legend bg",
776
+ value=True,
777
+ key="_shared_ply_leg_bg",
778
+ )
779
+ else 0.0
780
+ )
781
+ return config
782
+
783
+
784
+ # ── Per-plot-type widget functions ───────────────────────────────────────────
785
+
786
+
787
+ def _widgets_histogram(data: Any, prefix: str) -> Dict:
788
+ config: Dict = {}
789
+ numeric = _numeric_cols(data)
790
+
791
+ if isinstance(data, pd.DataFrame):
792
+ if not numeric:
793
+ st.warning("No numeric columns found in dataset.")
794
+ return {}
795
+ config["_data_col"] = st.selectbox(
796
+ "Data column",
797
+ numeric,
798
+ key=f"{prefix}_dcol",
799
+ )
800
+ elif isinstance(data, np.ndarray) and np.issubdtype(data.dtype, np.number):
801
+ config["_data_col"] = None
802
+ else:
803
+ st.warning("Histogram requires a numeric array or a DataFrame with numeric columns.")
804
+ return {}
805
+
806
+ st.markdown("**Histogram parameters**")
807
+ col1, col2, col3 = st.columns(3)
808
+ with col1:
809
+ bins_choice = st.selectbox(
810
+ "Bin strategy",
811
+ ["auto", "sqrt", "sturges", "custom"],
812
+ key=f"{prefix}_bins_choice",
813
+ )
814
+ if bins_choice == "custom":
815
+ config["bins"] = st.number_input(
816
+ "Number of bins",
817
+ min_value=2,
818
+ max_value=500,
819
+ value=30,
820
+ key=f"{prefix}_bins_n",
821
+ )
822
+ else:
823
+ config["bins"] = bins_choice
824
+ with col2:
825
+ config["color"] = _color_widget(prefix)
826
+ with col3:
827
+ config["alpha"] = st.slider(
828
+ "Opacity",
829
+ 0.1,
830
+ 1.0,
831
+ 0.7,
832
+ 0.05,
833
+ key=f"{prefix}_alpha",
834
+ )
835
+ config["density"] = st.checkbox("Normalize (density)", key=f"{prefix}_density")
836
+
837
+ st.markdown("**Figure & labels**")
838
+ config["figsize"] = _figsize_widget(prefix)
839
+ x_lbl = str(config.get("_data_col", "")) or ""
840
+ y_lbl = "Density" if config.get("density") else "Frequency"
841
+ config.update(_label_widgets(prefix, x_default=x_lbl, y_default=y_lbl))
842
+ with st.expander("Appearance"):
843
+ config.update(_grid_widgets(prefix))
844
+ config.update(_appearance_widgets(prefix))
845
+ config.update(_legend_style_widgets(prefix))
846
+ config.update(_axis_limits_widgets(prefix))
847
+ config.update(_y_axis_widgets(prefix))
848
+ config.update(_caption_widget(prefix))
849
+ return config
850
+
851
+
852
+ def _widgets_line_plot(data: Any, prefix: str) -> Dict:
853
+ config: Dict = {}
854
+ numeric = _numeric_cols(data)
855
+
856
+ if isinstance(data, pd.DataFrame):
857
+ if len(numeric) < 2:
858
+ st.warning("Line plot requires at least 2 numeric columns.")
859
+ return {}
860
+ config["_x_col"] = st.selectbox(
861
+ "X column",
862
+ numeric,
863
+ index=0,
864
+ key=f"{prefix}_xcol",
865
+ )
866
+ y_opts = [c for c in numeric if c != config["_x_col"]]
867
+ config["_y_cols"] = st.multiselect(
868
+ "Y column(s)",
869
+ y_opts,
870
+ default=[y_opts[0]] if y_opts else [],
871
+ key=f"{prefix}_ycols",
872
+ )
873
+ if not config["_y_cols"]:
874
+ st.info("Select at least one Y column.")
875
+ return {}
876
+
877
+ elif isinstance(data, np.ndarray):
878
+ if data.ndim == 1:
879
+ st.info("1D array: array index will be used as the X axis.")
880
+ config["_x_col"] = None
881
+ config["_y_cols"] = None
882
+ elif data.ndim == 2:
883
+ opts = [str(i) for i in range(data.shape[1])]
884
+ config["_x_col"] = st.selectbox(
885
+ "X column index",
886
+ opts,
887
+ index=0,
888
+ key=f"{prefix}_xcol",
889
+ )
890
+ y_opts = [c for c in opts if c != config["_x_col"]]
891
+ config["_y_cols"] = st.multiselect(
892
+ "Y column index(es)",
893
+ y_opts,
894
+ default=[y_opts[0]] if y_opts else [],
895
+ key=f"{prefix}_ycols",
896
+ )
897
+ if not config["_y_cols"]:
898
+ st.info("Select at least one Y column.")
899
+ return {}
900
+ else:
901
+ st.warning("Line plot requires a 1D or 2D array.")
902
+ return {}
903
+ else:
904
+ st.warning("Unsupported data type for line plot.")
905
+ return {}
906
+
907
+ st.markdown("**Style**")
908
+ col1, col2 = st.columns(2)
909
+ with col1:
910
+ linestyle = st.selectbox(
911
+ "Line style",
912
+ ["-", "--", "-.", ":"],
913
+ key=f"{prefix}_ls",
914
+ )
915
+ config["linestyles"] = [linestyle]
916
+ with col2:
917
+ marker = st.selectbox(
918
+ "Marker",
919
+ ["None", "o", "s", "^", "D", "v", "*"],
920
+ key=f"{prefix}_marker",
921
+ )
922
+ config["markers"] = [None if marker == "None" else marker]
923
+
924
+ # ── Custom legend labels ──────────────────────────────────────────────────
925
+ y_cols_lp = config.get("_y_cols") or []
926
+ config.update(_custom_labels_widget(prefix, [str(c) for c in y_cols_lp]))
927
+
928
+ # ── Secondary Y axis (DataFrame only) ────────────────────────────────────
929
+ if isinstance(data, pd.DataFrame) and y_cols_lp:
930
+ with st.expander("Secondary Y axis (optional)"):
931
+ use_sec = st.checkbox(
932
+ "Enable secondary Y axis",
933
+ key=f"{prefix}_use_sec_y",
934
+ )
935
+ if use_sec:
936
+ x_col_lp = config.get("_x_col")
937
+ sec_opts = [c for c in numeric if c != x_col_lp and c not in y_cols_lp]
938
+ if sec_opts:
939
+ config["_secondary_y_cols"] = st.multiselect(
940
+ "Secondary Y column(s)",
941
+ sec_opts,
942
+ key=f"{prefix}_sec_ycols",
943
+ )
944
+ c1, c2 = st.columns(2)
945
+ with c1:
946
+ config["_secondary_y_label"] = st.text_input(
947
+ "Secondary Y label",
948
+ key=f"{prefix}_sec_ylabel",
949
+ )
950
+ with c2:
951
+ config["_secondary_y_scale"] = st.selectbox(
952
+ "Secondary Y scale",
953
+ ["linear", "log"],
954
+ key=f"{prefix}_sec_yscale",
955
+ )
956
+ else:
957
+ st.info(
958
+ "All available numeric columns are already assigned to the primary Y axis."
959
+ )
960
+
961
+ st.markdown("**Figure & labels**")
962
+ config["figsize"] = _figsize_widget(prefix)
963
+ x_lbl = str(config.get("_x_col", "")) or ""
964
+ y_cols = config.get("_y_cols") or []
965
+ y_lbl = str(y_cols[0]) if y_cols else ""
966
+ config.update(_label_widgets(prefix, x_default=x_lbl, y_default=y_lbl))
967
+ with st.expander("Appearance"):
968
+ config.update(_grid_widgets(prefix))
969
+ config.update(_appearance_widgets(prefix, include_linewidth=True))
970
+ config.update(_palette_widget(prefix))
971
+ config.update(_legend_style_widgets(prefix))
972
+ config.update(_axis_limits_widgets(prefix))
973
+ config.update(_x_axis_widgets(prefix))
974
+ config.update(_y_axis_widgets(prefix))
975
+ config.update(_caption_widget(prefix))
976
+ return config
977
+
978
+
979
+ def _widgets_scatter_plot(data: Any, prefix: str) -> Dict:
980
+ config: Dict = {}
981
+ numeric = _numeric_cols(data)
982
+
983
+ if isinstance(data, pd.DataFrame):
984
+ if len(numeric) < 2:
985
+ st.warning("Scatter plot requires at least 2 numeric columns.")
986
+ return {}
987
+ col1, col2 = st.columns(2)
988
+ with col1:
989
+ config["_x_col"] = st.selectbox(
990
+ "X column",
991
+ numeric,
992
+ index=0,
993
+ key=f"{prefix}_xcol",
994
+ )
995
+ with col2:
996
+ y_opts = [c for c in numeric if c != config["_x_col"]]
997
+ config["_y_col"] = st.selectbox(
998
+ "Y column",
999
+ y_opts if y_opts else numeric,
1000
+ index=0,
1001
+ key=f"{prefix}_ycol",
1002
+ )
1003
+ elif isinstance(data, np.ndarray) and data.ndim == 2 and data.shape[1] >= 2:
1004
+ opts = [str(i) for i in range(data.shape[1])]
1005
+ col1, col2 = st.columns(2)
1006
+ with col1:
1007
+ config["_x_col"] = st.selectbox(
1008
+ "X column index",
1009
+ opts,
1010
+ index=0,
1011
+ key=f"{prefix}_xcol",
1012
+ )
1013
+ with col2:
1014
+ y_opts = [c for c in opts if c != config["_x_col"]]
1015
+ config["_y_col"] = st.selectbox(
1016
+ "Y column index",
1017
+ y_opts if y_opts else opts,
1018
+ index=0,
1019
+ key=f"{prefix}_ycol",
1020
+ )
1021
+ else:
1022
+ st.warning("Scatter plot requires a DataFrame or 2D array with >= 2 columns.")
1023
+ return {}
1024
+
1025
+ st.markdown("**Style**")
1026
+ col1, col2, col3 = st.columns(3)
1027
+ with col1:
1028
+ config["color"] = _color_widget(prefix)
1029
+ with col2:
1030
+ config["size"] = st.number_input(
1031
+ "Point size",
1032
+ min_value=1,
1033
+ max_value=500,
1034
+ value=50,
1035
+ key=f"{prefix}_size",
1036
+ )
1037
+ with col3:
1038
+ config["alpha"] = st.slider(
1039
+ "Opacity",
1040
+ 0.1,
1041
+ 1.0,
1042
+ 0.7,
1043
+ 0.05,
1044
+ key=f"{prefix}_alpha",
1045
+ )
1046
+
1047
+ st.markdown("**Figure & labels**")
1048
+ config["figsize"] = _figsize_widget(prefix)
1049
+ x_lbl = str(config.get("_x_col", "")) or ""
1050
+ y_lbl = str(config.get("_y_col", "")) or ""
1051
+ config.update(_label_widgets(prefix, x_default=x_lbl, y_default=y_lbl))
1052
+ with st.expander("Appearance"):
1053
+ config.update(_grid_widgets(prefix))
1054
+ config.update(_appearance_widgets(prefix))
1055
+ config.update(_legend_style_widgets(prefix))
1056
+ config.update(_axis_limits_widgets(prefix))
1057
+ config.update(_x_axis_widgets(prefix))
1058
+ config.update(_y_axis_widgets(prefix))
1059
+ config.update(_caption_widget(prefix))
1060
+ return config
1061
+
1062
+
1063
+ def _widgets_heatmap(data: Any, prefix: str) -> Dict:
1064
+ config: Dict = {}
1065
+
1066
+ if _is_2d_array(data):
1067
+ config["_use_numeric_df"] = False
1068
+ elif isinstance(data, pd.DataFrame):
1069
+ numeric = _numeric_cols(data)
1070
+ if len(numeric) < 2:
1071
+ st.warning("Heatmap requires a 2D array or DataFrame with >= 2 numeric columns.")
1072
+ return {}
1073
+ st.info(f"Heatmap will use all {len(numeric)} numeric columns as a matrix.")
1074
+ config["_use_numeric_df"] = True
1075
+ else:
1076
+ st.warning("Heatmap requires a 2D NumPy array or DataFrame.")
1077
+ return {}
1078
+
1079
+ col1, col2, col3 = st.columns(3)
1080
+ with col1:
1081
+ config["cmap"] = st.selectbox("Colormap", _CMAPS, key=f"{prefix}_cmap")
1082
+ config["colorbar"] = st.checkbox(
1083
+ "Show colorbar",
1084
+ value=True,
1085
+ key=f"{prefix}_cbar",
1086
+ )
1087
+ config["cmap_reversed"] = st.checkbox(
1088
+ "Reverse colormap",
1089
+ key=f"{prefix}_cmap_rev",
1090
+ )
1091
+ with col2:
1092
+ use_clim = st.checkbox("Set color limits", key=f"{prefix}_use_clim")
1093
+ if use_clim:
1094
+ config["vmin"] = st.number_input("vmin", value=0.0, key=f"{prefix}_vmin")
1095
+ config["vmax"] = st.number_input("vmax", value=1.0, key=f"{prefix}_vmax")
1096
+ with col3:
1097
+ use_n = st.checkbox("Discretize colormap", key=f"{prefix}_use_ncol")
1098
+ if use_n:
1099
+ config["cmap_n_colors"] = st.number_input(
1100
+ "N colors",
1101
+ min_value=2,
1102
+ max_value=256,
1103
+ value=10,
1104
+ key=f"{prefix}_ncol",
1105
+ )
1106
+
1107
+ st.markdown("**Figure & labels**")
1108
+ config["figsize"] = _figsize_widget(prefix)
1109
+ config.update(_label_widgets(prefix))
1110
+ with st.expander("Appearance"):
1111
+ config.update(_appearance_widgets(prefix))
1112
+ config.update(_grid_widgets(prefix))
1113
+ config.update(_x_axis_widgets(prefix))
1114
+ config.update(_y_axis_widgets(prefix))
1115
+ config.update(_z_axis_widgets(prefix))
1116
+ config.update(_axis_limits_widgets(prefix))
1117
+ config.update(_caption_widget(prefix))
1118
+ return config
1119
+
1120
+
1121
+ def _widgets_contour_plot(data: Any, prefix: str) -> Dict:
1122
+ config: Dict = {}
1123
+
1124
+ if _is_2d_array(data):
1125
+ config["_mode"] = "array"
1126
+ st.info("X and Y meshgrid coordinates are auto-generated from the Z array shape.")
1127
+ elif isinstance(data, pd.DataFrame):
1128
+ numeric = _numeric_cols(data)
1129
+ if len(numeric) < 3:
1130
+ st.warning(
1131
+ "Contour plot from a DataFrame requires at least 3 numeric columns (X, Y, Z)."
1132
+ )
1133
+ return {}
1134
+ config["_mode"] = "dataframe"
1135
+ c1, c2, c3 = st.columns(3)
1136
+ with c1:
1137
+ config["_x_col"] = st.selectbox("X column", numeric, index=0, key=f"{prefix}_xcol")
1138
+ with c2:
1139
+ y_opts = [c for c in numeric if c != config["_x_col"]]
1140
+ config["_y_col"] = st.selectbox(
1141
+ "Y column",
1142
+ y_opts or numeric,
1143
+ index=0,
1144
+ key=f"{prefix}_ycol",
1145
+ )
1146
+ with c3:
1147
+ z_opts = [c for c in numeric if c not in (config["_x_col"], config.get("_y_col", ""))]
1148
+ config["_z_col"] = st.selectbox(
1149
+ "Z column",
1150
+ z_opts or numeric,
1151
+ index=0,
1152
+ key=f"{prefix}_zcol",
1153
+ )
1154
+ c1, c2 = st.columns(2)
1155
+ with c1:
1156
+ config["_grid_n"] = st.number_input(
1157
+ "Grid resolution (N×N)",
1158
+ min_value=20,
1159
+ max_value=500,
1160
+ value=100,
1161
+ key=f"{prefix}_gridn",
1162
+ )
1163
+ with c2:
1164
+ config["_interp_method"] = st.selectbox(
1165
+ "Interpolation method",
1166
+ ["cubic", "linear", "nearest"],
1167
+ key=f"{prefix}_interp",
1168
+ help=(
1169
+ "'cubic' is smoothest; 'linear' is exact on grid points; "
1170
+ "'nearest' preserves raw values."
1171
+ ),
1172
+ )
1173
+ st.caption(
1174
+ "Structured-grid CSVs are reshaped directly; "
1175
+ "scattered XYZ data is interpolated via scipy griddata."
1176
+ )
1177
+ else:
1178
+ st.warning("Contour plot requires a 2D NumPy array or a DataFrame with X, Y, Z columns.")
1179
+ return {}
1180
+
1181
+ col1, col2, col3, col4 = st.columns(4)
1182
+ with col1:
1183
+ config["levels"] = st.number_input(
1184
+ "Contour levels",
1185
+ min_value=2,
1186
+ max_value=50,
1187
+ value=10,
1188
+ key=f"{prefix}_levels",
1189
+ )
1190
+ with col2:
1191
+ config["cmap"] = st.selectbox("Colormap", _CMAPS, key=f"{prefix}_cmap")
1192
+ config["cmap_reversed"] = st.checkbox(
1193
+ "Reverse colormap",
1194
+ key=f"{prefix}_cmap_rev",
1195
+ )
1196
+ with col3:
1197
+ use_n = st.checkbox("Discretize colormap", key=f"{prefix}_use_ncol")
1198
+ if use_n:
1199
+ config["cmap_n_colors"] = st.number_input(
1200
+ "N colors",
1201
+ min_value=2,
1202
+ max_value=256,
1203
+ value=10,
1204
+ key=f"{prefix}_ncol",
1205
+ )
1206
+ with col4:
1207
+ config["filled"] = st.checkbox(
1208
+ "Filled contours",
1209
+ value=True,
1210
+ key=f"{prefix}_filled",
1211
+ )
1212
+ config["colorbar"] = st.checkbox(
1213
+ "Show colorbar",
1214
+ value=True,
1215
+ key=f"{prefix}_cbar",
1216
+ )
1217
+
1218
+ c1, c2 = st.columns(2)
1219
+ with c1:
1220
+ if not config.get("filled", True):
1221
+ config["linewidths"] = st.slider(
1222
+ "Line width",
1223
+ 0.5,
1224
+ 5.0,
1225
+ 1.5,
1226
+ 0.25,
1227
+ key=f"{prefix}_lw",
1228
+ )
1229
+ with c2:
1230
+ use_clim = st.checkbox("Set color limits", key=f"{prefix}_use_clim")
1231
+ if use_clim:
1232
+ cc1, cc2 = st.columns(2)
1233
+ with cc1:
1234
+ config["vmin"] = st.number_input("vmin", value=0.0, key=f"{prefix}_vmin")
1235
+ with cc2:
1236
+ config["vmax"] = st.number_input("vmax", value=1.0, key=f"{prefix}_vmax")
1237
+
1238
+ st.markdown("**Figure & labels**")
1239
+ config["figsize"] = _figsize_widget(prefix)
1240
+ config.update(_label_widgets(prefix))
1241
+ with st.expander("Appearance"):
1242
+ config.update(_appearance_widgets(prefix))
1243
+ config.update(_grid_widgets(prefix))
1244
+ config.update(_x_axis_widgets(prefix))
1245
+ config.update(_y_axis_widgets(prefix))
1246
+ config.update(_z_axis_widgets(prefix))
1247
+ config.update(_axis_limits_widgets(prefix))
1248
+ config.update(_caption_widget(prefix))
1249
+ return config
1250
+
1251
+
1252
+ def _widgets_distribution_plot(data: Any, prefix: str) -> Dict:
1253
+ config: Dict = {}
1254
+ numeric = _numeric_cols(data)
1255
+
1256
+ if isinstance(data, pd.DataFrame):
1257
+ if not numeric:
1258
+ st.warning("No numeric columns found.")
1259
+ return {}
1260
+ config["_data_col"] = st.selectbox(
1261
+ "Data column",
1262
+ numeric,
1263
+ key=f"{prefix}_dcol",
1264
+ )
1265
+ elif isinstance(data, np.ndarray) and np.issubdtype(data.dtype, np.number):
1266
+ config["_data_col"] = None
1267
+ else:
1268
+ st.warning("Distribution plot requires a numeric array or DataFrame.")
1269
+ return {}
1270
+
1271
+ col1, col2 = st.columns(2)
1272
+ with col1:
1273
+ config["kind"] = st.selectbox(
1274
+ "Plot kind",
1275
+ ["hist", "kde", "ecdf"],
1276
+ key=f"{prefix}_kind",
1277
+ )
1278
+ with col2:
1279
+ config["kde"] = st.checkbox(
1280
+ "Overlay KDE (hist only)",
1281
+ value=True,
1282
+ key=f"{prefix}_kde",
1283
+ )
1284
+ with st.expander("Appearance"):
1285
+ config.update(_grid_widgets(prefix))
1286
+ config.update(_appearance_widgets(prefix))
1287
+ config.update(_axis_limits_widgets(prefix))
1288
+ return config
1289
+
1290
+
1291
+ def _widgets_box_plot(data: Any, prefix: str) -> Dict:
1292
+ config: Dict = {}
1293
+ all_cols = _all_cols(data)
1294
+ numeric = _numeric_cols(data)
1295
+
1296
+ if isinstance(data, pd.DataFrame):
1297
+ if not numeric:
1298
+ st.warning("No numeric columns found.")
1299
+ return {}
1300
+ categorical = [c for c in all_cols if c not in numeric]
1301
+ col1, col2, col3 = st.columns(3)
1302
+ with col1:
1303
+ x_choice = st.selectbox(
1304
+ "X (grouping) column",
1305
+ ["— none —"] + categorical + numeric,
1306
+ key=f"{prefix}_xcol",
1307
+ )
1308
+ config["x"] = None if x_choice == "— none —" else x_choice
1309
+ with col2:
1310
+ config["y"] = st.selectbox(
1311
+ "Y (value) column",
1312
+ numeric,
1313
+ key=f"{prefix}_ycol",
1314
+ )
1315
+ with col3:
1316
+ hue_choice = st.selectbox(
1317
+ "Hue (optional)",
1318
+ ["— none —"] + all_cols,
1319
+ key=f"{prefix}_hue",
1320
+ )
1321
+ config["hue"] = None if hue_choice == "— none —" else hue_choice
1322
+ elif isinstance(data, np.ndarray) and np.issubdtype(data.dtype, np.number):
1323
+ config["x"] = None
1324
+ config["y"] = None
1325
+ config["hue"] = None
1326
+ st.info("Box plot will use the numeric array as a single group.")
1327
+ else:
1328
+ st.warning("Box plot requires a numeric array or DataFrame.")
1329
+ return {}
1330
+
1331
+ config["kind"] = st.selectbox(
1332
+ "Plot kind",
1333
+ ["box", "violin", "boxen"],
1334
+ key=f"{prefix}_kind",
1335
+ )
1336
+ with st.expander("Appearance"):
1337
+ config.update(_grid_widgets(prefix))
1338
+ config.update(_appearance_widgets(prefix))
1339
+ config.update(_axis_limits_widgets(prefix))
1340
+ return config
1341
+
1342
+
1343
+ def _widgets_regression_plot(data: Any, prefix: str) -> Dict:
1344
+ config: Dict = {}
1345
+ numeric = _numeric_cols(data)
1346
+
1347
+ if isinstance(data, pd.DataFrame):
1348
+ if len(numeric) < 2:
1349
+ st.warning("Regression plot requires at least 2 numeric columns.")
1350
+ return {}
1351
+ col1, col2 = st.columns(2)
1352
+ with col1:
1353
+ config["_x_col"] = st.selectbox(
1354
+ "X column",
1355
+ numeric,
1356
+ index=0,
1357
+ key=f"{prefix}_xcol",
1358
+ )
1359
+ with col2:
1360
+ y_opts = [c for c in numeric if c != config["_x_col"]]
1361
+ config["_y_col"] = st.selectbox(
1362
+ "Y column",
1363
+ y_opts if y_opts else numeric,
1364
+ index=0,
1365
+ key=f"{prefix}_ycol",
1366
+ )
1367
+ config["_use_df"] = True
1368
+
1369
+ elif isinstance(data, np.ndarray) and data.ndim == 2 and data.shape[1] >= 2:
1370
+ opts = [str(i) for i in range(data.shape[1])]
1371
+ col1, col2 = st.columns(2)
1372
+ with col1:
1373
+ config["_x_col"] = st.selectbox(
1374
+ "X column index",
1375
+ opts,
1376
+ index=0,
1377
+ key=f"{prefix}_xcol",
1378
+ )
1379
+ with col2:
1380
+ y_opts = [c for c in opts if c != config["_x_col"]]
1381
+ config["_y_col"] = st.selectbox(
1382
+ "Y column index",
1383
+ y_opts if y_opts else opts,
1384
+ index=0,
1385
+ key=f"{prefix}_ycol",
1386
+ )
1387
+ config["_use_df"] = False
1388
+ else:
1389
+ st.warning("Regression plot requires a DataFrame or 2D array with >= 2 columns.")
1390
+ return {}
1391
+
1392
+ col1, col2 = st.columns(2)
1393
+ with col1:
1394
+ config["order"] = st.number_input(
1395
+ "Polynomial order",
1396
+ min_value=1,
1397
+ max_value=5,
1398
+ value=1,
1399
+ key=f"{prefix}_order",
1400
+ )
1401
+ with col2:
1402
+ config["ci"] = st.slider(
1403
+ "Confidence interval (%)",
1404
+ 0,
1405
+ 99,
1406
+ 95,
1407
+ key=f"{prefix}_ci",
1408
+ )
1409
+ x_lbl = str(config.get("_x_col", "")) or ""
1410
+ y_lbl = str(config.get("_y_col", "")) or ""
1411
+ with st.expander("Appearance"):
1412
+ config.update(_grid_widgets(prefix))
1413
+ config.update(_appearance_widgets(prefix))
1414
+ config.update(_axis_limits_widgets(prefix))
1415
+ # Regression has no separate label widgets — show them here
1416
+ col1, col2 = st.columns(2)
1417
+ with col1:
1418
+ xl = st.text_input(
1419
+ "X Label",
1420
+ placeholder=f"Default: {x_lbl}" if x_lbl else "",
1421
+ key=f"{prefix}_xlabel",
1422
+ )
1423
+ config["xlabel"] = xl or x_lbl or None
1424
+ with col2:
1425
+ yl = st.text_input(
1426
+ "Y Label",
1427
+ placeholder=f"Default: {y_lbl}" if y_lbl else "",
1428
+ key=f"{prefix}_ylabel",
1429
+ )
1430
+ config["ylabel"] = yl or y_lbl or None
1431
+ return config
1432
+
1433
+
1434
+ def _widgets_interactive_histogram(data: Any, prefix: str) -> Dict:
1435
+ config: Dict = {}
1436
+ numeric = _numeric_cols(data)
1437
+
1438
+ if isinstance(data, pd.DataFrame):
1439
+ if not numeric:
1440
+ st.warning("No numeric columns found.")
1441
+ return {}
1442
+ config["_data_col"] = st.selectbox(
1443
+ "Data column",
1444
+ numeric,
1445
+ key=f"{prefix}_dcol",
1446
+ )
1447
+ elif isinstance(data, np.ndarray) and np.issubdtype(data.dtype, np.number):
1448
+ config["_data_col"] = None
1449
+ else:
1450
+ st.warning("Interactive histogram requires a numeric array or DataFrame.")
1451
+ return {}
1452
+
1453
+ col1, col2, col3 = st.columns(3)
1454
+ with col1:
1455
+ config["bins"] = st.number_input(
1456
+ "Bins",
1457
+ min_value=2,
1458
+ max_value=500,
1459
+ value=30,
1460
+ key=f"{prefix}_bins",
1461
+ )
1462
+ with col2:
1463
+ config["title"] = st.text_input("Title", key=f"{prefix}_title") or None
1464
+ with col3:
1465
+ x_lbl = str(config.get("_data_col", "")) or ""
1466
+ xl = st.text_input(
1467
+ "X Label",
1468
+ placeholder=f"Default: {x_lbl}" if x_lbl else "",
1469
+ key=f"{prefix}_xlabel",
1470
+ )
1471
+ config["xlabel"] = xl or x_lbl or None
1472
+ with st.expander("Appearance"):
1473
+ config.update(_plotly_appearance_widgets(prefix))
1474
+ config.update(_axis_limits_widgets(prefix))
1475
+ return config
1476
+
1477
+
1478
+ def _widgets_interactive_scatter(data: Any, prefix: str) -> Dict:
1479
+ config: Dict = {}
1480
+ numeric = _numeric_cols(data)
1481
+
1482
+ if isinstance(data, pd.DataFrame):
1483
+ if len(numeric) < 2:
1484
+ st.warning("Interactive scatter requires at least 2 numeric columns.")
1485
+ return {}
1486
+ col1, col2 = st.columns(2)
1487
+ with col1:
1488
+ config["_x_col"] = st.selectbox(
1489
+ "X column",
1490
+ numeric,
1491
+ index=0,
1492
+ key=f"{prefix}_xcol",
1493
+ )
1494
+ with col2:
1495
+ y_opts = [c for c in numeric if c != config["_x_col"]]
1496
+ config["_y_col"] = st.selectbox(
1497
+ "Y column",
1498
+ y_opts if y_opts else numeric,
1499
+ index=0,
1500
+ key=f"{prefix}_ycol",
1501
+ )
1502
+ elif isinstance(data, np.ndarray) and data.ndim == 2 and data.shape[1] >= 2:
1503
+ opts = [str(i) for i in range(data.shape[1])]
1504
+ col1, col2 = st.columns(2)
1505
+ with col1:
1506
+ config["_x_col"] = st.selectbox(
1507
+ "X column index",
1508
+ opts,
1509
+ index=0,
1510
+ key=f"{prefix}_xcol",
1511
+ )
1512
+ with col2:
1513
+ y_opts = [c for c in opts if c != config["_x_col"]]
1514
+ config["_y_col"] = st.selectbox(
1515
+ "Y column index",
1516
+ y_opts if y_opts else opts,
1517
+ index=0,
1518
+ key=f"{prefix}_ycol",
1519
+ )
1520
+ else:
1521
+ st.warning("Interactive scatter requires a DataFrame or 2D array with >= 2 columns.")
1522
+ return {}
1523
+
1524
+ x_lbl = str(config.get("_x_col", "")) or ""
1525
+ y_lbl = str(config.get("_y_col", "")) or ""
1526
+ col1, col2, col3 = st.columns(3)
1527
+ with col1:
1528
+ config["title"] = st.text_input("Title", key=f"{prefix}_title") or None
1529
+ with col2:
1530
+ xl = st.text_input(
1531
+ "X Label",
1532
+ placeholder=f"Default: {x_lbl}" if x_lbl else "",
1533
+ key=f"{prefix}_xlabel",
1534
+ )
1535
+ config["xlabel"] = xl or x_lbl or None
1536
+ with col3:
1537
+ yl = st.text_input(
1538
+ "Y Label",
1539
+ placeholder=f"Default: {y_lbl}" if y_lbl else "",
1540
+ key=f"{prefix}_ylabel",
1541
+ )
1542
+ config["ylabel"] = yl or y_lbl or None
1543
+ with st.expander("Appearance"):
1544
+ config.update(_plotly_appearance_widgets(prefix))
1545
+ config.update(_axis_limits_widgets(prefix))
1546
+ config.update(_y_axis_widgets(prefix))
1547
+ return config
1548
+
1549
+
1550
+ def _widgets_interactive_line(data: Any, prefix: str) -> Dict:
1551
+ config: Dict = {}
1552
+ numeric = _numeric_cols(data)
1553
+
1554
+ if isinstance(data, pd.DataFrame):
1555
+ if len(numeric) < 2:
1556
+ st.warning("Interactive line requires at least 2 numeric columns.")
1557
+ return {}
1558
+ config["_x_col"] = st.selectbox(
1559
+ "X column",
1560
+ numeric,
1561
+ index=0,
1562
+ key=f"{prefix}_xcol",
1563
+ )
1564
+ y_opts = [c for c in numeric if c != config["_x_col"]]
1565
+ config["_y_cols"] = st.multiselect(
1566
+ "Y column(s)",
1567
+ y_opts,
1568
+ default=[y_opts[0]] if y_opts else [],
1569
+ key=f"{prefix}_ycols",
1570
+ )
1571
+ if not config["_y_cols"]:
1572
+ st.info("Select at least one Y column.")
1573
+ return {}
1574
+
1575
+ elif isinstance(data, np.ndarray):
1576
+ if data.ndim == 1:
1577
+ st.info("1D array: array index as X axis.")
1578
+ config["_x_col"] = None
1579
+ config["_y_cols"] = None
1580
+ elif data.ndim == 2:
1581
+ opts = [str(i) for i in range(data.shape[1])]
1582
+ config["_x_col"] = st.selectbox(
1583
+ "X column index",
1584
+ opts,
1585
+ index=0,
1586
+ key=f"{prefix}_xcol",
1587
+ )
1588
+ y_opts = [c for c in opts if c != config["_x_col"]]
1589
+ config["_y_cols"] = st.multiselect(
1590
+ "Y column index(es)",
1591
+ y_opts,
1592
+ default=[y_opts[0]] if y_opts else [],
1593
+ key=f"{prefix}_ycols",
1594
+ )
1595
+ if not config["_y_cols"]:
1596
+ st.info("Select at least one Y column.")
1597
+ return {}
1598
+ else:
1599
+ st.warning("Interactive line requires a 1D or 2D array.")
1600
+ return {}
1601
+ else:
1602
+ st.warning("Unsupported data type for interactive line plot.")
1603
+ return {}
1604
+
1605
+ x_lbl = str(config.get("_x_col", "")) or ""
1606
+ y_cols = config.get("_y_cols") or []
1607
+ y_lbl = str(y_cols[0]) if y_cols else ""
1608
+ col1, col2, col3 = st.columns(3)
1609
+ with col1:
1610
+ config["title"] = st.text_input("Title", key=f"{prefix}_title") or None
1611
+ with col2:
1612
+ xl = st.text_input(
1613
+ "X Label",
1614
+ placeholder=f"Default: {x_lbl}" if x_lbl else "",
1615
+ key=f"{prefix}_xlabel",
1616
+ )
1617
+ config["xlabel"] = xl or x_lbl or None
1618
+ with col3:
1619
+ yl = st.text_input(
1620
+ "Y Label",
1621
+ placeholder=f"Default: {y_lbl}" if y_lbl else "",
1622
+ key=f"{prefix}_ylabel",
1623
+ )
1624
+ config["ylabel"] = yl or y_lbl or None
1625
+ with st.expander("Appearance"):
1626
+ config.update(_plotly_appearance_widgets(prefix))
1627
+ config.update(_axis_limits_widgets(prefix))
1628
+ config.update(_y_axis_widgets(prefix))
1629
+ return config
1630
+
1631
+
1632
+ def _widgets_interactive_heatmap(data: Any, prefix: str) -> Dict:
1633
+ config: Dict = {}
1634
+
1635
+ if _is_2d_array(data):
1636
+ config["_use_numeric_df"] = False
1637
+ elif isinstance(data, pd.DataFrame):
1638
+ numeric = _numeric_cols(data)
1639
+ if len(numeric) < 2:
1640
+ st.warning(
1641
+ "Interactive heatmap requires a 2D array or DataFrame with >= 2 numeric columns."
1642
+ )
1643
+ return {}
1644
+ st.info(f"Heatmap will use all {len(numeric)} numeric columns.")
1645
+ config["_use_numeric_df"] = True
1646
+ else:
1647
+ st.warning("Interactive heatmap requires a 2D NumPy array or DataFrame.")
1648
+ return {}
1649
+
1650
+ col1, col2 = st.columns(2)
1651
+ with col1:
1652
+ config["colorscale"] = st.selectbox(
1653
+ "Color scale",
1654
+ _COLORSCALES,
1655
+ key=f"{prefix}_cs",
1656
+ )
1657
+ with col2:
1658
+ config["title"] = st.text_input("Title", key=f"{prefix}_title") or None
1659
+ with st.expander("Appearance"):
1660
+ config.update(_plotly_appearance_widgets(prefix))
1661
+ return config
1662
+
1663
+
1664
+ def _widgets_interactive_3d_surface(data: Any, prefix: str) -> Dict:
1665
+ config: Dict = {}
1666
+
1667
+ if not _is_2d_array(data):
1668
+ st.warning("3D surface plot requires a 2D NumPy array (Z values). Load a 2D .npy file.")
1669
+ return {}
1670
+
1671
+ st.info("X and Y meshgrid coordinates are auto-generated from the Z array shape.")
1672
+ col1, col2 = st.columns(2)
1673
+ with col1:
1674
+ config["colorscale"] = st.selectbox(
1675
+ "Color scale",
1676
+ _COLORSCALES,
1677
+ key=f"{prefix}_cs",
1678
+ )
1679
+ with col2:
1680
+ config["title"] = st.text_input("Title", key=f"{prefix}_title") or None
1681
+ with st.expander("Appearance"):
1682
+ config.update(_plotly_appearance_widgets(prefix))
1683
+ return config
1684
+
1685
+
1686
+ # ── M14 widget functions ─────────────────────────────────────────────────────
1687
+
1688
+
1689
+ def _widgets_bar_chart(data: Any, prefix: str) -> Dict:
1690
+ config: Dict = {}
1691
+ numeric = _numeric_cols(data)
1692
+
1693
+ if isinstance(data, pd.DataFrame):
1694
+ all_cols = _all_cols(data)
1695
+ col1, col2 = st.columns(2)
1696
+ with col1:
1697
+ config["_cat_col"] = st.selectbox(
1698
+ "Category column",
1699
+ all_cols,
1700
+ index=0,
1701
+ key=f"{prefix}_catcol",
1702
+ )
1703
+ with col2:
1704
+ config["_val_col"] = st.selectbox(
1705
+ "Value column",
1706
+ numeric if numeric else all_cols,
1707
+ index=min(1, len(all_cols) - 1),
1708
+ key=f"{prefix}_valcol",
1709
+ )
1710
+ elif isinstance(data, np.ndarray) and data.ndim == 2:
1711
+ opts = [str(i) for i in range(data.shape[1])]
1712
+ col1, col2 = st.columns(2)
1713
+ with col1:
1714
+ config["_cat_col"] = st.selectbox(
1715
+ "Category column index",
1716
+ opts,
1717
+ index=0,
1718
+ key=f"{prefix}_catcol",
1719
+ )
1720
+ with col2:
1721
+ config["_val_col"] = st.selectbox(
1722
+ "Value column index",
1723
+ opts,
1724
+ index=min(1, data.shape[1] - 1),
1725
+ key=f"{prefix}_valcol",
1726
+ )
1727
+ else:
1728
+ st.info(
1729
+ "Bar chart will use the loaded array as values. "
1730
+ "Integer index will be used as categories."
1731
+ )
1732
+ config["_cat_col"] = None
1733
+ config["_val_col"] = None
1734
+
1735
+ st.markdown("**Chart type & grouping**")
1736
+ col1, col2 = st.columns(2)
1737
+ with col1:
1738
+ config["kind"] = st.selectbox(
1739
+ "Chart kind",
1740
+ ["simple", "grouped", "stacked"],
1741
+ key=f"{prefix}_kind",
1742
+ )
1743
+ with col2:
1744
+ if config["kind"] in ("grouped", "stacked"):
1745
+ if isinstance(data, pd.DataFrame) and numeric:
1746
+ hue_opts = [
1747
+ c
1748
+ for c in numeric
1749
+ if c != config.get("_val_col") and c != config.get("_cat_col")
1750
+ ]
1751
+ config["_hue_cols"] = st.multiselect(
1752
+ "Group columns",
1753
+ hue_opts,
1754
+ default=[],
1755
+ key=f"{prefix}_huecols",
1756
+ help=(
1757
+ "Each selected column becomes a bar group. "
1758
+ "Leave empty to use a single grouped series."
1759
+ ),
1760
+ )
1761
+ else:
1762
+ st.info("Grouped/stacked mode requires a DataFrame with multiple numeric columns.")
1763
+
1764
+ st.markdown("**Style**")
1765
+ col1, col2 = st.columns(2)
1766
+ with col1:
1767
+ config["color"] = _color_widget(prefix)
1768
+ with col2:
1769
+ config["alpha"] = st.slider("Opacity", 0.1, 1.0, 0.8, 0.05, key=f"{prefix}_alpha")
1770
+
1771
+ # Error bars (simple mode only)
1772
+ if config.get("kind", "simple") == "simple":
1773
+ if isinstance(data, pd.DataFrame) and numeric:
1774
+ with st.expander("Error bars (optional)"):
1775
+ err_opts = ["None"] + [c for c in numeric if c != config.get("_val_col")]
1776
+ err_sel = st.selectbox(
1777
+ "Y error column",
1778
+ err_opts,
1779
+ key=f"{prefix}_yerr_col",
1780
+ )
1781
+ config["_yerr_col"] = None if err_sel == "None" else err_sel
1782
+
1783
+ st.markdown("**Figure & labels**")
1784
+ config["figsize"] = _figsize_widget(prefix)
1785
+ config.update(_label_widgets(prefix))
1786
+ with st.expander("Appearance"):
1787
+ config.update(_grid_widgets(prefix))
1788
+ config.update(_appearance_widgets(prefix))
1789
+ config.update(_axis_limits_widgets(prefix))
1790
+ config.update(_y_axis_widgets(prefix))
1791
+ config.update(_caption_widget(prefix))
1792
+ return config
1793
+
1794
+
1795
+ def _widgets_waterfall_plot(data: Any, prefix: str) -> Dict:
1796
+ config: Dict = {}
1797
+ numeric = _numeric_cols(data)
1798
+
1799
+ if isinstance(data, pd.DataFrame):
1800
+ if len(numeric) < 2:
1801
+ st.warning(
1802
+ "Waterfall plot requires at least 2 numeric columns (1 X + 1 or more Y traces)."
1803
+ )
1804
+ return {}
1805
+ config["_x_col"] = st.selectbox(
1806
+ "X column",
1807
+ numeric,
1808
+ index=0,
1809
+ key=f"{prefix}_xcol",
1810
+ )
1811
+ y_opts = [c for c in numeric if c != config["_x_col"]]
1812
+ config["_y_cols"] = st.multiselect(
1813
+ "Y (trace) column(s)",
1814
+ y_opts,
1815
+ default=y_opts[: min(5, len(y_opts))],
1816
+ key=f"{prefix}_ycols",
1817
+ help="Each column becomes one trace in the waterfall.",
1818
+ )
1819
+ if not config["_y_cols"]:
1820
+ st.info("Select at least one Y column.")
1821
+ return {}
1822
+ elif isinstance(data, np.ndarray):
1823
+ if data.ndim == 2:
1824
+ opts = [str(i) for i in range(data.shape[1])]
1825
+ config["_x_col"] = st.selectbox(
1826
+ "X column index",
1827
+ opts,
1828
+ index=0,
1829
+ key=f"{prefix}_xcol",
1830
+ )
1831
+ y_opts2 = [c for c in opts if c != config["_x_col"]]
1832
+ config["_y_cols"] = st.multiselect(
1833
+ "Y (trace) column indices",
1834
+ y_opts2,
1835
+ default=y_opts2,
1836
+ key=f"{prefix}_ycols",
1837
+ )
1838
+ elif data.ndim == 3:
1839
+ st.info("3-D array detected: each row slice will be one trace.")
1840
+ config["_x_col"] = None
1841
+ config["_y_cols"] = None
1842
+ else:
1843
+ st.warning("Waterfall plot requires a 2-D or 3-D array.")
1844
+ return {}
1845
+ else:
1846
+ st.warning("Unsupported data type for waterfall plot.")
1847
+ return {}
1848
+
1849
+ st.markdown("**Style**")
1850
+ col1, col2, col3 = st.columns(3)
1851
+ with col1:
1852
+ config["cmap"] = st.selectbox("Colormap", _CMAPS, index=0, key=f"{prefix}_cmap")
1853
+ with col2:
1854
+ config["alpha"] = st.slider("Opacity", 0.1, 1.0, 0.9, 0.05, key=f"{prefix}_alpha")
1855
+ with col3:
1856
+ offset_mode = st.selectbox(
1857
+ "Trace offset",
1858
+ ["Auto", "Manual"],
1859
+ key=f"{prefix}_offset_mode",
1860
+ )
1861
+ if offset_mode == "Manual":
1862
+ config["offset"] = st.number_input(
1863
+ "Offset value",
1864
+ value=1.0,
1865
+ key=f"{prefix}_offset",
1866
+ )
1867
+ else:
1868
+ config["offset"] = "auto"
1869
+
1870
+ st.markdown("**Figure & labels**")
1871
+ config["figsize"] = _figsize_widget(prefix, default=(10.0, 7.0))
1872
+ config.update(_label_widgets(prefix))
1873
+ with st.expander("Appearance"):
1874
+ config.update(_grid_widgets(prefix))
1875
+ config.update(_appearance_widgets(prefix, include_linewidth=True))
1876
+ config.update(_axis_limits_widgets(prefix))
1877
+ config.update(_caption_widget(prefix))
1878
+ return config
1879
+
1880
+
1881
+ def _widgets_dual_axis_plot(data: Any, prefix: str) -> Dict:
1882
+ config: Dict = {}
1883
+ numeric = _numeric_cols(data)
1884
+
1885
+ if isinstance(data, pd.DataFrame):
1886
+ if len(numeric) < 3:
1887
+ st.warning("Dual Y-axis plot requires at least 3 numeric columns (1 X + 2 Y).")
1888
+ return {}
1889
+ config["_x_col"] = st.selectbox(
1890
+ "X column",
1891
+ numeric,
1892
+ index=0,
1893
+ key=f"{prefix}_xcol",
1894
+ )
1895
+ remaining = [c for c in numeric if c != config["_x_col"]]
1896
+ col1, col2 = st.columns(2)
1897
+ with col1:
1898
+ config["_y1_col"] = st.selectbox(
1899
+ "Left Y column",
1900
+ remaining,
1901
+ index=0,
1902
+ key=f"{prefix}_y1col",
1903
+ )
1904
+ with col2:
1905
+ y2_opts = [c for c in remaining if c != config["_y1_col"]]
1906
+ config["_y2_col"] = st.selectbox(
1907
+ "Right Y column",
1908
+ y2_opts if y2_opts else remaining,
1909
+ index=0,
1910
+ key=f"{prefix}_y2col",
1911
+ )
1912
+ elif isinstance(data, np.ndarray) and data.ndim == 2 and data.shape[1] >= 3:
1913
+ opts = [str(i) for i in range(data.shape[1])]
1914
+ config["_x_col"] = st.selectbox("X column index", opts, index=0, key=f"{prefix}_xcol")
1915
+ col1, col2 = st.columns(2)
1916
+ with col1:
1917
+ config["_y1_col"] = st.selectbox(
1918
+ "Left Y column index",
1919
+ [c for c in opts if c != config["_x_col"]],
1920
+ index=0,
1921
+ key=f"{prefix}_y1col",
1922
+ )
1923
+ with col2:
1924
+ y2_opts2 = [c for c in opts if c not in (config["_x_col"], config["_y1_col"])]
1925
+ config["_y2_col"] = st.selectbox(
1926
+ "Right Y column index",
1927
+ y2_opts2 if y2_opts2 else opts,
1928
+ index=0,
1929
+ key=f"{prefix}_y2col",
1930
+ )
1931
+ else:
1932
+ st.warning(
1933
+ "Dual Y-axis plot requires a DataFrame or 2-D array with at least 3 numeric columns."
1934
+ )
1935
+ return {}
1936
+
1937
+ st.markdown("**Style**")
1938
+ col1, col2, col3, col4 = st.columns(4)
1939
+ with col1:
1940
+ config["color1"] = st.color_picker("Left color", "#4682B4", key=f"{prefix}_c1")
1941
+ with col2:
1942
+ config["color2"] = st.color_picker("Right color", "#FF6347", key=f"{prefix}_c2")
1943
+ with col3:
1944
+ config["linestyle1"] = st.selectbox(
1945
+ "Left style",
1946
+ ["-", "--", "-.", ":"],
1947
+ key=f"{prefix}_ls1",
1948
+ )
1949
+ with col4:
1950
+ config["linestyle2"] = st.selectbox(
1951
+ "Right style",
1952
+ ["--", "-", "-.", ":"],
1953
+ key=f"{prefix}_ls2",
1954
+ )
1955
+
1956
+ st.markdown("**Figure & labels**")
1957
+ config["figsize"] = _figsize_widget(prefix)
1958
+ col1, col2, col3, col4 = st.columns(4)
1959
+ with col1:
1960
+ config["title"] = st.text_input("Title", key=f"{prefix}_title", help=_LABEL_HELP) or None
1961
+ with col2:
1962
+ config["xlabel"] = (
1963
+ st.text_input("X Label", key=f"{prefix}_xlabel", help=_LABEL_HELP) or None
1964
+ )
1965
+ with col3:
1966
+ config["ylabel1"] = (
1967
+ st.text_input("Left Y Label", key=f"{prefix}_ylabel1", help=_LABEL_HELP) or None
1968
+ )
1969
+ with col4:
1970
+ config["ylabel2"] = (
1971
+ st.text_input("Right Y Label", key=f"{prefix}_ylabel2", help=_LABEL_HELP) or None
1972
+ )
1973
+ _symbol_reference_expander()
1974
+
1975
+ with st.expander("Appearance"):
1976
+ config.update(_appearance_widgets(prefix, include_linewidth=True))
1977
+ config.update(_caption_widget(prefix))
1978
+ return config
1979
+
1980
+
1981
+ def _widgets_z_colored_scatter(data: Any, prefix: str) -> Dict:
1982
+ config: Dict = {}
1983
+ numeric = _numeric_cols(data)
1984
+ if isinstance(data, pd.DataFrame) and len(numeric) >= 2:
1985
+ col1, col2, col3 = st.columns(3)
1986
+ with col1:
1987
+ config["_x_col"] = st.selectbox("X column", numeric, index=0, key=f"{prefix}_xcol")
1988
+ with col2:
1989
+ config["_y_col"] = st.selectbox(
1990
+ "Y column", numeric, index=min(1, len(numeric) - 1), key=f"{prefix}_ycol"
1991
+ )
1992
+ with col3:
1993
+ config["_z_col"] = st.selectbox(
1994
+ "Color column", numeric, index=min(2, len(numeric) - 1), key=f"{prefix}_zcol"
1995
+ )
1996
+ elif isinstance(data, np.ndarray) and data.ndim == 2 and data.shape[1] >= 3:
1997
+ opts = [str(i) for i in range(data.shape[1])]
1998
+ col1, col2, col3 = st.columns(3)
1999
+ with col1:
2000
+ config["_x_col"] = st.selectbox("X column index", opts, index=0, key=f"{prefix}_xcol")
2001
+ with col2:
2002
+ config["_y_col"] = st.selectbox("Y column index", opts, index=1, key=f"{prefix}_ycol")
2003
+ with col3:
2004
+ config["_z_col"] = st.selectbox(
2005
+ "Color column index", opts, index=2, key=f"{prefix}_zcol"
2006
+ )
2007
+ else:
2008
+ st.warning("Z-colored scatter requires at least 3 columns.")
2009
+
2010
+ st.markdown("**Appearance**")
2011
+ col1, col2 = st.columns(2)
2012
+ with col1:
2013
+ config["cmap"] = st.selectbox("Colormap", _CMAPS, index=0, key=f"{prefix}_cmap")
2014
+ with col2:
2015
+ config["alpha"] = st.slider("Opacity", 0.1, 1.0, 0.8, 0.05, key=f"{prefix}_alpha")
2016
+ config["colorbar"] = st.checkbox("Show colorbar", value=True, key=f"{prefix}_colorbar")
2017
+ config["colorbar_label"] = (
2018
+ st.text_input("Colorbar label", value="", key=f"{prefix}_cblabel") or None
2019
+ )
2020
+ config["s"] = st.slider("Marker size", 5, 200, 20, 5, key=f"{prefix}_markersize")
2021
+ config["figsize"] = _figsize_widget(prefix)
2022
+ config.update(_label_widgets(prefix))
2023
+ with st.expander("Appearance"):
2024
+ config.update(_grid_widgets(prefix))
2025
+ config.update(_appearance_widgets(prefix))
2026
+ config.update(_axis_limits_widgets(prefix))
2027
+ config.update(_caption_widget(prefix))
2028
+ return config
2029
+
2030
+
2031
+ def _widgets_bubble_chart(data: Any, prefix: str) -> Dict:
2032
+ config: Dict = {}
2033
+ numeric = _numeric_cols(data)
2034
+ if isinstance(data, pd.DataFrame) and len(numeric) >= 3:
2035
+ all_cols = numeric
2036
+ col1, col2, col3 = st.columns(3)
2037
+ with col1:
2038
+ config["_x_col"] = st.selectbox("X column", all_cols, index=0, key=f"{prefix}_xcol")
2039
+ with col2:
2040
+ config["_y_col"] = st.selectbox(
2041
+ "Y column", all_cols, index=min(1, len(all_cols) - 1), key=f"{prefix}_ycol"
2042
+ )
2043
+ with col3:
2044
+ config["_size_col"] = st.selectbox(
2045
+ "Size column", all_cols, index=min(2, len(all_cols) - 1), key=f"{prefix}_sizecol"
2046
+ )
2047
+ z_opts = ["None"] + list(all_cols)
2048
+ z_sel = st.selectbox("Color column (optional)", z_opts, index=0, key=f"{prefix}_zcol")
2049
+ config["_z_col"] = None if z_sel == "None" else z_sel
2050
+ elif isinstance(data, np.ndarray) and data.ndim == 2 and data.shape[1] >= 3:
2051
+ opts = [str(i) for i in range(data.shape[1])]
2052
+ col1, col2, col3 = st.columns(3)
2053
+ with col1:
2054
+ config["_x_col"] = st.selectbox("X col index", opts, index=0, key=f"{prefix}_xcol")
2055
+ with col2:
2056
+ config["_y_col"] = st.selectbox("Y col index", opts, index=1, key=f"{prefix}_ycol")
2057
+ with col3:
2058
+ config["_size_col"] = st.selectbox(
2059
+ "Size col index", opts, index=2, key=f"{prefix}_sizecol"
2060
+ )
2061
+ config["_z_col"] = None
2062
+ else:
2063
+ st.warning("Bubble chart requires at least 3 columns (x, y, size).")
2064
+
2065
+ st.markdown("**Appearance**")
2066
+ col1, col2 = st.columns(2)
2067
+ with col1:
2068
+ config["color"] = _color_widget(prefix)
2069
+ with col2:
2070
+ config["alpha"] = st.slider("Opacity", 0.1, 1.0, 0.6, 0.05, key=f"{prefix}_alpha")
2071
+ config["size_scale"] = st.slider("Size scale", 0.1, 10.0, 1.0, 0.1, key=f"{prefix}_sizescale")
2072
+ if config.get("_z_col"):
2073
+ config["cmap"] = st.selectbox("Colormap", _CMAPS, index=0, key=f"{prefix}_cmap")
2074
+ config["colorbar"] = st.checkbox("Show colorbar", value=True, key=f"{prefix}_colorbar")
2075
+ config["figsize"] = _figsize_widget(prefix)
2076
+ config.update(_label_widgets(prefix))
2077
+ with st.expander("Appearance"):
2078
+ config.update(_grid_widgets(prefix))
2079
+ config.update(_appearance_widgets(prefix))
2080
+ config.update(_axis_limits_widgets(prefix))
2081
+ config.update(_caption_widget(prefix))
2082
+ return config
2083
+
2084
+
2085
+ def _widgets_polar_plot(data: Any, prefix: str) -> Dict:
2086
+ config: Dict = {}
2087
+ numeric = _numeric_cols(data)
2088
+ if isinstance(data, pd.DataFrame) and len(numeric) >= 2:
2089
+ col1, col2 = st.columns(2)
2090
+ with col1:
2091
+ config["_theta_col"] = st.selectbox(
2092
+ "Theta column (radians)", numeric, index=0, key=f"{prefix}_thetacol"
2093
+ )
2094
+ with col2:
2095
+ config["_r_col"] = st.selectbox(
2096
+ "R column", numeric, index=min(1, len(numeric) - 1), key=f"{prefix}_rcol"
2097
+ )
2098
+ elif isinstance(data, np.ndarray) and data.ndim == 2 and data.shape[1] >= 2:
2099
+ opts = [str(i) for i in range(data.shape[1])]
2100
+ col1, col2 = st.columns(2)
2101
+ with col1:
2102
+ config["_theta_col"] = st.selectbox(
2103
+ "Theta column index", opts, index=0, key=f"{prefix}_thetacol"
2104
+ )
2105
+ with col2:
2106
+ config["_r_col"] = st.selectbox("R column index", opts, index=1, key=f"{prefix}_rcol")
2107
+ elif isinstance(data, np.ndarray) and data.ndim == 1:
2108
+ config["_theta_col"] = None
2109
+ config["_r_col"] = None
2110
+ st.info("1D array: will use index as theta (radians) and values as r.")
2111
+
2112
+ st.markdown("**Appearance**")
2113
+ col1, col2 = st.columns(2)
2114
+ with col1:
2115
+ config["color"] = _color_widget(prefix)
2116
+ config["fill"] = st.checkbox("Fill area", value=False, key=f"{prefix}_fill")
2117
+ with col2:
2118
+ config["theta_zero_location"] = st.selectbox(
2119
+ "0° at", ["N", "E", "S", "W"], index=0, key=f"{prefix}_theta0"
2120
+ )
2121
+ config["theta_direction"] = st.selectbox(
2122
+ "Direction",
2123
+ [-1, 1],
2124
+ format_func=lambda x: "Clockwise" if x == -1 else "Counter-clockwise",
2125
+ index=0,
2126
+ key=f"{prefix}_thetadir",
2127
+ )
2128
+ config["figsize"] = _figsize_widget(prefix)
2129
+ config["title"] = st.text_input("Title", value="", key=f"{prefix}_title") or None
2130
+ with st.expander("Appearance"):
2131
+ config.update(_grid_widgets(prefix))
2132
+ config.update(_appearance_widgets(prefix))
2133
+ return config
2134
+
2135
+
2136
+ def _widgets_histogram_2d(data: Any, prefix: str) -> Dict:
2137
+ config: Dict = {}
2138
+ numeric = _numeric_cols(data)
2139
+ if isinstance(data, pd.DataFrame) and len(numeric) >= 2:
2140
+ col1, col2 = st.columns(2)
2141
+ with col1:
2142
+ config["_x_col"] = st.selectbox("X column", numeric, index=0, key=f"{prefix}_xcol")
2143
+ with col2:
2144
+ config["_y_col"] = st.selectbox(
2145
+ "Y column", numeric, index=min(1, len(numeric) - 1), key=f"{prefix}_ycol"
2146
+ )
2147
+ elif isinstance(data, np.ndarray) and data.ndim == 2 and data.shape[1] >= 2:
2148
+ opts = [str(i) for i in range(data.shape[1])]
2149
+ col1, col2 = st.columns(2)
2150
+ with col1:
2151
+ config["_x_col"] = st.selectbox("X col index", opts, index=0, key=f"{prefix}_xcol")
2152
+ with col2:
2153
+ config["_y_col"] = st.selectbox("Y col index", opts, index=1, key=f"{prefix}_ycol")
2154
+ else:
2155
+ st.warning("2D histogram requires at least 2 columns.")
2156
+
2157
+ st.markdown("**Settings**")
2158
+ col1, col2 = st.columns(2)
2159
+ with col1:
2160
+ config["mode"] = st.selectbox("Mode", ["hist2d", "hexbin"], index=0, key=f"{prefix}_mode")
2161
+ with col2:
2162
+ config["cmap"] = st.selectbox("Colormap", _CMAPS, index=0, key=f"{prefix}_cmap")
2163
+ if config.get("mode") == "hexbin":
2164
+ config["gridsize"] = st.slider("Grid size", 5, 80, 30, 5, key=f"{prefix}_gridsize")
2165
+ else:
2166
+ config["bins"] = st.slider("Bins", 5, 100, 30, 5, key=f"{prefix}_bins")
2167
+ config["colorbar"] = st.checkbox("Show colorbar", value=True, key=f"{prefix}_colorbar")
2168
+ config["figsize"] = _figsize_widget(prefix)
2169
+ config.update(_label_widgets(prefix))
2170
+ with st.expander("Appearance"):
2171
+ config.update(_appearance_widgets(prefix))
2172
+ config.update(_axis_limits_widgets(prefix))
2173
+ config.update(_caption_widget(prefix))
2174
+ return config
2175
+
2176
+
2177
+ def _widgets_pair_plot(data: Any, prefix: str) -> Dict:
2178
+ config: Dict = {}
2179
+ if not isinstance(data, pd.DataFrame):
2180
+ st.warning("Pair plot requires a DataFrame.")
2181
+ return config
2182
+ numeric = _numeric_cols(data)
2183
+ all_cols = _all_cols(data)
2184
+ if len(numeric) < 2:
2185
+ st.warning("Pair plot requires at least 2 numeric columns.")
2186
+ return config
2187
+
2188
+ selected_vars = st.multiselect(
2189
+ "Variables to plot (leave empty for all numeric)",
2190
+ numeric,
2191
+ default=[],
2192
+ key=f"{prefix}_vars",
2193
+ )
2194
+ config["vars"] = selected_vars if selected_vars else None
2195
+
2196
+ hue_opts = ["None"] + all_cols
2197
+ hue_sel = st.selectbox("Hue column", hue_opts, index=0, key=f"{prefix}_hue")
2198
+ config["hue"] = None if hue_sel == "None" else hue_sel
2199
+
2200
+ col1, col2 = st.columns(2)
2201
+ with col1:
2202
+ config["kind"] = st.selectbox(
2203
+ "Off-diagonal", ["scatter", "kde", "hist", "reg"], index=0, key=f"{prefix}_kind"
2204
+ )
2205
+ with col2:
2206
+ config["diag_kind"] = st.selectbox(
2207
+ "Diagonal", ["hist", "kde", "auto"], index=0, key=f"{prefix}_diagkind"
2208
+ )
2209
+
2210
+ config["title"] = st.text_input("Title", value="", key=f"{prefix}_title") or None
2211
+ return config
2212
+
2213
+
2214
+ def _widgets_interactive_3d_scatter(data: Any, prefix: str) -> Dict:
2215
+ config: Dict = {}
2216
+ numeric = _numeric_cols(data)
2217
+ if isinstance(data, pd.DataFrame) and len(numeric) >= 3:
2218
+ col1, col2, col3 = st.columns(3)
2219
+ with col1:
2220
+ config["_x_col"] = st.selectbox("X column", numeric, index=0, key=f"{prefix}_xcol")
2221
+ with col2:
2222
+ config["_y_col"] = st.selectbox(
2223
+ "Y column", numeric, index=min(1, len(numeric) - 1), key=f"{prefix}_ycol"
2224
+ )
2225
+ with col3:
2226
+ config["_z_col"] = st.selectbox(
2227
+ "Z column", numeric, index=min(2, len(numeric) - 1), key=f"{prefix}_zcol"
2228
+ )
2229
+ extra_opts = ["None"] + list(numeric)
2230
+ col_a, col_b = st.columns(2)
2231
+ with col_a:
2232
+ color_sel = st.selectbox(
2233
+ "Color column (optional)", extra_opts, index=0, key=f"{prefix}_ccol"
2234
+ )
2235
+ config["_color_col"] = None if color_sel == "None" else color_sel
2236
+ with col_b:
2237
+ size_sel = st.selectbox(
2238
+ "Size column (optional)", extra_opts, index=0, key=f"{prefix}_scol"
2239
+ )
2240
+ config["_size_col"] = None if size_sel == "None" else size_sel
2241
+ elif isinstance(data, np.ndarray) and data.ndim == 2 and data.shape[1] >= 3:
2242
+ opts = [str(i) for i in range(data.shape[1])]
2243
+ col1, col2, col3 = st.columns(3)
2244
+ with col1:
2245
+ config["_x_col"] = st.selectbox("X col index", opts, index=0, key=f"{prefix}_xcol")
2246
+ with col2:
2247
+ config["_y_col"] = st.selectbox("Y col index", opts, index=1, key=f"{prefix}_ycol")
2248
+ with col3:
2249
+ config["_z_col"] = st.selectbox("Z col index", opts, index=2, key=f"{prefix}_zcol")
2250
+ config["_color_col"] = None
2251
+ config["_size_col"] = None
2252
+ else:
2253
+ st.warning("3D scatter requires at least 3 columns.")
2254
+
2255
+ st.markdown("**Appearance**")
2256
+ col1, col2 = st.columns(2)
2257
+ with col1:
2258
+ config["colorscale"] = st.selectbox("Colorscale", _COLORSCALES, index=0, key=f"{prefix}_cs")
2259
+ config["marker_size"] = st.slider("Marker size", 2, 20, 5, 1, key=f"{prefix}_msize")
2260
+ with col2:
2261
+ config["opacity"] = st.slider("Opacity", 0.1, 1.0, 0.8, 0.05, key=f"{prefix}_opacity")
2262
+ config["title"] = st.text_input("Title", value="", key=f"{prefix}_title") or None
2263
+ config["xlabel"] = st.text_input("X label", value="", key=f"{prefix}_xl") or None
2264
+ config["ylabel"] = st.text_input("Y label", value="", key=f"{prefix}_yl") or None
2265
+ config["zlabel"] = st.text_input("Z label", value="", key=f"{prefix}_zl") or None
2266
+ with st.expander("Plotly settings"):
2267
+ config["plotly_height"] = st.slider("Height (px)", 400, 1000, 600, 50, key=f"{prefix}_ph")
2268
+ return config
2269
+
2270
+
2271
+ def _widgets_scatter_with_regression(data: Any, prefix: str) -> Dict:
2272
+ config: Dict = {}
2273
+ numeric = _numeric_cols(data)
2274
+ if isinstance(data, pd.DataFrame) and len(numeric) >= 2:
2275
+ col1, col2 = st.columns(2)
2276
+ with col1:
2277
+ config["_x_col"] = st.selectbox("X column", numeric, index=0, key=f"{prefix}_xcol")
2278
+ with col2:
2279
+ config["_y_col"] = st.selectbox(
2280
+ "Y column", numeric, index=min(1, len(numeric) - 1), key=f"{prefix}_ycol"
2281
+ )
2282
+ elif isinstance(data, np.ndarray) and data.ndim == 2 and data.shape[1] >= 2:
2283
+ opts = [str(i) for i in range(data.shape[1])]
2284
+ col1, col2 = st.columns(2)
2285
+ with col1:
2286
+ config["_x_col"] = st.selectbox("X col index", opts, index=0, key=f"{prefix}_xcol")
2287
+ with col2:
2288
+ config["_y_col"] = st.selectbox("Y col index", opts, index=1, key=f"{prefix}_ycol")
2289
+ else:
2290
+ st.warning("Scatter + Regression requires at least 2 numeric columns.")
2291
+ col1, col2 = st.columns(2)
2292
+ with col1:
2293
+ config["color"] = _color_widget(prefix)
2294
+ config["show_ci"] = st.checkbox("Show 95% CI band", value=True, key=f"{prefix}_ci")
2295
+ with col2:
2296
+ config["alpha"] = st.slider("Opacity", 0.1, 1.0, 0.7, 0.05, key=f"{prefix}_alpha")
2297
+ config["show_equation"] = st.checkbox("Show equation", value=True, key=f"{prefix}_eq")
2298
+ config["figsize"] = _figsize_widget(prefix)
2299
+ config.update(_label_widgets(prefix))
2300
+ with st.expander("Appearance"):
2301
+ config.update(_grid_widgets(prefix))
2302
+ config.update(_appearance_widgets(prefix))
2303
+ config.update(_axis_limits_widgets(prefix))
2304
+ config.update(_caption_widget(prefix))
2305
+ return config
2306
+
2307
+
2308
+ def _widgets_residual_plot(data: Any, prefix: str) -> Dict:
2309
+ config: Dict = {}
2310
+ numeric = _numeric_cols(data)
2311
+ if isinstance(data, pd.DataFrame) and len(numeric) >= 3:
2312
+ col1, col2, col3 = st.columns(3)
2313
+ with col1:
2314
+ config["_x_col"] = st.selectbox("X column", numeric, index=0, key=f"{prefix}_xcol")
2315
+ with col2:
2316
+ config["_y_actual_col"] = st.selectbox(
2317
+ "Y actual", numeric, index=min(1, len(numeric) - 1), key=f"{prefix}_yacol"
2318
+ )
2319
+ with col3:
2320
+ config["_y_fitted_col"] = st.selectbox(
2321
+ "Y fitted", numeric, index=min(2, len(numeric) - 1), key=f"{prefix}_yfcol"
2322
+ )
2323
+ elif isinstance(data, np.ndarray) and data.ndim == 2 and data.shape[1] >= 3:
2324
+ opts = [str(i) for i in range(data.shape[1])]
2325
+ col1, col2, col3 = st.columns(3)
2326
+ with col1:
2327
+ config["_x_col"] = st.selectbox("X col index", opts, index=0, key=f"{prefix}_xcol")
2328
+ with col2:
2329
+ config["_y_actual_col"] = st.selectbox(
2330
+ "Y actual index", opts, index=1, key=f"{prefix}_yacol"
2331
+ )
2332
+ with col3:
2333
+ config["_y_fitted_col"] = st.selectbox(
2334
+ "Y fitted index", opts, index=2, key=f"{prefix}_yfcol"
2335
+ )
2336
+ else:
2337
+ st.warning("Residual plot requires at least 3 columns: x, y_actual, y_fitted.")
2338
+ config["vs_fitted"] = st.checkbox(
2339
+ "Plot vs. fitted values (not x)", value=False, key=f"{prefix}_vsfitted"
2340
+ )
2341
+ config["show_zero_line"] = st.checkbox("Show zero line", value=True, key=f"{prefix}_zeroline")
2342
+ config["alpha"] = st.slider("Opacity", 0.1, 1.0, 0.75, 0.05, key=f"{prefix}_alpha")
2343
+ config["figsize"] = _figsize_widget(prefix)
2344
+ config.update(_label_widgets(prefix))
2345
+ with st.expander("Appearance"):
2346
+ config.update(_grid_widgets(prefix))
2347
+ config.update(_appearance_widgets(prefix))
2348
+ config.update(_axis_limits_widgets(prefix))
2349
+ config.update(_caption_widget(prefix))
2350
+ return config
2351
+
2352
+
2353
+ def _widgets_interactive_ternary(data: Any, prefix: str) -> Dict:
2354
+ config: Dict = {}
2355
+ numeric = _numeric_cols(data)
2356
+ if isinstance(data, pd.DataFrame) and len(numeric) >= 3:
2357
+ col1, col2, col3 = st.columns(3)
2358
+ with col1:
2359
+ config["_a_col"] = st.selectbox("A column", numeric, index=0, key=f"{prefix}_acol")
2360
+ with col2:
2361
+ config["_b_col"] = st.selectbox(
2362
+ "B column", numeric, index=min(1, len(numeric) - 1), key=f"{prefix}_bcol"
2363
+ )
2364
+ with col3:
2365
+ config["_c_col"] = st.selectbox(
2366
+ "C column", numeric, index=min(2, len(numeric) - 1), key=f"{prefix}_ccol"
2367
+ )
2368
+ extra_opts = ["None"] + numeric
2369
+ color_sel = st.selectbox(
2370
+ "Color column (optional)", extra_opts, index=0, key=f"{prefix}_colcol"
2371
+ )
2372
+ config["_color_col"] = None if color_sel == "None" else color_sel
2373
+ elif isinstance(data, np.ndarray) and data.ndim == 2 and data.shape[1] >= 3:
2374
+ opts = [str(i) for i in range(data.shape[1])]
2375
+ col1, col2, col3 = st.columns(3)
2376
+ with col1:
2377
+ config["_a_col"] = st.selectbox("A col index", opts, index=0, key=f"{prefix}_acol")
2378
+ with col2:
2379
+ config["_b_col"] = st.selectbox("B col index", opts, index=1, key=f"{prefix}_bcol")
2380
+ with col3:
2381
+ config["_c_col"] = st.selectbox("C col index", opts, index=2, key=f"{prefix}_ccol")
2382
+ config["_color_col"] = None
2383
+ else:
2384
+ st.warning("Ternary plot requires at least 3 columns (A, B, C components).")
2385
+ col1, col2, col3 = st.columns(3)
2386
+ with col1:
2387
+ config["a_label"] = st.text_input("A label", value="A", key=f"{prefix}_al")
2388
+ with col2:
2389
+ config["b_label"] = st.text_input("B label", value="B", key=f"{prefix}_bl")
2390
+ with col3:
2391
+ config["c_label"] = st.text_input("C label", value="C", key=f"{prefix}_cl")
2392
+ config["colorscale"] = st.selectbox("Colorscale", _COLORSCALES, index=0, key=f"{prefix}_cs")
2393
+ config["marker_size"] = st.slider("Marker size", 3, 20, 8, 1, key=f"{prefix}_ms")
2394
+ config["opacity"] = st.slider("Opacity", 0.1, 1.0, 0.85, 0.05, key=f"{prefix}_op")
2395
+ config["title"] = st.text_input("Title", value="", key=f"{prefix}_title") or None
2396
+ with st.expander("Plotly settings"):
2397
+ config["plotly_height"] = st.slider("Height (px)", 400, 900, 600, 50, key=f"{prefix}_ph")
2398
+ return config
2399
+
2400
+
2401
+ def _widgets_broken_axis_plot(data: Any, prefix: str) -> Dict:
2402
+ config: Dict = {}
2403
+ numeric = _numeric_cols(data)
2404
+ if isinstance(data, pd.DataFrame) and len(numeric) >= 2:
2405
+ col1, col2 = st.columns(2)
2406
+ with col1:
2407
+ config["_x_col"] = st.selectbox("X column", numeric, index=0, key=f"{prefix}_xcol")
2408
+ with col2:
2409
+ config["_y_col"] = st.selectbox(
2410
+ "Y column", numeric, index=min(1, len(numeric) - 1), key=f"{prefix}_ycol"
2411
+ )
2412
+ elif isinstance(data, np.ndarray) and data.ndim == 2 and data.shape[1] >= 2:
2413
+ opts = [str(i) for i in range(data.shape[1])]
2414
+ col1, col2 = st.columns(2)
2415
+ with col1:
2416
+ config["_x_col"] = st.selectbox("X col index", opts, index=0, key=f"{prefix}_xcol")
2417
+ with col2:
2418
+ config["_y_col"] = st.selectbox("Y col index", opts, index=1, key=f"{prefix}_ycol")
2419
+ else:
2420
+ st.warning("Broken axis plot requires at least 2 columns (x and y).")
2421
+
2422
+ st.markdown("**Axis breaks** — define regions to skip")
2423
+ n_breaks = st.number_input(
2424
+ "Number of breaks", min_value=1, max_value=4, value=1, step=1, key=f"{prefix}_nbreaks"
2425
+ )
2426
+ breaks = []
2427
+ for i in range(int(n_breaks)):
2428
+ c1, c2 = st.columns(2)
2429
+ with c1:
2430
+ lo = st.number_input(
2431
+ f"Break {i + 1} start", value=0.0, key=f"{prefix}_blo{i}", format="%.4g"
2432
+ )
2433
+ with c2:
2434
+ hi = st.number_input(
2435
+ f"Break {i + 1} end", value=1.0, key=f"{prefix}_bhi{i}", format="%.4g"
2436
+ )
2437
+ if hi > lo:
2438
+ breaks.append((float(lo), float(hi)))
2439
+ config["_breaks"] = breaks if breaks else [(0.0, 0.0)]
2440
+ config["axis"] = st.selectbox("Break axis", ["x", "y"], index=0, key=f"{prefix}_axis")
2441
+ col1, col2 = st.columns(2)
2442
+ with col1:
2443
+ config["color"] = _color_widget(prefix)
2444
+ with col2:
2445
+ config["linewidth"] = st.slider("Line width", 0.5, 5.0, 1.5, 0.25, key=f"{prefix}_lw")
2446
+ config["figsize"] = _figsize_widget(prefix)
2447
+ config.update(_label_widgets(prefix))
2448
+ with st.expander("Appearance"):
2449
+ config.update(_grid_widgets(prefix))
2450
+ config.update(_appearance_widgets(prefix))
2451
+ config.update(_caption_widget(prefix))
2452
+ return config
2453
+
2454
+
2455
+ def _widgets_inset_plot(data: Any, prefix: str) -> Dict:
2456
+ """Widget function for the inset_plot specialty plot type."""
2457
+ config: Dict = {}
2458
+ numeric = _numeric_cols(data)
2459
+
2460
+ if isinstance(data, pd.DataFrame):
2461
+ if len(numeric) < 2:
2462
+ st.warning("Inset plot requires at least 2 numeric columns.")
2463
+ return {}
2464
+ st.markdown("**Main plot columns**")
2465
+ c1, c2 = st.columns(2)
2466
+ with c1:
2467
+ config["_x_col"] = st.selectbox(
2468
+ "X column (main)", numeric, index=0, key=f"{prefix}_xcol"
2469
+ )
2470
+ with c2:
2471
+ config["_y_col"] = st.selectbox(
2472
+ "Y column (main)", numeric, index=min(1, len(numeric) - 1), key=f"{prefix}_ycol"
2473
+ )
2474
+ st.markdown("**Inset plot columns**")
2475
+ c3, c4 = st.columns(2)
2476
+ with c3:
2477
+ config["_xi_col"] = st.selectbox(
2478
+ "X column (inset)", numeric, index=0, key=f"{prefix}_xicol"
2479
+ )
2480
+ with c4:
2481
+ config["_yi_col"] = st.selectbox(
2482
+ "Y column (inset)", numeric, index=min(1, len(numeric) - 1), key=f"{prefix}_yicol"
2483
+ )
2484
+ elif isinstance(data, np.ndarray) and data.ndim == 2 and data.shape[1] >= 2:
2485
+ opts = [str(i) for i in range(data.shape[1])]
2486
+ st.markdown("**Main plot columns**")
2487
+ c1, c2 = st.columns(2)
2488
+ with c1:
2489
+ config["_x_col"] = st.selectbox(
2490
+ "X col index (main)", opts, index=0, key=f"{prefix}_xcol"
2491
+ )
2492
+ with c2:
2493
+ config["_y_col"] = st.selectbox(
2494
+ "Y col index (main)", opts, index=min(1, len(opts) - 1), key=f"{prefix}_ycol"
2495
+ )
2496
+ st.markdown("**Inset plot columns**")
2497
+ c3, c4 = st.columns(2)
2498
+ with c3:
2499
+ config["_xi_col"] = st.selectbox(
2500
+ "X col index (inset)", opts, index=0, key=f"{prefix}_xicol"
2501
+ )
2502
+ with c4:
2503
+ config["_yi_col"] = st.selectbox(
2504
+ "Y col index (inset)", opts, index=min(1, len(opts) - 1), key=f"{prefix}_yicol"
2505
+ )
2506
+ else:
2507
+ st.warning("Inset plot requires a DataFrame or 2D array with >= 2 columns.")
2508
+ return {}
2509
+
2510
+ st.markdown("**Inset bounds** (axes fraction 0–1)")
2511
+ ib1, ib2, ib3, ib4 = st.columns(4)
2512
+ with ib1:
2513
+ config["_ib_x0"] = st.number_input(
2514
+ "x0", min_value=0.0, max_value=0.95, value=0.55, step=0.05, key=f"{prefix}_ibx0"
2515
+ )
2516
+ with ib2:
2517
+ config["_ib_y0"] = st.number_input(
2518
+ "y0", min_value=0.0, max_value=0.95, value=0.55, step=0.05, key=f"{prefix}_iby0"
2519
+ )
2520
+ with ib3:
2521
+ config["_ib_w"] = st.number_input(
2522
+ "width", min_value=0.05, max_value=0.9, value=0.4, step=0.05, key=f"{prefix}_ibw"
2523
+ )
2524
+ with ib4:
2525
+ config["_ib_h"] = st.number_input(
2526
+ "height", min_value=0.05, max_value=0.9, value=0.35, step=0.05, key=f"{prefix}_ibh"
2527
+ )
2528
+
2529
+ st.markdown("**Indicate region on main plot** (optional)")
2530
+ use_region = st.checkbox("Shade region on main plot", value=False, key=f"{prefix}_useregion")
2531
+ if use_region:
2532
+ rc1, rc2 = st.columns(2)
2533
+ with rc1:
2534
+ config["_region_start"] = st.number_input(
2535
+ "Region start", value=0.0, key=f"{prefix}_rstart"
2536
+ )
2537
+ with rc2:
2538
+ config["_region_end"] = st.number_input("Region end", value=1.0, key=f"{prefix}_rend")
2539
+
2540
+ st.markdown("**Labels**")
2541
+ config["inset_xlabel"] = (
2542
+ st.text_input("Inset X label", value="", key=f"{prefix}_ixlabel") or None
2543
+ )
2544
+ config["inset_ylabel"] = (
2545
+ st.text_input("Inset Y label", value="", key=f"{prefix}_iylabel") or None
2546
+ )
2547
+ config["figsize"] = _figsize_widget(prefix)
2548
+ config.update(_label_widgets(prefix))
2549
+ with st.expander("Appearance"):
2550
+ config.update(_grid_widgets(prefix))
2551
+ config.update(_appearance_widgets(prefix, include_linewidth=True))
2552
+ config.update(_axis_limits_widgets(prefix))
2553
+ config.update(_caption_widget(prefix))
2554
+ return config
2555
+
2556
+
2557
+ # ── Dispatch table ───────────────────────────────────────────────────────────
2558
+
2559
+ _WIDGET_FUNCS: Dict[str, Any] = {
2560
+ "histogram": _widgets_histogram,
2561
+ "line_plot": _widgets_line_plot,
2562
+ "scatter_plot": _widgets_scatter_plot,
2563
+ "bar_chart": _widgets_bar_chart,
2564
+ "heatmap": _widgets_heatmap,
2565
+ "contour_plot": _widgets_contour_plot,
2566
+ "waterfall_plot": _widgets_waterfall_plot,
2567
+ "dual_axis_plot": _widgets_dual_axis_plot,
2568
+ "broken_axis_plot": _widgets_broken_axis_plot,
2569
+ "z_colored_scatter": _widgets_z_colored_scatter,
2570
+ "bubble_chart": _widgets_bubble_chart,
2571
+ "polar_plot": _widgets_polar_plot,
2572
+ "histogram_2d": _widgets_histogram_2d,
2573
+ "distribution_plot": _widgets_distribution_plot,
2574
+ "box_plot": _widgets_box_plot,
2575
+ "regression_plot": _widgets_regression_plot,
2576
+ "pair_plot": _widgets_pair_plot,
2577
+ "interactive_histogram": _widgets_interactive_histogram,
2578
+ "interactive_scatter": _widgets_interactive_scatter,
2579
+ "interactive_line": _widgets_interactive_line,
2580
+ "interactive_heatmap": _widgets_interactive_heatmap,
2581
+ "interactive_3d_surface": _widgets_interactive_3d_surface,
2582
+ "interactive_3d_scatter": _widgets_interactive_3d_scatter,
2583
+ "scatter_with_regression": _widgets_scatter_with_regression,
2584
+ "residual_plot": _widgets_residual_plot,
2585
+ "interactive_ternary": _widgets_interactive_ternary,
2586
+ "inset_plot": _widgets_inset_plot,
2587
+ }
2588
+
2589
+
2590
+ # ── Public API ───────────────────────────────────────────────────────────────
2591
+
2592
+
2593
+ def get_plot_config_widgets(plot_type: str, data: Any) -> Dict:
2594
+ """Render Streamlit widgets for *plot_type* and return a config dict.
2595
+
2596
+ Call inside a Streamlit page after loading a dataset. Renders
2597
+ data-column selectors and plot-parameter controls appropriate for the
2598
+ chosen plot type and the actual type/shape of *data*.
2599
+
2600
+ The returned dictionary contains two kinds of keys:
2601
+
2602
+ * **Public keys** — valid keyword arguments for the matching plotting
2603
+ function (``title``, ``xlabel``, ``bins``, ``color``, ``figsize``, …).
2604
+ * **Private keys** (prefixed ``_``) — data-selection hints the caller
2605
+ uses to extract arrays from *data* before calling the plotting
2606
+ function (e.g. ``_x_col``, ``_y_col``, ``_data_col``).
2607
+
2608
+ Use :func:`get_plot_kwargs` to strip private keys before unpacking
2609
+ into a plotting function.
2610
+
2611
+ Parameters
2612
+ ----------
2613
+ plot_type : str
2614
+ One of the keys in :data:`PLOT_TYPES`.
2615
+ data : Any
2616
+ Currently loaded dataset (DataFrame, ndarray, …).
2617
+
2618
+ Returns
2619
+ -------
2620
+ config : dict
2621
+ Configuration dict; empty dict if *plot_type* is unknown or *data*
2622
+ is incompatible with the selected plot type.
2623
+
2624
+ Examples
2625
+ --------
2626
+ >>> config = get_plot_config_widgets('histogram', arr)
2627
+ >>> col = config.get('_data_col') # None -> use full array
2628
+ >>> plot_data = arr if col is None else df[col]
2629
+ >>> fig, ax, info = histogram(plot_data, **get_plot_kwargs(config))
2630
+ >>> st.pyplot(fig)
2631
+ """
2632
+ if plot_type not in _WIDGET_FUNCS:
2633
+ st.error(f"Unknown plot type: '{plot_type}'")
2634
+ return {}
2635
+ return _WIDGET_FUNCS[plot_type](data, plot_type)
2636
+
2637
+
2638
+ def get_plot_kwargs(config: Dict) -> Dict:
2639
+ """Return plotting-function kwargs extracted from *config*.
2640
+
2641
+ Strips private (``_``-prefixed) keys and drops ``None`` values so the
2642
+ result can be unpacked directly into any plotting function.
2643
+
2644
+ Parameters
2645
+ ----------
2646
+ config : dict
2647
+ Raw config returned by :func:`get_plot_config_widgets`.
2648
+
2649
+ Returns
2650
+ -------
2651
+ kwargs : dict
2652
+ Clean dict ready for ``**kwargs`` unpacking into a plotting function.
2653
+
2654
+ Examples
2655
+ --------
2656
+ >>> config = get_plot_config_widgets('scatter_plot', data)
2657
+ >>> fig, ax = scatter_plot(x, y, **get_plot_kwargs(config))
2658
+ """
2659
+ return {
2660
+ k: v
2661
+ for k, v in config.items()
2662
+ if not k.startswith("_") and v is not None and k not in _STYLE_KEYS
2663
+ }
2664
+
2665
+
2666
+ # ── Click-to-annotate helpers (Quick Plot, Plotly mode) ──────────────────────
2667
+
2668
+
2669
+ def _get(obj: Any, key: str, default: Any = None) -> Any:
2670
+ """Read ``key`` from a mapping or an attribute-style object."""
2671
+ if isinstance(obj, dict):
2672
+ return obj.get(key, default)
2673
+ try:
2674
+ return obj[key]
2675
+ except (KeyError, TypeError, IndexError):
2676
+ return getattr(obj, key, default)
2677
+
2678
+
2679
+ def selected_points(event: Any) -> List[Dict[str, Any]]:
2680
+ """Extract ``{"x", "y"}`` pairs from a ``st.plotly_chart`` selection event.
2681
+
2682
+ Streamlit returns each selected point as a plain ``dict`` (not an object),
2683
+ so attribute access such as ``point.x`` raises ``AttributeError``. This
2684
+ helper reads both forms and skips points that carry no x/y coordinate
2685
+ (e.g. pie slices).
2686
+
2687
+ Args:
2688
+ event: The value returned by ``st.plotly_chart(..., on_select="rerun")``,
2689
+ or ``None``.
2690
+
2691
+ Returns:
2692
+ List of ``{"x": ..., "y": ...}`` dicts, in selection order.
2693
+ """
2694
+ if not event:
2695
+ return []
2696
+ selection = _get(event, "selection")
2697
+ if not selection:
2698
+ return []
2699
+ points = _get(selection, "points") or []
2700
+ result: List[Dict[str, Any]] = []
2701
+ for pt in points:
2702
+ x, y = _get(pt, "x"), _get(pt, "y")
2703
+ if x is None or y is None:
2704
+ continue
2705
+ result.append({"x": x, "y": y})
2706
+ return result
2707
+
2708
+
2709
+ def format_marker_label(x: Any, y: Any) -> str:
2710
+ """Format a marked point as ``"(x, y)"``; numbers use 4 significant digits.
2711
+
2712
+ Non-numeric coordinates (category names, date strings) are shown as-is
2713
+ instead of raising on the ``:.4g`` format spec.
2714
+ """
2715
+
2716
+ def _fmt(v: Any) -> str:
2717
+ if isinstance(v, (int, float, np.number)) and not isinstance(v, bool):
2718
+ return f"{v:.4g}"
2719
+ return str(v)
2720
+
2721
+ return f"({_fmt(x)}, {_fmt(y)})"