figrecipe 0.7.4__py3-none-any.whl → 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. figrecipe/__init__.py +74 -76
  2. figrecipe/__main__.py +12 -0
  3. figrecipe/_api/_panel.py +67 -0
  4. figrecipe/_api/_save.py +100 -4
  5. figrecipe/_cli/__init__.py +7 -0
  6. figrecipe/_cli/_compose.py +87 -0
  7. figrecipe/_cli/_convert.py +117 -0
  8. figrecipe/_cli/_crop.py +82 -0
  9. figrecipe/_cli/_edit.py +70 -0
  10. figrecipe/_cli/_extract.py +128 -0
  11. figrecipe/_cli/_fonts.py +47 -0
  12. figrecipe/_cli/_info.py +67 -0
  13. figrecipe/_cli/_main.py +58 -0
  14. figrecipe/_cli/_reproduce.py +79 -0
  15. figrecipe/_cli/_style.py +77 -0
  16. figrecipe/_cli/_validate.py +66 -0
  17. figrecipe/_cli/_version.py +50 -0
  18. figrecipe/_composition/__init__.py +32 -0
  19. figrecipe/_composition/_alignment.py +452 -0
  20. figrecipe/_composition/_compose.py +179 -0
  21. figrecipe/_composition/_import_axes.py +127 -0
  22. figrecipe/_composition/_visibility.py +125 -0
  23. figrecipe/_dev/__init__.py +2 -0
  24. figrecipe/_dev/browser/__init__.py +69 -0
  25. figrecipe/_dev/browser/_audio.py +240 -0
  26. figrecipe/_dev/browser/_caption.py +356 -0
  27. figrecipe/_dev/browser/_click_effect.py +146 -0
  28. figrecipe/_dev/browser/_cursor.py +196 -0
  29. figrecipe/_dev/browser/_highlight.py +105 -0
  30. figrecipe/_dev/browser/_narration.py +237 -0
  31. figrecipe/_dev/browser/_recorder.py +446 -0
  32. figrecipe/_dev/browser/_utils.py +178 -0
  33. figrecipe/_dev/browser/_video_trim/__init__.py +152 -0
  34. figrecipe/_dev/browser/_video_trim/_detection.py +223 -0
  35. figrecipe/_dev/browser/_video_trim/_markers.py +140 -0
  36. figrecipe/_editor/__init__.py +36 -36
  37. figrecipe/_editor/_bbox/_extract.py +155 -9
  38. figrecipe/_editor/_bbox/_extract_text.py +124 -0
  39. figrecipe/_editor/_call_overrides.py +183 -0
  40. figrecipe/_editor/_datatable_plot_handlers.py +249 -0
  41. figrecipe/_editor/_figure_layout.py +211 -0
  42. figrecipe/_editor/_flask_app.py +157 -16
  43. figrecipe/_editor/_helpers.py +17 -8
  44. figrecipe/_editor/_hitmap/_detect.py +89 -32
  45. figrecipe/_editor/_hitmap_main.py +4 -4
  46. figrecipe/_editor/_overrides.py +4 -1
  47. figrecipe/_editor/_plot_types_registry.py +190 -0
  48. figrecipe/_editor/_render_overrides.py +38 -11
  49. figrecipe/_editor/_renderer.py +46 -1
  50. figrecipe/_editor/_routes_annotation.py +114 -0
  51. figrecipe/_editor/_routes_axis.py +35 -6
  52. figrecipe/_editor/_routes_captions.py +130 -0
  53. figrecipe/_editor/_routes_composition.py +270 -0
  54. figrecipe/_editor/_routes_core.py +15 -173
  55. figrecipe/_editor/_routes_datatable.py +364 -0
  56. figrecipe/_editor/_routes_element.py +37 -19
  57. figrecipe/_editor/_routes_files.py +443 -0
  58. figrecipe/_editor/_routes_image.py +200 -0
  59. figrecipe/_editor/_routes_snapshot.py +94 -0
  60. figrecipe/_editor/_routes_style.py +28 -8
  61. figrecipe/_editor/_templates/__init__.py +40 -2
  62. figrecipe/_editor/_templates/_html.py +97 -103
  63. figrecipe/_editor/_templates/_html_components/__init__.py +13 -0
  64. figrecipe/_editor/_templates/_html_components/_composition_toolbar.py +79 -0
  65. figrecipe/_editor/_templates/_html_components/_file_browser.py +41 -0
  66. figrecipe/_editor/_templates/_html_datatable.py +92 -0
  67. figrecipe/_editor/_templates/_scripts/__init__.py +58 -0
  68. figrecipe/_editor/_templates/_scripts/_accordion.py +328 -0
  69. figrecipe/_editor/_templates/_scripts/_annotation_drag.py +504 -0
  70. figrecipe/_editor/_templates/_scripts/_api.py +1 -1
  71. figrecipe/_editor/_templates/_scripts/_canvas_context_menu.py +182 -0
  72. figrecipe/_editor/_templates/_scripts/_captions.py +231 -0
  73. figrecipe/_editor/_templates/_scripts/_composition.py +283 -0
  74. figrecipe/_editor/_templates/_scripts/_core.py +94 -37
  75. figrecipe/_editor/_templates/_scripts/_datatable/__init__.py +59 -0
  76. figrecipe/_editor/_templates/_scripts/_datatable/_cell_edit.py +97 -0
  77. figrecipe/_editor/_templates/_scripts/_datatable/_clipboard.py +164 -0
  78. figrecipe/_editor/_templates/_scripts/_datatable/_context_menu.py +221 -0
  79. figrecipe/_editor/_templates/_scripts/_datatable/_core.py +150 -0
  80. figrecipe/_editor/_templates/_scripts/_datatable/_editable.py +511 -0
  81. figrecipe/_editor/_templates/_scripts/_datatable/_import.py +161 -0
  82. figrecipe/_editor/_templates/_scripts/_datatable/_plot.py +261 -0
  83. figrecipe/_editor/_templates/_scripts/_datatable/_selection.py +438 -0
  84. figrecipe/_editor/_templates/_scripts/_datatable/_table.py +256 -0
  85. figrecipe/_editor/_templates/_scripts/_datatable/_tabs.py +354 -0
  86. figrecipe/_editor/_templates/_scripts/_element_editor.py +17 -2
  87. figrecipe/_editor/_templates/_scripts/_files.py +274 -40
  88. figrecipe/_editor/_templates/_scripts/_files_context_menu.py +240 -0
  89. figrecipe/_editor/_templates/_scripts/_hitmap.py +87 -84
  90. figrecipe/_editor/_templates/_scripts/_image_drop.py +428 -0
  91. figrecipe/_editor/_templates/_scripts/_legend_drag.py +5 -0
  92. figrecipe/_editor/_templates/_scripts/_multi_select.py +198 -0
  93. figrecipe/_editor/_templates/_scripts/_panel_drag.py +219 -48
  94. figrecipe/_editor/_templates/_scripts/_panel_drag_snapshot.py +33 -0
  95. figrecipe/_editor/_templates/_scripts/_panel_position.py +238 -54
  96. figrecipe/_editor/_templates/_scripts/_panel_resize.py +230 -0
  97. figrecipe/_editor/_templates/_scripts/_panel_snap.py +307 -0
  98. figrecipe/_editor/_templates/_scripts/_region_select.py +255 -0
  99. figrecipe/_editor/_templates/_scripts/_selection.py +8 -1
  100. figrecipe/_editor/_templates/_scripts/_sync.py +242 -0
  101. figrecipe/_editor/_templates/_scripts/_undo_redo.py +348 -0
  102. figrecipe/_editor/_templates/_scripts/_zoom.py +52 -19
  103. figrecipe/_editor/_templates/_styles/__init__.py +9 -0
  104. figrecipe/_editor/_templates/_styles/_base.py +47 -0
  105. figrecipe/_editor/_templates/_styles/_buttons.py +127 -6
  106. figrecipe/_editor/_templates/_styles/_composition.py +87 -0
  107. figrecipe/_editor/_templates/_styles/_controls.py +168 -3
  108. figrecipe/_editor/_templates/_styles/_datatable/__init__.py +40 -0
  109. figrecipe/_editor/_templates/_styles/_datatable/_editable.py +203 -0
  110. figrecipe/_editor/_templates/_styles/_datatable/_panel.py +268 -0
  111. figrecipe/_editor/_templates/_styles/_datatable/_table.py +479 -0
  112. figrecipe/_editor/_templates/_styles/_datatable/_toolbar.py +384 -0
  113. figrecipe/_editor/_templates/_styles/_datatable/_vars.py +123 -0
  114. figrecipe/_editor/_templates/_styles/_dynamic_props.py +5 -5
  115. figrecipe/_editor/_templates/_styles/_file_browser.py +466 -0
  116. figrecipe/_editor/_templates/_styles/_forms.py +98 -0
  117. figrecipe/_editor/_templates/_styles/_hitmap.py +7 -0
  118. figrecipe/_editor/_templates/_styles/_modals.py +29 -0
  119. figrecipe/_editor/_templates/_styles/_overlays.py +5 -5
  120. figrecipe/_editor/_templates/_styles/_preview.py +213 -8
  121. figrecipe/_editor/_templates/_styles/_spinner.py +117 -0
  122. figrecipe/_editor/static/audio/click.mp3 +0 -0
  123. figrecipe/_editor/static/click.mp3 +0 -0
  124. figrecipe/_editor/static/icons/favicon.ico +0 -0
  125. figrecipe/_integrations/__init__.py +17 -0
  126. figrecipe/_integrations/_scitex_stats.py +298 -0
  127. figrecipe/_params/_DECORATION_METHODS.py +2 -0
  128. figrecipe/_recorder.py +28 -3
  129. figrecipe/_reproducer/_core.py +60 -49
  130. figrecipe/_utils/__init__.py +3 -0
  131. figrecipe/_utils/_bundle.py +205 -0
  132. figrecipe/_wrappers/_axes.py +150 -2
  133. figrecipe/_wrappers/_caption_generator.py +218 -0
  134. figrecipe/_wrappers/_figure.py +26 -1
  135. figrecipe/_wrappers/_stat_annotation.py +274 -0
  136. figrecipe/styles/_style_applier.py +10 -2
  137. figrecipe/styles/presets/SCITEX.yaml +11 -4
  138. {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/METADATA +144 -146
  139. figrecipe-0.9.0.dist-info/RECORD +277 -0
  140. figrecipe-0.9.0.dist-info/entry_points.txt +2 -0
  141. figrecipe-0.7.4.dist-info/RECORD +0 -188
  142. {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/WHEEL +0 -0
  143. {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/licenses/LICENSE +0 -0
@@ -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
- # Store original axes positions for restore functionality
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
- if not self._initial_axes_positions:
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
- if i in self._initial_axes_positions:
194
- pos = self._initial_axes_positions[i]
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
- app = Flask(__name__)
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://127.0.0.1:{self.port}"
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="127.0.0.1",
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"]
@@ -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
- new_fig.savefig(buf, format="png", dpi=150, bbox_inches="tight")
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
- new_fig.savefig(buf, format="png", dpi=150)
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
- # First pass: collect all ax_keys to determine grid dimensions
34
- ax_keys = list(record.axes.keys())
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
- # Extract ax_index from key (e.g., "ax_0_0" -> 0, "ax_2_2" -> 8 for 3x3)
48
- try:
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
- result[ax_idx]["types"].add(func_name)
65
-
66
- if func_name not in result[ax_idx]["call_ids"]:
67
- result[ax_idx]["call_ids"][func_name] = []
68
- result[ax_idx]["call_ids"][func_name].append(call_id)
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
@@ -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]: