plexora 0.0.1__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 (248) hide show
  1. plexora/__init__.py +129 -0
  2. plexora/__main__.py +15 -0
  3. plexora/_url.py +105 -0
  4. plexora/api/__init__.py +55 -0
  5. plexora/api/dataset.py +398 -0
  6. plexora/api/http.py +19 -0
  7. plexora/api/plugin.py +541 -0
  8. plexora/api/store.py +221 -0
  9. plexora/cli.py +777 -0
  10. plexora/client/dist/354_bundle.js +2 -0
  11. plexora/client/dist/354_bundle.js.LICENSE.txt +20 -0
  12. plexora/client/dist/418_bundle.js +2 -0
  13. plexora/client/dist/418_bundle.js.LICENSE.txt +1 -0
  14. plexora/client/dist/693b935cb1f907814be9c6199a68c217.svg +19 -0
  15. plexora/client/dist/713c134ff47cd328a5b711526f151082.svg +18 -0
  16. plexora/client/dist/770_bundle.js +1 -0
  17. plexora/client/dist/ff743f408972e0e96ca9153ed5ae4c83.svg +23 -0
  18. plexora/client/dist/vendor_bundle.js +2 -0
  19. plexora/client/dist/vendor_bundle.js.LICENSE.txt +308 -0
  20. plexora/client/external/openseadragon-bin-2.4.0/LICENSE.txt +28 -0
  21. plexora/client/external/openseadragon-bin-2.4.0/canvas-overlay-hd.js +140 -0
  22. plexora/client/external/openseadragon-bin-2.4.0/changelog.txt +501 -0
  23. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/.gitattributes +17 -0
  24. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/LICENSE.txt +116 -0
  25. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/Toolbar.png +0 -0
  26. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/fullpage.psd +0 -0
  27. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/home.psd +0 -0
  28. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/next.psd +0 -0
  29. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/previous.psd +0 -0
  30. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/rotateleft.psd +0 -0
  31. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/rotateright.psd +0 -0
  32. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/zoom.psd +0 -0
  33. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/README.md +9 -0
  34. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_grouphover.png +0 -0
  35. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_hover.png +0 -0
  36. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_pressed.png +0 -0
  37. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_rest.png +0 -0
  38. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_grouphover.png +0 -0
  39. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_hover.png +0 -0
  40. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_pressed.png +0 -0
  41. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_rest.png +0 -0
  42. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_grouphover.png +0 -0
  43. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_hover.png +0 -0
  44. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_pressed.png +0 -0
  45. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_rest.png +0 -0
  46. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_grouphover.png +0 -0
  47. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_hover.png +0 -0
  48. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_pressed.png +0 -0
  49. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_rest.png +0 -0
  50. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_grouphover.png +0 -0
  51. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_hover.png +0 -0
  52. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_pressed.png +0 -0
  53. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_rest.png +0 -0
  54. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_grouphover.png +0 -0
  55. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_hover.png +0 -0
  56. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_pressed.png +0 -0
  57. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_rest.png +0 -0
  58. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_grouphover.png +0 -0
  59. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_hover.png +0 -0
  60. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_pressed.png +0 -0
  61. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_rest.png +0 -0
  62. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_grouphover.png +0 -0
  63. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_hover.png +0 -0
  64. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_pressed.png +0 -0
  65. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_rest.png +0 -0
  66. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-scalebar.js +562 -0
  67. plexora/client/src/css/import.css +474 -0
  68. plexora/client/src/css/main.css +903 -0
  69. plexora/client/src/css/openProject.css +363 -0
  70. plexora/client/src/css/quickView.css +166 -0
  71. plexora/client/src/css/tokens.css +51 -0
  72. plexora/client/src/css/viewer.css +1655 -0
  73. plexora/client/src/img/apple-touch-icon.png +0 -0
  74. plexora/client/src/img/favicon.ico +0 -0
  75. plexora/client/src/img/logo.ai +8892 -33
  76. plexora/client/src/img/logo.svg +1 -0
  77. plexora/client/src/img/logo_with_text.ai +9011 -34
  78. plexora/client/src/img/logo_with_text.svg +1 -0
  79. plexora/client/src/js/main.js +704 -0
  80. plexora/client/src/js/pluginRegistry.js +108 -0
  81. plexora/client/src/js/services/appStatus.js +286 -0
  82. plexora/client/src/js/services/browsePicker.js +73 -0
  83. plexora/client/src/js/services/dataLayer.js +400 -0
  84. plexora/client/src/js/services/datasetContext.js +143 -0
  85. plexora/client/src/js/services/glRenderer.js +143 -0
  86. plexora/client/src/js/services/importFormValidation.js +257 -0
  87. plexora/client/src/js/services/numericData.js +100 -0
  88. plexora/client/src/js/services/passVariablesToFrontend.js +20 -0
  89. plexora/client/src/js/services/simpleEventHandler.js +25 -0
  90. plexora/client/src/js/vendor.js +29 -0
  91. plexora/client/src/js/views/channelList.js +663 -0
  92. plexora/client/src/js/views/colorSwatchPicker.js +202 -0
  93. plexora/client/src/js/views/columnClassifier.js +146 -0
  94. plexora/client/src/js/views/coordinateField.js +133 -0
  95. plexora/client/src/js/views/dataSourceField.js +276 -0
  96. plexora/client/src/js/views/imageViewer.js +3306 -0
  97. plexora/client/src/js/views/miniMap.js +787 -0
  98. plexora/client/src/js/views/navbarControls.js +178 -0
  99. plexora/client/src/js/views/openProjectPage.js +239 -0
  100. plexora/client/src/js/views/projectEdit.js +335 -0
  101. plexora/client/src/js/views/quickViewLanding.js +136 -0
  102. plexora/client/src/js/views/rainbow.js +326 -0
  103. plexora/client/src/js/views/requirementsModal.js +572 -0
  104. plexora/client/src/js/views/rgbImageViewer.js +115 -0
  105. plexora/client/src/js/views/searchableSelect.js +411 -0
  106. plexora/client/src/js/views/segmentationProgress.js +122 -0
  107. plexora/client/src/js/views/toolLoader.js +747 -0
  108. plexora/client/src/js/views/viewerControls.js +689 -0
  109. plexora/client/src/js/views/viewerManager.js +434 -0
  110. plexora/client/src/js/views/viewerSidebar.js +1117 -0
  111. plexora/client/src/js/workers/tileDecoder.js +130 -0
  112. plexora/client/src/shaders/frag.glsl +528 -0
  113. plexora/client/src/shaders/vert.glsl +12 -0
  114. plexora/client/templates/base.html +212 -0
  115. plexora/client/templates/index.html +238 -0
  116. plexora/client/templates/open_project.html +96 -0
  117. plexora/client/templates/project_columns.html +86 -0
  118. plexora/client/templates/project_edit.html +196 -0
  119. plexora/client/templates/upload.html +118 -0
  120. plexora/connect.py +528 -0
  121. plexora/datasource.py +782 -0
  122. plexora/jupyter.py +406 -0
  123. plexora/notebook_env.py +232 -0
  124. plexora/paths.py +486 -0
  125. plexora/plugins/__init__.py +12 -0
  126. plexora/plugins/cell_explorer/__init__.py +100 -0
  127. plexora/plugins/cell_explorer/server/__init__.py +11 -0
  128. plexora/plugins/cell_explorer/server/routes.py +167 -0
  129. plexora/plugins/cell_explorer/server/state.py +281 -0
  130. plexora/plugins/cell_explorer/server/values.py +145 -0
  131. plexora/plugins/cell_explorer/server/variables.py +367 -0
  132. plexora/plugins/cell_explorer/static/cellExplorerApi.js +158 -0
  133. plexora/plugins/cell_explorer/static/cellExplorerColors.js +316 -0
  134. plexora/plugins/cell_explorer/static/cellExplorerContinuous.js +403 -0
  135. plexora/plugins/cell_explorer/static/cellExplorerFigureBridge.js +133 -0
  136. plexora/plugins/cell_explorer/static/cellExplorerLegend.js +188 -0
  137. plexora/plugins/cell_explorer/static/cellExplorerRoiBridge.js +822 -0
  138. plexora/plugins/cell_explorer/static/cellExplorerSidebarController.js +826 -0
  139. plexora/plugins/cell_explorer/static/cellExplorerState.js +372 -0
  140. plexora/plugins/cell_explorer/static/cell_explorer.css +752 -0
  141. plexora/plugins/cell_explorer/templates/cell_explorer/panel.html +137 -0
  142. plexora/plugins/figure_builder/__init__.py +125 -0
  143. plexora/plugins/figure_builder/server/__init__.py +7 -0
  144. plexora/plugins/figure_builder/server/compose.py +365 -0
  145. plexora/plugins/figure_builder/server/export.py +784 -0
  146. plexora/plugins/figure_builder/server/export_jobs.py +148 -0
  147. plexora/plugins/figure_builder/server/operations.py +556 -0
  148. plexora/plugins/figure_builder/server/pixels.py +142 -0
  149. plexora/plugins/figure_builder/server/provenance.py +174 -0
  150. plexora/plugins/figure_builder/server/render.py +278 -0
  151. plexora/plugins/figure_builder/server/repository.py +701 -0
  152. plexora/plugins/figure_builder/server/routes.py +594 -0
  153. plexora/plugins/figure_builder/server/schema.py +1023 -0
  154. plexora/plugins/figure_builder/server/sources.py +120 -0
  155. plexora/plugins/figure_builder/server/textmetrics.py +155 -0
  156. plexora/plugins/figure_builder/static/figureActions.js +363 -0
  157. plexora/plugins/figure_builder/static/figureBuilderApi.js +255 -0
  158. plexora/plugins/figure_builder/static/figureCanvas.js +2362 -0
  159. plexora/plugins/figure_builder/static/figureCaptureBoxes.js +369 -0
  160. plexora/plugins/figure_builder/static/figureCaptureDock.js +466 -0
  161. plexora/plugins/figure_builder/static/figureCaptureTool.js +1034 -0
  162. plexora/plugins/figure_builder/static/figureConfirm.js +136 -0
  163. plexora/plugins/figure_builder/static/figureContextBar.js +1193 -0
  164. plexora/plugins/figure_builder/static/figureContextMenu.js +253 -0
  165. plexora/plugins/figure_builder/static/figureDocumentState.js +275 -0
  166. plexora/plugins/figure_builder/static/figureExportUi.js +174 -0
  167. plexora/plugins/figure_builder/static/figureLibrary.js +247 -0
  168. plexora/plugins/figure_builder/static/figureQuickEdit.js +628 -0
  169. plexora/plugins/figure_builder/static/figureRichText.js +634 -0
  170. plexora/plugins/figure_builder/static/figureSceneSnapshot.js +361 -0
  171. plexora/plugins/figure_builder/static/figureSchema.js +218 -0
  172. plexora/plugins/figure_builder/static/figureSidebarController.js +1159 -0
  173. plexora/plugins/figure_builder/static/figureTextEditor.js +503 -0
  174. plexora/plugins/figure_builder/static/figureTextPanel.js +491 -0
  175. plexora/plugins/figure_builder/static/figureViewOptions.js +273 -0
  176. plexora/plugins/figure_builder/static/figureWorkspace.js +2030 -0
  177. plexora/plugins/figure_builder/static/figure_builder.css +3292 -0
  178. plexora/plugins/figure_builder/templates/figure_builder/library.html +74 -0
  179. plexora/plugins/figure_builder/templates/figure_builder/workspace.html +27 -0
  180. plexora/plugins/figure_builder/templates/figure_builder/workspace_body.html +458 -0
  181. plexora/plugins/gating/__init__.py +76 -0
  182. plexora/plugins/gating/server/__init__.py +0 -0
  183. plexora/plugins/gating/server/anndata_gates.py +367 -0
  184. plexora/plugins/gating/server/database.py +16 -0
  185. plexora/plugins/gating/server/model.py +320 -0
  186. plexora/plugins/gating/server/routes.py +235 -0
  187. plexora/plugins/gating/static/csvGatingList.js +947 -0
  188. plexora/plugins/gating/static/gating.css +289 -0
  189. plexora/plugins/gating/static/gatingApi.js +221 -0
  190. plexora/plugins/gating/static/gatingSidebarController.js +513 -0
  191. plexora/plugins/gating/templates/gating/legacy.html +26 -0
  192. plexora/plugins/gating/templates/gating/panel.html +69 -0
  193. plexora/plugins/roi/__init__.py +95 -0
  194. plexora/plugins/roi/server/__init__.py +8 -0
  195. plexora/plugins/roi/server/adapters.py +655 -0
  196. plexora/plugins/roi/server/geojson.py +267 -0
  197. plexora/plugins/roi/server/geometry.py +181 -0
  198. plexora/plugins/roi/server/mapping.py +169 -0
  199. plexora/plugins/roi/server/operations.py +293 -0
  200. plexora/plugins/roi/server/repository.py +215 -0
  201. plexora/plugins/roi/server/routes.py +409 -0
  202. plexora/plugins/roi/server/schema.py +323 -0
  203. plexora/plugins/roi/static/roi.css +583 -0
  204. plexora/plugins/roi/static/roiApi.js +177 -0
  205. plexora/plugins/roi/static/roiFigureBridge.js +107 -0
  206. plexora/plugins/roi/static/roiGeometry.js +350 -0
  207. plexora/plugins/roi/static/roiRenderer.js +278 -0
  208. plexora/plugins/roi/static/roiSidebarController.js +1038 -0
  209. plexora/plugins/roi/static/roiState.js +518 -0
  210. plexora/plugins/roi/static/roiTools.js +993 -0
  211. plexora/plugins/roi/templates/roi/panel.html +240 -0
  212. plexora/proxy.py +57 -0
  213. plexora/server/models/adapters/__init__.py +94 -0
  214. plexora/server/models/adapters/anndata_adapter.py +388 -0
  215. plexora/server/models/adapters/base.py +93 -0
  216. plexora/server/models/adapters/classify.py +192 -0
  217. plexora/server/models/adapters/csv_adapter.py +105 -0
  218. plexora/server/models/adapters/inspection.py +265 -0
  219. plexora/server/models/adapters/spatialdata_adapter.py +248 -0
  220. plexora/server/models/centroid_tiles.py +305 -0
  221. plexora/server/models/data_model.py +1659 -0
  222. plexora/server/models/database_model.py +169 -0
  223. plexora/server/models/project.py +1270 -0
  224. plexora/server/plugins.py +199 -0
  225. plexora/server/routes/browse_routes.py +41 -0
  226. plexora/server/routes/data_routes.py +294 -0
  227. plexora/server/routes/import_routes.py +603 -0
  228. plexora/server/routes/page_routes.py +139 -0
  229. plexora/server/routes/project_routes.py +438 -0
  230. plexora/server/routes/quick_view_routes.py +62 -0
  231. plexora/server/routes/system_routes.py +39 -0
  232. plexora/server/routes/tool_routes.py +418 -0
  233. plexora/server/utils/addHEColumns.py +11 -0
  234. plexora/server/utils/fast_png.py +97 -0
  235. plexora/server/utils/fullConversion.py +50 -0
  236. plexora/server/utils/native_dialog.py +168 -0
  237. plexora/server/utils/pre_normalization.py +47 -0
  238. plexora/server/utils/segmentation_pyramid.py +598 -0
  239. plexora/server/utils/smallestenclosingcircle.py +126 -0
  240. plexora/server/utils/tiffsurgeon.py +372 -0
  241. plexora/server_cli.py +48 -0
  242. plexora/test.py +0 -0
  243. plexora-0.0.1.dist-info/METADATA +341 -0
  244. plexora-0.0.1.dist-info/RECORD +248 -0
  245. plexora-0.0.1.dist-info/WHEEL +5 -0
  246. plexora-0.0.1.dist-info/entry_points.txt +12 -0
  247. plexora-0.0.1.dist-info/licenses/LICENSE +207 -0
  248. plexora-0.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,747 @@
