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
@@ -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 (YAML: behavior_hide_top_spine, legacy: hide_top_spine)
104
- hide_top = overrides.get("behavior_hide_top_spine", overrides.get("hide_top_spine"))
105
- if hide_top is not None:
106
- ax.spines["top"].set_visible(not hide_top)
107
-
108
- hide_right = overrides.get(
109
- "behavior_hide_right_spine", overrides.get("hide_right_spine")
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:
@@ -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
- # Find the axes record key
423
+ matched_ax_key = None
418
424
  ax_keys = sorted(editor.fig.record.axes.keys())
419
- if ax_index < len(ax_keys):
420
- ax_key = ax_keys[ax_index]
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(mpl_fig, img_size[0], img_size[1])
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