figrecipe 0.5.0__py3-none-any.whl → 0.6.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 (90) hide show
  1. figrecipe/__init__.py +361 -93
  2. figrecipe/_dev/__init__.py +120 -0
  3. figrecipe/_dev/demo_plotters/__init__.py +195 -0
  4. figrecipe/_dev/demo_plotters/plot_acorr.py +24 -0
  5. figrecipe/_dev/demo_plotters/plot_angle_spectrum.py +28 -0
  6. figrecipe/_dev/demo_plotters/plot_bar.py +25 -0
  7. figrecipe/_dev/demo_plotters/plot_barbs.py +30 -0
  8. figrecipe/_dev/demo_plotters/plot_barh.py +25 -0
  9. figrecipe/_dev/demo_plotters/plot_boxplot.py +24 -0
  10. figrecipe/_dev/demo_plotters/plot_cohere.py +29 -0
  11. figrecipe/_dev/demo_plotters/plot_contour.py +30 -0
  12. figrecipe/_dev/demo_plotters/plot_contourf.py +29 -0
  13. figrecipe/_dev/demo_plotters/plot_csd.py +29 -0
  14. figrecipe/_dev/demo_plotters/plot_ecdf.py +24 -0
  15. figrecipe/_dev/demo_plotters/plot_errorbar.py +28 -0
  16. figrecipe/_dev/demo_plotters/plot_eventplot.py +25 -0
  17. figrecipe/_dev/demo_plotters/plot_fill.py +29 -0
  18. figrecipe/_dev/demo_plotters/plot_fill_between.py +30 -0
  19. figrecipe/_dev/demo_plotters/plot_fill_betweenx.py +28 -0
  20. figrecipe/_dev/demo_plotters/plot_hexbin.py +25 -0
  21. figrecipe/_dev/demo_plotters/plot_hist.py +24 -0
  22. figrecipe/_dev/demo_plotters/plot_hist2d.py +25 -0
  23. figrecipe/_dev/demo_plotters/plot_imshow.py +23 -0
  24. figrecipe/_dev/demo_plotters/plot_loglog.py +27 -0
  25. figrecipe/_dev/demo_plotters/plot_magnitude_spectrum.py +28 -0
  26. figrecipe/_dev/demo_plotters/plot_matshow.py +23 -0
  27. figrecipe/_dev/demo_plotters/plot_pcolor.py +29 -0
  28. figrecipe/_dev/demo_plotters/plot_pcolormesh.py +29 -0
  29. figrecipe/_dev/demo_plotters/plot_phase_spectrum.py +28 -0
  30. figrecipe/_dev/demo_plotters/plot_pie.py +23 -0
  31. figrecipe/_dev/demo_plotters/plot_plot.py +27 -0
  32. figrecipe/_dev/demo_plotters/plot_psd.py +29 -0
  33. figrecipe/_dev/demo_plotters/plot_quiver.py +30 -0
  34. figrecipe/_dev/demo_plotters/plot_scatter.py +24 -0
  35. figrecipe/_dev/demo_plotters/plot_semilogx.py +27 -0
  36. figrecipe/_dev/demo_plotters/plot_semilogy.py +27 -0
  37. figrecipe/_dev/demo_plotters/plot_specgram.py +30 -0
  38. figrecipe/_dev/demo_plotters/plot_spy.py +29 -0
  39. figrecipe/_dev/demo_plotters/plot_stackplot.py +29 -0
  40. figrecipe/_dev/demo_plotters/plot_stairs.py +27 -0
  41. figrecipe/_dev/demo_plotters/plot_stem.py +27 -0
  42. figrecipe/_dev/demo_plotters/plot_step.py +27 -0
  43. figrecipe/_dev/demo_plotters/plot_streamplot.py +30 -0
  44. figrecipe/_dev/demo_plotters/plot_tricontour.py +28 -0
  45. figrecipe/_dev/demo_plotters/plot_tricontourf.py +28 -0
  46. figrecipe/_dev/demo_plotters/plot_tripcolor.py +29 -0
  47. figrecipe/_dev/demo_plotters/plot_triplot.py +25 -0
  48. figrecipe/_dev/demo_plotters/plot_violinplot.py +25 -0
  49. figrecipe/_dev/demo_plotters/plot_xcorr.py +25 -0
  50. figrecipe/_editor/__init__.py +230 -0
  51. figrecipe/_editor/_bbox.py +978 -0
  52. figrecipe/_editor/_flask_app.py +1229 -0
  53. figrecipe/_editor/_hitmap.py +937 -0
  54. figrecipe/_editor/_overrides.py +318 -0
  55. figrecipe/_editor/_renderer.py +349 -0
  56. figrecipe/_editor/_templates/__init__.py +75 -0
  57. figrecipe/_editor/_templates/_html.py +406 -0
  58. figrecipe/_editor/_templates/_scripts.py +2778 -0
  59. figrecipe/_editor/_templates/_styles.py +1326 -0
  60. figrecipe/_params/_DECORATION_METHODS.py +27 -0
  61. figrecipe/_params/_PLOTTING_METHODS.py +58 -0
  62. figrecipe/_params/__init__.py +9 -0
  63. figrecipe/_recorder.py +126 -73
  64. figrecipe/_reproducer.py +658 -41
  65. figrecipe/_seaborn.py +14 -9
  66. figrecipe/_serializer.py +2 -2
  67. figrecipe/_signatures/README.md +68 -0
  68. figrecipe/_signatures/__init__.py +12 -2
  69. figrecipe/_signatures/_loader.py +515 -56
  70. figrecipe/_utils/__init__.py +6 -4
  71. figrecipe/_utils/_crop.py +10 -4
  72. figrecipe/_utils/_image_diff.py +37 -33
  73. figrecipe/_utils/_numpy_io.py +0 -1
  74. figrecipe/_utils/_units.py +11 -3
  75. figrecipe/_validator.py +12 -3
  76. figrecipe/_wrappers/_axes.py +860 -46
  77. figrecipe/_wrappers/_figure.py +115 -18
  78. figrecipe/plt.py +0 -1
  79. figrecipe/pyplot.py +2 -1
  80. figrecipe/styles/__init__.py +9 -10
  81. figrecipe/styles/_style_applier.py +332 -28
  82. figrecipe/styles/_style_loader.py +172 -44
  83. figrecipe/styles/presets/MATPLOTLIB.yaml +94 -0
  84. figrecipe/styles/presets/SCITEX.yaml +176 -0
  85. figrecipe-0.6.0.dist-info/METADATA +394 -0
  86. figrecipe-0.6.0.dist-info/RECORD +90 -0
  87. figrecipe-0.5.0.dist-info/METADATA +0 -336
  88. figrecipe-0.5.0.dist-info/RECORD +0 -26
  89. {figrecipe-0.5.0.dist-info → figrecipe-0.6.0.dist-info}/WHEEL +0 -0
  90. {figrecipe-0.5.0.dist-info → figrecipe-0.6.0.dist-info}/licenses/LICENSE +0 -0
