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/panels/_bar.py
ADDED
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
"""Bar chart controls — per-group visual styling + shared structural controls.
|
|
2
|
+
|
|
3
|
+
BarPanel: per-group controls (name, face color, edge color, edge width,
|
|
4
|
+
alpha, hatch, linestyle) with collapsible header and swatch palette.
|
|
5
|
+
BarSharedPanel: structural controls shared across all bar groups on the same
|
|
6
|
+
axes (bar width, gap, orientation, tick labels, tick rotation).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import ipywidgets as widgets
|
|
12
|
+
from matplotlib.colors import to_hex
|
|
13
|
+
|
|
14
|
+
from .._commands import BatchCommand, Command
|
|
15
|
+
from .._types import ArtistGroup, PlotType
|
|
16
|
+
from ._base import ArtistPanel
|
|
17
|
+
from ._color_utils import (
|
|
18
|
+
_DW, _NW, _SN,
|
|
19
|
+
_get_palette_colors, _make_color_dot, _refresh_legend, _slider_num,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BarPanel(ArtistPanel):
|
|
24
|
+
"""Per-group bar panel: name, color, edge color, edge width, alpha, hatch."""
|
|
25
|
+
|
|
26
|
+
_plot_number: int = 0
|
|
27
|
+
_on_label_changed = None
|
|
28
|
+
|
|
29
|
+
def build(self) -> widgets.Widget:
|
|
30
|
+
patches = self._group.artists
|
|
31
|
+
ref = patches[0]
|
|
32
|
+
meta = self._group.metadata
|
|
33
|
+
|
|
34
|
+
# Extract geometry from metadata
|
|
35
|
+
self._positions = meta.get("positions", [])
|
|
36
|
+
self._values = meta.get("values", [])
|
|
37
|
+
self._bottoms = meta.get("bottoms", [])
|
|
38
|
+
self._bar_width = meta.get("bar_width", 0.8)
|
|
39
|
+
self._orientation = meta.get("orientation", "vertical")
|
|
40
|
+
self._zorder = meta.get("zorder", ref.get_zorder())
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
self._color = to_hex(ref.get_facecolor())
|
|
44
|
+
except Exception:
|
|
45
|
+
self._color = "#1f77b4"
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
self._edgecolor = to_hex(ref.get_edgecolor())
|
|
49
|
+
except Exception:
|
|
50
|
+
self._edgecolor = "#000000"
|
|
51
|
+
|
|
52
|
+
self._edge_width = ref.get_linewidth() if hasattr(ref, 'get_linewidth') else 1.0
|
|
53
|
+
alpha = ref.get_alpha()
|
|
54
|
+
self._alpha = alpha if alpha is not None else 1.0
|
|
55
|
+
self._hatch = ref.get_hatch() or ""
|
|
56
|
+
self._linestyle = ref.get_linestyle() or "-"
|
|
57
|
+
|
|
58
|
+
label = self._group.label
|
|
59
|
+
if label.startswith("Bar: "):
|
|
60
|
+
label = label[len("Bar: "):]
|
|
61
|
+
self._label = label
|
|
62
|
+
|
|
63
|
+
# --- Collapsible header ---
|
|
64
|
+
num = self._plot_number or ""
|
|
65
|
+
header_prefix = f"Bar {num}: " if num else ""
|
|
66
|
+
self._header_prefix = header_prefix
|
|
67
|
+
|
|
68
|
+
self._color_indicator = widgets.HTML(_make_color_dot(self._color))
|
|
69
|
+
|
|
70
|
+
toggle_btn = widgets.Button(
|
|
71
|
+
description=f" {header_prefix}{self._label}",
|
|
72
|
+
icon="chevron-right",
|
|
73
|
+
layout=widgets.Layout(width='100%', height='28px'))
|
|
74
|
+
toggle_btn.style.button_color = '#f0f0f0'
|
|
75
|
+
toggle_btn.style.font_weight = 'bold'
|
|
76
|
+
self._toggle_btn = toggle_btn
|
|
77
|
+
|
|
78
|
+
header_row = widgets.HBox(
|
|
79
|
+
[self._color_indicator, toggle_btn],
|
|
80
|
+
layout=widgets.Layout(align_items='center', gap='4px'))
|
|
81
|
+
|
|
82
|
+
# --- Controls (collapsed by default) ---
|
|
83
|
+
controls = self._build_controls()
|
|
84
|
+
controls_box = widgets.VBox(
|
|
85
|
+
controls,
|
|
86
|
+
layout=widgets.Layout(display='none', padding='2px 0 4px 12px'))
|
|
87
|
+
self._controls_box = controls_box
|
|
88
|
+
self._is_expanded = False
|
|
89
|
+
|
|
90
|
+
def _toggle(btn):
|
|
91
|
+
self._is_expanded = not self._is_expanded
|
|
92
|
+
if self._is_expanded:
|
|
93
|
+
controls_box.layout.display = ''
|
|
94
|
+
toggle_btn.icon = "chevron-down"
|
|
95
|
+
else:
|
|
96
|
+
controls_box.layout.display = 'none'
|
|
97
|
+
toggle_btn.icon = "chevron-right"
|
|
98
|
+
toggle_btn.description = f" {self._header_prefix}{self._label}"
|
|
99
|
+
toggle_btn.on_click(_toggle)
|
|
100
|
+
|
|
101
|
+
return widgets.VBox(
|
|
102
|
+
[header_row, controls_box],
|
|
103
|
+
layout=widgets.Layout(
|
|
104
|
+
border='1px solid #ddd', border_radius='4px',
|
|
105
|
+
margin='2px 0', padding='2px'))
|
|
106
|
+
|
|
107
|
+
def _build_controls(self):
|
|
108
|
+
"""Build per-bar visual controls."""
|
|
109
|
+
controls = []
|
|
110
|
+
|
|
111
|
+
# --- Name ---
|
|
112
|
+
name_field = widgets.Text(
|
|
113
|
+
value=self._label, description="Name:",
|
|
114
|
+
style={"description_width": _DW},
|
|
115
|
+
layout=widgets.Layout(width="95%"))
|
|
116
|
+
|
|
117
|
+
def _on_name(change):
|
|
118
|
+
self._label = change["new"]
|
|
119
|
+
container = self._group.metadata.get("container")
|
|
120
|
+
if container is not None:
|
|
121
|
+
container.set_label(self._label)
|
|
122
|
+
if self._group.artists:
|
|
123
|
+
self._group.artists[0].set_label(self._label)
|
|
124
|
+
pfx = self._header_prefix
|
|
125
|
+
icon = "chevron-down" if self._is_expanded else "chevron-right"
|
|
126
|
+
self._toggle_btn.icon = icon
|
|
127
|
+
self._toggle_btn.description = f" {pfx}{self._label}"
|
|
128
|
+
_refresh_legend(self._group.axes)
|
|
129
|
+
self._update_bar_info()
|
|
130
|
+
if self._on_label_changed is not None:
|
|
131
|
+
self._on_label_changed()
|
|
132
|
+
self._canvas.force_redraw()
|
|
133
|
+
name_field.observe(_on_name, names="value")
|
|
134
|
+
controls.append(name_field)
|
|
135
|
+
|
|
136
|
+
# --- Face color ---
|
|
137
|
+
controls.append(self._build_color_section(
|
|
138
|
+
"Color:", self._color, is_face=True))
|
|
139
|
+
|
|
140
|
+
# --- Edge color ---
|
|
141
|
+
controls.append(self._build_color_section(
|
|
142
|
+
"Edge:", self._edgecolor, is_face=False))
|
|
143
|
+
|
|
144
|
+
# --- Edge width ---
|
|
145
|
+
edge_w_sl = widgets.FloatSlider(
|
|
146
|
+
value=round(self._edge_width, 2), min=0, max=5, step=0.1,
|
|
147
|
+
description="Edge w:", style=_SN)
|
|
148
|
+
|
|
149
|
+
def _ew_cb(change):
|
|
150
|
+
self._edge_width = change["new"]
|
|
151
|
+
for p in self._group.artists:
|
|
152
|
+
p.set_linewidth(self._edge_width)
|
|
153
|
+
self._update_bar_info()
|
|
154
|
+
_refresh_legend(self._group.axes)
|
|
155
|
+
self._canvas.force_redraw()
|
|
156
|
+
edge_w_sl.observe(_ew_cb, names="value")
|
|
157
|
+
controls.append(_slider_num(edge_w_sl))
|
|
158
|
+
|
|
159
|
+
# --- Alpha ---
|
|
160
|
+
alpha_sl = widgets.FloatSlider(
|
|
161
|
+
value=round(self._alpha, 2), min=0, max=1, step=0.05,
|
|
162
|
+
description="Alpha:", style=_SN)
|
|
163
|
+
|
|
164
|
+
def _alpha_cb(change):
|
|
165
|
+
self._alpha = change["new"]
|
|
166
|
+
for p in self._group.artists:
|
|
167
|
+
p.set_alpha(self._alpha)
|
|
168
|
+
self._update_bar_info()
|
|
169
|
+
_refresh_legend(self._group.axes)
|
|
170
|
+
self._canvas.force_redraw()
|
|
171
|
+
alpha_sl.observe(_alpha_cb, names="value")
|
|
172
|
+
controls.append(_slider_num(alpha_sl))
|
|
173
|
+
|
|
174
|
+
# --- Hatch ---
|
|
175
|
+
hatches = [
|
|
176
|
+
("none", ""), ("/ / /", "/"), ("\\ \\ \\", "\\"),
|
|
177
|
+
("| | |", "|"), ("- - -", "-"), ("+ + +", "+"),
|
|
178
|
+
("x x x", "x"), ("o o o", "o"), ("O O O", "O"),
|
|
179
|
+
(". . .", "."), ("* * *", "*"),
|
|
180
|
+
("// //", "//"), ("xx xx", "xx"),
|
|
181
|
+
]
|
|
182
|
+
cur_hatch = self._hatch
|
|
183
|
+
if cur_hatch not in [v for _, v in hatches]:
|
|
184
|
+
cur_hatch = ""
|
|
185
|
+
hatch_dd = widgets.Dropdown(
|
|
186
|
+
options=hatches, value=cur_hatch, description="Hatch:",
|
|
187
|
+
style=_SN, layout=widgets.Layout(width="180px"))
|
|
188
|
+
|
|
189
|
+
def _hatch_cb(change):
|
|
190
|
+
self._hatch = change["new"]
|
|
191
|
+
for p in self._group.artists:
|
|
192
|
+
p.set_hatch(self._hatch)
|
|
193
|
+
self._update_bar_info()
|
|
194
|
+
_refresh_legend(self._group.axes)
|
|
195
|
+
self._canvas.force_redraw()
|
|
196
|
+
hatch_dd.observe(_hatch_cb, names="value")
|
|
197
|
+
controls.append(hatch_dd)
|
|
198
|
+
|
|
199
|
+
# --- Linestyle ---
|
|
200
|
+
linestyles = [("solid", "-"), ("dashed", "--"),
|
|
201
|
+
("dotted", ":"), ("dashdot", "-.")]
|
|
202
|
+
_ls_map = {"solid": "-", "dashed": "--", "dotted": ":", "dashdot": "-."}
|
|
203
|
+
cur_ls = _ls_map.get(self._linestyle, self._linestyle)
|
|
204
|
+
if cur_ls not in [v for _, v in linestyles]:
|
|
205
|
+
cur_ls = "-"
|
|
206
|
+
ls_dd = widgets.Dropdown(
|
|
207
|
+
options=linestyles, value=cur_ls, description="Style:",
|
|
208
|
+
style=_SN, layout=widgets.Layout(width="180px"))
|
|
209
|
+
|
|
210
|
+
def _ls_cb(change):
|
|
211
|
+
self._linestyle = change["new"]
|
|
212
|
+
for p in self._group.artists:
|
|
213
|
+
p.set_linestyle(self._linestyle)
|
|
214
|
+
self._update_bar_info()
|
|
215
|
+
_refresh_legend(self._group.axes)
|
|
216
|
+
self._canvas.force_redraw()
|
|
217
|
+
ls_dd.observe(_ls_cb, names="value")
|
|
218
|
+
controls.append(ls_dd)
|
|
219
|
+
|
|
220
|
+
return controls
|
|
221
|
+
|
|
222
|
+
# --- Color section (reused for face + edge) ---
|
|
223
|
+
|
|
224
|
+
def _build_color_section(self, label_text, current_color, is_face):
|
|
225
|
+
"""Color section with swatch palette, matching histogram pattern."""
|
|
226
|
+
|
|
227
|
+
color_btn = widgets.Button(
|
|
228
|
+
layout=widgets.Layout(width='28px', height='28px',
|
|
229
|
+
padding='0', min_width='28px'),
|
|
230
|
+
tooltip="Click to choose color")
|
|
231
|
+
color_btn.style.button_color = current_color
|
|
232
|
+
|
|
233
|
+
color_row = widgets.HBox(
|
|
234
|
+
[widgets.Label(label_text, layout=widgets.Layout(width='42px')),
|
|
235
|
+
color_btn],
|
|
236
|
+
layout=widgets.Layout(align_items='center', gap='4px'))
|
|
237
|
+
|
|
238
|
+
_cmap_name = ["tab10"]
|
|
239
|
+
|
|
240
|
+
def _make_swatches(colors):
|
|
241
|
+
btns = []
|
|
242
|
+
for c in colors:
|
|
243
|
+
b = widgets.Button(
|
|
244
|
+
layout=widgets.Layout(width="18px", height="16px",
|
|
245
|
+
padding="0", margin="1px",
|
|
246
|
+
min_width="18px"))
|
|
247
|
+
b.style.button_color = c
|
|
248
|
+
btns.append(b)
|
|
249
|
+
return btns
|
|
250
|
+
|
|
251
|
+
colors_10 = _get_palette_colors("tab10", 10)
|
|
252
|
+
swatch_buttons = _make_swatches(colors_10)
|
|
253
|
+
colors_20 = _get_palette_colors("tab10", 20)
|
|
254
|
+
extra_buttons = _make_swatches(colors_20[10:])
|
|
255
|
+
|
|
256
|
+
_icon_css = widgets.HTML(
|
|
257
|
+
'<style>'
|
|
258
|
+
'.pb-swatch-btn button {'
|
|
259
|
+
' padding:0 !important;'
|
|
260
|
+
' min-width:0 !important;'
|
|
261
|
+
' overflow:hidden !important;'
|
|
262
|
+
'}'
|
|
263
|
+
'.pb-swatch-btn .fa {'
|
|
264
|
+
' font-size:9px !important;'
|
|
265
|
+
' position:relative !important;'
|
|
266
|
+
' top:-7px !important;'
|
|
267
|
+
'}'
|
|
268
|
+
'</style>')
|
|
269
|
+
|
|
270
|
+
expand_btn = widgets.Button(
|
|
271
|
+
icon="plus", tooltip="Show more colors",
|
|
272
|
+
layout=widgets.Layout(width="18px", height="16px",
|
|
273
|
+
padding="0", min_width="18px",
|
|
274
|
+
margin="1px"))
|
|
275
|
+
expand_btn.style.button_color = "#e0e0e0"
|
|
276
|
+
expand_btn.add_class("pb-swatch-btn")
|
|
277
|
+
|
|
278
|
+
palette_btn = widgets.Button(
|
|
279
|
+
icon="paint-brush", tooltip="Custom color...",
|
|
280
|
+
layout=widgets.Layout(width="18px", height="16px",
|
|
281
|
+
padding="0", min_width="18px",
|
|
282
|
+
margin="1px"))
|
|
283
|
+
palette_btn.style.button_color = "#e8e8e8"
|
|
284
|
+
palette_btn.add_class("pb-swatch-btn")
|
|
285
|
+
|
|
286
|
+
_picker_cls = f"pb-picker-bar-{id(self)}-{'face' if is_face else 'edge'}"
|
|
287
|
+
picker = widgets.ColorPicker(
|
|
288
|
+
value=current_color, concise=True,
|
|
289
|
+
layout=widgets.Layout(width="1px", height="1px",
|
|
290
|
+
overflow="hidden", padding="0",
|
|
291
|
+
margin="0", border="0"))
|
|
292
|
+
picker.add_class(_picker_cls)
|
|
293
|
+
|
|
294
|
+
_js_out = widgets.Output(
|
|
295
|
+
layout=widgets.Layout(height="0px", overflow="hidden"))
|
|
296
|
+
|
|
297
|
+
def _on_palette_btn(b):
|
|
298
|
+
with _js_out:
|
|
299
|
+
_js_out.clear_output()
|
|
300
|
+
from IPython.display import display as ipy_display, Javascript
|
|
301
|
+
ipy_display(Javascript(
|
|
302
|
+
"setTimeout(function(){"
|
|
303
|
+
"var el=document.querySelector('.%s input[type=\"color\"]');"
|
|
304
|
+
"if(el)el.click();"
|
|
305
|
+
"},150);" % _picker_cls))
|
|
306
|
+
palette_btn.on_click(_on_palette_btn)
|
|
307
|
+
|
|
308
|
+
extra_row = widgets.HBox(
|
|
309
|
+
extra_buttons,
|
|
310
|
+
layout=widgets.Layout(display='none', padding='1px 0 0 0',
|
|
311
|
+
align_items='center', gap='1px'))
|
|
312
|
+
|
|
313
|
+
main_row = widgets.HBox(
|
|
314
|
+
swatch_buttons + [expand_btn, palette_btn, picker, _icon_css,
|
|
315
|
+
_js_out],
|
|
316
|
+
layout=widgets.Layout(align_items='center', gap='1px'))
|
|
317
|
+
|
|
318
|
+
palette_panel = widgets.VBox(
|
|
319
|
+
[main_row, extra_row],
|
|
320
|
+
layout=widgets.Layout(display='none', padding='2px 0 0 0'))
|
|
321
|
+
|
|
322
|
+
_updating = [False]
|
|
323
|
+
|
|
324
|
+
def _sync_controls(hex_val):
|
|
325
|
+
_updating[0] = True
|
|
326
|
+
try:
|
|
327
|
+
color_btn.style.button_color = hex_val
|
|
328
|
+
picker.value = hex_val
|
|
329
|
+
if is_face:
|
|
330
|
+
self._color_indicator.value = _make_color_dot(hex_val)
|
|
331
|
+
finally:
|
|
332
|
+
_updating[0] = False
|
|
333
|
+
|
|
334
|
+
def _apply_color(hex_val):
|
|
335
|
+
if is_face:
|
|
336
|
+
self._color = hex_val
|
|
337
|
+
for p in self._group.artists:
|
|
338
|
+
p.set_facecolor(hex_val)
|
|
339
|
+
else:
|
|
340
|
+
self._edgecolor = hex_val
|
|
341
|
+
for p in self._group.artists:
|
|
342
|
+
p.set_edgecolor(hex_val)
|
|
343
|
+
self._update_bar_info()
|
|
344
|
+
_refresh_legend(self._group.axes)
|
|
345
|
+
self._canvas.force_redraw()
|
|
346
|
+
|
|
347
|
+
def _wire_swatch(btn):
|
|
348
|
+
def _on_swatch(b, _btn=btn):
|
|
349
|
+
c = _btn.style.button_color
|
|
350
|
+
_sync_controls(c)
|
|
351
|
+
_apply_color(c)
|
|
352
|
+
btn.on_click(_on_swatch)
|
|
353
|
+
for b in swatch_buttons + extra_buttons:
|
|
354
|
+
_wire_swatch(b)
|
|
355
|
+
|
|
356
|
+
def _on_expand(b):
|
|
357
|
+
cname = _cmap_name[0]
|
|
358
|
+
if extra_row.layout.display == 'none':
|
|
359
|
+
c20 = _get_palette_colors(cname, 20)
|
|
360
|
+
for i, btn in enumerate(swatch_buttons):
|
|
361
|
+
btn.style.button_color = c20[i]
|
|
362
|
+
for i, btn in enumerate(extra_buttons):
|
|
363
|
+
btn.style.button_color = c20[10 + i]
|
|
364
|
+
extra_row.layout.display = ''
|
|
365
|
+
expand_btn.icon = 'minus'
|
|
366
|
+
else:
|
|
367
|
+
c10 = _get_palette_colors(cname, 10)
|
|
368
|
+
for i, btn in enumerate(swatch_buttons):
|
|
369
|
+
btn.style.button_color = c10[i]
|
|
370
|
+
extra_row.layout.display = 'none'
|
|
371
|
+
expand_btn.icon = 'plus'
|
|
372
|
+
expand_btn.on_click(_on_expand)
|
|
373
|
+
|
|
374
|
+
def _from_picker(change):
|
|
375
|
+
if _updating[0]:
|
|
376
|
+
return
|
|
377
|
+
_sync_controls(change["new"])
|
|
378
|
+
_apply_color(change["new"])
|
|
379
|
+
picker.observe(_from_picker, names="value")
|
|
380
|
+
|
|
381
|
+
def _toggle_palette(btn):
|
|
382
|
+
if palette_panel.layout.display == 'none':
|
|
383
|
+
palette_panel.layout.display = ''
|
|
384
|
+
else:
|
|
385
|
+
palette_panel.layout.display = 'none'
|
|
386
|
+
color_btn.on_click(_toggle_palette)
|
|
387
|
+
|
|
388
|
+
# External hooks for ColormapPanel
|
|
389
|
+
if is_face:
|
|
390
|
+
self._update_color = _sync_controls
|
|
391
|
+
|
|
392
|
+
def _ext_update_palette(cmap_name):
|
|
393
|
+
_cmap_name[0] = cmap_name
|
|
394
|
+
is_expanded = extra_row.layout.display != 'none'
|
|
395
|
+
if is_expanded:
|
|
396
|
+
c20 = _get_palette_colors(cmap_name, 20)
|
|
397
|
+
for i, btn in enumerate(swatch_buttons):
|
|
398
|
+
btn.style.button_color = c20[i]
|
|
399
|
+
for i, btn in enumerate(extra_buttons):
|
|
400
|
+
btn.style.button_color = c20[10 + i]
|
|
401
|
+
else:
|
|
402
|
+
c10 = _get_palette_colors(cmap_name, 10)
|
|
403
|
+
for i, btn in enumerate(swatch_buttons):
|
|
404
|
+
btn.style.button_color = c10[i]
|
|
405
|
+
c20 = _get_palette_colors(cmap_name, 20)
|
|
406
|
+
for i, btn in enumerate(extra_buttons):
|
|
407
|
+
btn.style.button_color = c20[10 + i]
|
|
408
|
+
self._update_palette = _ext_update_palette
|
|
409
|
+
|
|
410
|
+
return widgets.VBox([color_row, palette_panel])
|
|
411
|
+
|
|
412
|
+
# --- Bar info storage (for code gen) ---
|
|
413
|
+
|
|
414
|
+
def _store_bar_info(self):
|
|
415
|
+
"""Store bar info on axes for code generation (initial setup)."""
|
|
416
|
+
ax = self._group.axes
|
|
417
|
+
|
|
418
|
+
# Detect tick labels from axes
|
|
419
|
+
orient = self._orientation
|
|
420
|
+
if orient == "vertical":
|
|
421
|
+
tick_labels = [t.get_text() for t in ax.get_xticklabels()]
|
|
422
|
+
else:
|
|
423
|
+
tick_labels = [t.get_text() for t in ax.get_yticklabels()]
|
|
424
|
+
|
|
425
|
+
# Compute tick centers as mean of all groups' positions on these axes
|
|
426
|
+
if all(not t for t in tick_labels):
|
|
427
|
+
tick_labels = [str(round(p, 2)) for p in self._positions]
|
|
428
|
+
|
|
429
|
+
bar_info = {
|
|
430
|
+
"_group_id": id(self._group),
|
|
431
|
+
"values": list(self._values),
|
|
432
|
+
"positions": list(self._positions),
|
|
433
|
+
"bottoms": list(self._bottoms),
|
|
434
|
+
"bar_width": self._bar_width,
|
|
435
|
+
"bar_gap": 0.0, # computed later by BarSharedPanel
|
|
436
|
+
"orientation": self._orientation,
|
|
437
|
+
"color": self._color,
|
|
438
|
+
"edgecolor": self._edgecolor,
|
|
439
|
+
"linewidth": self._edge_width,
|
|
440
|
+
"alpha": self._alpha,
|
|
441
|
+
"label": self._label,
|
|
442
|
+
"hatch": self._hatch,
|
|
443
|
+
"linestyle": self._linestyle,
|
|
444
|
+
"zorder": self._zorder,
|
|
445
|
+
"tick_labels": tick_labels,
|
|
446
|
+
"tick_centers": list(self._positions),
|
|
447
|
+
"tick_rotation": 0,
|
|
448
|
+
"tick_ha": "center",
|
|
449
|
+
"tick_pad": 4.0,
|
|
450
|
+
}
|
|
451
|
+
if not hasattr(ax, '_matplotly_bar_info'):
|
|
452
|
+
ax._matplotly_bar_info = []
|
|
453
|
+
ax._matplotly_bar_info.append(bar_info)
|
|
454
|
+
|
|
455
|
+
def _update_bar_info(self):
|
|
456
|
+
"""Update the bar_info entry on axes after a visual change."""
|
|
457
|
+
ax = self._group.axes
|
|
458
|
+
if not hasattr(ax, '_matplotly_bar_info'):
|
|
459
|
+
return
|
|
460
|
+
for i, info in enumerate(ax._matplotly_bar_info):
|
|
461
|
+
if info.get("_group_id") == id(self._group):
|
|
462
|
+
info["values"] = list(self._values)
|
|
463
|
+
info["positions"] = list(self._positions)
|
|
464
|
+
info["bottoms"] = list(self._bottoms)
|
|
465
|
+
info["bar_width"] = self._bar_width
|
|
466
|
+
info["orientation"] = self._orientation
|
|
467
|
+
info["color"] = self._color
|
|
468
|
+
info["edgecolor"] = self._edgecolor
|
|
469
|
+
info["linewidth"] = self._edge_width
|
|
470
|
+
info["alpha"] = self._alpha
|
|
471
|
+
info["label"] = self._label
|
|
472
|
+
info["hatch"] = self._hatch
|
|
473
|
+
info["linestyle"] = self._linestyle
|
|
474
|
+
info["zorder"] = self._zorder
|
|
475
|
+
break
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
class BarSharedPanel:
|
|
479
|
+
"""Shared structural controls applied to all bar groups on the same axes.
|
|
480
|
+
|
|
481
|
+
Controls: bar width, gap (grouped only), orientation, tick labels, tick rotation.
|
|
482
|
+
"""
|
|
483
|
+
|
|
484
|
+
def __init__(self, panels: list[BarPanel], canvas):
|
|
485
|
+
self._panels = panels
|
|
486
|
+
self._canvas = canvas
|
|
487
|
+
self._ax = panels[0]._group.axes
|
|
488
|
+
n_groups = len(panels)
|
|
489
|
+
|
|
490
|
+
# Read initial values
|
|
491
|
+
ref = panels[0]
|
|
492
|
+
self._bar_width = ref._bar_width
|
|
493
|
+
self._orientation = ref._orientation
|
|
494
|
+
self._n_ticks = len(ref._positions) if ref._positions else 0
|
|
495
|
+
|
|
496
|
+
# Compute tick centers (mean of all groups' positions per tick)
|
|
497
|
+
if n_groups > 1 and self._n_ticks > 0:
|
|
498
|
+
all_pos = np.array([p._positions for p in panels])
|
|
499
|
+
self._tick_centers = np.mean(all_pos, axis=0).tolist()
|
|
500
|
+
# Compute initial gap
|
|
501
|
+
if len(panels[0]._positions) > 0 and len(panels[1]._positions) > 0:
|
|
502
|
+
self._bar_gap = abs(
|
|
503
|
+
panels[1]._positions[0] - panels[0]._positions[0]
|
|
504
|
+
) - self._bar_width
|
|
505
|
+
if self._bar_gap < 0:
|
|
506
|
+
self._bar_gap = 0.0
|
|
507
|
+
else:
|
|
508
|
+
self._bar_gap = 0.0
|
|
509
|
+
else:
|
|
510
|
+
self._tick_centers = list(ref._positions)
|
|
511
|
+
self._bar_gap = 0.0
|
|
512
|
+
|
|
513
|
+
# Detect tick labels
|
|
514
|
+
if self._orientation == "vertical":
|
|
515
|
+
labels = [t.get_text() for t in self._ax.get_xticklabels()]
|
|
516
|
+
else:
|
|
517
|
+
labels = [t.get_text() for t in self._ax.get_yticklabels()]
|
|
518
|
+
if all(not t for t in labels):
|
|
519
|
+
labels = [str(round(p, 2)) for p in self._tick_centers]
|
|
520
|
+
self._tick_labels = labels[:self._n_ticks]
|
|
521
|
+
|
|
522
|
+
# Detect tick rotation
|
|
523
|
+
if self._orientation == "vertical":
|
|
524
|
+
tick_objs = self._ax.get_xticklabels()
|
|
525
|
+
else:
|
|
526
|
+
tick_objs = self._ax.get_yticklabels()
|
|
527
|
+
self._tick_rotation = int(tick_objs[0].get_rotation()) if tick_objs else 0
|
|
528
|
+
self._tick_ha = tick_objs[0].get_ha() if tick_objs else "center"
|
|
529
|
+
self._tick_pad = 4.0 # default tick padding in points
|
|
530
|
+
|
|
531
|
+
def build(self) -> widgets.Widget:
|
|
532
|
+
controls = []
|
|
533
|
+
n_groups = len(self._panels)
|
|
534
|
+
|
|
535
|
+
# --- Bar width ---
|
|
536
|
+
# Max width = 1/n_groups so bars from adjacent ticks don't overlap
|
|
537
|
+
max_w = round(1.0 / n_groups, 2)
|
|
538
|
+
width_sl = widgets.FloatSlider(
|
|
539
|
+
value=min(round(self._bar_width, 2), max_w),
|
|
540
|
+
min=0.05, max=max_w, step=0.05,
|
|
541
|
+
description="Width:", style=_SN,
|
|
542
|
+
continuous_update=True)
|
|
543
|
+
|
|
544
|
+
def _width_cb(change):
|
|
545
|
+
self._bar_width = change["new"]
|
|
546
|
+
self._redraw_bars()
|
|
547
|
+
width_sl.observe(_width_cb, names="value")
|
|
548
|
+
controls.append(_slider_num(width_sl))
|
|
549
|
+
|
|
550
|
+
# --- Bar gap (only for grouped bars) ---
|
|
551
|
+
if n_groups > 1:
|
|
552
|
+
gap_sl = widgets.FloatSlider(
|
|
553
|
+
value=round(self._bar_gap, 2), min=0.0, max=0.5, step=0.05,
|
|
554
|
+
description="Gap:", style=_SN,
|
|
555
|
+
continuous_update=True)
|
|
556
|
+
|
|
557
|
+
def _gap_cb(change):
|
|
558
|
+
self._bar_gap = change["new"]
|
|
559
|
+
self._redraw_bars()
|
|
560
|
+
gap_sl.observe(_gap_cb, names="value")
|
|
561
|
+
controls.append(_slider_num(gap_sl))
|
|
562
|
+
|
|
563
|
+
# --- Orientation ---
|
|
564
|
+
orient_dd = widgets.Dropdown(
|
|
565
|
+
options=[("vertical", "vertical"), ("horizontal", "horizontal")],
|
|
566
|
+
value=self._orientation, description="Orient:",
|
|
567
|
+
style=_SN, layout=widgets.Layout(width="180px"))
|
|
568
|
+
|
|
569
|
+
def _orient_cb(change):
|
|
570
|
+
self._orientation = change["new"]
|
|
571
|
+
self._redraw_bars()
|
|
572
|
+
orient_dd.observe(_orient_cb, names="value")
|
|
573
|
+
controls.append(orient_dd)
|
|
574
|
+
|
|
575
|
+
# --- Tick labels ---
|
|
576
|
+
if self._n_ticks > 0:
|
|
577
|
+
tick_widgets = []
|
|
578
|
+
self._tick_fields = []
|
|
579
|
+
for k in range(self._n_ticks):
|
|
580
|
+
lbl = self._tick_labels[k] if k < len(self._tick_labels) else ""
|
|
581
|
+
tw = widgets.Text(
|
|
582
|
+
value=lbl,
|
|
583
|
+
layout=widgets.Layout(width="70px"))
|
|
584
|
+
self._tick_fields.append(tw)
|
|
585
|
+
|
|
586
|
+
def _tick_cb(change, idx=k):
|
|
587
|
+
if idx < len(self._tick_labels):
|
|
588
|
+
self._tick_labels[idx] = change["new"]
|
|
589
|
+
self._apply_tick_labels()
|
|
590
|
+
tw.observe(_tick_cb, names="value")
|
|
591
|
+
tick_widgets.append(tw)
|
|
592
|
+
|
|
593
|
+
ticks_row = widgets.HBox(
|
|
594
|
+
[widgets.Label("Ticks:", layout=widgets.Layout(width='42px'))]
|
|
595
|
+
+ tick_widgets,
|
|
596
|
+
layout=widgets.Layout(flex_flow='row wrap', gap='2px'))
|
|
597
|
+
controls.append(ticks_row)
|
|
598
|
+
|
|
599
|
+
# --- Tick rotation ---
|
|
600
|
+
rot_sl = widgets.IntSlider(
|
|
601
|
+
value=self._tick_rotation, min=-90, max=90, step=5,
|
|
602
|
+
description="Rot:", style=_SN,
|
|
603
|
+
continuous_update=True)
|
|
604
|
+
|
|
605
|
+
def _rot_cb(change):
|
|
606
|
+
self._tick_rotation = change["new"]
|
|
607
|
+
self._apply_tick_labels()
|
|
608
|
+
rot_sl.observe(_rot_cb, names="value")
|
|
609
|
+
controls.append(_slider_num(rot_sl))
|
|
610
|
+
|
|
611
|
+
# --- Tick alignment ---
|
|
612
|
+
align_dd = widgets.Dropdown(
|
|
613
|
+
options=[("center", "center"), ("right", "right"),
|
|
614
|
+
("left", "left")],
|
|
615
|
+
value=self._tick_ha, description="Align:",
|
|
616
|
+
style=_SN, layout=widgets.Layout(width="180px"))
|
|
617
|
+
|
|
618
|
+
def _align_cb(change):
|
|
619
|
+
self._tick_ha = change["new"]
|
|
620
|
+
self._apply_tick_labels()
|
|
621
|
+
align_dd.observe(_align_cb, names="value")
|
|
622
|
+
controls.append(align_dd)
|
|
623
|
+
|
|
624
|
+
# --- Tick pad (distance from axis, in points) ---
|
|
625
|
+
pad_sl = widgets.FloatSlider(
|
|
626
|
+
value=self._tick_pad, min=0.0, max=20.0, step=0.5,
|
|
627
|
+
description="Pad:", style=_SN,
|
|
628
|
+
continuous_update=True)
|
|
629
|
+
|
|
630
|
+
def _pad_cb(change):
|
|
631
|
+
self._tick_pad = change["new"]
|
|
632
|
+
self._apply_tick_labels()
|
|
633
|
+
pad_sl.observe(_pad_cb, names="value")
|
|
634
|
+
controls.append(_slider_num(pad_sl))
|
|
635
|
+
|
|
636
|
+
return widgets.VBox(
|
|
637
|
+
controls,
|
|
638
|
+
layout=widgets.Layout(padding='4px 4px 4px 8px'))
|
|
639
|
+
|
|
640
|
+
def _apply_tick_labels(self):
|
|
641
|
+
"""Update tick labels, rotation, alignment, and pad on the axes."""
|
|
642
|
+
ax = self._ax
|
|
643
|
+
tc = np.array(self._tick_centers)
|
|
644
|
+
# Use rotation_mode='anchor' when ha is not center for clean rotated labels
|
|
645
|
+
rot_mode = 'anchor' if self._tick_ha != 'center' else 'default'
|
|
646
|
+
if self._orientation == "vertical":
|
|
647
|
+
ax.set_xticks(tc)
|
|
648
|
+
ax.set_xticklabels(self._tick_labels,
|
|
649
|
+
rotation=self._tick_rotation,
|
|
650
|
+
ha=self._tick_ha,
|
|
651
|
+
rotation_mode=rot_mode)
|
|
652
|
+
ax.tick_params(axis='x', pad=self._tick_pad)
|
|
653
|
+
else:
|
|
654
|
+
ax.set_yticks(tc)
|
|
655
|
+
ax.set_yticklabels(self._tick_labels,
|
|
656
|
+
rotation=self._tick_rotation,
|
|
657
|
+
ha=self._tick_ha,
|
|
658
|
+
rotation_mode=rot_mode)
|
|
659
|
+
ax.tick_params(axis='y', pad=self._tick_pad)
|
|
660
|
+
# Update bar info with new tick labels/centers
|
|
661
|
+
for info in getattr(ax, '_matplotly_bar_info', []):
|
|
662
|
+
info['tick_labels'] = list(self._tick_labels)
|
|
663
|
+
info['tick_centers'] = list(self._tick_centers)
|
|
664
|
+
info['tick_rotation'] = self._tick_rotation
|
|
665
|
+
info['tick_ha'] = self._tick_ha
|
|
666
|
+
info['tick_pad'] = self._tick_pad
|
|
667
|
+
self._canvas.force_redraw()
|
|
668
|
+
|
|
669
|
+
def _clear_all_bar_patches(self):
|
|
670
|
+
"""Remove all bar-chart BarContainers and patches (skip histograms)."""
|
|
671
|
+
from matplotlib.container import BarContainer
|
|
672
|
+
from .._introspect import FigureIntrospector as _FI
|
|
673
|
+
ax = self._ax
|
|
674
|
+
|
|
675
|
+
for c in list(ax.containers):
|
|
676
|
+
if isinstance(c, BarContainer) and not _FI._is_histogram_container(c):
|
|
677
|
+
for p in c:
|
|
678
|
+
p.remove()
|
|
679
|
+
ax.containers[:] = [
|
|
680
|
+
c for c in ax.containers
|
|
681
|
+
if not isinstance(c, BarContainer)
|
|
682
|
+
or _FI._is_histogram_container(c)
|
|
683
|
+
]
|
|
684
|
+
|
|
685
|
+
def _redraw_bars(self):
|
|
686
|
+
"""Recompute positions and recreate all bar groups."""
|
|
687
|
+
from matplotlib.container import BarContainer
|
|
688
|
+
ax = self._ax
|
|
689
|
+
n_groups = len(self._panels)
|
|
690
|
+
n_ticks = self._n_ticks
|
|
691
|
+
bw = self._bar_width
|
|
692
|
+
bg = self._bar_gap
|
|
693
|
+
|
|
694
|
+
# Remove existing bar patches
|
|
695
|
+
self._clear_all_bar_patches()
|
|
696
|
+
|
|
697
|
+
# Compute new positions
|
|
698
|
+
tick_centers = np.arange(n_ticks, dtype=float)
|
|
699
|
+
self._tick_centers = tick_centers.tolist()
|
|
700
|
+
|
|
701
|
+
for j, panel in enumerate(self._panels):
|
|
702
|
+
offset = (j - (n_groups - 1) / 2) * (bw + bg)
|
|
703
|
+
positions = tick_centers + offset
|
|
704
|
+
|
|
705
|
+
# Update panel state
|
|
706
|
+
panel._positions = positions.tolist()
|
|
707
|
+
panel._bar_width = bw
|
|
708
|
+
panel._orientation = self._orientation
|
|
709
|
+
|
|
710
|
+
# Build kwargs
|
|
711
|
+
kwargs = {
|
|
712
|
+
"width" if self._orientation == "vertical" else "height": bw,
|
|
713
|
+
"color": panel._color,
|
|
714
|
+
"edgecolor": panel._edgecolor,
|
|
715
|
+
"linewidth": panel._edge_width,
|
|
716
|
+
"alpha": panel._alpha,
|
|
717
|
+
"label": panel._label,
|
|
718
|
+
"zorder": panel._zorder,
|
|
719
|
+
}
|
|
720
|
+
if panel._hatch:
|
|
721
|
+
kwargs["hatch"] = panel._hatch
|
|
722
|
+
if panel._linestyle not in ("-", "solid"):
|
|
723
|
+
kwargs["linestyle"] = panel._linestyle
|
|
724
|
+
|
|
725
|
+
values = panel._values
|
|
726
|
+
if self._orientation == "vertical":
|
|
727
|
+
container = ax.bar(positions, values, **kwargs)
|
|
728
|
+
else:
|
|
729
|
+
container = ax.barh(positions, values, **kwargs)
|
|
730
|
+
|
|
731
|
+
# Update artist references
|
|
732
|
+
new_artists = list(container.patches)
|
|
733
|
+
panel._group.artists = new_artists
|
|
734
|
+
panel._group.metadata["container"] = container
|
|
735
|
+
panel._group.metadata["positions"] = panel._positions
|
|
736
|
+
panel._group.metadata["bar_width"] = bw
|
|
737
|
+
panel._group.metadata["orientation"] = self._orientation
|
|
738
|
+
|
|
739
|
+
# Update bar info for code gen
|
|
740
|
+
panel._update_bar_info()
|
|
741
|
+
|
|
742
|
+
# Update tick labels/positions and reset the opposite axis
|
|
743
|
+
from matplotlib.ticker import AutoLocator, ScalarFormatter
|
|
744
|
+
rot_mode = 'anchor' if self._tick_ha != 'center' else 'default'
|
|
745
|
+
if self._orientation == "vertical":
|
|
746
|
+
ax.set_xticks(tick_centers)
|
|
747
|
+
ax.set_xticklabels(self._tick_labels,
|
|
748
|
+
rotation=self._tick_rotation,
|
|
749
|
+
ha=self._tick_ha,
|
|
750
|
+
rotation_mode=rot_mode)
|
|
751
|
+
ax.tick_params(axis='x', pad=self._tick_pad)
|
|
752
|
+
# Reset y-axis to automatic ticking + clear rotation
|
|
753
|
+
ax.yaxis.set_major_locator(AutoLocator())
|
|
754
|
+
ax.yaxis.set_major_formatter(ScalarFormatter())
|
|
755
|
+
ax.tick_params(axis='y', rotation=0, pad=4.0)
|
|
756
|
+
for t in ax.get_yticklabels():
|
|
757
|
+
t.set_ha('center')
|
|
758
|
+
t.set_rotation_mode('default')
|
|
759
|
+
else:
|
|
760
|
+
ax.set_yticks(tick_centers)
|
|
761
|
+
ax.set_yticklabels(self._tick_labels,
|
|
762
|
+
rotation=self._tick_rotation,
|
|
763
|
+
ha=self._tick_ha,
|
|
764
|
+
rotation_mode=rot_mode)
|
|
765
|
+
ax.tick_params(axis='y', pad=self._tick_pad)
|
|
766
|
+
# Reset x-axis to automatic ticking + clear rotation
|
|
767
|
+
ax.xaxis.set_major_locator(AutoLocator())
|
|
768
|
+
ax.xaxis.set_major_formatter(ScalarFormatter())
|
|
769
|
+
ax.tick_params(axis='x', rotation=0, pad=4.0)
|
|
770
|
+
for t in ax.get_xticklabels():
|
|
771
|
+
t.set_ha('center')
|
|
772
|
+
t.set_rotation_mode('default')
|
|
773
|
+
|
|
774
|
+
# Update bar info with tick centers
|
|
775
|
+
for info in getattr(ax, '_matplotly_bar_info', []):
|
|
776
|
+
info['tick_labels'] = list(self._tick_labels)
|
|
777
|
+
info['tick_centers'] = self._tick_centers
|
|
778
|
+
info['bar_width'] = bw
|
|
779
|
+
info['bar_gap'] = bg
|
|
780
|
+
info['orientation'] = self._orientation
|
|
781
|
+
info['tick_rotation'] = self._tick_rotation
|
|
782
|
+
info['tick_ha'] = self._tick_ha
|
|
783
|
+
info['tick_pad'] = self._tick_pad
|
|
784
|
+
|
|
785
|
+
ax.relim()
|
|
786
|
+
ax.autoscale_view()
|
|
787
|
+
_refresh_legend(ax)
|
|
788
|
+
self._canvas.force_redraw()
|