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
eagleplot/plotting.py ADDED
@@ -0,0 +1,2630 @@
1
+ """Plotting module for creating scientific visualizations.
2
+
3
+ This module provides a high-level interface for creating publication-quality
4
+ plots using matplotlib. It includes functions for common plot types, figure
5
+ management, and styling utilities.
6
+
7
+ Examples
8
+ --------
9
+ >>> from eagleplot.plotting import line_plot, histogram, save_figure
10
+ >>> fig, ax = line_plot(x, y, xlabel='Time (s)', ylabel='Temperature (K)')
11
+ >>> save_figure(fig, 'output.png', dpi=300)
12
+ """
13
+
14
+ import numpy as np
15
+ import matplotlib.pyplot as plt
16
+ import matplotlib.colors as mcolors
17
+ from matplotlib.figure import Figure
18
+ from matplotlib.axes import Axes
19
+ from pathlib import Path
20
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union, cast
21
+
22
+ if TYPE_CHECKING:
23
+ import pandas as pd
24
+ from matplotlib.projections.polar import PolarAxes
25
+
26
+ # Okabe-Ito colorblind-safe palette (8 colors)
27
+ COLORBLIND_PALETTE: List[str] = [
28
+ "#E69F00", # orange
29
+ "#56B4E9", # sky blue
30
+ "#009E73", # bluish green
31
+ "#F0E442", # yellow
32
+ "#0072B2", # blue
33
+ "#D55E00", # vermillion
34
+ "#CC79A7", # reddish purple
35
+ "#000000", # black
36
+ ]
37
+
38
+ # Optional imports for advanced features
39
+ try:
40
+ import seaborn as sns
41
+
42
+ HAS_SEABORN = True
43
+ except ImportError:
44
+ HAS_SEABORN = False
45
+
46
+ try:
47
+ import plotly.graph_objects as go
48
+
49
+ HAS_PLOTLY = True
50
+ except ImportError:
51
+ HAS_PLOTLY = False
52
+
53
+
54
+ # ============================================================================
55
+ # Figure Management
56
+ # ============================================================================
57
+
58
+
59
+ def create_figure(
60
+ figsize: Tuple[float, float] = (8, 6), dpi: int = 100, **kwargs
61
+ ) -> Tuple[Figure, Axes]:
62
+ """Create a new figure with axes.
63
+
64
+ Parameters
65
+ ----------
66
+ figsize : tuple of (width, height), default (8, 6)
67
+ Figure size in inches
68
+ dpi : int, default 100
69
+ Dots per inch (resolution)
70
+ kwargs : dict
71
+ Additional arguments passed to plt.subplots()
72
+
73
+ Returns
74
+ -------
75
+ fig : Figure
76
+ Matplotlib figure object
77
+ ax : Axes
78
+ Matplotlib axes object
79
+
80
+ Examples
81
+ --------
82
+ >>> fig, ax = create_figure(figsize=(10, 6), dpi=150)
83
+ >>> ax.plot([1, 2, 3], [1, 4, 9])
84
+ """
85
+ fig, ax = plt.subplots(figsize=figsize, dpi=dpi, **kwargs)
86
+ return fig, ax
87
+
88
+
89
+ def configure_axes(
90
+ ax: Axes,
91
+ xlabel: Optional[str] = None,
92
+ ylabel: Optional[str] = None,
93
+ title: Optional[str] = None,
94
+ grid: bool = True,
95
+ legend: bool = False,
96
+ xlim: Optional[Tuple[float, float]] = None,
97
+ ylim: Optional[Tuple[float, float]] = None,
98
+ **kwargs,
99
+ ) -> Axes:
100
+ """Configure axes properties.
101
+
102
+ Parameters
103
+ ----------
104
+ ax : Axes
105
+ Matplotlib axes to configure
106
+ xlabel : str, optional
107
+ X-axis label
108
+ ylabel : str, optional
109
+ Y-axis label
110
+ title : str, optional
111
+ Plot title
112
+ grid : bool, default True
113
+ Whether to show grid
114
+ legend : bool, default False
115
+ Whether to show legend
116
+ xlim : tuple of (min, max), optional
117
+ X-axis limits
118
+ ylim : tuple of (min, max), optional
119
+ Y-axis limits
120
+ kwargs : dict
121
+ Additional axes properties
122
+
123
+ Returns
124
+ -------
125
+ ax : Axes
126
+ Configured axes object
127
+
128
+ Examples
129
+ --------
130
+ >>> configure_axes(ax, xlabel='Time', ylabel='Value', title='Results')
131
+ """
132
+ if xlabel is not None:
133
+ ax.set_xlabel(xlabel)
134
+ if ylabel is not None:
135
+ ax.set_ylabel(ylabel)
136
+ if title is not None:
137
+ ax.set_title(title)
138
+ if grid:
139
+ ax.grid(True, alpha=0.3)
140
+ if legend:
141
+ ax.legend()
142
+ if xlim is not None:
143
+ ax.set_xlim(xlim)
144
+ if ylim is not None:
145
+ ax.set_ylim(ylim)
146
+
147
+ # Apply any additional properties
148
+ for key, value in kwargs.items():
149
+ if hasattr(ax, f"set_{key}"):
150
+ getattr(ax, f"set_{key}")(value)
151
+
152
+ return ax
153
+
154
+
155
+ def save_figure(
156
+ fig: Figure,
157
+ filepath: Union[str, Path],
158
+ format: Optional[str] = None,
159
+ dpi: int = 300,
160
+ bbox_inches: str = "tight",
161
+ **kwargs,
162
+ ) -> None:
163
+ """Save figure to file.
164
+
165
+ Parameters
166
+ ----------
167
+ fig : Figure
168
+ Matplotlib figure to save
169
+ filepath : str or Path
170
+ Output file path
171
+ format : str, optional
172
+ File format (png, pdf, svg, etc.). If None, inferred from filepath
173
+ dpi : int, default 300
174
+ Resolution for raster formats
175
+ bbox_inches : str, default 'tight'
176
+ Bounding box setting
177
+ kwargs : dict
178
+ Additional arguments passed to fig.savefig()
179
+
180
+ Examples
181
+ --------
182
+ >>> save_figure(fig, 'plot.png', dpi=300)
183
+ >>> save_figure(fig, 'plot.pdf', format='pdf')
184
+ """
185
+ filepath = Path(filepath)
186
+ filepath.parent.mkdir(parents=True, exist_ok=True)
187
+
188
+ fig.savefig(filepath, format=format, dpi=dpi, bbox_inches=bbox_inches, **kwargs)
189
+
190
+
191
+ # ============================================================================
192
+ # Core Plotting Functions
193
+ # ============================================================================
194
+
195
+
196
+ def histogram(
197
+ data: np.ndarray,
198
+ bins: Union[int, str, np.ndarray] = "auto",
199
+ xlabel: Optional[str] = None,
200
+ ylabel: str = "Frequency",
201
+ title: Optional[str] = None,
202
+ figsize: Tuple[float, float] = (8, 6),
203
+ color: str = "steelblue",
204
+ alpha: float = 0.7,
205
+ density: bool = False,
206
+ **kwargs,
207
+ ) -> Tuple[Figure, Axes, Dict[str, Any]]:
208
+ """Create a histogram.
209
+
210
+ Parameters
211
+ ----------
212
+ data : np.ndarray
213
+ Data to plot
214
+ bins : int, str, or array, default 'auto'
215
+ Number of bins or bin edges
216
+ xlabel : str, optional
217
+ X-axis label
218
+ ylabel : str, default 'Frequency'
219
+ Y-axis label
220
+ title : str, optional
221
+ Plot title
222
+ figsize : tuple, default (8, 6)
223
+ Figure size in inches
224
+ color : str, default 'steelblue'
225
+ Bar color
226
+ alpha : float, default 0.7
227
+ Transparency (0-1)
228
+ density : bool, default False
229
+ If True, plot probability density instead of counts
230
+ kwargs : dict
231
+ Additional arguments passed to ax.hist()
232
+
233
+ Returns
234
+ -------
235
+ fig : Figure
236
+ Matplotlib figure
237
+ ax : Axes
238
+ Matplotlib axes
239
+ info : dict
240
+ Dictionary containing:
241
+ - 'counts': Histogram counts
242
+ - 'bins': Bin edges
243
+ - 'mean': Data mean
244
+ - 'std': Data standard deviation
245
+
246
+ Examples
247
+ --------
248
+ >>> data = np.random.normal(0, 1, 1000)
249
+ >>> fig, ax, info = histogram(data, bins=30, xlabel='Value')
250
+ >>> print(f"Mean: {info['mean']:.2f}")
251
+ """
252
+ data = np.asarray(data).flatten()
253
+
254
+ fig, ax = create_figure(figsize=figsize)
255
+
256
+ # matplotlib types `bins` as int | Sequence[float] | str | None; an ndarray of
257
+ # explicit edges is valid at runtime but not per the stub, so convert it.
258
+ _bins: Union[int, str, List[float], None] = (
259
+ [float(edge) for edge in bins] if isinstance(bins, np.ndarray) else bins
260
+ )
261
+ counts, bin_edges, patches = ax.hist(
262
+ data,
263
+ bins=_bins,
264
+ color=color,
265
+ alpha=alpha,
266
+ density=density,
267
+ **kwargs, # type: ignore[arg-type]
268
+ )
269
+
270
+ # Configure axes
271
+ if ylabel == "Frequency" and density:
272
+ ylabel = "Density"
273
+ configure_axes(ax, xlabel=xlabel, ylabel=ylabel, title=title)
274
+
275
+ # Calculate statistics
276
+ info = {
277
+ "counts": counts,
278
+ "bins": bin_edges,
279
+ "mean": float(np.mean(data)),
280
+ "std": float(np.std(data)),
281
+ }
282
+
283
+ return fig, ax, info
284
+
285
+
286
+ def line_plot(
287
+ x: np.ndarray,
288
+ y: Union[np.ndarray, List[np.ndarray]],
289
+ xlabel: Optional[str] = None,
290
+ ylabel: Optional[str] = None,
291
+ title: Optional[str] = None,
292
+ figsize: Tuple[float, float] = (8, 6),
293
+ labels: Optional[List[str]] = None,
294
+ colors: Optional[List[str]] = None,
295
+ linestyles: Optional[List[str]] = None,
296
+ markers: Optional[List[str]] = None,
297
+ legend: bool = True,
298
+ yerr: Optional[Union[np.ndarray, List[np.ndarray]]] = None,
299
+ **kwargs,
300
+ ) -> Tuple[Figure, Axes]:
301
+ """Create a line plot.
302
+
303
+ Parameters
304
+ ----------
305
+ x : np.ndarray
306
+ X-axis data
307
+ y : np.ndarray or list of np.ndarray
308
+ Y-axis data. Can be single array or list of arrays for multiple lines
309
+ xlabel : str, optional
310
+ X-axis label
311
+ ylabel : str, optional
312
+ Y-axis label
313
+ title : str, optional
314
+ Plot title
315
+ figsize : tuple, default (8, 6)
316
+ Figure size in inches
317
+ labels : list of str, optional
318
+ Line labels for legend
319
+ colors : list of str, optional
320
+ Line colors
321
+ linestyles : list of str, optional
322
+ Line styles ('-', '--', '-.', ':')
323
+ markers : list of str, optional
324
+ Marker styles ('o', 's', '^', etc.)
325
+ legend : bool, default True
326
+ Whether to show legend (only if labels provided)
327
+ yerr : np.ndarray or list of np.ndarray, optional
328
+ Y error values. A single array is applied to the first series;
329
+ a list of arrays matches each series in order.
330
+ kwargs : dict
331
+ Additional arguments passed to ax.plot() / ax.errorbar()
332
+
333
+ Returns
334
+ -------
335
+ fig : Figure
336
+ Matplotlib figure
337
+ ax : Axes
338
+ Matplotlib axes
339
+
340
+ Examples
341
+ --------
342
+ >>> x = np.linspace(0, 10, 100)
343
+ >>> y = np.sin(x)
344
+ >>> fig, ax = line_plot(x, y, xlabel='x', ylabel='sin(x)')
345
+
346
+ >>> # With error bars
347
+ >>> err = 0.05 * np.ones_like(y)
348
+ >>> fig, ax = line_plot(x, y, yerr=err)
349
+ """
350
+ x = np.asarray(x)
351
+
352
+ # Handle single vs multiple y arrays
353
+ if isinstance(y, np.ndarray) and y.ndim == 1:
354
+ y = [y]
355
+ elif not isinstance(y, list):
356
+ y = [np.asarray(y)]
357
+ else:
358
+ y = [np.asarray(yi) for yi in y]
359
+
360
+ n_lines = len(y)
361
+
362
+ # Normalise yerr to a list aligned with y series
363
+ if yerr is not None:
364
+ if isinstance(yerr, np.ndarray) and yerr.ndim == 1:
365
+ yerr_list: List[Optional[np.ndarray]] = [yerr] + [None] * (n_lines - 1)
366
+ elif isinstance(yerr, list):
367
+ yerr_list = list(yerr) + [None] * max(0, n_lines - len(yerr))
368
+ else:
369
+ yerr_list = [np.asarray(yerr)] + [None] * (n_lines - 1)
370
+ else:
371
+ yerr_list = [None] * n_lines
372
+
373
+ fig, ax = create_figure(figsize=figsize)
374
+
375
+ # Set default colors, linestyles, markers
376
+ if colors is None:
377
+ colors = [f"C{i}" for i in range(n_lines)]
378
+ if linestyles is None:
379
+ linestyles = ["-"] * n_lines
380
+ if markers is None:
381
+ markers = [None] * n_lines
382
+ if labels is None:
383
+ labels = [None] * n_lines
384
+
385
+ # Plot each line (with or without error bars)
386
+ for i, yi in enumerate(y):
387
+ c = colors[i % len(colors)]
388
+ ls = linestyles[i % len(linestyles)]
389
+ mk = markers[i % len(markers)]
390
+ lbl = labels[i]
391
+ yi_err = yerr_list[i]
392
+ if yi_err is not None:
393
+ ax.errorbar(
394
+ x,
395
+ yi,
396
+ yerr=yi_err,
397
+ color=c,
398
+ linestyle=ls,
399
+ marker=mk,
400
+ label=lbl,
401
+ capsize=3,
402
+ **kwargs,
403
+ )
404
+ else:
405
+ ax.plot(
406
+ x,
407
+ yi,
408
+ color=c,
409
+ linestyle=ls,
410
+ marker=mk,
411
+ label=lbl,
412
+ **kwargs,
413
+ )
414
+
415
+ # Configure axes
416
+ show_legend = legend and any(label is not None for label in labels)
417
+ configure_axes(ax, xlabel=xlabel, ylabel=ylabel, title=title, legend=show_legend)
418
+
419
+ return fig, ax
420
+
421
+
422
+ def scatter_plot(
423
+ x: np.ndarray,
424
+ y: np.ndarray,
425
+ xlabel: Optional[str] = None,
426
+ ylabel: Optional[str] = None,
427
+ title: Optional[str] = None,
428
+ figsize: Tuple[float, float] = (8, 6),
429
+ color: Union[str, np.ndarray] = "steelblue",
430
+ size: Union[float, np.ndarray] = 50,
431
+ alpha: float = 0.7,
432
+ colorbar: bool = False,
433
+ yerr: Optional[np.ndarray] = None,
434
+ xerr: Optional[np.ndarray] = None,
435
+ **kwargs,
436
+ ) -> Tuple[Figure, Axes]:
437
+ """Create a scatter plot.
438
+
439
+ Parameters
440
+ ----------
441
+ x : np.ndarray
442
+ X-axis data
443
+ y : np.ndarray
444
+ Y-axis data
445
+ xlabel : str, optional
446
+ X-axis label
447
+ ylabel : str, optional
448
+ Y-axis label
449
+ title : str, optional
450
+ Plot title
451
+ figsize : tuple, default (8, 6)
452
+ Figure size in inches
453
+ color : str or np.ndarray, default 'steelblue'
454
+ Point color(s). Can be single color or array for color mapping
455
+ size : float or np.ndarray, default 50
456
+ Point size(s). Can be single value or array
457
+ alpha : float, default 0.7
458
+ Transparency (0-1)
459
+ colorbar : bool, default False
460
+ Whether to add colorbar (only if color is array)
461
+ yerr : np.ndarray, optional
462
+ Symmetric Y error bar values
463
+ xerr : np.ndarray, optional
464
+ Symmetric X error bar values
465
+ kwargs : dict
466
+ Additional arguments passed to ax.scatter()
467
+
468
+ Returns
469
+ -------
470
+ fig : Figure
471
+ Matplotlib figure
472
+ ax : Axes
473
+ Matplotlib axes
474
+
475
+ Examples
476
+ --------
477
+ >>> x = np.random.rand(100)
478
+ >>> y = np.random.rand(100)
479
+ >>> fig, ax = scatter_plot(x, y, xlabel='X', ylabel='Y')
480
+
481
+ >>> # With error bars
482
+ >>> fig, ax = scatter_plot(x, y, yerr=0.1*np.ones_like(y))
483
+ """
484
+ x = np.asarray(x)
485
+ y = np.asarray(y)
486
+
487
+ fig, ax = create_figure(figsize=figsize)
488
+
489
+ scatter = ax.scatter(x, y, c=color, s=size, alpha=alpha, **kwargs)
490
+
491
+ # Add colorbar if color is array
492
+ if colorbar and isinstance(color, np.ndarray):
493
+ plt.colorbar(scatter, ax=ax)
494
+
495
+ # Overlay error bars if provided
496
+ if yerr is not None or xerr is not None:
497
+ ecolor = color if isinstance(color, str) else "gray"
498
+ ax.errorbar(
499
+ x,
500
+ y,
501
+ yerr=yerr,
502
+ xerr=xerr,
503
+ fmt="none",
504
+ color=ecolor,
505
+ alpha=alpha,
506
+ capsize=3,
507
+ )
508
+
509
+ configure_axes(ax, xlabel=xlabel, ylabel=ylabel, title=title)
510
+
511
+ return fig, ax
512
+
513
+
514
+ def heatmap(
515
+ data: np.ndarray,
516
+ xlabel: Optional[str] = None,
517
+ ylabel: Optional[str] = None,
518
+ title: Optional[str] = None,
519
+ figsize: Tuple[float, float] = (8, 6),
520
+ cmap: str = "viridis",
521
+ colorbar: bool = True,
522
+ vmin: Optional[float] = None,
523
+ vmax: Optional[float] = None,
524
+ cmap_n_colors: Optional[int] = None,
525
+ xticklabels: Optional[List[str]] = None,
526
+ yticklabels: Optional[List[str]] = None,
527
+ **kwargs,
528
+ ) -> Tuple[Figure, Axes]:
529
+ """Create a heatmap.
530
+
531
+ Parameters
532
+ ----------
533
+ data : np.ndarray
534
+ 2D array to visualize
535
+ xlabel : str, optional
536
+ X-axis label
537
+ ylabel : str, optional
538
+ Y-axis label
539
+ title : str, optional
540
+ Plot title
541
+ figsize : tuple, default (8, 6)
542
+ Figure size in inches
543
+ cmap : str, default 'viridis'
544
+ Colormap name
545
+ colorbar : bool, default True
546
+ Whether to show colorbar
547
+ vmin : float, optional
548
+ Minimum value for color scale
549
+ vmax : float, optional
550
+ Maximum value for color scale
551
+ cmap_n_colors : int, optional
552
+ If set, discretize the colormap into this many distinct colors (2–256)
553
+ xticklabels : list of str, optional
554
+ X-axis tick labels
555
+ yticklabels : list of str, optional
556
+ Y-axis tick labels
557
+ kwargs : dict
558
+ Additional arguments passed to ax.imshow()
559
+
560
+ Returns
561
+ -------
562
+ fig : Figure
563
+ Matplotlib figure
564
+ ax : Axes
565
+ Matplotlib axes
566
+
567
+ Examples
568
+ --------
569
+ >>> data = np.random.rand(10, 10)
570
+ >>> fig, ax = heatmap(data, title='Random Data', cmap='hot')
571
+ >>> fig, ax = heatmap(data, cmap='viridis', cmap_n_colors=8, vmin=0.2, vmax=0.8)
572
+ """
573
+ data = np.asarray(data)
574
+
575
+ if data.ndim != 2:
576
+ raise ValueError(f"Data must be 2D, got shape {data.shape}")
577
+
578
+ fig, ax = create_figure(figsize=figsize)
579
+
580
+ actual_cmap = plt.get_cmap(cmap, cmap_n_colors) if cmap_n_colors is not None else cmap
581
+ im = ax.imshow(data, cmap=actual_cmap, vmin=vmin, vmax=vmax, aspect="auto", **kwargs)
582
+
583
+ if colorbar:
584
+ plt.colorbar(im, ax=ax)
585
+
586
+ # Set tick labels if provided
587
+ if xticklabels is not None:
588
+ ax.set_xticks(np.arange(len(xticklabels)))
589
+ ax.set_xticklabels(xticklabels)
590
+ if yticklabels is not None:
591
+ ax.set_yticks(np.arange(len(yticklabels)))
592
+ ax.set_yticklabels(yticklabels)
593
+
594
+ configure_axes(ax, xlabel=xlabel, ylabel=ylabel, title=title, grid=False)
595
+
596
+ return fig, ax
597
+
598
+
599
+ def contour_plot(
600
+ X: np.ndarray,
601
+ Y: np.ndarray,
602
+ Z: np.ndarray,
603
+ xlabel: Optional[str] = None,
604
+ ylabel: Optional[str] = None,
605
+ title: Optional[str] = None,
606
+ figsize: Tuple[float, float] = (8, 6),
607
+ levels: Union[int, np.ndarray] = 10,
608
+ cmap: str = "viridis",
609
+ filled: bool = True,
610
+ colorbar: bool = True,
611
+ vmin: Optional[float] = None,
612
+ vmax: Optional[float] = None,
613
+ cmap_n_colors: Optional[int] = None,
614
+ linewidths: Optional[float] = None,
615
+ **kwargs,
616
+ ) -> Tuple[Figure, Axes]:
617
+ """Create a contour plot.
618
+
619
+ Parameters
620
+ ----------
621
+ X : np.ndarray
622
+ 2D array of X coordinates (from meshgrid)
623
+ Y : np.ndarray
624
+ 2D array of Y coordinates (from meshgrid)
625
+ Z : np.ndarray
626
+ 2D array of Z values
627
+ xlabel : str, optional
628
+ X-axis label
629
+ ylabel : str, optional
630
+ Y-axis label
631
+ title : str, optional
632
+ Plot title
633
+ figsize : tuple, default (8, 6)
634
+ Figure size in inches
635
+ levels : int or array, default 10
636
+ Number of contour levels or specific level values
637
+ cmap : str, default 'viridis'
638
+ Colormap name
639
+ filled : bool, default True
640
+ Whether to create filled contours (contourf) or line contours (contour)
641
+ colorbar : bool, default True
642
+ Whether to show colorbar
643
+ vmin : float, optional
644
+ Minimum value for color/level normalization
645
+ vmax : float, optional
646
+ Maximum value for color/level normalization
647
+ cmap_n_colors : int, optional
648
+ If set, discretize the colormap into this many distinct colors (2–256)
649
+ linewidths : float, optional
650
+ Line width for unfilled contour lines (ignored when filled=True)
651
+ kwargs : dict
652
+ Additional arguments passed to ax.contour() or ax.contourf()
653
+
654
+ Returns
655
+ -------
656
+ fig : Figure
657
+ Matplotlib figure
658
+ ax : Axes
659
+ Matplotlib axes
660
+
661
+ Examples
662
+ --------
663
+ >>> x = np.linspace(-3, 3, 100)
664
+ >>> y = np.linspace(-3, 3, 100)
665
+ >>> X, Y = np.meshgrid(x, y)
666
+ >>> Z = np.sqrt(X**2 + Y**2)
667
+ >>> fig, ax = contour_plot(X, Y, Z, xlabel='X', ylabel='Y')
668
+ >>> fig, ax = contour_plot(X, Y, Z, filled=False, cmap_n_colors=10, vmin=0, vmax=3)
669
+ """
670
+ X = np.asarray(X)
671
+ Y = np.asarray(Y)
672
+ Z = np.asarray(Z)
673
+
674
+ if X.shape != Y.shape or X.shape != Z.shape:
675
+ raise ValueError(
676
+ f"X, Y, Z must have same shape. Got X: {X.shape}, Y: {Y.shape}, Z: {Z.shape}"
677
+ )
678
+
679
+ fig, ax = create_figure(figsize=figsize)
680
+
681
+ actual_cmap = plt.get_cmap(cmap, cmap_n_colors) if cmap_n_colors is not None else cmap
682
+
683
+ plot_kwargs = dict(kwargs)
684
+ if (vmin is not None or vmax is not None) and "norm" not in plot_kwargs:
685
+ plot_kwargs["norm"] = mcolors.Normalize(vmin=vmin, vmax=vmax)
686
+
687
+ if filled:
688
+ contour = ax.contourf(X, Y, Z, levels=levels, cmap=actual_cmap, **plot_kwargs)
689
+ else:
690
+ if linewidths is not None and "linewidths" not in plot_kwargs:
691
+ plot_kwargs["linewidths"] = linewidths
692
+ contour = ax.contour(X, Y, Z, levels=levels, cmap=actual_cmap, **plot_kwargs)
693
+
694
+ if colorbar:
695
+ plt.colorbar(contour, ax=ax)
696
+
697
+ configure_axes(ax, xlabel=xlabel, ylabel=ylabel, title=title, grid=False)
698
+
699
+ return fig, ax
700
+
701
+
702
+ # ============================================================================
703
+ # Extended Plot Types (M14)
704
+ # ============================================================================
705
+
706
+
707
+ def bar_chart(
708
+ categories: Union[List, np.ndarray],
709
+ values: Union[np.ndarray, List],
710
+ yerr: Optional[Union[np.ndarray, List]] = None,
711
+ xerr: Optional[Union[np.ndarray, List]] = None,
712
+ hue: Optional[List[str]] = None,
713
+ kind: str = "simple",
714
+ xlabel: Optional[str] = None,
715
+ ylabel: Optional[str] = None,
716
+ title: Optional[str] = None,
717
+ figsize: Tuple[float, float] = (8, 6),
718
+ color: Union[str, List[str]] = "steelblue",
719
+ alpha: float = 0.8,
720
+ **kwargs,
721
+ ) -> Tuple[Figure, Axes, Dict[str, Any]]:
722
+ """Create a bar chart (simple, grouped, or stacked).
723
+
724
+ Parameters
725
+ ----------
726
+ categories : list or np.ndarray
727
+ Category labels for the X axis
728
+ values : np.ndarray
729
+ Bar heights. Shape ``(n_categories,)`` for simple/stacked;
730
+ shape ``(n_groups, n_categories)`` for grouped bars.
731
+ yerr : array-like, optional
732
+ Y error bar values. Shape matches *values*.
733
+ xerr : array-like, optional
734
+ X error bar values (simple bars only).
735
+ hue : list of str, optional
736
+ Group labels for grouped/stacked charts.
737
+ kind : str, default 'simple'
738
+ ``'simple'``, ``'grouped'``, or ``'stacked'``.
739
+ xlabel : str, optional
740
+ X-axis label
741
+ ylabel : str, optional
742
+ Y-axis label
743
+ title : str, optional
744
+ Plot title
745
+ figsize : tuple, default (8, 6)
746
+ Figure size in inches
747
+ color : str or list of str, default 'steelblue'
748
+ Bar color(s).
749
+ alpha : float, default 0.8
750
+ Transparency (0–1)
751
+ kwargs : dict
752
+ Extra keyword arguments forwarded to ``ax.bar()``.
753
+
754
+ Returns
755
+ -------
756
+ fig : Figure
757
+ ax : Axes
758
+ info : dict
759
+ ``kind``, ``n_categories``, ``n_groups``
760
+
761
+ Examples
762
+ --------
763
+ >>> fig, ax, info = bar_chart(['A', 'B', 'C'], [3, 5, 2])
764
+ >>> # Grouped
765
+ >>> fig, ax, info = bar_chart(
766
+ ... ['A', 'B'], [[2, 4], [3, 1]],
767
+ ... hue=['G1', 'G2'], kind='grouped',
768
+ ... )
769
+ """
770
+ cats = list(categories)
771
+ vals = np.asarray(values)
772
+ fig, ax = create_figure(figsize=figsize)
773
+
774
+ if kind == "simple":
775
+ bar_color = color if isinstance(color, str) else color[0]
776
+ ax.bar(
777
+ cats,
778
+ vals,
779
+ yerr=yerr,
780
+ xerr=xerr,
781
+ color=bar_color,
782
+ alpha=alpha,
783
+ capsize=4,
784
+ **kwargs,
785
+ )
786
+ n_groups = 1
787
+
788
+ elif kind == "grouped":
789
+ vals = vals if vals.ndim == 2 else vals.reshape(1, -1)
790
+ n_groups = vals.shape[0]
791
+ n_cats = len(cats)
792
+ colors_list = color if isinstance(color, list) else [f"C{i}" for i in range(n_groups)]
793
+ x = np.arange(n_cats)
794
+ width = 0.8 / n_groups
795
+ offsets = np.linspace(
796
+ -(n_groups - 1) * width / 2,
797
+ (n_groups - 1) * width / 2,
798
+ n_groups,
799
+ )
800
+ for i in range(n_groups):
801
+ g_err = (
802
+ yerr[i]
803
+ if (
804
+ yerr is not None
805
+ and hasattr(yerr, "__len__")
806
+ and len(yerr) == n_groups
807
+ and hasattr(yerr[0], "__len__")
808
+ )
809
+ else yerr
810
+ )
811
+ lbl = hue[i] if hue and i < len(hue) else f"Group {i + 1}"
812
+ ax.bar(
813
+ x + offsets[i],
814
+ vals[i],
815
+ width=width,
816
+ yerr=g_err,
817
+ label=lbl,
818
+ color=colors_list[i % len(colors_list)],
819
+ alpha=alpha,
820
+ capsize=3,
821
+ **kwargs,
822
+ )
823
+ ax.set_xticks(x)
824
+ ax.set_xticklabels(cats)
825
+ ax.legend()
826
+
827
+ elif kind == "stacked":
828
+ vals = vals if vals.ndim == 2 else vals.reshape(1, -1)
829
+ n_groups = vals.shape[0]
830
+ colors_list = color if isinstance(color, list) else [f"C{i}" for i in range(n_groups)]
831
+ bottom = np.zeros(len(cats))
832
+ for i in range(n_groups):
833
+ lbl = hue[i] if hue and i < len(hue) else f"Group {i + 1}"
834
+ ax.bar(
835
+ cats,
836
+ vals[i],
837
+ bottom=bottom,
838
+ label=lbl,
839
+ color=colors_list[i % len(colors_list)],
840
+ alpha=alpha,
841
+ **kwargs,
842
+ )
843
+ bottom = bottom + vals[i]
844
+ ax.legend()
845
+
846
+ else:
847
+ raise ValueError(
848
+ f"Unknown bar chart kind: '{kind}'. Use 'simple', 'grouped', or 'stacked'."
849
+ )
850
+
851
+ configure_axes(ax, xlabel=xlabel, ylabel=ylabel, title=title)
852
+
853
+ info: Dict[str, Any] = {
854
+ "kind": kind,
855
+ "n_categories": len(cats),
856
+ "n_groups": n_groups if kind != "simple" else 1,
857
+ }
858
+ return fig, ax, info
859
+
860
+
861
+ def waterfall_plot(
862
+ x: np.ndarray,
863
+ y_matrix: np.ndarray,
864
+ labels: Optional[List[str]] = None,
865
+ offset: Union[float, str] = "auto",
866
+ xlabel: Optional[str] = None,
867
+ ylabel: Optional[str] = None,
868
+ title: Optional[str] = None,
869
+ figsize: Tuple[float, float] = (10, 7),
870
+ cmap: str = "viridis",
871
+ alpha: float = 0.9,
872
+ linewidth: float = 1.5,
873
+ **kwargs,
874
+ ) -> Tuple[Figure, Axes, Dict[str, Any]]:
875
+ """Create a waterfall (stacked-offset line) plot.
876
+
877
+ Each row of *y_matrix* is plotted as a separate line shifted upward
878
+ by a constant *offset*, producing the characteristic waterfall
879
+ appearance used in time-resolved spectroscopy.
880
+
881
+ Parameters
882
+ ----------
883
+ x : np.ndarray
884
+ Shared X axis (wavenumber, wavelength, time, …)
885
+ y_matrix : np.ndarray
886
+ 2-D array of shape ``(n_traces, n_points)``. Each row is one
887
+ spectrum / signal trace.
888
+ labels : list of str, optional
889
+ Legend labels for each trace.
890
+ offset : float or 'auto', default 'auto'
891
+ Vertical spacing between traces. ``'auto'`` uses 60 % of the
892
+ largest peak-to-peak amplitude across all traces.
893
+ xlabel : str, optional
894
+ ylabel : str, optional
895
+ title : str, optional
896
+ figsize : tuple, default (10, 7)
897
+ cmap : str, default 'viridis'
898
+ Colormap used to color successive traces.
899
+ alpha : float, default 0.9
900
+ linewidth : float, default 1.5
901
+ kwargs : dict
902
+ Extra keyword arguments forwarded to ``ax.plot()``.
903
+
904
+ Returns
905
+ -------
906
+ fig : Figure
907
+ ax : Axes
908
+ info : dict
909
+ ``n_traces``, ``offset``
910
+
911
+ Examples
912
+ --------
913
+ >>> x = np.linspace(400, 4000, 1000)
914
+ >>> spectra = np.random.rand(5, 1000)
915
+ >>> fig, ax, info = waterfall_plot(x, spectra)
916
+ """
917
+ x = np.asarray(x)
918
+ y_mat = np.asarray(y_matrix)
919
+ if y_mat.ndim == 1:
920
+ y_mat = y_mat.reshape(1, -1)
921
+
922
+ n_traces = y_mat.shape[0]
923
+
924
+ if offset == "auto":
925
+ ptp = np.ptp(y_mat, axis=1)
926
+ offset_val = float(np.max(ptp) * 0.6) if np.max(ptp) > 0 else 1.0
927
+ else:
928
+ offset_val = float(offset)
929
+
930
+ fig, ax = create_figure(figsize=figsize)
931
+ cmap_obj = plt.get_cmap(cmap, n_traces)
932
+
933
+ for i in range(n_traces):
934
+ y_shifted = y_mat[i] + i * offset_val
935
+ clr = cmap_obj(i / max(n_traces - 1, 1))
936
+ lbl = labels[i] if (labels and i < len(labels)) else None
937
+ ax.plot(
938
+ x,
939
+ y_shifted,
940
+ color=clr,
941
+ alpha=alpha,
942
+ linewidth=linewidth,
943
+ label=lbl,
944
+ **kwargs,
945
+ )
946
+
947
+ configure_axes(
948
+ ax,
949
+ xlabel=xlabel,
950
+ ylabel=ylabel,
951
+ title=title,
952
+ legend=labels is not None,
953
+ )
954
+
955
+ info = {"n_traces": n_traces, "offset": offset_val}
956
+ return fig, ax, info
957
+
958
+
959
+ def dual_axis_plot(
960
+ x: np.ndarray,
961
+ y1: np.ndarray,
962
+ y2: np.ndarray,
963
+ xlabel: Optional[str] = None,
964
+ ylabel1: Optional[str] = None,
965
+ ylabel2: Optional[str] = None,
966
+ title: Optional[str] = None,
967
+ figsize: Tuple[float, float] = (8, 6),
968
+ color1: str = "steelblue",
969
+ color2: str = "tomato",
970
+ linestyle1: str = "-",
971
+ linestyle2: str = "--",
972
+ label1: Optional[str] = None,
973
+ label2: Optional[str] = None,
974
+ **kwargs,
975
+ ) -> Tuple[Figure, Axes, Dict[str, Any]]:
976
+ """Create a dual Y-axis line plot.
977
+
978
+ Plots *y1* on the left axis and *y2* on an independent right axis,
979
+ which is useful when comparing signals that have different units or
980
+ very different magnitudes.
981
+
982
+ Parameters
983
+ ----------
984
+ x : np.ndarray
985
+ Shared X axis
986
+ y1 : np.ndarray
987
+ Data for the left Y axis
988
+ y2 : np.ndarray
989
+ Data for the right Y axis
990
+ xlabel : str, optional
991
+ ylabel1 : str, optional
992
+ Left Y-axis label
993
+ ylabel2 : str, optional
994
+ Right Y-axis label
995
+ title : str, optional
996
+ figsize : tuple, default (8, 6)
997
+ color1 : str, default 'steelblue'
998
+ Color for left-axis series
999
+ color2 : str, default 'tomato'
1000
+ Color for right-axis series
1001
+ linestyle1 : str, default '-'
1002
+ linestyle2 : str, default '--'
1003
+ label1 : str, optional
1004
+ Legend label for left series
1005
+ label2 : str, optional
1006
+ Legend label for right series
1007
+ kwargs : dict
1008
+ Extra keyword arguments forwarded to both ``ax.plot()`` calls.
1009
+
1010
+ Returns
1011
+ -------
1012
+ fig : Figure
1013
+ ax : Axes
1014
+ Primary (left) axes
1015
+ info : dict
1016
+ ``ax2`` — the secondary (right) Axes object
1017
+
1018
+ Examples
1019
+ --------
1020
+ >>> x = np.linspace(0, 10, 100)
1021
+ >>> fig, ax, info = dual_axis_plot(
1022
+ ... x, np.sin(x), np.exp(0.3 * x),
1023
+ ... ylabel1='sin(x)', ylabel2='exp(0.3x)',
1024
+ ... )
1025
+ """
1026
+ x = np.asarray(x)
1027
+ y1 = np.asarray(y1)
1028
+ y2 = np.asarray(y2)
1029
+
1030
+ fig, ax1 = create_figure(figsize=figsize)
1031
+ ax2 = ax1.twinx()
1032
+
1033
+ lbl1 = label1 or ylabel1 or "Y1"
1034
+ lbl2 = label2 or ylabel2 or "Y2"
1035
+
1036
+ ax1.plot(x, y1, color=color1, linestyle=linestyle1, label=lbl1, **kwargs)
1037
+ ax2.plot(x, y2, color=color2, linestyle=linestyle2, label=lbl2, **kwargs)
1038
+
1039
+ if title:
1040
+ ax1.set_title(title)
1041
+ if xlabel:
1042
+ ax1.set_xlabel(xlabel)
1043
+ if ylabel1:
1044
+ ax1.set_ylabel(ylabel1, color=color1)
1045
+ ax1.tick_params(axis="y", labelcolor=color1)
1046
+ if ylabel2:
1047
+ ax2.set_ylabel(ylabel2, color=color2)
1048
+ ax2.tick_params(axis="y", labelcolor=color2)
1049
+
1050
+ # Merged legend from both axes
1051
+ h1, l1 = ax1.get_legend_handles_labels()
1052
+ h2, l2 = ax2.get_legend_handles_labels()
1053
+ ax1.legend(h1 + h2, l1 + l2, loc="best")
1054
+
1055
+ info: Dict[str, Any] = {"ax2": ax2}
1056
+ return fig, ax1, info
1057
+
1058
+
1059
+ # ============================================================================
1060
+ # Extended Plot Types (M21)
1061
+ # ============================================================================
1062
+
1063
+
1064
+ def z_colored_scatter(
1065
+ x,
1066
+ y,
1067
+ z,
1068
+ xlabel=None,
1069
+ ylabel=None,
1070
+ title=None,
1071
+ figsize=(8, 6),
1072
+ cmap="viridis",
1073
+ colorbar=True,
1074
+ colorbar_label=None,
1075
+ s=20,
1076
+ alpha=0.8,
1077
+ vmin=None,
1078
+ vmax=None,
1079
+ **kwargs,
1080
+ ) -> Tuple[Figure, Axes, Dict[str, Any]]:
1081
+ """Scatter plot with a third variable encoded as point color.
1082
+
1083
+ Parameters
1084
+ ----------
1085
+ x, y : array-like
1086
+ Point coordinates.
1087
+ z : array-like
1088
+ Values to map to color.
1089
+ cmap : str, default 'viridis'
1090
+ Matplotlib colormap name.
1091
+ colorbar : bool, default True
1092
+ Whether to add a colorbar.
1093
+ colorbar_label : str, optional
1094
+ Label for the colorbar.
1095
+ s : float, default 20
1096
+ Marker size.
1097
+ alpha : float, default 0.8
1098
+ Marker opacity.
1099
+ vmin, vmax : float, optional
1100
+ Color scale limits.
1101
+
1102
+ Returns
1103
+ -------
1104
+ fig, ax, info : tuple
1105
+ info keys: 'n_points', 'z_min', 'z_max'.
1106
+ """
1107
+ x, y, z = np.asarray(x), np.asarray(y), np.asarray(z)
1108
+ fig, ax = create_figure(figsize=figsize)
1109
+ sc = ax.scatter(x, y, c=z, cmap=cmap, s=s, alpha=alpha, vmin=vmin, vmax=vmax, **kwargs)
1110
+ if colorbar:
1111
+ cb = plt.colorbar(sc, ax=ax)
1112
+ if colorbar_label:
1113
+ cb.set_label(colorbar_label)
1114
+ configure_axes(ax, xlabel=xlabel, ylabel=ylabel, title=title)
1115
+ info: Dict[str, Any] = {
1116
+ "n_points": len(x),
1117
+ "z_min": float(np.nanmin(z)),
1118
+ "z_max": float(np.nanmax(z)),
1119
+ }
1120
+ return fig, ax, info
1121
+
1122
+
1123
+ def bubble_chart(
1124
+ x,
1125
+ y,
1126
+ sizes,
1127
+ z=None,
1128
+ xlabel=None,
1129
+ ylabel=None,
1130
+ title=None,
1131
+ figsize=(8, 6),
1132
+ cmap="viridis",
1133
+ colorbar=True,
1134
+ colorbar_label=None,
1135
+ color="steelblue",
1136
+ alpha=0.6,
1137
+ size_scale=1.0,
1138
+ **kwargs,
1139
+ ) -> Tuple[Figure, Axes, Dict[str, Any]]:
1140
+ """Scatter plot with size-encoded bubbles and optional color encoding.
1141
+
1142
+ Parameters
1143
+ ----------
1144
+ x, y : array-like
1145
+ Point coordinates.
1146
+ sizes : array-like
1147
+ Values that control bubble area.
1148
+ z : array-like, optional
1149
+ Values mapped to color.
1150
+ size_scale : float, default 1.0
1151
+ Multiplicative scale applied to *sizes*.
1152
+
1153
+ Returns
1154
+ -------
1155
+ fig, ax, info : tuple
1156
+ info keys: 'n_points', 'size_min', 'size_max'.
1157
+ """
1158
+ x, y, s = np.asarray(x), np.asarray(y), np.asarray(sizes, dtype=float)
1159
+ s_scaled = s * size_scale
1160
+ fig, ax = create_figure(figsize=figsize)
1161
+ if z is not None:
1162
+ z_arr = np.asarray(z)
1163
+ sc = ax.scatter(x, y, s=s_scaled, c=z_arr, cmap=cmap, alpha=alpha, **kwargs)
1164
+ if colorbar:
1165
+ cb = plt.colorbar(sc, ax=ax)
1166
+ if colorbar_label:
1167
+ cb.set_label(colorbar_label)
1168
+ else:
1169
+ ax.scatter(x, y, s=s_scaled, color=color, alpha=alpha, **kwargs)
1170
+ configure_axes(ax, xlabel=xlabel, ylabel=ylabel, title=title)
1171
+ info: Dict[str, Any] = {
1172
+ "n_points": len(x),
1173
+ "size_min": float(np.nanmin(s)),
1174
+ "size_max": float(np.nanmax(s)),
1175
+ }
1176
+ return fig, ax, info
1177
+
1178
+
1179
+ def polar_plot(
1180
+ theta,
1181
+ r,
1182
+ title=None,
1183
+ figsize=(7, 7),
1184
+ color="steelblue",
1185
+ linestyle="-",
1186
+ linewidth=1.5,
1187
+ fill=False,
1188
+ fill_alpha=0.15,
1189
+ theta_direction=-1,
1190
+ theta_zero_location="N",
1191
+ **kwargs,
1192
+ ) -> Tuple[Figure, Axes, Dict[str, Any]]:
1193
+ """Create a polar line/area plot.
1194
+
1195
+ Parameters
1196
+ ----------
1197
+ theta : array-like
1198
+ Angles in radians.
1199
+ r : array-like
1200
+ Radial values.
1201
+ fill : bool, default False
1202
+ Whether to fill the area under the curve.
1203
+ theta_direction : int, default -1
1204
+ -1 for clockwise, 1 for counter-clockwise.
1205
+ theta_zero_location : str, default 'N'
1206
+ Position of 0°: 'N', 'E', 'S', or 'W'.
1207
+
1208
+ Returns
1209
+ -------
1210
+ fig, ax, info : tuple
1211
+ info keys: 'n_points', 'r_max'.
1212
+ """
1213
+ theta_arr = np.asarray(theta, dtype=float)
1214
+ r_arr = np.asarray(r, dtype=float)
1215
+ fig, ax = plt.subplots(figsize=figsize, subplot_kw={"projection": "polar"})
1216
+ # subplot_kw guarantees a PolarAxes at runtime; plt.subplots is typed as returning
1217
+ # the base Axes, which has no set_theta_* methods.
1218
+ polar_ax = cast("PolarAxes", ax)
1219
+ polar_ax.set_theta_zero_location(theta_zero_location)
1220
+ polar_ax.set_theta_direction(theta_direction)
1221
+ ax.plot(theta_arr, r_arr, color=color, linestyle=linestyle, linewidth=linewidth, **kwargs)
1222
+ if fill:
1223
+ ax.fill(theta_arr, r_arr, alpha=fill_alpha, color=color)
1224
+ if title:
1225
+ ax.set_title(title, pad=15)
1226
+ info: Dict[str, Any] = {"n_points": len(theta_arr), "r_max": float(np.nanmax(r_arr))}
1227
+ return fig, ax, info
1228
+
1229
+
1230
+ def pair_plot(
1231
+ data,
1232
+ hue=None,
1233
+ vars=None,
1234
+ kind="scatter",
1235
+ diag_kind="hist",
1236
+ title=None,
1237
+ figsize=None,
1238
+ palette=None,
1239
+ **kwargs,
1240
+ ) -> Tuple[Figure, Axes, Dict[str, Any]]:
1241
+ """Create a pair plot (scatter matrix) using seaborn.
1242
+
1243
+ Parameters
1244
+ ----------
1245
+ data : pd.DataFrame
1246
+ Input data.
1247
+ hue : str, optional
1248
+ Column name to use for color encoding.
1249
+ vars : list of str, optional
1250
+ Columns to include; defaults to all numeric columns.
1251
+ kind : str, default 'scatter'
1252
+ Off-diagonal plot type: 'scatter', 'kde', 'hist', 'reg'.
1253
+ diag_kind : str, default 'hist'
1254
+ Diagonal plot type: 'hist', 'kde', 'auto'.
1255
+
1256
+ Returns
1257
+ -------
1258
+ fig, ax, info : tuple
1259
+ ax is grid.axes[0][0]; info keys: 'n_vars', 'hue'.
1260
+
1261
+ Raises
1262
+ ------
1263
+ ImportError
1264
+ If seaborn is not installed.
1265
+ """
1266
+ if not HAS_SEABORN:
1267
+ raise ImportError("seaborn is required for pair_plot. Install it with: pip install seaborn")
1268
+ import seaborn as sns
1269
+
1270
+ kw: Dict[str, Any] = dict(kind=kind, diag_kind=diag_kind)
1271
+ if hue is not None:
1272
+ kw["hue"] = hue
1273
+ if vars is not None:
1274
+ kw["vars"] = vars
1275
+ if palette is not None:
1276
+ kw["palette"] = palette
1277
+ if figsize is not None:
1278
+ n_vars = len(vars) if vars else len(data.select_dtypes("number").columns)
1279
+ kw["height"] = figsize[1] / max(n_vars, 1)
1280
+ grid = sns.pairplot(data, **kw, **kwargs)
1281
+ if title:
1282
+ grid.figure.suptitle(title, y=1.02)
1283
+ n_vars = len(vars) if vars else len(data.select_dtypes(include="number").columns)
1284
+ info: Dict[str, Any] = {"n_vars": n_vars, "hue": hue}
1285
+ ax = grid.axes[0][0]
1286
+ return grid.figure, ax, info
1287
+
1288
+
1289
+ def histogram_2d(
1290
+ x,
1291
+ y,
1292
+ bins=30,
1293
+ xlabel=None,
1294
+ ylabel=None,
1295
+ title=None,
1296
+ figsize=(8, 6),
1297
+ cmap="viridis",
1298
+ colorbar=True,
1299
+ mode="hist2d",
1300
+ gridsize=30,
1301
+ vmin=None,
1302
+ vmax=None,
1303
+ **kwargs,
1304
+ ) -> Tuple[Figure, Axes, Dict[str, Any]]:
1305
+ """Create a 2D histogram or hexbin density plot.
1306
+
1307
+ Parameters
1308
+ ----------
1309
+ x, y : array-like
1310
+ Input data arrays.
1311
+ bins : int, default 30
1312
+ Number of bins (used when mode='hist2d').
1313
+ mode : {'hist2d', 'hexbin'}, default 'hist2d'
1314
+ Plot mode.
1315
+ gridsize : int, default 30
1316
+ Hexbin grid size (used when mode='hexbin').
1317
+
1318
+ Returns
1319
+ -------
1320
+ fig, ax, info : tuple
1321
+ info keys: 'n_points', 'mode'.
1322
+ """
1323
+ x, y = np.asarray(x, dtype=float), np.asarray(y, dtype=float)
1324
+ fig, ax = create_figure(figsize=figsize)
1325
+ if mode == "hexbin":
1326
+ hb = ax.hexbin(x, y, gridsize=gridsize, cmap=cmap, vmin=vmin, vmax=vmax, **kwargs)
1327
+ if colorbar:
1328
+ plt.colorbar(hb, ax=ax, label="count")
1329
+ else:
1330
+ h, xedges, yedges, img = ax.hist2d(
1331
+ x, y, bins=bins, cmap=cmap, vmin=vmin, vmax=vmax, **kwargs
1332
+ )
1333
+ if colorbar:
1334
+ plt.colorbar(img, ax=ax, label="count")
1335
+ configure_axes(ax, xlabel=xlabel, ylabel=ylabel, title=title, grid=False)
1336
+ info: Dict[str, Any] = {"n_points": len(x), "mode": mode}
1337
+ return fig, ax, info
1338
+
1339
+
1340
+ def interactive_3d_scatter(
1341
+ x,
1342
+ y,
1343
+ z,
1344
+ color=None,
1345
+ size=None,
1346
+ labels=None,
1347
+ xlabel=None,
1348
+ ylabel=None,
1349
+ zlabel=None,
1350
+ title=None,
1351
+ colorscale="Viridis",
1352
+ opacity=0.8,
1353
+ marker_size=5,
1354
+ **kwargs,
1355
+ ) -> Tuple[Any, Dict[str, Any]]:
1356
+ """Create an interactive 3D scatter plot using Plotly.
1357
+
1358
+ Parameters
1359
+ ----------
1360
+ x, y, z : array-like
1361
+ Point coordinates.
1362
+ color : array-like, optional
1363
+ Values mapped to marker color.
1364
+ size : array-like, optional
1365
+ Values mapped to marker size.
1366
+ labels : array-like, optional
1367
+ Hover text labels.
1368
+ colorscale : str, default 'Viridis'
1369
+ Plotly colorscale name.
1370
+ opacity : float, default 0.8
1371
+ Marker opacity.
1372
+ marker_size : int, default 5
1373
+ Default marker size (overridden by *size* if given).
1374
+
1375
+ Returns
1376
+ -------
1377
+ fig, info : tuple
1378
+ info keys: 'n_points'.
1379
+
1380
+ Raises
1381
+ ------
1382
+ ImportError
1383
+ If plotly is not installed.
1384
+ """
1385
+ if not HAS_PLOTLY:
1386
+ raise ImportError(
1387
+ "plotly is required for interactive_3d_scatter. Install it with: pip install plotly"
1388
+ )
1389
+ x_arr, y_arr, z_arr = np.asarray(x), np.asarray(y), np.asarray(z)
1390
+
1391
+ marker_kw: Dict[str, Any] = {"size": marker_size, "opacity": opacity}
1392
+ if color is not None:
1393
+ c_arr = np.asarray(color)
1394
+ marker_kw["color"] = c_arr
1395
+ marker_kw["colorscale"] = colorscale
1396
+ marker_kw["showscale"] = True
1397
+ if size is not None:
1398
+ marker_kw["size"] = np.asarray(size)
1399
+
1400
+ scatter3d = go.Scatter3d(
1401
+ x=x_arr,
1402
+ y=y_arr,
1403
+ z=z_arr,
1404
+ mode="markers",
1405
+ marker=marker_kw,
1406
+ text=labels,
1407
+ **kwargs,
1408
+ )
1409
+ fig = go.Figure(data=[scatter3d])
1410
+ fig.update_layout(
1411
+ title=title or "",
1412
+ scene=dict(
1413
+ xaxis_title=xlabel or "X",
1414
+ yaxis_title=ylabel or "Y",
1415
+ zaxis_title=zlabel or "Z",
1416
+ ),
1417
+ )
1418
+ info: Dict[str, Any] = {"n_points": len(x_arr)}
1419
+ return fig, info
1420
+
1421
+
1422
+ def scatter_with_regression(
1423
+ x: np.ndarray,
1424
+ y: np.ndarray,
1425
+ xlabel: Optional[str] = None,
1426
+ ylabel: Optional[str] = None,
1427
+ title: Optional[str] = None,
1428
+ figsize: Tuple[float, float] = (8, 6),
1429
+ color: str = "steelblue",
1430
+ line_color: str = "tomato",
1431
+ alpha: float = 0.7,
1432
+ show_ci: bool = True,
1433
+ ci_alpha: float = 0.15,
1434
+ show_equation: bool = True,
1435
+ **kwargs,
1436
+ ) -> Tuple[Figure, Axes, Dict[str, Any]]:
1437
+ """Scatter plot with a linear regression line and optional confidence band.
1438
+
1439
+ Parameters
1440
+ ----------
1441
+ x, y : array-like
1442
+ Data arrays.
1443
+ xlabel, ylabel, title : str, optional
1444
+ figsize : tuple, default (8, 6)
1445
+ color : str, default 'steelblue'
1446
+ Scatter point color.
1447
+ line_color : str, default 'tomato'
1448
+ Regression line color.
1449
+ alpha : float, default 0.7
1450
+ Scatter point opacity.
1451
+ show_ci : bool, default True
1452
+ Shade a 95% confidence band around the regression line.
1453
+ ci_alpha : float, default 0.15
1454
+ Opacity of the confidence band.
1455
+ show_equation : bool, default True
1456
+ Annotate the plot with the regression equation and R².
1457
+ kwargs
1458
+ Extra keyword arguments forwarded to ``ax.scatter()``.
1459
+
1460
+ Returns
1461
+ -------
1462
+ fig : Figure
1463
+ ax : Axes
1464
+ info : dict
1465
+ ``slope``, ``intercept``, ``r_squared``, ``p_value``, ``stderr``
1466
+ """
1467
+ from scipy import stats as _stats
1468
+
1469
+ x_arr = np.asarray(x, dtype=float)
1470
+ y_arr = np.asarray(y, dtype=float)
1471
+
1472
+ result = _stats.linregress(x_arr, y_arr)
1473
+ slope, intercept, r_value, p_value, stderr = (
1474
+ result.slope,
1475
+ result.intercept,
1476
+ result.rvalue,
1477
+ result.pvalue,
1478
+ result.stderr,
1479
+ )
1480
+
1481
+ fig, ax = create_figure(figsize=figsize)
1482
+ ax.scatter(x_arr, y_arr, color=color, alpha=alpha, zorder=3, **kwargs)
1483
+
1484
+ x_fit = np.linspace(x_arr.min(), x_arr.max(), 300)
1485
+ y_fit = slope * x_fit + intercept
1486
+ ax.plot(x_fit, y_fit, color=line_color, linewidth=1.8, label="Regression line")
1487
+
1488
+ if show_ci:
1489
+ n = len(x_arr)
1490
+ t_crit = _stats.t.ppf(0.975, df=n - 2)
1491
+ se_fit = stderr * np.sqrt(
1492
+ 1.0 / n + (x_fit - x_arr.mean()) ** 2 / np.sum((x_arr - x_arr.mean()) ** 2)
1493
+ )
1494
+ ax.fill_between(
1495
+ x_fit,
1496
+ y_fit - t_crit * se_fit,
1497
+ y_fit + t_crit * se_fit,
1498
+ color=line_color,
1499
+ alpha=ci_alpha,
1500
+ label="95% CI",
1501
+ )
1502
+
1503
+ if show_equation:
1504
+ eq = (
1505
+ f"y = {slope:.4g}x {'+' if intercept >= 0 else '−'} {abs(intercept):.4g}\n"
1506
+ f"R² = {r_value**2:.4f} p = {p_value:.3g}"
1507
+ )
1508
+ ax.annotate(
1509
+ eq,
1510
+ xy=(0.05, 0.95),
1511
+ xycoords="axes fraction",
1512
+ va="top",
1513
+ fontsize=9,
1514
+ bbox=dict(boxstyle="round,pad=0.3", fc="white", alpha=0.7),
1515
+ )
1516
+
1517
+ configure_axes(ax, xlabel=xlabel, ylabel=ylabel, title=title, legend=show_ci)
1518
+
1519
+ info: Dict[str, Any] = {
1520
+ "slope": float(slope),
1521
+ "intercept": float(intercept),
1522
+ "r_squared": float(r_value**2),
1523
+ "p_value": float(p_value),
1524
+ "stderr": float(stderr),
1525
+ }
1526
+ return fig, ax, info
1527
+
1528
+
1529
+ def residual_plot(
1530
+ x: np.ndarray,
1531
+ y_actual: np.ndarray,
1532
+ y_fitted: np.ndarray,
1533
+ xlabel: Optional[str] = None,
1534
+ ylabel: Optional[str] = "Residual",
1535
+ title: Optional[str] = "Residual Plot",
1536
+ figsize: Tuple[float, float] = (8, 5),
1537
+ color_pos: str = "steelblue",
1538
+ color_neg: str = "tomato",
1539
+ alpha: float = 0.75,
1540
+ show_zero_line: bool = True,
1541
+ vs_fitted: bool = False,
1542
+ **kwargs,
1543
+ ) -> Tuple[Figure, Axes, Dict[str, Any]]:
1544
+ """Plot residuals from a curve fit.
1545
+
1546
+ Parameters
1547
+ ----------
1548
+ x : array-like
1549
+ Original x-axis values.
1550
+ y_actual : array-like
1551
+ Observed y values.
1552
+ y_fitted : array-like
1553
+ Model-predicted y values (from any fit).
1554
+ xlabel : str, optional
1555
+ X-axis label; defaults to 'x' or 'Fitted value' when ``vs_fitted=True``.
1556
+ ylabel : str, default 'Residual'
1557
+ title : str, default 'Residual Plot'
1558
+ figsize : tuple, default (8, 5)
1559
+ color_pos : str, default 'steelblue'
1560
+ Color for positive residuals.
1561
+ color_neg : str, default 'tomato'
1562
+ Color for negative residuals.
1563
+ alpha : float, default 0.75
1564
+ show_zero_line : bool, default True
1565
+ Draw a horizontal reference line at y = 0.
1566
+ vs_fitted : bool, default False
1567
+ If True, plot residuals vs. fitted values instead of vs. x.
1568
+ kwargs
1569
+ Extra keyword arguments forwarded to ``ax.scatter()``.
1570
+
1571
+ Returns
1572
+ -------
1573
+ fig : Figure
1574
+ ax : Axes
1575
+ info : dict
1576
+ ``n_points``, ``rmse``, ``max_residual``, ``mean_residual``
1577
+ """
1578
+ x_arr = np.asarray(x, dtype=float)
1579
+ ya = np.asarray(y_actual, dtype=float)
1580
+ yf = np.asarray(y_fitted, dtype=float)
1581
+ residuals = ya - yf
1582
+
1583
+ x_plot = yf if vs_fitted else x_arr
1584
+ default_xlabel = "Fitted value" if vs_fitted else (xlabel or "x")
1585
+
1586
+ colors = np.where(residuals >= 0, color_pos, color_neg)
1587
+
1588
+ fig, ax = create_figure(figsize=figsize)
1589
+ ax.scatter(x_plot, residuals, c=colors, alpha=alpha, zorder=3, **kwargs)
1590
+
1591
+ if show_zero_line:
1592
+ ax.axhline(0, color="black", linewidth=1.0, linestyle="--", zorder=2)
1593
+
1594
+ configure_axes(ax, xlabel=default_xlabel, ylabel=ylabel, title=title)
1595
+
1596
+ rmse = float(np.sqrt(np.mean(residuals**2)))
1597
+ info: Dict[str, Any] = {
1598
+ "n_points": len(residuals),
1599
+ "rmse": rmse,
1600
+ "max_residual": float(np.max(np.abs(residuals))),
1601
+ "mean_residual": float(np.mean(residuals)),
1602
+ }
1603
+ return fig, ax, info
1604
+
1605
+
1606
+ def interactive_ternary(
1607
+ a: np.ndarray,
1608
+ b: np.ndarray,
1609
+ c: np.ndarray,
1610
+ color: Optional[np.ndarray] = None,
1611
+ labels: Optional[List[str]] = None,
1612
+ a_label: str = "A",
1613
+ b_label: str = "B",
1614
+ c_label: str = "C",
1615
+ title: Optional[str] = None,
1616
+ colorscale: str = "Viridis",
1617
+ marker_size: int = 8,
1618
+ opacity: float = 0.85,
1619
+ **kwargs,
1620
+ ) -> Tuple[Any, Dict[str, Any]]:
1621
+ """Create an interactive ternary scatter plot using Plotly.
1622
+
1623
+ Each point represents a 3-component composition (a + b + c should ≈ 1 or
1624
+ a constant sum). Useful for phase diagrams, solvent composition studies,
1625
+ and materials science.
1626
+
1627
+ Parameters
1628
+ ----------
1629
+ a, b, c : array-like
1630
+ Component fractions for each axis.
1631
+ color : array-like, optional
1632
+ Numeric values mapped to a colorscale.
1633
+ labels : list of str, optional
1634
+ Hover text labels for each point.
1635
+ a_label, b_label, c_label : str
1636
+ Axis labels for the three components.
1637
+ title : str, optional
1638
+ colorscale : str, default 'Viridis'
1639
+ marker_size : int, default 8
1640
+ opacity : float, default 0.85
1641
+ kwargs
1642
+ Extra keyword arguments forwarded to ``go.Scatterternary``.
1643
+
1644
+ Returns
1645
+ -------
1646
+ fig : go.Figure
1647
+ info : dict
1648
+ ``n_points``
1649
+
1650
+ Raises
1651
+ ------
1652
+ ImportError
1653
+ If plotly is not installed.
1654
+ """
1655
+ if not HAS_PLOTLY:
1656
+ raise ImportError(
1657
+ "plotly is required for interactive_ternary. Install it with: pip install plotly"
1658
+ )
1659
+ a_arr = np.asarray(a, dtype=float)
1660
+ b_arr = np.asarray(b, dtype=float)
1661
+ c_arr = np.asarray(c, dtype=float)
1662
+
1663
+ marker_kw: Dict[str, Any] = {"size": marker_size, "opacity": opacity}
1664
+ if color is not None:
1665
+ marker_kw["color"] = np.asarray(color, dtype=float)
1666
+ marker_kw["colorscale"] = colorscale
1667
+ marker_kw["showscale"] = True
1668
+
1669
+ trace = go.Scatterternary(
1670
+ a=a_arr,
1671
+ b=b_arr,
1672
+ c=c_arr,
1673
+ mode="markers",
1674
+ marker=marker_kw,
1675
+ text=labels,
1676
+ hovertemplate=(
1677
+ f"{a_label}: %{{a:.3f}}<br>"
1678
+ f"{b_label}: %{{b:.3f}}<br>"
1679
+ f"{c_label}: %{{c:.3f}}<extra></extra>"
1680
+ ),
1681
+ **kwargs,
1682
+ )
1683
+ fig = go.Figure(data=[trace])
1684
+ fig.update_layout(
1685
+ title=title or "",
1686
+ ternary={
1687
+ "aaxis": {"title": a_label},
1688
+ "baxis": {"title": b_label},
1689
+ "caxis": {"title": c_label},
1690
+ },
1691
+ )
1692
+ return fig, {"n_points": len(a_arr)}
1693
+
1694
+
1695
+ def broken_axis_plot(
1696
+ x: np.ndarray,
1697
+ y: np.ndarray,
1698
+ breaks: List[Tuple[float, float]],
1699
+ axis: str = "x",
1700
+ xlabel: Optional[str] = None,
1701
+ ylabel: Optional[str] = None,
1702
+ title: Optional[str] = None,
1703
+ figsize: Tuple[float, float] = (9, 5),
1704
+ color: str = "steelblue",
1705
+ linewidth: float = 1.5,
1706
+ alpha: float = 1.0,
1707
+ d: float = 0.012,
1708
+ **kwargs,
1709
+ ) -> Tuple[Figure, Axes, Dict[str, Any]]:
1710
+ """Line plot with one or more broken axis segments.
1711
+
1712
+ Creates adjacent subplots for each unbroken segment and hides the inner
1713
+ spines to give the visual appearance of a continuous broken axis.
1714
+
1715
+ Parameters
1716
+ ----------
1717
+ x, y : array-like
1718
+ Data arrays. x must be sorted ascending.
1719
+ breaks : list of (lo, hi) tuples
1720
+ Each tuple defines a region to *exclude* from the plot.
1721
+ E.g. ``breaks=[(800, 1200)]`` skips x values between 800 and 1200.
1722
+ axis : {'x', 'y'}, default 'x'
1723
+ Which axis to break.
1724
+ xlabel, ylabel, title : str, optional
1725
+ figsize : tuple, default (9, 5)
1726
+ color : str, default 'steelblue'
1727
+ linewidth : float, default 1.5
1728
+ alpha : float, default 1.0
1729
+ d : float, default 0.012
1730
+ Size of the diagonal break markers (in axes-fraction units).
1731
+ kwargs
1732
+ Extra keyword arguments forwarded to ``ax.plot()``.
1733
+
1734
+ Returns
1735
+ -------
1736
+ fig : Figure
1737
+ ax : Axes
1738
+ The first (leftmost / bottom) axes object.
1739
+ info : dict
1740
+ ``n_segments`` — number of unbroken segments shown.
1741
+ """
1742
+ x_arr = np.asarray(x, dtype=float)
1743
+ y_arr = np.asarray(y, dtype=float)
1744
+
1745
+ # Build segment limits by excluding break regions
1746
+ all_breaks = sorted(breaks, key=lambda b: b[0])
1747
+
1748
+ limits = []
1749
+ prev = x_arr.min()
1750
+ for lo, hi in all_breaks:
1751
+ limits.append((prev, lo))
1752
+ prev = hi
1753
+ limits.append((prev, x_arr.max()))
1754
+ # Remove empty segments
1755
+ limits = [(a, b) for a, b in limits if b > a]
1756
+ n_seg = len(limits)
1757
+
1758
+ if axis == "x":
1759
+ widths = [b - a for a, b in limits]
1760
+ total = sum(widths)
1761
+ width_ratios = [w / total for w in widths]
1762
+ fig, axes = plt.subplots(
1763
+ 1,
1764
+ n_seg,
1765
+ sharey=True,
1766
+ figsize=figsize,
1767
+ gridspec_kw={"width_ratios": width_ratios, "wspace": 0.08},
1768
+ )
1769
+ if n_seg == 1:
1770
+ axes = [axes]
1771
+ for i, (ax_i, (lo, hi)) in enumerate(zip(axes, limits)):
1772
+ mask = (x_arr >= lo) & (x_arr <= hi)
1773
+ ax_i.plot(
1774
+ x_arr[mask], y_arr[mask], color=color, linewidth=linewidth, alpha=alpha, **kwargs
1775
+ )
1776
+ ax_i.set_xlim(lo, hi)
1777
+ # Hide inner spines
1778
+ if i > 0:
1779
+ ax_i.spines["left"].set_visible(False)
1780
+ ax_i.tick_params(left=False)
1781
+ if i < n_seg - 1:
1782
+ ax_i.spines["right"].set_visible(False)
1783
+ # Draw diagonal break markers
1784
+ kw_mark = dict(transform=ax_i.transAxes, color="k", clip_on=False, linewidth=1.2)
1785
+ if i > 0:
1786
+ ax_i.plot((-d, +d), (-d, +d), **kw_mark)
1787
+ ax_i.plot((-d, +d), (1 - d, 1 + d), **kw_mark)
1788
+ if i < n_seg - 1:
1789
+ ax_i.plot((1 - d, 1 + d), (-d, +d), **kw_mark)
1790
+ ax_i.plot((1 - d, 1 + d), (1 - d, 1 + d), **kw_mark)
1791
+ # Shared labels
1792
+ if xlabel:
1793
+ fig.text(0.5, 0.01, xlabel, ha="center")
1794
+ if ylabel:
1795
+ axes[0].set_ylabel(ylabel)
1796
+ if title:
1797
+ fig.suptitle(title)
1798
+ primary_ax = axes[0]
1799
+
1800
+ else: # axis == "y"
1801
+ heights = [b - a for a, b in limits]
1802
+ total = sum(heights)
1803
+ height_ratios = [h / total for h in reversed(heights)]
1804
+ fig, axes = plt.subplots(
1805
+ n_seg,
1806
+ 1,
1807
+ sharex=True,
1808
+ figsize=figsize,
1809
+ gridspec_kw={"height_ratios": height_ratios, "hspace": 0.08},
1810
+ )
1811
+ if n_seg == 1:
1812
+ axes = [axes]
1813
+ axes_ordered = list(reversed(axes)) # bottom to top
1814
+ for i, (ax_i, (lo, hi)) in enumerate(zip(axes_ordered, limits)):
1815
+ mask = (y_arr >= lo) & (y_arr <= hi)
1816
+ ax_i.plot(
1817
+ x_arr[mask], y_arr[mask], color=color, linewidth=linewidth, alpha=alpha, **kwargs
1818
+ )
1819
+ ax_i.set_ylim(lo, hi)
1820
+ if i > 0:
1821
+ ax_i.spines["bottom"].set_visible(False)
1822
+ ax_i.tick_params(bottom=False)
1823
+ if i < n_seg - 1:
1824
+ ax_i.spines["top"].set_visible(False)
1825
+ kw_mark = dict(transform=ax_i.transAxes, color="k", clip_on=False, linewidth=1.2)
1826
+ if i > 0:
1827
+ ax_i.plot((-d, +d), (-d, +d), **kw_mark)
1828
+ ax_i.plot((1 - d, 1 + d), (-d, +d), **kw_mark)
1829
+ if i < n_seg - 1:
1830
+ ax_i.plot((-d, +d), (1 - d, 1 + d), **kw_mark)
1831
+ ax_i.plot((1 - d, 1 + d), (1 - d, 1 + d), **kw_mark)
1832
+ if ylabel:
1833
+ fig.text(0.01, 0.5, ylabel, va="center", rotation="vertical")
1834
+ if xlabel:
1835
+ axes_ordered[0].set_xlabel(xlabel)
1836
+ if title:
1837
+ fig.suptitle(title)
1838
+ primary_ax = axes_ordered[0]
1839
+
1840
+ info: Dict[str, Any] = {"n_segments": n_seg}
1841
+ return fig, primary_ax, info
1842
+
1843
+
1844
+ def inset_plot(
1845
+ x,
1846
+ y,
1847
+ x_inset,
1848
+ y_inset,
1849
+ inset_bounds=None,
1850
+ indicate_region=None,
1851
+ title=None,
1852
+ xlabel=None,
1853
+ ylabel=None,
1854
+ inset_xlabel=None,
1855
+ inset_ylabel=None,
1856
+ color="steelblue",
1857
+ inset_color=None,
1858
+ linewidth=1.5,
1859
+ figsize=(8, 6),
1860
+ **kwargs,
1861
+ ) -> Tuple[Figure, Axes, Dict[str, Any]]:
1862
+ """Plot data with an inset subplot showing a zoomed or alternate view.
1863
+
1864
+ Parameters
1865
+ ----------
1866
+ x : array-like
1867
+ X data for main plot.
1868
+ y : array-like
1869
+ Y data for main plot.
1870
+ x_inset : array-like
1871
+ X data for inset (can be same as x for zoom, or different data).
1872
+ y_inset : array-like
1873
+ Y data for inset.
1874
+ inset_bounds : list of float, optional
1875
+ [x0, y0, width, height] in axes fraction coordinates (0–1).
1876
+ Defaults to [0.55, 0.55, 0.4, 0.35].
1877
+ indicate_region : tuple of float, optional
1878
+ (x_start, x_end) to draw a grey shaded region on the main plot
1879
+ matching the inset x range.
1880
+ title : str, optional
1881
+ xlabel : str, optional
1882
+ ylabel : str, optional
1883
+ inset_xlabel : str, optional
1884
+ Axis label for the inset X axis.
1885
+ inset_ylabel : str, optional
1886
+ Axis label for the inset Y axis.
1887
+ color : str, optional
1888
+ Color for main plot line. Default ``'steelblue'``.
1889
+ inset_color : str, optional
1890
+ Color for inset line. Defaults to *color*.
1891
+ linewidth : float, optional
1892
+ Line width for both plots. Default 1.5.
1893
+ figsize : tuple, default (8, 6)
1894
+ Figure size in inches.
1895
+ kwargs : dict
1896
+ Extra keyword arguments forwarded to ``ax.plot()`` for the main line.
1897
+
1898
+ Returns
1899
+ -------
1900
+ fig : Figure
1901
+ ax : Axes
1902
+ Main axes.
1903
+ info : dict
1904
+ ``n_points``, ``n_inset_points``, ``inset_bounds``.
1905
+
1906
+ Examples
1907
+ --------
1908
+ >>> x = np.linspace(0, 10, 200)
1909
+ >>> y = np.sin(x)
1910
+ >>> fig, ax, info = inset_plot(x, y, x[40:80], y[40:80],
1911
+ ... indicate_region=(2.0, 4.0))
1912
+ """
1913
+ x = np.asarray(x)
1914
+ y = np.asarray(y)
1915
+ x_inset = np.asarray(x_inset)
1916
+ y_inset = np.asarray(y_inset)
1917
+
1918
+ if inset_bounds is None:
1919
+ inset_bounds = [0.55, 0.55, 0.4, 0.35]
1920
+ if inset_color is None:
1921
+ inset_color = color
1922
+
1923
+ fig, ax = create_figure(figsize=figsize)
1924
+ ax.plot(x, y, color=color, linewidth=linewidth, **kwargs)
1925
+ if title:
1926
+ ax.set_title(title)
1927
+ if xlabel:
1928
+ ax.set_xlabel(xlabel)
1929
+ if ylabel:
1930
+ ax.set_ylabel(ylabel)
1931
+
1932
+ # Shade indicated region on main plot
1933
+ if indicate_region is not None:
1934
+ ax.axvspan(indicate_region[0], indicate_region[1], alpha=0.15, color="grey")
1935
+
1936
+ # Create inset axes
1937
+ ax_inset = ax.inset_axes(inset_bounds)
1938
+ ax_inset.plot(x_inset, y_inset, color=inset_color, linewidth=linewidth)
1939
+ if inset_xlabel:
1940
+ ax_inset.set_xlabel(inset_xlabel, fontsize=8)
1941
+ if inset_ylabel:
1942
+ ax_inset.set_ylabel(inset_ylabel, fontsize=8)
1943
+ ax_inset.tick_params(labelsize=7)
1944
+
1945
+ plt.tight_layout()
1946
+
1947
+ info: Dict[str, Any] = {
1948
+ "n_points": len(x),
1949
+ "n_inset_points": len(x_inset),
1950
+ "inset_bounds": inset_bounds,
1951
+ }
1952
+ return fig, ax, info
1953
+
1954
+
1955
+ # ============================================================================
1956
+ # Styling Utilities
1957
+ # ============================================================================
1958
+
1959
+
1960
+ def set_style(style: str = "default") -> None:
1961
+ """Set matplotlib style.
1962
+
1963
+ Parameters
1964
+ ----------
1965
+ style : str, default 'default'
1966
+ Style name. Options: 'default', 'seaborn', 'ggplot', 'bmh', 'dark_background'
1967
+
1968
+ Examples
1969
+ --------
1970
+ >>> set_style('seaborn')
1971
+ >>> fig, ax = line_plot(x, y)
1972
+ """
1973
+ if style == "default":
1974
+ plt.style.use("default")
1975
+ elif style in plt.style.available:
1976
+ plt.style.use(style)
1977
+ else:
1978
+ available = ", ".join(plt.style.available[:10])
1979
+ raise ValueError(f"Unknown style: {style}. Available styles include: {available}, ...")
1980
+
1981
+
1982
+ def get_color_palette(name: str = "tab10", n_colors: int = 10) -> List[str]:
1983
+ """Get a color palette.
1984
+
1985
+ Parameters
1986
+ ----------
1987
+ name : str, default 'tab10'
1988
+ Palette name. Options: 'tab10', 'tab20', 'Set1', 'Set2', 'Set3', etc.
1989
+ n_colors : int, default 10
1990
+ Number of colors to return
1991
+
1992
+ Returns
1993
+ -------
1994
+ colors : list of str
1995
+ List of color hex codes
1996
+
1997
+ Examples
1998
+ --------
1999
+ >>> colors = get_color_palette('Set1', n_colors=5)
2000
+ >>> fig, ax = line_plot(x, [y1, y2], colors=colors[:2])
2001
+ """
2002
+ if name == "colorblind":
2003
+ palette = COLORBLIND_PALETTE[:n_colors]
2004
+ return [str(c) for c in palette]
2005
+
2006
+ cmap = plt.get_cmap(name)
2007
+
2008
+ if hasattr(cmap, "colors"):
2009
+ # For qualitative colormaps
2010
+ colors = [mcolors.rgb2hex(c) for c in cmap.colors[:n_colors]]
2011
+ else:
2012
+ # For continuous colormaps
2013
+ colors = [mcolors.rgb2hex(cmap(i / n_colors)) for i in range(n_colors)]
2014
+
2015
+ return colors
2016
+
2017
+
2018
+ def apply_publication_style(
2019
+ fig: Figure,
2020
+ ax: Union[Axes, List[Axes]],
2021
+ fontsize: int = 12,
2022
+ labelsize: int = 10,
2023
+ linewidth: float = 1.5,
2024
+ ) -> None:
2025
+ """Apply publication-quality styling to figure.
2026
+
2027
+ Parameters
2028
+ ----------
2029
+ fig : Figure
2030
+ Matplotlib figure
2031
+ ax : Axes or list of Axes
2032
+ Axes to style
2033
+ fontsize : int, default 12
2034
+ Font size for labels
2035
+ labelsize : int, default 10
2036
+ Font size for tick labels
2037
+ linewidth : float, default 1.5
2038
+ Line width for plot elements
2039
+
2040
+ Examples
2041
+ --------
2042
+ >>> fig, ax = line_plot(x, y)
2043
+ >>> apply_publication_style(fig, ax)
2044
+ """
2045
+ # Handle single or multiple axes
2046
+ if not isinstance(ax, list):
2047
+ ax = [ax]
2048
+
2049
+ for a in ax:
2050
+ # Set font sizes
2051
+ a.title.set_fontsize(fontsize + 2)
2052
+ a.xaxis.label.set_fontsize(fontsize)
2053
+ a.yaxis.label.set_fontsize(fontsize)
2054
+ a.tick_params(labelsize=labelsize)
2055
+
2056
+ # Set line widths
2057
+ for line in a.lines:
2058
+ line.set_linewidth(linewidth)
2059
+
2060
+ # Spine styling
2061
+ for spine in a.spines.values():
2062
+ spine.set_linewidth(1.0)
2063
+
2064
+ # Tight layout
2065
+ fig.tight_layout()
2066
+
2067
+
2068
+ # ============================================================================
2069
+ # Seaborn Statistical Plots
2070
+ # ============================================================================
2071
+
2072
+
2073
+ def distribution_plot(
2074
+ data: Union[np.ndarray, List], kind: str = "hist", kde: bool = True, **kwargs
2075
+ ) -> Tuple[Figure, Axes]:
2076
+ """Create distribution plots using Seaborn.
2077
+
2078
+ Parameters
2079
+ ----------
2080
+ data : array-like
2081
+ Data to plot
2082
+ kind : str, default 'hist'
2083
+ Plot type: 'hist', 'kde', 'ecdf'
2084
+ kde : bool, default True
2085
+ Whether to overlay kernel density estimate
2086
+ kwargs : dict
2087
+ Additional arguments passed to seaborn plotting function
2088
+
2089
+ Returns
2090
+ -------
2091
+ fig : Figure
2092
+ Matplotlib figure object
2093
+ ax : Axes
2094
+ Matplotlib axes object
2095
+
2096
+ Raises
2097
+ ------
2098
+ ImportError
2099
+ If seaborn is not installed
2100
+
2101
+ Examples
2102
+ --------
2103
+ >>> data = np.random.normal(0, 1, 1000)
2104
+ >>> fig, ax = distribution_plot(data, kind='hist', kde=True)
2105
+ """
2106
+ if not HAS_SEABORN:
2107
+ raise ImportError(
2108
+ "Seaborn is required for distribution_plot. Install it with: pip install seaborn"
2109
+ )
2110
+
2111
+ figsize = kwargs.pop("figsize", (8, 6))
2112
+ title = kwargs.pop("title", None)
2113
+ xlabel = kwargs.pop("xlabel", None)
2114
+ ylabel = kwargs.pop("ylabel", None)
2115
+ fig, ax = create_figure(figsize=figsize)
2116
+
2117
+ if kind == "hist":
2118
+ sns.histplot(data=data, kde=kde, ax=ax, **kwargs)
2119
+ elif kind == "kde":
2120
+ sns.kdeplot(data=data, ax=ax, **kwargs)
2121
+ elif kind == "ecdf":
2122
+ sns.ecdfplot(data=data, ax=ax, **kwargs)
2123
+ else:
2124
+ raise ValueError(f"Unknown kind: {kind}. Supported: 'hist', 'kde', 'ecdf'")
2125
+
2126
+ configure_axes(ax, xlabel=xlabel, ylabel=ylabel, title=title)
2127
+ return fig, ax
2128
+
2129
+
2130
+ def box_plot(
2131
+ data: Union[np.ndarray, "pd.DataFrame"],
2132
+ x: Optional[str] = None,
2133
+ y: Optional[str] = None,
2134
+ hue: Optional[str] = None,
2135
+ kind: str = "box",
2136
+ **kwargs,
2137
+ ) -> Tuple[Figure, Axes]:
2138
+ """Create box plots or violin plots using Seaborn.
2139
+
2140
+ Parameters
2141
+ ----------
2142
+ data : array-like or DataFrame
2143
+ Data to plot
2144
+ x : str, optional
2145
+ Column name for x-axis (for DataFrame input)
2146
+ y : str, optional
2147
+ Column name for y-axis (for DataFrame input)
2148
+ hue : str, optional
2149
+ Column name for color grouping
2150
+ kind : str, default 'box'
2151
+ Plot type: 'box', 'violin', 'boxen'
2152
+ kwargs : dict
2153
+ Additional arguments passed to seaborn function
2154
+
2155
+ Returns
2156
+ -------
2157
+ fig : Figure
2158
+ Matplotlib figure object
2159
+ ax : Axes
2160
+ Matplotlib axes object
2161
+
2162
+ Raises
2163
+ ------
2164
+ ImportError
2165
+ If seaborn is not installed
2166
+
2167
+ Examples
2168
+ --------
2169
+ >>> import pandas as pd
2170
+ >>> df = pd.DataFrame({'group': ['A']*50 + ['B']*50,
2171
+ ... 'value': np.random.randn(100)})
2172
+ >>> fig, ax = box_plot(df, x='group', y='value')
2173
+ """
2174
+ if not HAS_SEABORN:
2175
+ raise ImportError("Seaborn is required for box_plot. Install it with: pip install seaborn")
2176
+
2177
+ figsize = kwargs.pop("figsize", (8, 6))
2178
+ title = kwargs.pop("title", None)
2179
+ xlabel = kwargs.pop("xlabel", None)
2180
+ ylabel = kwargs.pop("ylabel", None)
2181
+ fig, ax = create_figure(figsize=figsize)
2182
+
2183
+ if kind == "box":
2184
+ sns.boxplot(data=data, x=x, y=y, hue=hue, ax=ax, **kwargs)
2185
+ elif kind == "violin":
2186
+ sns.violinplot(data=data, x=x, y=y, hue=hue, ax=ax, **kwargs)
2187
+ elif kind == "boxen":
2188
+ sns.boxenplot(data=data, x=x, y=y, hue=hue, ax=ax, **kwargs)
2189
+ else:
2190
+ raise ValueError(f"Unknown kind: {kind}. Supported: 'box', 'violin', 'boxen'")
2191
+
2192
+ configure_axes(ax, xlabel=xlabel, ylabel=ylabel, title=title)
2193
+ return fig, ax
2194
+
2195
+
2196
+ def regression_plot(
2197
+ x: Union[np.ndarray, str],
2198
+ y: Union[np.ndarray, str],
2199
+ data: Optional["pd.DataFrame"] = None,
2200
+ order: int = 1,
2201
+ ci: int = 95,
2202
+ **kwargs,
2203
+ ) -> Tuple[Figure, Axes]:
2204
+ """Create regression plot with confidence interval.
2205
+
2206
+ Parameters
2207
+ ----------
2208
+ x : array-like or str
2209
+ X data or column name
2210
+ y : array-like or str
2211
+ Y data or column name
2212
+ data : DataFrame, optional
2213
+ DataFrame containing x and y columns
2214
+ order : int, default 1
2215
+ Polynomial order (1 = linear, 2 = quadratic, etc.)
2216
+ ci : int, default 95
2217
+ Confidence interval percentage
2218
+ kwargs : dict
2219
+ Additional arguments passed to seaborn.regplot
2220
+
2221
+ Returns
2222
+ -------
2223
+ fig : Figure
2224
+ Matplotlib figure object
2225
+ ax : Axes
2226
+ Matplotlib axes object
2227
+
2228
+ Raises
2229
+ ------
2230
+ ImportError
2231
+ If seaborn is not installed
2232
+
2233
+ Examples
2234
+ --------
2235
+ >>> x = np.linspace(0, 10, 50)
2236
+ >>> y = 2*x + 1 + np.random.randn(50)
2237
+ >>> fig, ax = regression_plot(x, y, order=1)
2238
+ """
2239
+ if not HAS_SEABORN:
2240
+ raise ImportError(
2241
+ "Seaborn is required for regression_plot. Install it with: pip install seaborn"
2242
+ )
2243
+
2244
+ figsize = kwargs.pop("figsize", (8, 6))
2245
+ title = kwargs.pop("title", None)
2246
+ xlabel = kwargs.pop("xlabel", None)
2247
+ ylabel = kwargs.pop("ylabel", None)
2248
+ fig, ax = create_figure(figsize=figsize)
2249
+
2250
+ sns.regplot(x=x, y=y, data=data, order=order, ci=ci, ax=ax, **kwargs)
2251
+
2252
+ configure_axes(ax, xlabel=xlabel, ylabel=ylabel, title=title)
2253
+ return fig, ax
2254
+
2255
+
2256
+ # ============================================================================
2257
+ # Plotly Interactive Plots
2258
+ # ============================================================================
2259
+
2260
+
2261
+ def interactive_histogram(
2262
+ data: np.ndarray,
2263
+ bins: int = 30,
2264
+ title: Optional[str] = None,
2265
+ xlabel: Optional[str] = None,
2266
+ ylabel: Optional[str] = None,
2267
+ **kwargs,
2268
+ ) -> "go.Figure":
2269
+ """Create interactive histogram using Plotly.
2270
+
2271
+ Parameters
2272
+ ----------
2273
+ data : array-like
2274
+ Data to plot
2275
+ bins : int, default 30
2276
+ Number of bins
2277
+ title : str, optional
2278
+ Plot title
2279
+ xlabel : str, optional
2280
+ X-axis label
2281
+ ylabel : str, optional
2282
+ Y-axis label
2283
+ kwargs : dict
2284
+ Additional arguments passed to go.Histogram
2285
+
2286
+ Returns
2287
+ -------
2288
+ fig : plotly.graph_objects.Figure
2289
+ Plotly figure object (use fig.show() to display)
2290
+
2291
+ Raises
2292
+ ------
2293
+ ImportError
2294
+ If plotly is not installed
2295
+
2296
+ Examples
2297
+ --------
2298
+ >>> data = np.random.normal(0, 1, 1000)
2299
+ >>> fig = interactive_histogram(data, bins=50, title='Distribution')
2300
+ >>> fig.write_html('histogram.html')
2301
+ """
2302
+ if not HAS_PLOTLY:
2303
+ raise ImportError(
2304
+ "Plotly is required for interactive_histogram. Install it with: pip install plotly"
2305
+ )
2306
+
2307
+ fig = go.Figure(data=[go.Histogram(x=data, nbinsx=bins, **kwargs)])
2308
+
2309
+ fig.update_layout(
2310
+ title=title,
2311
+ xaxis_title=xlabel or "Value",
2312
+ yaxis_title=ylabel or "Count",
2313
+ hovermode="x",
2314
+ )
2315
+
2316
+ return fig
2317
+
2318
+
2319
+ def interactive_scatter(
2320
+ x: np.ndarray,
2321
+ y: np.ndarray,
2322
+ color: Optional[np.ndarray] = None,
2323
+ size: Optional[np.ndarray] = None,
2324
+ hover_data: Optional[Dict] = None,
2325
+ title: Optional[str] = None,
2326
+ xlabel: Optional[str] = None,
2327
+ ylabel: Optional[str] = None,
2328
+ **kwargs,
2329
+ ) -> "go.Figure":
2330
+ """Create interactive scatter plot using Plotly.
2331
+
2332
+ Parameters
2333
+ ----------
2334
+ x : array-like
2335
+ X coordinates
2336
+ y : array-like
2337
+ Y coordinates
2338
+ color : array-like, optional
2339
+ Values for color mapping
2340
+ size : array-like, optional
2341
+ Values for marker size
2342
+ hover_data : dict, optional
2343
+ Additional data to show on hover
2344
+ title : str, optional
2345
+ Plot title
2346
+ xlabel : str, optional
2347
+ X-axis label
2348
+ ylabel : str, optional
2349
+ Y-axis label
2350
+ kwargs : dict
2351
+ Additional arguments passed to go.Scatter
2352
+
2353
+ Returns
2354
+ -------
2355
+ fig : plotly.graph_objects.Figure
2356
+ Plotly figure object
2357
+
2358
+ Raises
2359
+ ------
2360
+ ImportError
2361
+ If plotly is not installed
2362
+
2363
+ Examples
2364
+ --------
2365
+ >>> x = np.random.rand(100)
2366
+ >>> y = np.random.rand(100)
2367
+ >>> fig = interactive_scatter(x, y, title='Interactive Scatter')
2368
+ >>> fig.show()
2369
+ """
2370
+ if not HAS_PLOTLY:
2371
+ raise ImportError(
2372
+ "Plotly is required for interactive_scatter. Install it with: pip install plotly"
2373
+ )
2374
+
2375
+ scatter_kwargs = {"x": x, "y": y, "mode": "markers", **kwargs}
2376
+
2377
+ if color is not None:
2378
+ scatter_kwargs["marker"] = {
2379
+ "color": color,
2380
+ "colorscale": "Viridis",
2381
+ "showscale": True,
2382
+ }
2383
+
2384
+ if size is not None:
2385
+ if "marker" not in scatter_kwargs:
2386
+ scatter_kwargs["marker"] = {}
2387
+ scatter_kwargs["marker"]["size"] = size
2388
+
2389
+ if hover_data is not None:
2390
+ scatter_kwargs["customdata"] = hover_data
2391
+
2392
+ fig = go.Figure(data=[go.Scatter(**scatter_kwargs)])
2393
+
2394
+ fig.update_layout(
2395
+ title=title,
2396
+ xaxis_title=xlabel or "X",
2397
+ yaxis_title=ylabel or "Y",
2398
+ hovermode="closest",
2399
+ )
2400
+
2401
+ return fig
2402
+
2403
+
2404
+ def interactive_line(
2405
+ x: Union[np.ndarray, List[np.ndarray]],
2406
+ y: Union[np.ndarray, List[np.ndarray]],
2407
+ labels: Optional[List[str]] = None,
2408
+ title: Optional[str] = None,
2409
+ xlabel: Optional[str] = None,
2410
+ ylabel: Optional[str] = None,
2411
+ **kwargs,
2412
+ ) -> "go.Figure":
2413
+ """Create interactive line plot using Plotly.
2414
+
2415
+ Parameters
2416
+ ----------
2417
+ x : array-like or list of arrays
2418
+ X coordinates (single or multiple series)
2419
+ y : array-like or list of arrays
2420
+ Y coordinates (single or multiple series)
2421
+ labels : list of str, optional
2422
+ Line labels for legend
2423
+ title : str, optional
2424
+ Plot title
2425
+ xlabel : str, optional
2426
+ X-axis label
2427
+ ylabel : str, optional
2428
+ Y-axis label
2429
+ kwargs : dict
2430
+ Additional arguments passed to go.Scatter
2431
+
2432
+ Returns
2433
+ -------
2434
+ fig : plotly.graph_objects.Figure
2435
+ Plotly figure object
2436
+
2437
+ Raises
2438
+ ------
2439
+ ImportError
2440
+ If plotly is not installed
2441
+
2442
+ Examples
2443
+ --------
2444
+ >>> x = np.linspace(0, 10, 100)
2445
+ >>> y1 = np.sin(x)
2446
+ >>> y2 = np.cos(x)
2447
+ >>> fig = interactive_line(x, [y1, y2], labels=['sin', 'cos'])
2448
+ >>> fig.show()
2449
+ """
2450
+ if not HAS_PLOTLY:
2451
+ raise ImportError(
2452
+ "Plotly is required for interactive_line. Install it with: pip install plotly"
2453
+ )
2454
+
2455
+ fig = go.Figure()
2456
+
2457
+ # Handle single or multiple lines
2458
+ if not isinstance(y, list):
2459
+ y = [y]
2460
+ x_data = [x]
2461
+ else:
2462
+ x_data = [x] * len(y) if not isinstance(x, list) else x # type: ignore[assignment]
2463
+
2464
+ if labels is None:
2465
+ labels = [f"Line {i + 1}" for i in range(len(y))]
2466
+
2467
+ for i, (x_i, y_i) in enumerate(zip(x_data, y)):
2468
+ fig.add_trace(go.Scatter(x=x_i, y=y_i, mode="lines", name=labels[i], **kwargs))
2469
+
2470
+ fig.update_layout(
2471
+ title=title,
2472
+ xaxis_title=xlabel or "X",
2473
+ yaxis_title=ylabel or "Y",
2474
+ hovermode="x unified",
2475
+ height=450,
2476
+ autosize=True,
2477
+ )
2478
+
2479
+ return fig
2480
+
2481
+
2482
+ def interactive_heatmap(
2483
+ data: np.ndarray,
2484
+ x_labels: Optional[List[str]] = None,
2485
+ y_labels: Optional[List[str]] = None,
2486
+ title: Optional[str] = None,
2487
+ colorscale: str = "Viridis",
2488
+ **kwargs,
2489
+ ) -> "go.Figure":
2490
+ """Create interactive heatmap using Plotly.
2491
+
2492
+ Parameters
2493
+ ----------
2494
+ data : 2D array
2495
+ Data to visualize as heatmap
2496
+ x_labels : list of str, optional
2497
+ Labels for x-axis
2498
+ y_labels : list of str, optional
2499
+ Labels for y-axis
2500
+ title : str, optional
2501
+ Plot title
2502
+ colorscale : str, default 'Viridis'
2503
+ Colorscale name
2504
+ kwargs : dict
2505
+ Additional arguments passed to go.Heatmap
2506
+
2507
+ Returns
2508
+ -------
2509
+ fig : plotly.graph_objects.Figure
2510
+ Plotly figure object
2511
+
2512
+ Raises
2513
+ ------
2514
+ ImportError
2515
+ If plotly is not installed
2516
+
2517
+ Examples
2518
+ --------
2519
+ >>> data = np.random.rand(10, 10)
2520
+ >>> fig = interactive_heatmap(data, title='Interactive Heatmap')
2521
+ >>> fig.show()
2522
+ """
2523
+ if not HAS_PLOTLY:
2524
+ raise ImportError(
2525
+ "Plotly is required for interactive_heatmap. Install it with: pip install plotly"
2526
+ )
2527
+
2528
+ fig = go.Figure(
2529
+ data=go.Heatmap(z=data, x=x_labels, y=y_labels, colorscale=colorscale, **kwargs)
2530
+ )
2531
+
2532
+ fig.update_layout(title=title, xaxis_title="X", yaxis_title="Y")
2533
+
2534
+ return fig
2535
+
2536
+
2537
+ def interactive_3d_surface(
2538
+ x: np.ndarray,
2539
+ y: np.ndarray,
2540
+ z: np.ndarray,
2541
+ title: Optional[str] = None,
2542
+ colorscale: str = "Viridis",
2543
+ **kwargs,
2544
+ ) -> "go.Figure":
2545
+ """Create interactive 3D surface plot using Plotly.
2546
+
2547
+ Parameters
2548
+ ----------
2549
+ x : 2D array
2550
+ X coordinates (meshgrid format)
2551
+ y : 2D array
2552
+ Y coordinates (meshgrid format)
2553
+ z : 2D array
2554
+ Z values
2555
+ title : str, optional
2556
+ Plot title
2557
+ colorscale : str, default 'Viridis'
2558
+ Colorscale name
2559
+ kwargs : dict
2560
+ Additional arguments passed to go.Surface
2561
+
2562
+ Returns
2563
+ -------
2564
+ fig : plotly.graph_objects.Figure
2565
+ Plotly figure object
2566
+
2567
+ Raises
2568
+ ------
2569
+ ImportError
2570
+ If plotly is not installed
2571
+
2572
+ Examples
2573
+ --------
2574
+ >>> x = np.linspace(-5, 5, 50)
2575
+ >>> y = np.linspace(-5, 5, 50)
2576
+ >>> X, Y = np.meshgrid(x, y)
2577
+ >>> Z = np.sin(np.sqrt(X**2 + Y**2))
2578
+ >>> fig = interactive_3d_surface(X, Y, Z, title='3D Surface')
2579
+ >>> fig.show()
2580
+ """
2581
+ if not HAS_PLOTLY:
2582
+ raise ImportError(
2583
+ "Plotly is required for interactive_3d_surface. Install it with: pip install plotly"
2584
+ )
2585
+
2586
+ fig = go.Figure(data=[go.Surface(x=x, y=y, z=z, colorscale=colorscale, **kwargs)])
2587
+
2588
+ fig.update_layout(title=title, scene=dict(xaxis_title="X", yaxis_title="Y", zaxis_title="Z"))
2589
+
2590
+ return fig
2591
+
2592
+
2593
+ # ============================================================================
2594
+ # Export Utilities
2595
+ # ============================================================================
2596
+
2597
+
2598
+ def export_interactive(
2599
+ fig: "go.Figure",
2600
+ filepath: Union[str, Path],
2601
+ include_plotlyjs: str = "cdn",
2602
+ **kwargs,
2603
+ ) -> None:
2604
+ """Export Plotly figure to HTML file.
2605
+
2606
+ Parameters
2607
+ ----------
2608
+ fig : plotly.graph_objects.Figure
2609
+ Plotly figure to export
2610
+ filepath : str or Path
2611
+ Output file path (.html)
2612
+ include_plotlyjs : str, default 'cdn'
2613
+ How to include plotly.js: 'cdn', 'directory', or True (embed)
2614
+ kwargs : dict
2615
+ Additional arguments passed to fig.write_html()
2616
+
2617
+ Examples
2618
+ --------
2619
+ >>> fig = interactive_scatter(x, y)
2620
+ >>> export_interactive(fig, 'scatter.html')
2621
+ """
2622
+ if not HAS_PLOTLY:
2623
+ raise ImportError(
2624
+ "Plotly is required for export_interactive. Install it with: pip install plotly"
2625
+ )
2626
+
2627
+ filepath = Path(filepath)
2628
+ filepath.parent.mkdir(parents=True, exist_ok=True)
2629
+
2630
+ fig.write_html(str(filepath), include_plotlyjs=include_plotlyjs, **kwargs)