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
@@ -45,6 +45,11 @@ async function loadHitmap() {
45
45
  if (overlay) {
46
46
  overlay.src = hitmapImg.src;
47
47
  }
48
+
49
+ // Sync datatable tab colors now that colorMap is loaded
50
+ if (typeof updateTabColors === 'function') {
51
+ updateTabColors();
52
+ }
48
53
  };
49
54
  hitmapImg.src = 'data:image/png;base64,' + data.image;
50
55
  } catch (error) {
@@ -80,16 +85,15 @@ function drawHitRegions() {
80
85
  const overlay = document.getElementById('hitregion-overlay');
81
86
  overlay.innerHTML = '';
82
87
 
83
- const img = document.getElementById('preview-image');
84
-
85
- // Wait for image to load before drawing hit regions
86
- if (!img.naturalWidth || !img.naturalHeight) {
87
- console.log('Image not loaded yet, deferring hit regions draw');
88
- return;
88
+ // Context menu on overlay (pointer-events: auto captures right-clicks)
89
+ if (!overlay._ctxInit) {
90
+ overlay.addEventListener('contextmenu', (e) => { if (typeof showCanvasContextMenu === 'function') showCanvasContextMenu(e); });
91
+ overlay._ctxInit = true;
89
92
  }
90
93
 
91
- // Set SVG viewBox to match natural image size
92
- // CSS transform on zoom-container handles all scaling
94
+ const img = document.getElementById('preview-image');
95
+ if (!img.naturalWidth || !img.naturalHeight) { console.log('Image not loaded yet'); return; }
96
+
93
97
  overlay.setAttribute('viewBox', `0 0 ${img.naturalWidth} ${img.naturalHeight}`);
94
98
  overlay.style.width = `${img.naturalWidth}px`;
95
99
  overlay.style.height = `${img.naturalHeight}px`;
@@ -102,13 +106,25 @@ function drawHitRegions() {
102
106
 
103
107
  console.log('Drawing hit regions:', Object.keys(currentBboxes).length, 'elements');
104
108
 
105
- // Drawing z-order: background first (lower), foreground last (higher = on top visually)
106
- const zOrderPriority = { 'axes': 0, 'fill': 1, 'spine': 2, 'image': 3, 'contour': 3,
107
- 'bar': 4, 'pie': 4, 'quiver': 4, 'line': 5, 'scatter': 6, 'xticks': 7, 'yticks': 7,
108
- 'title': 8, 'xlabel': 8, 'ylabel': 8, 'legend': 9 };
109
-
110
- // Convert to array, filter, and sort by z-order
111
- // Include axes (panels) - they have lowest z-order so drawn first (background)
109
+ // Draw panel hit regions FIRST (lowest z-order) to catch empty space clicks
110
+ const panelBboxes = currentBboxes?._meta?.panel_bboxes;
111
+ if (panelBboxes) { for (const [axIdx, pb] of Object.entries(panelBboxes)) {
112
+ const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
113
+ rect.setAttribute('x', pb.x); rect.setAttribute('y', pb.y);
114
+ rect.setAttribute('width', pb.width); rect.setAttribute('height', pb.height);
115
+ rect.setAttribute('class', 'hitregion-rect panel-region'); rect.setAttribute('data-key', `ax${axIdx}_axes`);
116
+ rect.addEventListener('click', (e) => { e.stopPropagation();
117
+ const el = { key: `ax${axIdx}_axes`, type: 'panel', label: `Panel ${axIdx}`, ax_index: parseInt(axIdx), ...pb };
118
+ if (e.ctrlKey || e.metaKey) { if (typeof toggleInSelection === 'function') toggleInSelection(el); }
119
+ else { if (typeof clearMultiSelection === 'function') clearMultiSelection(); selectElement(el); }
120
+ });
121
+ rect.addEventListener('mousedown', (e) => { if (e.button === 0 && !e.ctrlKey && !e.metaKey && !e.altKey && typeof handlePanelDragStart === 'function') handlePanelDragStart(e); });
122
+ overlay.appendChild(rect);
123
+ }}
124
+ // Drawing z-order: axes lowest (background), panel_label/text highest (foreground)
125
+ const zOrderPriority = { 'axes': 0, 'fill': 1, 'spine': 2, 'image': 3, 'contour': 3, 'bar': 4, 'pie': 4,
126
+ 'quiver': 4, 'line': 5, 'scatter': 6, 'xticks': 7, 'yticks': 7, 'title': 8, 'xlabel': 8, 'ylabel': 8, 'legend': 9, 'panel_label': 10, 'text': 10 };
127
+ // Convert to array, filter, and sort by z-order (axes lowest, panel_label highest)
112
128
  const sortedEntries = Object.entries(currentBboxes)
113
129
  .filter(([key, bbox]) => key !== '_meta' && bbox && typeof bbox.x !== 'undefined')
114
130
  .sort((a, b) => (zOrderPriority[a[1].type] || 5) - (zOrderPriority[b[1].type] || 5));
@@ -151,10 +167,11 @@ function drawHitRegions() {
151
167
  shape.addEventListener('mouseleave', () => handleHitRegionLeave());
152
168
  shape.addEventListener('click', (e) => handleHitRegionClick(e, key, enrichedBbox));
153
169
 
154
- // Add mousedown for drag (legend or panel)
170
+ // Add mousedown for drag (legend, annotation, or panel)
155
171
  shape.addEventListener('mousedown', (e) => {
156
172
  if (e.button !== 0 || e.ctrlKey || e.metaKey || e.altKey) return;
157
173
  if (bbox.type === 'legend' && typeof startLegendDrag === 'function') { startLegendDrag(e, key); return; }
174
+ if ((bbox.type === 'panel_label' || bbox.type === 'text') && typeof startAnnotationDrag === 'function') { startAnnotationDrag(e, key); return; }
158
175
  if (typeof handlePanelDragStart === 'function') handlePanelDragStart(e);
159
176
  });
160
177
 
@@ -201,7 +218,7 @@ function _createScatterShape(bbox, key, originalColor, offsetX, offsetY, scaleX,
201
218
  shape.style.setProperty('--element-color', originalColor);
202
219
  }
203
220
 
204
- const hitRadius = 5;
221
+ const hitRadius = 8; // Larger radius for easier click targeting
205
222
  const allCircles = [];
206
223
 
207
224
  bbox.points.forEach((pt, idx) => {
@@ -239,7 +256,9 @@ function _createScatterShape(bbox, key, originalColor, offsetX, offsetY, scaleX,
239
256
  // Helper: Create rectangle shape for other elements
240
257
  function _createRectShape(bbox, key, originalColor, offsetX, offsetY, scaleX, scaleY) {
241
258
  let regionClass = 'hitregion-rect';
242
- if (bbox.type === 'line' || bbox.type === 'scatter') {
259
+ if (bbox.type === 'axes') {
260
+ regionClass += ' axes-region'; // Special class for axes - lower z-order
261
+ } else if (bbox.type === 'line' || bbox.type === 'scatter') {
243
262
  regionClass += ' line-region';
244
263
  } else if (['title', 'xlabel', 'ylabel', 'suptitle', 'supxlabel', 'supylabel'].includes(bbox.type)) {
245
264
  regionClass += ' text-region';
@@ -282,22 +301,13 @@ function handleHitRegionHover(key, bbox) {
282
301
  }
283
302
  }
284
303
 
285
- // Highlight all elements in a group
286
304
  function highlightGroupElements(keys) {
287
- keys.forEach(key => {
288
- const hitRegion = document.querySelector(`[data-key="${key}"]`);
289
- if (hitRegion) {
290
- hitRegion.classList.add('group-hovered');
291
- }
292
- });
305
+ keys.forEach(key => { const el = document.querySelector(`[data-key="${key}"]`); if (el) el.classList.add('group-hovered'); });
293
306
  }
294
307
 
295
- // Handle leaving hit region
296
308
  function handleHitRegionLeave() {
297
309
  hoveredElement = null;
298
- document.querySelectorAll('.group-hovered').forEach(el => {
299
- el.classList.remove('group-hovered');
300
- });
310
+ document.querySelectorAll('.group-hovered').forEach(el => el.classList.remove('group-hovered'));
301
311
  }
302
312
 
303
313
  // Handle click on hit region with Alt+Click cycling support
@@ -311,33 +321,30 @@ function handleHitRegionClick(event, key, bbox) {
311
321
  const colorMapInfo = (colorMap && colorMap[key]) || {};
312
322
  const element = { key, ...bbox, ...colorMapInfo };
313
323
 
314
- if (event.altKey) {
324
+ if (event.ctrlKey || event.metaKey) {
325
+ // Ctrl+Click: toggle multi-selection
326
+ if (typeof toggleInSelection === 'function') {
327
+ toggleInSelection(element);
328
+ if (typeof drawMultiSelection === 'function') drawMultiSelection();
329
+ } else { selectElement(element); }
330
+ } else if (event.altKey) {
315
331
  // Alt+Click: cycle through overlapping elements
316
332
  const clickPos = { x: event.clientX, y: event.clientY };
317
- const samePosition = lastClickPosition &&
318
- Math.abs(lastClickPosition.x - clickPos.x) < 5 &&
319
- Math.abs(lastClickPosition.y - clickPos.y) < 5;
320
-
333
+ const samePosition = lastClickPosition && Math.abs(lastClickPosition.x - clickPos.x) < 5 && Math.abs(lastClickPosition.y - clickPos.y) < 5;
321
334
  if (samePosition && overlappingElements.length > 1) {
322
335
  cycleIndex = (cycleIndex + 1) % overlappingElements.length;
323
336
  selectElement(overlappingElements[cycleIndex]);
324
337
  } else {
325
338
  overlappingElements = findOverlappingElements(clickPos);
326
- cycleIndex = 0;
327
- lastClickPosition = clickPos;
328
-
329
- if (overlappingElements.length > 0) {
330
- selectElement(overlappingElements[0]);
331
- } else {
332
- selectElement(element);
333
- }
339
+ cycleIndex = 0; lastClickPosition = clickPos;
340
+ selectElement(overlappingElements.length > 0 ? overlappingElements[0] : element);
334
341
  }
335
342
  } else {
336
- // Normal click: select the hovered element
337
- selectElement(element);
338
- lastClickPosition = null;
339
- overlappingElements = [];
340
- cycleIndex = 0;
343
+ // Normal click: clear multi-selection, use priority-based selection
344
+ if (typeof clearMultiSelection === 'function') clearMultiSelection();
345
+ const overlapping = findOverlappingElements({ x: event.clientX, y: event.clientY });
346
+ selectElement(overlapping.length > 0 ? overlapping[0] : element);
347
+ lastClickPosition = null; overlappingElements = []; cycleIndex = 0;
341
348
  }
342
349
  }
343
350
 
@@ -345,40 +352,46 @@ function handleHitRegionClick(event, key, bbox) {
345
352
  function findOverlappingElements(screenPos) {
346
353
  const img = document.getElementById('preview-image');
347
354
  const imgRect = img.getBoundingClientRect();
348
-
349
355
  const imgX = (screenPos.x - imgRect.left) * (img.naturalWidth / imgRect.width);
350
356
  const imgY = (screenPos.y - imgRect.top) * (img.naturalHeight / imgRect.height);
351
-
352
357
  const overlapping = [];
353
358
 
354
359
  for (const [key, bbox] of Object.entries(currentBboxes)) {
355
360
  if (key === '_meta') continue;
356
-
357
- if (imgX >= bbox.x && imgX <= bbox.x + bbox.width &&
358
- imgY >= bbox.y && imgY <= bbox.y + bbox.height) {
359
- overlapping.push({ key, ...bbox });
361
+ if (imgX >= bbox.x && imgX <= bbox.x + bbox.width && imgY >= bbox.y && imgY <= bbox.y + bbox.height) {
362
+ overlapping.push({ key, ...bbox, ...(colorMap?.[key] || {}) });
360
363
  }
361
-
362
364
  // For lines with points, check proximity
363
- if (bbox.points && bbox.points.length > 1) {
365
+ if (bbox.points?.length > 1) {
364
366
  for (const pt of bbox.points) {
365
- const dist = Math.sqrt(Math.pow(imgX - pt[0], 2) + Math.pow(imgY - pt[1], 2));
366
- if (dist < 15) {
367
- if (!overlapping.find(e => e.key === key)) {
368
- overlapping.push({ key, ...bbox });
369
- }
370
- break;
367
+ if (Math.hypot(imgX - pt[0], imgY - pt[1]) < 15 && !overlapping.find(e => e.key === key)) {
368
+ overlapping.push({ key, ...bbox, ...(colorMap?.[key] || {}) }); break;
371
369
  }
372
370
  }
373
371
  }
374
372
  }
375
-
376
- // Click priority: smaller/precise elements first, large background last (lower = higher priority)
377
- const clickPriority = { 'scatter': 0, 'legend': 1, 'title': 2, 'xlabel': 2, 'ylabel': 2,
378
- 'line': 3, 'bar': 4, 'pie': 4, 'contour': 5, 'quiver': 5, 'image': 5, 'fill': 6,
379
- 'xticks': 7, 'yticks': 7, 'spine': 8, 'axes': 9 };
380
- overlapping.sort((a, b) => (clickPriority[a.type] ?? 5) - (clickPriority[b.type] ?? 5));
381
-
373
+ // Panel bboxes as fallback - catches empty space within panels
374
+ const panelBboxes = currentBboxes?._meta?.panel_bboxes;
375
+ if (panelBboxes) {
376
+ for (const [axIdx, pb] of Object.entries(panelBboxes)) {
377
+ if (imgX >= pb.x && imgX <= pb.x + pb.width && imgY >= pb.y && imgY <= pb.y + pb.height) {
378
+ const axKey = `ax${axIdx}_axes`;
379
+ if (!overlapping.find(e => e.key === axKey)) {
380
+ overlapping.push({ key: axKey, type: 'panel', label: `Panel ${axIdx}`, ax_index: parseInt(axIdx), ...pb });
381
+ }
382
+ }
383
+ }
384
+ }
385
+ // Click priority (lower = higher priority). 'panel' lowest - selected only in empty space
386
+ const clickPriority = { 'scatter': 0, 'legend': 1, 'panel_label': 2, 'text': 2, 'title': 3, 'xlabel': 3, 'ylabel': 3,
387
+ 'line': 4, 'bar': 5, 'pie': 5, 'hist': 5, 'contour': 6, 'quiver': 6, 'image': 6, 'fill': 7,
388
+ 'xticks': 8, 'yticks': 8, 'spine': 9, 'axes': 10, 'panel': 11 };
389
+ overlapping.forEach(e => {
390
+ e._d = Infinity; const bb = currentBboxes[e.key];
391
+ if (bb?.points?.length) { for (const p of bb.points) { const d = Math.hypot(imgX - p[0], imgY - p[1]); if (d < e._d) e._d = d; } }
392
+ else { e._d = Math.hypot(imgX - (e.x + e.width/2), imgY - (e.y + e.height/2)); }
393
+ });
394
+ overlapping.sort((a, b) => { const p = (clickPriority[a.type] ?? 6) - (clickPriority[b.type] ?? 6); return p !== 0 ? p : a._d - b._d; });
382
395
  return overlapping;
383
396
  }
384
397
 
@@ -469,37 +482,27 @@ function findGroupElements(callId) {
469
482
  // Get representative color for a call_id group
470
483
  function getGroupRepresentativeColor(callId, fallbackColor) {
471
484
  if (!callId || !colorMap) return fallbackColor;
472
-
473
485
  const groupElements = findGroupElements(callId);
474
- if (groupElements.length === 0) return fallbackColor;
475
-
476
- const firstColor = groupElements[0].original_color;
477
- if (!firstColor) return fallbackColor;
478
-
479
- const allSameColor = groupElements.every(el => el.original_color === firstColor);
480
- return allSameColor ? firstColor : firstColor;
486
+ return groupElements.length > 0 && groupElements[0].original_color ? groupElements[0].original_color : fallbackColor;
481
487
  }
482
488
 
483
489
  // Select an element (and its logical group if applicable)
484
490
  function selectElement(element) {
485
491
  selectedElement = element;
486
-
487
492
  const callId = element.call_id || element.label;
488
493
  const groupElements = findGroupElements(callId);
489
-
490
494
  selectedElement.groupElements = groupElements.length > 1 ? groupElements : null;
491
495
 
492
496
  drawSelection(element.key);
493
497
  autoSwitchTab(element.type);
494
498
  updateTabHints();
495
499
  syncPropertiesToElement(element);
500
+ if (element && typeof syncDatatableToElement === 'function') syncDatatableToElement(element);
496
501
 
497
- // Sync with panel position if axes type or has ax_index
498
- if (element.type === 'axes' || element.ax_index !== undefined) {
499
- const axIndex = element.ax_index !== undefined ? element.ax_index : getPanelIndexFromKey(element.key);
500
- if (axIndex !== null && typeof selectPanelByIndex === 'function') {
501
- selectPanelByIndex(axIndex);
502
- }
502
+ // Always sync panel position for any element that belongs to a panel
503
+ const axIndex = element.ax_index !== undefined ? element.ax_index : getPanelIndexFromKey(element.key);
504
+ if (axIndex !== null && typeof selectPanelByIndex === 'function') {
505
+ selectPanelByIndex(axIndex, element.type === 'axes');
503
506
  }
504
507
  }
505
508
  """