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,190 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Registry for plot types using existing figrecipe infrastructure.
|
|
4
|
+
|
|
5
|
+
Uses:
|
|
6
|
+
- figrecipe._signatures for signature introspection
|
|
7
|
+
- figrecipe._dev.demo_plotters._categories for plot type organization
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import Any, Dict, List
|
|
11
|
+
|
|
12
|
+
from .._dev.demo_plotters._categories import CATEGORIES as _PLOT_CATEGORIES
|
|
13
|
+
from .._dev.demo_plotters._categories import (
|
|
14
|
+
CATEGORY_DISPLAY_NAMES as _PLOT_DISPLAY_NAMES,
|
|
15
|
+
)
|
|
16
|
+
from .._signatures import get_signature
|
|
17
|
+
|
|
18
|
+
# Decoration methods to add to the selector
|
|
19
|
+
DECORATION_METHODS: List[str] = [
|
|
20
|
+
"text",
|
|
21
|
+
"annotate",
|
|
22
|
+
"arrow",
|
|
23
|
+
"axhline",
|
|
24
|
+
"axvline",
|
|
25
|
+
"axhspan",
|
|
26
|
+
"axvspan",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
# Combined categories: plots + decorations
|
|
30
|
+
CATEGORIES: Dict[str, List[str]] = {
|
|
31
|
+
**_PLOT_CATEGORIES,
|
|
32
|
+
"decoration": DECORATION_METHODS,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
CATEGORY_DISPLAY_NAMES: Dict[str, str] = {
|
|
36
|
+
**_PLOT_DISPLAY_NAMES,
|
|
37
|
+
"decoration": "Decoration & Annotation",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _get_docstring(method_name: str) -> str:
|
|
42
|
+
"""Get first line of docstring for a matplotlib method."""
|
|
43
|
+
try:
|
|
44
|
+
import matplotlib.pyplot as plt
|
|
45
|
+
from matplotlib.axes import Axes
|
|
46
|
+
|
|
47
|
+
# Try Axes method first, then plt
|
|
48
|
+
fn = getattr(Axes, method_name, None) or getattr(plt, method_name, None)
|
|
49
|
+
if fn and fn.__doc__:
|
|
50
|
+
# Get first non-empty line of docstring
|
|
51
|
+
first_line = fn.__doc__.strip().split("\n")[0].strip()
|
|
52
|
+
# Clean up and truncate if needed
|
|
53
|
+
if len(first_line) > 100:
|
|
54
|
+
first_line = first_line[:97] + "..."
|
|
55
|
+
return first_line
|
|
56
|
+
except Exception:
|
|
57
|
+
pass
|
|
58
|
+
return ""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_plot_type_info(method_name: str) -> Dict[str, Any]:
|
|
62
|
+
"""Get plot type information from existing signature infrastructure."""
|
|
63
|
+
sig = get_signature(method_name)
|
|
64
|
+
|
|
65
|
+
args = sig.get("args", [])
|
|
66
|
+
kwargs = sig.get("kwargs", {})
|
|
67
|
+
|
|
68
|
+
# Args that also exist in kwargs with defaults are actually optional
|
|
69
|
+
kwargs_with_defaults = {
|
|
70
|
+
k for k, v in kwargs.items() if v.get("default") is not None or "default" in v
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# Format required and optional args
|
|
74
|
+
required_parts = []
|
|
75
|
+
optional_parts = []
|
|
76
|
+
|
|
77
|
+
for arg in args:
|
|
78
|
+
name = arg.get("name", "")
|
|
79
|
+
if name.startswith("*"):
|
|
80
|
+
continue
|
|
81
|
+
# Arg is optional if marked optional OR has a default in kwargs
|
|
82
|
+
is_optional = arg.get("optional", False) or name in kwargs_with_defaults
|
|
83
|
+
if is_optional:
|
|
84
|
+
optional_parts.append(name)
|
|
85
|
+
else:
|
|
86
|
+
required_parts.append(name)
|
|
87
|
+
|
|
88
|
+
required_str = ", ".join(required_parts) if required_parts else "data"
|
|
89
|
+
# Only show first 2 optional vars, deduplicated
|
|
90
|
+
optional_str = ", ".join(optional_parts[:2]) if optional_parts else ""
|
|
91
|
+
|
|
92
|
+
# Build hint string showing signature
|
|
93
|
+
hint_parts = []
|
|
94
|
+
for arg in args[:4]:
|
|
95
|
+
name = arg.get("name", "")
|
|
96
|
+
if name.startswith("*"):
|
|
97
|
+
continue
|
|
98
|
+
is_optional = arg.get("optional", False) or name in kwargs_with_defaults
|
|
99
|
+
if is_optional:
|
|
100
|
+
hint_parts.append(f"[{name}]")
|
|
101
|
+
else:
|
|
102
|
+
hint_parts.append(name)
|
|
103
|
+
|
|
104
|
+
if hint_parts:
|
|
105
|
+
hint = f"{method_name}({', '.join(hint_parts)})"
|
|
106
|
+
else:
|
|
107
|
+
hint = f"{method_name}(data)"
|
|
108
|
+
|
|
109
|
+
# Get docstring for tooltip
|
|
110
|
+
docstring = _get_docstring(method_name)
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
"name": method_name,
|
|
114
|
+
"required": required_str,
|
|
115
|
+
"optional": optional_str,
|
|
116
|
+
"hint": hint,
|
|
117
|
+
"docstring": docstring,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def get_all_plot_types() -> Dict[str, Dict[str, Any]]:
|
|
122
|
+
"""Get all plot types organized by category."""
|
|
123
|
+
result = {}
|
|
124
|
+
for cat_key, plots in CATEGORIES.items():
|
|
125
|
+
label = CATEGORY_DISPLAY_NAMES.get(cat_key, cat_key)
|
|
126
|
+
result[cat_key] = {
|
|
127
|
+
"label": label,
|
|
128
|
+
"types": [get_plot_type_info(m) for m in plots],
|
|
129
|
+
}
|
|
130
|
+
return result
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def generate_html_options() -> str:
|
|
134
|
+
"""Generate HTML <optgroup> and <option> elements for plot type selector.
|
|
135
|
+
|
|
136
|
+
Shows required and optional arguments with brackets around optional.
|
|
137
|
+
e.g., "fill_between (x, y1, [y2], [where])"
|
|
138
|
+
Includes docstring as tooltip on hover.
|
|
139
|
+
"""
|
|
140
|
+
import html
|
|
141
|
+
|
|
142
|
+
html_parts = []
|
|
143
|
+
first = True
|
|
144
|
+
for cat_key, plots in CATEGORIES.items():
|
|
145
|
+
label = CATEGORY_DISPLAY_NAMES.get(cat_key, cat_key)
|
|
146
|
+
html_parts.append(f' <optgroup label="{label}">')
|
|
147
|
+
for method in plots:
|
|
148
|
+
info = get_plot_type_info(method)
|
|
149
|
+
# Use hint which has correct bracket notation
|
|
150
|
+
# hint format: "method(x, y, [opt1], [opt2])"
|
|
151
|
+
display_text = info["hint"]
|
|
152
|
+
selected = " selected" if first else ""
|
|
153
|
+
first = False
|
|
154
|
+
# Add docstring as title for tooltip
|
|
155
|
+
docstring = info.get("docstring", "")
|
|
156
|
+
title_attr = f' title="{html.escape(docstring)}"' if docstring else ""
|
|
157
|
+
html_parts.append(
|
|
158
|
+
f' <option value="{method}"{selected}{title_attr}>{display_text}</option>'
|
|
159
|
+
)
|
|
160
|
+
html_parts.append(" </optgroup>")
|
|
161
|
+
return "\n".join(html_parts)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def generate_js_hints() -> str:
|
|
165
|
+
"""Generate JavaScript PLOT_TYPE_HINTS object from signature infrastructure."""
|
|
166
|
+
lines = ["const PLOT_TYPE_HINTS = {"]
|
|
167
|
+
for plots in CATEGORIES.values():
|
|
168
|
+
for method in plots:
|
|
169
|
+
info = get_plot_type_info(method)
|
|
170
|
+
req = info["required"].replace("'", "\\'")
|
|
171
|
+
opt = info["optional"].replace("'", "\\'")
|
|
172
|
+
hint = info["hint"].replace("'", "\\'")
|
|
173
|
+
doc = info.get("docstring", "").replace("'", "\\'")
|
|
174
|
+
lines.append(
|
|
175
|
+
f" {method}: {{ required: '{req}', optional: '{opt}', hint: '{hint}', doc: '{doc}' }},"
|
|
176
|
+
)
|
|
177
|
+
lines.append("};")
|
|
178
|
+
return "\n".join(lines)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
__all__ = [
|
|
182
|
+
"CATEGORIES",
|
|
183
|
+
"CATEGORY_DISPLAY_NAMES",
|
|
184
|
+
"get_plot_type_info",
|
|
185
|
+
"get_all_plot_types",
|
|
186
|
+
"generate_html_options",
|
|
187
|
+
"generate_js_hints",
|
|
188
|
+
]
|
|
189
|
+
|
|
190
|
+
# EOF
|
|
@@ -23,11 +23,18 @@ def apply_overrides(
|
|
|
23
23
|
- axes_width_mm, axes_height_mm
|
|
24
24
|
- fonts_axis_label_pt, fonts_tick_label_pt
|
|
25
25
|
- lines_trace_mm
|
|
26
|
+
- spacing_horizontal_mm, spacing_vertical_mm
|
|
27
|
+
- margins_left_mm, margins_right_mm, margins_bottom_mm, margins_top_mm
|
|
28
|
+
- call_overrides: dict mapping call_id -> {param: value}
|
|
26
29
|
- etc.
|
|
27
30
|
record : FigureRecord, optional
|
|
28
31
|
Recording record to access call IDs for grouping elements.
|
|
29
32
|
"""
|
|
30
33
|
from ..styles._style_applier import apply_style_mm
|
|
34
|
+
from ._figure_layout import apply_figure_layout_overrides
|
|
35
|
+
|
|
36
|
+
# Apply figure-level layout overrides (spacing, margins)
|
|
37
|
+
apply_figure_layout_overrides(fig, overrides, record)
|
|
31
38
|
|
|
32
39
|
axes_list = fig.get_axes()
|
|
33
40
|
|
|
@@ -49,6 +56,14 @@ def apply_overrides(
|
|
|
49
56
|
ax_record = _find_ax_record(ax, axes_list, record)
|
|
50
57
|
apply_color_palette(ax, color_palette, ax_record)
|
|
51
58
|
|
|
59
|
+
# Apply call-specific overrides (from Element tab edits)
|
|
60
|
+
# Single source of truth - same function for initial and re-render
|
|
61
|
+
call_overrides = overrides.get("call_overrides", {})
|
|
62
|
+
if call_overrides and record:
|
|
63
|
+
from ._call_overrides import apply_call_overrides
|
|
64
|
+
|
|
65
|
+
apply_call_overrides(fig, call_overrides, record)
|
|
66
|
+
|
|
52
67
|
|
|
53
68
|
def _apply_font_overrides(ax: Axes, overrides: Dict[str, Any]) -> None:
|
|
54
69
|
"""Apply font-related overrides to axes."""
|
|
@@ -100,16 +115,13 @@ def _apply_behavior_overrides(ax: Axes, overrides: Dict[str, Any]) -> None:
|
|
|
100
115
|
else:
|
|
101
116
|
ax.grid(False)
|
|
102
117
|
|
|
103
|
-
# Spines (
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
)
|
|
111
|
-
if hide_right is not None:
|
|
112
|
-
ax.spines["right"].set_visible(not hide_right)
|
|
118
|
+
# Spines visibility (all four directions)
|
|
119
|
+
for side in ["top", "right", "bottom", "left"]:
|
|
120
|
+
key = f"behavior_hide_{side}_spine"
|
|
121
|
+
legacy_key = f"hide_{side}_spine"
|
|
122
|
+
hide_value = overrides.get(key, overrides.get(legacy_key))
|
|
123
|
+
if hide_value is not None:
|
|
124
|
+
ax.spines[side].set_visible(not hide_value)
|
|
113
125
|
|
|
114
126
|
|
|
115
127
|
def _apply_legend_overrides(ax: Axes, overrides: Dict[str, Any]) -> None:
|
|
@@ -274,7 +286,10 @@ def _apply_colors_to_bars(
|
|
|
274
286
|
ax_record: Optional[Any],
|
|
275
287
|
call_color_map: Dict[str, int],
|
|
276
288
|
) -> None:
|
|
277
|
-
"""Apply colors to bar and histogram elements.
|
|
289
|
+
"""Apply colors to bar and histogram elements.
|
|
290
|
+
|
|
291
|
+
Skips bars that have an explicit 'color' kwarg set (from Element tab edits).
|
|
292
|
+
"""
|
|
278
293
|
from matplotlib.patches import Rectangle
|
|
279
294
|
|
|
280
295
|
rectangles = [p for p in ax.patches if isinstance(p, Rectangle)]
|
|
@@ -292,6 +307,9 @@ def _apply_colors_to_bars(
|
|
|
292
307
|
call_idx = (
|
|
293
308
|
min(i // rect_per_call, len(bar_calls) - 1) if rect_per_call > 0 else 0
|
|
294
309
|
)
|
|
310
|
+
# Skip if this call has an explicit color set (user override)
|
|
311
|
+
if call_idx < len(bar_calls) and "color" in bar_calls[call_idx].kwargs:
|
|
312
|
+
continue
|
|
295
313
|
if call_idx < len(bar_calls) and bar_calls[call_idx].id in call_color_map:
|
|
296
314
|
color_idx = call_color_map[bar_calls[call_idx].id]
|
|
297
315
|
else:
|
|
@@ -461,6 +479,15 @@ def _apply_dark_mode_to_axes(ax: Axes, bg_color: str, text_color: str) -> None:
|
|
|
461
479
|
for text in ax.texts:
|
|
462
480
|
text.set_color(text_color)
|
|
463
481
|
|
|
482
|
+
# Stat annotation bracket lines (Line2D with clip_on=False)
|
|
483
|
+
for line in ax.get_lines():
|
|
484
|
+
# Bracket lines have clip_on=False and are typically black
|
|
485
|
+
if not line.get_clip_on():
|
|
486
|
+
current_color = line.get_color()
|
|
487
|
+
# Only update if it's a dark color (black or near-black)
|
|
488
|
+
if current_color in ["black", "k", "#000000", (0, 0, 0), (0.0, 0.0, 0.0)]:
|
|
489
|
+
line.set_color(text_color)
|
|
490
|
+
|
|
464
491
|
# Legend
|
|
465
492
|
legend = ax.get_legend()
|
|
466
493
|
if legend is not None:
|
figrecipe/_editor/_renderer.py
CHANGED
|
@@ -15,6 +15,48 @@ from ._bbox import extract_bboxes
|
|
|
15
15
|
from ._render_overrides import apply_dark_mode, apply_overrides
|
|
16
16
|
|
|
17
17
|
|
|
18
|
+
def _restore_light_mode(fig) -> None:
|
|
19
|
+
"""Restore light mode colors to figure (undo dark mode changes)."""
|
|
20
|
+
text_color = "black"
|
|
21
|
+
|
|
22
|
+
# Figure background (transparent)
|
|
23
|
+
fig.patch.set_facecolor("none")
|
|
24
|
+
|
|
25
|
+
# Figure-level text
|
|
26
|
+
if hasattr(fig, "_suptitle") and fig._suptitle is not None:
|
|
27
|
+
fig._suptitle.set_color(text_color)
|
|
28
|
+
if hasattr(fig, "_supxlabel") and fig._supxlabel is not None:
|
|
29
|
+
fig._supxlabel.set_color(text_color)
|
|
30
|
+
if hasattr(fig, "_supylabel") and fig._supylabel is not None:
|
|
31
|
+
fig._supylabel.set_color(text_color)
|
|
32
|
+
|
|
33
|
+
for ax in fig.get_axes():
|
|
34
|
+
# Axes background (transparent)
|
|
35
|
+
ax.set_facecolor("none")
|
|
36
|
+
# Text colors
|
|
37
|
+
ax.xaxis.label.set_color(text_color)
|
|
38
|
+
ax.yaxis.label.set_color(text_color)
|
|
39
|
+
ax.title.set_color(text_color)
|
|
40
|
+
ax.tick_params(colors=text_color)
|
|
41
|
+
# Spines
|
|
42
|
+
for spine in ax.spines.values():
|
|
43
|
+
spine.set_edgecolor(text_color)
|
|
44
|
+
# Text objects (panel labels, annotations)
|
|
45
|
+
for text in ax.texts:
|
|
46
|
+
text.set_color(text_color)
|
|
47
|
+
# Bracket lines (Line2D with clip_on=False)
|
|
48
|
+
for line in ax.get_lines():
|
|
49
|
+
if not line.get_clip_on():
|
|
50
|
+
line.set_color(text_color)
|
|
51
|
+
# Legend
|
|
52
|
+
legend = ax.get_legend()
|
|
53
|
+
if legend is not None:
|
|
54
|
+
legend.get_frame().set_facecolor("none")
|
|
55
|
+
legend.get_frame().set_edgecolor(text_color)
|
|
56
|
+
for text in legend.get_texts():
|
|
57
|
+
text.set_color(text_color)
|
|
58
|
+
|
|
59
|
+
|
|
18
60
|
def render_preview(
|
|
19
61
|
fig: RecordingFigure,
|
|
20
62
|
overrides: Optional[Dict[str, Any]] = None,
|
|
@@ -54,9 +96,12 @@ def render_preview(
|
|
|
54
96
|
if overrides:
|
|
55
97
|
apply_overrides(mpl_fig, overrides, record)
|
|
56
98
|
|
|
57
|
-
# Apply dark mode if requested
|
|
99
|
+
# Apply dark mode if requested, or restore light mode colors
|
|
58
100
|
if dark_mode:
|
|
59
101
|
apply_dark_mode(mpl_fig)
|
|
102
|
+
else:
|
|
103
|
+
# Restore light mode colors (needed because figure is reused)
|
|
104
|
+
_restore_light_mode(mpl_fig)
|
|
60
105
|
|
|
61
106
|
# Finalize ticks and special plots (must be done after all plotting)
|
|
62
107
|
_finalize_figure(fig, mpl_fig)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Annotation-related Flask route handlers for the figure editor.
|
|
4
|
+
|
|
5
|
+
Handles updating positions of decorative elements like panel labels,
|
|
6
|
+
text annotations, and arrows.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from flask import jsonify, request
|
|
10
|
+
|
|
11
|
+
from ._helpers import render_with_overrides
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def register_annotation_routes(app, editor):
|
|
15
|
+
"""Register annotation-related routes with the Flask app."""
|
|
16
|
+
|
|
17
|
+
@app.route("/update_annotation_position", methods=["POST"])
|
|
18
|
+
def update_annotation_position():
|
|
19
|
+
"""Update annotation position (panel label, text, arrow).
|
|
20
|
+
|
|
21
|
+
Expects JSON: {
|
|
22
|
+
ax_index: int,
|
|
23
|
+
annotation_type: str ('panel_label', 'text', 'arrow'),
|
|
24
|
+
text_index: int,
|
|
25
|
+
x: float (axes-relative 0-1),
|
|
26
|
+
y: float (axes-relative 0-1)
|
|
27
|
+
}
|
|
28
|
+
"""
|
|
29
|
+
data = request.get_json() or {}
|
|
30
|
+
ax_index = data.get("ax_index", 0)
|
|
31
|
+
annotation_type = data.get("annotation_type", "text")
|
|
32
|
+
text_index = data.get("text_index", 0)
|
|
33
|
+
new_x = data.get("x")
|
|
34
|
+
new_y = data.get("y")
|
|
35
|
+
|
|
36
|
+
if new_x is None or new_y is None:
|
|
37
|
+
return jsonify({"error": "Missing x or y coordinate"}), 400
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
# Get the matplotlib figure
|
|
41
|
+
mpl_fig = editor.fig.fig if hasattr(editor.fig, "fig") else editor.fig
|
|
42
|
+
axes_list = mpl_fig.get_axes()
|
|
43
|
+
|
|
44
|
+
if ax_index >= len(axes_list):
|
|
45
|
+
return jsonify({"error": f"Invalid axis index: {ax_index}"}), 400
|
|
46
|
+
|
|
47
|
+
ax = axes_list[ax_index]
|
|
48
|
+
|
|
49
|
+
if annotation_type in ("panel_label", "text"):
|
|
50
|
+
# Update text position
|
|
51
|
+
if text_index >= len(ax.texts):
|
|
52
|
+
return jsonify({"error": f"Invalid text index: {text_index}"}), 400
|
|
53
|
+
|
|
54
|
+
text_obj = ax.texts[text_index]
|
|
55
|
+
|
|
56
|
+
# Set new position (in axes coordinates for transAxes transform)
|
|
57
|
+
if text_obj.get_transform() == ax.transAxes:
|
|
58
|
+
text_obj.set_position((new_x, new_y))
|
|
59
|
+
else:
|
|
60
|
+
# For data coordinates, convert from axes-relative
|
|
61
|
+
xlim = ax.get_xlim()
|
|
62
|
+
ylim = ax.get_ylim()
|
|
63
|
+
data_x = xlim[0] + new_x * (xlim[1] - xlim[0])
|
|
64
|
+
data_y = ylim[0] + new_y * (ylim[1] - ylim[0])
|
|
65
|
+
text_obj.set_position((data_x, data_y))
|
|
66
|
+
|
|
67
|
+
# Store in manual_overrides for persistence and undo support
|
|
68
|
+
override_key = f"annotation_pos_ax{ax_index}_text{text_index}"
|
|
69
|
+
editor.style_overrides.manual_overrides[override_key] = {
|
|
70
|
+
"x": new_x,
|
|
71
|
+
"y": new_y,
|
|
72
|
+
"type": annotation_type,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
elif annotation_type == "arrow":
|
|
76
|
+
# Update arrow position (FancyArrowPatch)
|
|
77
|
+
from matplotlib.patches import FancyArrowPatch
|
|
78
|
+
|
|
79
|
+
arrow_patches = [
|
|
80
|
+
p for p in ax.patches if isinstance(p, FancyArrowPatch)
|
|
81
|
+
]
|
|
82
|
+
arrow_index = data.get("arrow_index", 0)
|
|
83
|
+
|
|
84
|
+
if arrow_index >= len(arrow_patches):
|
|
85
|
+
return jsonify(
|
|
86
|
+
{"error": f"Invalid arrow index: {arrow_index}"}
|
|
87
|
+
), 400
|
|
88
|
+
|
|
89
|
+
# Arrow position update is more complex - skip for now
|
|
90
|
+
return jsonify({"error": "Arrow drag not yet implemented"}), 501
|
|
91
|
+
|
|
92
|
+
# Re-render the figure
|
|
93
|
+
base64_img, bboxes, img_size = render_with_overrides(
|
|
94
|
+
editor.fig,
|
|
95
|
+
editor.get_effective_style(),
|
|
96
|
+
editor.dark_mode,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
return jsonify(
|
|
100
|
+
{
|
|
101
|
+
"success": True,
|
|
102
|
+
"image": base64_img,
|
|
103
|
+
"bboxes": bboxes,
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
except Exception as e:
|
|
108
|
+
import traceback
|
|
109
|
+
|
|
110
|
+
traceback.print_exc()
|
|
111
|
+
return jsonify({"error": str(e)}), 500
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
__all__ = ["register_annotation_routes"]
|
|
@@ -401,6 +401,12 @@ def register_axis_routes(app, editor):
|
|
|
401
401
|
|
|
402
402
|
try:
|
|
403
403
|
ax = axes[ax_index]
|
|
404
|
+
|
|
405
|
+
# CRITICAL: Get current position BEFORE changing it
|
|
406
|
+
# We need this to find the correct ax_record to update
|
|
407
|
+
current_pos = ax.get_position()
|
|
408
|
+
|
|
409
|
+
# Now set the new position
|
|
404
410
|
ax.set_position([left, bottom, width, height])
|
|
405
411
|
|
|
406
412
|
# Store position override in manual_overrides (mm values with upper-left origin)
|
|
@@ -412,13 +418,36 @@ def register_axis_routes(app, editor):
|
|
|
412
418
|
"height_mm": height_mm,
|
|
413
419
|
}
|
|
414
420
|
|
|
415
|
-
# Update record if available
|
|
421
|
+
# Update record if available - find ax_record by matching CURRENT position
|
|
416
422
|
if hasattr(editor.fig, "record"):
|
|
417
|
-
|
|
423
|
+
matched_ax_key = None
|
|
418
424
|
ax_keys = sorted(editor.fig.record.axes.keys())
|
|
419
|
-
|
|
420
|
-
|
|
425
|
+
|
|
426
|
+
# First, try to match by position_override (for previously dragged panels)
|
|
427
|
+
for ax_key in ax_keys:
|
|
421
428
|
ax_record = editor.fig.record.axes[ax_key]
|
|
429
|
+
if (
|
|
430
|
+
hasattr(ax_record, "position_override")
|
|
431
|
+
and ax_record.position_override
|
|
432
|
+
):
|
|
433
|
+
rec_pos = ax_record.position_override
|
|
434
|
+
if len(rec_pos) >= 4:
|
|
435
|
+
if (
|
|
436
|
+
abs(rec_pos[0] - current_pos.x0) < 0.01
|
|
437
|
+
and abs(rec_pos[1] - current_pos.y0) < 0.01
|
|
438
|
+
and abs(rec_pos[2] - current_pos.width) < 0.01
|
|
439
|
+
and abs(rec_pos[3] - current_pos.height) < 0.01
|
|
440
|
+
):
|
|
441
|
+
matched_ax_key = ax_key
|
|
442
|
+
break
|
|
443
|
+
|
|
444
|
+
# If no position_override match, fall back to index-based matching
|
|
445
|
+
if matched_ax_key is None and ax_index < len(ax_keys):
|
|
446
|
+
matched_ax_key = ax_keys[ax_index]
|
|
447
|
+
|
|
448
|
+
# Update the matched ax_record with new position
|
|
449
|
+
if matched_ax_key:
|
|
450
|
+
ax_record = editor.fig.record.axes[matched_ax_key]
|
|
422
451
|
ax_record.position_override = [left, bottom, width, height]
|
|
423
452
|
|
|
424
453
|
# Re-render
|
|
@@ -428,8 +457,8 @@ def register_axis_routes(app, editor):
|
|
|
428
457
|
editor.dark_mode,
|
|
429
458
|
)
|
|
430
459
|
|
|
431
|
-
# Regenerate hitmap
|
|
432
|
-
hitmap_img, color_map = generate_hitmap(
|
|
460
|
+
# Regenerate hitmap - use editor.fig to preserve record access
|
|
461
|
+
hitmap_img, color_map = generate_hitmap(editor.fig, dpi=150)
|
|
433
462
|
editor._color_map = color_map
|
|
434
463
|
editor._hitmap_base64 = hitmap_to_base64(hitmap_img)
|
|
435
464
|
editor._hitmap_generated = True
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Caption-related Flask route handlers for the figure editor.
|
|
5
|
+
Handles scientific figure captions and panel captions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from flask import jsonify, request
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def register_caption_routes(app, editor):
|
|
12
|
+
"""Register caption-related routes with the Flask app."""
|
|
13
|
+
|
|
14
|
+
@app.route("/get_captions")
|
|
15
|
+
def get_captions():
|
|
16
|
+
"""Get current captions (figure and panel)."""
|
|
17
|
+
captions = {
|
|
18
|
+
"figure_number": 1,
|
|
19
|
+
"figure_caption": "",
|
|
20
|
+
"panel_captions": [], # List of panel captions
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
# Try to get caption from RecordingFigure's recorder
|
|
24
|
+
fig = editor.fig
|
|
25
|
+
if hasattr(fig, "caption") and fig.caption:
|
|
26
|
+
captions["figure_caption"] = fig.caption
|
|
27
|
+
|
|
28
|
+
# Get panel captions from axes
|
|
29
|
+
if hasattr(fig, "flat"):
|
|
30
|
+
for ax in fig.flat:
|
|
31
|
+
if hasattr(ax, "caption") and ax.caption:
|
|
32
|
+
captions["panel_captions"].append(ax.caption)
|
|
33
|
+
else:
|
|
34
|
+
captions["panel_captions"].append("")
|
|
35
|
+
|
|
36
|
+
# Check if we have recipe metadata (fallback)
|
|
37
|
+
if not captions["figure_caption"] and hasattr(fig, "_recipe_metadata"):
|
|
38
|
+
metadata = fig._recipe_metadata
|
|
39
|
+
if hasattr(metadata, "caption") and metadata.caption:
|
|
40
|
+
captions["figure_caption"] = metadata.caption
|
|
41
|
+
if hasattr(metadata, "figure_number") and metadata.figure_number:
|
|
42
|
+
captions["figure_number"] = metadata.figure_number
|
|
43
|
+
|
|
44
|
+
# Check editor's manual overrides for captions (highest priority)
|
|
45
|
+
if hasattr(editor, "style_overrides"):
|
|
46
|
+
manual = getattr(editor.style_overrides, "manual_overrides", {})
|
|
47
|
+
if "caption_figure_number" in manual:
|
|
48
|
+
captions["figure_number"] = manual["caption_figure_number"]
|
|
49
|
+
if "caption_figure_text" in manual:
|
|
50
|
+
captions["figure_caption"] = manual["caption_figure_text"]
|
|
51
|
+
# Load individual panel overrides
|
|
52
|
+
for i in range(len(captions["panel_captions"])):
|
|
53
|
+
key = f"caption_panel_{i}_text"
|
|
54
|
+
if key in manual:
|
|
55
|
+
captions["panel_captions"][i] = manual[key]
|
|
56
|
+
|
|
57
|
+
return jsonify(captions)
|
|
58
|
+
|
|
59
|
+
@app.route("/update_caption", methods=["POST"])
|
|
60
|
+
def update_caption():
|
|
61
|
+
"""Update figure or panel caption."""
|
|
62
|
+
data = request.get_json() or {}
|
|
63
|
+
caption_type = data.get("type") # 'figure' or 'panel'
|
|
64
|
+
|
|
65
|
+
if not caption_type:
|
|
66
|
+
return jsonify({"error": "Missing caption type"}), 400
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
if caption_type == "figure":
|
|
70
|
+
figure_number = data.get("figure_number", 1)
|
|
71
|
+
text = data.get("text", "")
|
|
72
|
+
|
|
73
|
+
# Store in manual overrides
|
|
74
|
+
editor.style_overrides.manual_overrides["caption_figure_number"] = (
|
|
75
|
+
figure_number
|
|
76
|
+
)
|
|
77
|
+
editor.style_overrides.manual_overrides["caption_figure_text"] = text
|
|
78
|
+
|
|
79
|
+
# Also store in recipe metadata if available
|
|
80
|
+
if hasattr(editor.fig, "_recipe_metadata"):
|
|
81
|
+
editor.fig._recipe_metadata.caption = text
|
|
82
|
+
editor.fig._recipe_metadata.figure_number = figure_number
|
|
83
|
+
|
|
84
|
+
return jsonify(
|
|
85
|
+
{
|
|
86
|
+
"success": True,
|
|
87
|
+
"caption_type": "figure",
|
|
88
|
+
"figure_number": figure_number,
|
|
89
|
+
"text": text,
|
|
90
|
+
}
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
elif caption_type == "panel":
|
|
94
|
+
panel_index = data.get("panel_index", 0)
|
|
95
|
+
text = data.get("text", "")
|
|
96
|
+
|
|
97
|
+
# Store in manual overrides
|
|
98
|
+
key = f"caption_panel_{panel_index}_text"
|
|
99
|
+
editor.style_overrides.manual_overrides[key] = text
|
|
100
|
+
# Also store general panel caption for current selection
|
|
101
|
+
editor.style_overrides.manual_overrides["caption_panel_text"] = text
|
|
102
|
+
|
|
103
|
+
# Store in axes metadata if available
|
|
104
|
+
if hasattr(editor.fig, "_axes_metadata"):
|
|
105
|
+
axes_meta = editor.fig._axes_metadata
|
|
106
|
+
if panel_index < len(axes_meta):
|
|
107
|
+
axes_meta[panel_index].caption = text
|
|
108
|
+
|
|
109
|
+
return jsonify(
|
|
110
|
+
{
|
|
111
|
+
"success": True,
|
|
112
|
+
"caption_type": "panel",
|
|
113
|
+
"panel_index": panel_index,
|
|
114
|
+
"text": text,
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
else:
|
|
119
|
+
return jsonify({"error": f"Unknown caption type: {caption_type}"}), 400
|
|
120
|
+
|
|
121
|
+
except Exception as e:
|
|
122
|
+
import traceback
|
|
123
|
+
|
|
124
|
+
traceback.print_exc()
|
|
125
|
+
return jsonify({"error": f"Caption update failed: {str(e)}"}), 500
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
__all__ = ["register_caption_routes"]
|
|
129
|
+
|
|
130
|
+
# EOF
|