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
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Caption generation utilities for scientific figures."""
4
+
5
+ from typing import Any, Dict, List, Literal, Optional
6
+
7
+ from ._stat_annotation import p_to_stars
8
+
9
+
10
+ def format_stats_value(value: float, precision: int = 2) -> str:
11
+ """Format a statistical value for display."""
12
+ if abs(value) >= 1000 or (abs(value) < 0.01 and value != 0):
13
+ return f"{value:.{precision}e}"
14
+ return f"{value:.{precision}f}"
15
+
16
+
17
+ def format_comparison(comp: Dict[str, Any], style: str = "publication") -> str:
18
+ """Format a single comparison result.
19
+
20
+ Parameters
21
+ ----------
22
+ comp : dict
23
+ Comparison dict with keys like: name, p_value, stars, effect_size, method.
24
+ style : str
25
+ "publication", "brief", or "detailed".
26
+
27
+ Returns
28
+ -------
29
+ str
30
+ Formatted comparison string.
31
+ """
32
+ name = comp.get("name", "comparison")
33
+ p_value = comp.get("p_value")
34
+ stars = comp.get("stars") or (p_to_stars(p_value) if p_value else "")
35
+ method = comp.get("method", "")
36
+ effect_size = comp.get("effect_size")
37
+
38
+ if style == "brief":
39
+ if p_value is not None:
40
+ return f"{name}: {stars}"
41
+ return name
42
+
43
+ elif style == "detailed":
44
+ parts = [name]
45
+ if method:
46
+ parts.append(f"({method})")
47
+ if p_value is not None:
48
+ if p_value < 0.001:
49
+ parts.append("p<0.001")
50
+ else:
51
+ parts.append(f"p={p_value:.3f}")
52
+ if effect_size:
53
+ if isinstance(effect_size, dict):
54
+ es_name = effect_size.get("name", "d")
55
+ es_val = effect_size.get("value", 0)
56
+ parts.append(f"{es_name}={es_val:.2f}")
57
+ else:
58
+ parts.append(f"d={effect_size:.2f}")
59
+ return " ".join(parts)
60
+
61
+ else: # publication
62
+ if p_value is not None:
63
+ if p_value < 0.001:
64
+ p_str = "p<0.001"
65
+ else:
66
+ p_str = f"p={p_value:.3f}"
67
+ if effect_size:
68
+ if isinstance(effect_size, dict):
69
+ es_val = effect_size.get("value", 0)
70
+ else:
71
+ es_val = effect_size
72
+ return f"{name} ({p_str}, d={es_val:.2f})"
73
+ return f"{name} ({p_str})"
74
+ return name
75
+
76
+
77
+ def format_panel_stats(stats: Dict[str, Any], style: str = "publication") -> str:
78
+ """Format panel-level statistics.
79
+
80
+ Parameters
81
+ ----------
82
+ stats : dict
83
+ Panel stats dict with keys like: n, mean, std, sem, group.
84
+ style : str
85
+ "publication", "brief", or "detailed".
86
+
87
+ Returns
88
+ -------
89
+ str
90
+ Formatted stats string.
91
+ """
92
+ parts = []
93
+
94
+ group = stats.get("group")
95
+ if group:
96
+ parts.append(group)
97
+
98
+ n = stats.get("n")
99
+ if n is not None:
100
+ parts.append(f"n={n}")
101
+
102
+ mean = stats.get("mean")
103
+ std = stats.get("std")
104
+ sem = stats.get("sem")
105
+
106
+ if mean is not None:
107
+ if std is not None:
108
+ parts.append(f"mean={format_stats_value(mean)}±{format_stats_value(std)}")
109
+ elif sem is not None:
110
+ parts.append(
111
+ f"mean={format_stats_value(mean)}±{format_stats_value(sem)} SEM"
112
+ )
113
+ else:
114
+ parts.append(f"mean={format_stats_value(mean)}")
115
+
116
+ return ", ".join(parts) if parts else ""
117
+
118
+
119
+ def generate_figure_caption(
120
+ title: Optional[str] = None,
121
+ panel_captions: Optional[List[str]] = None,
122
+ stats: Optional[Dict[str, Any]] = None,
123
+ style: Literal["publication", "brief", "detailed"] = "publication",
124
+ template: Optional[str] = None,
125
+ ) -> str:
126
+ """Generate a figure caption from components.
127
+
128
+ Parameters
129
+ ----------
130
+ title : str, optional
131
+ Figure title.
132
+ panel_captions : list of str, optional
133
+ List of panel captions.
134
+ stats : dict, optional
135
+ Figure-level stats with comparisons.
136
+ style : str
137
+ Caption style.
138
+ template : str, optional
139
+ Custom template with placeholders: {title}, {panels}, {stats}.
140
+
141
+ Returns
142
+ -------
143
+ str
144
+ Generated caption.
145
+ """
146
+ # Build components
147
+ title_str = title or ""
148
+
149
+ # Panel descriptions
150
+ panels_str = ""
151
+ if panel_captions:
152
+ panels_str = " ".join(p for p in panel_captions if p)
153
+
154
+ # Stats summary
155
+ stats_str = ""
156
+ if stats:
157
+ comparisons = stats.get("comparisons", [])
158
+ if comparisons:
159
+ formatted = [format_comparison(c, style) for c in comparisons]
160
+ stats_str = "; ".join(formatted)
161
+
162
+ # Apply template
163
+ if template:
164
+ return template.format(
165
+ title=title_str,
166
+ panels=panels_str,
167
+ stats=stats_str,
168
+ ).strip()
169
+
170
+ # Default formatting based on style
171
+ parts = []
172
+ if title_str:
173
+ parts.append(title_str + ".")
174
+
175
+ if panels_str:
176
+ parts.append(panels_str)
177
+
178
+ if stats_str:
179
+ parts.append(stats_str + ".")
180
+
181
+ return " ".join(parts).strip()
182
+
183
+
184
+ def generate_panel_caption(
185
+ label: Optional[str] = None,
186
+ stats: Optional[Dict[str, Any]] = None,
187
+ style: Literal["publication", "brief", "detailed"] = "publication",
188
+ ) -> str:
189
+ """Generate a panel caption from stats.
190
+
191
+ Parameters
192
+ ----------
193
+ label : str, optional
194
+ Panel label like "A" or "(A)".
195
+ stats : dict, optional
196
+ Panel-level stats.
197
+ style : str
198
+ Caption style.
199
+
200
+ Returns
201
+ -------
202
+ str
203
+ Generated panel caption.
204
+ """
205
+ parts = []
206
+
207
+ if label:
208
+ # Ensure label is in parentheses
209
+ if not label.startswith("("):
210
+ label = f"({label})"
211
+ parts.append(label)
212
+
213
+ if stats:
214
+ stats_str = format_panel_stats(stats, style)
215
+ if stats_str:
216
+ parts.append(stats_str)
217
+
218
+ return " ".join(parts)
@@ -3,7 +3,7 @@
3
3
  """Wrapped Figure that manages recording."""
4
4
 
5
5
  from pathlib import Path
6
- from typing import TYPE_CHECKING, Any, List, Literal, Optional, Tuple, Union
6
+ from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union
7
7
 
8
8
  import matplotlib.pyplot as plt
9
9
  import numpy as np
@@ -303,6 +303,31 @@ class RecordingFigure:
303
303
  """Get the figure caption metadata."""
304
304
  return self._recorder.figure_record.caption
305
305
 
306
+ def set_stats(self, stats: Dict[str, Any]) -> "RecordingFigure":
307
+ """Set figure-level statistics metadata (not rendered, stored in recipe).
308
+
309
+ Parameters
310
+ ----------
311
+ stats : dict
312
+ Statistics dictionary (comparisons, summary, correction_method, alpha).
313
+ """
314
+ self._recorder.figure_record.stats = stats
315
+ return self
316
+
317
+ @property
318
+ def stats(self) -> Optional[Dict[str, Any]]:
319
+ """Get the figure-level statistics metadata."""
320
+ return self._recorder.figure_record.stats
321
+
322
+ def generate_caption(self, style: str = "publication", template: str = None) -> str:
323
+ """Generate caption from stored stats. Styles: publication, brief, detailed."""
324
+ from ._caption_generator import generate_figure_caption
325
+
326
+ panels = [ax.caption for ax in self.flat if ax.caption]
327
+ return generate_figure_caption(
328
+ self.title_metadata, panels, self.stats, style, template
329
+ )
330
+
306
331
  def __getattr__(self, name: str) -> Any:
307
332
  """Delegate attribute access to underlying figure."""
308
333
  return getattr(self._fig, name)
@@ -0,0 +1,274 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Statistical annotation drawing utilities for comparison brackets and stars."""
4
+
5
+ from typing import Any, Dict, List, Literal, Optional
6
+
7
+ from matplotlib.axes import Axes
8
+
9
+
10
+ def get_theme_text_color(default: str = "black") -> str:
11
+ """Get text color from loaded style's theme settings."""
12
+ try:
13
+ from ..styles._style_loader import _STYLE_CACHE
14
+
15
+ if _STYLE_CACHE is not None:
16
+ theme = getattr(_STYLE_CACHE, "theme", None)
17
+ if theme is not None:
18
+ mode = getattr(theme, "mode", "light")
19
+ theme_colors = getattr(theme, mode, None)
20
+ if theme_colors is not None:
21
+ return getattr(theme_colors, "text", default)
22
+ except Exception:
23
+ pass
24
+ return default
25
+
26
+
27
+ def get_style_value(section: str, key: str, default: Any) -> Any:
28
+ """Get a value from loaded style settings.
29
+
30
+ Parameters
31
+ ----------
32
+ section : str
33
+ Style section (e.g., 'fonts', 'lines', 'stat_annotation')
34
+ key : str
35
+ Key within the section (e.g., 'annotation_pt', 'bracket_mm')
36
+ default : Any
37
+ Default value if not found
38
+ """
39
+ try:
40
+ from ..styles._style_loader import _STYLE_CACHE
41
+
42
+ if _STYLE_CACHE is not None:
43
+ section_obj = getattr(_STYLE_CACHE, section, None)
44
+ if section_obj is not None:
45
+ return getattr(section_obj, key, default)
46
+ except Exception:
47
+ pass
48
+ return default
49
+
50
+
51
+ def p_to_stars(p_value: float, ns_symbol: bool = True) -> str:
52
+ """Convert p-value to significance stars.
53
+
54
+ Parameters
55
+ ----------
56
+ p_value : float
57
+ The p-value to convert.
58
+ ns_symbol : bool
59
+ If True, return "n.s." for non-significant. If False, return "".
60
+
61
+ Returns
62
+ -------
63
+ str
64
+ Stars representation: "***" (p<0.001), "**" (p<0.01),
65
+ "*" (p<0.05), "n.s." or "" (p>=0.05).
66
+ """
67
+ if p_value < 0.001:
68
+ return "***"
69
+ elif p_value < 0.01:
70
+ return "**"
71
+ elif p_value < 0.05:
72
+ return "*"
73
+ else:
74
+ return "n.s." if ns_symbol else ""
75
+
76
+
77
+ def draw_stat_annotation(
78
+ ax: Axes,
79
+ x1: float,
80
+ x2: float,
81
+ y: Optional[float] = None,
82
+ text: Optional[str] = None,
83
+ p_value: Optional[float] = None,
84
+ style: Literal["stars", "p_value", "both", "bracket_only"] = "stars",
85
+ bracket_height: Optional[float] = None,
86
+ text_offset: Optional[float] = None,
87
+ color: Optional[str] = None,
88
+ linewidth: Optional[float] = None,
89
+ fontsize: Optional[float] = None,
90
+ fontweight: Optional[str] = None,
91
+ **kwargs,
92
+ ) -> List[Any]:
93
+ """Draw a statistical comparison bracket with annotation.
94
+
95
+ Parameters
96
+ ----------
97
+ ax : Axes
98
+ The matplotlib axes to draw on.
99
+ x1, x2 : float
100
+ X positions of the two groups being compared.
101
+ y : float, optional
102
+ Y position for the bracket. If None, auto-calculated from data.
103
+ text : str, optional
104
+ Custom text to display. Overrides p_value formatting.
105
+ p_value : float, optional
106
+ P-value for automatic star conversion.
107
+ style : str
108
+ Display style: "stars", "p_value", "both", "bracket_only".
109
+ bracket_height : float
110
+ Height of bracket tips as fraction of axes height.
111
+ text_offset : float
112
+ Offset of text above bracket as fraction of axes height.
113
+ color : str
114
+ Color for bracket and text.
115
+ linewidth : float
116
+ Line width for bracket.
117
+ fontsize : float
118
+ Font size for annotation text.
119
+ **kwargs
120
+ Additional kwargs passed to ax.text().
121
+
122
+ Returns
123
+ -------
124
+ list
125
+ List of matplotlib artists created (lines and text).
126
+ """
127
+ artists = []
128
+
129
+ from .._utils._units import mm_to_pt
130
+
131
+ # Resolve values from style if not explicitly provided
132
+ if color is None:
133
+ color = get_theme_text_color(default="black")
134
+ if bracket_height is None:
135
+ bracket_height = get_style_value("stat_annotation", "bracket_height", 0.03)
136
+ if text_offset is None:
137
+ text_offset = get_style_value("stat_annotation", "text_offset", 0.01)
138
+ if linewidth is None:
139
+ # Read mm value and convert to points
140
+ linewidth_mm = get_style_value("stat_annotation", "linewidth_mm", 0.2)
141
+ linewidth = mm_to_pt(linewidth_mm)
142
+
143
+ # Font settings from style: both stars and p-values use same fontsize_pt
144
+ # Stars are bold, p-values are normal weight
145
+ annotation_fontsize = get_style_value("stat_annotation", "fontsize_pt", 6)
146
+ stars_fontweight = get_style_value("stat_annotation", "stars_fontweight", "bold")
147
+
148
+ # Get axes limits for relative positioning
149
+ ylim = ax.get_ylim()
150
+ y_range = ylim[1] - ylim[0]
151
+
152
+ # Auto-calculate y position if not provided
153
+ if y is None:
154
+ # Find max y value in the x range and add padding
155
+ y = ylim[1] + y_range * 0.05
156
+
157
+ # Calculate bracket dimensions in data coordinates
158
+ tip_height = y_range * bracket_height
159
+ text_y_offset = y_range * text_offset
160
+
161
+ # Draw bracket: horizontal line with vertical tips
162
+ # Left tip
163
+ line1 = ax.plot(
164
+ [x1, x1], [y - tip_height, y], color=color, linewidth=linewidth, clip_on=False
165
+ )[0]
166
+ artists.append(line1)
167
+
168
+ # Horizontal bar
169
+ line2 = ax.plot([x1, x2], [y, y], color=color, linewidth=linewidth, clip_on=False)[
170
+ 0
171
+ ]
172
+ artists.append(line2)
173
+
174
+ # Right tip
175
+ line3 = ax.plot(
176
+ [x2, x2], [y, y - tip_height], color=color, linewidth=linewidth, clip_on=False
177
+ )[0]
178
+ artists.append(line3)
179
+
180
+ # Determine annotation text and whether it's stars-only
181
+ is_stars_only = False
182
+ if text is None and style != "bracket_only":
183
+ if p_value is not None:
184
+ if style == "stars":
185
+ text = p_to_stars(p_value)
186
+ # Only bold for actual stars, not for n.s.
187
+ is_stars_only = text not in ("n.s.", "")
188
+ elif style == "p_value":
189
+ # Use italic p with spaces around operators
190
+ if p_value < 0.001:
191
+ text = r"$\it{p}$ < 0.001"
192
+ else:
193
+ text = rf"$\it{{p}}$ = {p_value:.3f}"
194
+ elif style == "both":
195
+ stars = p_to_stars(p_value)
196
+ # Use italic p with spaces around operators
197
+ if p_value < 0.001:
198
+ text = rf"{stars} ($\it{{p}}$ < 0.001)"
199
+ else:
200
+ text = rf"{stars} ($\it{{p}}$ = {p_value:.3f})"
201
+
202
+ # Draw text if available
203
+ if text and style != "bracket_only":
204
+ text_x = (x1 + x2) / 2
205
+ text_y = y + text_y_offset
206
+
207
+ # Use same fontsize for stars and p-values, but stars are bold
208
+ effective_fontsize = fontsize if fontsize is not None else annotation_fontsize
209
+ if is_stars_only:
210
+ effective_fontweight = (
211
+ fontweight if fontweight is not None else stars_fontweight
212
+ )
213
+ else:
214
+ effective_fontweight = fontweight if fontweight is not None else "normal"
215
+
216
+ text_kwargs = {
217
+ "ha": "center",
218
+ "va": "bottom",
219
+ "fontsize": effective_fontsize,
220
+ "fontweight": effective_fontweight,
221
+ "color": color,
222
+ }
223
+ text_kwargs.update(kwargs)
224
+ txt = ax.text(text_x, text_y, text, **text_kwargs)
225
+ artists.append(txt)
226
+
227
+ return artists
228
+
229
+
230
+ def calculate_auto_y(
231
+ ax: Axes,
232
+ x1: float,
233
+ x2: float,
234
+ existing_annotations: List[Dict[str, Any]],
235
+ padding: float = 0.05,
236
+ ) -> float:
237
+ """Calculate automatic y position for a new annotation.
238
+
239
+ Avoids overlapping with existing annotations by stacking.
240
+
241
+ Parameters
242
+ ----------
243
+ ax : Axes
244
+ The matplotlib axes.
245
+ x1, x2 : float
246
+ X positions of the comparison.
247
+ existing_annotations : list
248
+ List of existing annotation info dicts with x1, x2, y keys.
249
+ padding : float
250
+ Padding as fraction of y range.
251
+
252
+ Returns
253
+ -------
254
+ float
255
+ Suggested y position for the new annotation.
256
+ """
257
+ ylim = ax.get_ylim()
258
+ y_range = ylim[1] - ylim[0]
259
+ pad = y_range * padding
260
+
261
+ # Start above the data
262
+ y = ylim[1] + pad
263
+
264
+ # Check for overlaps with existing annotations
265
+ for ann in existing_annotations:
266
+ ann_x1, ann_x2 = ann.get("x1", 0), ann.get("x2", 0)
267
+ ann_y = ann.get("y", 0)
268
+
269
+ # Check if x ranges overlap
270
+ if not (x2 < ann_x1 or x1 > ann_x2):
271
+ # Overlapping x range, need to stack
272
+ y = max(y, ann_y + pad * 2)
273
+
274
+ return y
@@ -85,7 +85,11 @@ def apply_style_mm(ax: Axes, style: Dict[str, Any]) -> float:
85
85
  import matplotlib as mpl
