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,50 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""version command - Show version information."""
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@click.command()
|
|
9
|
+
@click.option(
|
|
10
|
+
"--full",
|
|
11
|
+
is_flag=True,
|
|
12
|
+
help="Show full version info with dependencies.",
|
|
13
|
+
)
|
|
14
|
+
def version(full: bool) -> None:
|
|
15
|
+
"""Show version information."""
|
|
16
|
+
from .. import __version__
|
|
17
|
+
|
|
18
|
+
click.echo(f"figrecipe {__version__}")
|
|
19
|
+
|
|
20
|
+
if full:
|
|
21
|
+
click.echo()
|
|
22
|
+
_show_dependency_versions()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _show_dependency_versions() -> None:
|
|
26
|
+
"""Show versions of key dependencies."""
|
|
27
|
+
deps = [
|
|
28
|
+
("matplotlib", "matplotlib"),
|
|
29
|
+
("numpy", "numpy"),
|
|
30
|
+
("ruamel.yaml", "ruamel.yaml"),
|
|
31
|
+
("scipy", "scipy"),
|
|
32
|
+
("Pillow", "PIL"),
|
|
33
|
+
("seaborn", "seaborn"),
|
|
34
|
+
("pandas", "pandas"),
|
|
35
|
+
("flask", "flask"),
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
click.echo("Dependencies:")
|
|
39
|
+
for name, module in deps:
|
|
40
|
+
try:
|
|
41
|
+
mod = __import__(module)
|
|
42
|
+
ver = getattr(mod, "__version__", "unknown")
|
|
43
|
+
click.echo(f" {name}: {ver}")
|
|
44
|
+
except ImportError:
|
|
45
|
+
click.echo(f" {name}: not installed")
|
|
46
|
+
|
|
47
|
+
# Python version
|
|
48
|
+
import sys
|
|
49
|
+
|
|
50
|
+
click.echo(f"\nPython: {sys.version}")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Composition module for combining multiple figures.
|
|
4
|
+
|
|
5
|
+
This module provides functionality to:
|
|
6
|
+
- Compose new figures from multiple recipe sources
|
|
7
|
+
- Import axes from external recipes into existing figures
|
|
8
|
+
- Hide/show panels for visual composition
|
|
9
|
+
- Align and distribute panels
|
|
10
|
+
|
|
11
|
+
Phase 1-3 of the composition feature.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from ._alignment import AlignmentMode, align_panels, distribute_panels, smart_align
|
|
15
|
+
from ._compose import compose
|
|
16
|
+
from ._import_axes import import_axes
|
|
17
|
+
from ._visibility import hide_panel, show_panel, toggle_panel
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
# Phase 1: Composition
|
|
21
|
+
"compose",
|
|
22
|
+
"import_axes",
|
|
23
|
+
# Phase 2: Visibility
|
|
24
|
+
"hide_panel",
|
|
25
|
+
"show_panel",
|
|
26
|
+
"toggle_panel",
|
|
27
|
+
# Phase 3: Alignment
|
|
28
|
+
"AlignmentMode",
|
|
29
|
+
"align_panels",
|
|
30
|
+
"distribute_panels",
|
|
31
|
+
"smart_align",
|
|
32
|
+
]
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Panel alignment tools for composition feature.
|
|
4
|
+
|
|
5
|
+
Provides alignment and distribution functions for multi-panel figures.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import List, Optional, Tuple, Union
|
|
10
|
+
|
|
11
|
+
from matplotlib.transforms import Bbox
|
|
12
|
+
|
|
13
|
+
from .._wrappers import RecordingFigure
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AlignmentMode(Enum):
|
|
17
|
+
"""Alignment modes for panel positioning."""
|
|
18
|
+
|
|
19
|
+
LEFT = "left"
|
|
20
|
+
RIGHT = "right"
|
|
21
|
+
TOP = "top"
|
|
22
|
+
BOTTOM = "bottom"
|
|
23
|
+
CENTER_H = "center_h" # Horizontal center
|
|
24
|
+
CENTER_V = "center_v" # Vertical center
|
|
25
|
+
AXIS_X = "axis_x" # Align x-axes
|
|
26
|
+
AXIS_Y = "axis_y" # Align y-axes
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def align_panels(
|
|
30
|
+
fig: RecordingFigure,
|
|
31
|
+
panels: List[Tuple[int, int]],
|
|
32
|
+
mode: Union[str, AlignmentMode],
|
|
33
|
+
reference: Optional[Tuple[int, int]] = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Align multiple panels to a reference panel.
|
|
36
|
+
|
|
37
|
+
Parameters
|
|
38
|
+
----------
|
|
39
|
+
fig : RecordingFigure
|
|
40
|
+
The figure containing the panels.
|
|
41
|
+
panels : list of tuple
|
|
42
|
+
List of (row, col) positions to align.
|
|
43
|
+
mode : str or AlignmentMode
|
|
44
|
+
Alignment mode: 'left', 'right', 'top', 'bottom',
|
|
45
|
+
'center_h', 'center_v', 'axis_x', 'axis_y'.
|
|
46
|
+
reference : tuple, optional
|
|
47
|
+
Reference panel position. If None, uses first panel.
|
|
48
|
+
|
|
49
|
+
Examples
|
|
50
|
+
--------
|
|
51
|
+
>>> import figrecipe as fr
|
|
52
|
+
>>> fig, axes = fr.subplots(2, 2)
|
|
53
|
+
>>> # Align left column panels to left edge
|
|
54
|
+
>>> fr.align_panels(fig, [(0, 0), (1, 0)], mode="left")
|
|
55
|
+
"""
|
|
56
|
+
mode = AlignmentMode(mode) if isinstance(mode, str) else mode
|
|
57
|
+
|
|
58
|
+
if not panels:
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
ref_pos = reference or panels[0]
|
|
62
|
+
ref_ax = _get_mpl_axes(fig, ref_pos)
|
|
63
|
+
if ref_ax is None:
|
|
64
|
+
return
|
|
65
|
+
ref_bbox = ref_ax.get_position()
|
|
66
|
+
|
|
67
|
+
for pos in panels:
|
|
68
|
+
if pos == ref_pos:
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
ax = _get_mpl_axes(fig, pos)
|
|
72
|
+
if ax is None:
|
|
73
|
+
continue
|
|
74
|
+
bbox = ax.get_position()
|
|
75
|
+
new_bbox = _calculate_aligned_bbox(bbox, ref_bbox, mode)
|
|
76
|
+
ax.set_position(new_bbox)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def distribute_panels(
|
|
80
|
+
fig: RecordingFigure,
|
|
81
|
+
panels: List[Tuple[int, int]],
|
|
82
|
+
direction: str = "horizontal",
|
|
83
|
+
spacing_mm: Optional[float] = None,
|
|
84
|
+
) -> None:
|
|
85
|
+
"""Distribute panels evenly with optional fixed spacing.
|
|
86
|
+
|
|
87
|
+
Parameters
|
|
88
|
+
----------
|
|
89
|
+
fig : RecordingFigure
|
|
90
|
+
The figure containing the panels.
|
|
91
|
+
panels : list of tuple
|
|
92
|
+
List of (row, col) positions to distribute.
|
|
93
|
+
direction : str
|
|
94
|
+
'horizontal' or 'vertical'.
|
|
95
|
+
spacing_mm : float, optional
|
|
96
|
+
Fixed spacing in mm. If None, distribute evenly within
|
|
97
|
+
current bounds.
|
|
98
|
+
|
|
99
|
+
Examples
|
|
100
|
+
--------
|
|
101
|
+
>>> import figrecipe as fr
|
|
102
|
+
>>> fig, axes = fr.subplots(1, 3)
|
|
103
|
+
>>> # Distribute evenly
|
|
104
|
+
>>> fr.distribute_panels(fig, [(0, 0), (0, 1), (0, 2)])
|
|
105
|
+
>>> # With fixed 5mm spacing
|
|
106
|
+
>>> fr.distribute_panels(fig, [(0, 0), (0, 1), (0, 2)], spacing_mm=5)
|
|
107
|
+
"""
|
|
108
|
+
if len(panels) < 2:
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
# Sort panels by position
|
|
112
|
+
if direction == "horizontal":
|
|
113
|
+
sorted_panels = sorted(panels, key=lambda p: p[1])
|
|
114
|
+
else:
|
|
115
|
+
sorted_panels = sorted(panels, key=lambda p: p[0])
|
|
116
|
+
|
|
117
|
+
# Get bounding boxes
|
|
118
|
+
bboxes = []
|
|
119
|
+
valid_panels = []
|
|
120
|
+
for p in sorted_panels:
|
|
121
|
+
ax = _get_mpl_axes(fig, p)
|
|
122
|
+
if ax is not None:
|
|
123
|
+
bboxes.append(ax.get_position())
|
|
124
|
+
valid_panels.append(p)
|
|
125
|
+
|
|
126
|
+
if len(valid_panels) < 2:
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
# Calculate even distribution
|
|
130
|
+
if direction == "horizontal":
|
|
131
|
+
_distribute_horizontal(fig, valid_panels, bboxes, spacing_mm)
|
|
132
|
+
else:
|
|
133
|
+
_distribute_vertical(fig, valid_panels, bboxes, spacing_mm)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def smart_align(
|
|
137
|
+
fig: RecordingFigure,
|
|
138
|
+
panels: Optional[List[Tuple[int, int]]] = None,
|
|
139
|
+
) -> None:
|
|
140
|
+
"""Automatically align panels in a compact grid layout.
|
|
141
|
+
|
|
142
|
+
Works like human behavior:
|
|
143
|
+
1. Detect grid structure (nrows, ncols)
|
|
144
|
+
2. Place panels from top-left to bottom-right
|
|
145
|
+
3. Calculate minimum rectangle to cover all content in each row/column
|
|
146
|
+
4. Unify row heights and column widths
|
|
147
|
+
5. Use space effectively with theme margins and spacing
|
|
148
|
+
|
|
149
|
+
Uses margin and spacing values from the loaded SCITEX theme:
|
|
150
|
+
- margins.left_mm, margins.right_mm, margins.top_mm, margins.bottom_mm
|
|
151
|
+
- spacing.horizontal_mm, spacing.vertical_mm
|
|
152
|
+
|
|
153
|
+
Parameters
|
|
154
|
+
----------
|
|
155
|
+
fig : RecordingFigure
|
|
156
|
+
The figure containing the panels.
|
|
157
|
+
panels : list of tuple, optional
|
|
158
|
+
Specific panels to align. If None, aligns all panels.
|
|
159
|
+
|
|
160
|
+
Examples
|
|
161
|
+
--------
|
|
162
|
+
>>> import figrecipe as fr
|
|
163
|
+
>>> fig, axes = fr.subplots(2, 2)
|
|
164
|
+
>>> # ... add plots ...
|
|
165
|
+
>>> fr.smart_align(fig) # Align all panels using theme settings
|
|
166
|
+
"""
|
|
167
|
+
from .._utils._units import mm_to_inch
|
|
168
|
+
|
|
169
|
+
if panels is None:
|
|
170
|
+
panels = [tuple(map(int, ax_key.split("_")[1:3])) for ax_key in fig.record.axes]
|
|
171
|
+
|
|
172
|
+
if not panels:
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
# Get matplotlib figure
|
|
176
|
+
mpl_fig = fig.fig if hasattr(fig, "fig") else fig
|
|
177
|
+
|
|
178
|
+
# Get style from loaded theme
|
|
179
|
+
try:
|
|
180
|
+
from ..styles._style_loader import _STYLE_CACHE
|
|
181
|
+
|
|
182
|
+
style = _STYLE_CACHE
|
|
183
|
+
except (ImportError, AttributeError):
|
|
184
|
+
style = None
|
|
185
|
+
|
|
186
|
+
# Extract margin values from theme (with defaults)
|
|
187
|
+
if style and hasattr(style, "margins"):
|
|
188
|
+
margin_left = style.margins.get("left_mm", 6)
|
|
189
|
+
margin_right = style.margins.get("right_mm", 1)
|
|
190
|
+
margin_top = style.margins.get("top_mm", 5)
|
|
191
|
+
margin_bottom = style.margins.get("bottom_mm", 5)
|
|
192
|
+
else:
|
|
193
|
+
margin_left = margin_right = margin_top = margin_bottom = 5
|
|
194
|
+
|
|
195
|
+
# Extract spacing values from theme (with defaults)
|
|
196
|
+
if style and hasattr(style, "spacing"):
|
|
197
|
+
spacing_h_mm = style.spacing.get("horizontal_mm", 10)
|
|
198
|
+
spacing_v_mm = style.spacing.get("vertical_mm", 15)
|
|
199
|
+
else:
|
|
200
|
+
spacing_h_mm = 10
|
|
201
|
+
spacing_v_mm = 15
|
|
202
|
+
|
|
203
|
+
# Determine grid dimensions
|
|
204
|
+
max_row = max(p[0] for p in panels)
|
|
205
|
+
max_col = max(p[1] for p in panels)
|
|
206
|
+
nrows = max_row + 1
|
|
207
|
+
ncols = max_col + 1
|
|
208
|
+
|
|
209
|
+
# Get figure size in inches
|
|
210
|
+
fig_width, fig_height = mpl_fig.get_size_inches()
|
|
211
|
+
|
|
212
|
+
# Convert margins/spacing to figure fraction
|
|
213
|
+
margin_left_frac = mm_to_inch(margin_left) / fig_width
|
|
214
|
+
margin_right_frac = mm_to_inch(margin_right) / fig_width
|
|
215
|
+
margin_top_frac = mm_to_inch(margin_top) / fig_height
|
|
216
|
+
margin_bottom_frac = mm_to_inch(margin_bottom) / fig_height
|
|
217
|
+
spacing_frac_w = mm_to_inch(spacing_h_mm) / fig_width
|
|
218
|
+
spacing_frac_h = mm_to_inch(spacing_v_mm) / fig_height
|
|
219
|
+
|
|
220
|
+
# Build grid of axes
|
|
221
|
+
grid = {}
|
|
222
|
+
for pos in panels:
|
|
223
|
+
ax = _get_mpl_axes(fig, pos)
|
|
224
|
+
if ax is not None:
|
|
225
|
+
grid[pos] = ax
|
|
226
|
+
|
|
227
|
+
# Calculate content-based widths for each column
|
|
228
|
+
col_widths = []
|
|
229
|
+
for c in range(ncols):
|
|
230
|
+
max_width = 0
|
|
231
|
+
for r in range(nrows):
|
|
232
|
+
if (r, c) in grid:
|
|
233
|
+
bbox = grid[(r, c)].get_position()
|
|
234
|
+
max_width = max(max_width, bbox.width)
|
|
235
|
+
col_widths.append(max_width if max_width > 0 else 0.2)
|
|
236
|
+
|
|
237
|
+
# Calculate content-based heights for each row
|
|
238
|
+
row_heights = []
|
|
239
|
+
for r in range(nrows):
|
|
240
|
+
max_height = 0
|
|
241
|
+
for c in range(ncols):
|
|
242
|
+
if (r, c) in grid:
|
|
243
|
+
bbox = grid[(r, c)].get_position()
|
|
244
|
+
max_height = max(max_height, bbox.height)
|
|
245
|
+
row_heights.append(max_height if max_height > 0 else 0.15)
|
|
246
|
+
|
|
247
|
+
# Calculate total content size
|
|
248
|
+
total_content_w = sum(col_widths) + spacing_frac_w * (ncols - 1)
|
|
249
|
+
total_content_h = sum(row_heights) + spacing_frac_h * (nrows - 1)
|
|
250
|
+
|
|
251
|
+
# Available space after asymmetric margins
|
|
252
|
+
avail_w = 1.0 - margin_left_frac - margin_right_frac
|
|
253
|
+
avail_h = 1.0 - margin_top_frac - margin_bottom_frac
|
|
254
|
+
|
|
255
|
+
# Scale factor to fit content in available space
|
|
256
|
+
scale_w = avail_w / total_content_w if total_content_w > 0 else 1.0
|
|
257
|
+
scale_h = avail_h / total_content_h if total_content_h > 0 else 1.0
|
|
258
|
+
scale = min(scale_w, scale_h, 1.0) # Don't enlarge, only shrink if needed
|
|
259
|
+
|
|
260
|
+
# Apply scaling
|
|
261
|
+
col_widths = [w * scale for w in col_widths]
|
|
262
|
+
row_heights = [h * scale for h in row_heights]
|
|
263
|
+
spacing_w = spacing_frac_w * scale
|
|
264
|
+
spacing_h = spacing_frac_h * scale
|
|
265
|
+
|
|
266
|
+
# Recalculate total after scaling
|
|
267
|
+
total_w = sum(col_widths) + spacing_w * (ncols - 1)
|
|
268
|
+
total_h = sum(row_heights) + spacing_h * (nrows - 1)
|
|
269
|
+
|
|
270
|
+
# Position grid: left-aligned with left margin, centered vertically
|
|
271
|
+
start_x = margin_left_frac + (avail_w - total_w) / 2
|
|
272
|
+
|
|
273
|
+
# Position panels from top-left to bottom-right
|
|
274
|
+
# Matplotlib y=0 is bottom, so we work from top down
|
|
275
|
+
y = 1.0 - margin_top_frac - (avail_h - total_h) / 2
|
|
276
|
+
for r in range(nrows):
|
|
277
|
+
y -= row_heights[r]
|
|
278
|
+
x = start_x
|
|
279
|
+
for c in range(ncols):
|
|
280
|
+
if (r, c) in grid:
|
|
281
|
+
ax = grid[(r, c)]
|
|
282
|
+
new_bbox = Bbox.from_bounds(x, y, col_widths[c], row_heights[r])
|
|
283
|
+
ax.set_position(new_bbox)
|
|
284
|
+
x += col_widths[c] + spacing_w
|
|
285
|
+
y -= spacing_h
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _get_mpl_axes(fig: RecordingFigure, position: Tuple[int, int]):
|
|
289
|
+
"""Get matplotlib axes at position.
|
|
290
|
+
|
|
291
|
+
Parameters
|
|
292
|
+
----------
|
|
293
|
+
fig : RecordingFigure
|
|
294
|
+
The figure.
|
|
295
|
+
position : tuple
|
|
296
|
+
(row, col) position.
|
|
297
|
+
|
|
298
|
+
Returns
|
|
299
|
+
-------
|
|
300
|
+
matplotlib.axes.Axes or None
|
|
301
|
+
The matplotlib axes, or None if not found.
|
|
302
|
+
"""
|
|
303
|
+
row, col = position
|
|
304
|
+
try:
|
|
305
|
+
axes = fig._axes
|
|
306
|
+
if isinstance(axes, list):
|
|
307
|
+
if isinstance(axes[0], list):
|
|
308
|
+
ax = axes[row][col]
|
|
309
|
+
else:
|
|
310
|
+
# 1D list for single row/column
|
|
311
|
+
ax = axes[max(row, col)]
|
|
312
|
+
else:
|
|
313
|
+
# Numpy array
|
|
314
|
+
ax = axes[row, col]
|
|
315
|
+
|
|
316
|
+
return ax._ax if hasattr(ax, "_ax") else ax
|
|
317
|
+
except (IndexError, AttributeError, KeyError, TypeError):
|
|
318
|
+
return None
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _calculate_aligned_bbox(
|
|
322
|
+
bbox: Bbox,
|
|
323
|
+
ref_bbox: Bbox,
|
|
324
|
+
mode: AlignmentMode,
|
|
325
|
+
) -> Bbox:
|
|
326
|
+
"""Calculate new bbox aligned to reference.
|
|
327
|
+
|
|
328
|
+
Parameters
|
|
329
|
+
----------
|
|
330
|
+
bbox : Bbox
|
|
331
|
+
Current bounding box.
|
|
332
|
+
ref_bbox : Bbox
|
|
333
|
+
Reference bounding box.
|
|
334
|
+
mode : AlignmentMode
|
|
335
|
+
Alignment mode.
|
|
336
|
+
|
|
337
|
+
Returns
|
|
338
|
+
-------
|
|
339
|
+
Bbox
|
|
340
|
+
New aligned bounding box.
|
|
341
|
+
"""
|
|
342
|
+
x0, y0 = bbox.x0, bbox.y0
|
|
343
|
+
width, height = bbox.width, bbox.height
|
|
344
|
+
|
|
345
|
+
if mode == AlignmentMode.LEFT:
|
|
346
|
+
x0 = ref_bbox.x0
|
|
347
|
+
elif mode == AlignmentMode.RIGHT:
|
|
348
|
+
x0 = ref_bbox.x1 - width
|
|
349
|
+
elif mode == AlignmentMode.TOP:
|
|
350
|
+
y0 = ref_bbox.y1 - height
|
|
351
|
+
elif mode == AlignmentMode.BOTTOM:
|
|
352
|
+
y0 = ref_bbox.y0
|
|
353
|
+
elif mode == AlignmentMode.CENTER_H:
|
|
354
|
+
x0 = ref_bbox.x0 + (ref_bbox.width - width) / 2
|
|
355
|
+
elif mode == AlignmentMode.CENTER_V:
|
|
356
|
+
y0 = ref_bbox.y0 + (ref_bbox.height - height) / 2
|
|
357
|
+
elif mode == AlignmentMode.AXIS_X:
|
|
358
|
+
# Align bottom edges (x-axis position)
|
|
359
|
+
y0 = ref_bbox.y0
|
|
360
|
+
elif mode == AlignmentMode.AXIS_Y:
|
|
361
|
+
# Align left edges (y-axis position)
|
|
362
|
+
x0 = ref_bbox.x0
|
|
363
|
+
|
|
364
|
+
return Bbox.from_bounds(x0, y0, width, height)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _distribute_horizontal(
|
|
368
|
+
fig: RecordingFigure,
|
|
369
|
+
panels: List[Tuple[int, int]],
|
|
370
|
+
bboxes: List[Bbox],
|
|
371
|
+
spacing_mm: Optional[float],
|
|
372
|
+
) -> None:
|
|
373
|
+
"""Distribute panels horizontally.
|
|
374
|
+
|
|
375
|
+
Parameters
|
|
376
|
+
----------
|
|
377
|
+
fig : RecordingFigure
|
|
378
|
+
The figure.
|
|
379
|
+
panels : list of tuple
|
|
380
|
+
Panel positions (sorted).
|
|
381
|
+
bboxes : list of Bbox
|
|
382
|
+
Current bounding boxes.
|
|
383
|
+
spacing_mm : float or None
|
|
384
|
+
Fixed spacing in mm, or None for even distribution.
|
|
385
|
+
"""
|
|
386
|
+
if spacing_mm is not None:
|
|
387
|
+
from .._utils._units import mm_to_inch
|
|
388
|
+
|
|
389
|
+
fig_width = fig.fig.get_figwidth()
|
|
390
|
+
spacing = mm_to_inch(spacing_mm) / fig_width
|
|
391
|
+
else:
|
|
392
|
+
total_width = sum(b.width for b in bboxes)
|
|
393
|
+
available = bboxes[-1].x1 - bboxes[0].x0
|
|
394
|
+
spacing = (
|
|
395
|
+
(available - total_width) / (len(panels) - 1) if len(panels) > 1 else 0
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
x = bboxes[0].x0
|
|
399
|
+
for panel, bbox in zip(panels, bboxes):
|
|
400
|
+
ax = _get_mpl_axes(fig, panel)
|
|
401
|
+
if ax is not None:
|
|
402
|
+
new_bbox = Bbox.from_bounds(x, bbox.y0, bbox.width, bbox.height)
|
|
403
|
+
ax.set_position(new_bbox)
|
|
404
|
+
x += bbox.width + spacing
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _distribute_vertical(
|
|
408
|
+
fig: RecordingFigure,
|
|
409
|
+
panels: List[Tuple[int, int]],
|
|
410
|
+
bboxes: List[Bbox],
|
|
411
|
+
spacing_mm: Optional[float],
|
|
412
|
+
) -> None:
|
|
413
|
+
"""Distribute panels vertically.
|
|
414
|
+
|
|
415
|
+
Parameters
|
|
416
|
+
----------
|
|
417
|
+
fig : RecordingFigure
|
|
418
|
+
The figure.
|
|
419
|
+
panels : list of tuple
|
|
420
|
+
Panel positions (sorted).
|
|
421
|
+
bboxes : list of Bbox
|
|
422
|
+
Current bounding boxes.
|
|
423
|
+
spacing_mm : float or None
|
|
424
|
+
Fixed spacing in mm, or None for even distribution.
|
|
425
|
+
"""
|
|
426
|
+
if spacing_mm is not None:
|
|
427
|
+
from .._utils._units import mm_to_inch
|
|
428
|
+
|
|
429
|
+
fig_height = fig.fig.get_figheight()
|
|
430
|
+
spacing = mm_to_inch(spacing_mm) / fig_height
|
|
431
|
+
else:
|
|
432
|
+
total_height = sum(b.height for b in bboxes)
|
|
433
|
+
available = bboxes[-1].y1 - bboxes[0].y0
|
|
434
|
+
spacing = (
|
|
435
|
+
(available - total_height) / (len(panels) - 1) if len(panels) > 1 else 0
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
y = bboxes[0].y0
|
|
439
|
+
for panel, bbox in zip(panels, bboxes):
|
|
440
|
+
ax = _get_mpl_axes(fig, panel)
|
|
441
|
+
if ax is not None:
|
|
442
|
+
new_bbox = Bbox.from_bounds(bbox.x0, y, bbox.width, bbox.height)
|
|
443
|
+
ax.set_position(new_bbox)
|
|
444
|
+
y += bbox.height + spacing
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
__all__ = [
|
|
448
|
+
"AlignmentMode",
|
|
449
|
+
"align_panels",
|
|
450
|
+
"distribute_panels",
|
|
451
|
+
"smart_align",
|
|
452
|
+
]
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Main composition logic for combining multiple figures."""
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, Tuple, Union
|
|
7
|
+
|
|
8
|
+
from numpy.typing import NDArray
|
|
9
|
+
|
|
10
|
+
from .._recorder import FigureRecord
|
|
11
|
+
from .._serializer import load_recipe
|
|
12
|
+
from .._wrappers import RecordingAxes, RecordingFigure
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def compose(
|
|
16
|
+
layout: Tuple[int, int],
|
|
17
|
+
sources: Dict[Tuple[int, int], Union[str, Path, FigureRecord, Tuple]],
|
|
18
|
+
**kwargs,
|
|
19
|
+
) -> Tuple[RecordingFigure, Union[RecordingAxes, NDArray]]:
|
|
20
|
+
"""Compose a new figure from multiple recipe sources.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
layout : tuple
|
|
25
|
+
(nrows, ncols) for the new composite figure.
|
|
26
|
+
sources : dict
|
|
27
|
+
Mapping of (row, col) -> source specification.
|
|
28
|
+
Source can be:
|
|
29
|
+
- str/Path: Recipe file path (uses first axes)
|
|
30
|
+
- FigureRecord: Direct record (uses first axes)
|
|
31
|
+
- Tuple[source, ax_key]: Specific axes from source
|
|
32
|
+
|
|
33
|
+
**kwargs
|
|
34
|
+
Additional arguments passed to subplots().
|
|
35
|
+
|
|
36
|
+
Returns
|
|
37
|
+
-------
|
|
38
|
+
fig : RecordingFigure
|
|
39
|
+
Composed figure.
|
|
40
|
+
axes : RecordingAxes or ndarray of RecordingAxes
|
|
41
|
+
Axes of the composed figure.
|
|
42
|
+
|
|
43
|
+
Examples
|
|
44
|
+
--------
|
|
45
|
+
>>> import figrecipe as fr
|
|
46
|
+
>>> fig, axes = fr.compose(
|
|
47
|
+
... layout=(1, 2),
|
|
48
|
+
... sources={
|
|
49
|
+
... (0, 0): "experiment_a.yaml",
|
|
50
|
+
... (0, 1): "experiment_b.yaml",
|
|
51
|
+
... }
|
|
52
|
+
... )
|
|
53
|
+
"""
|
|
54
|
+
from .. import subplots
|
|
55
|
+
|
|
56
|
+
nrows, ncols = layout
|
|
57
|
+
fig, axes = subplots(nrows=nrows, ncols=ncols, **kwargs)
|
|
58
|
+
|
|
59
|
+
for (row, col), source_spec in sources.items():
|
|
60
|
+
source_record, ax_key = _parse_source_spec(source_spec)
|
|
61
|
+
ax_record = source_record.axes.get(ax_key)
|
|
62
|
+
|
|
63
|
+
if ax_record is None:
|
|
64
|
+
available = list(source_record.axes.keys())
|
|
65
|
+
raise ValueError(
|
|
66
|
+
f"Axes '{ax_key}' not found in source. Available: {available}"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
target_ax = _get_axes_at(axes, row, col, nrows, ncols)
|
|
70
|
+
_replay_axes_record(target_ax, ax_record, fig.record, row, col)
|
|
71
|
+
|
|
72
|
+
return fig, axes
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _parse_source_spec(
|
|
76
|
+
spec: Union[str, Path, FigureRecord, Tuple],
|
|
77
|
+
) -> Tuple[FigureRecord, str]:
|
|
78
|
+
"""Parse source specification into (FigureRecord, ax_key).
|
|
79
|
+
|
|
80
|
+
Parameters
|
|
81
|
+
----------
|
|
82
|
+
spec : various
|
|
83
|
+
Source specification.
|
|
84
|
+
|
|
85
|
+
Returns
|
|
86
|
+
-------
|
|
87
|
+
tuple
|
|
88
|
+
(FigureRecord, ax_key)
|
|
89
|
+
"""
|
|
90
|
+
if isinstance(spec, (str, Path)):
|
|
91
|
+
return load_recipe(spec), "ax_0_0"
|
|
92
|
+
elif isinstance(spec, FigureRecord):
|
|
93
|
+
return spec, "ax_0_0"
|
|
94
|
+
elif isinstance(spec, tuple) and len(spec) == 2:
|
|
95
|
+
source, ax_key = spec
|
|
96
|
+
if isinstance(source, (str, Path)):
|
|
97
|
+
return load_recipe(source), ax_key
|
|
98
|
+
elif isinstance(source, FigureRecord):
|
|
99
|
+
return source, ax_key
|
|
100
|
+
raise TypeError(f"Invalid source in tuple: {type(source)}")
|
|
101
|
+
raise TypeError(f"Invalid source spec type: {type(spec)}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _get_axes_at(
|
|
105
|
+
axes: Union[RecordingAxes, NDArray],
|
|
106
|
+
row: int,
|
|
107
|
+
col: int,
|
|
108
|
+
nrows: int,
|
|
109
|
+
ncols: int,
|
|
110
|
+
) -> RecordingAxes:
|
|
111
|
+
"""Get axes at position, handling different array shapes.
|
|
112
|
+
|
|
113
|
+
Parameters
|
|
114
|
+
----------
|
|
115
|
+
axes : RecordingAxes or ndarray
|
|
116
|
+
Axes object(s) from subplots.
|
|
117
|
+
row, col : int
|
|
118
|
+
Target position.
|
|
119
|
+
nrows, ncols : int
|
|
120
|
+
Grid dimensions.
|
|
121
|
+
|
|
122
|
+
Returns
|
|
123
|
+
-------
|
|
124
|
+
RecordingAxes
|
|
125
|
+
Axes at the specified position.
|
|
126
|
+
"""
|
|
127
|
+
if nrows == 1 and ncols == 1:
|
|
128
|
+
return axes
|
|
129
|
+
elif nrows == 1:
|
|
130
|
+
return axes[col]
|
|
131
|
+
elif ncols == 1:
|
|
132
|
+
return axes[row]
|
|
133
|
+
else:
|
|
134
|
+
return axes[row, col]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _replay_axes_record(
|
|
138
|
+
target_ax: RecordingAxes,
|
|
139
|
+
ax_record,
|
|
140
|
+
fig_record: FigureRecord,
|
|
141
|
+
row: int,
|
|
142
|
+
col: int,
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Replay all calls from ax_record onto target axes.
|
|
145
|
+
|
|
146
|
+
Parameters
|
|
147
|
+
----------
|
|
148
|
+
target_ax : RecordingAxes
|
|
149
|
+
Target axes to replay onto.
|
|
150
|
+
ax_record : AxesRecord
|
|
151
|
+
Source axes record with calls.
|
|
152
|
+
fig_record : FigureRecord
|
|
153
|
+
Figure record to update.
|
|
154
|
+
row, col : int
|
|
155
|
+
Target position for recording.
|
|
156
|
+
"""
|
|
157
|
+
from .._reproducer._core import _replay_call
|
|
158
|
+
|
|
159
|
+
mpl_ax = target_ax._ax if hasattr(target_ax, "_ax") else target_ax
|
|
160
|
+
result_cache: Dict[str, Any] = {}
|
|
161
|
+
|
|
162
|
+
# Replay plotting calls
|
|
163
|
+
for call in ax_record.calls:
|
|
164
|
+
result = _replay_call(mpl_ax, call, result_cache)
|
|
165
|
+
if result is not None:
|
|
166
|
+
result_cache[call.id] = result
|
|
167
|
+
|
|
168
|
+
# Replay decoration calls
|
|
169
|
+
for call in ax_record.decorations:
|
|
170
|
+
result = _replay_call(mpl_ax, call, result_cache)
|
|
171
|
+
if result is not None:
|
|
172
|
+
result_cache[call.id] = result
|
|
173
|
+
|
|
174
|
+
# Update figure record with imported axes
|
|
175
|
+
ax_key = f"ax_{row}_{col}"
|
|
176
|
+
fig_record.axes[ax_key] = ax_record
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
__all__ = ["compose"]
|