matplotly 0.1.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.
matplotly/_api.py ADDED
@@ -0,0 +1,984 @@
1
+ """PlotBuildSession — main orchestrator that wires everything together."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import ipywidgets as widgets
9
+ from IPython.display import display
10
+ from matplotlib.figure import Figure
11
+
12
+ from ._code_gen import generate_code
13
+ from ._commands import CommandStack
14
+ from ._introspect import FigureIntrospector
15
+ from ._renderer import CanvasManager
16
+ from ._profiles import create_profiles_panel
17
+ from ._types import ArtistGroup, PlotType
18
+ from .panels import create_panel
19
+ from .panels._global import GlobalPanel
20
+ from .panels._histogram import HistogramSharedPanel
21
+ from .panels._line import ColormapPanel
22
+ from .panels._marginal import MarginalHistogramManager
23
+ from .panels._subplot import PerSubplotPanel
24
+
25
+
26
+ def _extract_hist_calls(source: str) -> list[dict]:
27
+ """Extract .hist() call info from cell source using AST.
28
+
29
+ Returns list of dicts with keys:
30
+ start_line – 1-indexed first line of the call
31
+ end_line – 1-indexed last line of the call
32
+ data_var – source text of the first positional argument
33
+ label – string label if a label=... kwarg is present
34
+ """
35
+ import ast
36
+
37
+ try:
38
+ tree = ast.parse(source)
39
+ except SyntaxError:
40
+ return []
41
+
42
+ results: list[dict] = []
43
+ for node in ast.walk(tree):
44
+ if not isinstance(node, ast.Call):
45
+ continue
46
+ func = node.func
47
+ if not (isinstance(func, ast.Attribute) and func.attr == 'hist'):
48
+ continue
49
+ if not node.args:
50
+ continue
51
+
52
+ data_var = ast.get_source_segment(source, node.args[0])
53
+ if data_var is None:
54
+ continue
55
+
56
+ # Try to extract label keyword
57
+ label = None
58
+ for kw in node.keywords:
59
+ if kw.arg == 'label' and isinstance(kw.value, ast.Constant):
60
+ label = kw.value.value
61
+ break
62
+
63
+ results.append({
64
+ 'start_line': node.lineno,
65
+ 'end_line': node.end_lineno or node.lineno,
66
+ 'data_var': data_var,
67
+ 'label': label,
68
+ })
69
+ return results
70
+
71
+
72
+ def _match_hist_data_vars(
73
+ calls: list[dict], infos: list[dict],
74
+ ) -> list[str]:
75
+ """Match extracted hist calls to hist_info entries, return data var names.
76
+
77
+ Tries to match by label first; falls back to positional order.
78
+ """
79
+ info_labels = [hi.get('label', '') for hi in infos]
80
+ matched: list[str | None] = [None] * len(infos)
81
+
82
+ # Pass 1: match by label
83
+ used_calls: set[int] = set()
84
+ for i, il in enumerate(info_labels):
85
+ for j, c in enumerate(calls):
86
+ if j not in used_calls and c.get('label') == il and il:
87
+ matched[i] = c['data_var']
88
+ used_calls.add(j)
89
+ break
90
+
91
+ # Pass 2: fill remaining by positional order
92
+ unused = [c for j, c in enumerate(calls) if j not in used_calls]
93
+ ui = 0
94
+ for i in range(len(matched)):
95
+ if matched[i] is None and ui < len(unused):
96
+ matched[i] = unused[ui]['data_var']
97
+ ui += 1
98
+
99
+ return [m if m is not None else f'<data_{i}>' for i, m in enumerate(matched)]
100
+
101
+
102
+ def _extract_plot_calls(source: str, method: str) -> list[dict]:
103
+ """Extract .boxplot() / .violinplot() call info from cell source via AST.
104
+
105
+ Returns list of dicts with keys:
106
+ start_line – 1-indexed first line of the call
107
+ end_line – 1-indexed last line of the call
108
+ data_var – source text of the first positional argument
109
+ result_var – assignment target name (e.g. 'bp' from 'bp = ax.boxplot(...)')
110
+ label – string label if a label=... kwarg is present
111
+ """
112
+ import ast
113
+
114
+ try:
115
+ tree = ast.parse(source)
116
+ except SyntaxError:
117
+ return []
118
+
119
+ results: list[dict] = []
120
+ # Build a map from Call node id to the enclosing Assign target
121
+ assign_map: dict[int, str] = {}
122
+ for node in ast.walk(tree):
123
+ if isinstance(node, ast.Assign) and len(node.targets) == 1:
124
+ target = node.targets[0]
125
+ if isinstance(target, ast.Name) and isinstance(node.value, ast.Call):
126
+ assign_map[id(node.value)] = target.id
127
+
128
+ for node in ast.walk(tree):
129
+ if not isinstance(node, ast.Call):
130
+ continue
131
+ func = node.func
132
+ if not (isinstance(func, ast.Attribute) and func.attr == method):
133
+ continue
134
+ if not node.args:
135
+ continue
136
+
137
+ data_var = ast.get_source_segment(source, node.args[0])
138
+ if data_var is None:
139
+ continue
140
+
141
+ # Try to extract label keyword
142
+ label = None
143
+ for kw in node.keywords:
144
+ if kw.arg == 'label' and isinstance(kw.value, ast.Constant):
145
+ label = kw.value.value
146
+ break
147
+
148
+ result_var = assign_map.get(id(node))
149
+
150
+ results.append({
151
+ 'start_line': node.lineno,
152
+ 'end_line': node.end_lineno or node.lineno,
153
+ 'data_var': data_var,
154
+ 'result_var': result_var,
155
+ 'label': label,
156
+ })
157
+ return results
158
+
159
+
160
+ def _find_dependent_lines(source: str, var_name: str) -> set[int]:
161
+ """Find all lines that reference *var_name* (e.g. bp['boxes']).
162
+
163
+ Returns a set of 1-indexed line numbers.
164
+ """
165
+ import ast
166
+
167
+ try:
168
+ tree = ast.parse(source)
169
+ except SyntaxError:
170
+ return set()
171
+
172
+ dep_lines: set[int] = set()
173
+ for node in ast.walk(tree):
174
+ if isinstance(node, ast.Name) and node.id == var_name:
175
+ # Skip the assignment line itself (handled by caller)
176
+ dep_lines.add(node.lineno)
177
+ # Also grab end_lineno for multiline statements
178
+ parent = _find_stmt_parent(tree, node)
179
+ if parent is not None:
180
+ for ln in range(parent.lineno,
181
+ (parent.end_lineno or parent.lineno) + 1):
182
+ dep_lines.add(ln)
183
+ return dep_lines
184
+
185
+
186
+ def _find_stmt_parent(tree, target_node):
187
+ """Find the statement-level parent of a node in the AST."""
188
+ import ast
189
+
190
+ for node in ast.walk(tree):
191
+ for child in ast.iter_child_nodes(node):
192
+ if child is target_node:
193
+ # Return the statement-level node
194
+ if isinstance(node, ast.Module):
195
+ return target_node
196
+ return node
197
+ # Check deeper: the target may be nested
198
+ if _node_contains(child, target_node):
199
+ if isinstance(child, ast.stmt):
200
+ return child
201
+ return None
202
+
203
+
204
+ def _node_contains(parent, target):
205
+ """Check if target is anywhere inside parent's subtree."""
206
+ import ast
207
+ for node in ast.walk(parent):
208
+ if node is target:
209
+ return True
210
+ return False
211
+
212
+
213
+ def _unique_path(path: Path) -> Path:
214
+ """If path exists, append (1), (2), ... until it doesn't."""
215
+ if not path.exists():
216
+ return path
217
+ stem = path.stem
218
+ suffix = path.suffix
219
+ parent = path.parent
220
+ i = 1
221
+ while True:
222
+ candidate = parent / f"{stem}({i}){suffix}"
223
+ if not candidate.exists():
224
+ return candidate
225
+ i += 1
226
+
227
+
228
+ class PlotBuildSession:
229
+ """Orchestrates the interactive editing UI for a matplotlib Figure."""
230
+
231
+ SIDEBAR_WIDTH = "380px"
232
+
233
+ def __init__(self, fig: Figure, cell_source: str | None = None):
234
+ self._fig = fig
235
+ self._cell_source = cell_source
236
+ self._canvas = CanvasManager(fig)
237
+ self._stack = CommandStack(on_change=self._on_stack_change)
238
+
239
+ # Introspect
240
+ introspector = FigureIntrospector(fig)
241
+ self._groups = introspector.introspect()
242
+
243
+ # Toolbar buttons
244
+ _tb = widgets.Layout(height="30px")
245
+ self._undo_btn = widgets.Button(description="Undo", disabled=True,
246
+ icon="undo",
247
+ layout=widgets.Layout(width="80px", height="30px"))
248
+ self._undo_btn.style.button_color = "#f5f5f5"
249
+ self._redo_btn = widgets.Button(description="Redo", disabled=True,
250
+ icon="repeat",
251
+ layout=widgets.Layout(width="80px", height="30px"))
252
+ self._redo_btn.style.button_color = "#f5f5f5"
253
+ self._save_btn = widgets.Button(description="Save",
254
+ icon="download",
255
+ layout=widgets.Layout(width="80px", height="30px"))
256
+ self._save_btn.style.button_color = "#e8eaf6"
257
+ self._apply_btn = widgets.Button(description="Apply",
258
+ icon="check",
259
+ layout=widgets.Layout(width="80px", height="30px"),
260
+ tooltip="Apply changes, generate code, and close")
261
+ self._apply_btn.style.button_color = "#c8e6c9"
262
+ self._close_btn = widgets.Button(description="Close",
263
+ icon="times",
264
+ layout=widgets.Layout(width="80px", height="30px"),
265
+ tooltip="Revert all changes and close")
266
+ self._close_btn.style.button_color = "#ffcdd2"
267
+
268
+ # Save dialog (hidden until Save is clicked)
269
+ self._save_dialog = self._build_save_dialog()
270
+
271
+ self._undo_btn.on_click(self._on_undo)
272
+ self._redo_btn.on_click(self._on_redo)
273
+ self._save_btn.on_click(self._on_save_click)
274
+ self._apply_btn.on_click(self._close_apply)
275
+ self._close_btn.on_click(self._close_discard_direct)
276
+
277
+ # Code output area that persists after the editor closes
278
+ self._applied_code = widgets.Textarea(
279
+ value="",
280
+ layout=widgets.Layout(width="100%", height="300px"),
281
+ )
282
+ self._applied_status = widgets.HTML("")
283
+ # Hidden Output widget for executing JavaScript (cell insertion)
284
+ self._js_output = widgets.Output(
285
+ layout=widgets.Layout(height="0px", overflow="hidden"))
286
+ # Copy + Clear buttons (grouped together)
287
+ self._copy_btn = widgets.Button(
288
+ description="Copy", icon="clipboard",
289
+ button_style="info", layout=widgets.Layout(width="75px"))
290
+ self._copy_btn.on_click(self._copy_code_to_clipboard)
291
+ self._clear_btn = widgets.Button(
292
+ description="Clear", icon="times",
293
+ button_style="", layout=widgets.Layout(width="75px"))
294
+ self._clear_btn.on_click(lambda _: self._applied_box.close())
295
+ self._applied_box = widgets.VBox(
296
+ [widgets.HBox([self._applied_status,
297
+ widgets.HBox([self._copy_btn, self._clear_btn],
298
+ layout=widgets.Layout(gap="4px"))],
299
+ layout=widgets.Layout(justify_content="space-between",
300
+ align_items="center")),
301
+ self._applied_code, self._js_output],
302
+ layout=widgets.Layout(display="none"),
303
+ )
304
+
305
+ self._container = self._assemble()
306
+ # Outer wrapper: editor + persistent code output
307
+ self._outer = widgets.VBox([self._container, self._applied_box])
308
+
309
+ def display(self) -> None:
310
+ display(self._outer)
311
+
312
+ # ------------------------------------------------------------------
313
+ # Save dialog
314
+ # ------------------------------------------------------------------
315
+
316
+ def _build_save_dialog(self) -> widgets.VBox:
317
+ self._save_name = widgets.Text(value="figure", description="Filename:",
318
+ placeholder="figure",
319
+ style={"description_width": "70px"})
320
+ self._save_fmt = widgets.Dropdown(
321
+ options=["pdf", "png", "svg", "jpg"],
322
+ value="pdf",
323
+ description="Format:",
324
+ style={"description_width": "70px"},
325
+ )
326
+ self._save_dpi = widgets.IntText(value=150, description="DPI:",
327
+ style={"description_width": "70px"},
328
+ layout=widgets.Layout(width="160px"))
329
+ self._save_dir = widgets.Text(value=os.getcwd(), description="Directory:",
330
+ style={"description_width": "70px"},
331
+ layout=widgets.Layout(width="100%"))
332
+ self._save_confirm = widgets.Button(description="Save File",
333
+ button_style="success",
334
+ layout=widgets.Layout(width="100px"))
335
+ self._save_cancel = widgets.Button(description="Cancel",
336
+ layout=widgets.Layout(width="80px"))
337
+ self._save_status = widgets.HTML(value="")
338
+
339
+ self._save_confirm.on_click(self._do_save)
340
+ self._save_cancel.on_click(lambda _: self._hide_save_dialog())
341
+
342
+ dialog = widgets.VBox([
343
+ widgets.HTML("<b>Save Figure</b>"),
344
+ self._save_name,
345
+ self._save_fmt,
346
+ self._save_dpi,
347
+ self._save_dir,
348
+ widgets.HBox([self._save_confirm, self._save_cancel]),
349
+ self._save_status,
350
+ ], layout=widgets.Layout(
351
+ border="1px solid #ccc",
352
+ padding="8px",
353
+ margin="4px 0",
354
+ display="none", # hidden initially
355
+ ))
356
+ return dialog
357
+
358
+ def _show_save_dialog(self) -> None:
359
+ self._save_dialog.layout.display = ""
360
+ self._save_status.value = ""
361
+
362
+ def _hide_save_dialog(self) -> None:
363
+ self._save_dialog.layout.display = "none"
364
+
365
+ def _do_save(self, _btn: Any) -> None:
366
+ name = self._save_name.value.strip() or "figure"
367
+ fmt = self._save_fmt.value
368
+ dpi = self._save_dpi.value
369
+ directory = Path(self._save_dir.value.strip() or os.getcwd())
370
+
371
+ if not directory.is_dir():
372
+ self._save_status.value = (
373
+ "<span style='color:red'>Directory does not exist.</span>")
374
+ return
375
+
376
+ path = _unique_path(directory / f"{name}.{fmt}")
377
+ try:
378
+ # Collect extra artists (outside legends) for tight bbox
379
+ extra = []
380
+ for ax in self._fig.get_axes():
381
+ leg = ax.get_legend()
382
+ if leg is not None:
383
+ extra.append(leg)
384
+ try:
385
+ self._fig.tight_layout()
386
+ except Exception:
387
+ pass
388
+ # Reposition marginal histograms after layout change
389
+ for mgr in getattr(self._fig, '_matplotly_marginal_managers', []):
390
+ mgr._rebuild()
391
+ self._fig.savefig(str(path), dpi=dpi, bbox_inches="tight",
392
+ bbox_extra_artists=extra or None,
393
+ pad_inches=0.2,
394
+ facecolor=self._fig.get_facecolor())
395
+ self._save_status.value = (
396
+ f"<span style='color:green'>Saved to <code>{path}</code></span>")
397
+ except Exception as e:
398
+ self._save_status.value = (
399
+ f"<span style='color:red'>Error: {e}</span>")
400
+
401
+ # ------------------------------------------------------------------
402
+ # Real data injection for distribution panels
403
+ # ------------------------------------------------------------------
404
+
405
+ def _inject_real_dist_data(self, dist_panels):
406
+ """Replace reconstructed ~100-point data with real user data.
407
+
408
+ Parses the cell source to find boxplot()/violinplot() data variable
409
+ names, resolves them from the IPython user namespace, and swaps in the
410
+ actual arrays so jitter/violin rendering uses full-fidelity data.
411
+ """
412
+ import numpy as np
413
+
414
+ try:
415
+ from IPython import get_ipython
416
+ ip = get_ipython()
417
+ if ip is None:
418
+ return
419
+ user_ns = ip.user_ns
420
+ except Exception:
421
+ return
422
+
423
+ bp_calls = _extract_plot_calls(self._cell_source, 'boxplot')
424
+ vp_calls = _extract_plot_calls(self._cell_source, 'violinplot')
425
+ calls = bp_calls + vp_calls
426
+ if not calls:
427
+ return
428
+
429
+ # Resolve each call's data variable from user namespace
430
+ resolved = []
431
+ for call in calls:
432
+ try:
433
+ val = eval(call['data_var'], user_ns) # noqa: S307
434
+ except Exception:
435
+ resolved.append(None)
436
+ continue
437
+ # Normalise to list-of-arrays
438
+ if isinstance(val, np.ndarray):
439
+ if val.ndim == 2:
440
+ val = [val[:, i] for i in range(val.shape[1])]
441
+ elif val.ndim == 1:
442
+ val = [val]
443
+ if isinstance(val, (list, tuple)):
444
+ try:
445
+ arrays = [np.asarray(d, dtype=float) for d in val]
446
+ resolved.append(arrays)
447
+ except Exception:
448
+ resolved.append(None)
449
+ else:
450
+ resolved.append(None)
451
+
452
+ # Match resolved data to panels
453
+ if len(resolved) == len(dist_panels):
454
+ # One call per panel — direct mapping
455
+ for data, panel in zip(resolved, dist_panels):
456
+ if data is not None and len(data) == len(panel._raw_data):
457
+ panel._raw_data = data
458
+ elif len(resolved) == 1 and resolved[0] is not None:
459
+ # Single call, possibly split across multiple panels
460
+ data = resolved[0]
461
+ total_boxes = sum(len(p._raw_data) for p in dist_panels)
462
+ if len(data) == total_boxes:
463
+ idx = 0
464
+ for panel in dist_panels:
465
+ n = len(panel._raw_data)
466
+ panel._raw_data = data[idx:idx + n]
467
+ idx += n
468
+
469
+ # ------------------------------------------------------------------
470
+ # Close actions
471
+ # ------------------------------------------------------------------
472
+
473
+ def _copy_code_to_clipboard(self, _btn: Any) -> None:
474
+ """Copy the generated code to clipboard via JavaScript."""
475
+ import json
476
+ code = self._applied_code.value
477
+ js = "(function(){navigator.clipboard.writeText(%s)})();" % json.dumps(code)
478
+ with self._js_output:
479
+ self._js_output.clear_output()
480
+ from IPython.display import display as ipy_display, Javascript
481
+ ipy_display(Javascript(js))
482
+
483
+ def _close_apply(self, _btn: Any) -> None:
484
+ """Close editor, replace original cell with generated code."""
485
+ import json
486
+ import re
487
+
488
+ # --- Merged-histogram preprocessing: extract data variable names
489
+ # from the original source and store on axes before code gen. -------
490
+ hist_comment_lines: set[int] = set() # 1-indexed line numbers
491
+ if self._cell_source:
492
+ all_axes = self._fig.get_axes()
493
+ main_axes = [a for a in all_axes
494
+ if not getattr(a, '_matplotly_marginal', False)
495
+ and not hasattr(a, '_colorbar')]
496
+ for ax in main_axes:
497
+ if not getattr(ax, '_matplotly_hist_merged', False):
498
+ continue
499
+ calls = _extract_hist_calls(self._cell_source)
500
+ if calls:
501
+ infos = getattr(ax, '_matplotly_hist_info', [])
502
+ data_vars = _match_hist_data_vars(calls, infos)
503
+ ax._matplotly_hist_data_vars = data_vars
504
+ for c in calls:
505
+ for ln in range(c['start_line'],
506
+ c['end_line'] + 1):
507
+ hist_comment_lines.add(ln)
508
+
509
+ # --- Distribution preprocessing: extract boxplot/violinplot
510
+ # data variable names from the original source. -------
511
+ for ax in main_axes:
512
+ dist_infos = getattr(ax, '_matplotly_dist_info', [])
513
+ if not dist_infos:
514
+ continue
515
+ bp_calls = _extract_plot_calls(self._cell_source, 'boxplot')
516
+ vp_calls = _extract_plot_calls(
517
+ self._cell_source, 'violinplot')
518
+ calls = bp_calls + vp_calls
519
+ if calls:
520
+ data_vars = [c['data_var'] for c in calls]
521
+ ax._matplotly_dist_data_vars = data_vars
522
+ # Comment out call lines
523
+ for c in calls:
524
+ for ln in range(c['start_line'],
525
+ c['end_line'] + 1):
526
+ hist_comment_lines.add(ln)
527
+ # Comment out result variable references
528
+ for c in calls:
529
+ if c.get('result_var'):
530
+ dep_lines = _find_dependent_lines(
531
+ self._cell_source, c['result_var'])
532
+ # Remove the assignment line itself (already
533
+ # covered by call lines)
534
+ dep_lines -= set(range(c['start_line'],
535
+ c['end_line'] + 1))
536
+ hist_comment_lines.update(dep_lines)
537
+
538
+ modifications = generate_code(self._fig, self._stack)
539
+
540
+ # Build combined code: original (minus matplotly call,
541
+ # with merged-hist calls commented out) + modifications
542
+ if self._cell_source:
543
+ src_lines = self._cell_source.splitlines()
544
+ cleaned = []
545
+ for i, line in enumerate(src_lines, 1): # 1-indexed
546
+ stripped = line.strip()
547
+ if re.match(r'^matplotly\s*\(', stripped):
548
+ continue
549
+ if i in hist_comment_lines:
550
+ cleaned.append('# ' + line)
551
+ else:
552
+ cleaned.append(line)
553
+ original = "\n".join(cleaned).rstrip()
554
+ combined = original + "\n\n" + modifications
555
+ else:
556
+ combined = modifications
557
+
558
+ # Show fallback textarea (dismissible) + try replacing original cell
559
+ self._applied_code.value = combined
560
+ self._applied_status.value = (
561
+ "<b>Generated Code</b> "
562
+ "<small style='color:#666'>"
563
+ "(replaced in cell above — also copied to clipboard)</small>")
564
+ self._applied_box.layout.display = ""
565
+ self._container.close()
566
+
567
+ # A snippet from the original cell source to identify which cell to
568
+ # replace. We use the first 80 characters to keep the JS short.
569
+ cell_match = ""
570
+ if self._cell_source:
571
+ cell_match = self._cell_source[:80]
572
+
573
+ # JavaScript: replace the originating cell's source, then clipboard
574
+ js_code = """
575
+ (function() {
576
+ var code = %s;
577
+ var match = %s;
578
+ var replaced = false;
579
+
580
+ // Classic Jupyter Notebook (< 7)
581
+ if (typeof Jupyter !== 'undefined' && Jupyter.notebook) {
582
+ try {
583
+ var cells = Jupyter.notebook.get_cells();
584
+ for (var i = 0; i < cells.length; i++) {
585
+ if (cells[i].cell_type === 'code' &&
586
+ cells[i].get_text().indexOf(match) !== -1) {
587
+ cells[i].set_text(code);
588
+ replaced = true;
589
+ break;
590
+ }
591
+ }
592
+ } catch(e) {}
593
+ }
594
+
595
+ // JupyterLab / Notebook 7+
596
+ if (!replaced) {
597
+ try {
598
+ var nb = window.jupyterapp &&
599
+ window.jupyterapp.shell.currentWidget;
600
+ if (nb && nb.content && nb.content.model) {
601
+ var sm = nb.content.model.sharedModel;
602
+ if (sm && sm.cells) {
603
+ for (var i = 0; i < sm.cells.length; i++) {
604
+ var c = sm.cells[i];
605
+ if (c.cell_type === 'code' &&
606
+ c.getSource().indexOf(match) !== -1) {
607
+ c.setSource(code);
608
+ replaced = true;
609
+ break;
610
+ }
611
+ }
612
+ }
613
+ }
614
+ } catch(e) {}
615
+ }
616
+
617
+ // Always copy to clipboard as fallback
618
+ if (navigator.clipboard) {
619
+ navigator.clipboard.writeText(code).catch(function(){});
620
+ }
621
+ })();
622
+ """ % (json.dumps(combined), json.dumps(cell_match))
623
+
624
+ with self._js_output:
625
+ self._js_output.clear_output()
626
+ from IPython.display import display as ipy_display, Javascript
627
+ ipy_display(Javascript(js_code))
628
+
629
+ def _close_discard_direct(self, _btn: Any) -> None:
630
+ """Undo all changes (revert to original figure) and close."""
631
+ while self._stack.can_undo:
632
+ self._stack.undo()
633
+ self._canvas.redraw()
634
+ self._outer.close()
635
+
636
+ # ------------------------------------------------------------------
637
+ # UI assembly
638
+ # ------------------------------------------------------------------
639
+
640
+ def _assemble(self) -> widgets.VBox:
641
+ toolbar = widgets.HBox(
642
+ [self._undo_btn, self._redo_btn,
643
+ self._save_btn, self._apply_btn, self._close_btn],
644
+ layout=widgets.Layout(padding="4px", gap="4px"),
645
+ )
646
+
647
+ sidebar_sections = self._build_sidebar_sections()
648
+ sidebar = widgets.VBox(
649
+ sidebar_sections,
650
+ layout=widgets.Layout(
651
+ width=self.SIDEBAR_WIDTH,
652
+ min_width=self.SIDEBAR_WIDTH,
653
+ overflow_y="auto",
654
+ overflow_x="auto",
655
+ max_height="700px",
656
+ padding="4px",
657
+ ),
658
+ )
659
+
660
+ canvas_box = widgets.VBox(
661
+ [self._canvas.widget, self._save_dialog],
662
+ layout=widgets.Layout(flex="1 1 auto"),
663
+ )
664
+
665
+ main = widgets.HBox(
666
+ [sidebar, canvas_box],
667
+ layout=widgets.Layout(width="100%"),
668
+ )
669
+
670
+ return widgets.VBox([toolbar, main])
671
+
672
+ def _build_sidebar_sections(self) -> list[widgets.Widget]:
673
+ sections: list[widgets.Widget] = []
674
+
675
+ # Create all artist panels first, collecting typed panel lists
676
+ main_axes = [a for a in self._fig.get_axes()
677
+ if not getattr(a, '_matplotly_marginal', False)
678
+ and not hasattr(a, '_colorbar')]
679
+ multi_subplot = len(main_axes) > 1
680
+ line_panels: list = []
681
+ scatter_panels: list = []
682
+ histogram_panels: list = []
683
+ bar_panels: list = []
684
+ dist_panels: list = []
685
+ heatmap_panels: list = []
686
+ errorbar_panels: list = []
687
+ line_counter = 0
688
+ scatter_counter = 0
689
+ histogram_counter = 0
690
+ bar_counter = 0
691
+ dist_counter = 0
692
+ heatmap_counter = 0
693
+ errorbar_counter = 0
694
+
695
+ subplot_panels: dict[tuple[int, int], list[widgets.Widget]] = {}
696
+ for group in self._groups:
697
+ panel = create_panel(group, self._stack, self._canvas)
698
+ if panel is None:
699
+ continue
700
+ if group.plot_type == PlotType.LINE:
701
+ line_counter += 1
702
+ panel._plot_number = line_counter
703
+ _ = panel.widget
704
+ line_panels.append(panel)
705
+ elif group.plot_type == PlotType.SCATTER:
706
+ scatter_counter += 1
707
+ panel._plot_number = scatter_counter
708
+ _ = panel.widget
709
+ scatter_panels.append(panel)
710
+ elif group.plot_type == PlotType.HISTOGRAM:
711
+ histogram_counter += 1
712
+ panel._plot_number = histogram_counter
713
+ _ = panel.widget
714
+ panel._store_hist_info()
715
+ histogram_panels.append(panel)
716
+ elif group.plot_type in (PlotType.BAR, PlotType.GROUPED_BAR):
717
+ bar_counter += 1
718
+ panel._plot_number = bar_counter
719
+ _ = panel.widget
720
+ panel._store_bar_info()
721
+ bar_panels.append(panel)
722
+ elif group.plot_type in (PlotType.BOXPLOT, PlotType.VIOLIN):
723
+ dist_counter += 1
724
+ panel._plot_number = dist_counter
725
+ _ = panel.widget
726
+ panel._store_dist_info()
727
+ dist_panels.append(panel)
728
+ elif group.plot_type == PlotType.HEATMAP:
729
+ heatmap_counter += 1
730
+ panel._plot_number = heatmap_counter
731
+ _ = panel.widget
732
+ panel._store_heatmap_info()
733
+ heatmap_panels.append(panel)
734
+ elif group.plot_type == PlotType.ERRORBAR:
735
+ errorbar_counter += 1
736
+ panel._plot_number = errorbar_counter
737
+ _ = panel.widget
738
+ panel._store_errorbar_info()
739
+ errorbar_panels.append(panel)
740
+ key = group.subplot_index
741
+ subplot_panels.setdefault(key, []).append(panel.widget)
742
+
743
+ # Inject real data from user namespace into distribution panels
744
+ if dist_panels and self._cell_source:
745
+ self._inject_real_dist_data(dist_panels)
746
+
747
+ # Build shared histogram controls — one per subplot
748
+ if histogram_panels:
749
+ hist_by_subplot: dict[tuple[int, int], list] = {}
750
+ for p in histogram_panels:
751
+ hist_by_subplot.setdefault(
752
+ p._group.subplot_index, []).append(p)
753
+ for sp_key, sp_panels in hist_by_subplot.items():
754
+ shared_hist = HistogramSharedPanel(sp_panels, self._canvas)
755
+ shared_hist_widget = shared_hist.build()
756
+ existing = subplot_panels.get(sp_key, [])
757
+ first_hw = sp_panels[0].widget
758
+ idx = 0
759
+ for j, w in enumerate(existing):
760
+ if w is first_hw:
761
+ idx = j
762
+ break
763
+ existing.insert(idx, shared_hist_widget)
764
+ subplot_panels[sp_key] = existing
765
+
766
+ # Build shared bar controls — one per subplot
767
+ if bar_panels:
768
+ from .panels._bar import BarSharedPanel
769
+ bar_by_subplot: dict[tuple[int, int], list] = {}
770
+ for p in bar_panels:
771
+ bar_by_subplot.setdefault(
772
+ p._group.subplot_index, []).append(p)
773
+ for sp_key, sp_panels in bar_by_subplot.items():
774
+ shared_bar = BarSharedPanel(sp_panels, self._canvas)
775
+ shared_bar_widget = shared_bar.build()
776
+ existing = subplot_panels.get(sp_key, [])
777
+ first_bw = sp_panels[0].widget
778
+ idx = 0
779
+ for j, w in enumerate(existing):
780
+ if w is first_bw:
781
+ idx = j
782
+ break
783
+ existing.insert(idx, shared_bar_widget)
784
+ subplot_panels[sp_key] = existing
785
+
786
+ # Build shared distribution controls — one per subplot
787
+ if dist_panels:
788
+ from .panels._distribution import DistributionSharedPanel
789
+ dist_by_subplot: dict[tuple[int, int], list] = {}
790
+ for p in dist_panels:
791
+ dist_by_subplot.setdefault(
792
+ p._group.subplot_index, []).append(p)
793
+ for sp_key, sp_panels in dist_by_subplot.items():
794
+ types = {p._group.plot_type for p in sp_panels}
795
+ if PlotType.BOXPLOT in types and PlotType.VIOLIN in types:
796
+ initial_mode = "box+violin"
797
+ elif PlotType.VIOLIN in types:
798
+ initial_mode = "violin"
799
+ else:
800
+ initial_mode = "box"
801
+ shared_dist = DistributionSharedPanel(
802
+ sp_panels, self._canvas, initial_mode=initial_mode)
803
+ shared_dist_widget = shared_dist.build()
804
+ existing = subplot_panels.get(sp_key, [])
805
+ first_dw = sp_panels[0].widget
806
+ idx = 0
807
+ for j, w in enumerate(existing):
808
+ if w is first_dw:
809
+ idx = j
810
+ break
811
+ existing.insert(idx, shared_dist_widget)
812
+ subplot_panels[sp_key] = existing
813
+
814
+ # Build shared heatmap colorbar controls — one per subplot
815
+ if heatmap_panels:
816
+ from .panels._heatmap import HeatmapSharedPanel
817
+ heatmap_by_subplot: dict[tuple[int, int], list] = {}
818
+ for p in heatmap_panels:
819
+ heatmap_by_subplot.setdefault(
820
+ p._group.subplot_index, []).append(p)
821
+ for sp_key, sp_panels in heatmap_by_subplot.items():
822
+ shared_heatmap = HeatmapSharedPanel(sp_panels, self._canvas)
823
+ shared_heatmap_widget = shared_heatmap.build()
824
+ existing = subplot_panels.get(sp_key, [])
825
+ first_hw = sp_panels[0].widget
826
+ idx = 0
827
+ for j, w in enumerate(existing):
828
+ if w is first_hw:
829
+ idx = j
830
+ break
831
+ existing.insert(idx, shared_heatmap_widget)
832
+ subplot_panels[sp_key] = existing
833
+
834
+ # Build colormap panel (or None) to embed inside Global
835
+ cmap_widget = None
836
+ cmap_panel_ref = None
837
+ all_color_panels = (line_panels + scatter_panels + histogram_panels
838
+ + bar_panels + dist_panels + errorbar_panels)
839
+ if all_color_panels:
840
+ cmap_panel_ref = ColormapPanel(self._groups, self._stack, self._canvas,
841
+ line_panels=all_color_panels)
842
+ cmap_widget = cmap_panel_ref.build()
843
+
844
+ # Global panel (includes colormap if lines exist)
845
+ global_panel = GlobalPanel(self._fig, self._stack, self._canvas,
846
+ colormap_widget=cmap_widget,
847
+ cmap_panel=cmap_panel_ref,
848
+ multi_subplot=multi_subplot)
849
+ global_acc = widgets.Accordion(children=[global_panel.build()])
850
+ global_acc.set_title(0, "Global")
851
+ global_acc.selected_index = None
852
+ sections.append(global_acc)
853
+
854
+ def _type_display(name: str) -> str:
855
+ return "Plot" if name == "Line" else name
856
+
857
+ if multi_subplot:
858
+ # Build subplot_index → Axes mapping
859
+ nrows, ncols = FigureIntrospector._grid_shape(main_axes)
860
+ subplot_axes = {}
861
+ for idx, ax in enumerate(main_axes):
862
+ r, c = divmod(idx, max(ncols, 1))
863
+ subplot_axes[(r, c)] = ax
864
+
865
+ per_subplot_refs: dict[tuple[int, int], PerSubplotPanel] = {}
866
+
867
+ for (r, c), panels in sorted(subplot_panels.items()):
868
+ ax = subplot_axes.get((r, c))
869
+ if ax is None:
870
+ continue
871
+
872
+ # Create PerSubplotPanel for this axes
873
+ psp = PerSubplotPanel(ax, self._fig, self._stack, self._canvas,
874
+ cmap_panel=cmap_panel_ref)
875
+ psp_widget = psp.build()
876
+ per_subplot_refs[(r, c)] = psp
877
+
878
+ # Wire label-change callbacks for this subplot's artist panels
879
+ for panel in (line_panels + scatter_panels + histogram_panels
880
+ + bar_panels + dist_panels
881
+ + errorbar_panels):
882
+ if panel._group.subplot_index == (r, c):
883
+ panel._on_label_changed = psp._refresh_legend_labels
884
+
885
+ # Assemble: PerSubplotPanel first, then artist panels
886
+ all_widgets = [psp_widget] + panels
887
+ types_in_subplot = set()
888
+ for g in self._groups:
889
+ if g.subplot_index == (r, c):
890
+ types_in_subplot.add(_type_display(
891
+ g.plot_type.name.replace("_", " ").title()))
892
+ type_str = ", ".join(sorted(types_in_subplot))
893
+ title = f"Subplot ({r+1},{c+1}): {type_str}"
894
+ acc = widgets.Accordion(children=[widgets.VBox(all_widgets)])
895
+ acc.set_title(0, title)
896
+ acc.selected_index = None
897
+ sections.append(acc)
898
+
899
+ # Store per-subplot refs on GlobalPanel for Preferred Defaults
900
+ global_panel._per_subplot_panels = per_subplot_refs
901
+ else:
902
+ # Wire label-change callbacks to global legend
903
+ for panel in (line_panels + scatter_panels + histogram_panels
904
+ + bar_panels + dist_panels
905
+ + errorbar_panels):
906
+ panel._on_label_changed = global_panel._refresh_legend_labels
907
+
908
+ all_panels = []
909
+ for panels in subplot_panels.values():
910
+ all_panels.extend(panels)
911
+ if all_panels:
912
+ types_present = set()
913
+ for g in self._groups:
914
+ types_present.add(_type_display(
915
+ g.plot_type.name.replace("_", " ").title()))
916
+ type_str = ", ".join(sorted(types_present))
917
+ acc = widgets.Accordion(children=[widgets.VBox(all_panels)])
918
+ acc.set_title(0, type_str)
919
+ acc.selected_index = None
920
+ sections.append(acc)
921
+
922
+ # Marginal histograms (only when scatter collections exist)
923
+ scatter_colls_by_ax: dict[int, tuple] = {}
924
+ for group in self._groups:
925
+ if group.plot_type == PlotType.SCATTER:
926
+ ax = group.axes
927
+ ax_id = id(ax)
928
+ if ax_id not in scatter_colls_by_ax:
929
+ scatter_colls_by_ax[ax_id] = (ax, [])
930
+ scatter_colls_by_ax[ax_id][1].append(group.artists[0])
931
+
932
+ for ax_id, (ax, colls) in scatter_colls_by_ax.items():
933
+ mgr = MarginalHistogramManager(
934
+ self._fig, ax, colls, self._stack, self._canvas)
935
+ marginal_w = mgr.build_widget()
936
+ marginal_acc = widgets.Accordion(children=[marginal_w])
937
+ marginal_acc.set_title(0, "Marginal Histograms")
938
+ marginal_acc.selected_index = None
939
+ sections.append(marginal_acc)
940
+ # Wire color sync from scatter panels to marginal manager
941
+ for panel in scatter_panels:
942
+ coll = panel._group.artists[0]
943
+ if id(coll.axes) == ax_id:
944
+ panel._marginals = mgr
945
+
946
+ # Legend (separate section — only for single-subplot)
947
+ if not multi_subplot:
948
+ legend_acc = widgets.Accordion(children=[global_panel._legend_widget])
949
+ legend_acc.set_title(0, "Legend")
950
+ legend_acc.selected_index = None
951
+ sections.append(legend_acc)
952
+
953
+ # Profiles
954
+ profiles_panel = create_profiles_panel(global_panel, self._canvas)
955
+ profiles_acc = widgets.Accordion(children=[profiles_panel])
956
+ profiles_acc.set_title(0, "Profiles")
957
+ profiles_acc.selected_index = None
958
+ sections.append(profiles_acc)
959
+
960
+ return sections
961
+
962
+ # ------------------------------------------------------------------
963
+ # Callbacks
964
+ # ------------------------------------------------------------------
965
+
966
+ def _on_stack_change(self) -> None:
967
+ self._undo_btn.disabled = not self._stack.can_undo
968
+ self._redo_btn.disabled = not self._stack.can_redo
969
+
970
+ def _on_undo(self, _btn: Any) -> None:
971
+ self._stack.undo()
972
+ self._canvas.redraw()
973
+
974
+ def _on_redo(self, _btn: Any) -> None:
975
+ self._stack.redo()
976
+ self._canvas.redraw()
977
+
978
+ def _on_save_click(self, _btn: Any) -> None:
979
+ # Toggle save dialog visibility
980
+ if self._save_dialog.layout.display == "none":
981
+ self._show_save_dialog()
982
+ else:
983
+ self._hide_save_dialog()
984
+