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/__init__.py +124 -0
- matplotly/_api.py +984 -0
- matplotly/_code_gen.py +1793 -0
- matplotly/_commands.py +109 -0
- matplotly/_introspect.py +1197 -0
- matplotly/_profiles.py +241 -0
- matplotly/_renderer.py +79 -0
- matplotly/_style_import.py +155 -0
- matplotly/_types.py +31 -0
- matplotly/panels/__init__.py +37 -0
- matplotly/panels/_bar.py +788 -0
- matplotly/panels/_base.py +38 -0
- matplotly/panels/_color_utils.py +221 -0
- matplotly/panels/_distribution.py +1605 -0
- matplotly/panels/_errorbar.py +652 -0
- matplotly/panels/_fill.py +90 -0
- matplotly/panels/_global.py +1507 -0
- matplotly/panels/_heatmap.py +898 -0
- matplotly/panels/_histogram.py +938 -0
- matplotly/panels/_line.py +709 -0
- matplotly/panels/_marginal.py +944 -0
- matplotly/panels/_scatter.py +428 -0
- matplotly/panels/_subplot.py +846 -0
- matplotly-0.1.0.dist-info/METADATA +120 -0
- matplotly-0.1.0.dist-info/RECORD +27 -0
- matplotly-0.1.0.dist-info/WHEEL +4 -0
- matplotly-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
+
|