@@ -5,7 +5,13 @@
5
5
  Applies mm-based styling to matplotlib axes for publication-quality figures.
6
6
  """
7
7
 
8
- __all__ = ["apply_style_mm", "apply_theme_colors", "check_font", "list_available_fonts"]
8
+ __all__ = [
9
+ "apply_style_mm",
10
+ "apply_theme_colors",
11
+ "check_font",
12
+ "finalize_ticks",
13
+ "list_available_fonts",
14
+ ]
9
15
 
10
16
  import warnings
11
17
  from typing import Any, Dict, List, Optional
@@ -30,6 +36,7 @@ def list_available_fonts() -> List[str]:
30
36
  ['Arial', 'Courier New', 'DejaVu Sans', ...]
31
37
  """
32
38
  import matplotlib.font_manager as fm
39
+
33
40
  fonts = set()
34
41
  for font in fm.fontManager.ttflist:
35
42
  fonts.add(font.name)
@@ -56,7 +63,6 @@ def check_font(font_family: str, fallback: str = "DejaVu Sans") -> str:
56
63
  >>> font = check_font("Arial") # Returns "Arial" if available
57
64
  >>> font = check_font("NonExistentFont") # Returns fallback with warning
58
65
  """
59
- import matplotlib.font_manager as fm
60
66
 
61
67
  available = list_available_fonts()
62
68
 
@@ -70,8 +76,8 @@ def check_font(font_family: str, fallback: str = "DejaVu Sans") -> str:
70
76
  if similar:
71
77
  msg += f" Similar fonts available: {similar[:5]}\n"
72
78
  msg += f" Using fallback: '{fallback}'\n"
73
- msg += f" To see all available fonts: ps.list_available_fonts()\n"
74
- msg += f" To install Arial on Linux: sudo apt install ttf-mscorefonts-installer"
79
+ msg += " To see all available fonts: ps.list_available_fonts()\n"
80
+ msg += " To install Arial on Linux: sudo apt install ttf-mscorefonts-installer"
75
81
 
76
82
  warnings.warn(msg, UserWarning)
77
83
 
@@ -81,18 +87,18 @@ def check_font(font_family: str, fallback: str = "DejaVu Sans") -> str:
81
87
  # Default theme color palettes (Monaco/VS Code style for dark)
82
88
  THEME_COLORS = {
83
89
  "dark": {
84
- "figure_bg": "#1e1e1e", # VS Code main background
85
- "axes_bg": "#252526", # VS Code panel background
86
- "legend_bg": "#252526", # Same as axes
87
- "text": "#d4d4d4", # VS Code default text
88
- "spine": "#3c3c3c", # Subtle border color
89
- "tick": "#d4d4d4", # Match text
90
- "grid": "#3a3a3a", # Subtle grid
90
+ "figure_bg": "#1e1e1e", # VS Code main background
91
+ "axes_bg": "#252526", # VS Code panel background
92
+ "legend_bg": "#252526", # Same as axes
93
+ "text": "#d4d4d4", # VS Code default text
94
+ "spine": "#3c3c3c", # Subtle border color
95
+ "tick": "#d4d4d4", # Match text
96
+ "grid": "#3a3a3a", # Subtle grid
91
97
  },
92
98
  "light": {
93
- "figure_bg": "none", # Transparent
94
- "axes_bg": "none", # Transparent
95
- "legend_bg": "none", # Transparent
99
+ "figure_bg": "none", # Transparent
100
+ "axes_bg": "none", # Transparent
101
+ "legend_bg": "none", # Transparent
96
102
  "text": "black",
97
103
  "spine": "black",
98
104
  "tick": "black",
@@ -112,8 +118,9 @@ def apply_theme_colors(
112
118
  ----------
113
119
  ax : matplotlib.axes.Axes
114
120
  Target axes to apply theme to
115
- theme : str
121
+ theme : str or dict
116
122
  Color theme: "light" or "dark" (default: "light")
123
+ If dict, extracts 'mode' key (for YAML-style theme dicts)
117
124
  custom_colors : dict, optional
118
125
  Custom color overrides. Keys: figure_bg, axes_bg, legend_bg, text, spine, tick, grid
119
126
 
@@ -122,6 +129,14 @@ def apply_theme_colors(
122
129
  >>> fig, ax = plt.subplots()
123
130
  >>> apply_theme_colors(ax, theme="dark") # Eye-friendly dark mode
124
131
  """