86
86
 
87
87
  # Apply theme colors (dark/light mode)
88
- theme = style.get("theme", "light")
88
+ theme_section = style.get("theme", {})
89
+ if isinstance(theme_section, dict):
90
+ theme = theme_section.get("mode", "light")
91
+ else:
92
+ theme = str(theme_section) if theme_section else "light"
89
93
  theme_colors = style.get("theme_colors", None)
90
94
  apply_theme_colors(ax, theme, theme_colors)
91
95
 
@@ -171,7 +175,11 @@ def apply_style_mm(ax: Axes, style: Dict[str, Any]) -> float:
171
175
  mpl.rcParams["legend.title_fontsize"] = legend_fs
172
176
 
173
177
  # Set legend colors from theme
174
- theme = style.get("theme", "light")
178
+ theme_section = style.get("theme", {})
179
+ if isinstance(theme_section, dict):
180
+ theme = theme_section.get("mode", "light")
181
+ else:
182
+ theme = str(theme_section) if theme_section else "light"
175
183
  theme_colors = style.get("theme_colors", None)
176
184
  if theme_colors:
177
185
  legend_bg = theme_colors.get("legend_bg", theme_colors.get("axes_bg", "white"))
@@ -11,10 +11,10 @@ axes:
11
11
  thickness_mm: 0.2
