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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. figrecipe/__init__.py +74 -76
  2. figrecipe/__main__.py +12 -0
  3. figrecipe/_api/_panel.py +67 -0
  4. figrecipe/_api/_save.py +100 -4
  5. figrecipe/_cli/__init__.py +7 -0
  6. figrecipe/_cli/_compose.py +87 -0
  7. figrecipe/_cli/_convert.py +117 -0
  8. figrecipe/_cli/_crop.py +82 -0
  9. figrecipe/_cli/_edit.py +70 -0
  10. figrecipe/_cli/_extract.py +128 -0
  11. figrecipe/_cli/_fonts.py +47 -0
  12. figrecipe/_cli/_info.py +67 -0
  13. figrecipe/_cli/_main.py +58 -0
  14. figrecipe/_cli/_reproduce.py +79 -0
  15. figrecipe/_cli/_style.py +77 -0
  16. figrecipe/_cli/_validate.py +66 -0
  17. figrecipe/_cli/_version.py +50 -0
  18. figrecipe/_composition/__init__.py +32 -0
  19. figrecipe/_composition/_alignment.py +452 -0
  20. figrecipe/_composition/_compose.py +179 -0
  21. figrecipe/_composition/_import_axes.py +127 -0
  22. figrecipe/_composition/_visibility.py +125 -0
  23. figrecipe/_dev/__init__.py +2 -0
  24. figrecipe/_dev/browser/__init__.py +69 -0
  25. figrecipe/_dev/browser/_audio.py +240 -0
  26. figrecipe/_dev/browser/_caption.py +356 -0
  27. figrecipe/_dev/browser/_click_effect.py +146 -0
  28. figrecipe/_dev/browser/_cursor.py +196 -0
  29. figrecipe/_dev/browser/_highlight.py +105 -0
  30. figrecipe/_dev/browser/_narration.py +237 -0
  31. figrecipe/_dev/browser/_recorder.py +446 -0
  32. figrecipe/_dev/browser/_utils.py +178 -0
  33. figrecipe/_dev/browser/_video_trim/__init__.py +152 -0
  34. figrecipe/_dev/browser/_video_trim/_detection.py +223 -0
  35. figrecipe/_dev/browser/_video_trim/_markers.py +140 -0
  36. figrecipe/_editor/__init__.py +36 -36
  37. figrecipe/_editor/_bbox/_extract.py +155 -9
  38. figrecipe/_editor/_bbox/_extract_text.py +124 -0
  39. figrecipe/_editor/_call_overrides.py +183 -0
  40. figrecipe/_editor/_datatable_plot_handlers.py +249 -0
  41. figrecipe/_editor/_figure_layout.py +211 -0
  42. figrecipe/_editor/_flask_app.py +157 -16
  43. figrecipe/_editor/_helpers.py +17 -8
  44. figrecipe/_editor/_hitmap/_detect.py +89 -32
  45. figrecipe/_editor/_hitmap_main.py +4 -4
  46. figrecipe/_editor/_overrides.py +4 -1
  47. figrecipe/_editor/_plot_types_registry.py +190 -0
  48. figrecipe/_editor/_render_overrides.py +38 -11
  49. figrecipe/_editor/_renderer.py +46 -1
  50. figrecipe/_editor/_routes_annotation.py +114 -0
  51. figrecipe/_editor/_routes_axis.py +35 -6
  52. figrecipe/_editor/_routes_captions.py +130 -0
  53. figrecipe/_editor/_routes_composition.py +270 -0
  54. figrecipe/_editor/_routes_core.py +15 -173
  55. figrecipe/_editor/_routes_datatable.py +364 -0
  56. figrecipe/_editor/_routes_element.py +37 -19
  57. figrecipe/_editor/_routes_files.py +443 -0
  58. figrecipe/_editor/_routes_image.py +200 -0
  59. figrecipe/_editor/_routes_snapshot.py +94 -0
  60. figrecipe/_editor/_routes_style.py +28 -8
  61. figrecipe/_editor/_templates/__init__.py +40 -2
  62. figrecipe/_editor/_templates/_html.py +97 -103
  63. figrecipe/_editor/_templates/_html_components/__init__.py +13 -0
  64. figrecipe/_editor/_templates/_html_components/_composition_toolbar.py +79 -0
  65. figrecipe/_editor/_templates/_html_components/_file_browser.py +41 -0
  66. figrecipe/_editor/_templates/_html_datatable.py +92 -0
  67. figrecipe/_editor/_templates/_scripts/__init__.py +58 -0
  68. figrecipe/_editor/_templates/_scripts/_accordion.py +328 -0
  69. figrecipe/_editor/_templates/_scripts/_annotation_drag.py +504 -0
  70. figrecipe/_editor/_templates/_scripts/_api.py +1 -1
  71. figrecipe/_editor/_templates/_scripts/_canvas_context_menu.py +182 -0
  72. figrecipe/_editor/_templates/_scripts/_captions.py +231 -0
  73. figrecipe/_editor/_templates/_scripts/_composition.py +283 -0
  74. figrecipe/_editor/_templates/_scripts/_core.py +94 -37
  75. figrecipe/_editor/_templates/_scripts/_datatable/__init__.py +59 -0
  76. figrecipe/_editor/_templates/_scripts/_datatable/_cell_edit.py +97 -0
  77. figrecipe/_editor/_templates/_scripts/_datatable/_clipboard.py +164 -0
  78. figrecipe/_editor/_templates/_scripts/_datatable/_context_menu.py +221 -0
  79. figrecipe/_editor/_templates/_scripts/_datatable/_core.py +150 -0
  80. figrecipe/_editor/_templates/_scripts/_datatable/_editable.py +511 -0
  81. figrecipe/_editor/_templates/_scripts/_datatable/_import.py +161 -0
  82. figrecipe/_editor/_templates/_scripts/_datatable/_plot.py +261 -0
  83. figrecipe/_editor/_templates/_scripts/_datatable/_selection.py +438 -0
  84. figrecipe/_editor/_templates/_scripts/_datatable/_table.py +256 -0
  85. figrecipe/_editor/_templates/_scripts/_datatable/_tabs.py +354 -0
  86. figrecipe/_editor/_templates/_scripts/_element_editor.py +17 -2
  87. figrecipe/_editor/_templates/_scripts/_files.py +274 -40
  88. figrecipe/_editor/_templates/_scripts/_files_context_menu.py +240 -0
  89. figrecipe/_editor/_templates/_scripts/_hitmap.py +87 -84
  90. figrecipe/_editor/_templates/_scripts/_image_drop.py +428 -0
  91. figrecipe/_editor/_templates/_scripts/_legend_drag.py +5 -0
  92. figrecipe/_editor/_templates/_scripts/_multi_select.py +198 -0
  93. figrecipe/_editor/_templates/_scripts/_panel_drag.py +219 -48
  94. figrecipe/_editor/_templates/_scripts/_panel_drag_snapshot.py +33 -0
  95. figrecipe/_editor/_templates/_scripts/_panel_position.py +238 -54
  96. figrecipe/_editor/_templates/_scripts/_panel_resize.py +230 -0
  97. figrecipe/_editor/_templates/_scripts/_panel_snap.py +307 -0
  98. figrecipe/_editor/_templates/_scripts/_region_select.py +255 -0
  99. figrecipe/_editor/_templates/_scripts/_selection.py +8 -1
  100. figrecipe/_editor/_templates/_scripts/_sync.py +242 -0
  101. figrecipe/_editor/_templates/_scripts/_undo_redo.py +348 -0
  102. figrecipe/_editor/_templates/_scripts/_zoom.py +52 -19
  103. figrecipe/_editor/_templates/_styles/__init__.py +9 -0
  104. figrecipe/_editor/_templates/_styles/_base.py +47 -0
  105. figrecipe/_editor/_templates/_styles/_buttons.py +127 -6
  106. figrecipe/_editor/_templates/_styles/_composition.py +87 -0
  107. figrecipe/_editor/_templates/_styles/_controls.py +168 -3
  108. figrecipe/_editor/_templates/_styles/_datatable/__init__.py +40 -0
  109. figrecipe/_editor/_templates/_styles/_datatable/_editable.py +203 -0
  110. figrecipe/_editor/_templates/_styles/_datatable/_panel.py +268 -0
  111. figrecipe/_editor/_templates/_styles/_datatable/_table.py +479 -0
  112. figrecipe/_editor/_templates/_styles/_datatable/_toolbar.py +384 -0
  113. figrecipe/_editor/_templates/_styles/_datatable/_vars.py +123 -0
  114. figrecipe/_editor/_templates/_styles/_dynamic_props.py +5 -5
  115. figrecipe/_editor/_templates/_styles/_file_browser.py +466 -0
  116. figrecipe/_editor/_templates/_styles/_forms.py +98 -0
  117. figrecipe/_editor/_templates/_styles/_hitmap.py +7 -0
  118. figrecipe/_editor/_templates/_styles/_modals.py +29 -0
  119. figrecipe/_editor/_templates/_styles/_overlays.py +5 -5
  120. figrecipe/_editor/_templates/_styles/_preview.py +213 -8
  121. figrecipe/_editor/_templates/_styles/_spinner.py +117 -0
  122. figrecipe/_editor/static/audio/click.mp3 +0 -0
  123. figrecipe/_editor/static/click.mp3 +0 -0
  124. figrecipe/_editor/static/icons/favicon.ico +0 -0
  125. figrecipe/_integrations/__init__.py +17 -0
  126. figrecipe/_integrations/_scitex_stats.py +298 -0
  127. figrecipe/_params/_DECORATION_METHODS.py +2 -0
  128. figrecipe/_recorder.py +28 -3
  129. figrecipe/_reproducer/_core.py +60 -49
  130. figrecipe/_utils/__init__.py +3 -0
  131. figrecipe/_utils/_bundle.py +205 -0
  132. figrecipe/_wrappers/_axes.py +150 -2
  133. figrecipe/_wrappers/_caption_generator.py +218 -0
  134. figrecipe/_wrappers/_figure.py +26 -1
  135. figrecipe/_wrappers/_stat_annotation.py +274 -0
  136. figrecipe/styles/_style_applier.py +10 -2
  137. figrecipe/styles/presets/SCITEX.yaml +11 -4
  138. {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/METADATA +144 -146
  139. figrecipe-0.9.0.dist-info/RECORD +277 -0
  140. figrecipe-0.9.0.dist-info/entry_points.txt +2 -0
  141. figrecipe-0.7.4.dist-info/RECORD +0 -188
  142. {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/WHEEL +0 -0
  143. {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,256 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Datatable table rendering JavaScript with smart cell truncation."""
4
+
5
+ JS_DATATABLE_TABLE = """
6
+ // ============================================================================
7
+ // Render Datatable (with span-wrapped cells for smart truncation)
8
+ // ============================================================================
9
+ function renderDatatable() {
10
+ const content = document.getElementById('datatable-content');
11
+ if (!content || !datatableData) return;
12
+
13
+ const { columns, rows } = datatableData;
14
+
15
+ // Build table HTML with span-wrapped content (vis_app pattern)
16
+ let html = '<table class="datatable-table" tabindex="0">';
17
+
18
+ // Header row
19
+ html += '<thead><tr>';
20
+ html += '<th class="row-num">#</th>';
21
+ columns.forEach((col, idx) => {
22
+ const isSelected = datatableSelectedColumns.has(idx);
23
+ html += `<th class="${isSelected ? 'selected' : ''}" data-col="${idx}">
24
+ <div class="datatable-col-header">
25
+ <input type="checkbox"
26
+ data-col-idx="${idx}"
27
+ ${isSelected ? 'checked' : ''}
28
+ onchange="toggleColumnSelection(${idx})">
29
+ <span class="col-name" title="${col.name}">${col.name}</span>
30
+ <span class="col-type">${col.type === 'numeric' ? 'N' : 'S'}</span>
31
+ </div>
32
+ </th>`;
33
+ });
34
+ html += '</tr></thead>';
35
+
36
+ // Data rows (limit to first 100 for performance)
37
+ html += '<tbody>';
38
+ const maxRows = Math.min(rows.length, 100);
39
+ for (let i = 0; i < maxRows; i++) {
40
+ html += `<tr data-row-idx="${i}">`;
41
+ html += `<td class="row-num">${i + 1}</td>`;
42
+ columns.forEach((col, colIdx) => {
43
+ const value = rows[i][colIdx];
44
+ const displayValue = value === null || value === undefined ? '' : value;
45
+ // Wrap in span for smart truncation without interfering with editing
46
+ html += `<td data-row="${i}" data-col="${colIdx}" tabindex="0" title="${displayValue}">
47
+ <span class="cell-text">${displayValue}</span>
48
+ </td>`;
49
+ });
50
+ html += '</tr>';
51
+ }
52
+ html += '</tbody></table>';
53
+
54
+ content.innerHTML = html;
55
+
56
+ // Attach cell event listeners for selection/editing/clipboard
57
+ if (typeof attachCellEventListeners === 'function') {
58
+ attachCellEventListeners();
59
+ }
60
+
61
+ // Update selection info
62
+ updateSelectionInfo();
63
+
64
+ // Hide dropzone, show content
65
+ const dropzone = document.getElementById('datatable-dropzone');
66
+ if (dropzone) dropzone.style.display = 'none';
67
+ const toolbar = document.querySelector('.datatable-toolbar');
68
+ if (toolbar) toolbar.style.display = 'flex';
69
+ }
70
+
71
+ // ============================================================================
72
+ // Column Selection (for backward compatibility)
73
+ // ============================================================================
74
+ function toggleColumnSelection(colIdx) {
75
+ if (datatableSelectedColumns.has(colIdx)) {
76
+ datatableSelectedColumns.delete(colIdx);
77
+ } else {
78
+ datatableSelectedColumns.add(colIdx);
79
+ }
80
+
81
+ const isSelected = datatableSelectedColumns.has(colIdx);
82
+
83
+ // Update header styling
84
+ const th = document.querySelector(`th:has(input[data-col-idx="${colIdx}"])`);
85
+ if (th) {
86
+ th.classList.toggle('selected', isSelected);
87
+ }
88
+
89
+ // Update entire column cells highlighting
90
+ document.querySelectorAll(`td[data-col="${colIdx}"]`).forEach(td => {
91
+ td.classList.toggle('col-selected', isSelected);
92
+ });
93
+
94
+ updateSelectionInfo();
95
+ }
96
+
97
+ function updateSelectionInfo() {
98
+ const info = document.getElementById('datatable-selection-info');
99
+
100
+ if (info) {
101
+ // Count assigned variables
102
+ const assignedCount = Object.keys(datatableVarAssignments).length;
103
+ if (assignedCount > 0) {
104
+ info.innerHTML = `<span class="selected-count">${assignedCount}</span> variable${assignedCount !== 1 ? 's' : ''} assigned`;
105
+ } else {
106
+ info.innerHTML = `<span class="selected-count">0</span> variables assigned`;
107
+ }
108
+ }
109
+ }
110
+
111
+ // ============================================================================
112
+ // Canvas-Datatable Linking: Highlight columns for selected canvas element
113
+ // ============================================================================
114
+ let datatableLinkedCallId = null;
115
+
116
+ function highlightDatatableForElement(callId) {
117
+ datatableLinkedCallId = callId;
118
+
119
+ // Clear previous highlights (headers and data cells)
120
+ document.querySelectorAll('.datatable-table .canvas-linked').forEach(el => {
121
+ el.classList.remove('canvas-linked');
122
+ });
123
+
124
+ if (!callId || !datatableData) return;
125
+
126
+ const columns = datatableData.columns || [];
127
+ const matchedIndices = [];
128
+
129
+ // Strategy 1: Match columns by prefix pattern (e.g., scatter_x, scatter_y)
130
+ columns.forEach((col, idx) => {
131
+ if (col.name.startsWith(callId + '_') || col.name === callId) {
132
+ matchedIndices.push(idx);
133
+ // Highlight header
134
+ const th = document.querySelector(`.datatable-table th:nth-child(${idx + 2})`);
135
+ if (th) th.classList.add('canvas-linked');
136
+ }
137
+ });
138
+
139
+ // Highlight data cells in matched columns
140
+ matchedIndices.forEach(idx => {
141
+ document.querySelectorAll(`.datatable-table tr td:nth-child(${idx + 2})`).forEach(td => {
142
+ td.classList.add('canvas-linked');
143
+ });
144
+ });
145
+
146
+ let hasMatch = matchedIndices.length > 0;
147
+
148
+ // Strategy 2: If in a matching tab, highlight ALL columns in that tab
149
+ // (handles shared x-data case for line plots where x column isn't prefixed)
150
+ if (typeof datatableTabs !== 'undefined' && typeof activeTabId !== 'undefined') {
151
+ const activeTab = datatableTabs[activeTabId];
152
+ if (activeTab && (activeTab.callId === callId || activeTab.name === callId)) {
153
+ columns.forEach((col, idx) => {
154
+ // Highlight header
155
+ const th = document.querySelector(`.datatable-table th:nth-child(${idx + 2})`);
156
+ if (th) {
157
+ th.classList.add('canvas-linked');
158
+ hasMatch = true;
159
+ }
160
+ // Highlight data cells
161
+ document.querySelectorAll(`.datatable-table tr td:nth-child(${idx + 2})`).forEach(td => {
162
+ td.classList.add('canvas-linked');
163
+ });
164
+ });
165
+ }
166
+ }
167
+
168
+ // Expand datatable panel if collapsed and has linked columns
169
+ const panel = document.getElementById('datatable-panel');
170
+ if (hasMatch && panel && !panel.classList.contains('expanded')) {
171
+ panel.classList.add('expanded');
172
+ }
173
+ }
174
+
175
+ function clearDatatableHighlight() {
176
+ datatableLinkedCallId = null;
177
+ document.querySelectorAll('.datatable-table .canvas-linked').forEach(el => {
178
+ el.classList.remove('canvas-linked');
179
+ });
180
+ }
181
+
182
+ // Full sync: highlight columns + auto-select plot type + set target panel
183
+ function syncDatatableToElement(element) {
184
+ if (!element) {
185
+ clearDatatableHighlight();
186
+ return;
187
+ }
188
+
189
+ // Skip highlighting for panel/axes selections - they don't have data columns
190
+ const elemType = element.type || '';
191
+ if (elemType === 'axes' || elemType === 'panel' || (element.label && element.label.startsWith('Panel '))) {
192
+ console.log('[Highlight] Skipping panel/axes element');
193
+ return;
194
+ }
195
+
196
+ const callId = element.call_id || element.label;
197
+
198
+ // 1. Highlight matching columns
199
+ highlightDatatableForElement(callId);
200
+
201
+ // 2. Auto-select plot type if element has a function type
202
+ if (element.function) {
203
+ const plotSelect = document.getElementById('datatable-plot-type');
204
+ if (plotSelect) {
205
+ // Find matching option
206
+ const options = plotSelect.querySelectorAll('option');
207
+ for (const opt of options) {
208
+ if (opt.value === element.function) {
209
+ plotSelect.value = element.function;
210
+ datatablePlotType = element.function;
211
+ if (typeof updateVarAssignSlots === 'function') updateVarAssignSlots();
212
+ break;
213
+ }
214
+ }
215
+ }
216
+ }
217
+
218
+ // 3. Set target panel to the element's axis
219
+ if (element.ax_index !== undefined) {
220
+ const targetSelect = document.getElementById('datatable-target-panel');
221
+ if (targetSelect) {
222
+ targetSelect.value = element.ax_index;
223
+ datatableTargetAxis = element.ax_index;
224
+ }
225
+ }
226
+
227
+ // 4. Switch to matching datatable tab
228
+ if (typeof datatableTabs !== 'undefined') {
229
+ // Try multiple matching strategies
230
+ const elemKey = element.key || '';
231
+ const elemType = element.type || '';
232
+
233
+ for (const [tabId, tabState] of Object.entries(datatableTabs)) {
234
+ const tabName = (tabState.callId || tabState.name || '').toLowerCase();
235
+ // Match: exact callId, name match, or element key contains tab name
236
+ const matches = (callId && (tabState.callId === callId || tabState.name === callId)) ||
237
+ (elemKey && elemKey.toLowerCase().includes(tabName)) ||
238
+ (elemType && tabName.includes(elemType.toLowerCase()));
239
+ if (matches) {
240
+ if (activeTabId !== tabId && typeof selectTab === 'function') {
241
+ // Use internal flag to prevent sync loop
242
+ window._syncingFromCanvasToData = true;
243
+ selectTab(tabId);
244
+ window._syncingFromCanvasToData = false;
245
+ console.log('[Datatable] Canvas->Data: Switched to tab', tabName);
246
+ }
247
+ break;
248
+ }
249
+ }
250
+ }
251
+ }
252
+ """
253
+
254
+ __all__ = ["JS_DATATABLE_TABLE"]
255
+
256
+ # EOF
@@ -0,0 +1,354 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Datatable tab management JavaScript."""
4
+
5
+ JS_DATATABLE_TABS = """
6
+ // ============================================================================
7
+ // Multi-Tab Datatable State
8
+ // ============================================================================
9
+ let datatableTabs = {}; // Maps tabId -> {data, selectedColumns, varAssignments, plotType, targetAxis}
10
+ let activeTabId = null;
11
+ let tabCounter = 0;
12
+
13
+ // ============================================================================
14
+ // Tab Management
15
+ // ============================================================================
16
+ function initDatatableTabs() {
17
+ console.log('[Datatable] initDatatableTabs called');
18
+ const newTabBtn = document.getElementById('btn-new-tab');
19
+ if (newTabBtn) {
20
+ newTabBtn.addEventListener('click', createNewTab);
21
+ }
22
+
23
+ // Load tabs from figure data
24
+ loadTabsFromFigure();
25
+ }
26
+
27
+ function loadTabsFromFigure() {
28
+ // Fetch existing plot data and calls to get axis info
29
+ Promise.all([
30
+ fetch('/datatable/data').then(r => r.json()),
31
+ fetch('/calls').then(r => r.json())
32
+ ]).then(([data, calls]) => {
33
+ console.log('[Datatable] Loaded data:', data.columns?.length || 0, 'columns');
34
+ if (data.columns && data.columns.length > 0) {
35
+ // Build a map of call_id -> axis index from calls
36
+ // calls is an object: {call_id: {function, ax_key, ...}, ...}
37
+ const callToAxis = {};
38
+ const callToFunction = {};
39
+ Object.entries(calls).forEach(([callId, call]) => {
40
+ // Extract axis index from ax_key (e.g., "ax_0_1" -> row=0, col=1 -> panel index)
41
+ let axIndex = 0;
42
+ if (call.ax_key) {
43
+ const match = call.ax_key.match(/ax_(\\d+)_(\\d+)/);
44
+ if (match) {
45
+ const row = parseInt(match[1]);
46
+ const col = parseInt(match[2]);
47
+ // For now, use simple linear indexing (works for 1xN grids)
48
+ // TODO: get actual ncols from axes_positions for proper MxN support
49
+ axIndex = col > 0 ? col : row;
50
+ }
51
+ }
52
+ callToAxis[callId] = axIndex;
53
+ callToFunction[callId] = call.function || 'plot';
54
+ });
55
+
56
+ // Group columns by call_id (e.g., plot_000_x, plot_000_y -> plot_000)
57
+ // First pass: identify which columns belong to each group
58
+ const groups = {};
59
+ const colIndexMap = {}; // Maps callId -> [column indices]
60
+ data.columns.forEach((col, idx) => {
61
+ // Extract call_id from column name (e.g., "plot_000_x" -> "plot_000")
62
+ const match = col.name.match(/^(.+?)_[xy]$/);
63
+ const callId = match ? match[1] : col.name;
64
+ if (!groups[callId]) {
65
+ groups[callId] = {
66
+ columns: [],
67
+ axIndex: callToAxis[callId] !== undefined ? callToAxis[callId] : 0,
68
+ plotType: callToFunction[callId] || 'plot'
69
+ };
70
+ colIndexMap[callId] = [];
71
+ }
72
+ // Store column with new index for this group
73
+ groups[callId].columns.push({
74
+ ...col,
75
+ index: groups[callId].columns.length
76
+ });
77
+ colIndexMap[callId].push(idx);
78
+ });
79
+
80
+ // Second pass: extract rows for each group (only relevant columns)
81
+ Object.keys(groups).forEach(callId => {
82
+ const indices = colIndexMap[callId];
83
+ groups[callId].rows = data.rows.map(row =>
84
+ indices.map(idx => row[idx])
85
+ );
86
+ });
87
+
88
+ // Create a tab for each group with element colors
89
+ console.log('[Datatable] Creating tabs for', Object.keys(groups).length, 'groups:', Object.keys(groups));
90
+ Object.entries(groups).forEach(([callId, groupData]) => {
91
+ // Get element color from colorMap (populated by hitmap)
92
+ const elemColor = typeof getGroupRepresentativeColor === 'function'
93
+ ? getGroupRepresentativeColor(callId, null)
94
+ : null;
95
+ console.log('[Datatable] Creating tab:', callId, 'with', groupData.columns?.length, 'columns,', groupData.rows?.length, 'rows');
96
+ createTab(callId, groupData, false, groupData.axIndex, groupData.plotType, elemColor);
97
+ });
98
+
99
+ // Select first tab
100
+ const firstTabId = Object.keys(datatableTabs)[0];
101
+ if (firstTabId) {
102
+ selectTab(firstTabId);
103
+ }
104
+
105
+ // Retry color update after hitmap loads (may not be ready yet)
106
+ setTimeout(() => { if (typeof updateTabColors === 'function') updateTabColors(); }, 500);
107
+ }
108
+ }).catch(err => {
109
+ console.error('Failed to load tabs from figure:', err);
110
+ });
111
+ }
112
+
113
+ function createTab(name, data = null, select = true, axIndex = null, plotType = 'plot', elementColor = null) {
114
+ const tabId = `tab_${tabCounter++}`;
115
+ const tabList = document.getElementById('datatable-tab-list');
116
+
117
+ // Create tab element with axis badge
118
+ const tab = document.createElement('button');
119
+ tab.className = 'datatable-tab';
120
+ tab.dataset.tabId = tabId;
121
+ // Use letters A, B, C... to match subplot labels in figure
122
+ const axLabel = axIndex !== null ? String.fromCharCode(65 + axIndex) : null; // 65 is 'A'
123
+ const axBadge = axLabel ? `<span class="tab-axis" title="Panel ${axLabel}">${axLabel}</span>` : '';
124
+ tab.innerHTML = `
125
+ ${axBadge}
126
+ <span class="tab-name" title="${name}">${name}</span>
127
+ <span class="tab-close" onclick="event.stopPropagation(); closeTab('${tabId}')">&times;</span>
128
+ `;
129
+ tab.onclick = () => selectTab(tabId);
130
+
131
+ // Apply element color if available
132
+ if (elementColor) {
133
+ tab.style.setProperty('--element-color', elementColor);
134
+ }
135
+
136
+ tabList.appendChild(tab);
137
+
138
+ // Initialize tab state with axis association
139
+ datatableTabs[tabId] = {
140
+ name: name,
141
+ data: data,
142
+ selectedColumns: new Set(),
143
+ varAssignments: {},
144
+ varColors: {},
145
+ plotType: plotType,
146
+ targetAxis: axIndex, // Associated axis
147
+ callId: name, // Original call_id for linking
148
+ elementColor: elementColor // Element's original color
149
+ };
150
+
151
+ if (select) {
152
+ selectTab(tabId);
153
+ }
154
+
155
+ return tabId;
156
+ }
157
+
158
+ function createNewTab() {
159
+ const name = `Data ${tabCounter + 1}`;
160
+ // Auto-create large empty editable table (matches createNewCSV defaults)
161
+ const numRows = 100;
162
+ const numCols = 5;
163
+
164
+ const columns = [];
165
+ for (let i = 0; i < numCols; i++) {
166
+ // Default to 'string' type so users can type anything
167
+ columns.push({ name: `col${i + 1}`, type: 'string', index: i });
168
+ }
169
+
170
+ const rows = [];
171
+ for (let i = 0; i < numRows; i++) {
172
+ rows.push(new Array(numCols).fill(''));
173
+ }
174
+
175
+ const defaultData = { columns, rows };
176
+ createTab(name, defaultData, true);
177
+ }
178
+
179
+ function selectTab(tabId) {
180
+ if (!datatableTabs[tabId]) return;
181
+
182
+ // Save current tab state
183
+ saveCurrentTabState();
184
+
185
+ // Update active tab
186
+ activeTabId = tabId;
187
+
188
+ // Update tab UI
189
+ document.querySelectorAll('.datatable-tab').forEach(tab => {
190
+ tab.classList.toggle('active', tab.dataset.tabId === tabId);
191
+ });
192
+
193
+ // Restore tab state
194
+ restoreTabState(tabId);
195
+ }
196
+
197
+ function closeTab(tabId) {
198
+ const tab = document.querySelector(`.datatable-tab[data-tab-id="${tabId}"]`);
199
+ if (tab) {
200
+ tab.remove();
201
+ }
202
+
203
+ delete datatableTabs[tabId];
204
+
205
+ // If closing active tab, select another
206
+ if (activeTabId === tabId) {
207
+ const remainingTabs = Object.keys(datatableTabs);
208
+ if (remainingTabs.length > 0) {
209
+ selectTab(remainingTabs[0]);
210
+ } else {
211
+ activeTabId = null;
212
+ clearDatatableDisplay();
213
+ }
214
+ }
215
+ }
216
+
217
+ function saveCurrentTabState() {
218
+ if (!activeTabId || !datatableTabs[activeTabId]) return;
219
+
220
+ datatableTabs[activeTabId].data = datatableData;
221
+ datatableTabs[activeTabId].selectedColumns = new Set(datatableSelectedColumns);
222
+ datatableTabs[activeTabId].varAssignments = {...datatableVarAssignments};
223
+ datatableTabs[activeTabId].varColors = {...datatableVarColors};
224
+ datatableTabs[activeTabId].plotType = datatablePlotType;
225
+ datatableTabs[activeTabId].targetAxis = datatableTargetAxis;
226
+ }
227
+
228
+ function restoreTabState(tabId) {
229
+ const state = datatableTabs[tabId];
230
+ if (!state) return;
231
+
232
+ // Restore global state
233
+ datatableData = state.data;
234
+ datatableSelectedColumns = new Set(state.selectedColumns);
235
+ datatableVarAssignments = {...state.varAssignments};
236
+ datatableVarColors = state.varColors ? {...state.varColors} : {};
237
+ datatablePlotType = state.plotType || 'plot';
238
+ datatableTargetAxis = state.targetAxis;
239
+
240
+ // Update UI
241
+ if (datatableData) {
242
+ // Use renderEditableTable for direct editing capability
243
+ if (typeof renderEditableTable === 'function') {
244
+ renderEditableTable();
245
+ } else {
246
+ renderDatatable();
247
+ }
248
+
249
+ // Show toolbar, hide entire import section
250
+ const importSection = document.getElementById('datatable-import-section');
251
+ if (importSection) importSection.style.display = 'none';
252
+ const toolbar = document.querySelector('.datatable-toolbar');
253
+ if (toolbar) toolbar.style.display = 'flex';
254
+
255
+ // Restore plot type selection
256
+ const plotTypeSelect = document.getElementById('datatable-plot-type');
257
+ if (plotTypeSelect) plotTypeSelect.value = datatablePlotType;
258
+
259
+ // Restore target panel selection
260
+ const targetPanelSelect = document.getElementById('datatable-target-panel');
261
+ if (targetPanelSelect && datatableTargetAxis !== null) {
262
+ targetPanelSelect.value = datatableTargetAxis;
263
+ }
264
+
265
+ // Update variable assignment UI
266
+ updateVarAssignSlots();
267
+ } else {
268
+ clearDatatableDisplay();
269
+ }
270
+ }
271
+
272
+ function clearDatatableDisplay() {
273
+ const content = document.getElementById('datatable-content');
274
+ if (content) content.innerHTML = '';
275
+
276
+ // Show import section with dropzone
277
+ const importSection = document.getElementById('datatable-import-section');
278
+ if (importSection) importSection.style.display = 'block';
279
+
280
+ const toolbar = document.querySelector('.datatable-toolbar');
281
+ if (toolbar) toolbar.style.display = 'none';
282
+
283
+ const varAssign = document.getElementById('datatable-var-assign');
284
+ if (varAssign) varAssign.style.display = 'none';
285
+
286
+ updateSelectionInfo();
287
+ }
288
+
289
+ // Override loadExistingData to use tabs
290
+ function loadExistingData() {
291
+ // This is now handled by loadTabsFromFigure
292
+ // But we still call initDatatableTabs
293
+ initDatatableTabs();
294
+ }
295
+
296
+ // Override handleParsedData to work with tabs
297
+ const originalHandleParsedData = typeof handleParsedData === 'function' ? handleParsedData : null;
298
+
299
+ function handleParsedDataWithTabs(parsedData) {
300
+ if (!activeTabId) {
301
+ // Create a new tab for imported data
302
+ createNewTab();
303
+ }
304
+
305
+ // Store in current tab
306
+ datatableTabs[activeTabId].data = parsedData;
307
+ datatableData = parsedData;
308
+
309
+ renderDatatable();
310
+
311
+ // Show toolbar, hide entire import section
312
+ const importSection = document.getElementById('datatable-import-section');
313
+ if (importSection) importSection.style.display = 'none';
314
+ const toolbar = document.querySelector('.datatable-toolbar');
315
+ if (toolbar) toolbar.style.display = 'flex';
316
+
317
+ updateVarAssignSlots();
318
+ }
319
+
320
+ // Hook into handleParsedData
321
+ if (typeof window !== 'undefined') {
322
+ window.handleParsedData = handleParsedDataWithTabs;
323
+ }
324
+
325
+ // Update tab colors after colorMap is loaded
326
+ function updateTabColors() {
327
+ if (typeof colorMap === 'undefined' || !colorMap) return;
328
+ if (typeof getGroupRepresentativeColor !== 'function') return;
329
+
330
+ Object.entries(datatableTabs).forEach(([tabId, tabState]) => {
331
+ if (tabState.callId) {
332
+ // Always get the latest color from colorMap (override any stale color)
333
+ const elemColor = getGroupRepresentativeColor(tabState.callId, null);
334
+ if (elemColor) {
335
+ tabState.elementColor = elemColor;
336
+ const tabEl = document.querySelector(`.datatable-tab[data-tab-id="${tabId}"]`);
337
+ if (tabEl) {
338
+ tabEl.style.setProperty('--element-color', elemColor);
339
+ }
340
+ }
341
+ }
342
+ });
343
+ }
344
+
345
+ // Export for hitmap to call after colorMap is loaded
346
+ if (typeof window !== 'undefined') {
347
+ window.updateTabColors = updateTabColors;
348
+ }
349
+
350
+ """
351
+
352
+ __all__ = ["JS_DATATABLE_TABS"]
353
+
354
+ # EOF
@@ -94,7 +94,18 @@ function showDynamicCallProperties(element) {
94
94
 
95
95
  container.innerHTML = '';
96
96
 
97
- const callId = element.call_id || element.label;
97
+ // Get call_id - try element directly, then colorMap, then label
98
+ let callId = element.call_id;
99
+ if (!callId && element.key && typeof colorMap !== 'undefined') {
100
+ // Look up call_id from colorMap (hitmap has this info)
101
+ const colorInfo = colorMap[element.key];
102
+ if (colorInfo && colorInfo.call_id) {
103
+ callId = colorInfo.call_id;
104
+ }
105
+ }
106
+ if (!callId) {
107
+ callId = element.label;
108
+ }
98
109
 
99
110
  // If no call data found, show basic element info instead
100
111
  if (!callId || !callsData[callId]) {
@@ -286,7 +297,11 @@ async function handleDynamicParamChange(callId, param, input) {
286
297
  loadHitmap();
287
298
  updateHitRegions();
288
299
 
289
- if (callsData[callId]) {
300
+ // Sync callsData from server response (source of truth)
301
+ if (callsData[callId] && data.updated_call) {
302
+ callsData[callId].kwargs = data.updated_call.kwargs;
303
+ } else if (callsData[callId]) {
304
+ // Fallback: manual update
290
305
  if (value === null) {
291
306
  delete callsData[callId].kwargs[param];
292
307
  } else {