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
figrecipe/_editor/_flask_app.py
CHANGED
|
@@ -51,11 +51,13 @@ class FigureEditor:
|
|
|
51
51
|
recipe_path: Optional[Path] = None,
|
|
52
52
|
style: Optional[Dict[str, Any]] = None,
|
|
53
53
|
port: int = 5050,
|
|
54
|
+
host: str = "127.0.0.1",
|
|
54
55
|
static_png_path: Optional[Path] = None,
|
|
55
56
|
hitmap_base64: Optional[str] = None,
|
|
56
57
|
color_map: Optional[Dict] = None,
|
|
57
58
|
hot_reload: bool = False,
|
|
58
59
|
working_dir: Optional[Path] = None,
|
|
60
|
+
desktop: bool = False,
|
|
59
61
|
):
|
|
60
62
|
"""
|
|
61
63
|
Initialize figure editor.
|
|
@@ -80,10 +82,14 @@ class FigureEditor:
|
|
|
80
82
|
Enable hot reload - server restarts on source file changes.
|
|
81
83
|
working_dir : Path, optional
|
|
82
84
|
Working directory for file switching (default: current directory).
|
|
85
|
+
desktop : bool, optional
|
|
86
|
+
Launch as native desktop window using pywebview.
|
|
83
87
|
"""
|
|
84
88
|
self.fig = fig
|
|
89
|
+
self.desktop = desktop
|
|
85
90
|
self.recipe_path = Path(recipe_path) if recipe_path else None
|
|
86
91
|
self.port = port
|
|
92
|
+
self.host = host
|
|
87
93
|
self.hot_reload = hot_reload
|
|
88
94
|
self.working_dir = Path(working_dir) if working_dir else Path.cwd()
|
|
89
95
|
|
|
@@ -102,10 +108,7 @@ class FigureEditor:
|
|
|
102
108
|
with open(static_png_path, "rb") as f:
|
|
103
109
|
self._initial_base64 = base64.b64encode(f.read()).decode("utf-8")
|
|
104
110
|
|
|
105
|
-
#
|
|
106
|
-
self._initial_axes_positions = self._capture_axes_positions()
|
|
107
|
-
|
|
108
|
-
# Initialize style overrides system
|
|
111
|
+
# Initialize style overrides system (captures original positions into base_style)
|
|
109
112
|
self._init_style_overrides(style)
|
|
110
113
|
|
|
111
114
|
# Pre-generated hitmap and color_map
|
|
@@ -123,6 +126,8 @@ class FigureEditor:
|
|
|
123
126
|
self.style_overrides = existing
|
|
124
127
|
if programmatic_style:
|
|
125
128
|
self.style_overrides.programmatic_style = programmatic_style
|
|
129
|
+
# Ensure original positions are captured even when loading existing overrides
|
|
130
|
+
self._ensure_original_positions_in_base_style()
|
|
126
131
|
return
|
|
127
132
|
|
|
128
133
|
# Get base style from global preset
|
|
@@ -149,6 +154,16 @@ class FigureEditor:
|
|
|
149
154
|
|
|
150
155
|
self._style_name = style_name
|
|
151
156
|
|
|
157
|
+
# Capture original annotation positions into base_style for restore
|
|
158
|
+
annotation_positions = self._capture_annotation_positions()
|
|
159
|
+
for key, pos_data in annotation_positions.items():
|
|
160
|
+
base_style[f"_original_{key}"] = pos_data
|
|
161
|
+
|
|
162
|
+
# Capture original axes positions into base_style for restore
|
|
163
|
+
axes_positions = self._capture_axes_positions()
|
|
164
|
+
for ax_idx, pos in axes_positions.items():
|
|
165
|
+
base_style[f"_original_axes_position_{ax_idx}"] = pos
|
|
166
|
+
|
|
152
167
|
self.style_overrides = create_overrides_from_style(
|
|
153
168
|
base_style=base_style,
|
|
154
169
|
programmatic_style=programmatic_style or {},
|
|
@@ -173,6 +188,24 @@ class FigureEditor:
|
|
|
173
188
|
"""Get the final merged style."""
|
|
174
189
|
return self.style_overrides.get_effective_style()
|
|
175
190
|
|
|
191
|
+
def _ensure_original_positions_in_base_style(self) -> None:
|
|
192
|
+
"""Ensure original positions are captured in base_style (for existing overrides)."""
|
|
193
|
+
base_style = self.style_overrides.base_style
|
|
194
|
+
|
|
195
|
+
# Add annotation positions if not present
|
|
196
|
+
annotation_positions = self._capture_annotation_positions()
|
|
197
|
+
for key, pos_data in annotation_positions.items():
|
|
198
|
+
base_key = f"_original_{key}"
|
|
199
|
+
if base_key not in base_style:
|
|
200
|
+
base_style[base_key] = pos_data
|
|
201
|
+
|
|
202
|
+
# Add axes positions if not present
|
|
203
|
+
axes_positions = self._capture_axes_positions()
|
|
204
|
+
for ax_idx, pos in axes_positions.items():
|
|
205
|
+
base_key = f"_original_axes_position_{ax_idx}"
|
|
206
|
+
if base_key not in base_style:
|
|
207
|
+
base_style[base_key] = pos
|
|
208
|
+
|
|
176
209
|
def _capture_axes_positions(self) -> Dict[int, list]:
|
|
177
210
|
"""Capture current axes positions (matplotlib coords: [left, bottom, width, height])."""
|
|
178
211
|
mpl_fig = self.fig.fig if hasattr(self.fig, "fig") else self.fig
|
|
@@ -184,16 +217,46 @@ class FigureEditor:
|
|
|
184
217
|
return positions
|
|
185
218
|
|
|
186
219
|
def restore_axes_positions(self) -> None:
|
|
187
|
-
"""Restore axes to their original positions."""
|
|
188
|
-
|
|
189
|
-
return
|
|
220
|
+
"""Restore axes to their original positions from base_style."""
|
|
221
|
+
base_style = self.style_overrides.base_style
|
|
190
222
|
mpl_fig = self.fig.fig if hasattr(self.fig, "fig") else self.fig
|
|
191
223
|
axes = mpl_fig.get_axes()
|
|
192
224
|
for i, ax in enumerate(axes):
|
|
193
|
-
|
|
194
|
-
|
|
225
|
+
key = f"_original_axes_position_{i}"
|
|
226
|
+
if key in base_style:
|
|
227
|
+
pos = base_style[key]
|
|
195
228
|
ax.set_position(pos)
|
|
196
229
|
|
|
230
|
+
def _capture_annotation_positions(self) -> Dict[str, dict]:
|
|
231
|
+
"""Capture current annotation (text) positions for each axis."""
|
|
232
|
+
mpl_fig = self.fig.fig if hasattr(self.fig, "fig") else self.fig
|
|
233
|
+
axes = mpl_fig.get_axes()
|
|
234
|
+
positions = {}
|
|
235
|
+
for ax_idx, ax in enumerate(axes):
|
|
236
|
+
for text_idx, text_obj in enumerate(ax.texts):
|
|
237
|
+
key = f"ax{ax_idx}_text{text_idx}"
|
|
238
|
+
pos = text_obj.get_position()
|
|
239
|
+
positions[key] = {
|
|
240
|
+
"position": [
|
|
241
|
+
float(pos[0]),
|
|
242
|
+
float(pos[1]),
|
|
243
|
+
], # Convert to float for JSON
|
|
244
|
+
"transform_is_axes": bool(text_obj.get_transform() == ax.transAxes),
|
|
245
|
+
}
|
|
246
|
+
return positions
|
|
247
|
+
|
|
248
|
+
def restore_annotation_positions(self) -> None:
|
|
249
|
+
"""Restore annotations to their original positions from base_style."""
|
|
250
|
+
base_style = self.style_overrides.base_style
|
|
251
|
+
mpl_fig = self.fig.fig if hasattr(self.fig, "fig") else self.fig
|
|
252
|
+
axes = mpl_fig.get_axes()
|
|
253
|
+
for ax_idx, ax in enumerate(axes):
|
|
254
|
+
for text_idx, text_obj in enumerate(ax.texts):
|
|
255
|
+
key = f"_original_ax{ax_idx}_text{text_idx}"
|
|
256
|
+
if key in base_style:
|
|
257
|
+
orig = base_style[key]
|
|
258
|
+
text_obj.set_position(tuple(orig["position"]))
|
|
259
|
+
|
|
197
260
|
def run(self, open_browser: bool = True) -> Dict[str, Any]:
|
|
198
261
|
"""
|
|
199
262
|
Run the editor server.
|
|
@@ -210,25 +273,52 @@ class FigureEditor:
|
|
|
210
273
|
"""
|
|
211
274
|
from flask import Flask
|
|
212
275
|
|
|
276
|
+
from ._routes_annotation import register_annotation_routes
|
|
213
277
|
from ._routes_axis import register_axis_routes
|
|
278
|
+
|
|
279
|
+
# DISABLED: Snapshot feature corrupts figure state via visibility changes
|
|
280
|
+
# from ._routes_snapshot import register_snapshot_routes
|
|
281
|
+
from ._routes_captions import register_caption_routes
|
|
214
282
|
from ._routes_core import register_core_routes
|
|
283
|
+
from ._routes_datatable import register_datatable_routes
|
|
215
284
|
from ._routes_element import register_element_routes
|
|
285
|
+
from ._routes_files import register_file_routes
|
|
286
|
+
from ._routes_image import register_image_routes
|
|
216
287
|
from ._routes_style import register_style_routes
|
|
217
288
|
|
|
218
289
|
# Defer hitmap generation until first request (lazy loading)
|
|
219
290
|
self._hitmap_generated = self._hitmap_base64 is not None
|
|
220
291
|
|
|
221
|
-
# Create Flask app
|
|
222
|
-
|
|
292
|
+
# Create Flask app with static folder for assets (click sounds, etc.)
|
|
293
|
+
static_folder = Path(__file__).parent / "static"
|
|
294
|
+
app = Flask(
|
|
295
|
+
__name__, static_folder=str(static_folder), static_url_path="/static"
|
|
296
|
+
)
|
|
223
297
|
|
|
224
298
|
# Register all routes
|
|
225
299
|
register_core_routes(app, self)
|
|
300
|
+
register_file_routes(app, self)
|
|
226
301
|
register_style_routes(app, self)
|
|
227
302
|
register_axis_routes(app, self)
|
|
228
303
|
register_element_routes(app, self)
|
|
304
|
+
register_image_routes(app, self)
|
|
305
|
+
register_datatable_routes(app, self)
|
|
306
|
+
register_annotation_routes(app, self)
|
|
307
|
+
register_caption_routes(app, self)
|
|
308
|
+
# DISABLED: register_snapshot_routes(app, self)
|
|
229
309
|
|
|
230
310
|
# Start server
|
|
231
|
-
url = f"http://
|
|
311
|
+
url = f"http://{self.host}:{self.port}"
|
|
312
|
+
|
|
313
|
+
if self.desktop:
|
|
314
|
+
# Desktop mode using pywebview
|
|
315
|
+
return self._run_desktop(app, url)
|
|
316
|
+
else:
|
|
317
|
+
# Browser mode
|
|
318
|
+
return self._run_browser(app, url, open_browser)
|
|
319
|
+
|
|
320
|
+
def _run_browser(self, app, url: str, open_browser: bool) -> Dict[str, Any]:
|
|
321
|
+
"""Run editor in browser mode."""
|
|
232
322
|
print(f"Figure Editor running at {url}")
|
|
233
323
|
|
|
234
324
|
if self.hot_reload:
|
|
@@ -239,11 +329,8 @@ class FigureEditor:
|
|
|
239
329
|
webbrowser.open(url)
|
|
240
330
|
|
|
241
331
|
try:
|
|
242
|
-
# Use Flask's built-in reloader when hot_reload is enabled
|
|
243
|
-
# Note: debug and use_reloader are always False when working with
|
|
244
|
-
# multiple coding agents to avoid file watching conflicts
|
|
245
332
|
app.run(
|
|
246
|
-
host=
|
|
333
|
+
host=self.host,
|
|
247
334
|
port=self.port,
|
|
248
335
|
debug=False,
|
|
249
336
|
use_reloader=False,
|
|
@@ -254,5 +341,59 @@ class FigureEditor:
|
|
|
254
341
|
|
|
255
342
|
return self.overrides
|
|
256
343
|
|
|
344
|
+
def _run_desktop(self, app, url: str) -> Dict[str, Any]:
|
|
345
|
+
"""Run editor as native desktop window using pywebview."""
|
|
346
|
+
try:
|
|
347
|
+
import webview
|
|
348
|
+
except ImportError:
|
|
349
|
+
raise ImportError(
|
|
350
|
+
"pywebview is required for desktop mode. "
|
|
351
|
+
"Install with: pip install figrecipe[desktop]"
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
import threading
|
|
355
|
+
|
|
356
|
+
print("Figure Editor (Desktop Mode)")
|
|
357
|
+
print("Close the window to stop and return overrides")
|
|
358
|
+
|
|
359
|
+
# Start Flask in a background thread
|
|
360
|
+
def run_flask():
|
|
361
|
+
import logging
|
|
362
|
+
|
|
363
|
+
# Suppress Flask logging in desktop mode
|
|
364
|
+
log = logging.getLogger("werkzeug")
|
|
365
|
+
log.setLevel(logging.ERROR)
|
|
366
|
+
app.run(
|
|
367
|
+
host=self.host,
|
|
368
|
+
port=self.port,
|
|
369
|
+
debug=False,
|
|
370
|
+
use_reloader=False,
|
|
371
|
+
threaded=True,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
flask_thread = threading.Thread(target=run_flask, daemon=True)
|
|
375
|
+
flask_thread.start()
|
|
376
|
+
|
|
377
|
+
# Wait briefly for Flask to start
|
|
378
|
+
import time
|
|
379
|
+
|
|
380
|
+
time.sleep(0.5)
|
|
381
|
+
|
|
382
|
+
# Create native window (variable needed for pywebview lifecycle)
|
|
383
|
+
_window = webview.create_window(
|
|
384
|
+
title="FigRecipe Editor",
|
|
385
|
+
url=url,
|
|
386
|
+
width=1400,
|
|
387
|
+
height=900,
|
|
388
|
+
resizable=True,
|
|
389
|
+
min_size=(800, 600),
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
# Start webview (blocks until window is closed)
|
|
393
|
+
webview.start()
|
|
394
|
+
|
|
395
|
+
print("\nEditor closed")
|
|
396
|
+
return self.overrides
|
|
397
|
+
|
|
257
398
|
|
|
258
399
|
__all__ = ["FigureEditor"]
|
figrecipe/_editor/_helpers.py
CHANGED
|
@@ -68,7 +68,7 @@ def get_form_values_from_style(style: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
68
68
|
if "output" in style:
|
|
69
69
|
values["output_dpi"] = style["output"].get("dpi", 300)
|
|
70
70
|
|
|
71
|
-
# Behavior
|
|
71
|
+
# Behavior - spine visibility for all four sides
|
|
72
72
|
if "behavior" in style:
|
|
73
73
|
values["behavior_hide_top_spine"] = style["behavior"].get(
|
|
74
74
|
"hide_top_spine", True
|
|
@@ -76,6 +76,12 @@ def get_form_values_from_style(style: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
76
76
|
values["behavior_hide_right_spine"] = style["behavior"].get(
|
|
77
77
|
"hide_right_spine", True
|
|
78
78
|
)
|
|
79
|
+
values["behavior_hide_bottom_spine"] = style["behavior"].get(
|
|
80
|
+
"hide_bottom_spine", False
|
|
81
|
+
)
|
|
82
|
+
values["behavior_hide_left_spine"] = style["behavior"].get(
|
|
83
|
+
"hide_left_spine", False
|
|
84
|
+
)
|
|
79
85
|
values["behavior_grid"] = style["behavior"].get("grid", False)
|
|
80
86
|
|
|
81
87
|
# Legend
|
|
@@ -163,26 +169,29 @@ def render_with_overrides(
|
|
|
163
169
|
warnings.filterwarnings("ignore", "constrained_layout not applied")
|
|
164
170
|
warnings.filterwarnings("ignore", category=UserWarning)
|
|
165
171
|
try:
|
|
166
|
-
|
|
172
|
+
# Standard render - layout handled by figure settings
|
|
173
|
+
# Use transparent background if specified in overrides
|
|
174
|
+
transparent = (
|
|
175
|
+
overrides.get("output_transparent", True) if overrides else True
|
|
176
|
+
)
|
|
177
|
+
new_fig.savefig(buf, format="png", dpi=150, transparent=transparent)
|
|
167
178
|
except Exception:
|
|
168
|
-
# Fall back to saving without bbox_inches="tight"
|
|
169
|
-
# Catches matplotlib internal exceptions (e.g., Done from _get_renderer)
|
|
170
179
|
buf = io.BytesIO()
|
|
171
|
-
# Reset canvas and draw method to clean state
|
|
172
180
|
new_fig.set_canvas(FigureCanvasAgg(new_fig))
|
|
173
181
|
if original_draw is not None:
|
|
174
182
|
new_fig.draw = original_draw
|
|
175
183
|
try:
|
|
176
|
-
|
|
184
|
+
transparent = (
|
|
185
|
+
overrides.get("output_transparent", True) if overrides else True
|
|
186
|
+
)
|
|
187
|
+
new_fig.savefig(buf, format="png", dpi=150, transparent=transparent)
|
|
177
188
|
except Exception:
|
|
178
|
-
# Last resort: create empty placeholder
|
|
179
189
|
from PIL import Image as PILImage
|
|
180
190
|
|
|
181
191
|
placeholder = PILImage.new("RGB", (400, 300), color=(240, 240, 240))
|
|
182
192
|
placeholder.save(buf, format="PNG")
|
|
183
193
|
buf.seek(0)
|
|
184
194
|
finally:
|
|
185
|
-
# Always restore original draw method to prevent corruption
|
|
186
195
|
if original_draw is not None and hasattr(new_fig, "draw"):
|
|
187
196
|
try:
|
|
188
197
|
new_fig.draw = original_draw
|
|
@@ -5,18 +5,20 @@
|
|
|
5
5
|
from typing import Any, Dict
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
def detect_plot_types(fig) -> Dict[int, Dict[str, Any]]:
|
|
8
|
+
def detect_plot_types(fig, debug: bool = False) -> Dict[int, Dict[str, Any]]:
|
|
9
9
|
"""Detect plot types from recorded calls in figure.
|
|
10
10
|
|
|
11
11
|
Parameters
|
|
12
12
|
----------
|
|
13
13
|
fig : Figure
|
|
14
14
|
The figure to analyze.
|
|
15
|
+
debug : bool
|
|
16
|
+
If True, print debug information.
|
|
15
17
|
|
|
16
18
|
Returns
|
|
17
19
|
-------
|
|
18
20
|
dict
|
|
19
|
-
Mapping from ax_index to plot type info.
|
|
21
|
+
Mapping from ax_index (matching fig.get_axes() order) to plot type info.
|
|
20
22
|
"""
|
|
21
23
|
# Get figure record if available
|
|
22
24
|
if hasattr(fig, "record"):
|
|
@@ -24,48 +26,103 @@ def detect_plot_types(fig) -> Dict[int, Dict[str, Any]]:
|
|
|
24
26
|
elif hasattr(fig, "fig") and hasattr(fig.fig, "_record"):
|
|
25
27
|
record = fig.fig._record
|
|
26
28
|
else:
|
|
29
|
+
if debug:
|
|
30
|
+
print("[detect_plot_types] No record found")
|
|
27
31
|
return {}
|
|
28
32
|
|
|
33
|
+
# Get the actual matplotlib figure and its axes
|
|
34
|
+
mpl_fig = fig.fig if hasattr(fig, "fig") else fig
|
|
35
|
+
axes_list = mpl_fig.get_axes()
|
|
36
|
+
|
|
29
37
|
result = {}
|
|
30
38
|
|
|
31
39
|
# Process each axes in the record
|
|
32
40
|
if hasattr(record, "axes"):
|
|
33
|
-
#
|
|
34
|
-
|
|
35
|
-
max_row, max_col = 0, 0
|
|
36
|
-
for ax_key in ax_keys:
|
|
37
|
-
try:
|
|
38
|
-
parts = ax_key.split("_")
|
|
39
|
-
row, col = int(parts[1]), int(parts[2])
|
|
40
|
-
max_row = max(max_row, row)
|
|
41
|
-
max_col = max(max_col, col)
|
|
42
|
-
except (ValueError, IndexError):
|
|
43
|
-
pass
|
|
44
|
-
ncols = max_col + 1
|
|
45
|
-
|
|
41
|
+
# Build mapping from ax_key to ax_record's plot info
|
|
42
|
+
ax_key_to_info = {}
|
|
46
43
|
for ax_key, ax_record in record.axes.items():
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
parts = ax_key.split("_")
|
|
50
|
-
row, col = int(parts[1]), int(parts[2])
|
|
51
|
-
ax_idx = row * ncols + col
|
|
52
|
-
except (ValueError, IndexError):
|
|
53
|
-
ax_idx = 0
|
|
54
|
-
|
|
55
|
-
if ax_idx not in result:
|
|
56
|
-
result[ax_idx] = {"types": set(), "call_ids": {}}
|
|
57
|
-
|
|
58
|
-
# Collect all call types and their IDs
|
|
44
|
+
info = {"types": set(), "call_ids": {}}
|
|
45
|
+
|
|
59
46
|
if hasattr(ax_record, "calls"):
|
|
60
47
|
for call in ax_record.calls:
|
|
61
48
|
func_name = call.function
|
|
62
49
|
call_id = call.id
|
|
63
50
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if func_name not in
|
|
67
|
-
|
|
68
|
-
|
|
51
|
+
info["types"].add(func_name)
|
|
52
|
+
|
|
53
|
+
if func_name not in info["call_ids"]:
|
|
54
|
+
info["call_ids"][func_name] = []
|
|
55
|
+
info["call_ids"][func_name].append(call_id)
|
|
56
|
+
|
|
57
|
+
ax_key_to_info[ax_key] = info
|
|
58
|
+
|
|
59
|
+
# Map ax_keys to current axes positions using position matching
|
|
60
|
+
# This handles the case where panels have been dragged to new positions
|
|
61
|
+
ax_keys_sorted = sorted(record.axes.keys())
|
|
62
|
+
|
|
63
|
+
# Debug: Check which ax_keys have position_override
|
|
64
|
+
overrides = {
|
|
65
|
+
k: getattr(record.axes[k], "position_override", None)
|
|
66
|
+
for k in ax_keys_sorted
|
|
67
|
+
if hasattr(record.axes[k], "position_override")
|
|
68
|
+
and record.axes[k].position_override
|
|
69
|
+
}
|
|
70
|
+
if overrides:
|
|
71
|
+
print(f"[detect_plot_types] Position overrides: {overrides}")
|
|
72
|
+
|
|
73
|
+
for ax_idx, ax in enumerate(axes_list):
|
|
74
|
+
# Try to find the matching ax_record by comparing positions
|
|
75
|
+
# or fall back to index-based matching
|
|
76
|
+
matched = False
|
|
77
|
+
ax_pos = ax.get_position()
|
|
78
|
+
|
|
79
|
+
for ax_key in ax_keys_sorted:
|
|
80
|
+
ax_record = record.axes[ax_key]
|
|
81
|
+
# Check if there's a position_override that matches
|
|
82
|
+
# Must check ALL 4 coordinates to avoid false matches
|
|
83
|
+
if (
|
|
84
|
+
hasattr(ax_record, "position_override")
|
|
85
|
+
and ax_record.position_override
|
|
86
|
+
):
|
|
87
|
+
rec_pos = ax_record.position_override
|
|
88
|
+
# Position override is [x0, y0, width, height]
|
|
89
|
+
if len(rec_pos) >= 4:
|
|
90
|
+
if (
|
|
91
|
+
abs(rec_pos[0] - ax_pos.x0) < 0.01
|
|
92
|
+
and abs(rec_pos[1] - ax_pos.y0) < 0.01
|
|
93
|
+
and abs(rec_pos[2] - ax_pos.width) < 0.01
|
|
94
|
+
and abs(rec_pos[3] - ax_pos.height) < 0.01
|
|
95
|
+
):
|
|
96
|
+
print(
|
|
97
|
+
f"[detect_plot_types] ax_idx={ax_idx} matched {ax_key} "
|
|
98
|
+
f"via position_override"
|
|
99
|
+
)
|
|
100
|
+
result[ax_idx] = ax_key_to_info.get(
|
|
101
|
+
ax_key, {"types": set(), "call_ids": {}}
|
|
102
|
+
)
|
|
103
|
+
matched = True
|
|
104
|
+
break
|
|
105
|
+
else:
|
|
106
|
+
# Fallback for old format with only x0, y0
|
|
107
|
+
if (
|
|
108
|
+
abs(rec_pos[0] - ax_pos.x0) < 0.01
|
|
109
|
+
and abs(rec_pos[1] - ax_pos.y0) < 0.01
|
|
110
|
+
):
|
|
111
|
+
result[ax_idx] = ax_key_to_info.get(
|
|
112
|
+
ax_key, {"types": set(), "call_ids": {}}
|
|
113
|
+
)
|
|
114
|
+
matched = True
|
|
115
|
+
break
|
|
116
|
+
|
|
117
|
+
# Fall back to index-based matching if position match failed
|
|
118
|
+
if not matched and ax_idx < len(ax_keys_sorted):
|
|
119
|
+
ax_key = ax_keys_sorted[ax_idx]
|
|
120
|
+
info = ax_key_to_info.get(ax_key, {"types": set(), "call_ids": {}})
|
|
121
|
+
print(
|
|
122
|
+
f"[detect_plot_types] ax_idx={ax_idx} fallback to {ax_key}, "
|
|
123
|
+
f"types={info.get('types', set())}"
|
|
124
|
+
)
|
|
125
|
+
result[ax_idx] = info
|
|
69
126
|
|
|
70
127
|
return result
|
|
71
128
|
|
|
@@ -81,7 +81,7 @@ def generate_hitmap(
|
|
|
81
81
|
element_id = 1
|
|
82
82
|
|
|
83
83
|
# Detect plot types from record
|
|
84
|
-
plot_types = detect_plot_types(fig)
|
|
84
|
+
plot_types = detect_plot_types(fig, debug=False)
|
|
85
85
|
|
|
86
86
|
# Get all axes (handle RecordingFigure wrapper)
|
|
87
87
|
if hasattr(fig, "fig"):
|
|
@@ -135,10 +135,10 @@ def generate_hitmap(
|
|
|
135
135
|
ax.set_facecolor(normalize_color(BACKGROUND_COLOR))
|
|
136
136
|
|
|
137
137
|
# Render to buffer
|
|
138
|
+
# IMPORTANT: Do NOT use bbox_inches="tight" - it causes dimension changes
|
|
139
|
+
# between renders when elements change (e.g., color). Must match main render.
|
|
138
140
|
buf = io.BytesIO()
|
|
139
|
-
fig.savefig(
|
|
140
|
-
buf, format="png", dpi=dpi, facecolor=fig.get_facecolor(), bbox_inches="tight"
|
|
141
|
-
)
|
|
141
|
+
fig.savefig(buf, format="png", dpi=dpi, facecolor=fig.get_facecolor())
|
|
142
142
|
buf.seek(0)
|
|
143
143
|
|
|
144
144
|
# Load as PIL Image
|
figrecipe/_editor/_overrides.py
CHANGED
|
@@ -58,12 +58,15 @@ class StyleOverrides:
|
|
|
58
58
|
Returns
|
|
59
59
|
-------
|
|
60
60
|
dict
|
|
61
|
-
Merged style dictionary.
|
|
61
|
+
Merged style dictionary including call_overrides.
|
|
62
62
|
"""
|
|
63
63
|
result = {}
|
|
64
64
|
result.update(self.base_style)
|
|
65
65
|
result.update(self.programmatic_style)
|
|
66
66
|
result.update(self.manual_overrides)
|
|
67
|
+
# Include call_overrides for apply_overrides to use
|
|
68
|
+
if self.call_overrides:
|
|
69
|
+
result["call_overrides"] = self.call_overrides
|
|
67
70
|
return result
|
|
68
71
|
|
|
69
72
|
def get_original_style(self) -> Dict[str, Any]:
|