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,21 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # File: ./src/scitex/vis/editor/flask_editor/__init__.py
4
+ """Flask-based web editor for SciTeX figures."""
5
+
6
+ from .core import WebEditor
7
+ from .utils import find_available_port, kill_process_on_port, check_port_available
8
+ from .renderer import render_preview_with_bboxes
9
+ from .plotter import plot_from_csv
10
+
11
+ __all__ = [
12
+ 'WebEditor',
13
+ 'find_available_port',
14
+ 'kill_process_on_port',
15
+ 'check_port_available',
16
+ 'render_preview_with_bboxes',
17
+ 'plot_from_csv',
18
+ ]
19
+
20
+
21
+ # EOF
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # File: ./src/scitex/vis/editor/flask_editor/bbox.py
4
+ """Bounding box extraction for figure elements."""
5
+
6
+ from typing import Dict, Any
7
+
8
+
9
+ def extract_bboxes(fig, ax, renderer, img_width: int, img_height: int) -> Dict[str, Any]:
10
+ """Extract bounding boxes for all figure elements."""
11
+ from matplotlib.transforms import Bbox
12
+
13
+ # Get figure tight bbox in inches
14
+ fig_bbox = fig.get_tightbbox(renderer)
15
+ tight_x0 = fig_bbox.x0
16
+ tight_y0 = fig_bbox.y0
17
+ tight_width = fig_bbox.width
18
+ tight_height = fig_bbox.height
19
+
20
+ # bbox_inches='tight' adds pad_inches (default 0.1) around the tight bbox
21
+ pad_inches = 0.1
22
+ saved_width_inches = tight_width + 2 * pad_inches
23
+ saved_height_inches = tight_height + 2 * pad_inches
24
+
25
+ # Scale factors for converting inches to pixels
26
+ scale_x = img_width / saved_width_inches
27
+ scale_y = img_height / saved_height_inches
28
+
29
+ bboxes = {}
30
+
31
+ def get_element_bbox(element, name):
32
+ """Get element bbox in image pixel coordinates."""
33
+ try:
34
+ bbox = element.get_window_extent(renderer)
35
+
36
+ elem_x0_inches = bbox.x0 / fig.dpi
37
+ elem_x1_inches = bbox.x1 / fig.dpi
38
+ elem_y0_inches = bbox.y0 / fig.dpi
39
+ elem_y1_inches = bbox.y1 / fig.dpi
40
+
41
+ x0_rel = elem_x0_inches - tight_x0 + pad_inches
42
+ x1_rel = elem_x1_inches - tight_x0 + pad_inches
43
+ y0_rel = saved_height_inches - (elem_y1_inches - tight_y0 + pad_inches)
44
+ y1_rel = saved_height_inches - (elem_y0_inches - tight_y0 + pad_inches)
45
+
46
+ bboxes[name] = {
47
+ 'x0': max(0, int(x0_rel * scale_x)),
48
+ 'y0': max(0, int(y0_rel * scale_y)),
49
+ 'x1': min(img_width, int(x1_rel * scale_x)),
50
+ 'y1': min(img_height, int(y1_rel * scale_y)),
51
+ 'label': name.replace('_', ' ').title()
52
+ }
53
+ except Exception as e:
54
+ print(f"Error getting bbox for {name}: {e}")
55
+
56
+ def bbox_to_img_coords(bbox):
57
+ """Convert matplotlib bbox to image pixel coordinates."""
58
+ x0_inches = bbox.x0 / fig.dpi
59
+ y0_inches = bbox.y0 / fig.dpi
60
+ x1_inches = bbox.x1 / fig.dpi
61
+ y1_inches = bbox.y1 / fig.dpi
62
+ x0_rel = x0_inches - tight_x0 + pad_inches
63
+ y0_rel = y0_inches - tight_y0 + pad_inches
64
+ x1_rel = x1_inches - tight_x0 + pad_inches
65
+ y1_rel = y1_inches - tight_y0 + pad_inches
66
+ return {
67
+ 'x0': int(x0_rel * scale_x),
68
+ 'y0': int((saved_height_inches - y1_rel) * scale_y),
69
+ 'x1': int(x1_rel * scale_x),
70
+ 'y1': int((saved_height_inches - y0_rel) * scale_y),
71
+ }
72
+
73
+ # Get bboxes for title, labels
74
+ if ax.title.get_text():
75
+ get_element_bbox(ax.title, 'title')
76
+ if ax.xaxis.label.get_text():
77
+ get_element_bbox(ax.xaxis.label, 'xlabel')
78
+ if ax.yaxis.label.get_text():
79
+ get_element_bbox(ax.yaxis.label, 'ylabel')
80
+
81
+ # Get axis bboxes
82
+ _extract_axis_bboxes(ax, renderer, bboxes, bbox_to_img_coords, Bbox)
83
+
84
+ # Get legend bbox
85
+ legend = ax.get_legend()
86
+ if legend:
87
+ get_element_bbox(legend, 'legend')
88
+
89
+ # Get trace (line) bboxes
90
+ _extract_trace_bboxes(ax, fig, renderer, bboxes, get_element_bbox,
91
+ tight_x0, tight_y0, saved_height_inches, scale_x, scale_y, pad_inches)
92
+
93
+ return bboxes
94
+
95
+
96
+ def _extract_axis_bboxes(ax, renderer, bboxes, bbox_to_img_coords, Bbox):
97
+ """Extract bboxes for X and Y axis elements."""
98
+ try:
99
+ # X-axis: combine spine and tick labels into one bbox
100
+ x_axis_bboxes = []
101
+ for ticklabel in ax.xaxis.get_ticklabels():
102
+ if ticklabel.get_visible():
103
+ try:
104
+ tb = ticklabel.get_window_extent(renderer)
105
+ if tb.width > 0:
106
+ x_axis_bboxes.append(tb)
107
+ except Exception:
108
+ pass
109
+ for tick in ax.xaxis.get_major_ticks():
110
+ if tick.tick1line.get_visible():
111
+ try:
112
+ tb = tick.tick1line.get_window_extent(renderer)
113
+ if tb.width > 0 or tb.height > 0:
114
+ x_axis_bboxes.append(tb)
115
+ except Exception:
116
+ pass
117
+ spine_bbox = ax.spines['bottom'].get_window_extent(renderer)
118
+ if spine_bbox.width > 0:
119
+ if x_axis_bboxes:
120
+ tick_union = Bbox.union(x_axis_bboxes)
121
+ constrained_spine = Bbox.from_extents(
122
+ tick_union.x0, spine_bbox.y0,
123
+ tick_union.x1, spine_bbox.y1
124
+ )
125
+ x_axis_bboxes.append(constrained_spine)
126
+ else:
127
+ x_axis_bboxes.append(spine_bbox)
128
+ if x_axis_bboxes:
129
+ combined = Bbox.union(x_axis_bboxes)
130
+ bboxes['xaxis_ticks'] = bbox_to_img_coords(combined)
131
+ bboxes['xaxis_ticks']['label'] = 'X Spine & Ticks'
132
+
133
+ # Y-axis: combine spine and tick labels into one bbox
134
+ y_axis_bboxes = []
135
+ for ticklabel in ax.yaxis.get_ticklabels():
136
+ if ticklabel.get_visible():
137
+ try:
138
+ tb = ticklabel.get_window_extent(renderer)
139
+ if tb.width > 0:
140
+ y_axis_bboxes.append(tb)
141
+ except Exception:
142
+ pass
143
+ for tick in ax.yaxis.get_major_ticks():
144
+ if tick.tick1line.get_visible():
145
+ try:
146
+ tb = tick.tick1line.get_window_extent(renderer)
147
+ if tb.width > 0 or tb.height > 0:
148
+ y_axis_bboxes.append(tb)
149
+ except Exception:
150
+ pass
151
+ spine_bbox = ax.spines['left'].get_window_extent(renderer)
152
+ if spine_bbox.height > 0:
153
+ if y_axis_bboxes:
154
+ tick_union = Bbox.union(y_axis_bboxes)
155
+ constrained_spine = Bbox.from_extents(
156
+ spine_bbox.x0, tick_union.y0,
157
+ spine_bbox.x1, tick_union.y1
158
+ )
159
+ y_axis_bboxes.append(constrained_spine)
160
+ else:
161
+ y_axis_bboxes.append(spine_bbox)
162
+ if y_axis_bboxes:
163
+ combined = Bbox.union(y_axis_bboxes)
164
+ padded = Bbox.from_extents(
165
+ combined.x0 - 10,
166
+ combined.y0 - 5,
167
+ combined.x1 + 5,
168
+ combined.y1 + 5
169
+ )
170
+ bboxes['yaxis_ticks'] = bbox_to_img_coords(padded)
171
+ bboxes['yaxis_ticks']['label'] = 'Y Spine & Ticks'
172
+
173
+ except Exception as e:
174
+ print(f"Error getting axis bboxes: {e}")
175
+
176
+
177
+ def _extract_trace_bboxes(ax, fig, renderer, bboxes, get_element_bbox,
178
+ tight_x0, tight_y0, saved_height_inches, scale_x, scale_y, pad_inches):
179
+ """Extract bboxes for trace (line) elements with proximity detection points."""
180
+ for idx, line in enumerate(ax.get_lines()):
181
+ try:
182
+ label = line.get_label()
183
+ if label.startswith('_'):
184
+ continue
185
+ get_element_bbox(line, f'trace_{idx}')
186
+ if f'trace_{idx}' in bboxes:
187
+ bboxes[f'trace_{idx}']['label'] = label or f'Trace {idx}'
188
+ bboxes[f'trace_{idx}']['trace_idx'] = idx
189
+
190
+ # Get line data points in pixel coordinates for proximity detection
191
+ xdata, ydata = line.get_xdata(), line.get_ydata()
192
+ if len(xdata) > 0:
193
+ transform = ax.transData
194
+ points_display = transform.transform(list(zip(xdata, ydata)))
195
+
196
+ points_img = []
197
+ for px, py in points_display:
198
+ px_inches = px / fig.dpi
199
+ py_inches = py / fig.dpi
200
+ x_rel = px_inches - tight_x0 + pad_inches
201
+ y_rel = saved_height_inches - (py_inches - tight_y0 + pad_inches)
202
+ x_img = int(x_rel * scale_x)
203
+ y_img = int(y_rel * scale_y)
204
+ points_img.append([x_img, y_img])
205
+
206
+ # Downsample points if too many
207
+ if len(points_img) > 100:
208
+ step = len(points_img) // 100
209
+ points_img = points_img[::step]
210
+
211
+ bboxes[f'trace_{idx}']['points'] = points_img
212
+ except Exception as e:
213
+ print(f"Error getting trace bbox: {e}")
214
+
215
+
216
+ # EOF
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # File: ./src/scitex/vis/editor/flask_editor/core.py
4
+ """Core WebEditor class for Flask-based figure editing."""
5
+
6
+ from pathlib import Path
7
+ from typing import Dict, Any, Optional
8
+ import copy
9
+ import json
10
+ import threading
11
+ import webbrowser
12
+
13
+ from .utils import find_available_port, kill_process_on_port, check_port_available
14
+ from .renderer import render_preview_with_bboxes
15
+ from .templates import build_html_template
16
+
17
+
18
+ class WebEditor:
19
+ """
20
+ Browser-based figure editor using Flask.
21
+
22
+ Features:
23
+ - Modern responsive UI
24
+ - Real-time preview via WebSocket or polling
25
+ - Property editors with sliders and color pickers
26
+ - Save to .manual.json
27
+ - SciTeX style defaults pre-filled
28
+ - Auto-finds available port if default is in use
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ json_path: Path,
34
+ metadata: Dict[str, Any],
35
+ csv_data: Optional[Any] = None,
36
+ png_path: Optional[Path] = None,
37
+ manual_overrides: Optional[Dict[str, Any]] = None,
38
+ port: int = 5050,
39
+ ):
40
+ self.json_path = Path(json_path)
41
+ self.metadata = metadata
42
+ self.csv_data = csv_data
43
+ self.png_path = Path(png_path) if png_path else None
44
+ self.manual_overrides = manual_overrides or {}
45
+ self._requested_port = port
46
+ self.port = port
47
+
48
+ # Get SciTeX defaults and merge with metadata
49
+ from .._defaults import get_scitex_defaults, extract_defaults_from_metadata
50
+ self.scitex_defaults = get_scitex_defaults()
51
+ self.metadata_defaults = extract_defaults_from_metadata(metadata)
52
+
53
+ # Start with defaults, then overlay manual overrides
54
+ self.current_overrides = copy.deepcopy(self.scitex_defaults)
55
+ self.current_overrides.update(self.metadata_defaults)
56
+ self.current_overrides.update(self.manual_overrides)
57
+
58
+ # Track initial state to detect modifications
59
+ self._initial_overrides = copy.deepcopy(self.current_overrides)
60
+ self._user_modified = False
61
+
62
+ def run(self):
63
+ """Launch the web editor."""
64
+ try:
65
+ from flask import Flask, render_template_string, request, jsonify
66
+ except ImportError:
67
+ raise ImportError("Flask is required for web editor. Install: pip install flask")
68
+
69
+ # Handle port conflicts
70
+ if not check_port_available(self._requested_port):
71
+ print(f"Port {self._requested_port} is in use. Attempting to free it...")
72
+ if kill_process_on_port(self._requested_port):
73
+ import time
74
+ time.sleep(0.5)
75
+ self.port = self._requested_port
76
+ print(f"Successfully freed port {self.port}")
77
+ else:
78
+ self.port = find_available_port(self._requested_port + 1)
79
+ print(f"Using alternative port: {self.port}")
80
+ else:
81
+ self.port = self._requested_port
82
+
83
+ app = Flask(__name__)
84
+ editor = self
85
+ html_template = build_html_template()
86
+
87
+ @app.route('/')
88
+ def index():
89
+ return render_template_string(
90
+ html_template,
91
+ filename=str(editor.json_path.resolve()),
92
+ overrides=json.dumps(editor.current_overrides)
93
+ )
94
+
95
+ @app.route('/preview')
96
+ def preview():
97
+ """Generate figure preview as base64 PNG with element bboxes."""
98
+ img_data, bboxes, img_size = render_preview_with_bboxes(
99
+ editor.csv_data, editor.current_overrides
100
+ )
101
+ return jsonify({'image': img_data, 'bboxes': bboxes, 'img_size': img_size})
102
+
103
+ @app.route('/update', methods=['POST'])
104
+ def update():
105
+ """Update overrides and return new preview."""
106
+ data = request.json
107
+ editor.current_overrides.update(data.get('overrides', {}))
108
+ editor._user_modified = True
109
+ img_data, bboxes, img_size = render_preview_with_bboxes(
110
+ editor.csv_data, editor.current_overrides
111
+ )
112
+ return jsonify({
113
+ 'image': img_data,
114
+ 'bboxes': bboxes,
115
+ 'img_size': img_size,
116
+ 'status': 'updated'
117
+ })
118
+
119
+ @app.route('/save', methods=['POST'])
120
+ def save():
121
+ """Save to .manual.json."""
122
+ from .._edit import save_manual_overrides
123
+ try:
124
+ manual_path = save_manual_overrides(editor.json_path, editor.current_overrides)
125
+ return jsonify({'status': 'saved', 'path': str(manual_path)})
126
+ except Exception as e:
127
+ return jsonify({'status': 'error', 'message': str(e)}), 500
128
+
129
+ @app.route('/shutdown', methods=['POST'])
130
+ def shutdown():
131
+ """Shutdown the server."""
132
+ func = request.environ.get('werkzeug.server.shutdown')
133
+ if func is None:
134
+ raise RuntimeError('Not running with Werkzeug Server')
135
+ func()
136
+ return jsonify({'status': 'shutdown'})
137
+
138
+ # Open browser after short delay
139
+ def open_browser():
140
+ import time
141
+ time.sleep(0.5)
142
+ webbrowser.open(f'http://127.0.0.1:{self.port}')
143
+
144
+ threading.Thread(target=open_browser, daemon=True).start()
145
+
146
+ print(f"Starting SciTeX Editor at http://127.0.0.1:{self.port}")
147
+ print("Press Ctrl+C to stop")
148
+
149
+ app.run(host='127.0.0.1', port=self.port, debug=False, use_reloader=False)
150
+
151
+
152
+ # EOF
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # File: ./src/scitex/vis/editor/flask_editor/plotter.py
4
+ """CSV plotting functionality for Flask editor."""
5
+
6
+ from typing import Dict, Any, Optional
7
+ import pandas as pd
8
+
9
+
10
+ def plot_from_csv(ax, csv_data: Optional[pd.DataFrame], overrides: Dict[str, Any], linewidth: float = 1.0):
11
+ """Reconstruct plot from CSV data using trace info from overrides.
12
+
13
+ Args:
14
+ ax: Matplotlib axes object
15
+ csv_data: DataFrame containing CSV data
16
+ overrides: Dictionary with override settings including traces
17
+ linewidth: Default line width in points
18
+ """
19
+ if csv_data is None or not isinstance(csv_data, pd.DataFrame):
20
+ return
21
+
22
+ df = csv_data
23
+ o = overrides
24
+
25
+ # Get legend settings from overrides
26
+ legend_fontsize = o.get('legend_fontsize', 6)
27
+ legend_visible = o.get('legend_visible', True)
28
+ legend_frameon = o.get('legend_frameon', False)
29
+ legend_loc = o.get('legend_loc', 'best')
30
+ legend_x = o.get('legend_x', 0.5)
31
+ legend_y = o.get('legend_y', 0.5)
32
+
33
+ # Get traces from overrides (which may have been edited by user)
34
+ traces = o.get('traces', [])
35
+
36
+ if traces:
37
+ _plot_with_traces(ax, df, traces, linewidth, legend_fontsize, legend_visible,
38
+ legend_frameon, legend_loc, legend_x, legend_y)
39
+ else:
40
+ _plot_fallback(ax, df, linewidth, legend_fontsize, legend_visible,
41
+ legend_frameon, legend_loc, legend_x, legend_y)
42
+
43
+
44
+ def _plot_with_traces(ax, df, traces, linewidth, legend_fontsize, legend_visible,
45
+ legend_frameon, legend_loc, legend_x, legend_y):
46
+ """Plot using trace information from overrides."""
47
+ for trace in traces:
48
+ csv_cols = trace.get('csv_columns', {})
49
+ x_col = csv_cols.get('x')
50
+ y_col = csv_cols.get('y')
51
+
52
+ if x_col in df.columns and y_col in df.columns:
53
+ ax.plot(
54
+ df[x_col],
55
+ df[y_col],
56
+ label=trace.get('label', trace.get('id', '')),
57
+ color=trace.get('color'),
58
+ linestyle=trace.get('linestyle', '-'),
59
+ linewidth=trace.get('linewidth', linewidth),
60
+ marker=trace.get('marker', None),
61
+ markersize=trace.get('markersize', 6),
62
+ )
63
+
64
+ # Add legend if there are labeled traces
65
+ if legend_visible and any(t.get('label') for t in traces):
66
+ _add_legend(ax, legend_fontsize, legend_frameon, legend_loc, legend_x, legend_y)
67
+
68
+
69
+ def _plot_fallback(ax, df, linewidth, legend_fontsize, legend_visible,
70
+ legend_frameon, legend_loc, legend_x, legend_y):
71
+ """Fallback plotting when no trace info available - smart parsing of column names."""
72
+ cols = df.columns.tolist()
73
+
74
+ # Group columns by trace ID
75
+ trace_groups = {}
76
+ for col in cols:
77
+ if col.endswith('_x'):
78
+ trace_id = col[:-2] # Remove '_x'
79
+ y_col = trace_id + '_y'
80
+ if y_col in cols:
81
+ # Extract label from column name (e.g., ax_00_sine_plot -> sine)
82
+ parts = trace_id.split('_')
83
+ label = parts[2] if len(parts) > 2 else trace_id
84
+ trace_groups[trace_id] = {
85
+ 'x_col': col,
86
+ 'y_col': y_col,
87
+ 'label': label,
88
+ }
89
+
90
+ if trace_groups:
91
+ for trace_id, info in trace_groups.items():
92
+ ax.plot(
93
+ df[info['x_col']],
94
+ df[info['y_col']],
95
+ label=info['label'],
96
+ linewidth=linewidth,
97
+ )
98
+ if legend_visible:
99
+ _add_legend(ax, legend_fontsize, legend_frameon, legend_loc, legend_x, legend_y)
100
+
101
+ elif len(cols) >= 2:
102
+ # Last resort: assume first column is x, rest are y
103
+ x_col = cols[0]
104
+ for y_col in cols[1:]:
105
+ try:
106
+ ax.plot(df[x_col], df[y_col], label=str(y_col), linewidth=linewidth)
107
+ except Exception:
108
+ pass
109
+ if len(cols) > 2 and legend_visible:
110
+ _add_legend(ax, legend_fontsize, legend_frameon, legend_loc, legend_x, legend_y)
111
+
112
+
113
+ def _add_legend(ax, fontsize, frameon, loc, x, y):
114
+ """Add legend to axes with specified settings."""
115
+ if loc == 'custom':
116
+ ax.legend(
117
+ fontsize=fontsize,
118
+ frameon=frameon,
119
+ loc='upper left',
120
+ bbox_to_anchor=(x, y),
121
+ )
122
+ else:
123
+ ax.legend(
124
+ fontsize=fontsize,
125
+ frameon=frameon,
126
+ loc=loc,
127
+ )
128
+
129
+
130
+ # EOF