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.
@@ -0,0 +1,944 @@
1
+ """Marginal histogram controls — figure-level panel for all scatter collections.
2
+
3
+ Each marginal (X and Y) has its own independent toggle and full parameter set:
4
+ bins, height, alpha, pad, tick controls (side, fontsize, step, range),
5
+ label (text, fontsize, bold, italic, color), title (text, fontsize, bold,
6
+ italic, color), and individual spine checkboxes.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import ipywidgets as widgets
11
+ import numpy as np
12
+ from matplotlib.colors import to_hex
13
+ from matplotlib.ticker import AutoLocator, MultipleLocator
14
+
15
+ from .._commands import Command
16
+ from .._renderer import CanvasManager
17
+ from ._color_utils import _SN, _slider_num
18
+
19
+
20
+ class MarginalHistogramManager:
21
+ """Manages marginal histogram axes for all scatter collections on an axes."""
22
+
23
+ def __init__(self, fig, main_ax, scatter_colls, stack, canvas: CanvasManager):
24
+ self._fig = fig
25
+ self._ax = main_ax
26
+ self._colls = list(scatter_colls)
27
+ self._stack = stack
28
+ self._canvas = canvas
29
+ self._ax_x = None
30
+ self._ax_y = None
31
+
32
+ # Shared settings
33
+ self._mode = 'overlay'
34
+ self._separation = 0.1
35
+
36
+ # Read scatter plot font sizes for defaults
37
+ _tick_fs = 10.0
38
+ _label_fs = 10.0
39
+ _title_fs = 10.0
40
+ try:
41
+ xticks = main_ax.get_xticklabels()
42
+ if xticks:
43
+ _tick_fs = round(xticks[0].get_fontsize(), 1)
44
+ _label_fs = round(main_ax.xaxis.label.get_fontsize(), 1)
45
+ _title_fs = round(main_ax.title.get_fontsize(), 1)
46
+ except Exception:
47
+ pass
48
+
49
+ # Per-histogram settings (fully independent)
50
+ self._x = self._default_settings('x', _tick_fs, _label_fs, _title_fs)
51
+ self._y = self._default_settings('y', _tick_fs, _label_fs, _title_fs)
52
+
53
+ # Auto-tick state per histogram
54
+ self._x_auto_ticks = True
55
+ self._y_auto_ticks = True
56
+ # UI update hooks (set by _build_section)
57
+ self._x_tick_ui = None
58
+ self._y_tick_ui = None
59
+
60
+ # Register on figure so renderer can reposition after tight_layout
61
+ if not hasattr(fig, '_matplotly_marginal_managers'):
62
+ fig._matplotly_marginal_managers = []
63
+ fig._matplotly_marginal_managers.append(self)
64
+
65
+ @staticmethod
66
+ def _default_settings(which, tick_fs=10.0, label_fs=10.0, title_fs=10.0):
67
+ return {
68
+ 'enabled': False,
69
+ 'position': 'top' if which == 'x' else 'right',
70
+ 'bins': 20,
71
+ 'height': 1.0,
72
+ 'alpha': 0.5,
73
+ 'pad': 0,
74
+ # Tick controls
75
+ 'tick_side': 'left' if which == 'x' else 'bottom',
76
+ 'tick_fontsize': tick_fs,
77
+ 'tick_step': 0, # 0 = auto
78
+ 'range_min': 0, # 0,0 = auto
79
+ 'range_max': 0,
80
+ # Label
81
+ 'label': '',
82
+ 'label_fontsize': label_fs,
83
+ 'label_bold': False,
84
+ 'label_italic': False,
85
+ 'label_color': '#000000',
86
+ # Title
87
+ 'title': '',
88
+ 'title_fontsize': title_fs,
89
+ 'title_bold': False,
90
+ 'title_italic': False,
91
+ 'title_color': '#000000',
92
+ # Spines
93
+ 'spines': {'top': False, 'right': False,
94
+ 'bottom': True, 'left': True},
95
+ }
96
+
97
+ # ------------------------------------------------------------------
98
+ # Helpers
99
+ # ------------------------------------------------------------------
100
+
101
+ @staticmethod
102
+ def _get_color(coll):
103
+ try:
104
+ return to_hex(coll.get_facecolor()[0])
105
+ except Exception:
106
+ return '#1f77b4'
107
+
108
+ def _get_coll_index(self, coll):
109
+ for i, c in enumerate(self._ax.collections):
110
+ if c is coll:
111
+ return i
112
+ return 0
113
+
114
+ def _parent_ax_index(self):
115
+ for i, a in enumerate(self._fig.get_axes()):
116
+ if getattr(a, '_matplotly_marginal', False):
117
+ continue
118
+ if a is self._ax:
119
+ return i
120
+ return 0
121
+
122
+ def _compute_default_spines(self, which):
123
+ """Compute sensible spine visibility from position and tick side."""
124
+ s = self._x if which == 'x' else self._y
125
+ pos = s['position']
126
+ ts = s['tick_side']
127
+ result = {n: False for n in ('top', 'right', 'bottom', 'left')}
128
+
129
+ if which == 'x':
130
+ # Adjacent spine (between marginal and main plot)
131
+ result['bottom' if pos == 'top' else 'top'] = True
132
+ # Tick-side spine
133
+ if ts in ('left', 'right'):
134
+ result[ts] = True
135
+ else:
136
+ result['left' if pos == 'right' else 'right'] = True
137
+ if ts in ('top', 'bottom'):
138
+ result[ts] = True
139
+ return result
140
+
141
+ def _clear_data(self, ax):
142
+ """Remove plotted data without resetting shared axis properties.
143
+
144
+ Unlike cla(), this preserves the shared axis locator/formatter
145
+ so the main scatter plot ticks are not affected.
146
+ """
147
+ while ax.patches:
148
+ ax.patches[0].remove()
149
+ if hasattr(ax, 'containers'):
150
+ ax.containers.clear()
151
+ ax.relim()
152
+
153
+ def _read_auto_ticks(self, which):
154
+ """Read the auto-computed tick step and range from a marginal axes."""
155
+ ax = self._ax_x if which == 'x' else self._ax_y
156
+ if ax is None:
157
+ return None
158
+ # Count axis is Y for x-marginal, X for y-marginal
159
+ if which == 'x':
160
+ ticks = ax.get_yticks()
161
+ lim = ax.get_ylim()
162
+ else:
163
+ ticks = ax.get_xticks()
164
+ lim = ax.get_xlim()
165
+ step = 0.0
166
+ if len(ticks) >= 2:
167
+ step = round(abs(ticks[1] - ticks[0]), 4)
168
+ rmin = round(min(lim), 4)
169
+ rmax = round(max(lim), 4)
170
+ return {'step': step, 'min': rmin, 'max': rmax}
171
+
172
+ def _sync_auto_ui(self, which):
173
+ """If auto mode is on, read auto values and update text boxes."""
174
+ is_auto = self._x_auto_ticks if which == 'x' else self._y_auto_ticks
175
+ ui = self._x_tick_ui if which == 'x' else self._y_tick_ui
176
+ if not is_auto or ui is None:
177
+ return
178
+ vals = self._read_auto_ticks(which)
179
+ if vals is None:
180
+ return
181
+ guard, step_w, rmin_w, rmax_w = ui
182
+ guard[0] = True
183
+ try:
184
+ step_w.value = vals['step']
185
+ rmin_w.value = vals['min']
186
+ rmax_w.value = vals['max']
187
+ finally:
188
+ guard[0] = False
189
+
190
+ # ------------------------------------------------------------------
191
+ # Positioning
192
+ # ------------------------------------------------------------------
193
+
194
+ def _compute_x_rect(self):
195
+ pos = self._ax.get_position()
196
+ fig_h = self._fig.get_size_inches()[1]
197
+ h_frac = self._x['height'] / fig_h
198
+ pad_y = self._x['pad'] / fig_h
199
+ if self._x['position'] == 'top':
200
+ return [pos.x0, pos.y1 + pad_y, pos.width, h_frac]
201
+ else:
202
+ return [pos.x0, pos.y0 - h_frac - pad_y, pos.width, h_frac]
203
+
204
+ def _compute_y_rect(self):
205
+ pos = self._ax.get_position()
206
+ fig_w = self._fig.get_size_inches()[0]
207
+ w_frac = self._y['height'] / fig_w
208
+ pad_x = self._y['pad'] / fig_w
209
+ if self._y['position'] == 'right':
210
+ return [pos.x1 + pad_x, pos.y0, w_frac, pos.height]
211
+ else:
212
+ return [pos.x0 - w_frac - pad_x, pos.y0, w_frac, pos.height]
213
+
214
+ # ------------------------------------------------------------------
215
+ # Enable / disable
216
+ # ------------------------------------------------------------------
217
+
218
+ def _enable_x(self):
219
+ if self._ax_x is not None:
220
+ return
221
+ rect = self._compute_x_rect()
222
+ self._ax_x = self._fig.add_axes(rect, sharex=self._ax)
223
+ self._ax_x._matplotly_marginal = True
224
+ self._draw_x()
225
+
226
+ def _disable_x(self):
227
+ if self._ax_x is None:
228
+ return
229
+ self._fig.delaxes(self._ax_x)
230
+ self._ax_x = None
231
+
232
+ def _enable_y(self):
233
+ if self._ax_y is not None:
234
+ return
235
+ rect = self._compute_y_rect()
236
+ self._ax_y = self._fig.add_axes(rect, sharey=self._ax)
237
+ self._ax_y._matplotly_marginal = True
238
+ self._draw_y()
239
+
240
+ def _disable_y(self):
241
+ if self._ax_y is None:
242
+ return
243
+ self._fig.delaxes(self._ax_y)
244
+ self._ax_y = None
245
+
246
+ # ------------------------------------------------------------------
247
+ # Global bins
248
+ # ------------------------------------------------------------------
249
+
250
+ def _global_bin_edges(self, data_col, n_bins):
251
+ """Compute shared bin edges across all scatter collections."""
252
+ arrays = []
253
+ for coll in self._colls:
254
+ offsets = coll.get_offsets()
255
+ if len(offsets) > 0:
256
+ arrays.append(offsets[:, data_col])
257
+ if not arrays:
258
+ return np.linspace(0, 1, n_bins + 1)
259
+ combined = np.concatenate(arrays)
260
+ return np.histogram_bin_edges(combined, bins=n_bins)
261
+
262
+ # ------------------------------------------------------------------
263
+ # Drawing
264
+ # ------------------------------------------------------------------
265
+
266
+ def _draw_x(self):
267
+ if self._ax_x is None:
268
+ return
269
+ ax = self._ax_x
270
+ self._clear_data(ax)
271
+ ax._matplotly_marginal = True
272
+
273
+ # Save main axes limits — hist()/bar() can affect shared xlim
274
+ main_xlim = self._ax.get_xlim()
275
+
276
+ s = self._x
277
+ bin_edges = self._global_bin_edges(0, s['bins'])
278
+ alpha = s['alpha']
279
+
280
+ if self._mode == 'overlay':
281
+ for coll in self._colls:
282
+ offsets = coll.get_offsets()
283
+ if len(offsets) == 0:
284
+ continue
285
+ ax.hist(offsets[:, 0], bins=bin_edges,
286
+ color=self._get_color(coll),
287
+ alpha=alpha, edgecolor='none')
288
+ else: # dodge
289
+ n = len(self._colls)
290
+ bin_w = bin_edges[1] - bin_edges[0]
291
+ sub_w = bin_w / n
292
+ bar_w = sub_w * (1 - self._separation)
293
+ centers = (bin_edges[:-1] + bin_edges[1:]) / 2
294
+ for i, coll in enumerate(self._colls):
295
+ offsets = coll.get_offsets()
296
+ if len(offsets) == 0:
297
+ continue
298
+ counts, _ = np.histogram(offsets[:, 0], bins=bin_edges)
299
+ offset = sub_w * (i - (n - 1) / 2)
300
+ ax.bar(centers + offset, counts, width=bar_w,
301
+ color=self._get_color(coll), alpha=alpha,
302
+ edgecolor='none')
303
+
304
+ # Restore main axes limits
305
+ self._ax.set_xlim(main_xlim)
306
+
307
+ # Invert for bottom position (bars grow downward)
308
+ if s['position'] == 'bottom':
309
+ if not ax.yaxis_inverted():
310
+ ax.invert_yaxis()
311
+ else:
312
+ if ax.yaxis_inverted():
313
+ ax.invert_yaxis()
314
+
315
+ self._apply_config(ax, 'x')
316
+ self._store_info(ax, 'x')
317
+ self._sync_auto_ui('x')
318
+
319
+ def _draw_y(self):
320
+ if self._ax_y is None:
321
+ return
322
+ ax = self._ax_y
323
+ self._clear_data(ax)
324
+ ax._matplotly_marginal = True
325
+
326
+ # Save main axes limits — hist()/bar() can affect shared ylim
327
+ main_ylim = self._ax.get_ylim()
328
+
329
+ s = self._y
330
+ bin_edges = self._global_bin_edges(1, s['bins'])
331
+ alpha = s['alpha']
332
+
333
+ if self._mode == 'overlay':
334
+ for coll in self._colls:
335
+ offsets = coll.get_offsets()
336
+ if len(offsets) == 0:
337
+ continue
338
+ ax.hist(offsets[:, 1], bins=bin_edges,
339
+ color=self._get_color(coll),
340
+ alpha=alpha, orientation='horizontal',
341
+ edgecolor='none')
342
+ else: # dodge
343
+ n = len(self._colls)
344
+ bin_w = bin_edges[1] - bin_edges[0]
345
+ sub_w = bin_w / n
346
+ bar_h = sub_w * (1 - self._separation)
347
+ centers = (bin_edges[:-1] + bin_edges[1:]) / 2
348
+ for i, coll in enumerate(self._colls):
349
+ offsets = coll.get_offsets()
350
+ if len(offsets) == 0:
351
+ continue
352
+ counts, _ = np.histogram(offsets[:, 1], bins=bin_edges)
353
+ offset = sub_w * (i - (n - 1) / 2)
354
+ ax.barh(centers + offset, counts, height=bar_h,
355
+ color=self._get_color(coll), alpha=alpha,
356
+ edgecolor='none')
357
+
358
+ # Restore main axes limits
359
+ self._ax.set_ylim(main_ylim)
360
+
361
+ # Invert for left position (bars grow leftward)
362
+ if s['position'] == 'left':
363
+ if not ax.xaxis_inverted():
364
+ ax.invert_xaxis()
365
+ else:
366
+ if ax.xaxis_inverted():
367
+ ax.invert_xaxis()
368
+
369
+ self._apply_config(ax, 'y')
370
+ self._store_info(ax, 'y')
371
+ self._sync_auto_ui('y')
372
+
373
+ # ------------------------------------------------------------------
374
+ # Spine / tick / label / title configuration
375
+ # ------------------------------------------------------------------
376
+
377
+ def _apply_config(self, ax, which):
378
+ """Apply spines, ticks, labels, range, step, and title."""
379
+ s = self._x if which == 'x' else self._y
380
+
381
+ # Spines
382
+ for name in ('top', 'right', 'bottom', 'left'):
383
+ ax.spines[name].set_visible(s['spines'][name])
384
+
385
+ if which == 'x':
386
+ # Shared X axis — hide ALL data-axis ticks and labels
387
+ ax.tick_params(axis='x', bottom=False, top=False,
388
+ labelbottom=False, labeltop=False)
389
+ # Count-axis (Y) ticks
390
+ ts = s['tick_side']
391
+ tfs = s['tick_fontsize']
392
+ if ts == 'left':
393
+ ax.tick_params(axis='y', left=True, labelleft=True,
394
+ right=False, labelright=False, labelsize=tfs)
395
+ elif ts == 'right':
396
+ ax.tick_params(axis='y', left=False, labelleft=False,
397
+ right=True, labelright=True, labelsize=tfs)
398
+ else: # none
399
+ ax.tick_params(axis='y', left=False, labelleft=False,
400
+ right=False, labelright=False)
401
+ # Tick step
402
+ if s['tick_step'] > 0:
403
+ ax.yaxis.set_major_locator(MultipleLocator(s['tick_step']))
404
+ else:
405
+ ax.yaxis.set_major_locator(AutoLocator())
406
+ # Range
407
+ rmax = s['range_max']
408
+ if rmax > 0:
409
+ rmin = s['range_min']
410
+ if s['position'] == 'bottom':
411
+ ax.set_ylim(rmax, rmin)
412
+ else:
413
+ ax.set_ylim(rmin, rmax)
414
+ # Label
415
+ lbl = s['label']
416
+ if lbl:
417
+ if ts == 'right':
418
+ ax.yaxis.set_label_position('right')
419
+ else:
420
+ ax.yaxis.set_label_position('left')
421
+ weight = 'bold' if s['label_bold'] else 'normal'
422
+ style = 'italic' if s['label_italic'] else 'normal'
423
+ ax.set_ylabel(lbl, fontsize=s['label_fontsize'],
424
+ fontweight=weight, fontstyle=style,
425
+ color=s['label_color'])
426
+ else:
427
+ ax.set_ylabel('')
428
+ else:
429
+ # Shared Y axis — hide ALL data-axis ticks and labels
430
+ ax.tick_params(axis='y', left=False, right=False,
431
+ labelleft=False, labelright=False)
432
+ # Count-axis (X) ticks
433
+ ts = s['tick_side']
434
+ tfs = s['tick_fontsize']
435
+ if ts == 'bottom':
436
+ ax.tick_params(axis='x', bottom=True, labelbottom=True,
437
+ top=False, labeltop=False, labelsize=tfs)
438
+ elif ts == 'top':
439
+ ax.tick_params(axis='x', bottom=False, labelbottom=False,
440
+ top=True, labeltop=True, labelsize=tfs)
441
+ else: # none
442
+ ax.tick_params(axis='x', bottom=False, labelbottom=False,
443
+ top=False, labeltop=False)
444
+ # Tick step
445
+ if s['tick_step'] > 0:
446
+ ax.xaxis.set_major_locator(MultipleLocator(s['tick_step']))
447
+ else:
448
+ ax.xaxis.set_major_locator(AutoLocator())
449
+ # Range
450
+ rmax = s['range_max']
451
+ if rmax > 0:
452
+ rmin = s['range_min']
453
+ if s['position'] == 'left':
454
+ ax.set_xlim(rmax, rmin)
455
+ else:
456
+ ax.set_xlim(rmin, rmax)
457
+ # Label
458
+ lbl = s['label']
459
+ if lbl:
460
+ if ts == 'top':
461
+ ax.xaxis.set_label_position('top')
462
+ else:
463
+ ax.xaxis.set_label_position('bottom')
464
+ weight = 'bold' if s['label_bold'] else 'normal'
465
+ style = 'italic' if s['label_italic'] else 'normal'
466
+ ax.set_xlabel(lbl, fontsize=s['label_fontsize'],
467
+ fontweight=weight, fontstyle=style,
468
+ color=s['label_color'])
469
+ else:
470
+ ax.set_xlabel('')
471
+
472
+ # Title
473
+ title = s['title']
474
+ if title:
475
+ weight = 'bold' if s['title_bold'] else 'normal'
476
+ style = 'italic' if s['title_italic'] else 'normal'
477
+ ax.set_title(title, fontsize=s['title_fontsize'],
478
+ fontweight=weight, fontstyle=style,
479
+ color=s['title_color'])
480
+ else:
481
+ ax.set_title('')
482
+
483
+ # ------------------------------------------------------------------
484
+ # Code-gen metadata
485
+ # ------------------------------------------------------------------
486
+
487
+ def _store_info(self, ax, which):
488
+ s = self._x if which == 'x' else self._y
489
+ coll_info = []
490
+ for coll in self._colls:
491
+ coll_info.append({
492
+ 'coll_index': self._get_coll_index(coll),
493
+ 'color': self._get_color(coll),
494
+ })
495
+ inverted = ((s['position'] == 'bottom') if which == 'x'
496
+ else (s['position'] == 'left'))
497
+ ax._matplotly_marginal_info = {
498
+ 'axis': which,
499
+ 'parent_ax_index': self._parent_ax_index(),
500
+ 'mode': self._mode,
501
+ 'position': s['position'],
502
+ 'height': s['height'],
503
+ 'pad': s['pad'],
504
+ 'bins': s['bins'],
505
+ 'alpha': s['alpha'],
506
+ 'separation': self._separation,
507
+ 'inverted': inverted,
508
+ 'tick_side': s['tick_side'],
509
+ 'tick_fontsize': s['tick_fontsize'],
510
+ 'tick_step': s['tick_step'],
511
+ 'range_min': s['range_min'],
512
+ 'range_max': s['range_max'],
513
+ 'label': s['label'],
514
+ 'label_fontsize': s['label_fontsize'],
515
+ 'label_bold': s['label_bold'],
516
+ 'label_italic': s['label_italic'],
517
+ 'label_color': s['label_color'],
518
+ 'title': s['title'],
519
+ 'title_fontsize': s['title_fontsize'],
520
+ 'title_bold': s['title_bold'],
521
+ 'title_italic': s['title_italic'],
522
+ 'title_color': s['title_color'],
523
+ 'collections': coll_info,
524
+ }
525
+
526
+ # ------------------------------------------------------------------
527
+ # Rebuild / sync
528
+ # ------------------------------------------------------------------
529
+
530
+ def _rebuild(self):
531
+ """Shrink main axes to make room, then reposition marginals."""
532
+ pos = self._ax.get_position()
533
+ fig_w, fig_h = self._fig.get_size_inches()
534
+ x0, y0, w, h = pos.x0, pos.y0, pos.width, pos.height
535
+
536
+ if self._ax_x is not None:
537
+ h_frac = self._x['height'] / fig_h
538
+ pad_frac = self._x['pad'] / fig_h
539
+ need = h_frac + pad_frac
540
+ if self._x['position'] == 'top':
541
+ h -= need
542
+ else: # bottom
543
+ y0 += need
544
+ h -= need
545
+
546
+ if self._ax_y is not None:
547
+ w_frac = self._y['height'] / fig_w
548
+ pad_frac = self._y['pad'] / fig_w
549
+ need = w_frac + pad_frac
550
+ if self._y['position'] == 'right':
551
+ w -= need
552
+ else: # left
553
+ x0 += need
554
+ w -= need
555
+
556
+ if self._ax_x is not None or self._ax_y is not None:
557
+ self._ax.set_position([x0, y0, w, h])
558
+
559
+ self._rebuild_x()
560
+ self._rebuild_y()
561
+
562
+ def _rebuild_x(self):
563
+ if self._ax_x is not None:
564
+ self._ax_x.set_position(self._compute_x_rect())
565
+ self._draw_x()
566
+
567
+ def _rebuild_y(self):
568
+ if self._ax_y is not None:
569
+ self._ax_y.set_position(self._compute_y_rect())
570
+ self._draw_y()
571
+
572
+ def sync_colors(self):
573
+ """Redraw histograms to pick up scatter color changes."""
574
+ if self._ax_x is not None:
575
+ self._draw_x()
576
+ if self._ax_y is not None:
577
+ self._draw_y()
578
+
579
+ # ------------------------------------------------------------------
580
+ # Widget
581
+ # ------------------------------------------------------------------
582
+
583
+ def build_widget(self) -> widgets.Widget:
584
+ # --- Shared controls ---
585
+ mode_dd = widgets.Dropdown(
586
+ options=[('Overlay', 'overlay'), ('Side-by-side', 'dodge')],
587
+ value=self._mode, description='Mode:', style=_SN,
588
+ layout=widgets.Layout(width='180px'))
589
+ sep_sl = widgets.FloatSlider(
590
+ value=self._separation, min=0.0, max=0.8,
591
+ step=0.05, description='Gap:', style=_SN)
592
+ sep_row = _slider_num(sep_sl)
593
+ sep_row.layout.display = '' if self._mode == 'dodge' else 'none'
594
+
595
+ def _on_mode(change):
596
+ self._mode = change['new']
597
+ sep_row.layout.display = '' if change['new'] == 'dodge' else 'none'
598
+ self._rebuild()
599
+ if self._canvas:
600
+ self._canvas.force_redraw()
601
+
602
+ def _on_sep(change):
603
+ self._separation = change['new']
604
+ self._rebuild()
605
+ if self._canvas:
606
+ self._canvas.force_redraw()
607
+
608
+ mode_dd.observe(_on_mode, names='value')
609
+ sep_sl.observe(_on_sep, names='value')
610
+
611
+ shared_box = widgets.VBox([mode_dd, sep_row])
612
+
613
+ # --- Per-histogram sections ---
614
+ x_section = self._build_section('x')
615
+ y_section = self._build_section('y')
616
+
617
+ return widgets.VBox([shared_box, x_section, y_section])
618
+
619
+ def _build_section(self, which):
620
+ """Build a collapsible toggle section for one marginal histogram."""
621
+ s = self._x if which == 'x' else self._y
622
+ title = 'X Histogram' if which == 'x' else 'Y Histogram'
623
+
624
+ rebuild_fn = self._rebuild_x if which == 'x' else self._rebuild_y
625
+ enable_fn = self._enable_x if which == 'x' else self._enable_y
626
+ disable_fn = self._disable_x if which == 'x' else self._disable_y
627
+
628
+ # --- Enable + position header ---
629
+ enable_cb = widgets.Checkbox(
630
+ value=s['enabled'], description=title, indent=False,
631
+ layout=widgets.Layout(width='130px'))
632
+ if which == 'x':
633
+ pos_dd = widgets.Dropdown(
634
+ options=['top', 'bottom'], value=s['position'],
635
+ layout=widgets.Layout(width='90px'))
636
+ else:
637
+ pos_dd = widgets.Dropdown(
638
+ options=['right', 'left'], value=s['position'],
639
+ layout=widgets.Layout(width='90px'))
640
+
641
+ header = widgets.HBox(
642
+ [enable_cb, pos_dd],
643
+ layout=widgets.Layout(align_items='center'))
644
+
645
+ # --- Data controls ---
646
+ bins_sl = widgets.IntSlider(
647
+ value=s['bins'], min=5, max=100, step=1,
648
+ description='Bins:', style=_SN)
649
+ height_sl = widgets.FloatSlider(
650
+ value=s['height'], min=0.2, max=5.0, step=0.05,
651
+ description='Height:', style=_SN)
652
+ alpha_sl = widgets.FloatSlider(
653
+ value=s['alpha'], min=0.0, max=1.0, step=0.05,
654
+ description='Alpha:', style=_SN)
655
+ pad_sl = widgets.FloatSlider(
656
+ value=s['pad'], min=0.0, max=2.0, step=0.01,
657
+ description='Pad:', style=_SN)
658
+
659
+ # --- Tick controls ---
660
+ if which == 'x':
661
+ tick_dd = widgets.Dropdown(
662
+ options=['left', 'right', 'none'], value=s['tick_side'],
663
+ description='Ticks:', style=_SN,
664
+ layout=widgets.Layout(width='150px'))
665
+ else:
666
+ tick_dd = widgets.Dropdown(
667
+ options=['bottom', 'top', 'none'], value=s['tick_side'],
668
+ description='Ticks:', style=_SN,
669
+ layout=widgets.Layout(width='150px'))
670
+
671
+ tick_fontsize_sl = widgets.FloatSlider(
672
+ value=s['tick_fontsize'], min=4, max=20, step=0.5,
673
+ description='Tick sz:', style=_SN)
674
+
675
+ tick_step_w = widgets.FloatText(
676
+ value=s['tick_step'], description='Step:',
677
+ style={"description_width": "36px"},
678
+ layout=widgets.Layout(width='110px'))
679
+
680
+ # --- Range controls ---
681
+ range_min_w = widgets.FloatText(
682
+ value=s['range_min'], description='Min:',
683
+ style={"description_width": "30px"},
684
+ layout=widgets.Layout(width='100px'))
685
+ range_max_w = widgets.FloatText(
686
+ value=s['range_max'], description='Max:',
687
+ style={"description_width": "30px"},
688
+ layout=widgets.Layout(width='100px'))
689
+
690
+ # --- Auto button ---
691
+ _auto_ref = self._x_auto_ticks if which == 'x' else self._y_auto_ticks
692
+ auto_btn = widgets.Button(
693
+ description='Auto', icon='refresh',
694
+ layout=widgets.Layout(width='70px', height='24px',
695
+ padding='0', min_width='0'))
696
+ auto_btn.style.button_color = '#d4edda' if _auto_ref else '#f0f0f0'
697
+ _tick_guard = [False]
698
+
699
+ # Register UI hooks so _sync_auto_ui can update text boxes
700
+ if which == 'x':
701
+ self._x_tick_ui = (_tick_guard, tick_step_w,
702
+ range_min_w, range_max_w)
703
+ else:
704
+ self._y_tick_ui = (_tick_guard, tick_step_w,
705
+ range_min_w, range_max_w)
706
+
707
+ tick_step_row = widgets.HBox(
708
+ [tick_step_w, auto_btn],
709
+ layout=widgets.Layout(align_items='center', gap='4px'))
710
+ range_row = widgets.HBox(
711
+ [widgets.Label('Range:', layout=widgets.Layout(width='42px')),
712
+ range_min_w, range_max_w],
713
+ layout=widgets.Layout(align_items='center', gap='4px'))
714
+
715
+ # --- Label controls ---
716
+ label_w = widgets.Text(
717
+ value=s['label'], description='Label:', style=_SN,
718
+ placeholder='e.g. Count',
719
+ layout=widgets.Layout(width='95%'))
720
+ label_fontsize_sl = widgets.FloatSlider(
721
+ value=s['label_fontsize'], min=4, max=20, step=0.5,
722
+ description='Size:', style=_SN)
723
+ label_bold = widgets.ToggleButton(
724
+ value=s['label_bold'], description='B', tooltip='Bold',
725
+ layout=widgets.Layout(width='32px', height='24px',
726
+ padding='0', min_width='0'))
727
+ label_bold.style.font_weight = 'bold'
728
+ label_italic = widgets.ToggleButton(
729
+ value=s['label_italic'], description='I', tooltip='Italic',
730
+ layout=widgets.Layout(width='32px', height='24px',
731
+ padding='0', min_width='0'))
732
+ label_italic.style.font_style = 'italic'
733
+ label_color = widgets.ColorPicker(
734
+ value=s['label_color'], concise=True,
735
+ layout=widgets.Layout(width='28px', height='24px'))
736
+ label_fmt_row = widgets.HBox(
737
+ [label_color, label_bold, label_italic],
738
+ layout=widgets.Layout(align_items='center', gap='2px',
739
+ padding='0 0 0 52px'))
740
+
741
+ # --- Title controls ---
742
+ title_w = widgets.Text(
743
+ value=s['title'], description='Title:', style=_SN,
744
+ placeholder='(optional)',
745
+ layout=widgets.Layout(width='95%'))
746
+ title_fontsize_sl = widgets.FloatSlider(
747
+ value=s['title_fontsize'], min=4, max=20, step=0.5,
748
+ description='Size:', style=_SN)
749
+ title_bold = widgets.ToggleButton(
750
+ value=s['title_bold'], description='B', tooltip='Bold',
751
+ layout=widgets.Layout(width='32px', height='24px',
752
+ padding='0', min_width='0'))
753
+ title_bold.style.font_weight = 'bold'
754
+ title_italic = widgets.ToggleButton(
755
+ value=s['title_italic'], description='I', tooltip='Italic',
756
+ layout=widgets.Layout(width='32px', height='24px',
757
+ padding='0', min_width='0'))
758
+ title_italic.style.font_style = 'italic'
759
+ title_color = widgets.ColorPicker(
760
+ value=s['title_color'], concise=True,
761
+ layout=widgets.Layout(width='28px', height='24px'))
762
+ title_fmt_row = widgets.HBox(
763
+ [title_color, title_bold, title_italic],
764
+ layout=widgets.Layout(align_items='center', gap='2px',
765
+ padding='0 0 0 52px'))
766
+
767
+ # --- Spine checkboxes ---
768
+ sp = s['spines']
769
+ sp_left = widgets.Checkbox(
770
+ value=sp['left'], description='L', indent=False,
771
+ layout=widgets.Layout(width='auto'))
772
+ sp_bottom = widgets.Checkbox(
773
+ value=sp['bottom'], description='B', indent=False,
774
+ layout=widgets.Layout(width='auto'))
775
+ sp_right = widgets.Checkbox(
776
+ value=sp['right'], description='R', indent=False,
777
+ layout=widgets.Layout(width='auto'))
778
+ sp_top = widgets.Checkbox(
779
+ value=sp['top'], description='T', indent=False,
780
+ layout=widgets.Layout(width='auto'))
781
+ spine_row = widgets.HBox(
782
+ [widgets.Label('Spines:', layout=widgets.Layout(width='50px')),
783
+ sp_left, sp_bottom, sp_right, sp_top],
784
+ layout=widgets.Layout(align_items='center'))
785
+
786
+ controls_box = widgets.VBox([
787
+ _slider_num(bins_sl), _slider_num(height_sl),
788
+ _slider_num(alpha_sl), _slider_num(pad_sl),
789
+ widgets.HTML("<small><b>Ticks</b></small>"),
790
+ tick_dd, _slider_num(tick_fontsize_sl),
791
+ tick_step_row, range_row,
792
+ widgets.HTML("<small><b>Label</b></small>"),
793
+ label_w, _slider_num(label_fontsize_sl), label_fmt_row,
794
+ widgets.HTML("<small><b>Title</b></small>"),
795
+ title_w, _slider_num(title_fontsize_sl), title_fmt_row,
796
+ spine_row,
797
+ ], layout=widgets.Layout(
798
+ display='' if s['enabled'] else 'none',
799
+ padding='2px 0 4px 8px'))
800
+
801
+ # --- Guard flag for programmatic spine updates ---
802
+ _spine_updating = [False]
803
+
804
+ def _update_spine_cbs(sp_dict):
805
+ """Sync spine checkbox values without triggering rebuild."""
806
+ _spine_updating[0] = True
807
+ try:
808
+ sp_top.value = sp_dict['top']
809
+ sp_right.value = sp_dict['right']
810
+ sp_bottom.value = sp_dict['bottom']
811
+ sp_left.value = sp_dict['left']
812
+ finally:
813
+ _spine_updating[0] = False
814
+
815
+ # --- Callbacks ---
816
+ def _on_enable(change):
817
+ old = s['enabled']
818
+ new = change['new']
819
+ s['enabled'] = new
820
+ controls_box.layout.display = '' if new else 'none'
821
+ def _apply():
822
+ s['enabled'] = new
823
+ (enable_fn if new else disable_fn)()
824
+ def _revert():
825
+ s['enabled'] = old
826
+ (enable_fn if old else disable_fn)()
827
+ enable_cb.value = old
828
+ controls_box.layout.display = '' if old else 'none'
829
+ if self._stack:
830
+ self._stack.execute(
831
+ Command(self._ax, f'_marginal_{which}', old, new,
832
+ apply_fn=_apply, revert_fn=_revert,
833
+ description=f'marginal {which.upper()} histogram'))
834
+ if self._canvas:
835
+ self._canvas.force_redraw()
836
+
837
+ def _on_pos(change):
838
+ s['position'] = change['new']
839
+ # Reset spines to sensible defaults for new position
840
+ new_spines = self._compute_default_spines(which)
841
+ s['spines'] = new_spines
842
+ _update_spine_cbs(new_spines)
843
+ ax_ref = self._ax_x if which == 'x' else self._ax_y
844
+ if ax_ref is not None:
845
+ disable_fn()
846
+ enable_fn()
847
+ if self._canvas:
848
+ self._canvas.force_redraw()
849
+
850
+ def _on_tick_side(change):
851
+ s['tick_side'] = change['new']
852
+ # Auto-update spines to follow tick side
853
+ new_spines = self._compute_default_spines(which)
854
+ s['spines'] = new_spines
855
+ _update_spine_cbs(new_spines)
856
+ rebuild_fn()
857
+ if self._canvas:
858
+ self._canvas.force_redraw()
859
+
860
+ def _on_spine(name):
861
+ def _cb(change):
862
+ if _spine_updating[0]:
863
+ return
864
+ s['spines'][name] = change['new']
865
+ rebuild_fn()
866
+ if self._canvas:
867
+ self._canvas.force_redraw()
868
+ return _cb
869
+
870
+ def _on_setting(key):
871
+ def _cb(change):
872
+ s[key] = change['new']
873
+ rebuild_fn()
874
+ if self._canvas:
875
+ self._canvas.force_redraw()
876
+ return _cb
877
+
878
+ enable_cb.observe(_on_enable, names='value')
879
+ pos_dd.observe(_on_pos, names='value')
880
+ tick_dd.observe(_on_tick_side, names='value')
881
+
882
+ sp_top.observe(_on_spine('top'), names='value')
883
+ sp_right.observe(_on_spine('right'), names='value')
884
+ sp_bottom.observe(_on_spine('bottom'), names='value')
885
+ sp_left.observe(_on_spine('left'), names='value')
886
+
887
+ for sl, key in [(bins_sl, 'bins'), (height_sl, 'height'),
888
+ (alpha_sl, 'alpha'), (pad_sl, 'pad'),
889
+ (tick_fontsize_sl, 'tick_fontsize')]:
890
+ sl.observe(_on_setting(key), names='value')
891
+
892
+ label_w.observe(_on_setting('label'), names='value')
893
+ label_fontsize_sl.observe(_on_setting('label_fontsize'), names='value')
894
+ label_bold.observe(_on_setting('label_bold'), names='value')
895
+ label_italic.observe(_on_setting('label_italic'), names='value')
896
+ label_color.observe(_on_setting('label_color'), names='value')
897
+
898
+ title_w.observe(_on_setting('title'), names='value')
899
+ title_fontsize_sl.observe(_on_setting('title_fontsize'), names='value')
900
+ title_bold.observe(_on_setting('title_bold'), names='value')
901
+ title_italic.observe(_on_setting('title_italic'), names='value')
902
+ title_color.observe(_on_setting('title_color'), names='value')
903
+
904
+ def _on_tick_manual(key):
905
+ """Tick step / range changed by user — turn off auto."""
906
+ def _cb(change):
907
+ if _tick_guard[0]:
908
+ return
909
+ s[key] = change['new']
910
+ if which == 'x':
911
+ self._x_auto_ticks = False
912
+ else:
913
+ self._y_auto_ticks = False
914
+ auto_btn.style.button_color = '#f0f0f0'
915
+ rebuild_fn()
916
+ if self._canvas:
917
+ self._canvas.force_redraw()
918
+ return _cb
919
+
920
+ def _on_auto(btn):
921
+ """Restore auto mode: clear overrides, redraw, fill in values."""
922
+ if which == 'x':
923
+ self._x_auto_ticks = True
924
+ else:
925
+ self._y_auto_ticks = True
926
+ auto_btn.style.button_color = '#d4edda'
927
+ s['tick_step'] = 0
928
+ s['range_min'] = 0
929
+ s['range_max'] = 0
930
+ rebuild_fn()
931
+ if self._canvas:
932
+ self._canvas.force_redraw()
933
+ # _sync_auto_ui is called inside draw, so values are filled
934
+
935
+ auto_btn.on_click(_on_auto)
936
+ tick_step_w.observe(_on_tick_manual('tick_step'), names='value')
937
+ range_min_w.observe(_on_tick_manual('range_min'), names='value')
938
+ range_max_w.observe(_on_tick_manual('range_max'), names='value')
939
+
940
+ return widgets.VBox(
941
+ [header, controls_box],
942
+ layout=widgets.Layout(
943
+ border='1px solid #e0e0e0', border_radius='4px',
944
+ margin='4px 0', padding='4px'))