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
|
@@ -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'))
|