1
+ /**
2
+ * toolLoader.js - Always loaded, unlike a plugin's own scripts, which are fetched
3
+ * lazily and named by the plugin's descriptor (see plexora/api/plugin.py's
4
+ * Plugin.scripts and asset_urls).
5
+ * Intercepts the navbar Tools-menu links so opening/closing an addon tool mid-session
6
+ * never navigates: on first open it fetches the tool's sidebar HTML, stylesheets and
7
+ * scripts from `/<datasource>/tools/<tool>/panel`, injects all three, and hands off to
8
+ * main.js's `__plexora.activatePlugin()` to register the plugin exactly as it
9
+ * would be at boot. Later opens just toggle visibility -- nothing is re-fetched.
10
+ *
11
+ * Each tool gets its OWN mount inside the shared slot. That was not so while
12
+ * gating was the only plugin: the fragment was written straight to
13
+ * `slot.innerHTML`, which is a whole-slot replace. With a second plugin that is
14
+ * destructive in a way nothing reports -- opening B wipes A's panel out of the
15
+ * DOM while A's controller keeps the element handles it took at setup(), and the
16
+ * re-open path here (which only unhides the slot) then shows an empty panel and
17
+ * a live controller wired to nodes that are no longer on the page.
18
+ *
19
+ * THREE STATES, kept apart on purpose, because collapsing any two of them is
20
+ * what made a second plugin unusable:
21
+ *
22
+ * LOADED - the tool's record, panel DOM, controller and cached data exist. It
23
+ * has a card in the sidebar and draws nothing.
24
+ * VISIBLE - it contributes a layer to the image. Several tools may be visible
25
+ * at once, and the card order is the order they stack in.
26
+ * ACTIVE - it is the one the shared Cells control, the opacity slider, picking
27
+ * and the gate flows act on, and the one whose panel is expanded.
28
+ * Exactly one, or none.
29
+ *
30
+ * Opening a tool makes it all three and stands the previous one down to LOADED,
31
+ * so the default is still one thing on screen. Turning another card's eye back on
32
+ * is what stacks them -- one click, and it is the whole feature. That click also
33
+ * PINS the layer, and a pinned layer is exempt from the stand-down: the default
34
+ * is for the first switch, not a rule that keeps dismantling a stack somebody
35
+ * built on purpose.
36
+ *
37
+ * The one exception to single-active is a COEXISTING PAIR: two tools opened
38
+ * together through openToolAlongside(), which stay expanded and drawn side by
39
+ * side while the selection moves freely between them. Cell Explorer's Open ROIs
40
+ * button is the only caller, because its ROI composition card summarises the
41
+ * cells under an overlay that has to still be drawn for the answer to mean
42
+ * anything. Opening a third tool, or closing either half, dissolves the pair --
43
+ * the exception never outlives the pairing that justified it.
44
+ *
45
+ * Visibility of the PANELS is therefore a function of each entry's own
46
+ * `collapsed` flag, applied in `paint()`, rather than something each call site
47
+ * toggles for itself.
48
+ *
49
+ * Switching away also has to TELL the tool. A sidebar panel can be hidden and
50
+ * left running, but a tool that reaches outside its panel -- viewer-canvas
51
+ * pointer handlers, document-level keyboard shortcuts -- has to stand those down
52
+ * or it keeps eating input for a panel the user cannot see. That is what
53
+ * `onHide()` is for (see pluginRegistry.js); `onVisibilityChange()` is its
54
+ * counterpart for the eye toggle, and `ctx.onCleanup` remains the full-teardown
55
+ * path for when a plugin is removed outright.
56
+ *
57
+ * Deliberately kept off the `__plexora` object: that object is created fresh by
58
+ * main.js (`const __plexora = window.__plexora = {...}`), which -- because this
59
+ * script runs first in document order -- would clobber anything stored on it before
60
+ * main.js's own init() runs.
61
+ */
62
+ window.PlexoraToolLoader = (function () {
63
+ //: toolName -> { slotIds, sidebarController, visible, collapsed, pinned }
64
+ const loadedTools = new Map();
65
+
66
+ //: The tool the shared controls point at, or null when none is. Every
67
+ //: activation decision is derived from this rather than tracked per element.
68
+ let activeToolName = null;
69
+
70
+ //: The one sanctioned exception to single-active: a Set of exactly two tool
71
+ //: names that stay expanded and drawn together. Cell Explorer opens ROI this
72
+ //: way, because its ROI composition card only means anything while the
73
+ //: metadata overlay it summarises is still on screen underneath. Kept
74
+ //: deliberately narrow -- only openToolAlongside() forms a pair, and a third
75
+ //: tool or closing either half dissolves it rather than leaving a standing
76
+ //: exception behind.
77
+ let coexistPair = null;
78
+
79
+ /** The other half of the engaged pair, or null. */
80
+ function pairPartner(name) {
81
+ if (!coexistPair || !coexistPair.has(name)) return null;
82
+ let partner = null;
83
+ coexistPair.forEach((other) => { if (other !== name) partner = other; });
84
+ return partner;
85
+ }
86
+
87
+ /** Whether `name` is sharing the screen with whichever tool is selected. A
88
+ * pair is only ever engaged AROUND the active tool, so a name still in
89
+ * coexistPair while the active tool is not in it is stale, not coexisting. */
90
+ function isCoexisting(name) {
91
+ return Boolean(coexistPair && coexistPair.has(name)
92
+ && coexistPair.has(activeToolName));
93
+ }
94
+
95
+ const HIDDEN = "tool-panel-hidden";
96
+ const MOUNT_ATTR = "data-tool-panel";
97
+ const CARD_ATTR = "data-tool-card";
98
+
99
+ //: The one slot that gets cards. `tool_panel_legacy_slot` is off-screen
100
+ //: scaffolding (gating mounts its download panel there); wrapping that in a
101
+ //: card would put a draggable header on something nobody can see.
102
+ const CARD_SLOT = "tool_panel_slot";
103
+
104
+ //: The drag-to-restack binding, created once the first card exists.
105
+ let sortable = null;
106
+
107
+ function detach(node) {
108
+ if (!node) return;
109
+ if (node.remove) node.remove();
110
+ else node.parentNode?.removeChild?.(node);
111
+ }
112
+
113
+ /** The tool's human name, taken from the Tools-menu link that opens it, so
114
+ * a plugin does not have to send its label twice. */
115
+ function toolLabel(toolName) {
116
+ const link = document.querySelector?.(`a[data-tool="${toolName}"]`);
117
+ const text = link?.textContent?.trim?.();
118
+ return text || toolName;
119
+ }
120
+
121
+ /** `icons` is one class string per glyph. More than one is how a button that
122
+ * has two states is built here -- see the eye in buildCard. */
123
+ function iconButton(className, title, icons, onClick) {
124
+ const button = document.createElement("button");
125
+ button.type = "button";
126
+ button.className = className;
127
+ button.title = title;
128
+ button.innerHTML = [].concat(icons)
129
+ .map((icon) => `<span class="${icon}"></span>`).join("");
130
+ button.addEventListener("click", onClick);
131
+ return button;
132
+ }
133
+
134
+ /**
135
+ * One tool's card: a grip, a collapse chevron, the tool's name, an eye and a
136
+ * remove button, over the panel the plugin rendered.
137
+ *
138
+ * The panel is wrapped, never re-parented later: collapsing is a class on
139
+ * the card, so the controller's element handles -- taken once at setup() --
140
+ * stay valid for the life of the tool. That is what makes expanding instant
141
+ * instead of a re-render.
142
+ */
143
+ function buildCard(toolName, mount) {
144
+ const card = document.createElement("section");
145
+ card.className = "tool-card";
146
+ card.setAttribute(CARD_ATTR, toolName);
147
+
148
+ const header = document.createElement("div");
149
+ header.className = "tool-card-header";
150
+
151
+ const grip = document.createElement("span");
152
+ grip.className = "tool-card-grip fas fa-grip-vertical";
153
+ grip.title = "Drag to restack the layers";
154
+ header.appendChild(grip);
155
+
156
+ header.appendChild(iconButton(
157
+ "tool-card-collapse", "Collapse or expand this panel", "fas fa-chevron-down",
158
+ () => setToolCollapsed(toolName, !loadedTools.get(toolName)?.collapsed)));
159
+
160
+ const title = document.createElement("button");
161
+ title.type = "button";
162
+ title.className = "tool-card-title";
163
+ title.textContent = toolLabel(toolName);
164
+ // Selecting a card is what moves the shared controls onto it -- and,
165
+ // being the single-active path, folds the previous one away.
166
+ title.addEventListener("click", () => show(toolName));
167
+ header.appendChild(title);
168
+
169
+ // Both glyphs go in, and CSS shows whichever the card's is-layer-off
170
+ // class calls for. Rewriting one glyph's class from JS does NOT work:
171
+ // FontAwesome is loaded as JS (vendor.js), so it replaces every
172
+ // `<span class="fas fa-...">` with an `<svg>` before anyone can click
173
+ // anything -- the span the swap went looking for is no longer on the
174
+ // page, and nothing reports that. It is why a hidden layer used to sit
175
+ // under an open eye.
176
+ header.appendChild(iconButton(
177
+ "tool-card-eye", "Show or hide this tool's layer",
178
+ ["fas fa-eye tool-card-eye-on", "fas fa-eye-slash tool-card-eye-off"],
179
+ () => setToolVisible(toolName, !loadedTools.get(toolName)?.visible)));
180
+
181
+ header.appendChild(iconButton(
182
+ "tool-card-remove", "Remove this tool", "fas fa-xmark",
183
+ () => removeTool(toolName)));
184
+
185
+ card.appendChild(header);
186
+
187
+ const body = document.createElement("div");
188
+ body.className = "tool-card-body";
189
+ body.appendChild(mount);
190
+ card.appendChild(body);
191
+ return card;
192
+ }
193
+
194
+ function cardFor(toolName) {
195
+ const slot = document.getElementById(CARD_SLOT);
196
+ return slot?.querySelector?.(`[${CARD_ATTR}="${toolName}"]`) || null;
197
+ }
198
+
199
+ /** One tool's wrapper inside one slot, created on demand. */
200
+ function mountFor(slotId, toolName, create) {
201
+ const slot = document.getElementById(slotId);
202
+ if (!slot) return null;
203
+ let mount = slot.querySelector?.(`[${MOUNT_ATTR}="${toolName}"]`) || null;
204
+ if (!mount && create) {
205
+ mount = document.createElement("div");
206
+ mount.className = "tool-panel-mount";
207
+ mount.setAttribute(MOUNT_ATTR, toolName);
208
+ if (slotId === CARD_SLOT) {
209
+ // At the TOP of the slot: the cards read downwards and the top
210
+ // one is the topmost layer, so a tool the user just opened is
211
+ // over everything already loaded.
212
+ const card = buildCard(toolName, mount);
213
+ if (slot.firstChild) slot.insertBefore(card, slot.firstChild);
214
+ else slot.appendChild(card);
215
+ } else {
216
+ slot.appendChild(mount);
217
+ }
218
+ }
219
+ return mount;
220
+ }
221
+
222
+ /**
223
+ * Show every expanded tool's panel and hide the collapsed ones.
224
+ *
225
+ * A slot is hidden when nothing in it is showing, which is what the class on
226
+ * the slot itself has always meant -- the server renders it that way for a
227
+ * page opened with no tool. The card slot is the exception: a collapsed card
228
+ * still shows its header, so that slot stays open for as long as it holds a
229
+ * card at all.
230
+ */
231
+ function paint() {
232
+ const slotIds = new Set();
233
+ loadedTools.forEach((entry) => entry.slotIds.forEach((id) => slotIds.add(id)));
234
+
235
+ slotIds.forEach((slotId) => {
236
+ const slot = document.getElementById(slotId);
237
+ if (!slot) return;
238
+ const isCardSlot = slotId === CARD_SLOT;
239
+ let showing = false;
240
+ loadedTools.forEach((entry, name) => {
241
+ if (!entry.slotIds.includes(slotId)) return;
242
+ // In the card slot a panel shows unless its own card is folded.
243
+ // In the off-screen legacy slot there are no cards, so only the
244
+ // active tool's mount shows -- which is what it always did.
245
+ const visible = isCardSlot
246
+ ? !entry.collapsed
247
+ : (name === activeToolName || isCoexisting(name));
248
+ showing = showing || visible || isCardSlot;
249
+ const mount = mountFor(slotId, name);
250
+ if (mount) mount.classList.toggle(HIDDEN, !visible);
251
+ });
252
+ slot.classList.toggle(HIDDEN, !showing);
253
+ });
254
+ paintCards();
255
+ }
256
+
257
+ /**
258
+ * The card headers: which is folded, which is selected, which layer is off.
259
+ *
260
+ * Three classes and nothing else -- the chevron's direction and which of the
261
+ * eye's two glyphs shows both hang off these in CSS, so there is no glyph
262
+ * here to keep in step and no way for the icons to drift out of it.
263
+ */
264
+ function paintCards() {
265
+ loadedTools.forEach((entry, name) => {
266
+ const card = cardFor(name);
267
+ if (!card) return;
268
+ card.classList.toggle("is-collapsed", Boolean(entry.collapsed));
269
+ // Both halves of a coexisting pair read as selected: only one of
270
+ // them owns the shared controls, but the user opened them together
271
+ // and one greyed-out card would look like something had failed.
272
+ card.classList.toggle("is-active",
273
+ name === activeToolName || isCoexisting(name));
274
+ card.classList.toggle("is-layer-off", !entry.visible);
275
+ });
276
+ }
277
+
278
+ /**
279
+ * Sidebar order is layer order, and the top card is the top layer.
280
+ *
281
+ * Core stacks bottom-first, so the DOM order is reversed on the way out
282
+ * rather than the cards being built upside down -- a list whose first row is
283
+ * the bottom of the picture reads backwards to everyone.
284
+ */
285
+ function syncLayerOrder() {
286
+ const slot = document.getElementById(CARD_SLOT);
287
+ if (!slot?.children) return;
288
+ const names = [];
289
+ Array.from(slot.children).forEach((child) => {
290
+ const name = child.getAttribute?.(CARD_ATTR);
291
+ if (name && loadedTools.has(name)) names.push(name);
292
+ });
293
+ names.reverse();
294
+ try {
295
+ window.__plexora?.setToolLayerOrder?.(names);
296
+ } catch (error) {
297
+ console.error("toolLoader: setToolLayerOrder() failed", error);
298
+ }
299
+ }
300
+
301
+ /** Drag-to-restack, on the same vendored library the column classifier uses
302
+ * (columnClassifier.js). Handle-only, so a click anywhere else in the
303
+ * header still reaches the button it landed on. */
304
+ function ensureSortable() {
305
+ const slot = document.getElementById(CARD_SLOT);
306
+ if (!slot || sortable || typeof window.Sortable !== "function") return;
307
+ sortable = new window.Sortable(slot, {
308
+ handle: ".tool-card-grip",
309
+ draggable: ".tool-card",
310
+ animation: 150,
311
+ onSort: syncLayerOrder,
312
+ });
313
+ }
314
+
315
+ /**
316
+ * Push one tool's on/off state everywhere it has to land.
317
+ *
318
+ * Core switches the cell layer for a plugin that has one; a tool that draws
319
+ * its own overlay (ROI) has nothing for core to switch, so its controller is
320
+ * told directly. Without the second half the eye on ROI's card would be a
321
+ * button that does nothing.
322
+ */
323
+ function applyToolVisible(toolName, on) {
324
+ const entry = loadedTools.get(toolName);
325
+ if (!entry) return;
326
+ entry.visible = Boolean(on);
327
+ try {
328
+ window.__plexora?.setToolLayerVisible?.(toolName, entry.visible);
329
+ } catch (error) {
330
+ console.error("toolLoader: setToolLayerVisible() failed", error);
331
+ }
332
+ try {
333
+ entry.sidebarController?.onVisibilityChange?.(entry.visible);
334
+ } catch (error) {
335
+ console.error("toolLoader: onVisibilityChange() failed", error);
336
+ }
337
+ }
338
+
339
+ /**
340
+ * The card's eye. Unlike every other path that changes visibility, this one
341
+ * is a DECISION, and it is recorded as one.
342
+ *
343
+ * `pinned` is what stops the single-active default from undoing a stack the
344
+ * user built on purpose. Opening a tool turns the previous one's layer off,
345
+ * which is right the first time -- but once somebody has explicitly put a
346
+ * second layer back on to compare the two, clicking between their cards must
347
+ * stop taking it away again. Same shape as a layer's `userMode`: an
348
+ * automatic choice fills a gap, an explicit one is kept.
349
+ */
350
+ function setToolVisible(toolName, on) {
351
+ const entry = loadedTools.get(toolName);
352
+ if (!entry) return;
353
+ entry.pinned = Boolean(on);
354
+ if (entry.visible === Boolean(on)) return;
355
+ applyToolVisible(toolName, on);
356
+ paintCards();
357
+ }
358
+
359
+ function setToolCollapsed(toolName, collapsed) {
360
+ const entry = loadedTools.get(toolName);
361
+ if (!entry) return;
362
+ entry.collapsed = Boolean(collapsed);
363
+ paint();
364
+ }
365
+
366
+ /** Put one tool back to LOADED: folded away, no longer drawing unless the
367
+ * user pinned it, and told so anything it hung outside its own panel --
368
+ * canvas handlers, document shortcuts -- stands down with it. */
369
+ function fold(toolName) {
370
+ const entry = loadedTools.get(toolName);
371
+ if (!entry) return;
372
+ entry.collapsed = true;
373
+ if (!entry.pinned) applyToolVisible(toolName, false);
374
+ try {
375
+ entry.sidebarController?.onHide?.();
376
+ } catch (error) {
377
+ console.error("toolLoader: onHide() failed", error);
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Stand the selected tool down, unless it is the one being opened.
383
+ *
384
+ * Called before another tool is shown rather than when its own close button
385
+ * is pressed, so a tool never has to know it was switched away from.
386
+ *
387
+ * Single-active by default: the outgoing tool folds up AND its layer goes
388
+ * off, so opening a second tool shows one picture rather than two stacked
389
+ * ones nobody asked to compare. It stays LOADED -- its data, its colours and
390
+ * its panel are all still there -- so turning it back on is one click.
391
+ *
392
+ * Unless the user has pinned it with the eye, which is the one thing that
393
+ * outranks the default -- see setToolVisible -- or the outgoing tool is
394
+ * half of a coexisting pair, which is the other.
395
+ */
396
+ function standDown(except) {
397
+ if (!activeToolName || activeToolName === except) return;
398
+ const previous = activeToolName;
399
+ // Moving the selection between the two halves of a coexisting pair is
400
+ // not a switch away from anything: both cards stay open and both layers
401
+ // stay drawn, and only which one the shared controls point at changes.
402
+ if (except && isCoexisting(previous) && coexistPair.has(except)) return;
403
+ // A third tool does end the arrangement, and it folds BOTH halves --
404
+ // otherwise the exception outlives the pairing that justified it and the
405
+ // user is left with a stray layer nobody asked to keep.
406
+ const partner = pairPartner(previous);
407
+ coexistPair = null;
408
+ activeToolName = null;
409
+ fold(previous);
410
+ if (partner) fold(partner);
411
+ }
412
+
413
+ function show(toolName) {
414
+ standDown(toolName);
415
+ const entry = loadedTools.get(toolName);
416
+ if (entry) {
417
+ entry.collapsed = false;
418
+ applyToolVisible(toolName, true);
419
+ }
420
+ activeToolName = toolName;
421
+ paint();
422
+ syncLayerOrder();
423
+ // Before onShow(), not after: a controller that re-applies its cell
424
+ // colours there is talking to controls that have to be pointing at this
425
+ // tool already, and core's own Cells control has to have adopted this
426
+ // layer's mode before the panel reads it back. See main.js's
427
+ // setActiveTool.
428
+ try {
429
+ window.__plexora?.setActiveTool?.(toolName);
430
+ } catch (error) {
431
+ console.error("toolLoader: setActiveTool() failed", error);
432
+ }
433
+ try {
434
+ loadedTools.get(toolName)?.sidebarController?.onShow?.();
435
+ } catch (error) {
436
+ console.error("toolLoader: onShow() failed", error);
437
+ }
438
+ }
439
+
440
+ function loadScript(src) {
441
+ if (document.querySelector(`script[src="${src}"]`)) return Promise.resolve();
442
+ return new Promise((resolve, reject) => {
443
+ const script = document.createElement("script");
444
+ script.src = src;
445
+ script.onload = () => resolve();
446
+ script.onerror = () => reject(new Error(`toolLoader: failed to load ${src}`));
447
+ document.head.appendChild(script);
448
+ });
449
+ }
450
+
451
+ function loadStyle(href) {
452
+ if (document.querySelector(`link[rel="stylesheet"][href="${href}"]`)) return Promise.resolve();
453
+ return new Promise((resolve) => {
454
+ const link = document.createElement("link");
455
+ link.rel = "stylesheet";
456
+ link.href = href;
457
+ // Resolves either way, unlike loadScript: a plugin whose stylesheet
458
+ // is missing still works, so refusing to open it would turn a
459
+ // cosmetic fault into a broken tool. The console line is what says
460
+ // an unstyled panel was not intended.
461
+ link.onload = () => resolve();
462
+ link.onerror = () => {
463
+ console.error(`toolLoader: failed to load ${href}`);
464
+ resolve();
465
+ };
466
+ document.head.appendChild(link);
467
+ });
468
+ }
469
+
470
+ async function openTool(toolName, linkEl) {
471
+ if (loadedTools.has(toolName)) {
472
+ show(toolName);
473
+ return;
474
+ }
475
+
476
+ linkEl?.classList.add("tool-loading");
477
+ try {
478
+ const datasource = window.flaskVariables?.datasource;
479
+ const baseUrl = window.PLEXORA_BASE_URL || "";
480
+ const response = await fetch(`${baseUrl}/${datasource}/tools/${toolName}/panel`);
481
+ const payload = await response.json();
482
+
483
+ if (payload.needs) {
484
+ // The tool is installed and compatible but the project is
485
+ // missing something it declared. Ask for exactly that, then
486
+ // re-enter -- navigating away to collect a column name would
487
+ // tear down and rebuild the whole viewer to answer one
488
+ // question, which is the reason this lazy path exists.
489
+ const satisfied = await window.PlexoraRequirements.collect(
490
+ datasource, payload.needs);
491
+ if (!satisfied) return;
492
+ return openTool(toolName, linkEl);
493
+ }
494
+
495
+ if (payload.redirect) {
496
+ // Unknown datasource or an uninstalled tool -- the server has
497
+ // decided where to send us.
498
+ window.location.href = payload.redirect;
499
+ return;
500
+ }
501
+
502
+ // Stylesheets before the markup, and awaited: the fragments below
503
+ // are shown as soon as their slots are unhidden, and a plugin's own
504
+ // CSS is the only thing that styles them. Skipping this is what
505
+ // made gating's panels render raw -- the file input as a bare
506
+ // "Choose File", the download panel with no surface -- whenever the
507
+ // tool was opened from the Tools menu rather than loaded with
508
+ // ?tool=..., which is the path base.html covers.
509
+ await Promise.all((payload.styles || []).map(loadStyle));
510
+
511
+ const slotIds = Object.keys(payload.fragments || {});
512
+ slotIds.forEach((slotId) => {
513
+ // Into this tool's own mount, never the slot: the slot is shared
514
+ // and writing the whole of it destroys any other tool's panel.
515
+ const mount = mountFor(slotId, toolName, true);
516
+ if (mount) mount.innerHTML = payload.fragments[slotId];
517
+ });
518
+
519
+ for (const src of payload.scripts || []) {
520
+ await loadScript(src);
521
+ }
522
+
523
+ // main.js runs before any tool can be opened (deferred, but earlier in
524
+ // document order isn't guaranteed here -- this script loads first --
525
+ // so wait on its own readiness promise rather than assuming it's done).
526
+ await window.__plexoraReady;
527
+ const moduleDef = window.Plexora?.plugins?.get(toolName);
528
+ if (!moduleDef || !window.__plexora?.activatePlugin) return;
529
+ const { sidebarController } = await window.__plexora.activatePlugin(moduleDef);
530
+
531
+ loadedTools.set(toolName, {
532
+ slotIds,
533
+ sidebarController,
534
+ visible: true,
535
+ collapsed: false,
536
+ pinned: false,
537
+ });
538
+ ensureSortable();
539
+ show(toolName);
540
+ } finally {
541
+ linkEl?.classList.remove("tool-loading");
542
+ }
543
+ }
544
+
545
+ /**
546
+ * Open `toolName` alongside `anchorToolName` rather than in place of it.
547
+ *
548
+ * Single-active is the right default for tools that are alternative views of
549
+ * one picture, but Cell Explorer and ROI are not alternatives: the ROI
550
+ * composition card only means anything while the metadata overlay it
551
+ * summarises is still drawn underneath. Rather than weaken standDown() for
552
+ * everyone, the exception is a named pair that only this entry point can
553
+ * form -- opening ROI from the Tools menu still behaves exactly as before.
554
+ *
555
+ * The pair is set BEFORE openTool(), because openTool() reaches show() ->
556
+ * standDown() synchronously on the already-loaded path, and standDown() is
557
+ * the call the pair exists to short-circuit.
558
+ */
559
+ async function openToolAlongside(toolName, anchorToolName) {
560
+ if (!toolName) return;
561
+ if (!anchorToolName || anchorToolName === toolName
562
+ || !loadedTools.has(anchorToolName)) {
563
+ // Nothing to ride along with -- an ordinary open.
564
+ return openTool(toolName);
565
+ }
566
+ if (activeToolName !== anchorToolName) show(anchorToolName);
567
+ const anchor = loadedTools.get(anchorToolName);
568
+ if (anchor) anchor.collapsed = false;
569
+ coexistPair = new Set([anchorToolName, toolName]);
570
+ try {
571
+ await openTool(toolName);
572
+ } finally {
573
+ // openTool() returns without loading anything on the redirect and
574
+ // requirements-declined paths; there is no pair if the second half
575
+ // never arrived.
576
+ if (!loadedTools.has(toolName)) coexistPair = null;
577
+ paint();
578
+ }
579
+ }
580
+
581
+ /**
582
+ * The panel's own close button: fold the card away and stop drawing, but
583
+ * keep the tool loaded.
584
+ *
585
+ * Removing it outright is the card's X instead. A close that unloaded
586
+ * everything would throw away a cached column and a rebuilt lookup table to
587
+ * reclaim a strip of sidebar, and there would be no sign the tool had ever
588
+ * been open.
589
+ */
590
+ function hideTool(toolName) {
591
+ const entry = loadedTools.get(toolName);
592
+ if (!entry) return;
593
+ // Explicit, so it clears the pin the same way the eye does -- a close
594
+ // that left the layer drawing would be a button whose name is a lie.
595
+ entry.pinned = false;
596
+ const partner = pairPartner(toolName);
597
+ if (partner) coexistPair = null;
598
+ if (activeToolName === toolName) {
599
+ standDown();
600
+ } else {
601
+ entry.collapsed = true;
602
+ applyToolVisible(toolName, false);
603
+ }
604
+ // The surviving half of a pair becomes the sole selected tool. Without
605
+ // this, closing ROI from its own panel would leave Cell Explorer
606
+ // expanded but unselected, with the shared controls pointing at nothing.
607
+ if (partner && loadedTools.has(partner) && activeToolName !== partner) {
608
+ show(partner);
609
+ return;
610
+ }
611
+ paint();
612
+ }
613
+
614
+ /**
615
+ * Unload a tool: tear the plugin down, take its card and every one of its
616
+ * mounts off the page, and forget it.
617
+ *
618
+ * Re-opening from the Tools menu afterwards goes the full way round again --
619
+ * the script tags persist so loadScript no-ops, re-registering the same name
620
+ * in the plugin registry is harmless, and a fresh instance and controller are
621
+ * built. The mounts have to go from EVERY slot for that to work, including
622
+ * the off-screen legacy one: a stale wrapper there is found by mountFor,
623
+ * returned as if it were new, and the freshly fetched fragment is written
624
+ * into markup the new controller never saw.
625
+ */
626
+ function removeTool(toolName) {
627
+ const entry = loadedTools.get(toolName);
628
+ if (!entry) return;
629
+ // Read before the pair is dissolved: closing one half of a coexisting
630
+ // pair has to leave the other one selected, not drop the selection to
631
+ // nothing the way standDown() on its own would.
632
+ const partner = pairPartner(toolName);
633
+ // Only when this tool is IN the pair -- removing an unrelated third tool
634
+ // has nothing to say about two others sharing the screen.
635
+ if (partner) coexistPair = null;
636
+ if (activeToolName === toolName) standDown();
637
+ try {
638
+ window.__plexora?.deactivatePlugin?.(toolName);
639
+ } catch (error) {
640
+ console.error("toolLoader: deactivatePlugin() failed", error);
641
+ }
642
+ entry.slotIds.forEach((slotId) => {
643
+ const mount = mountFor(slotId, toolName);
644
+ const card = slotId === CARD_SLOT ? cardFor(toolName) : null;
645
+ detach(card || mount);
646
+ });
647
+ loadedTools.delete(toolName);
648
+ if (partner && loadedTools.has(partner) && activeToolName !== partner) {
649
+ show(partner); //: paints and re-syncs layer order for us
650
+ return;
651
+ }
652
+ paint();
653
+ syncLayerOrder();
654
+ }
655
+
656
+ /**
657
+ * Wrap a server-rendered panel in the same per-tool mount and card the lazy
658
+ * path creates, so both paths leave the slot in the same shape.
659
+ *
660
+ * Without this, a page opened with ?tool=gating has gating's markup sitting
661
+ * loose in the slot; opening a second tool would append its card alongside
662
+ * and `paint()` would have nothing to hide the first one by.
663
+ */
664
+ function adopt(slotId, toolName) {
665
+ const slot = document.getElementById(slotId);
666
+ if (!slot || !slot.appendChild) return;
667
+ if (slot.querySelector?.(`[${MOUNT_ATTR}="${toolName}"]`)) return;
668
+ const mount = document.createElement("div");
669
+ mount.className = "tool-panel-mount";
670
+ mount.setAttribute(MOUNT_ATTR, toolName);
671
+ while (slot.firstChild) mount.appendChild(slot.firstChild);
672
+ slot.appendChild(slotId === CARD_SLOT ? buildCard(toolName, mount) : mount);
673
+ }
674
+
675
+ // Called by main.js when a tool was already active at boot (a direct/bookmarked
676
+ // ?tool=gating load rendered its panel server-side, not through openTool() above) --
677
+ // without this, the close button's hideToolPanel() would find nothing to hide, and
678
+ // a later Tools-menu click would re-fetch/re-activate a module that's already live.
679
+ function registerLoaded(toolName, slotIds, sidebarController) {
680
+ if (loadedTools.has(toolName)) return;
681
+ // Only the slots the server actually filled. main.js works the list out
682
+ // from `data-tool-mount`, which index.html stamps on EVERY slot with the
683
+ // active tool's name -- so a plugin that declared one panel is still
684
+ // named on all of them. Adopting an empty slot builds a card with
685
+ // nothing in it: a header, a grip and an eye over a panel that does not
686
+ // exist. A plugin whose controls live somewhere other than the sidebar
687
+ // (figure_builder puts them on the image) is entitled to no card at all,
688
+ // and this is what makes the boot path agree with the lazy one, which
689
+ // only ever sees the slots the payload named.
690
+ //
691
+ // `children`, not `firstChild` or textContent: a server-rendered slot
692
+ // with no panel in it still holds the template's whitespace, which is a
693
+ // text node and would read as content.
694
+ const filled = slotIds.filter(
695
+ (slotId) => Boolean(document.getElementById(slotId)?.children?.length));
696
+ filled.forEach((slotId) => adopt(slotId, toolName));
697
+ loadedTools.set(toolName, {
698
+ slotIds: filled,
699
+ sidebarController,
700
+ visible: true,
701
+ collapsed: false,
702
+ pinned: false,
703
+ });
704
+ ensureSortable();
705
+ // Through show(), so this path is not a second, quieter version of the
706
+ // menu one. A server-rendered panel is every bit as visible as a lazily
707
+ // opened one and needs the same onShow(): ROI attaches its viewer-canvas
708
+ // and document handlers there, so setting activeToolName directly gave
709
+ // a ?tool=roi page a panel that looked right and a pen that drew
710
+ // nothing -- no pointer handlers, no shortcuts, and no error to say so.
711
+ show(toolName);
712
+ }
713
+
714
+ document.addEventListener("DOMContentLoaded", () => {
715
+ document.querySelectorAll("a[data-tool]").forEach((link) => {
716
+ link.addEventListener("click", (event) => {
717
+ event.preventDefault();
718
+ openTool(link.dataset.tool, link);
719
+ });
720
+ });
721
+ });
722
+
723
+ return {
724
+ hideToolPanel: hideTool,
725
+ registerLoaded,
726
+ /** Which tool's panels are showing, for a tool deciding whether its own
727
+ * global shortcuts should be listening. */
728
+ activeTool: () => activeToolName,
729
+ /** Whether a tool's layer is currently drawn, for a controller that has
730
+ * to answer the same question its card's eye does. */
731
+ isToolVisible: (name) => Boolean(loadedTools.get(name)?.visible),
732
+ setToolVisible,
733
+ setToolCollapsed,
734
+ removeTool,
735
+ /** Open `toolName` WITHOUT standing `anchorToolName` down -- the one
736
+ * sanctioned exception to single-active. Both cards stay expanded and
737
+ * both layers stay drawn until a third tool opens or either half is
738
+ * closed. Cell Explorer's Open ROIs button is the caller. */
739
+ openToolAlongside,
740
+ /** Whether this tool is sharing the screen with another right now. */
741
+ isCoexisting,
742
+ /** The tool it is sharing with, or null. */
743
+ coexistPartner: (name) => (isCoexisting(name) ? pairPartner(name) : null),
744
+ /** Every loaded tool, top card first. */
745
+ loadedTools: () => Array.from(loadedTools.keys()),
746
+ };
747
+ })();