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.
- figrecipe/__init__.py +74 -76
- figrecipe/__main__.py +12 -0
- figrecipe/_api/_panel.py +67 -0
- figrecipe/_api/_save.py +100 -4
- figrecipe/_cli/__init__.py +7 -0
- figrecipe/_cli/_compose.py +87 -0
- figrecipe/_cli/_convert.py +117 -0
- figrecipe/_cli/_crop.py +82 -0
- figrecipe/_cli/_edit.py +70 -0
- figrecipe/_cli/_extract.py +128 -0
- figrecipe/_cli/_fonts.py +47 -0
- figrecipe/_cli/_info.py +67 -0
- figrecipe/_cli/_main.py +58 -0
- figrecipe/_cli/_reproduce.py +79 -0
- figrecipe/_cli/_style.py +77 -0
- figrecipe/_cli/_validate.py +66 -0
- figrecipe/_cli/_version.py +50 -0
- figrecipe/_composition/__init__.py +32 -0
- figrecipe/_composition/_alignment.py +452 -0
- figrecipe/_composition/_compose.py +179 -0
- figrecipe/_composition/_import_axes.py +127 -0
- figrecipe/_composition/_visibility.py +125 -0
- figrecipe/_dev/__init__.py +2 -0
- figrecipe/_dev/browser/__init__.py +69 -0
- figrecipe/_dev/browser/_audio.py +240 -0
- figrecipe/_dev/browser/_caption.py +356 -0
- figrecipe/_dev/browser/_click_effect.py +146 -0
- figrecipe/_dev/browser/_cursor.py +196 -0
- figrecipe/_dev/browser/_highlight.py +105 -0
- figrecipe/_dev/browser/_narration.py +237 -0
- figrecipe/_dev/browser/_recorder.py +446 -0
- figrecipe/_dev/browser/_utils.py +178 -0
- figrecipe/_dev/browser/_video_trim/__init__.py +152 -0
- figrecipe/_dev/browser/_video_trim/_detection.py +223 -0
- figrecipe/_dev/browser/_video_trim/_markers.py +140 -0
- figrecipe/_editor/__init__.py +36 -36
- figrecipe/_editor/_bbox/_extract.py +155 -9
- figrecipe/_editor/_bbox/_extract_text.py +124 -0
- figrecipe/_editor/_call_overrides.py +183 -0
- figrecipe/_editor/_datatable_plot_handlers.py +249 -0
- figrecipe/_editor/_figure_layout.py +211 -0
- figrecipe/_editor/_flask_app.py +157 -16
- figrecipe/_editor/_helpers.py +17 -8
- figrecipe/_editor/_hitmap/_detect.py +89 -32
- figrecipe/_editor/_hitmap_main.py +4 -4
- figrecipe/_editor/_overrides.py +4 -1
- figrecipe/_editor/_plot_types_registry.py +190 -0
- figrecipe/_editor/_render_overrides.py +38 -11
- figrecipe/_editor/_renderer.py +46 -1
- figrecipe/_editor/_routes_annotation.py +114 -0
- figrecipe/_editor/_routes_axis.py +35 -6
- figrecipe/_editor/_routes_captions.py +130 -0
- figrecipe/_editor/_routes_composition.py +270 -0
- figrecipe/_editor/_routes_core.py +15 -173
- figrecipe/_editor/_routes_datatable.py +364 -0
- figrecipe/_editor/_routes_element.py +37 -19
- figrecipe/_editor/_routes_files.py +443 -0
- figrecipe/_editor/_routes_image.py +200 -0
- figrecipe/_editor/_routes_snapshot.py +94 -0
- figrecipe/_editor/_routes_style.py +28 -8
- figrecipe/_editor/_templates/__init__.py +40 -2
- figrecipe/_editor/_templates/_html.py +97 -103
- figrecipe/_editor/_templates/_html_components/__init__.py +13 -0
- figrecipe/_editor/_templates/_html_components/_composition_toolbar.py +79 -0
- figrecipe/_editor/_templates/_html_components/_file_browser.py +41 -0
- figrecipe/_editor/_templates/_html_datatable.py +92 -0
- figrecipe/_editor/_templates/_scripts/__init__.py +58 -0
- figrecipe/_editor/_templates/_scripts/_accordion.py +328 -0
- figrecipe/_editor/_templates/_scripts/_annotation_drag.py +504 -0
- figrecipe/_editor/_templates/_scripts/_api.py +1 -1
- figrecipe/_editor/_templates/_scripts/_canvas_context_menu.py +182 -0
- figrecipe/_editor/_templates/_scripts/_captions.py +231 -0
- figrecipe/_editor/_templates/_scripts/_composition.py +283 -0
- figrecipe/_editor/_templates/_scripts/_core.py +94 -37
- figrecipe/_editor/_templates/_scripts/_datatable/__init__.py +59 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_cell_edit.py +97 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_clipboard.py +164 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_context_menu.py +221 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_core.py +150 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_editable.py +511 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_import.py +161 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_plot.py +261 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_selection.py +438 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_table.py +256 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_tabs.py +354 -0
- figrecipe/_editor/_templates/_scripts/_element_editor.py +17 -2
- figrecipe/_editor/_templates/_scripts/_files.py +274 -40
- figrecipe/_editor/_templates/_scripts/_files_context_menu.py +240 -0
- figrecipe/_editor/_templates/_scripts/_hitmap.py +87 -84
- figrecipe/_editor/_templates/_scripts/_image_drop.py +428 -0
- figrecipe/_editor/_templates/_scripts/_legend_drag.py +5 -0
- figrecipe/_editor/_templates/_scripts/_multi_select.py +198 -0
- figrecipe/_editor/_templates/_scripts/_panel_drag.py +219 -48
- figrecipe/_editor/_templates/_scripts/_panel_drag_snapshot.py +33 -0
- figrecipe/_editor/_templates/_scripts/_panel_position.py +238 -54
- figrecipe/_editor/_templates/_scripts/_panel_resize.py +230 -0
- figrecipe/_editor/_templates/_scripts/_panel_snap.py +307 -0
- figrecipe/_editor/_templates/_scripts/_region_select.py +255 -0
- figrecipe/_editor/_templates/_scripts/_selection.py +8 -1
- figrecipe/_editor/_templates/_scripts/_sync.py +242 -0
- figrecipe/_editor/_templates/_scripts/_undo_redo.py +348 -0
- figrecipe/_editor/_templates/_scripts/_zoom.py +52 -19
- figrecipe/_editor/_templates/_styles/__init__.py +9 -0
- figrecipe/_editor/_templates/_styles/_base.py +47 -0
- figrecipe/_editor/_templates/_styles/_buttons.py +127 -6
- figrecipe/_editor/_templates/_styles/_composition.py +87 -0
- figrecipe/_editor/_templates/_styles/_controls.py +168 -3
- figrecipe/_editor/_templates/_styles/_datatable/__init__.py +40 -0
- figrecipe/_editor/_templates/_styles/_datatable/_editable.py +203 -0
- figrecipe/_editor/_templates/_styles/_datatable/_panel.py +268 -0
- figrecipe/_editor/_templates/_styles/_datatable/_table.py +479 -0
- figrecipe/_editor/_templates/_styles/_datatable/_toolbar.py +384 -0
- figrecipe/_editor/_templates/_styles/_datatable/_vars.py +123 -0
- figrecipe/_editor/_templates/_styles/_dynamic_props.py +5 -5
- figrecipe/_editor/_templates/_styles/_file_browser.py +466 -0
- figrecipe/_editor/_templates/_styles/_forms.py +98 -0
- figrecipe/_editor/_templates/_styles/_hitmap.py +7 -0
- figrecipe/_editor/_templates/_styles/_modals.py +29 -0
- figrecipe/_editor/_templates/_styles/_overlays.py +5 -5
- figrecipe/_editor/_templates/_styles/_preview.py +213 -8
- figrecipe/_editor/_templates/_styles/_spinner.py +117 -0
- figrecipe/_editor/static/audio/click.mp3 +0 -0
- figrecipe/_editor/static/click.mp3 +0 -0
- figrecipe/_editor/static/icons/favicon.ico +0 -0
- figrecipe/_integrations/__init__.py +17 -0
- figrecipe/_integrations/_scitex_stats.py +298 -0
- figrecipe/_params/_DECORATION_METHODS.py +2 -0
- figrecipe/_recorder.py +28 -3
- figrecipe/_reproducer/_core.py +60 -49
- figrecipe/_utils/__init__.py +3 -0
- figrecipe/_utils/_bundle.py +205 -0
- figrecipe/_wrappers/_axes.py +150 -2
- figrecipe/_wrappers/_caption_generator.py +218 -0
- figrecipe/_wrappers/_figure.py +26 -1
- figrecipe/_wrappers/_stat_annotation.py +274 -0
- figrecipe/styles/_style_applier.py +10 -2
- figrecipe/styles/presets/SCITEX.yaml +11 -4
- {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/METADATA +144 -146
- figrecipe-0.9.0.dist-info/RECORD +277 -0
- figrecipe-0.9.0.dist-info/entry_points.txt +2 -0
- figrecipe-0.7.4.dist-info/RECORD +0 -188
- {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/WHEEL +0 -0
- {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)
|
figrecipe/_wrappers/_figure.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
14
|
+
left_mm: 1 # Minimal - constrained_layout handles labels
|
|
15
15
|
right_mm: 1
|
|
16
|
-
bottom_mm:
|
|
17
|
-
top_mm:
|
|
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
|
|
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:
|