scitex 2.7.3__py3-none-any.whl → 2.10.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 (563) hide show
  1. scitex/__init__.py +15 -7
  2. scitex/__version__.py +1 -2
  3. scitex/_install_guide.py +250 -0
  4. scitex/_optional_deps.py +206 -39
  5. scitex/ai/_gen_ai/_Groq.py +2 -4
  6. scitex/ai/_gen_ai/_OpenAI.py +5 -2
  7. scitex/ai/_gen_ai/_Perplexity.py +20 -6
  8. scitex/audio/__init__.py +24 -15
  9. scitex/audio/_cross_process_lock.py +139 -0
  10. scitex/audio/_mcp_handlers.py +256 -0
  11. scitex/audio/_mcp_tool_schemas.py +203 -0
  12. scitex/audio/engines/elevenlabs_engine.py +5 -2
  13. scitex/audio/mcp_server.py +98 -457
  14. scitex/bridge/__init__.py +30 -19
  15. scitex/bridge/_figrecipe.py +245 -0
  16. scitex/bridge/_helpers.py +2 -1
  17. scitex/bridge/_plt_vis.py +23 -10
  18. scitex/bridge/_stats_plt.py +18 -5
  19. scitex/bridge/_stats_vis.py +16 -2
  20. scitex/browser/__init__.py +84 -44
  21. scitex/browser/automation/__init__.py +5 -1
  22. scitex/browser/core/BrowserMixin.py +17 -4
  23. scitex/browser/core/__init__.py +11 -2
  24. scitex/browser/remote/CaptchaHandler.py +1 -1
  25. scitex/browser/remote/ZenRowsAPIClient.py +1 -1
  26. scitex/capture/grid.py +487 -0
  27. scitex/capture/mcp_handlers.py +401 -0
  28. scitex/capture/mcp_tool_defs.py +192 -0
  29. scitex/capture/mcp_tools.py +241 -0
  30. scitex/capture/mcp_utils.py +30 -0
  31. scitex/cli/convert.py +421 -0
  32. scitex/cli/main.py +6 -4
  33. scitex/datetime/__init__.py +46 -0
  34. scitex/datetime/_linspace.py +100 -0
  35. scitex/datetime/_normalize_timestamp.py +306 -0
  36. scitex/db/_delete_duplicates.py +4 -4
  37. scitex/db/_sqlite3/_delete_duplicates.py +11 -2
  38. scitex/dev/plt/__init__.py +61 -62
  39. scitex/dev/plt/demo_plotters/__init__.py +0 -0
  40. scitex/dev/plt/demo_plotters/plot_mpl_axhline.py +28 -0
  41. scitex/dev/plt/demo_plotters/plot_mpl_axhspan.py +28 -0
  42. scitex/dev/plt/demo_plotters/plot_mpl_axvline.py +28 -0
  43. scitex/dev/plt/demo_plotters/plot_mpl_axvspan.py +28 -0
  44. scitex/dev/plt/demo_plotters/plot_mpl_bar.py +29 -0
  45. scitex/dev/plt/demo_plotters/plot_mpl_barh.py +29 -0
  46. scitex/dev/plt/demo_plotters/plot_mpl_boxplot.py +28 -0
  47. scitex/dev/plt/demo_plotters/plot_mpl_contour.py +31 -0
  48. scitex/dev/plt/demo_plotters/plot_mpl_contourf.py +31 -0
  49. scitex/dev/plt/demo_plotters/plot_mpl_errorbar.py +30 -0
  50. scitex/dev/plt/demo_plotters/plot_mpl_eventplot.py +28 -0
  51. scitex/dev/plt/demo_plotters/plot_mpl_fill.py +30 -0
  52. scitex/dev/plt/demo_plotters/plot_mpl_fill_between.py +31 -0
  53. scitex/dev/plt/demo_plotters/plot_mpl_hexbin.py +28 -0
  54. scitex/dev/plt/demo_plotters/plot_mpl_hist.py +28 -0
  55. scitex/dev/plt/demo_plotters/plot_mpl_hist2d.py +28 -0
  56. scitex/dev/plt/demo_plotters/plot_mpl_imshow.py +29 -0
  57. scitex/dev/plt/demo_plotters/plot_mpl_pcolormesh.py +31 -0
  58. scitex/dev/plt/demo_plotters/plot_mpl_pie.py +29 -0
  59. scitex/dev/plt/demo_plotters/plot_mpl_plot.py +29 -0
  60. scitex/dev/plt/demo_plotters/plot_mpl_quiver.py +31 -0
  61. scitex/dev/plt/demo_plotters/plot_mpl_scatter.py +28 -0
  62. scitex/dev/plt/demo_plotters/plot_mpl_stackplot.py +31 -0
  63. scitex/dev/plt/demo_plotters/plot_mpl_stem.py +29 -0
  64. scitex/dev/plt/demo_plotters/plot_mpl_step.py +29 -0
  65. scitex/dev/plt/demo_plotters/plot_mpl_violinplot.py +28 -0
  66. scitex/dev/plt/demo_plotters/plot_sns_barplot.py +29 -0
  67. scitex/dev/plt/demo_plotters/plot_sns_boxplot.py +29 -0
  68. scitex/dev/plt/demo_plotters/plot_sns_heatmap.py +28 -0
  69. scitex/dev/plt/demo_plotters/plot_sns_histplot.py +29 -0
  70. scitex/dev/plt/demo_plotters/plot_sns_kdeplot.py +29 -0
  71. scitex/dev/plt/demo_plotters/plot_sns_lineplot.py +31 -0
  72. scitex/dev/plt/demo_plotters/plot_sns_scatterplot.py +29 -0
  73. scitex/dev/plt/demo_plotters/plot_sns_stripplot.py +29 -0
  74. scitex/dev/plt/demo_plotters/plot_sns_swarmplot.py +29 -0
  75. scitex/dev/plt/demo_plotters/plot_sns_violinplot.py +29 -0
  76. scitex/dev/plt/demo_plotters/plot_stx_bar.py +29 -0
  77. scitex/dev/plt/demo_plotters/plot_stx_barh.py +29 -0
  78. scitex/dev/plt/demo_plotters/plot_stx_box.py +28 -0
  79. scitex/dev/plt/demo_plotters/plot_stx_boxplot.py +28 -0
  80. scitex/dev/plt/demo_plotters/plot_stx_conf_mat.py +28 -0
  81. scitex/dev/plt/demo_plotters/plot_stx_contour.py +31 -0
  82. scitex/dev/plt/demo_plotters/plot_stx_ecdf.py +28 -0
  83. scitex/dev/plt/demo_plotters/plot_stx_errorbar.py +30 -0
  84. scitex/dev/plt/demo_plotters/plot_stx_fill_between.py +31 -0
  85. scitex/dev/plt/demo_plotters/plot_stx_fillv.py +28 -0
  86. scitex/dev/plt/demo_plotters/plot_stx_heatmap.py +28 -0
  87. scitex/dev/plt/demo_plotters/plot_stx_image.py +28 -0
  88. scitex/dev/plt/demo_plotters/plot_stx_imshow.py +28 -0
  89. scitex/dev/plt/demo_plotters/plot_stx_joyplot.py +28 -0
  90. scitex/dev/plt/demo_plotters/plot_stx_kde.py +28 -0
  91. scitex/dev/plt/demo_plotters/plot_stx_line.py +28 -0
  92. scitex/dev/plt/demo_plotters/plot_stx_mean_ci.py +28 -0
  93. scitex/dev/plt/demo_plotters/plot_stx_mean_std.py +28 -0
  94. scitex/dev/plt/demo_plotters/plot_stx_median_iqr.py +28 -0
  95. scitex/dev/plt/demo_plotters/plot_stx_raster.py +28 -0
  96. scitex/dev/plt/demo_plotters/plot_stx_rectangle.py +28 -0
  97. scitex/dev/plt/demo_plotters/plot_stx_scatter.py +29 -0
  98. scitex/dev/plt/demo_plotters/plot_stx_shaded_line.py +29 -0
  99. scitex/dev/plt/demo_plotters/plot_stx_violin.py +28 -0
  100. scitex/dev/plt/demo_plotters/plot_stx_violinplot.py +28 -0
  101. scitex/dev/plt/mpl/get_dir_ax.py +46 -0
  102. scitex/dev/plt/mpl/get_signatures.py +176 -0
  103. scitex/dev/plt/mpl/get_signatures_details.py +522 -0
  104. scitex/dev/plt/plot_mpl_axhline.py +0 -0
  105. scitex/dev/plt/plot_mpl_axhspan.py +0 -0
  106. scitex/dev/plt/plot_mpl_axvline.py +0 -0
  107. scitex/dev/plt/plot_mpl_axvspan.py +0 -0
  108. scitex/dev/plt/plot_mpl_bar.py +0 -0
  109. scitex/dev/plt/plot_mpl_barh.py +0 -0
  110. scitex/dev/plt/plot_mpl_boxplot.py +0 -0
  111. scitex/dev/plt/plot_mpl_contour.py +0 -0
  112. scitex/dev/plt/plot_mpl_contourf.py +0 -0
  113. scitex/dev/plt/plot_mpl_errorbar.py +0 -0
  114. scitex/dev/plt/plot_mpl_eventplot.py +0 -0
  115. scitex/dev/plt/plot_mpl_fill.py +0 -0
  116. scitex/dev/plt/plot_mpl_fill_between.py +0 -0
  117. scitex/dev/plt/plot_mpl_hexbin.py +0 -0
  118. scitex/dev/plt/plot_mpl_hist.py +0 -0
  119. scitex/dev/plt/plot_mpl_hist2d.py +0 -0
  120. scitex/dev/plt/plot_mpl_imshow.py +0 -0
  121. scitex/dev/plt/plot_mpl_pcolormesh.py +0 -0
  122. scitex/dev/plt/plot_mpl_pie.py +0 -0
  123. scitex/dev/plt/plot_mpl_plot.py +0 -0
  124. scitex/dev/plt/plot_mpl_quiver.py +0 -0
  125. scitex/dev/plt/plot_mpl_scatter.py +0 -0
  126. scitex/dev/plt/plot_mpl_stackplot.py +0 -0
  127. scitex/dev/plt/plot_mpl_stem.py +0 -0
  128. scitex/dev/plt/plot_mpl_step.py +0 -0
  129. scitex/dev/plt/plot_mpl_violinplot.py +0 -0
  130. scitex/dev/plt/plot_sns_barplot.py +0 -0
  131. scitex/dev/plt/plot_sns_boxplot.py +0 -0
  132. scitex/dev/plt/plot_sns_heatmap.py +0 -0
  133. scitex/dev/plt/plot_sns_histplot.py +0 -0
  134. scitex/dev/plt/plot_sns_kdeplot.py +0 -0
  135. scitex/dev/plt/plot_sns_lineplot.py +0 -0
  136. scitex/dev/plt/plot_sns_scatterplot.py +0 -0
  137. scitex/dev/plt/plot_sns_stripplot.py +0 -0
  138. scitex/dev/plt/plot_sns_swarmplot.py +0 -0
  139. scitex/dev/plt/plot_sns_violinplot.py +0 -0
  140. scitex/dev/plt/plot_stx_bar.py +0 -0
  141. scitex/dev/plt/plot_stx_barh.py +0 -0
  142. scitex/dev/plt/plot_stx_box.py +0 -0
  143. scitex/dev/plt/plot_stx_boxplot.py +0 -0
  144. scitex/dev/plt/plot_stx_conf_mat.py +0 -0
  145. scitex/dev/plt/plot_stx_contour.py +0 -0
  146. scitex/dev/plt/plot_stx_ecdf.py +0 -0
  147. scitex/dev/plt/plot_stx_errorbar.py +0 -0
  148. scitex/dev/plt/plot_stx_fill_between.py +0 -0
  149. scitex/dev/plt/plot_stx_fillv.py +0 -0
  150. scitex/dev/plt/plot_stx_heatmap.py +0 -0
  151. scitex/dev/plt/plot_stx_image.py +0 -0
  152. scitex/dev/plt/plot_stx_imshow.py +0 -0
  153. scitex/dev/plt/plot_stx_joyplot.py +0 -0
  154. scitex/dev/plt/plot_stx_kde.py +0 -0
  155. scitex/dev/plt/plot_stx_line.py +0 -0
  156. scitex/dev/plt/plot_stx_mean_ci.py +0 -0
  157. scitex/dev/plt/plot_stx_mean_std.py +0 -0
  158. scitex/dev/plt/plot_stx_median_iqr.py +0 -0
  159. scitex/dev/plt/plot_stx_raster.py +0 -0
  160. scitex/dev/plt/plot_stx_rectangle.py +0 -0
  161. scitex/dev/plt/plot_stx_scatter.py +0 -0
  162. scitex/dev/plt/plot_stx_shaded_line.py +0 -0
  163. scitex/dev/plt/plot_stx_violin.py +0 -0
  164. scitex/dev/plt/plot_stx_violinplot.py +0 -0
  165. scitex/diagram/README.md +197 -0
  166. scitex/diagram/__init__.py +48 -0
  167. scitex/diagram/_compile.py +312 -0
  168. scitex/diagram/_diagram.py +355 -0
  169. scitex/diagram/_presets.py +173 -0
  170. scitex/diagram/_schema.py +182 -0
  171. scitex/diagram/_split.py +278 -0
  172. scitex/dict/_pop_keys.py +1 -7
  173. scitex/dsp/__init__.py +15 -10
  174. scitex/dsp/add_noise.py +5 -2
  175. scitex/dsp/example.py +35 -22
  176. scitex/dsp/filt.py +8 -3
  177. scitex/dsp/reference.py +3 -2
  178. scitex/dsp/utils/__init__.py +2 -1
  179. scitex/dsp/utils/_differential_bandpass_filters.py +14 -4
  180. scitex/dt/__init__.py +39 -2
  181. scitex/errors.py +82 -521
  182. scitex/fig/__init__.py +4 -4
  183. scitex/fig/editor/__init__.py +5 -2
  184. scitex/fig/editor/_dearpygui_editor.py +1 -1
  185. scitex/fig/editor/_mpl_editor.py +1 -1
  186. scitex/fig/editor/_qt_editor.py +1 -1
  187. scitex/fig/editor/_tkinter_editor.py +1 -1
  188. scitex/fig/editor/edit/__init__.py +50 -0
  189. scitex/fig/editor/edit/backend_detector.py +109 -0
  190. scitex/fig/editor/edit/bundle_resolver.py +240 -0
  191. scitex/fig/editor/edit/editor_launcher.py +239 -0
  192. scitex/fig/editor/edit/manual_handler.py +53 -0
  193. scitex/fig/editor/edit/panel_loader.py +232 -0
  194. scitex/fig/editor/edit/path_resolver.py +67 -0
  195. scitex/fig/editor/flask_editor/_bbox.py +23 -0
  196. scitex/fig/editor/flask_editor/_core.py +908 -103
  197. scitex/fig/editor/flask_editor/_renderer.py +74 -0
  198. scitex/fig/editor/flask_editor/static/css/base/reset.css +41 -0
  199. scitex/fig/editor/flask_editor/static/css/base/typography.css +16 -0
  200. scitex/fig/editor/flask_editor/static/css/base/variables.css +85 -0
  201. scitex/fig/editor/flask_editor/static/css/components/buttons.css +217 -0
  202. scitex/fig/editor/flask_editor/static/css/components/context-menu.css +93 -0
  203. scitex/fig/editor/flask_editor/static/css/components/dropdown.css +57 -0
  204. scitex/fig/editor/flask_editor/static/css/components/forms.css +112 -0
  205. scitex/fig/editor/flask_editor/static/css/components/modal.css +59 -0
  206. scitex/fig/editor/flask_editor/static/css/components/sections.css +212 -0
  207. scitex/fig/editor/flask_editor/static/css/features/canvas.css +176 -0
  208. scitex/fig/editor/flask_editor/static/css/features/element-inspector.css +190 -0
  209. scitex/fig/editor/flask_editor/static/css/features/loading.css +59 -0
  210. scitex/fig/editor/flask_editor/static/css/features/overlay.css +45 -0
  211. scitex/fig/editor/flask_editor/static/css/features/panel-grid.css +95 -0
  212. scitex/fig/editor/flask_editor/static/css/features/selection.css +101 -0
  213. scitex/fig/editor/flask_editor/static/css/features/statistics.css +138 -0
  214. scitex/fig/editor/flask_editor/static/css/index.css +31 -0
  215. scitex/fig/editor/flask_editor/static/css/layout/container.css +7 -0
  216. scitex/fig/editor/flask_editor/static/css/layout/controls.css +56 -0
  217. scitex/fig/editor/flask_editor/static/css/layout/preview.css +78 -0
  218. scitex/fig/editor/flask_editor/static/js/alignment/axis.js +314 -0
  219. scitex/fig/editor/flask_editor/static/js/alignment/basic.js +107 -0
  220. scitex/fig/editor/flask_editor/static/js/alignment/distribute.js +54 -0
  221. scitex/fig/editor/flask_editor/static/js/canvas/canvas.js +172 -0
  222. scitex/fig/editor/flask_editor/static/js/canvas/dragging.js +258 -0
  223. scitex/fig/editor/flask_editor/static/js/canvas/resize.js +48 -0
  224. scitex/fig/editor/flask_editor/static/js/canvas/selection.js +71 -0
  225. scitex/fig/editor/flask_editor/static/js/core/api.js +288 -0
  226. scitex/fig/editor/flask_editor/static/js/core/state.js +143 -0
  227. scitex/fig/editor/flask_editor/static/js/core/utils.js +245 -0
  228. scitex/fig/editor/flask_editor/static/js/dev/element-inspector.js +992 -0
  229. scitex/fig/editor/flask_editor/static/js/editor/bbox.js +339 -0
  230. scitex/fig/editor/flask_editor/static/js/editor/element-drag.js +286 -0
  231. scitex/fig/editor/flask_editor/static/js/editor/overlay.js +371 -0
  232. scitex/fig/editor/flask_editor/static/js/editor/preview.js +293 -0
  233. scitex/fig/editor/flask_editor/static/js/main.js +426 -0
  234. scitex/fig/editor/flask_editor/static/js/shortcuts/context-menu.js +152 -0
  235. scitex/fig/editor/flask_editor/static/js/shortcuts/keyboard.js +265 -0
  236. scitex/fig/editor/flask_editor/static/js/ui/controls.js +184 -0
  237. scitex/fig/editor/flask_editor/static/js/ui/download.js +57 -0
  238. scitex/fig/editor/flask_editor/static/js/ui/help.js +100 -0
  239. scitex/fig/editor/flask_editor/static/js/ui/theme.js +34 -0
  240. scitex/fig/editor/flask_editor/templates/__init__.py +95 -5
  241. scitex/fig/editor/flask_editor/templates/_html.py +27 -9
  242. scitex/fig/editor/flask_editor/templates/_scripts.py +1928 -131
  243. scitex/fig/editor/flask_editor/templates/_styles.py +363 -51
  244. scitex/fig/io/_bundle.py +104 -19
  245. scitex/fts/README.md +262 -0
  246. scitex/fts/TODO.md +66 -0
  247. scitex/fts/__init__.py +90 -0
  248. scitex/fts/_bundle/README_IN_BUNDLE.md +102 -0
  249. scitex/fts/_bundle/_FTS.py +657 -0
  250. scitex/fts/_bundle/__init__.py +38 -0
  251. scitex/fts/_bundle/_children.py +216 -0
  252. scitex/fts/_bundle/_conversion/__init__.py +15 -0
  253. scitex/fts/_bundle/_conversion/_bundle2dict.py +44 -0
  254. scitex/fts/_bundle/_conversion/_dict2bundle.py +50 -0
  255. scitex/fts/_bundle/_dataclasses/_Axes.py +57 -0
  256. scitex/fts/_bundle/_dataclasses/_BBox.py +54 -0
  257. scitex/fts/_bundle/_dataclasses/_ColumnDef.py +72 -0
  258. scitex/fts/_bundle/_dataclasses/_DataFormat.py +40 -0
  259. scitex/fts/_bundle/_dataclasses/_DataInfo.py +135 -0
  260. scitex/fts/_bundle/_dataclasses/_DataSource.py +44 -0
  261. scitex/fts/_bundle/_dataclasses/_Node.py +319 -0
  262. scitex/fts/_bundle/_dataclasses/_NodeRefs.py +45 -0
  263. scitex/fts/_bundle/_dataclasses/_SizeMM.py +38 -0
  264. scitex/fts/_bundle/_dataclasses/__init__.py +35 -0
  265. scitex/fts/_bundle/_extractors/__init__.py +32 -0
  266. scitex/fts/_bundle/_extractors/_extract_bar.py +131 -0
  267. scitex/fts/_bundle/_extractors/_extract_line.py +71 -0
  268. scitex/fts/_bundle/_extractors/_extract_scatter.py +79 -0
  269. scitex/fts/_bundle/_loader.py +134 -0
  270. scitex/fts/_bundle/_mpl_helpers.py +389 -0
  271. scitex/fts/_bundle/_saver.py +269 -0
  272. scitex/fts/_bundle/_storage.py +200 -0
  273. scitex/fts/_bundle/_utils/__init__.py +55 -0
  274. scitex/fts/_bundle/_utils/_const.py +26 -0
  275. scitex/fts/_bundle/_utils/_errors.py +73 -0
  276. scitex/fts/_bundle/_utils/_generate.py +21 -0
  277. scitex/fts/_bundle/_utils/_types.py +76 -0
  278. scitex/fts/_bundle/_validation.py +434 -0
  279. scitex/fts/_bundle/_zipbundle.py +165 -0
  280. scitex/fts/_fig/__init__.py +22 -0
  281. scitex/fts/_fig/_backend/__init__.py +53 -0
  282. scitex/fts/_fig/_backend/_export.py +165 -0
  283. scitex/fts/_fig/_backend/_parser.py +188 -0
  284. scitex/fts/_fig/_backend/_render.py +538 -0
  285. scitex/fts/_fig/_composite.py +345 -0
  286. scitex/fts/_fig/_dataclasses/_ChannelEncoding.py +46 -0
  287. scitex/fts/_fig/_dataclasses/_Encoding.py +82 -0
  288. scitex/fts/_fig/_dataclasses/_Theme.py +441 -0
  289. scitex/fts/_fig/_dataclasses/_TraceEncoding.py +52 -0
  290. scitex/fts/_fig/_dataclasses/__init__.py +47 -0
  291. scitex/fts/_fig/_editor/__init__.py +14 -0
  292. scitex/fts/_fig/_editor/_cui/__init__.py +33 -0
  293. scitex/fts/_fig/_editor/_cui/_backend_detector.py +39 -0
  294. scitex/fts/_fig/_editor/_cui/_bundle_resolver.py +366 -0
  295. scitex/fts/_fig/_editor/_cui/_editor_launcher.py +175 -0
  296. scitex/fts/_fig/_editor/_cui/_manual_handler.py +52 -0
  297. scitex/fts/_fig/_editor/_cui/_panel_loader.py +246 -0
  298. scitex/fts/_fig/_editor/_cui/_path_resolver.py +66 -0
  299. scitex/fts/_fig/_editor/_defaults.py +300 -0
  300. scitex/fts/_fig/_editor/_gui/__init__.py +11 -0
  301. scitex/fts/_fig/_editor/_gui/_flask_editor/__init__.py +20 -0
  302. scitex/fts/_fig/_editor/_gui/_flask_editor/_bbox.py +1339 -0
  303. scitex/fts/_fig/_editor/_gui/_flask_editor/_core.py +1688 -0
  304. scitex/fts/_fig/_editor/_gui/_flask_editor/_plotter.py +664 -0
  305. scitex/fts/_fig/_editor/_gui/_flask_editor/_renderer.py +853 -0
  306. scitex/fts/_fig/_editor/_gui/_flask_editor/_utils.py +79 -0
  307. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/base/reset.css +41 -0
  308. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/base/typography.css +16 -0
  309. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/base/variables.css +85 -0
  310. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/components/buttons.css +217 -0
  311. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/components/context-menu.css +93 -0
  312. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/components/dropdown.css +57 -0
  313. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/components/forms.css +112 -0
  314. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/components/modal.css +59 -0
  315. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/components/sections.css +212 -0
  316. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/features/canvas.css +176 -0
  317. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/features/element-inspector.css +190 -0
  318. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/features/loading.css +59 -0
  319. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/features/overlay.css +45 -0
  320. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/features/panel-grid.css +95 -0
  321. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/features/selection.css +101 -0
  322. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/features/statistics.css +138 -0
  323. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/index.css +31 -0
  324. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/layout/container.css +7 -0
  325. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/layout/controls.css +56 -0
  326. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/layout/preview.css +78 -0
  327. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/alignment/axis.js +314 -0
  328. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/alignment/basic.js +107 -0
  329. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/alignment/distribute.js +54 -0
  330. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/canvas/canvas.js +172 -0
  331. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/canvas/dragging.js +258 -0
  332. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/canvas/resize.js +48 -0
  333. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/canvas/selection.js +71 -0
  334. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/core/api.js +288 -0
  335. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/core/state.js +143 -0
  336. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/core/utils.js +245 -0
  337. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/dev/element-inspector.js +992 -0
  338. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/editor/bbox.js +339 -0
  339. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/editor/element-drag.js +286 -0
  340. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/editor/overlay.js +371 -0
  341. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/editor/preview.js +293 -0
  342. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/main.js +426 -0
  343. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/shortcuts/context-menu.js +152 -0
  344. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/shortcuts/keyboard.js +265 -0
  345. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/ui/controls.js +184 -0
  346. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/ui/download.js +57 -0
  347. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/ui/help.js +100 -0
  348. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/ui/theme.js +34 -0
  349. scitex/fts/_fig/_editor/_gui/_flask_editor/templates/__init__.py +124 -0
  350. scitex/fts/_fig/_editor/_gui/_flask_editor/templates/_html.py +851 -0
  351. scitex/fts/_fig/_editor/_gui/_flask_editor/templates/_scripts.py +4932 -0
  352. scitex/fts/_fig/_editor/_gui/_flask_editor/templates/_styles.py +1657 -0
  353. scitex/fts/_fig/_editor/_gui/_flask_editor.py +36 -0
  354. scitex/fts/_fig/_models/_Annotations.py +115 -0
  355. scitex/fts/_fig/_models/_Axes.py +152 -0
  356. scitex/fts/_fig/_models/_Figure.py +138 -0
  357. scitex/fts/_fig/_models/_Guides.py +104 -0
  358. scitex/fts/_fig/_models/_Plot.py +123 -0
  359. scitex/fts/_fig/_models/_Styles.py +245 -0
  360. scitex/fts/_fig/_models/__init__.py +80 -0
  361. scitex/fts/_fig/_models/_plot_types/__init__.py +156 -0
  362. scitex/fts/_fig/_models/_plot_types/_bar.py +43 -0
  363. scitex/fts/_fig/_models/_plot_types/_box.py +38 -0
  364. scitex/fts/_fig/_models/_plot_types/_distribution.py +36 -0
  365. scitex/fts/_fig/_models/_plot_types/_errorbar.py +60 -0
  366. scitex/fts/_fig/_models/_plot_types/_histogram.py +30 -0
  367. scitex/fts/_fig/_models/_plot_types/_image.py +61 -0
  368. scitex/fts/_fig/_models/_plot_types/_line.py +57 -0
  369. scitex/fts/_fig/_models/_plot_types/_scatter.py +30 -0
  370. scitex/fts/_fig/_models/_plot_types/_seaborn.py +121 -0
  371. scitex/fts/_fig/_models/_plot_types/_violin.py +36 -0
  372. scitex/fts/_fig/_utils/__init__.py +129 -0
  373. scitex/fts/_fig/_utils/_auto_layout.py +127 -0
  374. scitex/fts/_fig/_utils/_calc_bounds.py +111 -0
  375. scitex/fts/_fig/_utils/_const_sizes.py +48 -0
  376. scitex/fts/_fig/_utils/_convert_coords.py +77 -0
  377. scitex/fts/_fig/_utils/_get_template.py +178 -0
  378. scitex/fts/_fig/_utils/_normalize.py +73 -0
  379. scitex/fts/_fig/_utils/_plot_layout.py +397 -0
  380. scitex/fts/_fig/_utils/_validate.py +197 -0
  381. scitex/fts/_kinds/__init__.py +45 -0
  382. scitex/fts/_kinds/_figure/__init__.py +19 -0
  383. scitex/fts/_kinds/_figure/_composite.py +345 -0
  384. scitex/fts/_kinds/_plot/__init__.py +25 -0
  385. scitex/fts/_kinds/_plot/_backend/__init__.py +53 -0
  386. scitex/fts/_kinds/_plot/_backend/_export.py +165 -0
  387. scitex/fts/_kinds/_plot/_backend/_parser.py +188 -0
  388. scitex/fts/_kinds/_plot/_backend/_render.py +538 -0
  389. scitex/fts/_kinds/_plot/_dataclasses/_ChannelEncoding.py +46 -0
  390. scitex/fts/_kinds/_plot/_dataclasses/_Encoding.py +82 -0
  391. scitex/fts/_kinds/_plot/_dataclasses/_Theme.py +441 -0
  392. scitex/fts/_kinds/_plot/_dataclasses/_TraceEncoding.py +52 -0
  393. scitex/fts/_kinds/_plot/_dataclasses/__init__.py +47 -0
  394. scitex/fts/_kinds/_plot/_models/_Annotations.py +115 -0
  395. scitex/fts/_kinds/_plot/_models/_Axes.py +152 -0
  396. scitex/fts/_kinds/_plot/_models/_Figure.py +138 -0
  397. scitex/fts/_kinds/_plot/_models/_Guides.py +104 -0
  398. scitex/fts/_kinds/_plot/_models/_Plot.py +123 -0
  399. scitex/fts/_kinds/_plot/_models/_Styles.py +245 -0
  400. scitex/fts/_kinds/_plot/_models/__init__.py +80 -0
  401. scitex/fts/_kinds/_plot/_models/_plot_types/__init__.py +156 -0
  402. scitex/fts/_kinds/_plot/_models/_plot_types/_bar.py +43 -0
  403. scitex/fts/_kinds/_plot/_models/_plot_types/_box.py +38 -0
  404. scitex/fts/_kinds/_plot/_models/_plot_types/_distribution.py +36 -0
  405. scitex/fts/_kinds/_plot/_models/_plot_types/_errorbar.py +60 -0
  406. scitex/fts/_kinds/_plot/_models/_plot_types/_histogram.py +30 -0
  407. scitex/fts/_kinds/_plot/_models/_plot_types/_image.py +61 -0
  408. scitex/fts/_kinds/_plot/_models/_plot_types/_line.py +57 -0
  409. scitex/fts/_kinds/_plot/_models/_plot_types/_scatter.py +30 -0
  410. scitex/fts/_kinds/_plot/_models/_plot_types/_seaborn.py +121 -0
  411. scitex/fts/_kinds/_plot/_models/_plot_types/_violin.py +36 -0
  412. scitex/fts/_kinds/_plot/_utils/__init__.py +129 -0
  413. scitex/fts/_kinds/_plot/_utils/_auto_layout.py +127 -0
  414. scitex/fts/_kinds/_plot/_utils/_calc_bounds.py +111 -0
  415. scitex/fts/_kinds/_plot/_utils/_const_sizes.py +48 -0
  416. scitex/fts/_kinds/_plot/_utils/_convert_coords.py +77 -0
  417. scitex/fts/_kinds/_plot/_utils/_get_template.py +178 -0
  418. scitex/fts/_kinds/_plot/_utils/_normalize.py +73 -0
  419. scitex/fts/_kinds/_plot/_utils/_plot_layout.py +397 -0
  420. scitex/fts/_kinds/_plot/_utils/_validate.py +197 -0
  421. scitex/fts/_kinds/_shape/__init__.py +141 -0
  422. scitex/fts/_kinds/_stats/__init__.py +56 -0
  423. scitex/fts/_kinds/_stats/_dataclasses/_Stats.py +423 -0
  424. scitex/fts/_kinds/_stats/_dataclasses/__init__.py +48 -0
  425. scitex/fts/_kinds/_table/__init__.py +72 -0
  426. scitex/fts/_kinds/_table/_latex/__init__.py +93 -0
  427. scitex/fts/_kinds/_table/_latex/_editor/__init__.py +11 -0
  428. scitex/fts/_kinds/_table/_latex/_editor/_app.py +725 -0
  429. scitex/fts/_kinds/_table/_latex/_export.py +279 -0
  430. scitex/fts/_kinds/_table/_latex/_figure_exporter.py +153 -0
  431. scitex/fts/_kinds/_table/_latex/_stats_formatter.py +274 -0
  432. scitex/fts/_kinds/_table/_latex/_table_exporter.py +362 -0
  433. scitex/fts/_kinds/_table/_latex/_utils.py +369 -0
  434. scitex/fts/_kinds/_table/_latex/_validator.py +445 -0
  435. scitex/fts/_kinds/_text/__init__.py +77 -0
  436. scitex/fts/_schemas/data_info.schema.json +75 -0
  437. scitex/fts/_schemas/encoding.schema.json +90 -0
  438. scitex/fts/_schemas/node.schema.json +145 -0
  439. scitex/fts/_schemas/render_manifest.schema.json +62 -0
  440. scitex/fts/_schemas/stats.schema.json +132 -0
  441. scitex/fts/_schemas/theme.schema.json +141 -0
  442. scitex/fts/_stats/__init__.py +48 -0
  443. scitex/fts/_stats/_dataclasses/_Stats.py +423 -0
  444. scitex/fts/_stats/_dataclasses/__init__.py +48 -0
  445. scitex/fts/_tables/__init__.py +65 -0
  446. scitex/fts/_tables/_latex/__init__.py +93 -0
  447. scitex/fts/_tables/_latex/_editor/__init__.py +11 -0
  448. scitex/fts/_tables/_latex/_editor/_app.py +725 -0
  449. scitex/fts/_tables/_latex/_export.py +279 -0
  450. scitex/fts/_tables/_latex/_figure_exporter.py +153 -0
  451. scitex/fts/_tables/_latex/_stats_formatter.py +274 -0
  452. scitex/fts/_tables/_latex/_table_exporter.py +362 -0
  453. scitex/fts/_tables/_latex/_utils.py +369 -0
  454. scitex/fts/_tables/_latex/_validator.py +445 -0
  455. scitex/gen/__init__.py +66 -25
  456. scitex/gen/misc.py +28 -0
  457. scitex/io/__init__.py +47 -20
  458. scitex/io/_load.py +87 -36
  459. scitex/io/_load_modules/__init__.py +10 -7
  460. scitex/io/_load_modules/_pandas.py +6 -1
  461. scitex/io/_save.py +299 -1556
  462. scitex/io/_save_modules/__init__.py +76 -19
  463. scitex/io/_save_modules/_figure_utils.py +90 -0
  464. scitex/io/_save_modules/_image_csv.py +497 -0
  465. scitex/io/_save_modules/_legends.py +91 -0
  466. scitex/io/_save_modules/_pltz_bundle.py +356 -0
  467. scitex/io/_save_modules/_pltz_stx.py +536 -0
  468. scitex/io/_save_modules/_stx_bundle.py +104 -0
  469. scitex/io/_save_modules/_symlink.py +96 -0
  470. scitex/io/_save_modules/_yaml.py +1 -1
  471. scitex/io/_save_modules/_zarr.py +64 -18
  472. scitex/io/bundle/README.md +212 -0
  473. scitex/io/bundle/__init__.py +110 -0
  474. scitex/io/{_bundle.py → bundle/_core.py} +219 -89
  475. scitex/io/bundle/_nested.py +713 -0
  476. scitex/io/bundle/_types.py +74 -0
  477. scitex/io/bundle/_zip.py +487 -0
  478. scitex/io/utils/h5_to_zarr.py +1 -1
  479. scitex/logging/__init__.py +108 -13
  480. scitex/logging/_errors.py +508 -0
  481. scitex/logging/_formatters.py +30 -6
  482. scitex/logging/_warnings.py +261 -0
  483. scitex/plt/__init__.py +4 -1
  484. scitex/plt/_figrecipe.py +236 -0
  485. scitex/plt/_subplots/_AxisWrapper.py +6 -0
  486. scitex/plt/_subplots/_AxisWrapperMixins/_AdjustmentMixin/__init__.py +0 -0
  487. scitex/plt/_subplots/_AxisWrapperMixins/_AdjustmentMixin/_labels.py +0 -0
  488. scitex/plt/_subplots/_AxisWrapperMixins/_AdjustmentMixin/_metadata.py +0 -0
  489. scitex/plt/_subplots/_AxisWrapperMixins/_AdjustmentMixin/_visual.py +0 -0
  490. scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin/__init__.py +0 -0
  491. scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin/_base.py +0 -0
  492. scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin/_scientific.py +0 -0
  493. scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin/_statistical.py +0 -0
  494. scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin/_stx_aliases.py +0 -0
  495. scitex/plt/_subplots/_AxisWrapperMixins/_RawMatplotlibMixin.py +0 -0
  496. scitex/plt/_subplots/_AxisWrapperMixins/_SeabornMixin/__init__.py +0 -0
  497. scitex/plt/_subplots/_AxisWrapperMixins/_SeabornMixin/_base.py +0 -0
  498. scitex/plt/_subplots/_AxisWrapperMixins/_SeabornMixin/_wrappers.py +0 -0
  499. scitex/plt/_subplots/_AxisWrapperMixins/_UnitAwareMixin.py +112 -1
  500. scitex/plt/_subplots/_FigWrapper.py +15 -0
  501. scitex/plt/_subplots/_SubplotsWrapper.py +125 -489
  502. scitex/plt/_subplots/_export_as_csv.py +11 -0
  503. scitex/plt/_subplots/_export_as_csv_formatters/__init__.py +2 -0
  504. scitex/plt/_subplots/_export_as_csv_formatters/_format_pcolormesh.py +66 -0
  505. scitex/plt/_subplots/_export_as_csv_formatters/_format_stackplot.py +62 -0
  506. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_bar.py +0 -0
  507. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_barh.py +0 -0
  508. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_errorbar.py +0 -0
  509. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_scatter.py +0 -0
  510. scitex/plt/_subplots/_export_as_csv_formatters/test_formatters.py +208 -0
  511. scitex/plt/_subplots/_fonts.py +71 -0
  512. scitex/plt/_subplots/_mm_layout.py +282 -0
  513. scitex/plt/gallery/__init__.py +99 -2
  514. scitex/plt/io/_layered_bundle.py +0 -0
  515. scitex/plt/styles/_plot_postprocess.py +3 -1
  516. scitex/plt/utils/_configure_mpl.py +16 -19
  517. scitex/repro/_RandomStateManager.py +13 -8
  518. scitex/resource/__init__.py +19 -1
  519. scitex/resource/_utils/_get_env_info.py +13 -25
  520. scitex/schema/__init__.py +149 -160
  521. scitex/schema/_encoding.py +273 -0
  522. scitex/schema/_figure_elements.py +406 -0
  523. scitex/schema/_plot.py +0 -0
  524. scitex/schema/_theme.py +360 -0
  525. scitex/schema/_validation.py +0 -98
  526. scitex/scholar/__init__.py +56 -14
  527. scitex/scholar/auth/ScholarAuthManager.py +1 -1
  528. scitex/scholar/auth/__init__.py +11 -2
  529. scitex/scholar/auth/providers/BaseAuthenticator.py +1 -1
  530. scitex/scholar/auth/providers/EZProxyAuthenticator.py +1 -1
  531. scitex/scholar/auth/providers/OpenAthensAuthenticator.py +1 -1
  532. scitex/scholar/auth/providers/ShibbolethAuthenticator.py +1 -1
  533. scitex/scholar/config/ScholarConfig.py +1 -1
  534. scitex/scholar/core/Scholar.py +1 -1
  535. scitex/session/_decorator.py +18 -16
  536. scitex/session/_lifecycle.py +9 -11
  537. scitex/session/template.py +9 -8
  538. scitex/sh/test_sh.py +72 -0
  539. scitex/sh/test_sh_simple.py +61 -0
  540. scitex/stats/__init__.py +221 -97
  541. scitex/stats/_schema.py +21 -22
  542. scitex/stats/descriptive/_circular.py +212 -351
  543. scitex/stats/descriptive/_describe.py +81 -132
  544. scitex/stats/descriptive/_nan.py +205 -433
  545. scitex/stats/descriptive/_real.py +127 -141
  546. scitex/str/_format_plot_text.py +5 -5
  547. scitex/str/_latex.py +26 -84
  548. scitex/str/_latex_fallback.py +53 -47
  549. scitex/web/_search_pubmed.py +5 -4
  550. scitex/writer/tests/test_diff_between.py +451 -0
  551. scitex/writer/tests/test_document_section.py +311 -0
  552. scitex/writer/tests/test_document_workflow.py +393 -0
  553. scitex/writer/tests/test_writer.py +361 -0
  554. scitex/writer/tests/test_writer_integration.py +303 -0
  555. {scitex-2.7.3.dist-info → scitex-2.10.0.dist-info}/METADATA +364 -181
  556. {scitex-2.7.3.dist-info → scitex-2.10.0.dist-info}/RECORD +479 -108
  557. scitex/fig/editor/_edit.py +0 -751
  558. scitex/scholar/docs/to_claude/guidelines/examples/mgmt/ARCHITECTURE_EXAMPLE.md +0 -905
  559. scitex/scholar/docs/to_claude/guidelines/examples/mgmt/BULLETIN_BOARD_EXAMPLE.md +0 -99
  560. scitex/scholar/docs/to_claude/guidelines/examples/mgmt/PROJECT_DESCRIPTION_EXAMPLE.md +0 -96
  561. {scitex-2.7.3.dist-info → scitex-2.10.0.dist-info}/WHEEL +0 -0
  562. {scitex-2.7.3.dist-info → scitex-2.10.0.dist-info}/entry_points.txt +0 -0
  563. {scitex-2.7.3.dist-info → scitex-2.10.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,7 +1,24 @@
1
1
  #!/usr/bin/env python3
2
2
  # -*- coding: utf-8 -*-
3
3
  # File: ./src/scitex/vis/editor/flask_editor/templates/scripts.py
4
- """JavaScript for the Flask editor UI."""
4
+ """JavaScript for the Flask editor UI.
5
+
6
+ DEPRECATED: This inline JavaScript module is kept for fallback compatibility only.
7
+ The JavaScript has been modularized into static/js/ directory:
8
+ - static/js/main.js (main entry point)
9
+ - static/js/core/ (state, api, utils)
10
+ - static/js/canvas/ (canvas, dragging, resize, selection)
11
+ - static/js/editor/ (preview, overlay, bbox, element-drag)
12
+ - static/js/alignment/ (basic, axis, distribute)
13
+ - static/js/shortcuts/ (keyboard, context-menu)
14
+ - static/js/ui/ (controls, download, help, theme)
15
+
16
+ To use static files (recommended):
17
+ Set USE_STATIC_FILES = True in templates/__init__.py
18
+
19
+ To use this inline version (fallback):
20
+ Set USE_STATIC_FILES = False in templates/__init__.py
21
+ """
5
22
 
6
23
  JS_SCRIPTS = """
7
24
  let overrides = {{ overrides|safe }};
@@ -41,6 +58,56 @@ function isDarkMode() {
41
58
  return document.documentElement.getAttribute('data-theme') === 'dark';
42
59
  }
43
60
 
61
+ // Calculate actual rendered image dimensions when using object-fit: contain
62
+ // Returns: {renderedWidth, renderedHeight, offsetX, offsetY, containerWidth, containerHeight}
63
+ function getObjectFitContainDimensions(img) {
64
+ const containerWidth = img.offsetWidth;
65
+ const containerHeight = img.offsetHeight;
66
+ const naturalWidth = img.naturalWidth;
67
+ const naturalHeight = img.naturalHeight;
68
+
69
+ // Handle edge cases
70
+ if (!naturalWidth || !naturalHeight || !containerWidth || !containerHeight) {
71
+ return {
72
+ renderedWidth: containerWidth,
73
+ renderedHeight: containerHeight,
74
+ offsetX: 0,
75
+ offsetY: 0,
76
+ containerWidth,
77
+ containerHeight
78
+ };
79
+ }
80
+
81
+ // Calculate scale factor for object-fit: contain
82
+ const containerRatio = containerWidth / containerHeight;
83
+ const imageRatio = naturalWidth / naturalHeight;
84
+
85
+ let renderedWidth, renderedHeight, offsetX, offsetY;
86
+
87
+ if (imageRatio > containerRatio) {
88
+ // Image is wider than container - fit to width, letterbox top/bottom
89
+ renderedWidth = containerWidth;
90
+ renderedHeight = containerWidth / imageRatio;
91
+ offsetX = 0;
92
+ offsetY = (containerHeight - renderedHeight) / 2;
93
+ } else {
94
+ // Image is taller than container - fit to height, letterbox left/right
95
+ renderedHeight = containerHeight;
96
+ renderedWidth = containerHeight * imageRatio;
97
+ offsetX = (containerWidth - renderedWidth) / 2;
98
+ offsetY = 0;
99
+ }
100
+
101
+ return {
102
+ renderedWidth,
103
+ renderedHeight,
104
+ offsetX,
105
+ offsetY,
106
+ containerWidth,
107
+ containerHeight
108
+ };
109
+ }
110
+
44
111
  // Hitmap-based element detection
45
112
  let hitmapCanvas = null;
46
113
  let hitmapCtx = null;
@@ -1353,6 +1420,8 @@ function toggleTheme() {
1353
1420
  html.setAttribute('data-theme', next);
1354
1421
  document.getElementById('theme-icon').innerHTML = next === 'dark' ? '☾' : '☼';
1355
1422
  localStorage.setItem('scitex-editor-theme', next);
1423
+ // Re-render single panel preview with dark/light mode colors (if visible)
1424
+ updatePreview(true);
1356
1425
  }
1357
1426
 
1358
1427
  // Collapsible sections
@@ -1632,14 +1701,22 @@ document.addEventListener('DOMContentLoaded', () => {
1632
1701
  // =============================================================================
1633
1702
  // Loading Helpers
1634
1703
  // =============================================================================
1635
- function showLoading() {
1636
- const overlay = document.getElementById('loading-overlay');
1637
- if (overlay) overlay.style.display = 'flex';
1704
+ function showLoading(message = 'Loading...') {
1705
+ const globalOverlay = document.getElementById('global-loading-overlay');
1706
+ const localOverlay = document.getElementById('loading-overlay');
1707
+ if (globalOverlay) {
1708
+ globalOverlay.style.display = 'flex';
1709
+ const loadingText = globalOverlay.querySelector('.loading-text');
1710
+ if (loadingText) loadingText.textContent = message;
1711
+ }
1712
+ if (localOverlay) localOverlay.style.display = 'flex';
1638
1713
  }
1639
1714
 
1640
1715
  function hideLoading() {
1641
- const overlay = document.getElementById('loading-overlay');
1642
- if (overlay) overlay.style.display = 'none';
1716
+ const globalOverlay = document.getElementById('global-loading-overlay');
1717
+ const localOverlay = document.getElementById('loading-overlay');
1718
+ if (globalOverlay) globalOverlay.style.display = 'none';
1719
+ if (localOverlay) localOverlay.style.display = 'none';
1643
1720
  }
1644
1721
 
1645
1722
  // Update form controls from overrides (used when switching panels)
@@ -1793,8 +1870,12 @@ async function loadPanelGrid() {
1793
1870
 
1794
1871
  console.log('Loading panel canvas for', panelData.panels.length, 'panels');
1795
1872
 
1796
- // Show panel header
1797
- document.getElementById('preview-header').style.display = 'flex';
1873
+ // Hide single-panel preview completely for multi-panel bundles (unified canvas only)
1874
+ document.getElementById('preview-header').style.display = 'none';
1875
+ const previewWrapper = document.querySelector('.preview-wrapper');
1876
+ if (previewWrapper) {
1877
+ previewWrapper.style.display = 'none';
1878
+ }
1798
1879
 
1799
1880
  // Fetch all panel images with bboxes
1800
1881
  try {
@@ -1809,12 +1890,29 @@ async function loadPanelGrid() {
1809
1890
  const canvasEl = document.getElementById('panel-canvas');
1810
1891
  canvasEl.innerHTML = '';
1811
1892
 
1812
- // Calculate layout - arrange panels in a grid-like canvas
1813
- const numPanels = data.panels.length;
1814
- const cols = Math.ceil(Math.sqrt(numPanels));
1815
- const baseWidth = 220;
1816
- const baseHeight = 180;
1817
- const padding = 15;
1893
+ // Use figz layout to position panels as unified canvas (matching export)
1894
+ const hasLayout = data.layout && Object.keys(data.layout).length > 0;
1895
+
1896
+ // Calculate scale factor: convert mm to pixels
1897
+ // Find total figure dimensions from layout
1898
+ let maxX = 0, maxY = 0;
1899
+ if (hasLayout) {
1900
+ Object.values(data.layout).forEach(l => {
1901
+ const right = (l.position?.x_mm || 0) + (l.size?.width_mm || 80);
1902
+ const bottom = (l.position?.y_mm || 0) + (l.size?.height_mm || 50);
1903
+ maxX = Math.max(maxX, right);
1904
+ maxY = Math.max(maxY, bottom);
1905
+ });
1906
+ }
1907
+
1908
+ // Scale to fit canvas (max width ~700px for good display)
1909
+ const canvasMaxWidth = 700;
1910
+ const scale = hasLayout && maxX > 0 ? canvasMaxWidth / maxX : 3; // ~3px per mm fallback
1911
+ canvasScale = scale; // Store globally for drag conversions
1912
+
1913
+ // Reset layout tracking
1914
+ panelLayoutMm = {};
1915
+ layoutModified = false;
1818
1916
 
1819
1917
  data.panels.forEach((panel, idx) => {
1820
1918
  // Store bboxes and imgSize in cache for interactive hover/click
@@ -1828,18 +1926,42 @@ async function loadPanelGrid() {
1828
1926
  console.warn(`Panel ${panel.name}: missing bboxes or img_size`, {bboxes: !!panel.bboxes, img_size: !!panel.img_size});
1829
1927
  }
1830
1928
 
1831
- // Calculate position
1832
- const col = idx % cols;
1833
- const row = Math.floor(idx / cols);
1834
- if (!panelPositions[panel.name]) {
1835
- panelPositions[panel.name] = {
1929
+ // Use figz layout for positioning (unified canvas like export)
1930
+ let pos, posMm;
1931
+ if (panel.layout && panel.layout.position && panel.layout.size) {
1932
+ const x_mm = panel.layout.position.x_mm || 0;
1933
+ const y_mm = panel.layout.position.y_mm || 0;
1934
+ const width_mm = panel.layout.size.width_mm || 80;
1935
+ const height_mm = panel.layout.size.height_mm || 50;
1936
+ pos = {
1937
+ x: x_mm * scale,
1938
+ y: y_mm * scale,
1939
+ width: width_mm * scale,
1940
+ height: height_mm * scale,
1941
+ };
1942
+ posMm = { x_mm, y_mm, width_mm, height_mm };
1943
+ } else {
1944
+ // Fallback grid layout if no figz layout
1945
+ const cols = Math.ceil(Math.sqrt(data.panels.length));
1946
+ const baseWidth = 220, baseHeight = 180, padding = 15;
1947
+ const col = idx % cols;
1948
+ const row = Math.floor(idx / cols);
1949
+ pos = {
1836
1950
  x: padding + col * (baseWidth + padding),
1837
1951
  y: padding + row * (baseHeight + padding),
1838
1952
  width: baseWidth,
1839
1953
  height: baseHeight,
1840
1954
  };
1955
+ // Convert to mm for fallback
1956
+ posMm = {
1957
+ x_mm: pos.x / scale,
1958
+ y_mm: pos.y / scale,
1959
+ width_mm: pos.width / scale,
1960
+ height_mm: pos.height / scale,
1961
+ };
1841
1962
  }
1842
- const pos = panelPositions[panel.name];
1963
+ panelPositions[panel.name] = pos;
1964
+ panelLayoutMm[panel.name] = posMm;
1843
1965
 
1844
1966
  const item = document.createElement('div');
1845
1967
  item.className = 'panel-canvas-item' + (idx === currentPanelIndex ? ' active' : '');
@@ -1852,43 +1974,44 @@ async function loadPanelGrid() {
1852
1974
 
1853
1975
  if (panel.image) {
1854
1976
  item.innerHTML = `
1855
- <span class="panel-canvas-label">Panel ${panel.name}</span>
1977
+ <span class="panel-canvas-label">${panel.name}</span>
1978
+ <span class="panel-position-indicator" id="pos-${panel.name}"></span>
1979
+ <div class="panel-drag-handle" title="Drag to move panel">⋮⋮</div>
1856
1980
  <div class="panel-card-container">
1857
1981
  <img src="data:image/png;base64,${panel.image}" alt="Panel ${panel.name}">
1858
1982
  <svg class="panel-card-overlay" id="panel-overlay-${idx}"></svg>
1859
1983
  </div>
1860
- <div class="panel-canvas-resize"></div>
1861
1984
  `;
1862
1985
  } else {
1863
1986
  item.innerHTML = `
1864
- <span class="panel-canvas-label">Panel ${panel.name}</span>
1987
+ <span class="panel-canvas-label">${panel.name}</span>
1988
+ <span class="panel-position-indicator" id="pos-${panel.name}"></span>
1989
+ <div class="panel-drag-handle" title="Drag to move panel">⋮⋮</div>
1865
1990
  <div style="padding: 20px; color: var(--text-muted);">No preview</div>
1866
1991
  `;
1867
1992
  }
1868
1993
 
1869
- // Add interactive event handlers
1994
+ // Add interactive event handlers (hover, click for element selection)
1870
1995
  initCanvasItemInteraction(item, idx, panel.name);
1871
1996
 
1997
+ // Add drag handler for panel repositioning
1998
+ initPanelDrag(item, panel.name);
1999
+
1872
2000
  canvasEl.appendChild(item);
1873
2001
  });
1874
2002
 
1875
- // Update canvas height to fit all panels
1876
- const maxY = Math.max(...Object.values(panelPositions).map(p => p.y + p.height)) + padding;
1877
- canvasEl.style.minHeight = Math.max(400, maxY) + 'px';
2003
+ // Update canvas size to fit all panels (unified canvas)
2004
+ const canvasHeight = Math.max(...Object.values(panelPositions).map(p => p.y + p.height)) + 10;
2005
+ const canvasWidth = Math.max(...Object.values(panelPositions).map(p => p.x + p.width)) + 10;
2006
+ canvasEl.style.minHeight = Math.max(400, canvasHeight) + 'px';
2007
+ canvasEl.style.minWidth = canvasWidth + 'px';
1878
2008
 
1879
2009
  // Update panel indicator
1880
2010
  updatePanelIndicator();
1881
2011
 
1882
- // Show canvas for multi-panel figures
1883
- if (data.panels.length > 1) {
1884
- showingPanelGrid = true;
1885
- document.getElementById('panel-grid-section').style.display = 'block';
1886
- // Hide single-panel preview for multi-panel bundles
1887
- const previewWrapper = document.querySelector('.preview-wrapper');
1888
- if (previewWrapper) {
1889
- previewWrapper.style.display = 'none';
1890
- }
1891
- }
2012
+ // Show unified canvas for multi-panel figures
2013
+ showingPanelGrid = true;
2014
+ document.getElementById('panel-grid-section').style.display = 'block';
1892
2015
  } catch (e) {
1893
2016
  console.error('Error loading panels:', e);
1894
2017
  }
@@ -1911,25 +2034,43 @@ function initCanvasItemInteraction(item, panelIdx, panelName) {
1911
2034
  overlay.style.height = img.offsetHeight + 'px';
1912
2035
  });
1913
2036
 
1914
- // Mousemove for hover detection
2037
+ // Mousemove for hover detection (accounting for object-fit:contain letterboxing)
1915
2038
  container.addEventListener('mousemove', (e) => {
1916
2039
  const panelCache = panelBboxesCache[panelName];
1917
2040
  if (!panelCache) return;
1918
2041
 
1919
2042
  const rect = img.getBoundingClientRect();
2043
+ const dims = getObjectFitContainDimensions(img);
2044
+
2045
+ // Mouse position relative to container
1920
2046
  const x = e.clientX - rect.left;
1921
2047
  const y = e.clientY - rect.top;
1922
2048
 
1923
- const scaleX = panelCache.imgSize.width / rect.width;
1924
- const scaleY = panelCache.imgSize.height / rect.height;
1925
- const imgX = x * scaleX;
1926
- const imgY = y * scaleY;
2049
+ // Adjust for letterbox offset to get position relative to actual rendered image
2050
+ const imgRelX = x - dims.offsetX;
2051
+ const imgRelY = y - dims.offsetY;
2052
+
2053
+ // Check if click is within rendered image bounds
2054
+ if (imgRelX < 0 || imgRelY < 0 || imgRelX > dims.renderedWidth || imgRelY > dims.renderedHeight) {
2055
+ // Outside rendered image area (in letterbox region)
2056
+ if (panelHoveredElement !== null) {
2057
+ panelHoveredElement = null;
2058
+ updatePanelOverlay(overlay, panelCache.bboxes, panelCache.imgSize, rect.width, rect.height, null, null, img);
2059
+ }
2060
+ return;
2061
+ }
2062
+
2063
+ // Scale to original image coordinates
2064
+ const scaleX = panelCache.imgSize.width / dims.renderedWidth;
2065
+ const scaleY = panelCache.imgSize.height / dims.renderedHeight;
2066
+ const imgX = imgRelX * scaleX;
2067
+ const imgY = imgRelY * scaleY;
1927
2068
 
1928
2069
  const element = findElementInPanelAt(imgX, imgY, panelCache.bboxes);
1929
2070
  if (element !== panelHoveredElement || activePanelCard !== item) {
1930
2071
  panelHoveredElement = element;
1931
2072
  activePanelCard = item;
1932
- updatePanelOverlay(overlay, panelCache.bboxes, panelCache.imgSize, rect.width, rect.height, panelHoveredElement, null);
2073
+ updatePanelOverlay(overlay, panelCache.bboxes, panelCache.imgSize, rect.width, rect.height, panelHoveredElement, null, img);
1933
2074
  }
1934
2075
  });
1935
2076
 
@@ -1937,11 +2078,22 @@ function initCanvasItemInteraction(item, panelIdx, panelName) {
1937
2078
  container.addEventListener('mouseleave', () => {
1938
2079
  panelHoveredElement = null;
1939
2080
  if (activePanelCard === item) {
1940
- updatePanelOverlay(overlay, {}, {width: 0, height: 0}, 0, 0, null, null);
2081
+ updatePanelOverlay(overlay, {}, {width: 0, height: 0}, 0, 0, null, null, null);
1941
2082
  }
1942
2083
  });
1943
2084
 
1944
- // Click to select element
2085
+ // Mousedown to start element drag (ONLY for legends and panel letters)
2086
+ container.addEventListener('mousedown', (e) => {
2087
+ const panelCache = panelBboxesCache[panelName];
2088
+ if (!panelCache || !panelHoveredElement) return;
2089
+
2090
+ // Only allow dragging of legends and panel letters (scientific rigor)
2091
+ if (isDraggableElement(panelHoveredElement, panelCache.bboxes)) {
2092
+ startElementDrag(e, panelHoveredElement, panelName, img, panelCache.bboxes);
2093
+ }
2094
+ });
2095
+
2096
+ // Click to select element (accounting for object-fit:contain letterboxing)
1945
2097
  container.addEventListener('click', (e) => {
1946
2098
  e.stopPropagation();
1947
2099
 
@@ -1951,16 +2103,30 @@ function initCanvasItemInteraction(item, panelIdx, panelName) {
1951
2103
 
1952
2104
  if (panelCache && img) {
1953
2105
  const rect = img.getBoundingClientRect();
2106
+ const dims = getObjectFitContainDimensions(img);
2107
+
2108
+ // Mouse position relative to container
1954
2109
  const x = e.clientX - rect.left;
1955
2110
  const y = e.clientY - rect.top;
1956
2111
 
1957
- const scaleX = panelCache.imgSize.width / rect.width;
1958
- const scaleY = panelCache.imgSize.height / rect.height;
1959
- const imgX = x * scaleX;
1960
- const imgY = y * scaleY;
2112
+ // Adjust for letterbox offset
2113
+ const imgRelX = x - dims.offsetX;
2114
+ const imgRelY = y - dims.offsetY;
1961
2115
 
1962
- clickedElement = findElementInPanelAt(imgX, imgY, panelCache.bboxes);
1963
- console.log(`Click at (${imgX.toFixed(0)}, ${imgY.toFixed(0)}) -> element: ${clickedElement}`);
2116
+ // Check if click is within rendered image bounds
2117
+ if (imgRelX >= 0 && imgRelY >= 0 && imgRelX <= dims.renderedWidth && imgRelY <= dims.renderedHeight) {
2118
+ // Scale to original image coordinates
2119
+ const scaleX = panelCache.imgSize.width / dims.renderedWidth;
2120
+ const scaleY = panelCache.imgSize.height / dims.renderedHeight;
2121
+ const imgX = imgRelX * scaleX;
2122
+ const imgY = imgRelY * scaleY;
2123
+
2124
+ clickedElement = findElementInPanelAt(imgX, imgY, panelCache.bboxes);
2125
+ console.log(`Click at (${imgX.toFixed(0)}, ${imgY.toFixed(0)}) -> element: ${clickedElement}`);
2126
+ } else {
2127
+ clickedElement = null;
2128
+ console.log('Click outside rendered image bounds (in letterbox area)');
2129
+ }
1964
2130
  }
1965
2131
 
1966
2132
  if (clickedElement) {
@@ -2017,26 +2183,44 @@ function initPanelCardInteraction(card, panelIdx, panelName) {
2017
2183
  overlay.style.height = img.offsetHeight + 'px';
2018
2184
  });
2019
2185
 
2020
- // Mousemove for hover detection
2186
+ // Mousemove for hover detection (accounting for object-fit:contain letterboxing)
2021
2187
  container.addEventListener('mousemove', (e) => {
2022
2188
  const panelCache = panelBboxesCache[panelName];
2023
2189
  if (!panelCache) return;
2024
2190
 
2025
2191
  const rect = img.getBoundingClientRect();
2192
+ const dims = getObjectFitContainDimensions(img);
2193
+
2194
+ // Mouse position relative to container
2026
2195
  const x = e.clientX - rect.left;
2027
2196
  const y = e.clientY - rect.top;
2028
2197
 
2029
- const scaleX = panelCache.imgSize.width / rect.width;
2030
- const scaleY = panelCache.imgSize.height / rect.height;
2031
- const imgX = x * scaleX;
2032
- const imgY = y * scaleY;
2198
+ // Adjust for letterbox offset to get position relative to actual rendered image
2199
+ const imgRelX = x - dims.offsetX;
2200
+ const imgRelY = y - dims.offsetY;
2201
+
2202
+ // Check if mouse is within rendered image bounds
2203
+ if (imgRelX < 0 || imgRelY < 0 || imgRelX > dims.renderedWidth || imgRelY > dims.renderedHeight) {
2204
+ // Outside rendered image area (in letterbox region)
2205
+ if (panelHoveredElement !== null) {
2206
+ panelHoveredElement = null;
2207
+ updatePanelOverlay(overlay, panelCache.bboxes, panelCache.imgSize, rect.width, rect.height, null, null, img);
2208
+ }
2209
+ return;
2210
+ }
2211
+
2212
+ // Scale to original image coordinates
2213
+ const scaleX = panelCache.imgSize.width / dims.renderedWidth;
2214
+ const scaleY = panelCache.imgSize.height / dims.renderedHeight;
2215
+ const imgX = imgRelX * scaleX;
2216
+ const imgY = imgRelY * scaleY;
2033
2217
 
2034
2218
  // Find element at cursor using panel's bboxes
2035
2219
  const element = findElementInPanelAt(imgX, imgY, panelCache.bboxes);
2036
2220
  if (element !== panelHoveredElement || activePanelCard !== card) {
2037
2221
  panelHoveredElement = element;
2038
2222
  activePanelCard = card;
2039
- updatePanelOverlay(overlay, panelCache.bboxes, panelCache.imgSize, rect.width, rect.height, panelHoveredElement, null);
2223
+ updatePanelOverlay(overlay, panelCache.bboxes, panelCache.imgSize, rect.width, rect.height, panelHoveredElement, null, img);
2040
2224
  }
2041
2225
  });
2042
2226
 
@@ -2044,7 +2228,7 @@ function initPanelCardInteraction(card, panelIdx, panelName) {
2044
2228
  container.addEventListener('mouseleave', () => {
2045
2229
  panelHoveredElement = null;
2046
2230
  if (activePanelCard === card) {
2047
- updatePanelOverlay(overlay, {}, {width: 0, height: 0}, 0, 0, null, null);
2231
+ updatePanelOverlay(overlay, {}, {width: 0, height: 0}, 0, 0, null, null, null);
2048
2232
  }
2049
2233
  });
2050
2234
 
@@ -2175,29 +2359,55 @@ function redrawAllPanelOverlays() {
2175
2359
  const rect = img.getBoundingClientRect();
2176
2360
  console.log(`Redraw panel ${panelName}: rect=${rect.width}x${rect.height}, bboxes=${Object.keys(panelCache.bboxes).length}`);
2177
2361
  if (rect.width > 0 && rect.height > 0) {
2178
- updatePanelOverlay(overlay, panelCache.bboxes, panelCache.imgSize, rect.width, rect.height, null, null);
2362
+ updatePanelOverlay(overlay, panelCache.bboxes, panelCache.imgSize, rect.width, rect.height, null, null, img);
2179
2363
  }
2180
2364
  });
2181
2365
  }
2182
2366
 
2183
2367
  // Update SVG overlay for a panel card
2184
- function updatePanelOverlay(overlay, bboxes, imgSizePanel, displayWidth, displayHeight, hovered, selected) {
2185
- if (!overlay || displayWidth === 0 || displayHeight === 0 || !imgSizePanel || imgSizePanel.width === 0) {
2368
+ // img: the img element (to calculate object-fit:contain dimensions)
2369
+ // OR pass null with displayWidth/displayHeight for backward compatibility
2370
+ function updatePanelOverlay(overlay, bboxes, imgSizePanel, displayWidth, displayHeight, hovered, selected, img) {
2371
+ if (!overlay || !imgSizePanel || imgSizePanel.width === 0) {
2186
2372
  if (overlay) overlay.innerHTML = '';
2187
2373
  return;
2188
2374
  }
2189
2375
 
2190
- overlay.setAttribute('width', displayWidth);
2191
- overlay.setAttribute('height', displayHeight);
2376
+ // Calculate actual rendered dimensions accounting for object-fit: contain
2377
+ let renderedWidth, renderedHeight, offsetX, offsetY;
2378
+ if (img) {
2379
+ const dims = getObjectFitContainDimensions(img);
2380
+ renderedWidth = dims.renderedWidth;
2381
+ renderedHeight = dims.renderedHeight;
2382
+ offsetX = dims.offsetX;
2383
+ offsetY = dims.offsetY;
2384
+ // Use container dimensions for the overlay size
2385
+ overlay.setAttribute('width', dims.containerWidth);
2386
+ overlay.setAttribute('height', dims.containerHeight);
2387
+ overlay.style.width = dims.containerWidth + 'px';
2388
+ overlay.style.height = dims.containerHeight + 'px';
2389
+ } else {
2390
+ // Fallback for backward compatibility
2391
+ if (displayWidth === 0 || displayHeight === 0) {
2392
+ if (overlay) overlay.innerHTML = '';
2393
+ return;
2394
+ }
2395
+ renderedWidth = displayWidth;
2396
+ renderedHeight = displayHeight;
2397
+ offsetX = 0;
2398
+ offsetY = 0;
2399
+ overlay.setAttribute('width', displayWidth);
2400
+ overlay.setAttribute('height', displayHeight);
2401
+ }
2192
2402
 
2193
- const scaleX = displayWidth / imgSizePanel.width;
2194
- const scaleY = displayHeight / imgSizePanel.height;
2403
+ const scaleX = renderedWidth / imgSizePanel.width;
2404
+ const scaleY = renderedHeight / imgSizePanel.height;
2195
2405
 
2196
2406
  let svg = '';
2197
2407
 
2198
- // Debug mode: draw all bboxes
2408
+ // Debug mode: draw all bboxes (with offset for object-fit:contain letterboxing)
2199
2409
  if (panelDebugMode && bboxes) {
2200
- svg += drawPanelDebugBboxes(bboxes, scaleX, scaleY);
2410
+ svg += drawPanelDebugBboxes(bboxes, scaleX, scaleY, offsetX, offsetY);
2201
2411
  }
2202
2412
 
2203
2413
  function drawPanelElement(elementName, type) {
@@ -2207,35 +2417,35 @@ function updatePanelOverlay(overlay, bboxes, imgSizePanel, displayWidth, display
2207
2417
  const elementType = bbox.element_type || '';
2208
2418
  const hasPoints = bbox.points && bbox.points.length > 0;
2209
2419
 
2210
- // Lines - draw as path
2420
+ // Lines - draw as path (with offset)
2211
2421
  if ((elementType === 'line' || elementName.includes('trace_')) && hasPoints) {
2212
2422
  if (bbox.points.length < 2) return '';
2213
2423
  const points = bbox.points.filter(pt => Array.isArray(pt) && pt.length >= 2);
2214
2424
  if (points.length < 2) return '';
2215
2425
 
2216
- let pathD = `M ${points[0][0] * scaleX} ${points[0][1] * scaleY}`;
2426
+ let pathD = `M ${points[0][0] * scaleX + offsetX} ${points[0][1] * scaleY + offsetY}`;
2217
2427
  for (let i = 1; i < points.length; i++) {
2218
- pathD += ` L ${points[i][0] * scaleX} ${points[i][1] * scaleY}`;
2428
+ pathD += ` L ${points[i][0] * scaleX + offsetX} ${points[i][1] * scaleY + offsetY}`;
2219
2429
  }
2220
2430
 
2221
2431
  const className = type === 'hover' ? 'hover-path' : 'selected-path';
2222
2432
  return `<path class="${className}" d="${pathD}"/>`;
2223
2433
  }
2224
- // Scatter - draw as circles
2434
+ // Scatter - draw as circles (with offset)
2225
2435
  else if (elementType === 'scatter' && hasPoints) {
2226
2436
  const className = type === 'hover' ? 'hover-scatter' : 'selected-scatter';
2227
2437
  let result = '';
2228
2438
  for (const pt of bbox.points) {
2229
2439
  if (!Array.isArray(pt) || pt.length < 2) continue;
2230
- result += `<circle class="${className}" cx="${pt[0] * scaleX}" cy="${pt[1] * scaleY}" r="3"/>`;
2440
+ result += `<circle class="${className}" cx="${pt[0] * scaleX + offsetX}" cy="${pt[1] * scaleY + offsetY}" r="3"/>`;
2231
2441
  }
2232
2442
  return result;
2233
2443
  }
2234
- // Default - draw bbox rectangle
2444
+ // Default - draw bbox rectangle (with offset)
2235
2445
  else {
2236
2446
  const rectClass = type === 'hover' ? 'hover-rect' : 'selected-rect';
2237
- const x = bbox.x0 * scaleX - 1;
2238
- const y = bbox.y0 * scaleY - 1;
2447
+ const x = bbox.x0 * scaleX + offsetX - 1;
2448
+ const y = bbox.y0 * scaleY + offsetY - 1;
2239
2449
  const w = (bbox.x1 - bbox.x0) * scaleX + 2;
2240
2450
  const h = (bbox.y1 - bbox.y0) * scaleY + 2;
2241
2451
  return `<rect class="${rectClass}" x="${x}" y="${y}" width="${w}" height="${h}" rx="2"/>`;
@@ -2253,10 +2463,13 @@ function updatePanelOverlay(overlay, bboxes, imgSizePanel, displayWidth, display
2253
2463
  overlay.innerHTML = svg;
2254
2464
  }
2255
2465
 
2256
- // Draw all bboxes for a panel in debug mode
2257
- function drawPanelDebugBboxes(bboxes, scaleX, scaleY) {
2466
+ // Draw all bboxes for a panel in debug mode (with offset for object-fit:contain)
2467
+ function drawPanelDebugBboxes(bboxes, scaleX, scaleY, offsetX, offsetY) {
2258
2468
  let svg = '';
2259
2469
  let count = 0;
2470
+ // Default offset to 0 if not provided
2471
+ offsetX = offsetX || 0;
2472
+ offsetY = offsetY || 0;
2260
2473
 
2261
2474
  for (const [name, bbox] of Object.entries(bboxes)) {
2262
2475
  if (name === '_meta') continue;
@@ -2276,9 +2489,9 @@ function drawPanelDebugBboxes(bboxes, scaleX, scaleY) {
2276
2489
  rectClass = 'debug-rect-trace';
2277
2490
  }
2278
2491
 
2279
- // Draw bbox rectangle
2280
- const x = bbox.x0 * scaleX;
2281
- const y = bbox.y0 * scaleY;
2492
+ // Draw bbox rectangle (with offset for object-fit:contain letterboxing)
2493
+ const x = bbox.x0 * scaleX + offsetX;
2494
+ const y = bbox.y0 * scaleY + offsetY;
2282
2495
  const w = (bbox.x1 - bbox.x0) * scaleX;
2283
2496
  const h = (bbox.y1 - bbox.y0) * scaleY;
2284
2497
 
@@ -2288,13 +2501,13 @@ function drawPanelDebugBboxes(bboxes, scaleX, scaleY) {
2288
2501
  const shortName = name.length > 10 ? name.substring(0, 8) + '..' : name;
2289
2502
  svg += `<text class="debug-label" x="${x + 1}" y="${y + 8}" style="font-size: 6px;">${shortName}</text>`;
2290
2503
 
2291
- // Draw path points if available
2504
+ // Draw path points if available (with offset)
2292
2505
  if (hasPoints && bbox.points.length > 1) {
2293
- let pathD = `M ${bbox.points[0][0] * scaleX} ${bbox.points[0][1] * scaleY}`;
2506
+ let pathD = `M ${bbox.points[0][0] * scaleX + offsetX} ${bbox.points[0][1] * scaleY + offsetY}`;
2294
2507
  for (let i = 1; i < bbox.points.length; i++) {
2295
2508
  const pt = bbox.points[i];
2296
2509
  if (pt && pt.length >= 2) {
2297
- pathD += ` L ${pt[0] * scaleX} ${pt[1] * scaleY}`;
2510
+ pathD += ` L ${pt[0] * scaleX + offsetX} ${pt[1] * scaleY + offsetY}`;
2298
2511
  }
2299
2512
  }
2300
2513
  svg += `<path class="debug-path" d="${pathD}"/>`;
@@ -2353,11 +2566,7 @@ async function loadPanelForEditing(panelIdx, panelName, elementToSelect) {
2353
2566
  // Scroll to section and show properties
2354
2567
  scrollToSection(selectedElement);
2355
2568
 
2356
- // Show single-panel preview when element selected
2357
- const previewWrapper = document.querySelector('.preview-wrapper');
2358
- if (previewWrapper) {
2359
- previewWrapper.style.display = 'block';
2360
- }
2569
+ // Keep unified canvas view only - don't show single-panel preview
2361
2570
 
2362
2571
  // Update panel path display in right panel header
2363
2572
  const panelPathEl = document.getElementById('panel-path-display');
@@ -2377,14 +2586,8 @@ async function loadPanelForEditing(panelIdx, panelName, elementToSelect) {
2377
2586
  function togglePanelGrid() {
2378
2587
  showingPanelGrid = !showingPanelGrid;
2379
2588
  const gridSection = document.getElementById('panel-grid-section');
2380
- const showBtn = document.getElementById('show-grid-btn');
2381
-
2382
- if (showingPanelGrid) {
2383
- gridSection.style.display = 'block';
2384
- showBtn.textContent = 'Hide All';
2385
- } else {
2386
- gridSection.style.display = 'none';
2387
- showBtn.textContent = 'Show All';
2589
+ if (gridSection) {
2590
+ gridSection.style.display = showingPanelGrid ? 'block' : 'none';
2388
2591
  }
2389
2592
  }
2390
2593
 
@@ -2471,21 +2674,24 @@ function updatePanelIndicator() {
2471
2674
  const current = currentPanelIndex + 1;
2472
2675
  const panelName = panelData.panels[currentPanelIndex];
2473
2676
 
2474
- document.getElementById('panel-indicator').textContent = `${current} / ${total}`;
2475
- document.getElementById('current-panel-name').textContent = `Panel ${panelName.replace('.pltz.d', '')}`;
2677
+ // Update indicator text (if elements exist)
2678
+ const indicatorEl = document.getElementById('panel-indicator');
2679
+ if (indicatorEl) indicatorEl.textContent = `${current} / ${total}`;
2476
2680
 
2477
- // Update prev/next button states
2478
- document.getElementById('prev-panel-btn').disabled = currentPanelIndex === 0;
2479
- document.getElementById('next-panel-btn').disabled = currentPanelIndex === total - 1;
2681
+ const nameEl = document.getElementById('current-panel-name');
2682
+ if (nameEl) nameEl.textContent = `Panel ${panelName.replace('.pltz.d', '')}`;
2480
2683
  }
2481
2684
 
2482
2685
  // =============================================================================
2483
2686
  // Canvas Mode (Draggable Panel Layout)
2484
2687
  // =============================================================================
2485
2688
  let canvasMode = 'grid'; // 'grid' or 'canvas'
2486
- let panelPositions = {}; // Store panel positions {name: {x, y, width, height}}
2689
+ let panelPositions = {}; // Store panel positions {name: {x, y, width, height}} in pixels
2690
+ let panelLayoutMm = {}; // Store panel positions in mm for saving {name: {x_mm, y_mm, width_mm, height_mm}}
2691
+ let canvasScale = 3; // Scale factor: pixels per mm (updated in loadPanelGrid)
2487
2692
  let draggedPanel = null;
2488
2693
  let dragOffset = {x: 0, y: 0};
2694
+ let layoutModified = false; // Track if layout has been modified
2489
2695
 
2490
2696
  function setCanvasMode(mode) {
2491
2697
  canvasMode = mode;
@@ -2586,43 +2792,608 @@ async function renderPanelCanvas() {
2586
2792
  }
2587
2793
  }
2588
2794
 
2589
- function startDrag(e, item, name) {
2795
+ // Check if an element is interactive (should not initiate drag)
2796
+ function isInteractiveElement(target) {
2797
+ // SVG paths with hover-path class are interactive elements
2798
+ if (target.classList && target.classList.contains('hover-path')) return true;
2799
+ if (target.classList && target.classList.contains('hit-path')) return true;
2800
+
2801
+ // Check parent elements for hover-path (click might be on child)
2802
+ let el = target;
2803
+ while (el && el !== document.body) {
2804
+ if (el.tagName === 'path' || el.tagName === 'PATH') {
2805
+ // Path elements in SVG overlay are interactive
2806
+ const svg = el.closest('svg');
2807
+ if (svg && svg.classList.contains('panel-card-overlay')) {
2808
+ return true;
2809
+ }
2810
+ }
2811
+ el = el.parentElement;
2812
+ }
2813
+ return false;
2814
+ }
2815
+
2816
+ // =============================================================================
2817
+ // Element Dragging (Legends, Panel Letters)
2818
+ // =============================================================================
2819
+ let elementDragState = null; // {element, panelName, startPos, elementType, axId}
2820
+
2821
+ // Snap positions for draggable elements (normalized axes coordinates 0-1)
2822
+ const SNAP_POSITIONS = {
2823
+ 'upper left': {x: 0.02, y: 0.98},
2824
+ 'upper center': {x: 0.50, y: 0.98},
2825
+ 'upper right': {x: 0.98, y: 0.98},
2826
+ 'center left': {x: 0.02, y: 0.50},
2827
+ 'center': {x: 0.50, y: 0.50},
2828
+ 'center right': {x: 0.98, y: 0.50},
2829
+ 'lower left': {x: 0.02, y: 0.02},
2830
+ 'lower center': {x: 0.50, y: 0.02},
2831
+ 'lower right': {x: 0.98, y: 0.02},
2832
+ };
2833
+
2834
+ // Check if an element is draggable
2835
+ // ONLY panel letters and legends are draggable to maintain scientific rigor
2836
+ // Data elements (lines, scatter, bars, etc.) must NOT be movable
2837
+ function isDraggableElement(elementName, bboxes) {
2838
+ if (!elementName || !bboxes) return false;
2839
+
2840
+ // Whitelist: ONLY these element types are draggable
2841
+ const DRAGGABLE_TYPES = ['legend', 'panel_letter'];
2842
+
2843
+ // Check by element_type in bbox info
2844
+ const info = bboxes[elementName];
2845
+ if (info && DRAGGABLE_TYPES.includes(info.element_type)) {
2846
+ return true;
2847
+ }
2848
+
2849
+ // Check by naming convention (strict match)
2850
+ if (elementName.match(/_legend$/)) return true;
2851
+ if (elementName.match(/_panel_letter_[A-Z]$/)) return true;
2852
+
2853
+ // Everything else is NOT draggable (data integrity)
2854
+ return false;
2855
+ }
2856
+
2857
+ // Start element drag (for legends and panel letters)
2858
+ function startElementDrag(e, elementName, panelName, img, bboxes) {
2859
+ const info = bboxes[elementName] || {};
2860
+ const elementType = info.element_type || (elementName.includes('legend') ? 'legend' : 'panel_letter');
2861
+
2862
+ // Extract ax_id from element name (e.g., "ax_00_legend" -> "ax_00")
2863
+ const axId = elementName.split('_').slice(0, 2).join('_');
2864
+
2865
+ // Get axes bbox for constraining drag
2866
+ const axesBbox = bboxes[`${axId}_panel`] || null;
2867
+
2868
+ elementDragState = {
2869
+ element: elementName,
2870
+ panelName: panelName,
2871
+ elementType: elementType,
2872
+ axId: axId,
2873
+ axesBbox: axesBbox,
2874
+ bboxes: bboxes,
2875
+ img: img,
2876
+ startMouseX: e.clientX,
2877
+ startMouseY: e.clientY,
2878
+ startBbox: {...info},
2879
+ };
2880
+
2881
+ // Show snap guide overlay
2882
+ showSnapGuides(img, axesBbox, bboxes);
2883
+
2884
+ document.addEventListener('mousemove', onElementDrag);
2885
+ document.addEventListener('mouseup', stopElementDrag);
2886
+
2590
2887
  e.preventDefault();
2591
- draggedPanel = {item, name};
2592
- dragOffset.x = e.clientX - item.offsetLeft;
2593
- dragOffset.y = e.clientY - item.offsetTop;
2594
- item.classList.add('dragging');
2888
+ e.stopPropagation();
2889
+ }
2890
+
2891
+ // Handle element drag movement
2892
+ function onElementDrag(e) {
2893
+ if (!elementDragState) return;
2894
+
2895
+ const {img, bboxes, element, axId, axesBbox, startBbox, startMouseX, startMouseY} = elementDragState;
2896
+ if (!img) return;
2897
+
2898
+ const rect = img.getBoundingClientRect();
2899
+ const dims = getObjectFitContainDimensions(img);
2900
+
2901
+ // Calculate delta in image coordinates
2902
+ const deltaX = e.clientX - startMouseX;
2903
+ const deltaY = e.clientY - startMouseY;
2904
+
2905
+ // Convert to image pixel coordinates
2906
+ const scaleX = dims.renderedWidth / rect.width;
2907
+ const scaleY = dims.renderedHeight / rect.height;
2908
+ const imgDeltaX = deltaX * scaleX * (bboxes._meta?.imgSize?.width || 1) / dims.renderedWidth;
2909
+ const imgDeltaY = deltaY * scaleY * (bboxes._meta?.imgSize?.height || 1) / dims.renderedHeight;
2910
+
2911
+ // Update bbox position (for visual feedback)
2912
+ if (bboxes[element]) {
2913
+ const newX0 = startBbox.x0 + imgDeltaX;
2914
+ const newY0 = startBbox.y0 + imgDeltaY;
2915
+ bboxes[element].x0 = newX0;
2916
+ bboxes[element].y0 = newY0;
2917
+ bboxes[element].x1 = newX0 + (startBbox.x1 - startBbox.x0);
2918
+ bboxes[element].y1 = newY0 + (startBbox.y1 - startBbox.y0);
2919
+ }
2595
2920
 
2596
- document.addEventListener('mousemove', onDrag);
2597
- document.addEventListener('mouseup', stopDrag);
2921
+ // Calculate normalized axes position (0-1)
2922
+ if (axesBbox) {
2923
+ const axesWidth = axesBbox.x1 - axesBbox.x0;
2924
+ const axesHeight = axesBbox.y1 - axesBbox.y0;
2925
+ const elemCenterX = (bboxes[element].x0 + bboxes[element].x1) / 2;
2926
+ const elemCenterY = (bboxes[element].y0 + bboxes[element].y1) / 2;
2927
+ const normX = (elemCenterX - axesBbox.x0) / axesWidth;
2928
+ const normY = 1 - (elemCenterY - axesBbox.y0) / axesHeight; // Flip Y
2929
+
2930
+ // Update snap guide highlighting
2931
+ updateSnapHighlight(normX, normY);
2932
+
2933
+ // Show position indicator
2934
+ showElementPositionIndicator(element, normX, normY);
2935
+ }
2936
+
2937
+ // Redraw overlay
2938
+ const overlay = img.parentElement?.querySelector('svg.panel-card-overlay');
2939
+ if (overlay) {
2940
+ const panelCache = panelBboxesCache[elementDragState.panelName];
2941
+ if (panelCache) {
2942
+ updatePanelOverlay(overlay, bboxes, panelCache.imgSize, rect.width, rect.height, element, element, img);
2943
+ }
2944
+ }
2598
2945
  }
2599
2946
 
2600
- function onDrag(e) {
2601
- if (!draggedPanel) return;
2947
+ // Stop element drag and save position
2948
+ function stopElementDrag() {
2949
+ if (!elementDragState) return;
2950
+
2951
+ const {element, panelName, elementType, axId, bboxes, axesBbox} = elementDragState;
2952
+
2953
+ // Calculate final normalized position
2954
+ let finalPosition = null;
2955
+ let snapName = null;
2956
+
2957
+ if (axesBbox && bboxes[element]) {
2958
+ const axesWidth = axesBbox.x1 - axesBbox.x0;
2959
+ const axesHeight = axesBbox.y1 - axesBbox.y0;
2960
+ const elemCenterX = (bboxes[element].x0 + bboxes[element].x1) / 2;
2961
+ const elemCenterY = (bboxes[element].y0 + bboxes[element].y1) / 2;
2962
+ const normX = (elemCenterX - axesBbox.x0) / axesWidth;
2963
+ const normY = 1 - (elemCenterY - axesBbox.y0) / axesHeight;
2964
+
2965
+ // Check for snap to named position
2966
+ snapName = findNearestSnapPosition(normX, normY);
2967
+ finalPosition = snapName ? SNAP_POSITIONS[snapName] : {x: normX, y: normY};
2968
+ }
2969
+
2970
+ // Hide snap guides
2971
+ hideSnapGuides();
2972
+ hideElementPositionIndicator();
2973
+
2974
+ // Save position to server
2975
+ if (finalPosition) {
2976
+ saveElementPosition(element, panelName, elementType, finalPosition, snapName);
2977
+ }
2978
+
2979
+ document.removeEventListener('mousemove', onElementDrag);
2980
+ document.removeEventListener('mouseup', stopElementDrag);
2981
+ elementDragState = null;
2982
+ }
2983
+
2984
+ // Find nearest snap position if within threshold
2985
+ function findNearestSnapPosition(normX, normY, threshold = 0.08) {
2986
+ let nearest = null;
2987
+ let minDist = Infinity;
2988
+
2989
+ for (const [name, pos] of Object.entries(SNAP_POSITIONS)) {
2990
+ const dist = Math.sqrt(Math.pow(normX - pos.x, 2) + Math.pow(normY - pos.y, 2));
2991
+ if (dist < threshold && dist < minDist) {
2992
+ minDist = dist;
2993
+ nearest = name;
2994
+ }
2995
+ }
2996
+ return nearest;
2997
+ }
2998
+
2999
+ // Show snap guide overlay on axes
3000
+ function showSnapGuides(img, axesBbox, bboxes) {
3001
+ if (!img || !axesBbox) return;
3002
+
3003
+ const container = img.parentElement;
3004
+ if (!container) return;
3005
+
3006
+ // Remove existing guides
3007
+ container.querySelectorAll('.snap-guide').forEach(el => el.remove());
3008
+
3009
+ const rect = img.getBoundingClientRect();
3010
+ const dims = getObjectFitContainDimensions(img);
3011
+ const imgSize = bboxes._meta?.imgSize || {width: dims.renderedWidth, height: dims.renderedHeight};
3012
+
3013
+ // Scale factors
3014
+ const scaleX = dims.renderedWidth / imgSize.width;
3015
+ const scaleY = dims.renderedHeight / imgSize.height;
3016
+
3017
+ // Create snap points
3018
+ for (const [name, pos] of Object.entries(SNAP_POSITIONS)) {
3019
+ const axesWidth = axesBbox.x1 - axesBbox.x0;
3020
+ const axesHeight = axesBbox.y1 - axesBbox.y0;
3021
+
3022
+ // Calculate pixel position
3023
+ const imgX = axesBbox.x0 + pos.x * axesWidth;
3024
+ const imgY = axesBbox.y0 + (1 - pos.y) * axesHeight;
3025
+
3026
+ const displayX = dims.offsetX + imgX * scaleX;
3027
+ const displayY = dims.offsetY + imgY * scaleY;
3028
+
3029
+ const guide = document.createElement('div');
3030
+ guide.className = 'snap-guide';
3031
+ guide.dataset.snapName = name;
3032
+ guide.style.cssText = `
3033
+ position: absolute;
3034
+ left: ${displayX - 6}px;
3035
+ top: ${displayY - 6}px;
3036
+ width: 12px;
3037
+ height: 12px;
3038
+ border: 2px dashed rgba(100, 149, 237, 0.6);
3039
+ border-radius: 50%;
3040
+ pointer-events: none;
3041
+ z-index: 50;
3042
+ transition: all 0.15s ease;
3043
+ `;
3044
+ container.appendChild(guide);
3045
+ }
3046
+ }
3047
+
3048
+ // Highlight snap position when near
3049
+ function updateSnapHighlight(normX, normY) {
3050
+ const threshold = 0.08;
3051
+ document.querySelectorAll('.snap-guide').forEach(guide => {
3052
+ const name = guide.dataset.snapName;
3053
+ const pos = SNAP_POSITIONS[name];
3054
+ const dist = Math.sqrt(Math.pow(normX - pos.x, 2) + Math.pow(normY - pos.y, 2));
3055
+ if (dist < threshold) {
3056
+ guide.style.borderColor = 'rgba(76, 175, 80, 0.9)';
3057
+ guide.style.backgroundColor = 'rgba(76, 175, 80, 0.3)';
3058
+ guide.style.transform = 'scale(1.5)';
3059
+ } else {
3060
+ guide.style.borderColor = 'rgba(100, 149, 237, 0.6)';
3061
+ guide.style.backgroundColor = 'transparent';
3062
+ guide.style.transform = 'scale(1)';
3063
+ }
3064
+ });
3065
+ }
3066
+
3067
+ // Hide snap guides
3068
+ function hideSnapGuides() {
3069
+ document.querySelectorAll('.snap-guide').forEach(el => el.remove());
3070
+ }
3071
+
3072
+ // Show position indicator while dragging element
3073
+ function showElementPositionIndicator(element, normX, normY) {
3074
+ let indicator = document.getElementById('element-pos-indicator');
3075
+ if (!indicator) {
3076
+ indicator = document.createElement('div');
3077
+ indicator.id = 'element-pos-indicator';
3078
+ indicator.style.cssText = `
3079
+ position: fixed;
3080
+ bottom: 20px;
3081
+ right: 20px;
3082
+ background: rgba(0, 0, 0, 0.8);
3083
+ color: #4fc3f7;
3084
+ padding: 8px 12px;
3085
+ border-radius: 4px;
3086
+ font-family: monospace;
3087
+ font-size: 12px;
3088
+ z-index: 1000;
3089
+ `;
3090
+ document.body.appendChild(indicator);
3091
+ }
3092
+ const snapName = findNearestSnapPosition(normX, normY);
3093
+ if (snapName) {
3094
+ indicator.innerHTML = `Position: <b>${snapName}</b>`;
3095
+ indicator.style.color = '#4caf50';
3096
+ } else {
3097
+ indicator.innerHTML = `Position: (${normX.toFixed(2)}, ${normY.toFixed(2)})`;
3098
+ indicator.style.color = '#4fc3f7';
3099
+ }
3100
+ indicator.style.display = 'block';
3101
+ }
3102
+
3103
+ // Hide position indicator
3104
+ function hideElementPositionIndicator() {
3105
+ const indicator = document.getElementById('element-pos-indicator');
3106
+ if (indicator) indicator.style.display = 'none';
3107
+ }
3108
+
3109
+ // Save element position to server
3110
+ async function saveElementPosition(element, panelName, elementType, position, snapName) {
3111
+ try {
3112
+ const response = await fetch('/save_element_position', {
3113
+ method: 'POST',
3114
+ headers: {'Content-Type': 'application/json'},
3115
+ body: JSON.stringify({
3116
+ element: element,
3117
+ panel: panelName,
3118
+ element_type: elementType,
3119
+ position: position,
3120
+ snap_name: snapName,
3121
+ }),
3122
+ });
3123
+ const data = await response.json();
3124
+ if (data.success) {
3125
+ setStatus(`Saved ${elementType} position: ${snapName || `(${position.x.toFixed(2)}, ${position.y.toFixed(2)})`}`, false);
3126
+ } else {
3127
+ setStatus(`Failed to save position: ${data.error}`, true);
3128
+ }
3129
+ } catch (err) {
3130
+ console.error('Error saving element position:', err);
3131
+ setStatus('Error saving position', true);
3132
+ }
3133
+ }
3134
+
3135
+ // Initialize drag handler for a panel item
3136
+ function initPanelDrag(item, panelName) {
3137
+ const dragHandle = item.querySelector('.panel-drag-handle');
3138
+
3139
+ // Drag from handle (always works)
3140
+ if (dragHandle) {
3141
+ dragHandle.addEventListener('mousedown', (e) => {
3142
+ e.preventDefault();
3143
+ e.stopPropagation();
3144
+ startPanelDrag(e, item, panelName);
3145
+ });
3146
+ }
3147
+
3148
+ // Also allow dragging from panel label
3149
+ const label = item.querySelector('.panel-canvas-label');
3150
+ if (label) {
3151
+ label.style.cursor = 'move';
3152
+ label.addEventListener('mousedown', (e) => {
3153
+ e.preventDefault();
3154
+ e.stopPropagation();
3155
+ startPanelDrag(e, item, panelName);
3156
+ });
3157
+ }
3158
+
3159
+ // Allow dragging from anywhere on the panel (except interactive elements)
3160
+ // This enables intuitive drag behavior while preserving element selection
3161
+ item.addEventListener('mousedown', (e) => {
3162
+ // Skip if clicking on interactive elements (legends, text paths, etc.)
3163
+ if (isInteractiveElement(e.target)) return;
3164
+
3165
+ // Skip if clicking on drag handle or label (already handled above)
3166
+ if (e.target.closest('.panel-drag-handle')) return;
3167
+ if (e.target.closest('.panel-canvas-label')) return;
3168
+ if (e.target.closest('.panel-position-indicator')) return;
3169
+
3170
+ // Start drag from anywhere else on the panel
3171
+ e.preventDefault();
3172
+ startPanelDrag(e, item, panelName);
3173
+ });
3174
+
3175
+ // Set cursor to indicate draggability
3176
+ item.style.cursor = 'grab';
3177
+ }
3178
+
3179
+ function startPanelDrag(e, item, name) {
3180
+ e.preventDefault();
3181
+
3182
+ // Handle selection based on Ctrl key
3183
+ const isCtrlPressed = e.ctrlKey || e.metaKey;
3184
+ const wasAlreadySelected = item.classList.contains('active');
3185
+
3186
+ if (isCtrlPressed) {
3187
+ // Ctrl+Click: toggle this panel's selection
3188
+ item.classList.toggle('active');
3189
+ } else if (!wasAlreadySelected) {
3190
+ // Regular click on unselected panel: select only this one
3191
+ deselectAllPanels();
3192
+ item.classList.add('active');
3193
+ }
3194
+ // If clicking on already-selected panel without Ctrl:
3195
+ // Don't change selection yet - could be start of multi-panel drag
3196
+ // Selection will be finalized in stopPanelDrag based on hasMoved
3197
+
3198
+ // Collect all selected panels for group dragging
3199
+ const selectedPanels = Array.from(document.querySelectorAll('.panel-canvas-item.active'));
3200
+ if (selectedPanels.length === 0) {
3201
+ // If somehow nothing selected, select the clicked item
3202
+ item.classList.add('active');
3203
+ selectedPanels.push(item);
3204
+ }
3205
+
3206
+ // Store drag state for all selected panels
3207
+ draggedPanel = {
3208
+ item,
3209
+ name,
3210
+ hasMoved: false, // Track if actual drag occurred
3211
+ wasAlreadySelected, // Track initial selection state for click handling
3212
+ isCtrlPressed, // Track if Ctrl was pressed
3213
+ selectedPanels: selectedPanels.map(p => ({
3214
+ item: p,
3215
+ name: p.dataset.panelName,
3216
+ startLeft: parseFloat(p.style.left) || 0,
3217
+ startTop: parseFloat(p.style.top) || 0
3218
+ }))
3219
+ };
3220
+ dragOffset.x = e.clientX;
3221
+ dragOffset.y = e.clientY;
3222
+
3223
+ selectedPanels.forEach(p => {
3224
+ p.classList.add('dragging');
3225
+ p.style.cursor = 'grabbing';
3226
+ });
3227
+
3228
+ // Show position indicator for primary panel
3229
+ updatePositionIndicator(name, item.offsetLeft, item.offsetTop);
3230
+
3231
+ document.addEventListener('mousemove', onPanelDrag);
3232
+ document.addEventListener('mouseup', stopPanelDrag);
3233
+ }
3234
+
3235
+ function onPanelDrag(e) {
3236
+ if (!draggedPanel || !draggedPanel.selectedPanels) return;
2602
3237
  const canvasEl = document.getElementById('panel-canvas');
2603
- const rect = canvasEl.getBoundingClientRect();
2604
3238
 
2605
- let newX = e.clientX - dragOffset.x;
2606
- let newY = e.clientY - dragOffset.y;
3239
+ // Calculate delta from drag start
3240
+ let deltaX = e.clientX - dragOffset.x;
3241
+ let deltaY = e.clientY - dragOffset.y;
2607
3242
 
2608
- // Constrain to canvas bounds
2609
- newX = Math.max(0, Math.min(newX, canvasEl.offsetWidth - draggedPanel.item.offsetWidth));
2610
- newY = Math.max(0, newY);
3243
+ // Mark as moved if we've actually dragged (threshold: 3px)
3244
+ if (Math.abs(deltaX) > 3 || Math.abs(deltaY) > 3) {
3245
+ draggedPanel.hasMoved = true;
3246
+ }
3247
+
3248
+ // Snap to grid (optional: 5mm grid)
3249
+ const gridSnap = 5 * canvasScale; // 5mm in pixels
3250
+ if (e.shiftKey) {
3251
+ deltaX = Math.round(deltaX / gridSnap) * gridSnap;
3252
+ deltaY = Math.round(deltaY / gridSnap) * gridSnap;
3253
+ }
3254
+
3255
+ // Move all selected panels by the same delta
3256
+ for (const panelInfo of draggedPanel.selectedPanels) {
3257
+ let newX = panelInfo.startLeft + deltaX;
3258
+ let newY = panelInfo.startTop + deltaY;
3259
+
3260
+ // Constrain to canvas bounds (allow slight negative for edge alignment)
3261
+ newX = Math.max(-5, Math.min(newX, canvasEl.offsetWidth - panelInfo.item.offsetWidth + 5));
3262
+ newY = Math.max(-5, newY);
3263
+
3264
+ panelInfo.item.style.left = newX + 'px';
3265
+ panelInfo.item.style.top = newY + 'px';
3266
+
3267
+ // Update pixel positions
3268
+ if (panelPositions[panelInfo.name]) {
3269
+ panelPositions[panelInfo.name].x = newX;
3270
+ panelPositions[panelInfo.name].y = newY;
3271
+ }
2611
3272
 
2612
- draggedPanel.item.style.left = newX + 'px';
2613
- draggedPanel.item.style.top = newY + 'px';
3273
+ // Update mm positions
3274
+ if (panelLayoutMm[panelInfo.name]) {
3275
+ panelLayoutMm[panelInfo.name].x_mm = newX / canvasScale;
3276
+ panelLayoutMm[panelInfo.name].y_mm = newY / canvasScale;
3277
+ }
3278
+ }
2614
3279
 
2615
- panelPositions[draggedPanel.name].x = newX;
2616
- panelPositions[draggedPanel.name].y = newY;
3280
+ // Show position indicator for primary panel
3281
+ const primaryNewX = draggedPanel.selectedPanels[0].startLeft + deltaX;
3282
+ const primaryNewY = draggedPanel.selectedPanels[0].startTop + deltaY;
3283
+ updatePositionIndicator(draggedPanel.name, primaryNewX, primaryNewY);
3284
+
3285
+ // Mark layout as modified
3286
+ layoutModified = true;
2617
3287
  }
2618
3288
 
2619
- function stopDrag() {
3289
+ function stopPanelDrag() {
2620
3290
  if (draggedPanel) {
2621
- draggedPanel.item.classList.remove('dragging');
3291
+ // Handle click (no movement) on already-selected panel without Ctrl:
3292
+ // Finalize selection to only the clicked panel
3293
+ if (!draggedPanel.hasMoved && draggedPanel.wasAlreadySelected && !draggedPanel.isCtrlPressed) {
3294
+ // This was a simple click on an already-selected panel
3295
+ // Deselect all others, keep only the clicked panel selected
3296
+ deselectAllPanels();
3297
+ draggedPanel.item.classList.add('active');
3298
+ }
3299
+
3300
+ // Reset cursor for all selected panels
3301
+ if (draggedPanel.selectedPanels) {
3302
+ draggedPanel.selectedPanels.forEach(p => {
3303
+ p.item.classList.remove('dragging');
3304
+ p.item.style.cursor = 'grab';
3305
+ });
3306
+ } else {
3307
+ draggedPanel.item.classList.remove('dragging');
3308
+ draggedPanel.item.style.cursor = 'grab';
3309
+ }
3310
+
3311
+ // Update canvas size if panel moved outside
3312
+ updateCanvasSize();
3313
+
3314
+ // Hide position indicator after a delay
3315
+ const name = draggedPanel.name;
3316
+ setTimeout(() => {
3317
+ const indicator = document.getElementById(`pos-${name}`);
3318
+ if (indicator) indicator.style.opacity = '0';
3319
+ }, 1500);
3320
+
3321
+ // Auto-save layout
3322
+ if (layoutModified) {
3323
+ autoSaveLayout();
3324
+ }
3325
+
2622
3326
  draggedPanel = null;
2623
3327
  }
2624
- document.removeEventListener('mousemove', onDrag);
2625
- document.removeEventListener('mouseup', stopDrag);
3328
+ document.removeEventListener('mousemove', onPanelDrag);
3329
+ document.removeEventListener('mouseup', stopPanelDrag);
3330
+ }
3331
+
3332
+ // Update position indicator showing mm coordinates
3333
+ function updatePositionIndicator(panelName, x, y) {
3334
+ const indicator = document.getElementById(`pos-${panelName}`);
3335
+ if (!indicator) return;
3336
+
3337
+ const x_mm = (x / canvasScale).toFixed(1);
3338
+ const y_mm = (y / canvasScale).toFixed(1);
3339
+ indicator.textContent = `${x_mm}, ${y_mm} mm`;
3340
+ indicator.style.opacity = '1';
3341
+ }
3342
+
3343
+ // Update canvas size to fit all panels after drag
3344
+ function updateCanvasSize() {
3345
+ const canvasEl = document.getElementById('panel-canvas');
3346
+ if (!canvasEl) return;
3347
+
3348
+ const maxY = Math.max(...Object.values(panelPositions).map(p => p.y + p.height)) + 20;
3349
+ const maxX = Math.max(...Object.values(panelPositions).map(p => p.x + p.width)) + 20;
3350
+ canvasEl.style.minHeight = Math.max(400, maxY) + 'px';
3351
+ canvasEl.style.minWidth = Math.max(700, maxX) + 'px';
3352
+ }
3353
+
3354
+ // Auto-save layout to server
3355
+ async function autoSaveLayout() {
3356
+ if (!layoutModified) return;
3357
+
3358
+ try {
3359
+ const resp = await fetch('/save_layout', {
3360
+ method: 'POST',
3361
+ headers: { 'Content-Type': 'application/json' },
3362
+ body: JSON.stringify({ layout: panelLayoutMm })
3363
+ });
3364
+ const data = await resp.json();
3365
+
3366
+ if (data.success) {
3367
+ layoutModified = false;
3368
+ setStatus('Layout saved', false);
3369
+ console.log('Layout auto-saved:', panelLayoutMm);
3370
+ } else {
3371
+ console.error('Layout save failed:', data.error);
3372
+ setStatus('Layout save failed: ' + data.error, true);
3373
+ }
3374
+ } catch (e) {
3375
+ console.error('Error saving layout:', e);
3376
+ setStatus('Error saving layout', true);
3377
+ }
3378
+ }
3379
+
3380
+ // Manual save layout button handler
3381
+ function saveLayoutManually() {
3382
+ layoutModified = true; // Force save
3383
+ autoSaveLayout();
3384
+ }
3385
+
3386
+ // Legacy drag functions (kept for backward compatibility with canvas mode)
3387
+ function startDrag(e, item, name) {
3388
+ startPanelDrag(e, item, name);
3389
+ }
3390
+
3391
+ function onDrag(e) {
3392
+ onPanelDrag(e);
3393
+ }
3394
+
3395
+ function stopDrag() {
3396
+ stopPanelDrag();
2626
3397
  }
2627
3398
 
2628
3399
  let resizingPanel = null;
@@ -2905,6 +3676,18 @@ async function saveManual() {
2905
3676
  const data = await resp.json();
2906
3677
  if (data.status === 'saved') {
2907
3678
  setStatus('Saved: ' + data.path.split('/').pop(), false);
3679
+
3680
+ // Also export to bundle (png and svg)
3681
+ try {
3682
+ await fetch('/export', {
3683
+ method: 'POST',
3684
+ headers: {'Content-Type': 'application/json'},
3685
+ body: JSON.stringify({formats: ['png', 'svg']})
3686
+ });
3687
+ setStatus('Saved and exported to bundle', false);
3688
+ } catch (exportErr) {
3689
+ console.warn('Export failed:', exportErr);
3690
+ }
2908
3691
  } else {
2909
3692
  setStatus('Error: ' + data.message, true);
2910
3693
  }
@@ -2919,6 +3702,47 @@ function resetOverrides() {
2919
3702
  }
2920
3703
  }
2921
3704
 
3705
+ // Download menu toggle
3706
+ function toggleDownloadMenu() {
3707
+ const menu = document.getElementById('download-menu');
3708
+ if (menu.style.display === 'none') {
3709
+ menu.style.display = 'block';
3710
+ // Close when clicking outside
3711
+ setTimeout(() => {
3712
+ document.addEventListener('click', closeDownloadMenuOnClickOutside);
3713
+ }, 10);
3714
+ } else {
3715
+ menu.style.display = 'none';
3716
+ }
3717
+ }
3718
+
3719
+ function closeDownloadMenuOnClickOutside(e) {
3720
+ const menu = document.getElementById('download-menu');
3721
+ const btn = document.getElementById('download-btn');
3722
+ if (!menu.contains(e.target) && !btn.contains(e.target)) {
3723
+ menu.style.display = 'none';
3724
+ document.removeEventListener('click', closeDownloadMenuOnClickOutside);
3725
+ }
3726
+ }
3727
+
3728
+ // Export figure to bundle and trigger download
3729
+ async function exportAndDownload(format) {
3730
+ setStatus(`Exporting ${format.toUpperCase()}...`, false);
3731
+ try {
3732
+ // First export to bundle
3733
+ await fetch('/export', {
3734
+ method: 'POST',
3735
+ headers: {'Content-Type': 'application/json'},
3736
+ body: JSON.stringify({formats: [format]})
3737
+ });
3738
+ // Then trigger download
3739
+ window.location.href = `/download/${format}`;
3740
+ setStatus(`Downloaded ${format.toUpperCase()}`, false);
3741
+ } catch (e) {
3742
+ setStatus('Export error: ' + e.message, true);
3743
+ }
3744
+ }
3745
+
2922
3746
  function addAnnotation() {
2923
3747
  const text = document.getElementById('annot-text').value;
2924
3748
  if (!text) return;
@@ -3057,14 +3881,24 @@ function renderGroupStats(group) {
3057
3881
 
3058
3882
  function setStatus(msg, isError = false) {
3059
3883
  const el = document.getElementById('status');
3060
- const loadingOverlay = document.getElementById('loading-overlay');
3884
+ const globalOverlay = document.getElementById('global-loading-overlay');
3885
+ const localOverlay = document.getElementById('loading-overlay');
3061
3886
 
3062
3887
  // Show/hide spinner for loading states
3063
3888
  if (msg === 'Updating...' || msg === 'Loading preview...') {
3064
- loadingOverlay.style.display = 'flex';
3889
+ // Show global overlay (visible for both single and multi-panel views)
3890
+ if (globalOverlay) {
3891
+ globalOverlay.style.display = 'flex';
3892
+ const loadingText = globalOverlay.querySelector('.loading-text');
3893
+ if (loadingText) loadingText.textContent = msg;
3894
+ }
3895
+ // Also show local overlay if visible
3896
+ if (localOverlay) localOverlay.style.display = 'flex';
3065
3897
  el.textContent = ''; // Clear status text during loading
3066
3898
  } else {
3067
- loadingOverlay.style.display = 'none';
3899
+ // Hide both overlays
3900
+ if (globalOverlay) globalOverlay.style.display = 'none';
3901
+ if (localOverlay) localOverlay.style.display = 'none';
3068
3902
  el.textContent = msg;
3069
3903
  }
3070
3904
  el.classList.toggle('error', isError);
@@ -3106,11 +3940,974 @@ document.querySelectorAll('input[type="color"]').forEach(el => {
3106
3940
  });
3107
3941
  });
3108
3942
 
3109
- // Ctrl+S keyboard shortcut to save
3943
+ // =============================================================================
3944
+ // Keyboard Shortcuts (matching SciTeX Cloud vis app)
3945
+ // =============================================================================
3946
+ let shortcutMode = null; // For multi-key shortcuts like Alt+A → L
3947
+
3110
3948
  document.addEventListener('keydown', (e) => {
3111
- if ((e.ctrlKey || e.metaKey) && e.key === 's') {
3949
+ const key = e.key.toLowerCase();
3950
+ const isCtrl = e.ctrlKey || e.metaKey;
3951
+ const isShift = e.shiftKey;
3952
+ const isAlt = e.altKey;
3953
+
3954
+ // Don't capture shortcuts when typing in inputs
3955
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') {
3956
+ return;
3957
+ }
3958
+
3959
+ // =========================================================================
3960
+ // Multi-key shortcut mode (Alt+A → alignment, Alt+Shift+A → axis alignment)
3961
+ // =========================================================================
3962
+ if (shortcutMode === 'align') {
3112
3963
  e.preventDefault();
3113
- saveManual();
3964
+ handleAlignShortcut(key, isShift);
3965
+ shortcutMode = null;
3966
+ return;
3967
+ }
3968
+
3969
+ if (shortcutMode === 'alignByAxis') {
3970
+ e.preventDefault();
3971
+ handleAlignByAxisShortcut(key);
3972
+ shortcutMode = null;
3973
+ return;
3974
+ }
3975
+
3976
+ // =========================================================================
3977
+ // Basic Operations
3978
+ // =========================================================================
3979
+
3980
+ // Ctrl+S: Save
3981
+ if (isCtrl && key === 's') {
3982
+ e.preventDefault();
3983
+ saveManual();
3984
+ return;
3985
+ }
3986
+
3987
+ // Ctrl+Z: Undo
3988
+ if (isCtrl && !isShift && key === 'z') {
3989
+ e.preventDefault();
3990
+ undoLastChange();
3991
+ return;
3992
+ }
3993
+
3994
+ // Ctrl+Y or Ctrl+Shift+Z: Redo
3995
+ if ((isCtrl && key === 'y') || (isCtrl && isShift && key === 'z')) {
3996
+ e.preventDefault();
3997
+ redoLastChange();
3998
+ return;
3999
+ }
4000
+
4001
+ // Delete: Remove selected element override
4002
+ if (key === 'delete' || key === 'backspace') {
4003
+ if (selectedElement && !isCtrl) {
4004
+ e.preventDefault();
4005
+ deleteSelectedOverride();
4006
+ return;
4007
+ }
4008
+ }
4009
+
4010
+ // =========================================================================
4011
+ // Panel/Element Movement (Arrow keys)
4012
+ // =========================================================================
4013
+
4014
+ // Arrow keys: Move selected panel by 1mm (or 5mm with Shift)
4015
+ if (['arrowup', 'arrowdown', 'arrowleft', 'arrowright'].includes(key)) {
4016
+ e.preventDefault();
4017
+ const amount = isShift ? 5 : 1; // 5mm or 1mm
4018
+ moveSelectedPanel(key.replace('arrow', ''), amount);
4019
+ return;
4020
+ }
4021
+
4022
+ // =========================================================================
4023
+ // View Controls
4024
+ // =========================================================================
4025
+
4026
+ // + or =: Zoom in
4027
+ if ((key === '+' || key === '=') && !isCtrl) {
4028
+ e.preventDefault();
4029
+ zoomCanvas(1.1);
4030
+ return;
4031
+ }
4032
+
4033
+ // -: Zoom out
4034
+ if (key === '-' && !isCtrl) {
4035
+ e.preventDefault();
4036
+ zoomCanvas(0.9);
4037
+ return;
4038
+ }
4039
+
4040
+ // 0: Fit to window
4041
+ if (key === '0' && !isCtrl) {
4042
+ e.preventDefault();
4043
+ fitCanvasToWindow();
4044
+ return;
4045
+ }
4046
+
4047
+ // Ctrl++ : Increase canvas size
4048
+ if (isCtrl && (key === '+' || key === '=')) {
4049
+ e.preventDefault();
4050
+ resizeCanvas(1.1);
4051
+ return;
4052
+ }
4053
+
4054
+ // Ctrl+- : Decrease canvas size
4055
+ if (isCtrl && key === '-') {
4056
+ e.preventDefault();
4057
+ resizeCanvas(0.9);
4058
+ return;
4059
+ }
4060
+
4061
+ // =========================================================================
4062
+ // Alignment Modes (Alt+A → basic, Alt+Shift+A → by axis)
4063
+ // =========================================================================
4064
+ if (isAlt && isShift && key === 'a') {
4065
+ // Alt+Shift+A: Align by Axis (scientific alignment based on plot axes)
4066
+ e.preventDefault();
4067
+ shortcutMode = 'alignByAxis';
4068
+ setStatus('Align by Axis: L=Y-Axis(left) R=Right T=Top B=X-Axis(bottom) C=Center-H M=Center-V S=Stack', false);
4069
+ setTimeout(() => {
4070
+ if (shortcutMode === 'alignByAxis') {
4071
+ shortcutMode = null;
4072
+ setStatus('Ready', false);
4073
+ }
4074
+ }, 3000);
4075
+ return;
4076
+ }
4077
+ if (isAlt && !isShift && key === 'a') {
4078
+ // Alt+A: Basic alignment (by bounding box)
4079
+ e.preventDefault();
4080
+ shortcutMode = 'align';
4081
+ setStatus('Alignment mode: L=Left R=Right T=Top B=Bottom C=Center H=DistH V=DistV', false);
4082
+ setTimeout(() => {
4083
+ if (shortcutMode === 'align') {
4084
+ shortcutMode = null;
4085
+ setStatus('Ready', false);
4086
+ }
4087
+ }, 3000);
4088
+ return;
4089
+ }
4090
+
4091
+ // =========================================================================
4092
+ // Arrange (Alt+F, Alt+B)
4093
+ // =========================================================================
4094
+ if (isAlt && key === 'f') {
4095
+ e.preventDefault();
4096
+ bringPanelToFront();
4097
+ return;
4098
+ }
4099
+ if (isAlt && key === 'b') {
4100
+ e.preventDefault();
4101
+ sendPanelToBack();
4102
+ return;
4103
+ }
4104
+
4105
+ // =========================================================================
4106
+ // Escape: Deselect/Cancel mode
4107
+ // =========================================================================
4108
+ if (key === 'escape') {
4109
+ e.preventDefault();
4110
+ shortcutMode = null;
4111
+ deselectAllPanels();
4112
+ setStatus('Ready', false);
4113
+ return;
4114
+ }
4115
+
4116
+ // =========================================================================
4117
+ // G: Toggle grid visibility
4118
+ // =========================================================================
4119
+ if (key === 'g' && !isCtrl && !isAlt) {
4120
+ e.preventDefault();
4121
+ toggleGridVisibility();
4122
+ return;
4123
+ }
4124
+
4125
+ // =========================================================================
4126
+ // Ctrl+A: Select all panels
4127
+ // =========================================================================
4128
+ if (isCtrl && key === 'a') {
4129
+ e.preventDefault();
4130
+ selectAllPanels();
4131
+ return;
4132
+ }
4133
+
4134
+ // =========================================================================
4135
+ // Help (? or F1)
4136
+ // =========================================================================
4137
+ if (key === '?' || key === 'f1') {
4138
+ e.preventDefault();
4139
+ showShortcutHelp();
4140
+ return;
4141
+ }
4142
+ });
4143
+
4144
+ // Handle alignment sub-shortcuts (basic bounding box alignment)
4145
+ function handleAlignShortcut(key, isShift) {
4146
+ const panels = document.querySelectorAll('.panel-canvas-item');
4147
+ if (panels.length < 2) {
4148
+ setStatus('Need multiple panels for alignment', true);
4149
+ return;
4150
+ }
4151
+
4152
+ switch(key) {
4153
+ case 'l': alignPanels('left'); break;
4154
+ case 'r': alignPanels('right'); break;
4155
+ case 't': alignPanels('top'); break;
4156
+ case 'b': alignPanels('bottom'); break;
4157
+ case 'c': alignPanels('center-h'); break;
4158
+ case 'm': alignPanels('center-v'); break;
4159
+ case 'h': distributePanels('horizontal'); break;
4160
+ case 'v': distributePanels('vertical'); break;
4161
+ default:
4162
+ setStatus('Unknown alignment key: ' + key, true);
4163
+ }
4164
+ }
4165
+
4166
+ // Handle axis-based alignment sub-shortcuts (scientific plot alignment)
4167
+ function handleAlignByAxisShortcut(key) {
4168
+ const panels = document.querySelectorAll('.panel-canvas-item');
4169
+ if (panels.length < 2) {
4170
+ setStatus('Need multiple panels for axis alignment', true);
4171
+ return;
4172
+ }
4173
+
4174
+ const dirNames = {
4175
+ 'l': 'Y-axis (left edge)',
4176
+ 'r': 'Right edge',
4177
+ 't': 'Top edge',
4178
+ 'b': 'X-axis (bottom edge)',
4179
+ 'c': 'Center horizontal',
4180
+ 'm': 'Center vertical',
4181
+ 's': 'Stacked vertically'
4182
+ };
4183
+
4184
+ switch(key) {
4185
+ case 'l': alignPanelsByAxis('left'); break; // Y-axis left
4186
+ case 'r': alignPanelsByAxis('right'); break; // Right edge
4187
+ case 't': alignPanelsByAxis('top'); break; // Top edge
4188
+ case 'b': alignPanelsByAxis('bottom'); break; // X-axis bottom
4189
+ case 'c': alignPanelsByAxis('center-h'); break; // Horizontal center
4190
+ case 'm': alignPanelsByAxis('center-v'); break; // Vertical center
4191
+ case 's': stackPanelsVertically(); break; // Stack with Y-axis alignment
4192
+ default:
4193
+ setStatus('Unknown axis key: ' + key + '. Use L/R/T/B/C/M/S', true);
4194
+ return;
4195
+ }
4196
+ if (dirNames[key]) {
4197
+ setStatus(`Aligned by axis: ${dirNames[key]}`, false);
4198
+ }
4199
+ }
4200
+
4201
+ // Get axes bounding box from panel's cached bboxes
4202
+ // Returns {x0, y0, x1, y1} in image pixels, or null if not found
4203
+ function getAxesBboxForPanel(panelName) {
4204
+ const cache = panelBboxesCache[panelName];
4205
+ if (!cache || !cache.bboxes) return null;
4206
+
4207
+ // Look for ax_00_panel, ax_01_panel, etc.
4208
+ const bboxes = cache.bboxes;
4209
+ for (const key of Object.keys(bboxes)) {
4210
+ if (key.endsWith('_panel') && key.startsWith('ax_')) {
4211
+ const bbox = bboxes[key];
4212
+ if (bbox && bbox.x0 !== undefined) {
4213
+ return {
4214
+ x0: bbox.x0,
4215
+ y0: bbox.y0,
4216
+ x1: bbox.x1,
4217
+ y1: bbox.y1,
4218
+ key: key
4219
+ };
4220
+ }
4221
+ }
4222
+ }
4223
+
4224
+ // Fallback: check _meta.axes_bbox_px for single-axes plots
4225
+ if (bboxes._meta && bboxes._meta.axes_bbox_px) {
4226
+ const axBbox = bboxes._meta.axes_bbox_px;
4227
+ return {
4228
+ x0: axBbox.x0 || axBbox.x,
4229
+ y0: axBbox.y0 || axBbox.y,
4230
+ x1: axBbox.x1 || (axBbox.x + axBbox.width),
4231
+ y1: axBbox.y1 || (axBbox.y + axBbox.height),
4232
+ key: '_meta.axes_bbox_px'
4233
+ };
4234
+ }
4235
+
4236
+ return null;
4237
+ }
4238
+
4239
+ // Calculate panel offset to align by axis edge
4240
+ // Returns the axis edge position in canvas pixels relative to panel's top-left
4241
+ function getAxisEdgeOffset(panel, axesBbox, edge, imgSize) {
4242
+ if (!axesBbox || !imgSize) return 0;
4243
+
4244
+ // Scale factor from image pixels to displayed panel pixels
4245
+ const panelEl = panel;
4246
+ const displayWidth = panelEl.offsetWidth;
4247
+ const displayHeight = panelEl.offsetHeight;
4248
+ const scaleX = displayWidth / imgSize.width;
4249
+ const scaleY = displayHeight / imgSize.height;
4250
+
4251
+ switch(edge) {
4252
+ case 'left':
4253
+ // Y-axis left edge
4254
+ return axesBbox.x0 * scaleX;
4255
+ case 'right':
4256
+ // Right edge of axes
4257
+ return axesBbox.x1 * scaleX;
4258
+ case 'top':
4259
+ // Top edge of axes
4260
+ return axesBbox.y0 * scaleY;
4261
+ case 'bottom':
4262
+ // X-axis bottom edge
4263
+ return axesBbox.y1 * scaleY;
4264
+ case 'center-h':
4265
+ // Horizontal center of axes
4266
+ return ((axesBbox.x0 + axesBbox.x1) / 2) * scaleX;
4267
+ case 'center-v':
4268
+ // Vertical center of axes
4269
+ return ((axesBbox.y0 + axesBbox.y1) / 2) * scaleY;
4270
+ default:
4271
+ return 0;
4272
+ }
4273
+ }
4274
+
4275
+ // Align panels by axis edges (scientific alignment for plots)
4276
+ function alignPanelsByAxis(edge) {
4277
+ const panels = Array.from(document.querySelectorAll('.panel-canvas-item'));
4278
+ if (panels.length < 2) {
4279
+ setStatus('Need multiple panels for axis alignment', true);
4280
+ return;
4281
+ }
4282
+
4283
+ // Collect panel info with axes bboxes
4284
+ const panelInfos = [];
4285
+ for (const panel of panels) {
4286
+ const panelName = panel.dataset.panelName;
4287
+ const cache = panelBboxesCache[panelName];
4288
+ const axesBbox = getAxesBboxForPanel(panelName);
4289
+ const imgSize = cache ? cache.imgSize : null;
4290
+
4291
+ if (!axesBbox || !imgSize) {
4292
+ console.warn(`Panel ${panelName} has no axes bbox data`);
4293
+ continue;
4294
+ }
4295
+
4296
+ panelInfos.push({
4297
+ el: panel,
4298
+ name: panelName,
4299
+ left: parseFloat(panel.style.left) || 0,
4300
+ top: parseFloat(panel.style.top) || 0,
4301
+ width: panel.offsetWidth,
4302
+ height: panel.offsetHeight,
4303
+ axesBbox: axesBbox,
4304
+ imgSize: imgSize,
4305
+ axisOffset: getAxisEdgeOffset(panel, axesBbox, edge, imgSize)
4306
+ });
4307
+ }
4308
+
4309
+ if (panelInfos.length < 2) {
4310
+ setStatus('Need at least 2 panels with axis data for alignment', true);
4311
+ return;
4312
+ }
4313
+
4314
+ // Calculate target position - use the first panel's axis position as reference
4315
+ const isHorizontal = ['left', 'right', 'center-h'].includes(edge);
4316
+
4317
+ if (isHorizontal) {
4318
+ // Align horizontally (match X positions of axis edges)
4319
+ // Target = first panel's axis X position in canvas coords
4320
+ const refPanel = panelInfos[0];
4321
+ const targetAxisX = refPanel.left + refPanel.axisOffset;
4322
+
4323
+ for (const info of panelInfos) {
4324
+ const newLeft = targetAxisX - info.axisOffset;
4325
+ info.el.style.left = newLeft + 'px';
4326
+ }
4327
+ } else {
4328
+ // Align vertically (match Y positions of axis edges)
4329
+ // Target = first panel's axis Y position in canvas coords
4330
+ const refPanel = panelInfos[0];
4331
+ const targetAxisY = refPanel.top + refPanel.axisOffset;
4332
+
4333
+ for (const info of panelInfos) {
4334
+ const newTop = targetAxisY - info.axisOffset;
4335
+ info.el.style.top = newTop + 'px';
4336
+ }
4337
+ }
4338
+
4339
+ // Update layout data
4340
+ updatePanelLayoutFromDOM();
4341
+ console.log(`Aligned ${panelInfos.length} panels by axis: ${edge}`);
4342
+ }
4343
+
4344
+ // Stack panels vertically with Y-axis alignment
4345
+ function stackPanelsVertically() {
4346
+ const panels = Array.from(document.querySelectorAll('.panel-canvas-item'));
4347
+ if (panels.length < 2) {
4348
+ setStatus('Need multiple panels for stacking', true);
4349
+ return;
4350
+ }
4351
+
4352
+ // Collect panel info with axes bboxes
4353
+ const panelInfos = [];
4354
+ for (const panel of panels) {
4355
+ const panelName = panel.dataset.panelName;
4356
+ const cache = panelBboxesCache[panelName];
4357
+ const axesBbox = getAxesBboxForPanel(panelName);
4358
+ const imgSize = cache ? cache.imgSize : null;
4359
+
4360
+ if (!axesBbox || !imgSize) {
4361
+ console.warn(`Panel ${panelName} has no axes bbox data`);
4362
+ continue;
4363
+ }
4364
+
4365
+ panelInfos.push({
4366
+ el: panel,
4367
+ name: panelName,
4368
+ left: parseFloat(panel.style.left) || 0,
4369
+ top: parseFloat(panel.style.top) || 0,
4370
+ width: panel.offsetWidth,
4371
+ height: panel.offsetHeight,
4372
+ axesBbox: axesBbox,
4373
+ imgSize: imgSize,
4374
+ yAxisOffset: getAxisEdgeOffset(panel, axesBbox, 'left', imgSize)
4375
+ });
4376
+ }
4377
+
4378
+ if (panelInfos.length < 2) {
4379
+ setStatus('Need at least 2 panels with axis data for stacking', true);
4380
+ return;
4381
+ }
4382
+
4383
+ // Sort by current vertical position
4384
+ panelInfos.sort((a, b) => a.top - b.top);
4385
+
4386
+ // Use first panel as reference for Y-axis alignment
4387
+ const refPanel = panelInfos[0];
4388
+ const targetAxisX = refPanel.left + refPanel.yAxisOffset;
4389
+
4390
+ // Stack panels vertically with small gap, aligned by Y-axis
4391
+ const gap = 10; // pixels gap between panels
4392
+ let currentY = refPanel.top;
4393
+
4394
+ for (let i = 0; i < panelInfos.length; i++) {
4395
+ const info = panelInfos[i];
4396
+
4397
+ // Align Y-axis (left edge of axes)
4398
+ const newLeft = targetAxisX - info.yAxisOffset;
4399
+ info.el.style.left = newLeft + 'px';
4400
+
4401
+ // Stack vertically
4402
+ info.el.style.top = currentY + 'px';
4403
+ currentY += info.height + gap;
4404
+ }
4405
+
4406
+ // Update layout data
4407
+ updatePanelLayoutFromDOM();
4408
+ setStatus(`Stacked ${panelInfos.length} panels with Y-axis alignment`, false);
4409
+ }
4410
+
4411
+ // Move selected panel(s) by delta in mm
4412
+ function moveSelectedPanel(direction, amountMm) {
4413
+ const selectedPanels = document.querySelectorAll('.panel-canvas-item.active');
4414
+ if (selectedPanels.length === 0) {
4415
+ setStatus('No panel selected', true);
4416
+ return;
4417
+ }
4418
+
4419
+ const deltaX = direction === 'left' ? -amountMm : (direction === 'right' ? amountMm : 0);
4420
+ const deltaY = direction === 'up' ? -amountMm : (direction === 'down' ? amountMm : 0);
4421
+
4422
+ selectedPanels.forEach(panel => {
4423
+ const panelName = panel.dataset.panelName;
4424
+
4425
+ // Update position in pixels (canvasScale = px/mm)
4426
+ const currentLeft = parseFloat(panel.style.left) || 0;
4427
+ const currentTop = parseFloat(panel.style.top) || 0;
4428
+
4429
+ panel.style.left = (currentLeft + deltaX * canvasScale) + 'px';
4430
+ panel.style.top = (currentTop + deltaY * canvasScale) + 'px';
4431
+
4432
+ // Update layout data
4433
+ if (panelLayoutMm[panelName]) {
4434
+ panelLayoutMm[panelName].x_mm += deltaX;
4435
+ panelLayoutMm[panelName].y_mm += deltaY;
4436
+ layoutModified = true;
4437
+ }
4438
+ });
4439
+
4440
+ const count = selectedPanels.length;
4441
+ const panelText = count === 1 ? selectedPanels[0].dataset.panelName : `${count} panels`;
4442
+ setStatus(`Moved ${panelText} by ${amountMm}mm ${direction}`, false);
4443
+ }
4444
+
4445
+ // Zoom canvas view
4446
+ let canvasZoom = 1.0;
4447
+ function zoomCanvas(factor) {
4448
+ canvasZoom *= factor;
4449
+ canvasZoom = Math.max(0.25, Math.min(4, canvasZoom)); // Limit 25%-400%
4450
+ const canvas = document.getElementById('panel-canvas');
4451
+ if (canvas) {
4452
+ canvas.style.transform = `scale(${canvasZoom})`;
4453
+ canvas.style.transformOrigin = 'top left';
4454
+ }
4455
+ setStatus(`Zoom: ${Math.round(canvasZoom * 100)}%`, false);
4456
+ }
4457
+
4458
+ // Fit canvas to window
4459
+ function fitCanvasToWindow() {
4460
+ canvasZoom = 1.0;
4461
+ const canvas = document.getElementById('panel-canvas');
4462
+ if (canvas) {
4463
+ canvas.style.transform = 'scale(1)';
4464
+ }
4465
+ setStatus('Fit to window', false);
4466
+ }
4467
+
4468
+ // Resize canvas (actual size, not view)
4469
+ function resizeCanvas(factor) {
4470
+ const canvas = document.getElementById('panel-canvas');
4471
+ if (!canvas) return;
4472
+ const currentWidth = canvas.offsetWidth;
4473
+ const currentHeight = canvas.offsetHeight;
4474
+ canvas.style.width = (currentWidth * factor) + 'px';
4475
+ canvas.style.minHeight = (currentHeight * factor) + 'px';
4476
+ setStatus(`Canvas: ${Math.round(currentWidth * factor)}x${Math.round(currentHeight * factor)}px`, false);
4477
+ }
4478
+
4479
+ // Align panels
4480
+ function alignPanels(mode) {
4481
+ const panels = Array.from(document.querySelectorAll('.panel-canvas-item'));
4482
+ if (panels.length < 2) return;
4483
+
4484
+ // Get bounds
4485
+ const bounds = panels.map(p => ({
4486
+ el: p,
4487
+ left: parseFloat(p.style.left) || 0,
4488
+ top: parseFloat(p.style.top) || 0,
4489
+ width: p.offsetWidth,
4490
+ height: p.offsetHeight
4491
+ }));
4492
+
4493
+ let targetValue;
4494
+ switch(mode) {
4495
+ case 'left':
4496
+ targetValue = Math.min(...bounds.map(b => b.left));
4497
+ bounds.forEach(b => { b.el.style.left = targetValue + 'px'; });
4498
+ break;
4499
+ case 'right':
4500
+ targetValue = Math.max(...bounds.map(b => b.left + b.width));
4501
+ bounds.forEach(b => { b.el.style.left = (targetValue - b.width) + 'px'; });
4502
+ break;
4503
+ case 'top':
4504
+ targetValue = Math.min(...bounds.map(b => b.top));
4505
+ bounds.forEach(b => { b.el.style.top = targetValue + 'px'; });
4506
+ break;
4507
+ case 'bottom':
4508
+ targetValue = Math.max(...bounds.map(b => b.top + b.height));
4509
+ bounds.forEach(b => { b.el.style.top = (targetValue - b.height) + 'px'; });
4510
+ break;
4511
+ case 'center-h':
4512
+ targetValue = bounds.reduce((sum, b) => sum + b.left + b.width/2, 0) / bounds.length;
4513
+ bounds.forEach(b => { b.el.style.left = (targetValue - b.width/2) + 'px'; });
4514
+ break;
4515
+ case 'center-v':
4516
+ targetValue = bounds.reduce((sum, b) => sum + b.top + b.height/2, 0) / bounds.length;
4517
+ bounds.forEach(b => { b.el.style.top = (targetValue - b.height/2) + 'px'; });
4518
+ break;
4519
+ }
4520
+
4521
+ // Update layout data
4522
+ updatePanelLayoutFromDOM();
4523
+ setStatus(`Aligned panels: ${mode}`, false);
4524
+ }
4525
+
4526
+ // Distribute panels evenly
4527
+ function distributePanels(direction) {
4528
+ const panels = Array.from(document.querySelectorAll('.panel-canvas-item'));
4529
+ if (panels.length < 3) {
4530
+ setStatus('Need at least 3 panels to distribute', true);
4531
+ return;
4532
+ }
4533
+
4534
+ const bounds = panels.map(p => ({
4535
+ el: p,
4536
+ left: parseFloat(p.style.left) || 0,
4537
+ top: parseFloat(p.style.top) || 0,
4538
+ width: p.offsetWidth,
4539
+ height: p.offsetHeight
4540
+ }));
4541
+
4542
+ if (direction === 'horizontal') {
4543
+ bounds.sort((a, b) => a.left - b.left);
4544
+ const totalWidth = bounds.reduce((sum, b) => sum + b.width, 0);
4545
+ const start = bounds[0].left;
4546
+ const end = bounds[bounds.length - 1].left + bounds[bounds.length - 1].width;
4547
+ const gap = (end - start - totalWidth) / (bounds.length - 1);
4548
+
4549
+ let currentX = start;
4550
+ bounds.forEach(b => {
4551
+ b.el.style.left = currentX + 'px';
4552
+ currentX += b.width + gap;
4553
+ });
4554
+ } else {
4555
+ bounds.sort((a, b) => a.top - b.top);
4556
+ const totalHeight = bounds.reduce((sum, b) => sum + b.height, 0);
4557
+ const start = bounds[0].top;
4558
+ const end = bounds[bounds.length - 1].top + bounds[bounds.length - 1].height;
4559
+ const gap = (end - start - totalHeight) / (bounds.length - 1);
4560
+
4561
+ let currentY = start;
4562
+ bounds.forEach(b => {
4563
+ b.el.style.top = currentY + 'px';
4564
+ currentY += b.height + gap;
4565
+ });
4566
+ }
4567
+
4568
+ updatePanelLayoutFromDOM();
4569
+ setStatus(`Distributed panels: ${direction}`, false);
4570
+ }
4571
+
4572
+ // Update layout data from DOM positions
4573
+ function updatePanelLayoutFromDOM() {
4574
+ document.querySelectorAll('.panel-canvas-item').forEach(panel => {
4575
+ const name = panel.dataset.panelName;
4576
+ if (panelLayoutMm[name]) {
4577
+ panelLayoutMm[name].x_mm = parseFloat(panel.style.left) / canvasScale;
4578
+ panelLayoutMm[name].y_mm = parseFloat(panel.style.top) / canvasScale;
4579
+ }
4580
+ });
4581
+ layoutModified = true;
4582
+ autoSaveLayout();
4583
+ }
4584
+
4585
+ // Bring selected panel(s) to front
4586
+ function bringPanelToFront() {
4587
+ const selectedPanels = document.querySelectorAll('.panel-canvas-item.active');
4588
+ if (selectedPanels.length === 0) return;
4589
+ const maxZ = Math.max(...Array.from(document.querySelectorAll('.panel-canvas-item')).map(p => parseInt(p.style.zIndex) || 0));
4590
+ selectedPanels.forEach((panel, i) => {
4591
+ panel.style.zIndex = maxZ + 1 + i;
4592
+ });
4593
+ setStatus(`Brought ${selectedPanels.length > 1 ? selectedPanels.length + ' panels' : 'panel'} to front`, false);
4594
+ }
4595
+
4596
+ // Send selected panel(s) to back
4597
+ function sendPanelToBack() {
4598
+ const selectedPanels = document.querySelectorAll('.panel-canvas-item.active');
4599
+ if (selectedPanels.length === 0) return;
4600
+ const minZ = Math.min(...Array.from(document.querySelectorAll('.panel-canvas-item')).map(p => parseInt(p.style.zIndex) || 0));
4601
+ selectedPanels.forEach((panel, i) => {
4602
+ panel.style.zIndex = minZ - selectedPanels.length + i;
4603
+ });
4604
+ setStatus(`Sent ${selectedPanels.length > 1 ? selectedPanels.length + ' panels' : 'panel'} to back`, false);
4605
+ }
4606
+
4607
+ // Deselect all panels
4608
+ function deselectAllPanels() {
4609
+ document.querySelectorAll('.panel-canvas-item.active').forEach(p => {
4610
+ p.classList.remove('active');
4611
+ });
4612
+ // Also clear element selection in single-panel view
4613
+ if (typeof selectedElement !== 'undefined') {
4614
+ selectedElement = null;
4615
+ }
4616
+ }
4617
+
4618
+ // Select all panels
4619
+ function selectAllPanels() {
4620
+ const panels = document.querySelectorAll('.panel-canvas-item');
4621
+ panels.forEach(p => p.classList.add('active'));
4622
+ setStatus(`Selected ${panels.length} panels`, false);
4623
+ }
4624
+
4625
+ // Toggle grid visibility
4626
+ let gridVisible = true;
4627
+ function toggleGridVisibility() {
4628
+ gridVisible = !gridVisible;
4629
+ const gridElements = document.querySelectorAll('.canvas-grid, .grid-lines, .ruler-marks');
4630
+ gridElements.forEach(el => {
4631
+ el.style.opacity = gridVisible ? '1' : '0';
4632
+ });
4633
+ // Also toggle the canvas background grid if using CSS grid
4634
+ const canvasContainer = document.querySelector('.panel-canvas, #canvas-container');
4635
+ if (canvasContainer) {
4636
+ if (gridVisible) {
4637
+ canvasContainer.classList.remove('hide-grid');
4638
+ } else {
4639
+ canvasContainer.classList.add('hide-grid');
4640
+ }
4641
+ }
4642
+ setStatus(gridVisible ? 'Grid visible' : 'Grid hidden', false);
4643
+ }
4644
+
4645
+ // Undo/Redo stacks
4646
+ let undoStack = [];
4647
+ let redoStack = [];
4648
+
4649
+ function undoLastChange() {
4650
+ if (undoStack.length === 0) {
4651
+ setStatus('Nothing to undo', true);
4652
+ return;
4653
+ }
4654
+ const state = undoStack.pop();
4655
+ redoStack.push(JSON.stringify(overrides));
4656
+ overrides = JSON.parse(state);
4657
+ updatePreview();
4658
+ setStatus('Undo', false);
4659
+ }
4660
+
4661
+ function redoLastChange() {
4662
+ if (redoStack.length === 0) {
4663
+ setStatus('Nothing to redo', true);
4664
+ return;
4665
+ }
4666
+ const state = redoStack.pop();
4667
+ undoStack.push(JSON.stringify(overrides));
4668
+ overrides = JSON.parse(state);
4669
+ updatePreview();
4670
+ setStatus('Redo', false);
4671
+ }
4672
+
4673
+ // Save state for undo before changes
4674
+ function saveUndoState() {
4675
+ undoStack.push(JSON.stringify(overrides));
4676
+ if (undoStack.length > 50) undoStack.shift(); // Limit stack size
4677
+ redoStack = []; // Clear redo on new change
4678
+ }
4679
+
4680
+ // Delete selected element override
4681
+ function deleteSelectedOverride() {
4682
+ if (!selectedElement) return;
4683
+ saveUndoState();
4684
+ if (overrides.element_overrides && overrides.element_overrides[selectedElement]) {
4685
+ delete overrides.element_overrides[selectedElement];
4686
+ updatePreview();
4687
+ setStatus(`Deleted override for ${selectedElement}`, false);
4688
+ }
4689
+ }
4690
+
4691
+ // Show keyboard shortcuts help
4692
+ function showShortcutHelp() {
4693
+ const helpHtml = `
4694
+ <div id="shortcut-modal" style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.8); z-index: 10000; display: flex; align-items: center; justify-content: center;" onclick="this.remove()">
4695
+ <div style="background: var(--bg-secondary); padding: 24px; border-radius: 8px; max-width: 700px; max-height: 80vh; overflow-y: auto; color: var(--text-primary);" onclick="event.stopPropagation()">
4696
+ <h2 style="margin-top: 0; border-bottom: 1px solid var(--border-color); padding-bottom: 8px;">⌨️ Keyboard Shortcuts</h2>
4697
+
4698
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
4699
+ <div>
4700
+ <h4 style="color: var(--accent-primary);">Basic</h4>
4701
+ <div><kbd>Ctrl+S</kbd> Save</div>
4702
+ <div><kbd>Ctrl+Z</kbd> Undo</div>
4703
+ <div><kbd>Ctrl+Y</kbd> Redo</div>
4704
+ <div><kbd>Del</kbd> Delete override</div>
4705
+ <div><kbd>Esc</kbd> Deselect / Cancel</div>
4706
+ <div><kbd>Ctrl+A</kbd> Select all panels</div>
4707
+
4708
+ <h4 style="color: var(--accent-primary); margin-top: 16px;">Selection</h4>
4709
+ <div><kbd>Click</kbd> Select panel</div>
4710
+ <div><kbd>Ctrl+Click</kbd> Multi-select</div>
4711
+ <div><kbd>Right-Click</kbd> Context menu</div>
4712
+
4713
+ <h4 style="color: var(--accent-primary); margin-top: 16px;">Movement</h4>
4714
+ <div><kbd>↑↓←→</kbd> Move panel 1mm</div>
4715
+ <div><kbd>Shift+↑↓←→</kbd> Move panel 5mm</div>
4716
+ </div>
4717
+
4718
+ <div>
4719
+ <h4 style="color: var(--accent-primary);">View</h4>
4720
+ <div><kbd>+</kbd> Zoom in</div>
4721
+ <div><kbd>-</kbd> Zoom out</div>
4722
+ <div><kbd>0</kbd> Fit to window</div>
4723
+ <div><kbd>G</kbd> Toggle grid</div>
4724
+ <div><kbd>Ctrl++</kbd> Increase canvas</div>
4725
+ <div><kbd>Ctrl+-</kbd> Decrease canvas</div>
4726
+
4727
+ <h4 style="color: var(--accent-primary); margin-top: 16px;">Arrange</h4>
4728
+ <div><kbd>Alt+F</kbd> Bring to front</div>
4729
+ <div><kbd>Alt+B</kbd> Send to back</div>
4730
+ </div>
4731
+ </div>
4732
+
4733
+ <h4 style="color: var(--accent-primary); margin-top: 16px;">Alignment (Alt+A → ...)</h4>
4734
+ <div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;">
4735
+ <div><kbd>L</kbd> Left</div>
4736
+ <div><kbd>R</kbd> Right</div>
4737
+ <div><kbd>T</kbd> Top</div>
4738
+ <div><kbd>B</kbd> Bottom</div>
4739
+ <div><kbd>C</kbd> Center H</div>
4740
+ <div><kbd>M</kbd> Center V</div>
4741
+ <div><kbd>H</kbd> Distribute H</div>
4742
+ <div><kbd>V</kbd> Distribute V</div>
4743
+ </div>
4744
+
4745
+ <h4 style="color: var(--accent-primary); margin-top: 16px;">Axis Alignment (Alt+Shift+A → ...)</h4>
4746
+ <p style="font-size: 0.85em; color: var(--text-muted); margin-top: 4px;">Aligns panels by plot axis edges, not bounding boxes</p>
4747
+ <div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;">
4748
+ <div><kbd>L</kbd> Y-axis (left)</div>
4749
+ <div><kbd>R</kbd> Right edge</div>
4750
+ <div><kbd>T</kbd> Top edge</div>
4751
+ <div><kbd>B</kbd> X-axis (bottom)</div>
4752
+ <div><kbd>C</kbd> Axes center H</div>
4753
+ <div><kbd>M</kbd> Axes center V</div>
4754
+ <div><kbd>S</kbd> Stack vertically</div>
4755
+ </div>
4756
+
4757
+ <div style="margin-top: 20px; text-align: center; color: var(--text-muted);">
4758
+ Press <kbd>?</kbd> or <kbd>F1</kbd> anytime to show this help
4759
+ </div>
4760
+ </div>
4761
+ </div>`;
4762
+ document.body.insertAdjacentHTML('beforeend', helpHtml);
4763
+ }
4764
+
4765
+ // Add kbd styling
4766
+ const kbdStyle = document.createElement('style');
4767
+ kbdStyle.textContent = `
4768
+ kbd {
4769
+ background: var(--bg-tertiary, #333);
4770
+ border: 1px solid var(--border-color, #555);
4771
+ border-radius: 3px;
4772
+ padding: 2px 6px;
4773
+ font-family: monospace;
4774
+ font-size: 0.85em;
4775
+ margin-right: 8px;
4776
+ }
4777
+ `;
4778
+ document.head.appendChild(kbdStyle);
4779
+
4780
+ // =============================================================================
4781
+ // Right-Click Context Menu
4782
+ // =============================================================================
4783
+ let contextMenu = null;
4784
+
4785
+ function showContextMenu(e, panelName) {
4786
+ e.preventDefault();
4787
+ hideContextMenu();
4788
+
4789
+ const selectedCount = document.querySelectorAll('.panel-canvas-item.active').length;
4790
+ const hasSelection = selectedCount > 0;
4791
+
4792
+ const menu = document.createElement('div');
4793
+ menu.id = 'canvas-context-menu';
4794
+ menu.className = 'context-menu';
4795
+ menu.innerHTML = `
4796
+ <div class="context-menu-item" onclick="selectAllPanels(); hideContextMenu();">
4797
+ <span class="context-menu-icon">⬚</span> Select All <span class="context-menu-shortcut">Ctrl+A</span>
4798
+ </div>
4799
+ <div class="context-menu-item ${!hasSelection ? 'disabled' : ''}" onclick="${hasSelection ? 'deselectAllPanels(); hideContextMenu();' : ''}">
4800
+ <span class="context-menu-icon">○</span> Deselect All <span class="context-menu-shortcut">Esc</span>
4801
+ </div>
4802
+ <div class="context-menu-divider"></div>
4803
+ <div class="context-menu-item ${!hasSelection ? 'disabled' : ''}" onclick="${hasSelection ? 'bringPanelToFront(); hideContextMenu();' : ''}">
4804
+ <span class="context-menu-icon">↑</span> Bring to Front <span class="context-menu-shortcut">Alt+F</span>
4805
+ </div>
4806
+ <div class="context-menu-item ${!hasSelection ? 'disabled' : ''}" onclick="${hasSelection ? 'sendPanelToBack(); hideContextMenu();' : ''}">
4807
+ <span class="context-menu-icon">↓</span> Send to Back <span class="context-menu-shortcut">Alt+B</span>
4808
+ </div>
4809
+ <div class="context-menu-divider"></div>
4810
+ <div class="context-menu-submenu">
4811
+ <div class="context-menu-item">
4812
+ <span class="context-menu-icon">≡</span> Align <span class="context-menu-arrow">▶</span>
4813
+ </div>
4814
+ <div class="context-submenu">
4815
+ <div class="context-menu-item ${selectedCount < 2 ? 'disabled' : ''}" onclick="${selectedCount >= 2 ? "alignPanels('left'); hideContextMenu();" : ''}">Left</div>
4816
+ <div class="context-menu-item ${selectedCount < 2 ? 'disabled' : ''}" onclick="${selectedCount >= 2 ? "alignPanels('right'); hideContextMenu();" : ''}">Right</div>
4817
+ <div class="context-menu-item ${selectedCount < 2 ? 'disabled' : ''}" onclick="${selectedCount >= 2 ? "alignPanels('top'); hideContextMenu();" : ''}">Top</div>
4818
+ <div class="context-menu-item ${selectedCount < 2 ? 'disabled' : ''}" onclick="${selectedCount >= 2 ? "alignPanels('bottom'); hideContextMenu();" : ''}">Bottom</div>
4819
+ <div class="context-menu-divider"></div>
4820
+ <div class="context-menu-item ${selectedCount < 2 ? 'disabled' : ''}" onclick="${selectedCount >= 2 ? "alignPanels('center-h'); hideContextMenu();" : ''}">Center H</div>
4821
+ <div class="context-menu-item ${selectedCount < 2 ? 'disabled' : ''}" onclick="${selectedCount >= 2 ? "alignPanels('center-v'); hideContextMenu();" : ''}">Center V</div>
4822
+ </div>
4823
+ </div>
4824
+ <div class="context-menu-submenu">
4825
+ <div class="context-menu-item">
4826
+ <span class="context-menu-icon">⊞</span> Align by Axis <span class="context-menu-arrow">▶</span>
4827
+ </div>
4828
+ <div class="context-submenu">
4829
+ <div class="context-menu-item ${selectedCount < 2 ? 'disabled' : ''}" onclick="${selectedCount >= 2 ? "alignPanelsByAxis('left'); hideContextMenu();" : ''}">Y-Axis (Left)</div>
4830
+ <div class="context-menu-item ${selectedCount < 2 ? 'disabled' : ''}" onclick="${selectedCount >= 2 ? "alignPanelsByAxis('bottom'); hideContextMenu();" : ''}">X-Axis (Bottom)</div>
4831
+ <div class="context-menu-divider"></div>
4832
+ <div class="context-menu-item ${selectedCount < 2 ? 'disabled' : ''}" onclick="${selectedCount >= 2 ? "stackPanelsVertically(); hideContextMenu();" : ''}">Stack Vertically</div>
4833
+ </div>
4834
+ </div>
4835
+ <div class="context-menu-submenu">
4836
+ <div class="context-menu-item">
4837
+ <span class="context-menu-icon">⇔</span> Distribute <span class="context-menu-arrow">▶</span>
4838
+ </div>
4839
+ <div class="context-submenu">
4840
+ <div class="context-menu-item ${selectedCount < 2 ? 'disabled' : ''}" onclick="${selectedCount >= 2 ? "distributePanels('horizontal'); hideContextMenu();" : ''}">Horizontal</div>
4841
+ <div class="context-menu-item ${selectedCount < 2 ? 'disabled' : ''}" onclick="${selectedCount >= 2 ? "distributePanels('vertical'); hideContextMenu();" : ''}">Vertical</div>
4842
+ </div>
4843
+ </div>
4844
+ <div class="context-menu-divider"></div>
4845
+ <div class="context-menu-item" onclick="toggleGridVisibility(); hideContextMenu();">
4846
+ <span class="context-menu-icon">⊞</span> Toggle Grid <span class="context-menu-shortcut">G</span>
4847
+ </div>
4848
+ <div class="context-menu-divider"></div>
4849
+ <div class="context-menu-item" onclick="showShortcutHelp(); hideContextMenu();">
4850
+ <span class="context-menu-icon">⌨</span> Keyboard Shortcuts <span class="context-menu-shortcut">?</span>
4851
+ </div>
4852
+ `;
4853
+
4854
+ // Position menu at cursor
4855
+ menu.style.left = e.clientX + 'px';
4856
+ menu.style.top = e.clientY + 'px';
4857
+
4858
+ document.body.appendChild(menu);
4859
+ contextMenu = menu;
4860
+
4861
+ // Adjust position if menu goes off screen
4862
+ const rect = menu.getBoundingClientRect();
4863
+ if (rect.right > window.innerWidth) {
4864
+ menu.style.left = (window.innerWidth - rect.width - 5) + 'px';
4865
+ }
4866
+ if (rect.bottom > window.innerHeight) {
4867
+ menu.style.top = (window.innerHeight - rect.height - 5) + 'px';
4868
+ }
4869
+ }
4870
+
4871
+ function hideContextMenu() {
4872
+ if (contextMenu) {
4873
+ contextMenu.remove();
4874
+ contextMenu = null;
4875
+ }
4876
+ }
4877
+
4878
+ // Close context menu on click outside
4879
+ document.addEventListener('click', (e) => {
4880
+ if (contextMenu && !contextMenu.contains(e.target)) {
4881
+ hideContextMenu();
4882
+ }
4883
+ });
4884
+
4885
+ // Close context menu on Escape
4886
+ document.addEventListener('keydown', (e) => {
4887
+ if (e.key === 'Escape' && contextMenu) {
4888
+ hideContextMenu();
4889
+ }
4890
+ });
4891
+
4892
+ // Attach context menu to canvas
4893
+ document.addEventListener('DOMContentLoaded', () => {
4894
+ const canvas = document.getElementById('panel-canvas');
4895
+ if (canvas) {
4896
+ canvas.addEventListener('contextmenu', (e) => {
4897
+ // Check if right-click is on a panel
4898
+ const panel = e.target.closest('.panel-canvas-item');
4899
+ const panelName = panel ? panel.dataset.panelName : null;
4900
+
4901
+ // If clicking on a panel that's not selected, select it
4902
+ if (panel && !panel.classList.contains('active')) {
4903
+ if (!e.ctrlKey && !e.metaKey) {
4904
+ deselectAllPanels();
4905
+ }
4906
+ panel.classList.add('active');
4907
+ }
4908
+
4909
+ showContextMenu(e, panelName);
4910
+ });
3114
4911
  }
3115
4912
  });
3116
4913