figrecipe 0.7.4__py3-none-any.whl → 0.9.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.
Files changed (143) hide show
  1. figrecipe/__init__.py +74 -76
  2. figrecipe/__main__.py +12 -0
  3. figrecipe/_api/_panel.py +67 -0
  4. figrecipe/_api/_save.py +100 -4
  5. figrecipe/_cli/__init__.py +7 -0
  6. figrecipe/_cli/_compose.py +87 -0
  7. figrecipe/_cli/_convert.py +117 -0
  8. figrecipe/_cli/_crop.py +82 -0
  9. figrecipe/_cli/_edit.py +70 -0
  10. figrecipe/_cli/_extract.py +128 -0
  11. figrecipe/_cli/_fonts.py +47 -0
  12. figrecipe/_cli/_info.py +67 -0
  13. figrecipe/_cli/_main.py +58 -0
  14. figrecipe/_cli/_reproduce.py +79 -0
  15. figrecipe/_cli/_style.py +77 -0
  16. figrecipe/_cli/_validate.py +66 -0
  17. figrecipe/_cli/_version.py +50 -0
  18. figrecipe/_composition/__init__.py +32 -0
  19. figrecipe/_composition/_alignment.py +452 -0
  20. figrecipe/_composition/_compose.py +179 -0
  21. figrecipe/_composition/_import_axes.py +127 -0
  22. figrecipe/_composition/_visibility.py +125 -0
  23. figrecipe/_dev/__init__.py +2 -0
  24. figrecipe/_dev/browser/__init__.py +69 -0
  25. figrecipe/_dev/browser/_audio.py +240 -0
  26. figrecipe/_dev/browser/_caption.py +356 -0
  27. figrecipe/_dev/browser/_click_effect.py +146 -0
  28. figrecipe/_dev/browser/_cursor.py +196 -0
  29. figrecipe/_dev/browser/_highlight.py +105 -0
  30. figrecipe/_dev/browser/_narration.py +237 -0
  31. figrecipe/_dev/browser/_recorder.py +446 -0
  32. figrecipe/_dev/browser/_utils.py +178 -0
  33. figrecipe/_dev/browser/_video_trim/__init__.py +152 -0
  34. figrecipe/_dev/browser/_video_trim/_detection.py +223 -0
  35. figrecipe/_dev/browser/_video_trim/_markers.py +140 -0
  36. figrecipe/_editor/__init__.py +36 -36
  37. figrecipe/_editor/_bbox/_extract.py +155 -9
  38. figrecipe/_editor/_bbox/_extract_text.py +124 -0
  39. figrecipe/_editor/_call_overrides.py +183 -0
  40. figrecipe/_editor/_datatable_plot_handlers.py +249 -0
  41. figrecipe/_editor/_figure_layout.py +211 -0
  42. figrecipe/_editor/_flask_app.py +157 -16
  43. figrecipe/_editor/_helpers.py +17 -8
  44. figrecipe/_editor/_hitmap/_detect.py +89 -32
  45. figrecipe/_editor/_hitmap_main.py +4 -4
  46. figrecipe/_editor/_overrides.py +4 -1
  47. figrecipe/_editor/_plot_types_registry.py +190 -0
  48. figrecipe/_editor/_render_overrides.py +38 -11
  49. figrecipe/_editor/_renderer.py +46 -1
  50. figrecipe/_editor/_routes_annotation.py +114 -0
  51. figrecipe/_editor/_routes_axis.py +35 -6
  52. figrecipe/_editor/_routes_captions.py +130 -0
  53. figrecipe/_editor/_routes_composition.py +270 -0
  54. figrecipe/_editor/_routes_core.py +15 -173
  55. figrecipe/_editor/_routes_datatable.py +364 -0
  56. figrecipe/_editor/_routes_element.py +37 -19
  57. figrecipe/_editor/_routes_files.py +443 -0
  58. figrecipe/_editor/_routes_image.py +200 -0
  59. figrecipe/_editor/_routes_snapshot.py +94 -0
  60. figrecipe/_editor/_routes_style.py +28 -8
  61. figrecipe/_editor/_templates/__init__.py +40 -2
  62. figrecipe/_editor/_templates/_html.py +97 -103
  63. figrecipe/_editor/_templates/_html_components/__init__.py +13 -0
  64. figrecipe/_editor/_templates/_html_components/_composition_toolbar.py +79 -0
  65. figrecipe/_editor/_templates/_html_components/_file_browser.py +41 -0
  66. figrecipe/_editor/_templates/_html_datatable.py +92 -0
  67. figrecipe/_editor/_templates/_scripts/__init__.py +58 -0
  68. figrecipe/_editor/_templates/_scripts/_accordion.py +328 -0
  69. figrecipe/_editor/_templates/_scripts/_annotation_drag.py +504 -0
  70. figrecipe/_editor/_templates/_scripts/_api.py +1 -1
  71. figrecipe/_editor/_templates/_scripts/_canvas_context_menu.py +182 -0
  72. figrecipe/_editor/_templates/_scripts/_captions.py +231 -0
  73. figrecipe/_editor/_templates/_scripts/_composition.py +283 -0
  74. figrecipe/_editor/_templates/_scripts/_core.py +94 -37
  75. figrecipe/_editor/_templates/_scripts/_datatable/__init__.py +59 -0
  76. figrecipe/_editor/_templates/_scripts/_datatable/_cell_edit.py +97 -0
  77. figrecipe/_editor/_templates/_scripts/_datatable/_clipboard.py +164 -0
  78. figrecipe/_editor/_templates/_scripts/_datatable/_context_menu.py +221 -0
  79. figrecipe/_editor/_templates/_scripts/_datatable/_core.py +150 -0
  80. figrecipe/_editor/_templates/_scripts/_datatable/_editable.py +511 -0
  81. figrecipe/_editor/_templates/_scripts/_datatable/_import.py +161 -0
  82. figrecipe/_editor/_templates/_scripts/_datatable/_plot.py +261 -0
  83. figrecipe/_editor/_templates/_scripts/_datatable/_selection.py +438 -0
  84. figrecipe/_editor/_templates/_scripts/_datatable/_table.py +256 -0
  85. figrecipe/_editor/_templates/_scripts/_datatable/_tabs.py +354 -0
  86. figrecipe/_editor/_templates/_scripts/_element_editor.py +17 -2
  87. figrecipe/_editor/_templates/_scripts/_files.py +274 -40
  88. figrecipe/_editor/_templates/_scripts/_files_context_menu.py +240 -0
  89. figrecipe/_editor/_templates/_scripts/_hitmap.py +87 -84
  90. figrecipe/_editor/_templates/_scripts/_image_drop.py +428 -0
  91. figrecipe/_editor/_templates/_scripts/_legend_drag.py +5 -0
  92. figrecipe/_editor/_templates/_scripts/_multi_select.py +198 -0
  93. figrecipe/_editor/_templates/_scripts/_panel_drag.py +219 -48
  94. figrecipe/_editor/_templates/_scripts/_panel_drag_snapshot.py +33 -0
  95. figrecipe/_editor/_templates/_scripts/_panel_position.py +238 -54
  96. figrecipe/_editor/_templates/_scripts/_panel_resize.py +230 -0
  97. figrecipe/_editor/_templates/_scripts/_panel_snap.py +307 -0
  98. figrecipe/_editor/_templates/_scripts/_region_select.py +255 -0
  99. figrecipe/_editor/_templates/_scripts/_selection.py +8 -1
  100. figrecipe/_editor/_templates/_scripts/_sync.py +242 -0
  101. figrecipe/_editor/_templates/_scripts/_undo_redo.py +348 -0
  102. figrecipe/_editor/_templates/_scripts/_zoom.py +52 -19
  103. figrecipe/_editor/_templates/_styles/__init__.py +9 -0
  104. figrecipe/_editor/_templates/_styles/_base.py +47 -0
  105. figrecipe/_editor/_templates/_styles/_buttons.py +127 -6
  106. figrecipe/_editor/_templates/_styles/_composition.py +87 -0
  107. figrecipe/_editor/_templates/_styles/_controls.py +168 -3
  108. figrecipe/_editor/_templates/_styles/_datatable/__init__.py +40 -0
  109. figrecipe/_editor/_templates/_styles/_datatable/_editable.py +203 -0
  110. figrecipe/_editor/_templates/_styles/_datatable/_panel.py +268 -0
  111. figrecipe/_editor/_templates/_styles/_datatable/_table.py +479 -0
  112. figrecipe/_editor/_templates/_styles/_datatable/_toolbar.py +384 -0
  113. figrecipe/_editor/_templates/_styles/_datatable/_vars.py +123 -0
  114. figrecipe/_editor/_templates/_styles/_dynamic_props.py +5 -5
  115. figrecipe/_editor/_templates/_styles/_file_browser.py +466 -0
  116. figrecipe/_editor/_templates/_styles/_forms.py +98 -0
  117. figrecipe/_editor/_templates/_styles/_hitmap.py +7 -0
  118. figrecipe/_editor/_templates/_styles/_modals.py +29 -0
  119. figrecipe/_editor/_templates/_styles/_overlays.py +5 -5
  120. figrecipe/_editor/_templates/_styles/_preview.py +213 -8
  121. figrecipe/_editor/_templates/_styles/_spinner.py +117 -0
  122. figrecipe/_editor/static/audio/click.mp3 +0 -0
  123. figrecipe/_editor/static/click.mp3 +0 -0
  124. figrecipe/_editor/static/icons/favicon.ico +0 -0
  125. figrecipe/_integrations/__init__.py +17 -0
  126. figrecipe/_integrations/_scitex_stats.py +298 -0
  127. figrecipe/_params/_DECORATION_METHODS.py +2 -0
  128. figrecipe/_recorder.py +28 -3
  129. figrecipe/_reproducer/_core.py +60 -49
  130. figrecipe/_utils/__init__.py +3 -0
  131. figrecipe/_utils/_bundle.py +205 -0
  132. figrecipe/_wrappers/_axes.py +150 -2
  133. figrecipe/_wrappers/_caption_generator.py +218 -0
  134. figrecipe/_wrappers/_figure.py +26 -1
  135. figrecipe/_wrappers/_stat_annotation.py +274 -0
  136. figrecipe/styles/_style_applier.py +10 -2
  137. figrecipe/styles/presets/SCITEX.yaml +11 -4
  138. {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/METADATA +144 -146
  139. figrecipe-0.9.0.dist-info/RECORD +277 -0
  140. figrecipe-0.9.0.dist-info/entry_points.txt +2 -0
  141. figrecipe-0.7.4.dist-info/RECORD +0 -188
  142. {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/WHEEL +0 -0
  143. {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/licenses/LICENSE +0 -0
@@ -20,6 +20,7 @@ from ._extract_axes import (
20
20
  extract_patches,
21
21
  )
22
22
  from ._extract_text import (
23
+ extract_annotations,
23
24
  extract_figure_text,
24
25
  extract_legend,
25
26
  extract_spines,
@@ -55,18 +56,35 @@ def extract_bboxes(
55
56
  bboxes = {}
56
57
 
57
58
  # Get renderer for bbox calculations
58
- fig.canvas.draw()
59
+ # Handle matplotlib's Done exception from _get_renderer (can occur with corrupted canvas state)
60
+ try:
61
+ fig.canvas.draw()
62
+ except Exception as e:
63
+ # Matplotlib's Done exception or other draw issues - reset canvas and retry
64
+ if "Done" in str(type(e).__name__) or "Done" in str(e):
65
+ from matplotlib.backends.backend_agg import FigureCanvasAgg
66
+
67
+ fig.set_canvas(FigureCanvasAgg(fig))
68
+ try:
69
+ fig.canvas.draw()
70
+ except Exception:
71
+ pass # Continue with potentially stale renderer
72
+ else:
73
+ raise
59
74
  renderer = fig.canvas.get_renderer()
60
75
 
61
- # Get tight bbox for coordinate transformation
62
- tight_bbox = fig.get_tightbbox(renderer)
63
- if tight_bbox is None:
64
- tight_bbox = Bbox.from_bounds(0, 0, fig.get_figwidth(), fig.get_figheight())
76
+ # Get figure bounds for coordinate transformation
77
+ # NOTE: We do NOT use bbox_inches='tight' in savefig, so use fixed figsize
78
+ fig_width_inches = fig.get_figwidth()
79
+ fig_height_inches = fig.get_figheight()
80
+
81
+ # Create a bbox representing the full figure (for compatibility with element extraction)
82
+ tight_bbox = Bbox.from_bounds(0, 0, fig_width_inches, fig_height_inches)
65
83
 
66
- # bbox_inches='tight' adds pad_inches (default 0.1) around the tight bbox
67
- pad_inches = 0.1
68
- saved_width_inches = tight_bbox.width + 2 * pad_inches
69
- saved_height_inches = tight_bbox.height + 2 * pad_inches
84
+ # No padding since we don't use bbox_inches='tight'
85
+ pad_inches = 0.0
86
+ saved_width_inches = fig_width_inches
87
+ saved_height_inches = fig_height_inches
70
88
 
71
89
  # Calculate scale factors from saved image size to pixel size
72
90
  scale_x = img_width / saved_width_inches if saved_width_inches > 0 else 1
@@ -104,6 +122,16 @@ def extract_bboxes(
104
122
  bboxes,
105
123
  )
106
124
 
125
+ # Compute panel bboxes using tight bbox from matplotlib
126
+ panel_bboxes = _compute_panel_bboxes(
127
+ bboxes,
128
+ len(fig.get_axes()),
129
+ fig=fig,
130
+ renderer=renderer,
131
+ img_width=img_width,
132
+ img_height=img_height,
133
+ )
134
+
107
135
  # Add metadata
108
136
  bboxes["_meta"] = {
109
137
  "img_width": img_width,
@@ -111,6 +139,7 @@ def extract_bboxes(
111
139
  "fig_width_inches": fig.get_figwidth(),
112
140
  "fig_height_inches": fig.get_figheight(),
113
141
  "dpi": fig.dpi,
142
+ "panel_bboxes": panel_bboxes,
114
143
  }
115
144
 
116
145
  return bboxes
@@ -249,6 +278,123 @@ def _extract_axes_bboxes(
249
278
  saved_height_inches,
250
279
  bboxes,
251
280
  )
281
+ extract_annotations(
282
+ ax,
283
+ ax_idx,
284
+ fig,
285
+ renderer,
286
+ tight_bbox,
287
+ img_width,
288
+ img_height,
289
+ scale_x,
290
+ scale_y,
291
+ pad_inches,
292
+ saved_height_inches,
293
+ bboxes,
294
+ )
295
+
296
+
297
+ def _compute_panel_bboxes(
298
+ bboxes: Dict[str, Any],
299
+ num_axes: int,
300
+ fig: Figure = None,
301
+ renderer=None,
302
+ img_width: int = None,
303
+ img_height: int = None,
304
+ ) -> Dict[int, Dict[str, float]]:
305
+ """
306
+ Compute tight bounding box for each panel (axis).
307
+
308
+ Uses matplotlib's ax.get_tightbbox() for reliable panel bounds that include
309
+ all decorators (title, labels, tick labels) without outliers.
310
+
311
+ Falls back to union of elements if tight bbox is unavailable.
312
+
313
+ Parameters
314
+ ----------
315
+ bboxes : dict
316
+ All extracted element bboxes.
317
+ num_axes : int
318
+ Number of axes in the figure.
319
+ fig : Figure, optional
320
+ Matplotlib figure (needed for tight bbox computation).
321
+ renderer : optional
322
+ Matplotlib renderer (needed for tight bbox computation).
323
+ img_width : int, optional
324
+ Image width in pixels.
325
+ img_height : int, optional
326
+ Image height in pixels.
327
+
328
+ Returns
329
+ -------
330
+ dict
331
+ Mapping from ax_index to panel bbox: {ax_index: {x, y, width, height}}
332
+ """
333
+ panel_bboxes = {}
334
+
335
+ # Try to use tight bbox if figure is available
336
+ if fig is not None and renderer is not None and img_width and img_height:
337
+ fig_width_inches = fig.get_figwidth()
338
+ fig_height_inches = fig.get_figheight()
339
+ dpi = fig.dpi
340
+
341
+ for ax_idx, ax in enumerate(fig.get_axes()):
342
+ try:
343
+ tight = ax.get_tightbbox(renderer)
344
+ if tight is not None:
345
+ # Convert from display coords to image coords
346
+ # Display y=0 is at bottom, image y=0 is at top
347
+ x = tight.x0 * img_width / (fig_width_inches * dpi)
348
+ y = img_height - tight.y1 * img_height / (fig_height_inches * dpi)
349
+ width = tight.width * img_width / (fig_width_inches * dpi)
350
+ height = tight.height * img_height / (fig_height_inches * dpi)
351
+ panel_bboxes[ax_idx] = {
352
+ "x": x,
353
+ "y": y,
354
+ "width": width,
355
+ "height": height,
356
+ }
357
+ except Exception:
358
+ pass # Fall back to union method below
359
+
360
+ # Fall back to union method for any missing panels
361
+ for ax_idx in range(num_axes):
362
+ if ax_idx in panel_bboxes:
363
+ continue
364
+
365
+ min_x, min_y = float("inf"), float("inf")
366
+ max_x, max_y = float("-inf"), float("-inf")
367
+
368
+ for key, bbox in bboxes.items():
369
+ if key == "_meta" or not isinstance(bbox, dict):
370
+ continue
371
+ elem_ax_index = bbox.get("ax_index")
372
+ if elem_ax_index is None:
373
+ if key.startswith("ax") and "_" in key:
374
+ try:
375
+ elem_ax_index = int(key.split("_")[0][2:])
376
+ except (ValueError, IndexError):
377
+ continue
378
+ else:
379
+ continue
380
+ if elem_ax_index != ax_idx:
381
+ continue
382
+ x, y = bbox.get("x"), bbox.get("y")
383
+ w, h = bbox.get("width"), bbox.get("height")
384
+ if x is None or y is None or w is None or h is None:
385
+ continue
386
+ min_x, min_y = min(min_x, x), min(min_y, y)
387
+ max_x, max_y = max(max_x, x + w), max(max_y, y + h)
388
+
389
+ if min_x != float("inf"):
390
+ panel_bboxes[ax_idx] = {
391
+ "x": min_x,
392
+ "y": min_y,
393
+ "width": max_x - min_x,
394
+ "height": max_y - min_y,
395
+ }
396
+
397
+ return panel_bboxes
252
398
 
253
399
 
254
400
  __all__ = ["extract_bboxes"]
@@ -332,11 +332,135 @@ def extract_figure_text(
332
332
  pass
333
333
 
334
334
 
335
+ def extract_annotations(
336
+ ax,
337
+ ax_idx,
338
+ fig,
339
+ renderer,
340
+ tight_bbox,
341
+ img_width,
342
+ img_height,
343
+ scale_x,
344
+ scale_y,
345
+ pad_inches,
346
+ saved_height_inches,
347
+ bboxes,
348
+ ):
349
+ """Extract bboxes for decorative elements (ax.text, ax.annotate, panel labels).
350
+
351
+ These are cosmetic elements that can be repositioned without affecting data.
352
+ """
353
+ import string
354
+
355
+ panel_label_chars = set(string.ascii_uppercase)
356
+
357
+ # Extract ax.texts (includes panel labels and other text annotations)
358
+ for i, text_obj in enumerate(ax.texts):
359
+ text_content = text_obj.get_text().strip()
360
+ if not text_content:
361
+ continue
362
+
363
+ try:
364
+ bbox = get_text_bbox(
365
+ text_obj,
366
+ fig,
367
+ renderer,
368
+ tight_bbox,
369
+ img_width,
370
+ img_height,
371
+ scale_x,
372
+ scale_y,
373
+ pad_inches,
374
+ saved_height_inches,
375
+ )
376
+ if bbox:
377
+ # Determine if this is a panel label
378
+ is_panel_label = (
379
+ text_content in panel_label_chars
380
+ and text_obj.get_transform() == ax.transAxes
381
+ )
382
+
383
+ # Get position
384
+ pos = text_obj.get_position()
385
+
386
+ # Calculate axes-relative position (0-1)
387
+ if text_obj.get_transform() == ax.transAxes:
388
+ # Already in axes coordinates
389
+ rel_x, rel_y = pos[0], pos[1]
390
+ else:
391
+ # Convert from data coordinates to axes
392
+ xlim = ax.get_xlim()
393
+ ylim = ax.get_ylim()
394
+ rel_x = (
395
+ (pos[0] - xlim[0]) / (xlim[1] - xlim[0])
396
+ if xlim[1] != xlim[0]
397
+ else 0.5
398
+ )
399
+ rel_y = (
400
+ (pos[1] - ylim[0]) / (ylim[1] - ylim[0])
401
+ if ylim[1] != ylim[0]
402
+ else 0.5
403
+ )
404
+
405
+ if is_panel_label:
406
+ key = f"ax{ax_idx}_panel_label"
407
+ elem_type = "panel_label"
408
+ else:
409
+ key = f"ax{ax_idx}_text_{i}"
410
+ elem_type = "text"
411
+
412
+ bboxes[key] = {
413
+ **bbox,
414
+ "type": elem_type,
415
+ "label": elem_type,
416
+ "ax_index": ax_idx,
417
+ "text": text_content,
418
+ "text_index": i,
419
+ "pos_x": pos[0],
420
+ "pos_y": pos[1],
421
+ "rel_x": rel_x,
422
+ "rel_y": rel_y,
423
+ }
424
+ except Exception:
425
+ pass
426
+
427
+ # Extract ax.patches that are arrows (FancyArrowPatch)
428
+ from matplotlib.patches import FancyArrowPatch
429
+
430
+ for i, patch in enumerate(ax.patches):
431
+ if isinstance(patch, FancyArrowPatch):
432
+ try:
433
+ patch_bbox = patch.get_window_extent(renderer)
434
+ if patch_bbox is not None:
435
+ bbox = transform_bbox(
436
+ patch_bbox,
437
+ fig,
438
+ tight_bbox,
439
+ img_width,
440
+ img_height,
441
+ scale_x,
442
+ scale_y,
443
+ pad_inches,
444
+ saved_height_inches,
445
+ )
446
+ if bbox:
447
+ bboxes[f"ax{ax_idx}_arrow_{i}"] = {
448
+ **bbox,
449
+ "type": "arrow",
450
+ "label": "arrow",
451
+ "ax_index": ax_idx,
452
+ "arrow_index": i,
453
+ }
454
+ except Exception:
455
+ pass
456
+
457
+
335
458
  __all__ = [
336
459
  "extract_text_elements",
337
460
  "extract_legend",
338
461
  "extract_spines",
339
462
  "extract_figure_text",
463
+ "extract_annotations",
340
464
  ]
341
465
 
342
466
  # EOF
@@ -0,0 +1,183 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Call-specific override application for element properties.
5
+
6
+ This is the SINGLE SOURCE OF TRUTH for applying element-level changes.
7
+ Both initial render and re-render use this same function through apply_overrides().
8
+ """
9
+
10
+ from typing import Any, Dict
11
+
12
+ from matplotlib.figure import Figure
13
+
14
+
15
+ def apply_call_overrides(
16
+ fig: Figure, call_overrides: Dict[str, Dict[str, Any]], record: Any
17
+ ) -> None:
18
+ """Apply call-specific overrides to figure elements.
19
+
20
+ Parameters
21
+ ----------
22
+ fig : Figure
23
+ Matplotlib figure.
24
+ call_overrides : dict
25
+ Mapping from call_id to {param: value} overrides.
26
+ record : FigureRecord
27
+ Recording record to find call metadata.
28
+ """
29
+ from matplotlib.patches import Wedge
30
+
31
+ axes_list = fig.get_axes()
32
+
33
+ # Build mapping from ax_key to axes index
34
+ # ax_keys are in format "ax_{row}_{col}", need to map to actual axes indices
35
+ ax_keys_sorted = sorted(record.axes.keys())
36
+ ax_key_to_index = {key: idx for idx, key in enumerate(ax_keys_sorted)}
37
+
38
+ for call_id, params in call_overrides.items():
39
+ # Find the call in record to get function type, ax_index, and call position
40
+ call_function = None
41
+ ax_index = None
42
+ ax_record_found = None
43
+ for ax_key, ax_record in record.axes.items():
44
+ for call in ax_record.calls:
45
+ if call.id == call_id:
46
+ call_function = call.function
47
+ ax_record_found = ax_record
48
+ # Use sorted key order to get correct axes index
49
+ ax_index = ax_key_to_index.get(ax_key, 0)
50
+ break
51
+ if call_function:
52
+ break
53
+
54
+ if call_function is None or ax_index is None or ax_index >= len(axes_list):
55
+ continue
56
+
57
+ ax = axes_list[ax_index]
58
+
59
+ # Apply overrides based on plot type
60
+ for param, value in params.items():
61
+ if call_function in ("bar", "barh", "hist"):
62
+ # Bar/hist creates multiple patches per call - apply to ALL
63
+ _apply_bar_override(ax, ax_record_found, call_id, param, value)
64
+ elif call_function == "plot":
65
+ _apply_line_override(ax, ax_record_found, call_id, param, value)
66
+ elif call_function == "scatter":
67
+ _apply_scatter_override(ax, ax_record_found, call_id, param, value)
68
+ elif call_function == "pie":
69
+ # Pie wedges - apply to all wedges for this call
70
+ wedges = [p for p in ax.patches if isinstance(p, Wedge)]
71
+ for wedge in wedges:
72
+ _apply_patch_param(wedge, param, value)
73
+ elif call_function in ("fill_between", "fill_betweenx"):
74
+ _apply_fill_override(ax, ax_record_found, call_id, param, value)
75
+
76
+
77
+ def _apply_bar_override(ax, ax_record, call_id, param, value):
78
+ """Apply override to bar/hist patches for a specific call."""
79
+ from matplotlib.patches import Rectangle
80
+
81
+ rectangles = [p for p in ax.patches if isinstance(p, Rectangle)]
82
+ if not rectangles:
83
+ return
84
+
85
+ # Find all bar/hist calls to determine grouping
86
+ bar_calls = [c for c in ax_record.calls if c.function in ("bar", "barh", "hist")]
87
+ if not bar_calls:
88
+ return
89
+
90
+ # Find which call index this is
91
+ call_idx = next((i for i, c in enumerate(bar_calls) if c.id == call_id), None)
92
+ if call_idx is None:
93
+ return
94
+
95
+ # Distribute patches among calls
96
+ patches_per_call = len(rectangles) // len(bar_calls) if bar_calls else 1
97
+ start_idx = call_idx * patches_per_call
98
+ end_idx = start_idx + patches_per_call
99
+
100
+ # Apply to all patches for this call
101
+ for patch in rectangles[start_idx:end_idx]:
102
+ _apply_patch_param(patch, param, value)
103
+
104
+
105
+ def _apply_line_override(ax, ax_record, call_id, param, value):
106
+ """Apply override to line for a specific call."""
107
+ lines = [line for line in ax.get_lines() if not line.get_label().startswith("_")]
108
+ line_calls = [c for c in ax_record.calls if c.function == "plot"]
109
+
110
+ call_idx = next((i for i, c in enumerate(line_calls) if c.id == call_id), None)
111
+ if call_idx is not None and call_idx < len(lines):
112
+ _apply_line_param(lines[call_idx], param, value)
113
+
114
+
115
+ def _apply_scatter_override(ax, ax_record, call_id, param, value):
116
+ """Apply override to scatter collection for a specific call."""
117
+ from matplotlib.collections import PathCollection
118
+
119
+ collections = [c for c in ax.collections if isinstance(c, PathCollection)]
120
+ scatter_calls = [c for c in ax_record.calls if c.function == "scatter"]
121
+
122
+ call_idx = next((i for i, c in enumerate(scatter_calls) if c.id == call_id), None)
123
+ if call_idx is not None and call_idx < len(collections):
124
+ _apply_collection_param(collections[call_idx], param, value)
125
+
126
+
127
+ def _apply_fill_override(ax, ax_record, call_id, param, value):
128
+ """Apply override to fill_between for a specific call."""
129
+ from matplotlib.collections import PolyCollection
130
+
131
+ fills = [c for c in ax.collections if isinstance(c, PolyCollection)]
132
+ fill_calls = [
133
+ c for c in ax_record.calls if c.function in ("fill_between", "fill_betweenx")
134
+ ]
135
+
136
+ call_idx = next((i for i, c in enumerate(fill_calls) if c.id == call_id), None)
137
+ if call_idx is not None and call_idx < len(fills):
138
+ _apply_collection_param(fills[call_idx], param, value)
139
+
140
+
141
+ def _apply_patch_param(patch: Any, param: str, value: Any) -> None:
142
+ """Apply parameter to a patch (bar, wedge, etc.)."""
143
+ if param == "color":
144
+ patch.set_facecolor(value)
145
+ elif param == "edgecolor":
146
+ patch.set_edgecolor(value)
147
+ elif param == "linewidth":
148
+ patch.set_linewidth(value)
149
+ elif param == "alpha":
150
+ patch.set_alpha(value)
151
+
152
+
153
+ def _apply_line_param(line: Any, param: str, value: Any) -> None:
154
+ """Apply parameter to a line."""
155
+ if param == "color":
156
+ line.set_color(value)
157
+ elif param == "linewidth":
158
+ line.set_linewidth(value)
159
+ elif param == "linestyle":
160
+ line.set_linestyle(value)
161
+ elif param == "alpha":
162
+ line.set_alpha(value)
163
+ elif param == "marker":
164
+ line.set_marker(value)
165
+ elif param == "markersize":
166
+ line.set_markersize(value)
167
+
168
+
169
+ def _apply_collection_param(coll: Any, param: str, value: Any) -> None:
170
+ """Apply parameter to a collection (scatter, fill)."""
171
+ if param in ("color", "c"):
172
+ coll.set_facecolors(value)
173
+ elif param == "edgecolor":
174
+ coll.set_edgecolors(value)
175
+ elif param == "s":
176
+ coll.set_sizes([value] if not hasattr(value, "__len__") else value)
177
+ elif param == "alpha":
178
+ coll.set_alpha(value)
179
+
180
+
181
+ __all__ = ["apply_call_overrides"]
182
+
183
+ # EOF