scitex 2.4.2__py3-none-any.whl → 2.5.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 (64) hide show
  1. scitex/__version__.py +1 -1
  2. scitex/browser/__init__.py +53 -0
  3. scitex/browser/debugging/__init__.py +56 -0
  4. scitex/browser/debugging/_failure_capture.py +372 -0
  5. scitex/browser/debugging/_sync_session.py +259 -0
  6. scitex/browser/debugging/_test_monitor.py +284 -0
  7. scitex/browser/debugging/_visual_cursor.py +432 -0
  8. scitex/io/_load.py +5 -0
  9. scitex/io/_load_modules/_canvas.py +171 -0
  10. scitex/io/_save.py +8 -0
  11. scitex/io/_save_modules/_canvas.py +356 -0
  12. scitex/plt/_subplots/_export_as_csv_formatters/_format_plot.py +77 -22
  13. scitex/plt/docs/FIGURE_ARCHITECTURE.md +257 -0
  14. scitex/plt/utils/__init__.py +10 -0
  15. scitex/plt/utils/_collect_figure_metadata.py +14 -12
  16. scitex/plt/utils/_csv_column_naming.py +237 -0
  17. scitex/scholar/citation_graph/database.py +9 -2
  18. scitex/scholar/config/ScholarConfig.py +23 -3
  19. scitex/scholar/config/default.yaml +55 -0
  20. scitex/scholar/core/Paper.py +102 -0
  21. scitex/scholar/core/__init__.py +44 -0
  22. scitex/scholar/core/journal_normalizer.py +524 -0
  23. scitex/scholar/core/oa_cache.py +285 -0
  24. scitex/scholar/core/open_access.py +457 -0
  25. scitex/scholar/pdf_download/ScholarPDFDownloader.py +137 -0
  26. scitex/scholar/pdf_download/strategies/__init__.py +6 -0
  27. scitex/scholar/pdf_download/strategies/open_access_download.py +186 -0
  28. scitex/scholar/pipelines/ScholarPipelineSearchParallel.py +18 -3
  29. scitex/scholar/pipelines/ScholarPipelineSearchSingle.py +15 -2
  30. scitex/session/_decorator.py +13 -1
  31. scitex/vis/README.md +246 -615
  32. scitex/vis/__init__.py +138 -78
  33. scitex/vis/canvas.py +423 -0
  34. scitex/vis/docs/CANVAS_ARCHITECTURE.md +307 -0
  35. scitex/vis/editor/__init__.py +1 -1
  36. scitex/vis/editor/_dearpygui_editor.py +1830 -0
  37. scitex/vis/editor/_defaults.py +40 -1
  38. scitex/vis/editor/_edit.py +54 -18
  39. scitex/vis/editor/_flask_editor.py +37 -0
  40. scitex/vis/editor/_qt_editor.py +865 -0
  41. scitex/vis/editor/flask_editor/__init__.py +21 -0
  42. scitex/vis/editor/flask_editor/bbox.py +216 -0
  43. scitex/vis/editor/flask_editor/core.py +152 -0
  44. scitex/vis/editor/flask_editor/plotter.py +130 -0
  45. scitex/vis/editor/flask_editor/renderer.py +184 -0
  46. scitex/vis/editor/flask_editor/templates/__init__.py +33 -0
  47. scitex/vis/editor/flask_editor/templates/html.py +295 -0
  48. scitex/vis/editor/flask_editor/templates/scripts.py +614 -0
  49. scitex/vis/editor/flask_editor/templates/styles.py +549 -0
  50. scitex/vis/editor/flask_editor/utils.py +81 -0
  51. scitex/vis/io/__init__.py +84 -21
  52. scitex/vis/io/canvas.py +226 -0
  53. scitex/vis/io/data.py +204 -0
  54. scitex/vis/io/directory.py +202 -0
  55. scitex/vis/io/export.py +460 -0
  56. scitex/vis/io/panel.py +424 -0
  57. {scitex-2.4.2.dist-info → scitex-2.5.0.dist-info}/METADATA +9 -2
  58. {scitex-2.4.2.dist-info → scitex-2.5.0.dist-info}/RECORD +61 -32
  59. scitex/vis/DJANGO_INTEGRATION.md +0 -677
  60. scitex/vis/editor/_web_editor.py +0 -1440
  61. scitex/vis/tmp.txt +0 -239
  62. {scitex-2.4.2.dist-info → scitex-2.5.0.dist-info}/WHEEL +0 -0
  63. {scitex-2.4.2.dist-info → scitex-2.5.0.dist-info}/entry_points.txt +0 -0
  64. {scitex-2.4.2.dist-info → scitex-2.5.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1830 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # Timestamp: 2025-12-08
4
+ # File: ./src/scitex/vis/editor/_dearpygui_editor.py
5
+ """DearPyGui-based figure editor with GPU-accelerated rendering."""
6
+
7
+ from pathlib import Path
8
+ from typing import Dict, Any, Optional
9
+ import copy
10
+ import io
11
+ import base64
12
+
13
+
14
+ def _create_checkerboard(width: int, height: int, square_size: int = 10) -> "Image":
15
+ """Create a checkerboard pattern image for transparency preview.
16
+
17
+ Parameters
18
+ ----------
19
+ width : int
20
+ Image width in pixels
21
+ height : int
22
+ Image height in pixels
23
+ square_size : int
24
+ Size of each checkerboard square (default: 10)
25
+
26
+ Returns
27
+ -------
28
+ PIL.Image
29
+ RGBA image with checkerboard pattern (light/dark gray)
30
+ """
31
+ from PIL import Image
32
+ import numpy as np
33
+
34
+ # Create checkerboard pattern
35
+ light_gray = (220, 220, 220, 255)
36
+ dark_gray = (180, 180, 180, 255)
37
+
38
+ # Create array
39
+ img_array = np.zeros((height, width, 4), dtype=np.uint8)
40
+
41
+ for y in range(height):
42
+ for x in range(width):
43
+ # Determine which square we're in
44
+ square_x = x // square_size
45
+ square_y = y // square_size
46
+ if (square_x + square_y) % 2 == 0:
47
+ img_array[y, x] = light_gray
48
+ else:
49
+ img_array[y, x] = dark_gray
50
+
51
+ return Image.fromarray(img_array, 'RGBA')
52
+
53
+
54
+ class DearPyGuiEditor:
55
+ """
56
+ GPU-accelerated figure editor using DearPyGui.
57
+
58
+ Features:
59
+ - Modern immediate-mode GUI with GPU acceleration
60
+ - Real-time figure preview
61
+ - Property editors with sliders, color pickers, and input fields
62
+ - Click-to-select traces on preview
63
+ - Save to .manual.json
64
+ - SciTeX style defaults pre-filled
65
+ - Dark/light theme support
66
+ """
67
+
68
+ def __init__(
69
+ self,
70
+ json_path: Path,
71
+ metadata: Dict[str, Any],
72
+ csv_data: Optional[Any] = None,
73
+ png_path: Optional[Path] = None,
74
+ manual_overrides: Optional[Dict[str, Any]] = None,
75
+ ):
76
+ self.json_path = Path(json_path)
77
+ self.metadata = metadata
78
+ self.csv_data = csv_data
79
+ self.png_path = Path(png_path) if png_path else None
80
+ self.manual_overrides = manual_overrides or {}
81
+
82
+ # Get SciTeX defaults and merge with metadata
83
+ from ._defaults import get_scitex_defaults, extract_defaults_from_metadata
84
+ self.scitex_defaults = get_scitex_defaults()
85
+ self.metadata_defaults = extract_defaults_from_metadata(metadata)
86
+
87
+ # Start with defaults, then overlay manual overrides
88
+ self.current_overrides = copy.deepcopy(self.scitex_defaults)
89
+ self.current_overrides.update(self.metadata_defaults)
90
+ self.current_overrides.update(self.manual_overrides)
91
+
92
+ # Track modifications
93
+ self._initial_overrides = copy.deepcopy(self.current_overrides)
94
+ self._user_modified = False
95
+ self._texture_id = None
96
+
97
+ # Click-to-select state
98
+ self._selected_element = None # {'type': 'trace'|'title'|'xlabel'|'ylabel'|'legend'|'xaxis'|'yaxis', 'index': int|None}
99
+ self._selected_trace_index = None # Legacy compat
100
+ self._preview_bounds = None # (x_offset, y_offset, width, height) of figure in preview
101
+ self._axes_transform = None # Transform info for data coordinates
102
+ self._element_bboxes = {} # Store bboxes for all selectable elements
103
+
104
+ # Hover state
105
+ self._hovered_element = None # Element currently being hovered
106
+ self._last_hover_check = 0 # For throttling hover updates
107
+ self._backend_name = "dearpygui" # Backend name for title
108
+
109
+ # Cached rendering for fast hover response
110
+ self._cached_base_image = None # PIL Image of base figure (no highlights)
111
+ self._cached_base_data = None # Flattened RGBA data for DearPyGui
112
+ self._cache_dirty = True # Flag to indicate cache needs rebuild
113
+
114
+ def run(self):
115
+ """Launch the DearPyGui editor."""
116
+ try:
117
+ import dearpygui.dearpygui as dpg
118
+ except ImportError:
119
+ raise ImportError(
120
+ "DearPyGui is required for this editor. "
121
+ "Install with: pip install dearpygui"
122
+ )
123
+
124
+ dpg.create_context()
125
+
126
+ # Configure viewport
127
+ dpg.create_viewport(
128
+ title=f"SciTeX Editor ({self._backend_name}) - {self.json_path.name}",
129
+ width=1400,
130
+ height=900,
131
+ )
132
+
133
+ # Create texture registry for image preview
134
+ with dpg.texture_registry(show=False):
135
+ # Create initial texture with placeholder
136
+ width, height = 800, 600
137
+ texture_data = [0.2, 0.2, 0.2, 1.0] * (width * height)
138
+ self._texture_id = dpg.add_dynamic_texture(
139
+ width=width,
140
+ height=height,
141
+ default_value=texture_data,
142
+ tag="preview_texture"
143
+ )
144
+
145
+ # Create main window
146
+ with dpg.window(label="SciTeX Figure Editor", tag="main_window"):
147
+ with dpg.group(horizontal=True):
148
+ # Left panel: Preview
149
+ self._create_preview_panel(dpg)
150
+
151
+ # Right panel: Controls
152
+ self._create_control_panel(dpg)
153
+
154
+ # Set main window as primary
155
+ dpg.set_primary_window("main_window", True)
156
+
157
+ # Initial render
158
+ self._update_preview(dpg)
159
+
160
+ # Setup and show
161
+ dpg.setup_dearpygui()
162
+ dpg.show_viewport()
163
+ dpg.start_dearpygui()
164
+ dpg.destroy_context()
165
+
166
+ def _create_preview_panel(self, dpg):
167
+ """Create the preview panel with figure image, click handler, and hover detection."""
168
+ with dpg.child_window(width=900, height=-1, tag="preview_panel"):
169
+ dpg.add_text("Figure Preview (click to select, hover to highlight)", color=(100, 200, 100))
170
+ dpg.add_separator()
171
+
172
+ # Image display with click and move handlers
173
+ with dpg.handler_registry(tag="preview_handler"):
174
+ dpg.add_mouse_click_handler(callback=self._on_preview_click)
175
+ dpg.add_mouse_move_handler(callback=self._on_preview_hover)
176
+
177
+ dpg.add_image("preview_texture", tag="preview_image")
178
+
179
+ dpg.add_separator()
180
+ dpg.add_text("", tag="hover_text", color=(150, 200, 150))
181
+ dpg.add_text("", tag="status_text", color=(150, 150, 150))
182
+ dpg.add_text("", tag="selection_text", color=(200, 200, 100))
183
+
184
+ def _create_control_panel(self, dpg):
185
+ """Create the control panel with all editing options."""
186
+ with dpg.child_window(width=-1, height=-1, tag="control_panel"):
187
+ dpg.add_text("Properties", color=(100, 200, 100))
188
+ dpg.add_separator()
189
+
190
+ # Labels Section
191
+ with dpg.collapsing_header(label="Labels", default_open=True):
192
+ dpg.add_input_text(
193
+ label="Title",
194
+ default_value=self.current_overrides.get('title', ''),
195
+ tag="title_input",
196
+ callback=self._on_value_change,
197
+ on_enter=True,
198
+ width=250,
199
+ )
200
+ dpg.add_input_text(
201
+ label="X Label",
202
+ default_value=self.current_overrides.get('xlabel', ''),
203
+ tag="xlabel_input",
204
+ callback=self._on_value_change,
205
+ on_enter=True,
206
+ width=250,
207
+ )
208
+ dpg.add_input_text(
209
+ label="Y Label",
210
+ default_value=self.current_overrides.get('ylabel', ''),
211
+ tag="ylabel_input",
212
+ callback=self._on_value_change,
213
+ on_enter=True,
214
+ width=250,
215
+ )
216
+
217
+ # Axis Limits Section
218
+ with dpg.collapsing_header(label="Axis Limits", default_open=False):
219
+ with dpg.group(horizontal=True):
220
+ xlim = self.current_overrides.get('xlim', [0, 1])
221
+ dpg.add_input_float(
222
+ label="X Min",
223
+ default_value=xlim[0] if xlim else 0,
224
+ tag="xmin_input",
225
+ width=100,
226
+ )
227
+ dpg.add_input_float(
228
+ label="X Max",
229
+ default_value=xlim[1] if xlim else 1,
230
+ tag="xmax_input",
231
+ width=100,
232
+ )
233
+ with dpg.group(horizontal=True):
234
+ ylim = self.current_overrides.get('ylim', [0, 1])
235
+ dpg.add_input_float(
236
+ label="Y Min",
237
+ default_value=ylim[0] if ylim else 0,
238
+ tag="ymin_input",
239
+ width=100,
240
+ )
241
+ dpg.add_input_float(
242
+ label="Y Max",
243
+ default_value=ylim[1] if ylim else 1,
244
+ tag="ymax_input",
245
+ width=100,
246
+ )
247
+ dpg.add_button(
248
+ label="Apply Limits",
249
+ callback=self._apply_limits,
250
+ width=150,
251
+ )
252
+
253
+ # Line Style Section
254
+ with dpg.collapsing_header(label="Line Style", default_open=True):
255
+ dpg.add_slider_float(
256
+ label="Line Width (pt)",
257
+ default_value=self.current_overrides.get('linewidth', 1.0),
258
+ min_value=0.1,
259
+ max_value=5.0,
260
+ tag="linewidth_slider",
261
+ callback=self._on_value_change,
262
+ width=200,
263
+ )
264
+
265
+ # Font Settings Section
266
+ with dpg.collapsing_header(label="Font Settings", default_open=False):
267
+ dpg.add_slider_int(
268
+ label="Title Font Size",
269
+ default_value=self.current_overrides.get('title_fontsize', 8),
270
+ min_value=6,
271
+ max_value=20,
272
+ tag="title_fontsize_slider",
273
+ callback=self._on_value_change,
274
+ width=200,
275
+ )
276
+ dpg.add_slider_int(
277
+ label="Axis Font Size",
278
+ default_value=self.current_overrides.get('axis_fontsize', 7),
279
+ min_value=6,
280
+ max_value=16,
281
+ tag="axis_fontsize_slider",
282
+ callback=self._on_value_change,
283
+ width=200,
284
+ )
285
+ dpg.add_slider_int(
286
+ label="Tick Font Size",
287
+ default_value=self.current_overrides.get('tick_fontsize', 7),
288
+ min_value=6,
289
+ max_value=16,
290
+ tag="tick_fontsize_slider",
291
+ callback=self._on_value_change,
292
+ width=200,
293
+ )
294
+ dpg.add_slider_int(
295
+ label="Legend Font Size",
296
+ default_value=self.current_overrides.get('legend_fontsize', 6),
297
+ min_value=4,
298
+ max_value=14,
299
+ tag="legend_fontsize_slider",
300
+ callback=self._on_value_change,
301
+ width=200,
302
+ )
303
+
304
+ # Tick Settings Section
305
+ with dpg.collapsing_header(label="Tick Settings", default_open=False):
306
+ dpg.add_slider_int(
307
+ label="N Ticks",
308
+ default_value=self.current_overrides.get('n_ticks', 4),
309
+ min_value=2,
310
+ max_value=10,
311
+ tag="n_ticks_slider",
312
+ callback=self._on_value_change,
313
+ width=200,
314
+ )
315
+ dpg.add_slider_float(
316
+ label="Tick Length (mm)",
317
+ default_value=self.current_overrides.get('tick_length', 0.8),
318
+ min_value=0.2,
319
+ max_value=3.0,
320
+ tag="tick_length_slider",
321
+ callback=self._on_value_change,
322
+ width=200,
323
+ )
324
+ dpg.add_slider_float(
325
+ label="Tick Width (mm)",
326
+ default_value=self.current_overrides.get('tick_width', 0.2),
327
+ min_value=0.05,
328
+ max_value=1.0,
329
+ tag="tick_width_slider",
330
+ callback=self._on_value_change,
331
+ width=200,
332
+ )
333
+ dpg.add_combo(
334
+ label="Tick Direction",
335
+ items=["out", "in", "inout"],
336
+ default_value=self.current_overrides.get('tick_direction', 'out'),
337
+ tag="tick_direction_combo",
338
+ callback=self._on_value_change,
339
+ width=150,
340
+ )
341
+
342
+ # Style Section
343
+ with dpg.collapsing_header(label="Style", default_open=True):
344
+ dpg.add_checkbox(
345
+ label="Show Grid",
346
+ default_value=self.current_overrides.get('grid', False),
347
+ tag="grid_checkbox",
348
+ callback=self._on_value_change,
349
+ )
350
+ dpg.add_checkbox(
351
+ label="Hide Top Spine",
352
+ default_value=self.current_overrides.get('hide_top_spine', True),
353
+ tag="hide_top_spine_checkbox",
354
+ callback=self._on_value_change,
355
+ )
356
+ dpg.add_checkbox(
357
+ label="Hide Right Spine",
358
+ default_value=self.current_overrides.get('hide_right_spine', True),
359
+ tag="hide_right_spine_checkbox",
360
+ callback=self._on_value_change,
361
+ )
362
+ dpg.add_checkbox(
363
+ label="Transparent Background",
364
+ default_value=self.current_overrides.get('transparent', True),
365
+ tag="transparent_checkbox",
366
+ callback=self._on_value_change,
367
+ )
368
+ dpg.add_slider_float(
369
+ label="Axis Width (mm)",
370
+ default_value=self.current_overrides.get('axis_width', 0.2),
371
+ min_value=0.05,
372
+ max_value=1.0,
373
+ tag="axis_width_slider",
374
+ callback=self._on_value_change,
375
+ width=200,
376
+ )
377
+
378
+ # Legend Section
379
+ with dpg.collapsing_header(label="Legend", default_open=False):
380
+ dpg.add_checkbox(
381
+ label="Show Legend",
382
+ default_value=self.current_overrides.get('legend_visible', True),
383
+ tag="legend_visible_checkbox",
384
+ callback=self._on_value_change,
385
+ )
386
+ dpg.add_checkbox(
387
+ label="Show Frame",
388
+ default_value=self.current_overrides.get('legend_frameon', False),
389
+ tag="legend_frameon_checkbox",
390
+ callback=self._on_value_change,
391
+ )
392
+ dpg.add_combo(
393
+ label="Position",
394
+ items=["best", "upper right", "upper left", "lower right",
395
+ "lower left", "center right", "center left",
396
+ "upper center", "lower center", "center"],
397
+ default_value=self.current_overrides.get('legend_loc', 'best'),
398
+ tag="legend_loc_combo",
399
+ callback=self._on_value_change,
400
+ width=150,
401
+ )
402
+
403
+ # Selected Element Section (click on preview to select)
404
+ with dpg.collapsing_header(label="Selected Element", default_open=True, tag="selected_element_header"):
405
+ dpg.add_text("Click on preview to select elements", tag="element_hint_text", color=(150, 150, 150))
406
+ dpg.add_combo(
407
+ label="Element",
408
+ items=self._get_all_element_labels(),
409
+ tag="element_selector_combo",
410
+ callback=self._on_element_selected,
411
+ width=200,
412
+ )
413
+ dpg.add_separator()
414
+
415
+ # Trace-specific controls (shown when trace selected)
416
+ with dpg.group(tag="trace_controls_group", show=False):
417
+ dpg.add_input_text(
418
+ label="Label",
419
+ tag="trace_label_input",
420
+ callback=self._on_trace_property_change,
421
+ on_enter=True,
422
+ width=200,
423
+ )
424
+ dpg.add_color_edit(
425
+ label="Color",
426
+ tag="trace_color_picker",
427
+ callback=self._on_trace_property_change,
428
+ no_alpha=True,
429
+ width=200,
430
+ )
431
+ dpg.add_slider_float(
432
+ label="Line Width",
433
+ tag="trace_linewidth_slider",
434
+ default_value=1.0,
435
+ min_value=0.1,
436
+ max_value=5.0,
437
+ callback=self._on_trace_property_change,
438
+ width=200,
439
+ )
440
+ dpg.add_combo(
441
+ label="Line Style",
442
+ items=["-", "--", "-.", ":", ""],
443
+ default_value="-",
444
+ tag="trace_linestyle_combo",
445
+ callback=self._on_trace_property_change,
446
+ width=100,
447
+ )
448
+ dpg.add_combo(
449
+ label="Marker",
450
+ items=["", "o", "s", "^", "v", "D", "x", "+", "*"],
451
+ default_value="",
452
+ tag="trace_marker_combo",
453
+ callback=self._on_trace_property_change,
454
+ width=100,
455
+ )
456
+ dpg.add_slider_float(
457
+ label="Marker Size",
458
+ tag="trace_markersize_slider",
459
+ default_value=6.0,
460
+ min_value=1.0,
461
+ max_value=20.0,
462
+ callback=self._on_trace_property_change,
463
+ width=200,
464
+ )
465
+
466
+ # Text element controls (title, xlabel, ylabel)
467
+ with dpg.group(tag="text_controls_group", show=False):
468
+ dpg.add_input_text(
469
+ label="Text",
470
+ tag="element_text_input",
471
+ callback=self._on_text_element_change,
472
+ on_enter=True,
473
+ width=200,
474
+ )
475
+ dpg.add_slider_int(
476
+ label="Font Size",
477
+ tag="element_fontsize_slider",
478
+ default_value=8,
479
+ min_value=4,
480
+ max_value=24,
481
+ callback=self._on_text_element_change,
482
+ width=200,
483
+ )
484
+ dpg.add_color_edit(
485
+ label="Color",
486
+ tag="element_text_color",
487
+ callback=self._on_text_element_change,
488
+ no_alpha=True,
489
+ default_value=[0, 0, 0],
490
+ width=200,
491
+ )
492
+
493
+ # Axis element controls (xaxis, yaxis)
494
+ with dpg.group(tag="axis_controls_group", show=False):
495
+ dpg.add_slider_float(
496
+ label="Line Width (mm)",
497
+ tag="axis_linewidth_slider",
498
+ default_value=0.2,
499
+ min_value=0.05,
500
+ max_value=1.0,
501
+ callback=self._on_axis_element_change,
502
+ width=200,
503
+ )
504
+ dpg.add_slider_float(
505
+ label="Tick Length (mm)",
506
+ tag="axis_tick_length_slider",
507
+ default_value=0.8,
508
+ min_value=0.2,
509
+ max_value=3.0,
510
+ callback=self._on_axis_element_change,
511
+ width=200,
512
+ )
513
+ dpg.add_slider_int(
514
+ label="Tick Font Size",
515
+ tag="axis_tick_fontsize_slider",
516
+ default_value=7,
517
+ min_value=4,
518
+ max_value=16,
519
+ callback=self._on_axis_element_change,
520
+ width=200,
521
+ )
522
+ dpg.add_checkbox(
523
+ label="Show Spine",
524
+ tag="axis_show_spine_checkbox",
525
+ default_value=True,
526
+ callback=self._on_axis_element_change,
527
+ )
528
+
529
+ # Legend controls
530
+ with dpg.group(tag="legend_controls_group", show=False):
531
+ dpg.add_checkbox(
532
+ label="Visible",
533
+ tag="legend_visible_edit",
534
+ default_value=True,
535
+ callback=self._on_legend_element_change,
536
+ )
537
+ dpg.add_checkbox(
538
+ label="Show Frame",
539
+ tag="legend_frameon_edit",
540
+ default_value=False,
541
+ callback=self._on_legend_element_change,
542
+ )
543
+ dpg.add_combo(
544
+ label="Position",
545
+ items=["best", "upper right", "upper left", "lower right",
546
+ "lower left", "center right", "center left",
547
+ "upper center", "lower center", "center"],
548
+ default_value="best",
549
+ tag="legend_loc_edit",
550
+ callback=self._on_legend_element_change,
551
+ width=150,
552
+ )
553
+ dpg.add_slider_int(
554
+ label="Font Size",
555
+ tag="legend_fontsize_edit",
556
+ default_value=6,
557
+ min_value=4,
558
+ max_value=14,
559
+ callback=self._on_legend_element_change,
560
+ width=200,
561
+ )
562
+
563
+ dpg.add_button(
564
+ label="Deselect",
565
+ callback=self._deselect_element,
566
+ width=100,
567
+ )
568
+
569
+ # Dimensions Section
570
+ with dpg.collapsing_header(label="Dimensions", default_open=False):
571
+ fig_size = self.current_overrides.get('fig_size', [3.15, 2.68])
572
+ with dpg.group(horizontal=True):
573
+ dpg.add_input_float(
574
+ label="Width (in)",
575
+ default_value=fig_size[0],
576
+ tag="fig_width_input",
577
+ width=100,
578
+ )
579
+ dpg.add_input_float(
580
+ label="Height (in)",
581
+ default_value=fig_size[1],
582
+ tag="fig_height_input",
583
+ width=100,
584
+ )
585
+ dpg.add_slider_int(
586
+ label="DPI",
587
+ default_value=self.current_overrides.get('dpi', 300),
588
+ min_value=72,
589
+ max_value=600,
590
+ tag="dpi_slider",
591
+ callback=self._on_value_change,
592
+ width=200,
593
+ )
594
+
595
+ # Annotations Section
596
+ with dpg.collapsing_header(label="Annotations", default_open=False):
597
+ dpg.add_input_text(
598
+ label="Text",
599
+ tag="annot_text_input",
600
+ width=200,
601
+ )
602
+ with dpg.group(horizontal=True):
603
+ dpg.add_input_float(
604
+ label="X",
605
+ default_value=0.5,
606
+ tag="annot_x_input",
607
+ width=80,
608
+ )
609
+ dpg.add_input_float(
610
+ label="Y",
611
+ default_value=0.5,
612
+ tag="annot_y_input",
613
+ width=80,
614
+ )
615
+ dpg.add_button(
616
+ label="Add Annotation",
617
+ callback=self._add_annotation,
618
+ width=150,
619
+ )
620
+ dpg.add_listbox(
621
+ label="",
622
+ items=[],
623
+ tag="annotations_listbox",
624
+ num_items=4,
625
+ width=250,
626
+ )
627
+ dpg.add_button(
628
+ label="Remove Selected",
629
+ callback=self._remove_annotation,
630
+ width=150,
631
+ )
632
+
633
+ dpg.add_separator()
634
+
635
+ # Action buttons
636
+ dpg.add_button(
637
+ label="Update Preview",
638
+ callback=lambda: self._update_preview(dpg),
639
+ width=-1,
640
+ )
641
+ dpg.add_button(
642
+ label="Save to .manual.json",
643
+ callback=self._save_manual,
644
+ width=-1,
645
+ )
646
+ dpg.add_button(
647
+ label="Reset to Original",
648
+ callback=self._reset_overrides,
649
+ width=-1,
650
+ )
651
+ dpg.add_button(
652
+ label="Export PNG",
653
+ callback=self._export_png,
654
+ width=-1,
655
+ )
656
+
657
+ def _on_value_change(self, sender, app_data, user_data=None):
658
+ """Handle value changes from widgets."""
659
+ import dearpygui.dearpygui as dpg
660
+ self._user_modified = True
661
+ self._collect_overrides(dpg)
662
+ self._update_preview(dpg)
663
+
664
+ def _collect_overrides(self, dpg):
665
+ """Collect current values from all widgets."""
666
+ # Labels
667
+ self.current_overrides['title'] = dpg.get_value("title_input")
668
+ self.current_overrides['xlabel'] = dpg.get_value("xlabel_input")
669
+ self.current_overrides['ylabel'] = dpg.get_value("ylabel_input")
670
+
671
+ # Line style
672
+ self.current_overrides['linewidth'] = dpg.get_value("linewidth_slider")
673
+
674
+ # Font settings
675
+ self.current_overrides['title_fontsize'] = dpg.get_value("title_fontsize_slider")
676
+ self.current_overrides['axis_fontsize'] = dpg.get_value("axis_fontsize_slider")
677
+ self.current_overrides['tick_fontsize'] = dpg.get_value("tick_fontsize_slider")
678
+ self.current_overrides['legend_fontsize'] = dpg.get_value("legend_fontsize_slider")
679
+
680
+ # Tick settings
681
+ self.current_overrides['n_ticks'] = dpg.get_value("n_ticks_slider")
682
+ self.current_overrides['tick_length'] = dpg.get_value("tick_length_slider")
683
+ self.current_overrides['tick_width'] = dpg.get_value("tick_width_slider")
684
+ self.current_overrides['tick_direction'] = dpg.get_value("tick_direction_combo")
685
+
686
+ # Style
687
+ self.current_overrides['grid'] = dpg.get_value("grid_checkbox")
688
+ self.current_overrides['hide_top_spine'] = dpg.get_value("hide_top_spine_checkbox")
689
+ self.current_overrides['hide_right_spine'] = dpg.get_value("hide_right_spine_checkbox")
690
+ self.current_overrides['transparent'] = dpg.get_value("transparent_checkbox")
691
+ self.current_overrides['axis_width'] = dpg.get_value("axis_width_slider")
692
+
693
+ # Legend
694
+ self.current_overrides['legend_visible'] = dpg.get_value("legend_visible_checkbox")
695
+ self.current_overrides['legend_frameon'] = dpg.get_value("legend_frameon_checkbox")
696
+ self.current_overrides['legend_loc'] = dpg.get_value("legend_loc_combo")
697
+
698
+ # Dimensions
699
+ self.current_overrides['fig_size'] = [
700
+ dpg.get_value("fig_width_input"),
701
+ dpg.get_value("fig_height_input"),
702
+ ]
703
+ self.current_overrides['dpi'] = dpg.get_value("dpi_slider")
704
+
705
+ def _apply_limits(self, sender=None, app_data=None, user_data=None):
706
+ """Apply axis limits."""
707
+ import dearpygui.dearpygui as dpg
708
+
709
+ xmin = dpg.get_value("xmin_input")
710
+ xmax = dpg.get_value("xmax_input")
711
+ ymin = dpg.get_value("ymin_input")
712
+ ymax = dpg.get_value("ymax_input")
713
+
714
+ if xmin < xmax:
715
+ self.current_overrides['xlim'] = [xmin, xmax]
716
+ if ymin < ymax:
717
+ self.current_overrides['ylim'] = [ymin, ymax]
718
+
719
+ self._user_modified = True
720
+ self._update_preview(dpg)
721
+
722
+ def _add_annotation(self, sender=None, app_data=None, user_data=None):
723
+ """Add text annotation."""
724
+ import dearpygui.dearpygui as dpg
725
+
726
+ text = dpg.get_value("annot_text_input")
727
+ if not text:
728
+ return
729
+
730
+ x = dpg.get_value("annot_x_input")
731
+ y = dpg.get_value("annot_y_input")
732
+
733
+ if 'annotations' not in self.current_overrides:
734
+ self.current_overrides['annotations'] = []
735
+
736
+ self.current_overrides['annotations'].append({
737
+ 'type': 'text',
738
+ 'text': text,
739
+ 'x': x,
740
+ 'y': y,
741
+ 'fontsize': self.current_overrides.get('axis_fontsize', 7),
742
+ })
743
+
744
+ dpg.set_value("annot_text_input", "")
745
+ self._update_annotations_list(dpg)
746
+ self._user_modified = True
747
+ self._update_preview(dpg)
748
+
749
+ def _remove_annotation(self, sender=None, app_data=None, user_data=None):
750
+ """Remove selected annotation."""
751
+ import dearpygui.dearpygui as dpg
752
+
753
+ selected = dpg.get_value("annotations_listbox")
754
+ annotations = self.current_overrides.get('annotations', [])
755
+
756
+ if selected and annotations:
757
+ # Find index by text
758
+ for i, ann in enumerate(annotations):
759
+ label = f"{ann.get('text', '')[:20]} ({ann.get('x', 0):.2f}, {ann.get('y', 0):.2f})"
760
+ if label == selected:
761
+ del annotations[i]
762
+ break
763
+
764
+ self._update_annotations_list(dpg)
765
+ self._user_modified = True
766
+ self._update_preview(dpg)
767
+
768
+ def _update_annotations_list(self, dpg):
769
+ """Update the annotations listbox."""
770
+ annotations = self.current_overrides.get('annotations', [])
771
+ items = []
772
+ for ann in annotations:
773
+ if ann.get('type') == 'text':
774
+ label = f"{ann.get('text', '')[:20]} ({ann.get('x', 0):.2f}, {ann.get('y', 0):.2f})"
775
+ items.append(label)
776
+
777
+ dpg.configure_item("annotations_listbox", items=items)
778
+
779
+ def _update_preview(self, dpg):
780
+ """Update the figure preview (full re-render)."""
781
+ try:
782
+ # Mark cache dirty and do full render
783
+ self._cache_dirty = True
784
+ img_data, width, height = self._render_figure()
785
+
786
+ # Update texture
787
+ dpg.set_value("preview_texture", img_data)
788
+
789
+ # Update status
790
+ dpg.set_value("status_text", f"Preview updated ({width}x{height})")
791
+
792
+ except Exception as e:
793
+ dpg.set_value("status_text", f"Error: {str(e)}")
794
+
795
+ def _update_hover_overlay(self, dpg):
796
+ """Fast hover overlay update using cached base image (no matplotlib re-render)."""
797
+ import numpy as np
798
+ from PIL import Image, ImageDraw
799
+
800
+ # If no cached base, do full render
801
+ if self._cached_base_image is None:
802
+ self._update_preview(dpg)
803
+ return
804
+
805
+ try:
806
+ # Start with a copy of cached base
807
+ img = self._cached_base_image.copy()
808
+ draw = ImageDraw.Draw(img, 'RGBA')
809
+
810
+ # Get hover element type
811
+ hovered_type = self._hovered_element.get('type') if self._hovered_element else None
812
+ selected_type = self._selected_element.get('type') if self._selected_element else None
813
+
814
+ # Draw hover highlight (outline only, no fill) for non-trace elements
815
+ if hovered_type and hovered_type != 'trace' and hovered_type != selected_type:
816
+ bbox = self._element_bboxes.get(hovered_type)
817
+ if bbox:
818
+ x0, y0, x1, y1 = bbox
819
+ # Transparent outline only - no fill to avoid covering content
820
+ draw.rectangle([x0-2, y0-2, x1+2, y1+2], fill=None, outline=(100, 180, 255, 100), width=1)
821
+
822
+ # Draw selection highlight (outline only, no fill) for non-trace elements
823
+ if selected_type and selected_type != 'trace':
824
+ bbox = self._element_bboxes.get(selected_type)
825
+ if bbox:
826
+ x0, y0, x1, y1 = bbox
827
+ # Transparent outline only - no fill to avoid covering content
828
+ draw.rectangle([x0-2, y0-2, x1+2, y1+2], fill=None, outline=(255, 200, 80, 150), width=2)
829
+
830
+ # Convert to DearPyGui texture format
831
+ img_array = np.array(img).astype(np.float32) / 255.0
832
+ img_data = img_array.flatten().tolist()
833
+
834
+ # Update texture
835
+ dpg.set_value("preview_texture", img_data)
836
+
837
+ except Exception as e:
838
+ # Fallback to full render on error
839
+ self._update_preview(dpg)
840
+
841
+ def _render_figure(self):
842
+ """Render figure and return as RGBA data for texture."""
843
+ import matplotlib
844
+ matplotlib.use('Agg')
845
+ import matplotlib.pyplot as plt
846
+ from matplotlib.ticker import MaxNLocator
847
+ import numpy as np
848
+ from PIL import Image
849
+ import dearpygui.dearpygui as dpg
850
+
851
+ # mm to pt conversion
852
+ mm_to_pt = 2.83465
853
+
854
+ o = self.current_overrides
855
+
856
+ # Dimensions - use fixed size for preview
857
+ preview_dpi = 100
858
+ fig_size = o.get('fig_size', [3.15, 2.68])
859
+
860
+ # Create figure with white background for preview
861
+ fig, ax = plt.subplots(figsize=fig_size, dpi=preview_dpi)
862
+
863
+ # For preview, use white background (transparent doesn't show well in GUI)
864
+ fig.patch.set_facecolor('white')
865
+ ax.patch.set_facecolor('white')
866
+
867
+ # Plot from CSV data (only pass selection, hover is via PIL overlay for speed)
868
+ if self.csv_data is not None:
869
+ self._plot_from_csv(ax, o, highlight_trace=self._selected_trace_index)
870
+ else:
871
+ ax.text(0.5, 0.5, "No plot data available\n(CSV not found)",
872
+ ha='center', va='center', transform=ax.transAxes,
873
+ fontsize=o.get('axis_fontsize', 7))
874
+
875
+ # Apply labels
876
+ if o.get('title'):
877
+ ax.set_title(o['title'], fontsize=o.get('title_fontsize', 8))
878
+ if o.get('xlabel'):
879
+ ax.set_xlabel(o['xlabel'], fontsize=o.get('axis_fontsize', 7))
880
+ if o.get('ylabel'):
881
+ ax.set_ylabel(o['ylabel'], fontsize=o.get('axis_fontsize', 7))
882
+
883
+ # Tick styling
884
+ ax.tick_params(
885
+ axis='both',
886
+ labelsize=o.get('tick_fontsize', 7),
887
+ length=o.get('tick_length', 0.8) * mm_to_pt,
888
+ width=o.get('tick_width', 0.2) * mm_to_pt,
889
+ direction=o.get('tick_direction', 'out'),
890
+ )
891
+
892
+ # Number of ticks
893
+ ax.xaxis.set_major_locator(MaxNLocator(nbins=o.get('n_ticks', 4)))
894
+ ax.yaxis.set_major_locator(MaxNLocator(nbins=o.get('n_ticks', 4)))
895
+
896
+ # Grid
897
+ if o.get('grid'):
898
+ ax.grid(True, linewidth=o.get('axis_width', 0.2) * mm_to_pt, alpha=0.3)
899
+
900
+ # Axis limits
901
+ if o.get('xlim'):
902
+ ax.set_xlim(o['xlim'])
903
+ if o.get('ylim'):
904
+ ax.set_ylim(o['ylim'])
905
+
906
+ # Spines
907
+ if o.get('hide_top_spine', True):
908
+ ax.spines['top'].set_visible(False)
909
+ if o.get('hide_right_spine', True):
910
+ ax.spines['right'].set_visible(False)
911
+
912
+ for spine in ax.spines.values():
913
+ spine.set_linewidth(o.get('axis_width', 0.2) * mm_to_pt)
914
+
915
+ # Annotations
916
+ for annot in o.get('annotations', []):
917
+ if annot.get('type') == 'text':
918
+ ax.text(
919
+ annot.get('x', 0.5),
920
+ annot.get('y', 0.5),
921
+ annot.get('text', ''),
922
+ transform=ax.transAxes,
923
+ fontsize=annot.get('fontsize', o.get('axis_fontsize', 7)),
924
+ )
925
+
926
+ fig.tight_layout()
927
+
928
+ # Draw before collecting bboxes so we have accurate positions
929
+ fig.canvas.draw()
930
+
931
+ # Draw hover/selection highlights for non-trace elements
932
+ self._draw_element_highlights(fig, ax)
933
+
934
+ # Store axes transform info for click-to-select
935
+ fig.canvas.draw()
936
+ ax_bbox = ax.get_position()
937
+ fig_width_px = int(fig_size[0] * preview_dpi)
938
+ fig_height_px = int(fig_size[1] * preview_dpi)
939
+
940
+ # Collect element bboxes for click detection (in figure pixel coordinates)
941
+ # We'll scale these later after resize
942
+ self._element_bboxes_raw = {}
943
+
944
+ # Title bbox
945
+ if ax.title.get_text():
946
+ try:
947
+ title_bbox = ax.title.get_window_extent(fig.canvas.get_renderer())
948
+ self._element_bboxes_raw['title'] = (
949
+ title_bbox.x0, title_bbox.y0, title_bbox.x1, title_bbox.y1
950
+ )
951
+ except Exception:
952
+ pass
953
+
954
+ # X label bbox
955
+ if ax.xaxis.label.get_text():
956
+ try:
957
+ xlabel_bbox = ax.xaxis.label.get_window_extent(fig.canvas.get_renderer())
958
+ self._element_bboxes_raw['xlabel'] = (
959
+ xlabel_bbox.x0, xlabel_bbox.y0, xlabel_bbox.x1, xlabel_bbox.y1
960
+ )
961
+ except Exception:
962
+ pass
963
+
964
+ # Y label bbox
965
+ if ax.yaxis.label.get_text():
966
+ try:
967
+ ylabel_bbox = ax.yaxis.label.get_window_extent(fig.canvas.get_renderer())
968
+ self._element_bboxes_raw['ylabel'] = (
969
+ ylabel_bbox.x0, ylabel_bbox.y0, ylabel_bbox.x1, ylabel_bbox.y1
970
+ )
971
+ except Exception:
972
+ pass
973
+
974
+ # Legend bbox
975
+ legend = ax.get_legend()
976
+ if legend:
977
+ try:
978
+ legend_bbox = legend.get_window_extent(fig.canvas.get_renderer())
979
+ self._element_bboxes_raw['legend'] = (
980
+ legend_bbox.x0, legend_bbox.y0, legend_bbox.x1, legend_bbox.y1
981
+ )
982
+ except Exception:
983
+ pass
984
+
985
+ # X axis (bottom spine area)
986
+ try:
987
+ xaxis_bbox = ax.spines['bottom'].get_window_extent(fig.canvas.get_renderer())
988
+ # Expand bbox slightly for easier clicking
989
+ self._element_bboxes_raw['xaxis'] = (
990
+ xaxis_bbox.x0, xaxis_bbox.y0 - 20, xaxis_bbox.x1, xaxis_bbox.y1 + 10
991
+ )
992
+ except Exception:
993
+ pass
994
+
995
+ # Y axis (left spine area)
996
+ try:
997
+ yaxis_bbox = ax.spines['left'].get_window_extent(fig.canvas.get_renderer())
998
+ # Expand bbox slightly for easier clicking
999
+ self._element_bboxes_raw['yaxis'] = (
1000
+ yaxis_bbox.x0 - 20, yaxis_bbox.y0, yaxis_bbox.x1 + 10, yaxis_bbox.y1
1001
+ )
1002
+ except Exception:
1003
+ pass
1004
+
1005
+ # Convert to RGBA data for DearPyGui texture
1006
+ buf = io.BytesIO()
1007
+ fig.savefig(buf, format='png', dpi=preview_dpi, bbox_inches='tight',
1008
+ facecolor='white', edgecolor='none')
1009
+ buf.seek(0)
1010
+
1011
+ # Load with PIL and convert to normalized RGBA
1012
+ img = Image.open(buf).convert('RGBA')
1013
+ width, height = img.size
1014
+
1015
+ # Resize to fit within max preview size while preserving aspect ratio
1016
+ max_width, max_height = 800, 600
1017
+ ratio = min(max_width / width, max_height / height)
1018
+ new_width = int(width * ratio)
1019
+ new_height = int(height * ratio)
1020
+ img = img.resize((new_width, new_height), Image.LANCZOS)
1021
+
1022
+ # Store preview bounds for coordinate conversion (after resize)
1023
+ x_offset = (max_width - new_width) // 2
1024
+ y_offset = (max_height - new_height) // 2
1025
+ self._preview_bounds = (x_offset, y_offset, new_width, new_height)
1026
+
1027
+ # Scale element bboxes to preview coordinates
1028
+ # Note: matplotlib uses bottom-left origin, we need top-left for preview
1029
+ self._element_bboxes = {}
1030
+ for elem_type, raw_bbox in getattr(self, '_element_bboxes_raw', {}).items():
1031
+ if raw_bbox is None:
1032
+ continue
1033
+ rx0, ry0, rx1, ry1 = raw_bbox
1034
+ # Scale to resized image
1035
+ sx0 = int(rx0 * ratio) + x_offset
1036
+ sx1 = int(rx1 * ratio) + x_offset
1037
+ # Flip Y coordinate (matplotlib origin is bottom, preview is top)
1038
+ sy0 = new_height - int(ry1 * ratio) + y_offset
1039
+ sy1 = new_height - int(ry0 * ratio) + y_offset
1040
+ self._element_bboxes[elem_type] = (sx0, sy0, sx1, sy1)
1041
+
1042
+ # Store axes transform info (scaled to resized image)
1043
+ # ax_bbox is in figure fraction coordinates
1044
+ ax_x0 = int(ax_bbox.x0 * new_width)
1045
+ ax_y0 = int((1 - ax_bbox.y1) * new_height) # Flip y (0 at top)
1046
+ ax_width = int(ax_bbox.width * new_width)
1047
+ ax_height = int(ax_bbox.height * new_height)
1048
+ xlim = ax.get_xlim()
1049
+ ylim = ax.get_ylim()
1050
+ self._axes_transform = (ax_x0, ax_y0, ax_width, ax_height, xlim, ylim)
1051
+
1052
+ # Create background - checkerboard for transparent, white otherwise
1053
+ transparent = o.get('transparent', True)
1054
+ if transparent:
1055
+ # Create checkerboard pattern for transparency preview
1056
+ padded = _create_checkerboard(max_width, max_height, square_size=10)
1057
+ else:
1058
+ padded = Image.new('RGBA', (max_width, max_height), (255, 255, 255, 255))
1059
+
1060
+ # Paste figure centered on background
1061
+ padded.paste(img, (x_offset, y_offset), img) # Use img as mask for alpha
1062
+ img = padded
1063
+ width, height = max_width, max_height
1064
+
1065
+ # Cache the base image (without highlights) for fast hover updates
1066
+ self._cached_base_image = img.copy()
1067
+ self._cache_dirty = False
1068
+
1069
+ # Convert to normalized float array for DearPyGui
1070
+ img_array = np.array(img).astype(np.float32) / 255.0
1071
+ img_data = img_array.flatten().tolist()
1072
+
1073
+ plt.close(fig)
1074
+
1075
+ # Update texture data (don't recreate texture, just update values)
1076
+ dpg.set_value("preview_texture", img_data)
1077
+
1078
+ return img_data, width, height
1079
+
1080
+ def _draw_element_highlights(self, fig, ax):
1081
+ """Draw selection highlights for non-trace elements (hover handled via PIL overlay)."""
1082
+ from matplotlib.patches import FancyBboxPatch
1083
+ import matplotlib.transforms as transforms
1084
+
1085
+ renderer = fig.canvas.get_renderer()
1086
+
1087
+ # Only draw selection highlights here (hover is done via fast PIL overlay)
1088
+ selected_type = self._selected_element.get('type') if self._selected_element else None
1089
+
1090
+ # Skip if selecting traces (handled separately in _plot_from_csv)
1091
+ if selected_type == 'trace':
1092
+ selected_type = None
1093
+
1094
+ def add_highlight_box(text_obj, color, alpha, linewidth=2):
1095
+ """Add highlight rectangle around a text object (outline only)."""
1096
+ try:
1097
+ bbox = text_obj.get_window_extent(renderer)
1098
+ # Convert to figure coordinates
1099
+ fig_bbox = bbox.transformed(fig.transFigure.inverted())
1100
+ # Add padding
1101
+ padding = 0.01
1102
+ rect = FancyBboxPatch(
1103
+ (fig_bbox.x0 - padding, fig_bbox.y0 - padding),
1104
+ fig_bbox.width + 2 * padding,
1105
+ fig_bbox.height + 2 * padding,
1106
+ boxstyle="round,pad=0.02,rounding_size=0.01",
1107
+ facecolor='none',
1108
+ edgecolor=color,
1109
+ alpha=0.7,
1110
+ linewidth=linewidth,
1111
+ transform=fig.transFigure,
1112
+ zorder=100,
1113
+ )
1114
+ fig.patches.append(rect)
1115
+ except Exception:
1116
+ pass
1117
+
1118
+ def add_spine_highlight(spine, color, alpha, linewidth=2):
1119
+ """Add highlight to a spine/axis (outline only)."""
1120
+ try:
1121
+ bbox = spine.get_window_extent(renderer)
1122
+ fig_bbox = bbox.transformed(fig.transFigure.inverted())
1123
+ padding = 0.01
1124
+ rect = FancyBboxPatch(
1125
+ (fig_bbox.x0 - padding, fig_bbox.y0 - padding),
1126
+ fig_bbox.width + 2 * padding,
1127
+ fig_bbox.height + 2 * padding,
1128
+ boxstyle="round,pad=0.01",
1129
+ facecolor='none',
1130
+ edgecolor=color,
1131
+ alpha=0.7,
1132
+ linewidth=linewidth,
1133
+ transform=fig.transFigure,
1134
+ zorder=100,
1135
+ )
1136
+ fig.patches.append(rect)
1137
+ except Exception:
1138
+ pass
1139
+
1140
+ # Map element types to matplotlib objects
1141
+ element_map = {
1142
+ 'title': ax.title,
1143
+ 'xlabel': ax.xaxis.label,
1144
+ 'ylabel': ax.yaxis.label,
1145
+ }
1146
+
1147
+ # Draw selection highlight (outline only, no fill)
1148
+ select_color = '#FFC850' # Soft warm yellow for outline
1149
+ if selected_type in element_map:
1150
+ add_highlight_box(element_map[selected_type], select_color, 0.0, linewidth=2)
1151
+ elif selected_type == 'xaxis':
1152
+ add_spine_highlight(ax.spines['bottom'], select_color, 0.0, linewidth=2)
1153
+ elif selected_type == 'yaxis':
1154
+ add_spine_highlight(ax.spines['left'], select_color, 0.0, linewidth=2)
1155
+ elif selected_type == 'legend':
1156
+ legend = ax.get_legend()
1157
+ if legend:
1158
+ try:
1159
+ bbox = legend.get_window_extent(renderer)
1160
+ fig_bbox = bbox.transformed(fig.transFigure.inverted())
1161
+ padding = 0.01
1162
+ rect = FancyBboxPatch(
1163
+ (fig_bbox.x0 - padding, fig_bbox.y0 - padding),
1164
+ fig_bbox.width + 2 * padding,
1165
+ fig_bbox.height + 2 * padding,
1166
+ boxstyle="round,pad=0.02",
1167
+ facecolor='none',
1168
+ edgecolor=select_color,
1169
+ alpha=0.7,
1170
+ linewidth=2,
1171
+ transform=fig.transFigure,
1172
+ zorder=100,
1173
+ )
1174
+ fig.patches.append(rect)
1175
+ except Exception:
1176
+ pass
1177
+
1178
+ # Note: Hover highlights are now drawn via fast PIL overlay in _update_hover_overlay()
1179
+
1180
+ def _plot_from_csv(self, ax, o, highlight_trace=None, hover_trace=None):
1181
+ """Reconstruct plot from CSV data using trace info.
1182
+
1183
+ Parameters
1184
+ ----------
1185
+ ax : matplotlib.axes.Axes
1186
+ The axes to plot on
1187
+ o : dict
1188
+ Current overrides containing trace info
1189
+ highlight_trace : int, optional
1190
+ Index of trace to highlight with selection effect (yellow glow)
1191
+ hover_trace : int, optional
1192
+ Index of trace to highlight with hover effect (cyan glow)
1193
+ """
1194
+ import pandas as pd
1195
+ from ._defaults import _normalize_legend_loc
1196
+
1197
+ if not isinstance(self.csv_data, pd.DataFrame):
1198
+ return
1199
+
1200
+ df = self.csv_data
1201
+ linewidth = o.get('linewidth', 1.0)
1202
+ legend_visible = o.get('legend_visible', True)
1203
+ legend_fontsize = o.get('legend_fontsize', 6)
1204
+ legend_frameon = o.get('legend_frameon', False)
1205
+ legend_loc = _normalize_legend_loc(o.get('legend_loc', 'best'))
1206
+
1207
+ traces = o.get('traces', [])
1208
+
1209
+ if traces:
1210
+ for i, trace in enumerate(traces):
1211
+ csv_cols = trace.get('csv_columns', {})
1212
+ x_col = csv_cols.get('x')
1213
+ y_col = csv_cols.get('y')
1214
+
1215
+ if x_col in df.columns and y_col in df.columns:
1216
+ trace_linewidth = trace.get('linewidth', linewidth)
1217
+ is_selected = (highlight_trace is not None and i == highlight_trace)
1218
+ is_hovered = (hover_trace is not None and i == hover_trace and not is_selected)
1219
+
1220
+ # Draw selection glow (yellow, stronger)
1221
+ if is_selected:
1222
+ ax.plot(
1223
+ df[x_col],
1224
+ df[y_col],
1225
+ color='yellow',
1226
+ linewidth=trace_linewidth * 4,
1227
+ alpha=0.5,
1228
+ zorder=0,
1229
+ )
1230
+ # Draw hover glow (cyan, subtler)
1231
+ elif is_hovered:
1232
+ ax.plot(
1233
+ df[x_col],
1234
+ df[y_col],
1235
+ color='cyan',
1236
+ linewidth=trace_linewidth * 3,
1237
+ alpha=0.3,
1238
+ zorder=0,
1239
+ )
1240
+
1241
+ ax.plot(
1242
+ df[x_col],
1243
+ df[y_col],
1244
+ label=trace.get('label', trace.get('id', '')),
1245
+ color=trace.get('color'),
1246
+ linestyle=trace.get('linestyle', '-'),
1247
+ linewidth=trace_linewidth * (1.5 if is_selected else (1.2 if is_hovered else 1.0)),
1248
+ marker=trace.get('marker', None),
1249
+ markersize=trace.get('markersize', 6),
1250
+ zorder=10 if is_selected else (5 if is_hovered else 1),
1251
+ )
1252
+
1253
+ if legend_visible and any(t.get('label') for t in traces):
1254
+ ax.legend(fontsize=legend_fontsize, frameon=legend_frameon, loc=legend_loc)
1255
+ else:
1256
+ # Fallback: parse column names
1257
+ cols = df.columns.tolist()
1258
+ trace_groups = {}
1259
+
1260
+ for col in cols:
1261
+ if col.endswith('_x'):
1262
+ trace_id = col[:-2]
1263
+ y_col = trace_id + '_y'
1264
+ if y_col in cols:
1265
+ parts = trace_id.split('_')
1266
+ label = parts[2] if len(parts) > 2 else trace_id
1267
+ trace_groups[trace_id] = {
1268
+ 'x_col': col,
1269
+ 'y_col': y_col,
1270
+ 'label': label,
1271
+ }
1272
+
1273
+ if trace_groups:
1274
+ for trace_id, info in trace_groups.items():
1275
+ ax.plot(
1276
+ df[info['x_col']],
1277
+ df[info['y_col']],
1278
+ label=info['label'],
1279
+ linewidth=linewidth,
1280
+ )
1281
+ if legend_visible:
1282
+ ax.legend(fontsize=legend_fontsize, frameon=legend_frameon, loc=legend_loc)
1283
+ elif len(cols) >= 2:
1284
+ x_col = cols[0]
1285
+ for y_col in cols[1:]:
1286
+ try:
1287
+ ax.plot(df[x_col], df[y_col], label=str(y_col), linewidth=linewidth)
1288
+ except Exception:
1289
+ pass
1290
+ if len(cols) > 2 and legend_visible:
1291
+ ax.legend(fontsize=legend_fontsize, frameon=legend_frameon, loc=legend_loc)
1292
+
1293
+ def _get_trace_labels(self):
1294
+ """Get list of trace labels for selection combo."""
1295
+ traces = self.current_overrides.get('traces', [])
1296
+ if not traces:
1297
+ return ["(no traces)"]
1298
+ return [t.get('label', t.get('id', f'Trace {i}')) for i, t in enumerate(traces)]
1299
+
1300
+ def _get_all_element_labels(self):
1301
+ """Get list of all selectable element labels."""
1302
+ labels = []
1303
+
1304
+ # Fixed elements
1305
+ labels.append("Title")
1306
+ labels.append("X Label")
1307
+ labels.append("Y Label")
1308
+ labels.append("X Axis")
1309
+ labels.append("Y Axis")
1310
+ labels.append("Legend")
1311
+
1312
+ # Traces
1313
+ traces = self.current_overrides.get('traces', [])
1314
+ for i, t in enumerate(traces):
1315
+ label = t.get('label', t.get('id', f'Trace {i}'))
1316
+ labels.append(f"Trace: {label}")
1317
+
1318
+ return labels
1319
+
1320
+ def _on_preview_click(self, sender, app_data):
1321
+ """Handle click on preview image to select element."""
1322
+ import dearpygui.dearpygui as dpg
1323
+
1324
+ # Only handle left clicks
1325
+ if app_data != 0: # 0 = left button
1326
+ return
1327
+
1328
+ # Get mouse position relative to viewport
1329
+ mouse_pos = dpg.get_mouse_pos(local=False)
1330
+
1331
+ # Get preview image position and size
1332
+ if not dpg.does_item_exist("preview_image"):
1333
+ return
1334
+
1335
+ # Get the image item's position in the window
1336
+ img_pos = dpg.get_item_pos("preview_image")
1337
+ panel_pos = dpg.get_item_pos("preview_panel")
1338
+
1339
+ # Calculate click position relative to image
1340
+ click_x = mouse_pos[0] - panel_pos[0] - img_pos[0]
1341
+ click_y = mouse_pos[1] - panel_pos[1] - img_pos[1]
1342
+
1343
+ # Check if click is within image bounds (800x600)
1344
+ max_width, max_height = 800, 600
1345
+ if not (0 <= click_x <= max_width and 0 <= click_y <= max_height):
1346
+ return
1347
+
1348
+ # First check if click is on a fixed element (title, labels, axes, legend)
1349
+ element = self._find_clicked_element(click_x, click_y)
1350
+
1351
+ if element:
1352
+ self._select_element(element, dpg)
1353
+ else:
1354
+ # Fall back to trace selection
1355
+ trace_idx = self._find_nearest_trace(click_x, click_y, max_width, max_height)
1356
+ if trace_idx is not None:
1357
+ self._select_element({'type': 'trace', 'index': trace_idx}, dpg)
1358
+
1359
+ def _on_preview_hover(self, sender, app_data):
1360
+ """Handle mouse move for hover effects on preview (optimized with caching)."""
1361
+ import dearpygui.dearpygui as dpg
1362
+ import time
1363
+
1364
+ # Throttle hover updates - reduced to 16ms (~60fps) since we use fast overlay
1365
+ current_time = time.time()
1366
+ if current_time - self._last_hover_check < 0.016:
1367
+ return
1368
+ self._last_hover_check = current_time
1369
+
1370
+ # Get mouse position relative to viewport
1371
+ mouse_pos = dpg.get_mouse_pos(local=False)
1372
+
1373
+ # Get preview image position
1374
+ if not dpg.does_item_exist("preview_image"):
1375
+ return
1376
+
1377
+ img_pos = dpg.get_item_pos("preview_image")
1378
+ panel_pos = dpg.get_item_pos("preview_panel")
1379
+
1380
+ # Calculate hover position relative to image
1381
+ hover_x = mouse_pos[0] - panel_pos[0] - img_pos[0]
1382
+ hover_y = mouse_pos[1] - panel_pos[1] - img_pos[1]
1383
+
1384
+ # Check if within image bounds
1385
+ max_width, max_height = 800, 600
1386
+ if not (0 <= hover_x <= max_width and 0 <= hover_y <= max_height):
1387
+ if self._hovered_element is not None:
1388
+ self._hovered_element = None
1389
+ dpg.set_value("hover_text", "")
1390
+ # Use fast overlay update instead of full redraw
1391
+ self._update_hover_overlay(dpg)
1392
+ return
1393
+
1394
+ # Find element under cursor
1395
+ element = self._find_clicked_element(hover_x, hover_y)
1396
+
1397
+ if element is None:
1398
+ # Check for trace hover
1399
+ trace_idx = self._find_nearest_trace(hover_x, hover_y, max_width, max_height)
1400
+ if trace_idx is not None:
1401
+ element = {'type': 'trace', 'index': trace_idx}
1402
+
1403
+ # Check if hover changed
1404
+ old_hover = self._hovered_element
1405
+ if element != old_hover:
1406
+ self._hovered_element = element
1407
+ if element:
1408
+ elem_type = element.get('type', '')
1409
+ elem_idx = element.get('index')
1410
+ if elem_type == 'trace' and elem_idx is not None:
1411
+ traces = self.current_overrides.get('traces', [])
1412
+ if elem_idx < len(traces):
1413
+ label = traces[elem_idx].get('label', f'Trace {elem_idx}')
1414
+ dpg.set_value("hover_text", f"Hover: {label} (click to select)")
1415
+ else:
1416
+ label = elem_type.replace('x', 'X ').replace('y', 'Y ').title()
1417
+ dpg.set_value("hover_text", f"Hover: {label} (click to select)")
1418
+ else:
1419
+ dpg.set_value("hover_text", "")
1420
+
1421
+ # Use fast overlay update for hover (no matplotlib re-render)
1422
+ self._update_hover_overlay(dpg)
1423
+
1424
+ def _find_clicked_element(self, click_x, click_y):
1425
+ """Find which element was clicked based on stored bboxes."""
1426
+ if not self._element_bboxes:
1427
+ return None
1428
+
1429
+ # Check each element bbox
1430
+ for element_type, bbox in self._element_bboxes.items():
1431
+ if bbox is None:
1432
+ continue
1433
+ x0, y0, x1, y1 = bbox
1434
+ if x0 <= click_x <= x1 and y0 <= click_y <= y1:
1435
+ return {'type': element_type, 'index': None}
1436
+
1437
+ return None
1438
+
1439
+ def _select_element(self, element, dpg):
1440
+ """Select an element and show appropriate controls."""
1441
+ self._selected_element = element
1442
+ elem_type = element.get('type')
1443
+ elem_idx = element.get('index')
1444
+
1445
+ # Hide all control groups first
1446
+ dpg.configure_item("trace_controls_group", show=False)
1447
+ dpg.configure_item("text_controls_group", show=False)
1448
+ dpg.configure_item("axis_controls_group", show=False)
1449
+ dpg.configure_item("legend_controls_group", show=False)
1450
+
1451
+ # Update combo selection
1452
+ if elem_type == 'trace':
1453
+ traces = self.current_overrides.get('traces', [])
1454
+ if elem_idx is not None and elem_idx < len(traces):
1455
+ trace = traces[elem_idx]
1456
+ label = f"Trace: {trace.get('label', trace.get('id', f'Trace {elem_idx}'))}"
1457
+ dpg.set_value("element_selector_combo", label)
1458
+
1459
+ # Show trace controls and populate
1460
+ dpg.configure_item("trace_controls_group", show=True)
1461
+ self._selected_trace_index = elem_idx
1462
+ dpg.set_value("trace_label_input", trace.get('label', ''))
1463
+
1464
+ color_hex = trace.get('color', '#0080bf')
1465
+ try:
1466
+ r = int(color_hex[1:3], 16)
1467
+ g = int(color_hex[3:5], 16)
1468
+ b = int(color_hex[5:7], 16)
1469
+ dpg.set_value("trace_color_picker", [r, g, b])
1470
+ except (ValueError, IndexError):
1471
+ dpg.set_value("trace_color_picker", [128, 128, 191])
1472
+
1473
+ dpg.set_value("trace_linewidth_slider", trace.get('linewidth', 1.0))
1474
+ dpg.set_value("trace_linestyle_combo", trace.get('linestyle', '-'))
1475
+ dpg.set_value("trace_marker_combo", trace.get('marker', '') or '')
1476
+ dpg.set_value("trace_markersize_slider", trace.get('markersize', 6.0))
1477
+
1478
+ dpg.set_value("selection_text", f"Selected: {trace.get('label', f'Trace {elem_idx}')}")
1479
+
1480
+ elif elem_type in ('title', 'xlabel', 'ylabel'):
1481
+ dpg.set_value("element_selector_combo", elem_type.replace('x', 'X ').replace('y', 'Y ').title())
1482
+ dpg.configure_item("text_controls_group", show=True)
1483
+
1484
+ o = self.current_overrides
1485
+ if elem_type == 'title':
1486
+ dpg.set_value("element_text_input", o.get('title', ''))
1487
+ dpg.set_value("element_fontsize_slider", o.get('title_fontsize', 8))
1488
+ elif elem_type == 'xlabel':
1489
+ dpg.set_value("element_text_input", o.get('xlabel', ''))
1490
+ dpg.set_value("element_fontsize_slider", o.get('axis_fontsize', 7))
1491
+ elif elem_type == 'ylabel':
1492
+ dpg.set_value("element_text_input", o.get('ylabel', ''))
1493
+ dpg.set_value("element_fontsize_slider", o.get('axis_fontsize', 7))
1494
+
1495
+ dpg.set_value("selection_text", f"Selected: {elem_type.title()}")
1496
+
1497
+ elif elem_type in ('xaxis', 'yaxis'):
1498
+ label = "X Axis" if elem_type == 'xaxis' else "Y Axis"
1499
+ dpg.set_value("element_selector_combo", label)
1500
+ dpg.configure_item("axis_controls_group", show=True)
1501
+
1502
+ o = self.current_overrides
1503
+ dpg.set_value("axis_linewidth_slider", o.get('axis_width', 0.2))
1504
+ dpg.set_value("axis_tick_length_slider", o.get('tick_length', 0.8))
1505
+ dpg.set_value("axis_tick_fontsize_slider", o.get('tick_fontsize', 7))
1506
+
1507
+ if elem_type == 'xaxis':
1508
+ dpg.set_value("axis_show_spine_checkbox", not o.get('hide_bottom_spine', False))
1509
+ else:
1510
+ dpg.set_value("axis_show_spine_checkbox", not o.get('hide_left_spine', False))
1511
+
1512
+ dpg.set_value("selection_text", f"Selected: {label}")
1513
+
1514
+ elif elem_type == 'legend':
1515
+ dpg.set_value("element_selector_combo", "Legend")
1516
+ dpg.configure_item("legend_controls_group", show=True)
1517
+
1518
+ o = self.current_overrides
1519
+ dpg.set_value("legend_visible_edit", o.get('legend_visible', True))
1520
+ dpg.set_value("legend_frameon_edit", o.get('legend_frameon', False))
1521
+ dpg.set_value("legend_loc_edit", o.get('legend_loc', 'best'))
1522
+ dpg.set_value("legend_fontsize_edit", o.get('legend_fontsize', 6))
1523
+
1524
+ dpg.set_value("selection_text", "Selected: Legend")
1525
+
1526
+ # Redraw with highlight
1527
+ self._update_preview(dpg)
1528
+
1529
+ def _on_element_selected(self, sender, app_data):
1530
+ """Handle element selection from combo box."""
1531
+ import dearpygui.dearpygui as dpg
1532
+
1533
+ if app_data == "Title":
1534
+ self._select_element({'type': 'title', 'index': None}, dpg)
1535
+ elif app_data == "X Label":
1536
+ self._select_element({'type': 'xlabel', 'index': None}, dpg)
1537
+ elif app_data == "Y Label":
1538
+ self._select_element({'type': 'ylabel', 'index': None}, dpg)
1539
+ elif app_data == "X Axis":
1540
+ self._select_element({'type': 'xaxis', 'index': None}, dpg)
1541
+ elif app_data == "Y Axis":
1542
+ self._select_element({'type': 'yaxis', 'index': None}, dpg)
1543
+ elif app_data == "Legend":
1544
+ self._select_element({'type': 'legend', 'index': None}, dpg)
1545
+ elif app_data.startswith("Trace: "):
1546
+ # Find trace index
1547
+ trace_label = app_data[7:] # Remove "Trace: " prefix
1548
+ traces = self.current_overrides.get('traces', [])
1549
+ for i, t in enumerate(traces):
1550
+ if t.get('label', t.get('id', f'Trace {i}')) == trace_label:
1551
+ self._select_element({'type': 'trace', 'index': i}, dpg)
1552
+ break
1553
+
1554
+ def _on_text_element_change(self, sender, app_data, user_data=None):
1555
+ """Handle changes to text element properties."""
1556
+ import dearpygui.dearpygui as dpg
1557
+
1558
+ if self._selected_element is None:
1559
+ return
1560
+
1561
+ elem_type = self._selected_element.get('type')
1562
+ if elem_type not in ('title', 'xlabel', 'ylabel'):
1563
+ return
1564
+
1565
+ text = dpg.get_value("element_text_input")
1566
+ fontsize = dpg.get_value("element_fontsize_slider")
1567
+
1568
+ if elem_type == 'title':
1569
+ self.current_overrides['title'] = text
1570
+ self.current_overrides['title_fontsize'] = fontsize
1571
+ elif elem_type == 'xlabel':
1572
+ self.current_overrides['xlabel'] = text
1573
+ self.current_overrides['axis_fontsize'] = fontsize
1574
+ elif elem_type == 'ylabel':
1575
+ self.current_overrides['ylabel'] = text
1576
+ self.current_overrides['axis_fontsize'] = fontsize
1577
+
1578
+ self._user_modified = True
1579
+ self._update_preview(dpg)
1580
+
1581
+ def _on_axis_element_change(self, sender, app_data, user_data=None):
1582
+ """Handle changes to axis element properties."""
1583
+ import dearpygui.dearpygui as dpg
1584
+
1585
+ if self._selected_element is None:
1586
+ return
1587
+
1588
+ elem_type = self._selected_element.get('type')
1589
+ if elem_type not in ('xaxis', 'yaxis'):
1590
+ return
1591
+
1592
+ self.current_overrides['axis_width'] = dpg.get_value("axis_linewidth_slider")
1593
+ self.current_overrides['tick_length'] = dpg.get_value("axis_tick_length_slider")
1594
+ self.current_overrides['tick_fontsize'] = dpg.get_value("axis_tick_fontsize_slider")
1595
+
1596
+ show_spine = dpg.get_value("axis_show_spine_checkbox")
1597
+ if elem_type == 'xaxis':
1598
+ self.current_overrides['hide_bottom_spine'] = not show_spine
1599
+ else:
1600
+ self.current_overrides['hide_left_spine'] = not show_spine
1601
+
1602
+ self._user_modified = True
1603
+ self._update_preview(dpg)
1604
+
1605
+ def _on_legend_element_change(self, sender, app_data, user_data=None):
1606
+ """Handle changes to legend element properties."""
1607
+ import dearpygui.dearpygui as dpg
1608
+
1609
+ if self._selected_element is None:
1610
+ return
1611
+
1612
+ elem_type = self._selected_element.get('type')
1613
+ if elem_type != 'legend':
1614
+ return
1615
+
1616
+ self.current_overrides['legend_visible'] = dpg.get_value("legend_visible_edit")
1617
+ self.current_overrides['legend_frameon'] = dpg.get_value("legend_frameon_edit")
1618
+ self.current_overrides['legend_loc'] = dpg.get_value("legend_loc_edit")
1619
+ self.current_overrides['legend_fontsize'] = dpg.get_value("legend_fontsize_edit")
1620
+
1621
+ self._user_modified = True
1622
+ self._update_preview(dpg)
1623
+
1624
+ def _deselect_element(self, sender=None, app_data=None, user_data=None):
1625
+ """Deselect the current element."""
1626
+ import dearpygui.dearpygui as dpg
1627
+
1628
+ self._selected_element = None
1629
+ self._selected_trace_index = None
1630
+
1631
+ # Hide all control groups
1632
+ dpg.configure_item("trace_controls_group", show=False)
1633
+ dpg.configure_item("text_controls_group", show=False)
1634
+ dpg.configure_item("axis_controls_group", show=False)
1635
+ dpg.configure_item("legend_controls_group", show=False)
1636
+
1637
+ dpg.set_value("selection_text", "")
1638
+ dpg.set_value("element_selector_combo", "")
1639
+ self._update_preview(dpg)
1640
+
1641
+ def _find_nearest_trace(self, click_x, click_y, preview_width, preview_height):
1642
+ """Find the nearest trace to the click position."""
1643
+ import pandas as pd
1644
+ import numpy as np
1645
+
1646
+ if self.csv_data is None or not isinstance(self.csv_data, pd.DataFrame):
1647
+ return None
1648
+
1649
+ traces = self.current_overrides.get('traces', [])
1650
+ if not traces:
1651
+ return None
1652
+
1653
+ # Get preview bounds from last render
1654
+ if self._preview_bounds is None:
1655
+ return None
1656
+
1657
+ x_offset, y_offset, fig_width, fig_height = self._preview_bounds
1658
+
1659
+ # Adjust click coordinates to figure space
1660
+ fig_x = click_x - x_offset
1661
+ fig_y = click_y - y_offset
1662
+
1663
+ # Check if click is within figure bounds
1664
+ if not (0 <= fig_x <= fig_width and 0 <= fig_y <= fig_height):
1665
+ return None
1666
+
1667
+ # Get axes transform info
1668
+ if self._axes_transform is None:
1669
+ return None
1670
+
1671
+ ax_x0, ax_y0, ax_width, ax_height, xlim, ylim = self._axes_transform
1672
+
1673
+ # Convert figure pixel to axes pixel
1674
+ ax_pixel_x = fig_x - ax_x0
1675
+ ax_pixel_y = fig_y - ax_y0
1676
+
1677
+ # Check if click is within axes bounds
1678
+ if not (0 <= ax_pixel_x <= ax_width and 0 <= ax_pixel_y <= ax_height):
1679
+ return None
1680
+
1681
+ # Convert axes pixel to data coordinates
1682
+ # Note: y is flipped (0 at top in pixel space)
1683
+ data_x = xlim[0] + (ax_pixel_x / ax_width) * (xlim[1] - xlim[0])
1684
+ data_y = ylim[1] - (ax_pixel_y / ax_height) * (ylim[1] - ylim[0])
1685
+
1686
+ # Find nearest trace
1687
+ df = self.csv_data
1688
+ min_dist = float('inf')
1689
+ nearest_idx = None
1690
+
1691
+ for i, trace in enumerate(traces):
1692
+ csv_cols = trace.get('csv_columns', {})
1693
+ x_col = csv_cols.get('x')
1694
+ y_col = csv_cols.get('y')
1695
+
1696
+ if x_col not in df.columns or y_col not in df.columns:
1697
+ continue
1698
+
1699
+ trace_x = df[x_col].dropna().values
1700
+ trace_y = df[y_col].dropna().values
1701
+
1702
+ if len(trace_x) == 0:
1703
+ continue
1704
+
1705
+ # Normalize coordinates for distance calculation
1706
+ x_range = xlim[1] - xlim[0]
1707
+ y_range = ylim[1] - ylim[0]
1708
+
1709
+ norm_click_x = (data_x - xlim[0]) / x_range if x_range > 0 else 0
1710
+ norm_click_y = (data_y - ylim[0]) / y_range if y_range > 0 else 0
1711
+
1712
+ norm_trace_x = (trace_x - xlim[0]) / x_range if x_range > 0 else trace_x
1713
+ norm_trace_y = (trace_y - ylim[0]) / y_range if y_range > 0 else trace_y
1714
+
1715
+ # Calculate distances to all points
1716
+ distances = np.sqrt((norm_trace_x - norm_click_x)**2 + (norm_trace_y - norm_click_y)**2)
1717
+ min_trace_dist = np.min(distances)
1718
+
1719
+ if min_trace_dist < min_dist:
1720
+ min_dist = min_trace_dist
1721
+ nearest_idx = i
1722
+
1723
+ # Only select if close enough (threshold in normalized space)
1724
+ if min_dist < 0.1: # 10% of plot area
1725
+ return nearest_idx
1726
+
1727
+ return None
1728
+
1729
+ def _on_trace_property_change(self, sender, app_data, user_data=None):
1730
+ """Handle changes to selected trace properties."""
1731
+ import dearpygui.dearpygui as dpg
1732
+
1733
+ if self._selected_trace_index is None:
1734
+ return
1735
+
1736
+ traces = self.current_overrides.get('traces', [])
1737
+ if self._selected_trace_index >= len(traces):
1738
+ return
1739
+
1740
+ trace = traces[self._selected_trace_index]
1741
+
1742
+ # Update trace properties from widgets
1743
+ trace['label'] = dpg.get_value("trace_label_input")
1744
+
1745
+ # Convert RGB to hex
1746
+ color_rgb = dpg.get_value("trace_color_picker")
1747
+ if color_rgb and len(color_rgb) >= 3:
1748
+ r, g, b = int(color_rgb[0]), int(color_rgb[1]), int(color_rgb[2])
1749
+ trace['color'] = f"#{r:02x}{g:02x}{b:02x}"
1750
+
1751
+ trace['linewidth'] = dpg.get_value("trace_linewidth_slider")
1752
+ trace['linestyle'] = dpg.get_value("trace_linestyle_combo")
1753
+
1754
+ marker = dpg.get_value("trace_marker_combo")
1755
+ trace['marker'] = marker if marker else None
1756
+
1757
+ trace['markersize'] = dpg.get_value("trace_markersize_slider")
1758
+
1759
+ self._user_modified = True
1760
+ self._update_preview(dpg)
1761
+
1762
+ def _save_manual(self, sender=None, app_data=None, user_data=None):
1763
+ """Save current overrides to .manual.json."""
1764
+ import dearpygui.dearpygui as dpg
1765
+ from ._edit import save_manual_overrides
1766
+
1767
+ try:
1768
+ self._collect_overrides(dpg)
1769
+ manual_path = save_manual_overrides(self.json_path, self.current_overrides)
1770
+ dpg.set_value("status_text", f"Saved: {manual_path.name}")
1771
+ except Exception as e:
1772
+ dpg.set_value("status_text", f"Error: {str(e)}")
1773
+
1774
+ def _reset_overrides(self, sender=None, app_data=None, user_data=None):
1775
+ """Reset to initial overrides."""
1776
+ import dearpygui.dearpygui as dpg
1777
+
1778
+ self.current_overrides = copy.deepcopy(self._initial_overrides)
1779
+ self._user_modified = False
1780
+
1781
+ # Update all widgets
1782
+ dpg.set_value("title_input", self.current_overrides.get('title', ''))
1783
+ dpg.set_value("xlabel_input", self.current_overrides.get('xlabel', ''))
1784
+ dpg.set_value("ylabel_input", self.current_overrides.get('ylabel', ''))
1785
+ dpg.set_value("linewidth_slider", self.current_overrides.get('linewidth', 1.0))
1786
+ dpg.set_value("grid_checkbox", self.current_overrides.get('grid', False))
1787
+ dpg.set_value("transparent_checkbox", self.current_overrides.get('transparent', True))
1788
+
1789
+ self._update_preview(dpg)
1790
+ dpg.set_value("status_text", "Reset to original")
1791
+
1792
+ def _export_png(self, sender=None, app_data=None, user_data=None):
1793
+ """Export current view to PNG."""
1794
+ import dearpygui.dearpygui as dpg
1795
+ import matplotlib
1796
+ matplotlib.use('Agg')
1797
+ import matplotlib.pyplot as plt
1798
+
1799
+ try:
1800
+ self._collect_overrides(dpg)
1801
+ output_path = self.json_path.with_suffix('.edited.png')
1802
+
1803
+ # Full resolution render
1804
+ o = self.current_overrides
1805
+ fig_size = o.get('fig_size', [3.15, 2.68])
1806
+ dpi = o.get('dpi', 300)
1807
+
1808
+ fig, ax = plt.subplots(figsize=fig_size, dpi=dpi)
1809
+
1810
+ if self.csv_data is not None:
1811
+ self._plot_from_csv(ax, o)
1812
+
1813
+ if o.get('title'):
1814
+ ax.set_title(o['title'], fontsize=o.get('title_fontsize', 8))
1815
+ if o.get('xlabel'):
1816
+ ax.set_xlabel(o['xlabel'], fontsize=o.get('axis_fontsize', 7))
1817
+ if o.get('ylabel'):
1818
+ ax.set_ylabel(o['ylabel'], fontsize=o.get('axis_fontsize', 7))
1819
+
1820
+ fig.tight_layout()
1821
+ fig.savefig(output_path, dpi=dpi, bbox_inches='tight',
1822
+ transparent=o.get('transparent', True))
1823
+ plt.close(fig)
1824
+
1825
+ dpg.set_value("status_text", f"Exported: {output_path.name}")
1826
+ except Exception as e:
1827
+ dpg.set_value("status_text", f"Error: {str(e)}")
1828
+
1829
+
1830
+ # EOF