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,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()