12
12
 
13
13
  margins:
14
- left_mm: 6 # Room for supylabel
14
+ left_mm: 1 # Minimal - constrained_layout handles labels
15
15
  right_mm: 1
16
- bottom_mm: 5 # Room for supxlabel
17
- top_mm: 5 # Room for suptitle
16
+ bottom_mm: 1 # Minimal - constrained_layout handles labels
17
+ top_mm: 1 # Minimal - constrained_layout handles labels
18
18
 
19
19
  spacing:
20
20
  horizontal_mm: 10 # Between columns
@@ -112,6 +112,13 @@ legend:
112
112
  alpha: 1.0 # Transparency
113
113
  loc: "best"
114
114
 
115
+ stat_annotation:
116
+ bracket_height: 0.03 # Height of bracket tips as fraction of y-range
117
+ text_offset: 0.01 # Text offset above bracket as fraction of y-range
118
+ linewidth_mm: 0.2 # Bracket line width in mm
119
+ fontsize_pt: 6 # Font size for annotation text (stars and p-values)
120
+ stars_fontweight: "bold" # Font weight for stars
121
+
115
122
  output:
116
123
  dpi: 300
117
124
  transparent: true
@@ -122,7 +129,7 @@ behavior:
122
129
  hide_top_spine: true
123
130
  hide_right_spine: true
124
131
  grid: false
125
- constrained_layout: true # Auto-spacing for suptitle/supxlabel/supylabel
132
+ constrained_layout: true # Auto-fits content; spacing controls disabled
126
133
  panel_labels: true # Auto-add A, B, C labels to multi-panel figures
127
134
 
128
135
  theme: