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.
- scitex/__version__.py +1 -1
- scitex/browser/__init__.py +53 -0
- scitex/browser/debugging/__init__.py +56 -0
- scitex/browser/debugging/_failure_capture.py +372 -0
- scitex/browser/debugging/_sync_session.py +259 -0
- scitex/browser/debugging/_test_monitor.py +284 -0
- scitex/browser/debugging/_visual_cursor.py +432 -0
- scitex/io/_load.py +5 -0
- scitex/io/_load_modules/_canvas.py +171 -0
- scitex/io/_save.py +8 -0
- scitex/io/_save_modules/_canvas.py +356 -0
- scitex/plt/_subplots/_export_as_csv_formatters/_format_plot.py +77 -22
- scitex/plt/docs/FIGURE_ARCHITECTURE.md +257 -0
- scitex/plt/utils/__init__.py +10 -0
- scitex/plt/utils/_collect_figure_metadata.py +14 -12
- scitex/plt/utils/_csv_column_naming.py +237 -0
- scitex/scholar/citation_graph/database.py +9 -2
- scitex/scholar/config/ScholarConfig.py +23 -3
- scitex/scholar/config/default.yaml +55 -0
- scitex/scholar/core/Paper.py +102 -0
- scitex/scholar/core/__init__.py +44 -0
- scitex/scholar/core/journal_normalizer.py +524 -0
- scitex/scholar/core/oa_cache.py +285 -0
- scitex/scholar/core/open_access.py +457 -0
- scitex/scholar/pdf_download/ScholarPDFDownloader.py +137 -0
- scitex/scholar/pdf_download/strategies/__init__.py +6 -0
- scitex/scholar/pdf_download/strategies/open_access_download.py +186 -0
- scitex/scholar/pipelines/ScholarPipelineSearchParallel.py +18 -3
- scitex/scholar/pipelines/ScholarPipelineSearchSingle.py +15 -2
- scitex/session/_decorator.py +13 -1
- scitex/vis/README.md +246 -615
- scitex/vis/__init__.py +138 -78
- scitex/vis/canvas.py +423 -0
- scitex/vis/docs/CANVAS_ARCHITECTURE.md +307 -0
- scitex/vis/editor/__init__.py +1 -1
- scitex/vis/editor/_dearpygui_editor.py +1830 -0
- scitex/vis/editor/_defaults.py +40 -1
- scitex/vis/editor/_edit.py +54 -18
- scitex/vis/editor/_flask_editor.py +37 -0
- scitex/vis/editor/_qt_editor.py +865 -0
- scitex/vis/editor/flask_editor/__init__.py +21 -0
- scitex/vis/editor/flask_editor/bbox.py +216 -0
- scitex/vis/editor/flask_editor/core.py +152 -0
- scitex/vis/editor/flask_editor/plotter.py +130 -0
- scitex/vis/editor/flask_editor/renderer.py +184 -0
- scitex/vis/editor/flask_editor/templates/__init__.py +33 -0
- scitex/vis/editor/flask_editor/templates/html.py +295 -0
- scitex/vis/editor/flask_editor/templates/scripts.py +614 -0
- scitex/vis/editor/flask_editor/templates/styles.py +549 -0
- scitex/vis/editor/flask_editor/utils.py +81 -0
- scitex/vis/io/__init__.py +84 -21
- scitex/vis/io/canvas.py +226 -0
- scitex/vis/io/data.py +204 -0
- scitex/vis/io/directory.py +202 -0
- scitex/vis/io/export.py +460 -0
- scitex/vis/io/panel.py +424 -0
- {scitex-2.4.2.dist-info → scitex-2.5.0.dist-info}/METADATA +9 -2
- {scitex-2.4.2.dist-info → scitex-2.5.0.dist-info}/RECORD +61 -32
- scitex/vis/DJANGO_INTEGRATION.md +0 -677
- scitex/vis/editor/_web_editor.py +0 -1440
- scitex/vis/tmp.txt +0 -239
- {scitex-2.4.2.dist-info → scitex-2.5.0.dist-info}/WHEEL +0 -0
- {scitex-2.4.2.dist-info → scitex-2.5.0.dist-info}/entry_points.txt +0 -0
- {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
|