132
+ # Handle dict-style theme (from YAML: {mode: "light", dark: {...}})
133
+ if isinstance(theme, dict):
134
+ theme = theme.get("mode", "light")
135
+
136
+ # Ensure theme is a string
137
+ if not isinstance(theme, str):
138
+ theme = "light"
139
+
125
140
  # Get base theme colors
126
141
  colors = THEME_COLORS.get(theme, THEME_COLORS["light"]).copy()
127
142
 
@@ -156,6 +171,14 @@ def apply_theme_colors(
156
171
  else:
157
172
  fig.patch.set_facecolor(fig_bg)
158
173
 
174
+ # Apply text colors to figure-level text elements (suptitle, supxlabel, supylabel)
175
+ if hasattr(fig, "_suptitle") and fig._suptitle is not None:
176
+ fig._suptitle.set_color(colors["text"])
177
+ if hasattr(fig, "_supxlabel") and fig._supxlabel is not None:
178
+ fig._supxlabel.set_color(colors["text"])
179
+ if hasattr(fig, "_supylabel") and fig._supylabel is not None:
180
+ fig._supylabel.set_color(colors["text"])
181
+
159
182
  # Apply text colors (labels, titles)
160
183
  ax.xaxis.label.set_color(colors["text"])
161
184
  ax.yaxis.label.set_color(colors["text"])
@@ -262,9 +285,43 @@ def apply_style_mm(ax: Axes, style: Dict[str, Any]) -> float:
262
285
  marker_size_mm = style.get("marker_size_mm")
263
286
  if marker_size_mm is not None:
264
287
  import matplotlib as mpl
288
+
265
289
  marker_size_pt = mm_to_pt(marker_size_mm)
266
290
  mpl.rcParams["lines.markersize"] = marker_size_pt
267
291
 
292
+ # Set boxplot flier (outlier) marker size
293
+ flier_mm = style.get("markers_flier_mm", style.get("flier_mm"))
294
+ if flier_mm is not None:
295
+ import matplotlib as mpl
296
+
297
+ flier_size_pt = mm_to_pt(flier_mm)
298
+ mpl.rcParams["boxplot.flierprops.markersize"] = flier_size_pt
299
+
300
+ # Set boxplot median color
301
+ median_color = style.get("boxplot_median_color")
302
+ if median_color is not None:
303
+ import matplotlib as mpl
304
+
305
+ mpl.rcParams["boxplot.medianprops.color"] = median_color
306
+
307
+ # Apply boxplot line widths to existing boxplot elements
308
+ _apply_boxplot_style(ax, style)
309
+
310
+ # Apply violinplot line widths to existing violinplot elements
311
+ _apply_violinplot_style(ax, style)
312
+
313
+ # Apply barplot edge widths to existing bar elements
314
+ _apply_barplot_style(ax, style)
315
+
316
+ # Apply histogram edge widths to existing histogram elements
317
+ _apply_histogram_style(ax, style)
318
+
319
+ # Apply pie chart styling
320
+ _apply_pie_style(ax, style)
321
+
322
+ # Apply imshow/matshow/spy styling (hide axes if configured)
323
+ _apply_matrix_style(ax, style)
324
+
268
325
  # Configure tick parameters
269
326
  tick_pad_pt = style.get("tick_pad_pt", 2.0)
270
327
  tick_direction = style.get("tick_direction", "out")
@@ -305,8 +362,9 @@ def apply_style_mm(ax: Axes, style: Dict[str, Any]) -> float:
305
362
 
306
363
  # Set legend font size and background via rcParams (for future legends)
307
364
  import matplotlib as mpl
308
- mpl.rcParams['legend.fontsize'] = legend_fs
309
- mpl.rcParams['legend.title_fontsize'] = legend_fs
365
+
366
+ mpl.rcParams["legend.fontsize"] = legend_fs
367
+ mpl.rcParams["legend.title_fontsize"] = legend_fs
310
368
 
311
369
  # Set legend colors from theme
312
370
  theme = style.get("theme", "light")
@@ -323,15 +381,15 @@ def apply_style_mm(ax: Axes, style: Dict[str, Any]) -> float:
323
381
 
324
382
  # Handle transparent backgrounds
325
383
  if str(legend_bg).lower() in ("none", "transparent"):
326
- mpl.rcParams['legend.facecolor'] = 'none'
327
- mpl.rcParams['legend.framealpha'] = 0
384
+ mpl.rcParams["legend.facecolor"] = "none"
385
+ mpl.rcParams["legend.framealpha"] = 0
328
386
  else:
329
- mpl.rcParams['legend.facecolor'] = legend_bg
330
- mpl.rcParams['legend.framealpha'] = 1.0
387
+ mpl.rcParams["legend.facecolor"] = legend_bg
388
+ mpl.rcParams["legend.framealpha"] = 1.0
331
389
 
332
390
  # Set legend text and edge colors
333
- mpl.rcParams['legend.edgecolor'] = spine_color
334
- mpl.rcParams['legend.labelcolor'] = text_color
391
+ mpl.rcParams["legend.edgecolor"] = spine_color
392
+ mpl.rcParams["legend.labelcolor"] = text_color
335
393
 
336
394
  legend = ax.get_legend()
337
395
  if legend is not None:
@@ -345,17 +403,20 @@ def apply_style_mm(ax: Axes, style: Dict[str, Any]) -> float:
345
403
  else:
346
404
  ax.grid(False)
347
405
 
348
- # Configure number of ticks
406
+ # Configure number of ticks (only for numeric axes, not categorical)
407
+ # We defer tick configuration to avoid interfering with categorical axes
408
+ # that get set up later by bar(), boxplot(), etc.
349
409
  n_ticks = style.get("n_ticks")
350
410
  if n_ticks is not None:
351
- from matplotlib.ticker import MaxNLocator
352
- ax.xaxis.set_major_locator(MaxNLocator(nbins=n_ticks))
353
- ax.yaxis.set_major_locator(MaxNLocator(nbins=n_ticks))
411
+ # Store n_ticks preference on the axes for later application
412
+ # This will be applied in _finalize_ticks() before saving
413
+ ax._figrecipe_n_ticks = n_ticks
354
414
 
355
415
  # Apply color palette to both rcParams and this specific axes
356
416
  color_palette = style.get("color_palette")
357
417
  if color_palette is not None:
358
418
  import matplotlib as mpl
419
+
359
420
  # Normalize colors (RGB 0-255 to 0-1)
360
421
  normalized_palette = []
361
422
  for c in color_palette:
@@ -368,7 +429,7 @@ def apply_style_mm(ax: Axes, style: Dict[str, Any]) -> float:
368
429
  else:
369
430
  normalized_palette.append(c)
370
431
  # Set rcParams for future axes
371
- mpl.rcParams['axes.prop_cycle'] = mpl.cycler(color=normalized_palette)
432
+ mpl.rcParams["axes.prop_cycle"] = mpl.cycler(color=normalized_palette)
372
433
  # Also set the color cycle on this specific axes (axes cache cycler at creation)
373
434
  ax.set_prop_cycle(color=normalized_palette)
374
435
 
@@ -380,6 +441,249 @@ def apply_style_mm(ax: Axes, style: Dict[str, Any]) -> float:
380
441
  return trace_lw_pt
381
442
 
382
443
 
444
+ def _apply_boxplot_style(ax: Axes, style: Dict[str, Any]) -> None:
445
+ """Apply boxplot line width styling to existing boxplot elements.
446
+
447
+ Parameters
448
+ ----------
449
+ ax : matplotlib.axes.Axes
450
+ Target axes containing boxplot elements.
451
+ style : dict
452
+ Style dictionary with boxplot_* keys.
453
+ """
454
+ from matplotlib.lines import Line2D
455
+ from matplotlib.patches import PathPatch
456
+
457
+ # Get line widths from style
458
+ box_lw = mm_to_pt(style.get("boxplot_line_mm", 0.2))
459
+ whisker_lw = mm_to_pt(style.get("boxplot_whisker_mm", 0.2))
460
+ cap_lw = mm_to_pt(style.get("boxplot_cap_mm", 0.2))
461
+ median_lw = mm_to_pt(style.get("boxplot_median_mm", 0.2))
462
+ median_color = style.get("boxplot_median_color", "black")
463
+ flier_edge_lw = mm_to_pt(style.get("boxplot_flier_edge_mm", 0.2))
464
+
465
+ # Boxplot creates Line2D objects for whiskers, caps, medians, fliers
466
+ # and PathPatch objects for boxes
467
+ for child in ax.get_children():
468
+ # Check if it's a boxplot box (PathPatch with specific properties)
469
+ if isinstance(child, PathPatch):
470
+ # Boxes are typically PathPatch with edgecolor
471
+ if child.get_edgecolor() is not None:
472
+ child.set_linewidth(box_lw)
473
+
474
+ # Check for Line2D objects (whiskers, caps, medians, fliers)
475
+ elif isinstance(child, Line2D):
476
+ xdata = child.get_xdata()
477
+ ydata = child.get_ydata()
478
+
479
+ # Fliers are markers with no line (linestyle='None' or '')
480
+ # and typically have varying number of points (outliers)
481
+ marker = child.get_marker()
482
+ linestyle = child.get_linestyle()
483
+ if marker and marker != "None" and linestyle in ("None", "", " "):
484
+ # This is likely a flier (outlier marker)
485
+ child.set_markeredgewidth(flier_edge_lw)
486
+ elif len(xdata) == 2 and len(ydata) == 2:
487
+ # Horizontal line (could be median or cap)
488
+ if ydata[0] == ydata[1]:
489
+ # Check if it's likely a median (middle of box) or cap
490
+ # Medians are usually solid, caps are at extremes
491
+ if linestyle == "-":
492
+ # Could be median - apply median style
493
+ child.set_linewidth(median_lw)
494
+ if median_color:
495
+ child.set_color(median_color)
496
+ else:
497
+ child.set_linewidth(cap_lw)
498
+ # Vertical line (whisker)
499
+ elif xdata[0] == xdata[1]:
500
+ child.set_linewidth(whisker_lw)
501
+
502
+
503
+ def _apply_violinplot_style(ax: Axes, style: Dict[str, Any]) -> None:
504
+ """Apply violinplot line width styling to existing violinplot elements.
505
+
506
+ Parameters
507
+ ----------
508
+ ax : matplotlib.axes.Axes
509
+ Target axes containing violinplot elements.
510
+ style : dict
511
+ Style dictionary with violinplot_* keys.
512
+ """
513
+ from matplotlib.collections import LineCollection, PolyCollection
514
+
515
+ # Get line widths from style
516
+ body_lw = mm_to_pt(style.get("violinplot_line_mm", 0.2))
517
+ whisker_lw = mm_to_pt(style.get("violinplot_whisker_mm", 0.2))
518
+
519
+ for child in ax.get_children():
520
+ # Violin bodies are PolyCollection
521
+ if isinstance(child, PolyCollection):
522
+ child.set_linewidth(body_lw)
523
+
524
+ # Violin inner elements (cbars, cmins, cmaxes) are LineCollection
525
+ elif isinstance(child, LineCollection):
526
+ child.set_linewidth(whisker_lw)
527
+
528
+
529
+ def _apply_barplot_style(ax: Axes, style: Dict[str, Any]) -> None:
530
+ """Apply barplot edge styling to existing bar elements.
531
+
532
+ Parameters
533
+ ----------
534
+ ax : matplotlib.axes.Axes
535
+ Target axes containing bar elements.
536
+ style : dict
537
+ Style dictionary with barplot_* keys.
538
+ """
539
+ from matplotlib.patches import Rectangle
540
+
541
+ # Get edge width from style
542
+ edge_lw = mm_to_pt(style.get("barplot_edge_mm", 0.2))
543
+
544
+ # Bar plots create Rectangle patches
545
+ for patch in ax.patches:
546
+ if isinstance(patch, Rectangle):
547
+ patch.set_linewidth(edge_lw)
548
+ # Set edge color to black for clean scientific look
549
+ patch.set_edgecolor("black")
550
+
551
+
552
+ def _apply_histogram_style(ax: Axes, style: Dict[str, Any]) -> None:
553
+ """Apply histogram edge styling to existing histogram elements.
554
+
555
+ Parameters
556
+ ----------
557
+ ax : matplotlib.axes.Axes
558
+ Target axes containing histogram elements.
559
+ style : dict
560
+ Style dictionary with histogram_* keys.
561
+ """
562
+ from matplotlib.patches import Rectangle
563
+
564
+ # Get edge width from style
565
+ edge_lw = mm_to_pt(style.get("histogram_edge_mm", 0.2))
566
+
567
+ # Histograms also create Rectangle patches
568
+ for patch in ax.patches:
569
+ if isinstance(patch, Rectangle):
570
+ patch.set_linewidth(edge_lw)
571
+ # Set edge color to black for clean scientific look
572
+ patch.set_edgecolor("black")
573
+
574
+
575
+ def _apply_pie_style(ax: Axes, style: Dict[str, Any]) -> None:
576
+ """Apply pie chart styling to existing pie elements.
577
+
578
+ Parameters
579
+ ----------
580
+ ax : matplotlib.axes.Axes
581
+ Target axes containing pie chart elements.
582
+ style : dict
583
+ Style dictionary with pie_* keys.
584
+ """
585
+ from matplotlib.patches import Wedge
586
+
587
+ # Check if axes contains pie chart (wedge patches)
588
+ has_pie = any(isinstance(p, Wedge) for p in ax.patches)
589
+ if not has_pie:
590
+ return
591
+
592
+ # Get pie text size from style (default 6pt for scientific publications)
593
+ text_pt = style.get("pie_text_pt", 6)
594
+ show_axes = style.get("pie_show_axes", False)
595
+ font_family = check_font(style.get("font_family", "Arial"))
596
+
597
+ # Apply text size to all pie text elements (labels and percentages)
598
+ for text in ax.texts:
599
+ text.set_fontsize(text_pt)
600
+ text.set_fontfamily(font_family)
601
+
602
+ # Hide axes if configured (default: hide for pie charts)
603
+ if not show_axes:
604
+ ax.set_xticks([])
605
+ ax.set_yticks([])
606
+ ax.set_xticklabels([])
607
+ ax.set_yticklabels([])
608
+ # Hide spines
609
+ for spine in ax.spines.values():
610
+ spine.set_visible(False)
611
+
612
+
613
+ def _apply_matrix_style(ax: Axes, style: Dict[str, Any]) -> None:
614
+ """Apply imshow/matshow/spy styling (hide axes if configured).
615
+
616
+ Parameters
617
+ ----------
618
+ ax : matplotlib.axes.Axes
619
+ Target axes containing matrix plot elements.
620
+ style : dict
621
+ Style dictionary with imshow_*, matshow_*, spy_* keys.
622
+ """
623
+ from matplotlib.image import AxesImage
624
+
625
+ # Check if axes contains an image (imshow/matshow)
626
+ has_image = any(isinstance(c, AxesImage) for c in ax.get_children())
627
+ if not has_image:
628
+ return
629
+
630
+ # Check if imshow_show_axes is False
631
+ show_axes = style.get("imshow_show_axes", True)
632
+ show_labels = style.get("imshow_show_labels", True)
633
+
634
+ if not show_axes:
635
+ ax.set_xticks([])
636
+ ax.set_yticks([])
637
+ ax.set_xticklabels([])
638
+ ax.set_yticklabels([])
639
+ # Hide spines
640
+ for spine in ax.spines.values():
641
+ spine.set_visible(False)
642
+
643
+ if not show_labels:
644
+ ax.set_xlabel("")
645
+ ax.set_ylabel("")
646
+
647
+
648
+ def finalize_ticks(ax: Axes) -> None:
649
+ """
650
+ Apply deferred tick configuration after all plotting is done.
651
+
652
+ This function applies the n_ticks setting stored by apply_style_mm(),
653
+ but only to numeric axes (not categorical).
654
+
655
+ Parameters
656
+ ----------
657
+ ax : matplotlib.axes.Axes
658
+ The axes to finalize.
659
+ """
660
+ from matplotlib.ticker import MaxNLocator
661
+
662
+ n_ticks = getattr(ax, "_figrecipe_n_ticks", None)
663
+ if n_ticks is None:
664
+ return
665
+
666
+ # Check if x-axis is categorical (has string tick labels)
667
+ x_labels = [t.get_text() for t in ax.get_xticklabels()]
668
+ x_is_categorical = any(
669
+ lbl and not lbl.replace(".", "").replace("-", "").replace("+", "").isdigit()
670
+ for lbl in x_labels
671
+ if lbl
672
+ )
673
+ if not x_is_categorical:
674
+ ax.xaxis.set_major_locator(MaxNLocator(nbins=n_ticks))
675
+
676
+ # Check if y-axis is categorical
677
+ y_labels = [t.get_text() for t in ax.get_yticklabels()]
678
+ y_is_categorical = any(
679
+ lbl and not lbl.replace(".", "").replace("-", "").replace("+", "").isdigit()
680
+ for lbl in y_labels
681
+ if lbl
682
+ )
683
+ if not y_is_categorical:
684
+ ax.yaxis.set_major_locator(MaxNLocator(nbins=n_ticks))
685
+
686
+
383
687
  if __name__ == "__main__":
384
688
  import matplotlib.pyplot as plt
385
689
  import numpy as np