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,709 @@
1
+ """Line plot controls — collapsible per-curve sections with compact color UI."""
2
+ from __future__ import annotations
3
+
4
+ import ipywidgets as widgets
5
+ import matplotlib
6
+ from matplotlib.colors import to_hex
7
+
8
+ from .._commands import BatchCommand, Command
9
+ from .._types import ArtistGroup, PlotType
10
+ from ._base import ArtistPanel
11
+ from ._color_utils import (
12
+ _COLORMAPS, _DW, _NW, _SN,
13
+ _cmap_color, _get_palette_colors, _make_color_dot,
14
+ _refresh_legend, _slider_num,
15
+ )
16
+
17
+
18
+ class LinePanel(ArtistPanel):
19
+ _plot_number: int = 0 # set by _api.py before build()
20
+ _on_label_changed = None # callback set by _api.py for legend label sync
21
+
22
+ def build(self) -> widgets.Widget:
23
+ line = self._group.artists[0]
24
+ current_label = line.get_label() or self._group.label
25
+
26
+ try:
27
+ current_color = to_hex(line.get_color())
28
+ except Exception:
29
+ current_color = "#1f77b4"
30
+
31
+ # --- Collapsible header ---
32
+ num = self._plot_number or ""
33
+ header_prefix = f"Line {num}: " if num else ""
34
+ self._header_prefix = header_prefix
35
+
36
+ self._color_indicator = widgets.HTML(_make_color_dot(current_color))
37
+
38
+ toggle_btn = widgets.Button(
39
+ description=f" {header_prefix}{current_label}",
40
+ icon="chevron-right",
41
+ layout=widgets.Layout(width='100%', height='28px'))
42
+ toggle_btn.style.button_color = '#f0f0f0'
43
+ toggle_btn.style.font_weight = 'bold'
44
+ self._toggle_btn = toggle_btn
45
+
46
+ header_row = widgets.HBox(
47
+ [self._color_indicator, toggle_btn],
48
+ layout=widgets.Layout(align_items='center', gap='4px'))
49
+
50
+ # --- Controls (collapsed by default) ---
51
+ controls = self._build_controls(line, current_label, current_color)
52
+ controls_box = widgets.VBox(
53
+ controls,
54
+ layout=widgets.Layout(display='none', padding='2px 0 4px 12px'))
55
+ self._controls_box = controls_box
56
+ self._is_expanded = False
57
+
58
+ def _toggle(btn, l=line):
59
+ self._is_expanded = not self._is_expanded
60
+ lbl = l.get_label() or self._group.label
61
+ if self._is_expanded:
62
+ controls_box.layout.display = ''
63
+ toggle_btn.icon = "chevron-down"
64
+ toggle_btn.description = f" {self._header_prefix}{lbl}"
65
+ else:
66
+ controls_box.layout.display = 'none'
67
+ toggle_btn.icon = "chevron-right"
68
+ toggle_btn.description = f" {self._header_prefix}{lbl}"
69
+ toggle_btn.on_click(_toggle)
70
+
71
+ return widgets.VBox(
72
+ [header_row, controls_box],
73
+ layout=widgets.Layout(
74
+ border='1px solid #ddd', border_radius='4px',
75
+ margin='2px 0', padding='2px'))
76
+
77
+ def _build_controls(self, line, current_label, current_color):
78
+ """Build all controls for this line."""
79
+ controls = []
80
+
81
+ # --- Name ---
82
+ name_field = widgets.Text(
83
+ value=current_label, description="Name:",
84
+ style={"description_width": _DW},
85
+ layout=widgets.Layout(width="95%"))
86
+
87
+ def _on_name(change, l=line):
88
+ old_label = l.get_label()
89
+ new_label = change["new"]
90
+ self._stack.execute(
91
+ Command(l, "label", old_label, new_label,
92
+ description=f"{self._group.label} label"))
93
+ pfx = self._header_prefix
94
+ icon = "chevron-down" if self._is_expanded else "chevron-right"
95
+ self._toggle_btn.icon = icon
96
+ self._toggle_btn.description = f" {pfx}{new_label}"
97
+ _refresh_legend(l.axes)
98
+ if self._on_label_changed is not None:
99
+ self._on_label_changed()
100
+ self._canvas.force_redraw()
101
+ name_field.observe(_on_name, names="value")
102
+ controls.append(name_field)
103
+
104
+ # --- Color ---
105
+ controls.append(self._build_color_section(line, current_color))
106
+
107
+ # --- Width ---
108
+ width = widgets.FloatSlider(
109
+ value=round(line.get_linewidth(), 2),
110
+ min=0.1, max=10, step=0.1, description="Width:", style=_SN)
111
+
112
+ def _width_cb(change, l=line):
113
+ self._stack.execute(
114
+ Command(l, "linewidth", l.get_linewidth(), change["new"],
115
+ description=f"{self._group.label} width"))
116
+ _refresh_legend(l.axes)
117
+ self._canvas.force_redraw()
118
+ width.observe(_width_cb, names="value")
119
+ controls.append(_slider_num(width))
120
+
121
+ # --- Style ---
122
+ styles = [("solid", "-"), ("dashed", "--"),
123
+ ("dotted", ":"), ("dashdot", "-.")]
124
+ _ls_map = {"solid": "-", "dashed": "--", "dotted": ":", "dashdot": "-."}
125
+ current_ls = _ls_map.get(line.get_linestyle(), line.get_linestyle())
126
+ if current_ls not in [v for _, v in styles]:
127
+ current_ls = "-"
128
+ style = widgets.Dropdown(
129
+ options=styles, value=current_ls, description="Style:",
130
+ style=_SN, layout=widgets.Layout(width="150px"))
131
+
132
+ def _style_cb(change, l=line):
133
+ self._stack.execute(
134
+ Command(l, "linestyle", l.get_linestyle(), change["new"],
135
+ description=f"{self._group.label} style"))
136
+ _refresh_legend(l.axes)
137
+ self._canvas.force_redraw()
138
+ style.observe(_style_cb, names="value")
139
+ controls.append(style)
140
+
141
+ # --- Alpha ---
142
+ alpha_val = line.get_alpha()
143
+ alpha_sl = widgets.FloatSlider(
144
+ value=round(alpha_val if alpha_val is not None else 1.0, 2),
145
+ min=0, max=1, step=0.05, description="Alpha:", style=_SN)
146
+
147
+ def _alpha_cb(change, l=line):
148
+ self._stack.execute(
149
+ Command(l, "alpha", l.get_alpha(), change["new"],
150
+ description=f"{self._group.label} alpha"))
151
+ _refresh_legend(l.axes)
152
+ self._canvas.force_redraw()
153
+ alpha_sl.observe(_alpha_cb, names="value")
154
+ controls.append(_slider_num(alpha_sl))
155
+
156
+ # --- Marker ---
157
+ markers = [("none", ""), ("circle", "o"), ("square", "s"),
158
+ ("triangle", "^"), ("diamond", "D"), ("plus", "+"),
159
+ ("x", "x"), ("star", "*")]
160
+ cur_m = line.get_marker()
161
+ if cur_m not in [v for _, v in markers]:
162
+ cur_m = ""
163
+ marker = widgets.Dropdown(
164
+ options=markers, value=cur_m, description="Marker:",
165
+ style=_SN, layout=widgets.Layout(width="150px"))
166
+
167
+ def _marker_cb(change, l=line):
168
+ self._stack.execute(
169
+ Command(l, "marker", l.get_marker(), change["new"],
170
+ description=f"{self._group.label} marker"))
171
+ _refresh_legend(l.axes)
172
+ self._canvas.force_redraw()
173
+ marker.observe(_marker_cb, names="value")
174
+ controls.append(marker)
175
+
176
+ # --- Marker size ---
177
+ marker_size = widgets.FloatSlider(
178
+ value=round(line.get_markersize(), 2), min=0, max=20,
179
+ step=0.5, description="Mkr sz:", style=_SN)
180
+
181
+ def _ms_cb(change, l=line):
182
+ self._stack.execute(
183
+ Command(l, "markersize", l.get_markersize(), change["new"],
184
+ description=f"{self._group.label} marker size"))
185
+ _refresh_legend(l.axes)
186
+ self._canvas.force_redraw()
187
+ marker_size.observe(_ms_cb, names="value")
188
+ controls.append(_slider_num(marker_size))
189
+
190
+ return controls
191
+
192
+ def _build_color_section(self, line, current_color):
193
+ """Color: click swatch → palette swatches + expand + colorwheel."""
194
+
195
+ # --- Main color button (shows current color) ---
196
+ color_btn = widgets.Button(
197
+ layout=widgets.Layout(width='28px', height='28px',
198
+ padding='0', min_width='28px'),
199
+ tooltip="Click to choose color")
200
+ color_btn.style.button_color = current_color
201
+
202
+ color_row = widgets.HBox(
203
+ [widgets.Label("Color:", layout=widgets.Layout(width='42px')),
204
+ color_btn],
205
+ layout=widgets.Layout(align_items='center', gap='4px'))
206
+
207
+ # --- Palette: 10 swatches (compact row) + 10 more (expanded row) ---
208
+ _cmap_name = ["tab10"] # mutable, updated by ColormapPanel
209
+
210
+ def _make_swatches(colors):
211
+ btns = []
212
+ for c in colors:
213
+ b = widgets.Button(
214
+ layout=widgets.Layout(width="18px", height="16px",
215
+ padding="0", margin="1px",
216
+ min_width="18px"))
217
+ b.style.button_color = c
218
+ btns.append(b)
219
+ return btns
220
+
221
+ colors_10 = _get_palette_colors("tab10", 10)
222
+ swatch_buttons = _make_swatches(colors_10)
223
+ colors_20 = _get_palette_colors("tab10", 20)
224
+ extra_buttons = _make_swatches(colors_20[10:])
225
+
226
+ # CSS: match swatch size, nudge FA icon up to center it
227
+ _icon_css = widgets.HTML(
228
+ '<style>'
229
+ '.pb-swatch-btn button {'
230
+ ' padding:0 !important;'
231
+ ' min-width:0 !important;'
232
+ ' overflow:hidden !important;'
233
+ '}'
234
+ '.pb-swatch-btn .fa {'
235
+ ' font-size:9px !important;'
236
+ ' position:relative !important;'
237
+ ' top:-7px !important;'
238
+ '}'
239
+ '</style>')
240
+
241
+ # Expand / collapse button (same size as swatch: 18x16)
242
+ expand_btn = widgets.Button(
243
+ icon="plus", tooltip="Show more colors",
244
+ layout=widgets.Layout(width="18px", height="16px",
245
+ padding="0", min_width="18px",
246
+ margin="1px"))
247
+ expand_btn.style.button_color = "#e0e0e0"
248
+ expand_btn.add_class("pb-swatch-btn")
249
+
250
+ # Palette icon button (same size as swatch: 18x16)
251
+ palette_btn = widgets.Button(
252
+ icon="paint-brush", tooltip="Custom color...",
253
+ layout=widgets.Layout(width="18px", height="16px",
254
+ padding="0", min_width="18px",
255
+ margin="1px"))
256
+ palette_btn.style.button_color = "#e8e8e8"
257
+ palette_btn.add_class("pb-swatch-btn")
258
+
259
+ # Hidden color picker (programmatically opened by JS click)
260
+ _picker_cls = f"pb-picker-{id(line)}"
261
+ picker = widgets.ColorPicker(
262
+ value=current_color, concise=True,
263
+ layout=widgets.Layout(width="1px", height="1px",
264
+ overflow="hidden", padding="0",
265
+ margin="0", border="0"))
266
+ picker.add_class(_picker_cls)
267
+
268
+ _js_out = widgets.Output(
269
+ layout=widgets.Layout(height="0px", overflow="hidden"))
270
+
271
+ def _on_palette_btn(b):
272
+ with _js_out:
273
+ _js_out.clear_output()
274
+ from IPython.display import display as ipy_display, Javascript
275
+ ipy_display(Javascript(
276
+ "setTimeout(function(){"
277
+ "var el=document.querySelector('.%s input[type=\"color\"]');"
278
+ "if(el)el.click();"
279
+ "},150);" % _picker_cls))
280
+ palette_btn.on_click(_on_palette_btn)
281
+
282
+ extra_row = widgets.HBox(
283
+ extra_buttons,
284
+ layout=widgets.Layout(display='none', padding='1px 0 0 0',
285
+ align_items='center', gap='1px'))
286
+
287
+ main_row = widgets.HBox(
288
+ swatch_buttons + [expand_btn, palette_btn, picker, _icon_css,
289
+ _js_out],
290
+ layout=widgets.Layout(align_items='center', gap='1px'))
291
+
292
+ palette_panel = widgets.VBox(
293
+ [main_row, extra_row],
294
+ layout=widgets.Layout(display='none', padding='2px 0 0 0'))
295
+
296
+ # --- Sync logic ---
297
+ _updating = [False]
298
+
299
+ def _sync_controls(hex_val):
300
+ """Update all visual controls without triggering callbacks."""
301
+ _updating[0] = True
302
+ try:
303
+ color_btn.style.button_color = hex_val
304
+ picker.value = hex_val
305
+ self._color_indicator.value = _make_color_dot(hex_val)
306
+ finally:
307
+ _updating[0] = False
308
+
309
+ def _apply(hex_val, l=line):
310
+ self._stack.execute(
311
+ Command(l, "color", to_hex(l.get_color()), hex_val,
312
+ description=f"{self._group.label} color"))
313
+ _refresh_legend(l.axes)
314
+ self._canvas.force_redraw()
315
+
316
+ # Wire swatch clicks (both rows)
317
+ def _wire_swatch(btn):
318
+ def _on_swatch(b, _btn=btn):
319
+ c = _btn.style.button_color
320
+ _sync_controls(c)
321
+ _apply(c)
322
+ btn.on_click(_on_swatch)
323
+ for b in swatch_buttons + extra_buttons:
324
+ _wire_swatch(b)
325
+
326
+ # Expand / collapse extra row
327
+ def _on_expand(b):
328
+ cname = _cmap_name[0]
329
+ if extra_row.layout.display == 'none':
330
+ # Expanding: switch row 1 to first 10 of 20-point sampling
331
+ c20 = _get_palette_colors(cname, 20)
332
+ for i, btn in enumerate(swatch_buttons):
333
+ btn.style.button_color = c20[i]
334
+ for i, btn in enumerate(extra_buttons):
335
+ btn.style.button_color = c20[10 + i]
336
+ extra_row.layout.display = ''
337
+ expand_btn.icon = 'minus'
338
+ expand_btn.tooltip = 'Show fewer colors'
339
+ else:
340
+ # Collapsing: switch row 1 back to 10-point full-range sampling
341
+ c10 = _get_palette_colors(cname, 10)
342
+ for i, btn in enumerate(swatch_buttons):
343
+ btn.style.button_color = c10[i]
344
+ extra_row.layout.display = 'none'
345
+ expand_btn.icon = 'plus'
346
+ expand_btn.tooltip = 'Show more colors'
347
+ expand_btn.on_click(_on_expand)
348
+
349
+ # Picker changes
350
+ def _from_picker(change):
351
+ if _updating[0]:
352
+ return
353
+ _sync_controls(change["new"])
354
+ _apply(change["new"])
355
+ picker.observe(_from_picker, names="value")
356
+
357
+ # Toggle palette panel
358
+ def _toggle_palette(btn):
359
+ if palette_panel.layout.display == 'none':
360
+ palette_panel.layout.display = ''
361
+ else:
362
+ palette_panel.layout.display = 'none'
363
+ color_btn.on_click(_toggle_palette)
364
+
365
+ # External update hooks (used by ColormapPanel)
366
+ self._update_color = _sync_controls
367
+
368
+ def _ext_update_palette(cmap_name):
369
+ _cmap_name[0] = cmap_name
370
+ is_expanded = extra_row.layout.display != 'none'
371
+ if is_expanded:
372
+ c20 = _get_palette_colors(cmap_name, 20)
373
+ for i, btn in enumerate(swatch_buttons):
374
+ btn.style.button_color = c20[i]
375
+ for i, btn in enumerate(extra_buttons):
376
+ btn.style.button_color = c20[10 + i]
377
+ else:
378
+ c10 = _get_palette_colors(cmap_name, 10)
379
+ for i, btn in enumerate(swatch_buttons):
380
+ btn.style.button_color = c10[i]
381
+ # Pre-compute row 2 for when it gets expanded
382
+ c20 = _get_palette_colors(cmap_name, 20)
383
+ for i, btn in enumerate(extra_buttons):
384
+ btn.style.button_color = c20[10 + i]
385
+ self._update_palette = _ext_update_palette
386
+
387
+ return widgets.VBox([color_row, palette_panel])
388
+
389
+
390
+ class ColormapPanel:
391
+ """Global colormap selector — compact header with expandable picker.
392
+
393
+ Shows current colormap name + swatches. Click to expand full list.
394
+ Selecting a colormap auto-applies it and collapses the list.
395
+ """
396
+
397
+ def __init__(self, groups: list[ArtistGroup], stack, canvas,
398
+ line_panels: list | None = None):
399
+ self._color_groups = [g for g in groups
400
+ if g.plot_type in (PlotType.LINE, PlotType.SCATTER,
401
+ PlotType.HISTOGRAM,
402
+ PlotType.BAR,
403
+ PlotType.GROUPED_BAR,
404
+ PlotType.BOXPLOT,
405
+ PlotType.VIOLIN,
406
+ PlotType.ERRORBAR)]
407
+ self._stack = stack
408
+ self._canvas = canvas
409
+ self._line_panels = line_panels or []
410
+ self._selected = "tab10"
411
+
412
+ def apply(self, cmap_name: str) -> None:
413
+ """Apply a colormap by name (public API for Preferred Defaults)."""
414
+ self._do_apply(cmap_name)
415
+
416
+ def build(self) -> widgets.Widget:
417
+ if not self._color_groups:
418
+ return widgets.HTML("<i>No series to apply colormap to.</i>")
419
+
420
+ # --- Compact header: name + large swatches on one row, Change below ---
421
+ self._name_html = widgets.HTML(
422
+ f"<b style='font-size:12px'>{self._selected}</b>")
423
+ self._swatch_display = widgets.HTML(
424
+ self._row_swatch(self._selected, n=10, size=20))
425
+
426
+ header_row = widgets.HBox(
427
+ [self._name_html, self._swatch_display],
428
+ layout=widgets.Layout(align_items='center', gap='6px'))
429
+
430
+ self._change_btn = widgets.Button(
431
+ description="Change Colormap", icon="chevron-right",
432
+ layout=widgets.Layout(width="100%", height="26px"))
433
+ self._change_btn.style.button_color = '#f5f5f5'
434
+
435
+ header = widgets.VBox([header_row, self._change_btn])
436
+
437
+ # --- Expandable list of all colormaps ---
438
+ self._row_btns: list[widgets.Button] = []
439
+ self._swatch_btns: list[widgets.Button] = []
440
+ rows = []
441
+ for name in _COLORMAPS:
442
+ # Small clickable name button
443
+ btn = widgets.Button(
444
+ description=name,
445
+ layout=widgets.Layout(width='70px', height='22px',
446
+ padding='0 2px', min_width='70px'),
447
+ tooltip=name)
448
+ btn.style.button_color = 'transparent'
449
+ btn.style.font_weight = 'normal'
450
+ btn.style.font_size = '10px'
451
+ self._row_btns.append(btn)
452
+
453
+ # Swatch gradient bar (HTML) + transparent overlay button
454
+ swatch_html = self._row_swatch(name, n=10, size=20, stretch=True)
455
+ swatch_w = widgets.HTML(
456
+ swatch_html,
457
+ layout=widgets.Layout(flex='1 1 auto', min_width='0'))
458
+ # Invisible button overlaying the swatch for click detection
459
+ swatch_click = widgets.Button(
460
+ description='',
461
+ layout=widgets.Layout(width='100%', height='20px',
462
+ padding='0', margin='-20px 0 0 0'),
463
+ tooltip=f"Apply {name}")
464
+ swatch_click.style.button_color = 'transparent'
465
+ self._swatch_btns.append(swatch_click)
466
+
467
+ swatch_stack = widgets.VBox(
468
+ [swatch_w, swatch_click],
469
+ layout=widgets.Layout(flex='1 1 auto', min_width='0'))
470
+
471
+ row = widgets.HBox(
472
+ [btn, swatch_stack],
473
+ layout=widgets.Layout(align_items='center', gap='6px',
474
+ padding='4px 2px'))
475
+
476
+ def _on_click(b, cmap_name=name):
477
+ self._do_apply(cmap_name)
478
+ self._cmap_list.layout.display = 'none'
479
+ self._change_btn.icon = 'chevron-right'
480
+ btn.on_click(_on_click)
481
+ swatch_click.on_click(_on_click)
482
+ rows.append(row)
483
+
484
+ # Highlight initial selection
485
+ for rb in self._row_btns:
486
+ if rb.description == self._selected:
487
+ rb.style.button_color = '#d4e6f1'
488
+ rb.style.font_weight = 'bold'
489
+
490
+ self._cmap_list = widgets.VBox(
491
+ rows,
492
+ layout=widgets.Layout(
493
+ border='1px solid #ddd', padding='4px',
494
+ display='none')) # collapsed by default
495
+
496
+ def _toggle_list(btn):
497
+ if self._cmap_list.layout.display == 'none':
498
+ self._cmap_list.layout.display = ''
499
+ self._change_btn.icon = 'chevron-down'
500
+ else:
501
+ self._cmap_list.layout.display = 'none'
502
+ self._change_btn.icon = 'chevron-right'
503
+ self._change_btn.on_click(_toggle_list)
504
+
505
+ return widgets.VBox([header, self._cmap_list])
506
+
507
+ def _do_apply(self, cmap_name: str) -> None:
508
+ """Apply colormap to all lines/scatter and update all UI elements."""
509
+ self._selected = cmap_name
510
+ cmap = matplotlib.colormaps.get_cmap(cmap_name)
511
+ n = len(self._color_groups)
512
+ cmds = []
513
+ for i, group in enumerate(self._color_groups):
514
+ new_color = to_hex(_cmap_color(cmap, i, n))
515
+ if group.plot_type == PlotType.LINE:
516
+ artist = group.artists[0] if group.artists else None
517
+ if artist is None:
518
+ continue
519
+ try:
520
+ old_color = to_hex(artist.get_color())
521
+ except Exception:
522
+ old_color = "#000000"
523
+ cmds.append(Command(artist, "color", old_color, new_color,
524
+ description=f"{group.label} color"))
525
+ elif group.plot_type == PlotType.SCATTER:
526
+ artist = group.artists[0] if group.artists else None
527
+ if artist is None:
528
+ continue
529
+ old_fc = artist.get_facecolor().copy()
530
+ _new = new_color # capture for closures
531
+ _old = old_fc
532
+ _art = artist
533
+ # Check if edge color should follow face color
534
+ _panel = (self._line_panels[i]
535
+ if i < len(self._line_panels) else None)
536
+ _sync_edge = (_panel is not None
537
+ and hasattr(_panel, '_edge_manual')
538
+ and not _panel._edge_manual)
539
+ old_ec = artist.get_edgecolor().copy() if _sync_edge else None
540
+ cmds.append(Command(
541
+ artist, "facecolor", old_fc, new_color,
542
+ apply_fn=lambda _a=_art, _n=_new, _se=_sync_edge: (
543
+ _a.set_facecolor(_n),
544
+ _a.set_edgecolor(_n) if _se else None),
545
+ revert_fn=lambda _a=_art, _o=_old, _se=_sync_edge, _oe=old_ec: (
546
+ _a.set_facecolor(_o),
547
+ _a.set_edgecolor(_oe) if _se else None),
548
+ description=f"{group.label} color"))
549
+ elif group.plot_type == PlotType.HISTOGRAM:
550
+ # Apply color to all patches in the histogram
551
+ if not group.artists:
552
+ continue
553
+ artist = group.artists[0]
554
+ _patches = group.artists
555
+ _new = new_color
556
+ _old_colors = [to_hex(p.get_facecolor()) for p in _patches]
557
+ def _apply_hist(_ps=_patches, _c=_new):
558
+ for p in _ps:
559
+ p.set_facecolor(_c)
560
+ def _revert_hist(_ps=_patches, _oc=_old_colors):
561
+ for p, c in zip(_ps, _oc):
562
+ p.set_facecolor(c)
563
+ cmds.append(Command(
564
+ artist, "facecolor", _old_colors, new_color,
565
+ apply_fn=_apply_hist, revert_fn=_revert_hist,
566
+ description=f"{group.label} color"))
567
+ # Also update the panel's internal color state
568
+ _panel = (self._line_panels[i]
569
+ if i < len(self._line_panels) else None)
570
+ if _panel is not None and hasattr(_panel, '_color'):
571
+ _panel._color = new_color
572
+ elif group.plot_type in (PlotType.BAR, PlotType.GROUPED_BAR):
573
+ # Apply color to all patches in the bar group
574
+ if not group.artists:
575
+ continue
576
+ artist = group.artists[0]
577
+ _patches = group.artists
578
+ _new = new_color
579
+ _old_colors = [to_hex(p.get_facecolor()) for p in _patches]
580
+ def _apply_bar(_ps=_patches, _c=_new):
581
+ for p in _ps:
582
+ p.set_facecolor(_c)
583
+ def _revert_bar(_ps=_patches, _oc=_old_colors):
584
+ for p, c in zip(_ps, _oc):
585
+ p.set_facecolor(c)
586
+ cmds.append(Command(
587
+ artist, "facecolor", _old_colors, new_color,
588
+ apply_fn=_apply_bar, revert_fn=_revert_bar,
589
+ description=f"{group.label} color"))
590
+ _panel = (self._line_panels[i]
591
+ if i < len(self._line_panels) else None)
592
+ if _panel is not None and hasattr(_panel, '_color'):
593
+ _panel._color = new_color
594
+ elif group.plot_type in (PlotType.BOXPLOT, PlotType.VIOLIN):
595
+ # Apply color to distribution panel (no artist needed —
596
+ # originals are cleared by _redraw, colours live on panel)
597
+ _panel = (self._line_panels[i]
598
+ if i < len(self._line_panels) else None)
599
+ if _panel is not None:
600
+ _old_bc = getattr(_panel, '_box_color', '#1f77b4')
601
+ _new_c = new_color
602
+ _p = _panel
603
+ def _apply_dist(_p=_p, _c=_new_c):
604
+ _p._box_color = _c
605
+ _p._violin_color = _c
606
+ _p._jitter_color = _c
607
+ if hasattr(_p, '_shared_panel') and _p._shared_panel:
608
+ _p._shared_panel._redraw()
609
+ def _revert_dist(_p=_p, _c=_old_bc):
610
+ _p._box_color = _c
611
+ _p._violin_color = _c
612
+ _p._jitter_color = _c
613
+ if hasattr(_p, '_shared_panel') and _p._shared_panel:
614
+ _p._shared_panel._redraw()
615
+ # Use group.axes as artist placeholder (original
616
+ # artists are removed by _redraw).
617
+ cmds.append(Command(
618
+ group.axes, "color", _old_bc, new_color,
619
+ apply_fn=_apply_dist, revert_fn=_revert_dist,
620
+ description=f"{group.label} color"))
621
+ elif group.plot_type == PlotType.ERRORBAR:
622
+ _panel = (self._line_panels[i]
623
+ if i < len(self._line_panels) else None)
624
+ if _panel is not None:
625
+ _old_ec = getattr(_panel, '_bar_color', '#1f77b4')
626
+ _new_c = new_color
627
+ _p = _panel
628
+ def _apply_eb(_p=_p, _c=_new_c):
629
+ _p._bar_color = _c
630
+ _p._marker_color = _c
631
+ _p._line_color = _c
632
+ _p._shade_color = _c
633
+ _p._redraw()
634
+ def _revert_eb(_p=_p, _c=_old_ec):
635
+ _p._bar_color = _c
636
+ _p._marker_color = _c
637
+ _p._line_color = _c
638
+ _p._shade_color = _c
639
+ _p._redraw()
640
+ cmds.append(Command(
641
+ group.axes, "color", _old_ec, new_color,
642
+ apply_fn=_apply_eb, revert_fn=_revert_eb,
643
+ description=f"{group.label} color"))
644
+ if cmds:
645
+ self._stack.execute(BatchCommand(cmds, "Apply colormap"))
646
+ for ax_set in {g.axes for g in self._color_groups}:
647
+ _refresh_legend(ax_set)
648
+ self._canvas.redraw()
649
+ for i, panel in enumerate(self._line_panels):
650
+ if hasattr(panel, "_update_color") and i < n:
651
+ color = to_hex(_cmap_color(cmap, i, n))
652
+ panel._update_color(color)
653
+ # Sync edge color UI if not manually overridden
654
+ if (hasattr(panel, '_edge_manual')
655
+ and not panel._edge_manual
656
+ and hasattr(panel, '_sync_edge_ui')):
657
+ panel._sync_edge_ui(color)
658
+ if hasattr(panel, "_update_palette"):
659
+ panel._update_palette(cmap_name)
660
+
661
+ # Update header display
662
+ if hasattr(self, '_name_html'):
663
+ self._name_html.value = (
664
+ f"<b style='font-size:12px'>{cmap_name}</b>")
665
+ self._swatch_display.value = self._row_swatch(cmap_name, n=10, size=20)
666
+ # Highlight selection in list
667
+ if hasattr(self, '_row_btns'):
668
+ for rb in self._row_btns:
669
+ if rb.description == cmap_name:
670
+ rb.style.button_color = '#d4e6f1'
671
+ rb.style.font_weight = 'bold'
672
+ else:
673
+ rb.style.button_color = 'transparent'
674
+ rb.style.font_weight = 'normal'
675
+
676
+ @staticmethod
677
+ def _row_swatch(cmap_name: str, n: int = 10, size: int = 18,
678
+ stretch: bool = False) -> str:
679
+ """Generate inline HTML swatches for a colormap.
680
+
681
+ If *stretch* is True, renders as a CSS-gradient bar that fills its
682
+ container width. Otherwise, renders n fixed-width squares of *size*
683
+ pixels each (used for the compact header).
684
+ """
685
+ try:
686
+ cmap = matplotlib.colormaps.get_cmap(cmap_name)
687
+ except Exception:
688
+ return ""
689
+ if stretch:
690
+ # Build a banded CSS gradient that fills full width
691
+ stops = []
692
+ for i in range(n):
693
+ c = to_hex(_cmap_color(cmap, i, n))
694
+ pct0 = round(i / n * 100, 2)
695
+ pct1 = round((i + 1) / n * 100, 2)
696
+ stops.append(f"{c} {pct0}%, {c} {pct1}%")
697
+ grad = ", ".join(stops)
698
+ return (f'<div style="width:100%;height:{size}px;'
699
+ f'border-radius:3px;background:linear-gradient('
700
+ f'to right, {grad})"></div>')
701
+ # Fixed-width squares
702
+ spans = []
703
+ for i in range(n):
704
+ c = to_hex(_cmap_color(cmap, i, n))
705
+ spans.append(
706
+ f'<span style="display:inline-block;width:{size}px;'
707
+ f'height:{size}px;background:{c};margin:0;border-right:'
708
+ f'1px solid rgba(0,0,0,0.05)"></span>')
709
+ return ''.join(spans)