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,1038 @@
1
+ /**
2
+ * roiSidebarController.js - the panel, and the plugin's registration.
3
+ *
4
+ * Everything user-facing that is not on the image itself: the tool buttons, the
5
+ * category list, the selected-region fields, the save indicator, import/export,
6
+ * and the two banners that appear when saving cannot proceed.
7
+ *
8
+ * Every piece of user text on this panel is written with `textContent`, never
9
+ * `innerHTML`. Category and region names are user input, they survive a round
10
+ * trip through the store, and they come back through an import from a file that
11
+ * may have been written by anything at all -- so the one place they reach the
12
+ * DOM is the one place that has to be careful.
13
+ *
14
+ * Registration is at the bottom of this file because it is loaded last (see
15
+ * PLUGIN.scripts): by then RoiApi, RoiGeometry, RoiStore, RoiRenderer and
16
+ * RoiInteraction all exist.
17
+ */
18
+ class RoiSidebarController {
19
+
20
+ constructor(ctx) {
21
+ this.ctx = ctx;
22
+ this.api = new RoiApi(ctx);
23
+ this.store = new RoiStore(ctx, this.api);
24
+ this.renderer = new RoiRenderer(ctx, this.store);
25
+ this.tools = new RoiInteraction(ctx, this.store, this.renderer);
26
+ this.tools.onNotify = (message) => this.notify(message);
27
+ this._messageTimer = null;
28
+ this._nameTimer = null;
29
+ this._unsubscribe = null;
30
+ this._destinationOpen = false;
31
+ }
32
+
33
+ // -- lifecycle -------------------------------------------------------
34
+
35
+ setup() {
36
+ this.bindToolbar();
37
+ this.bindCategoryForm();
38
+ this.bindSelection();
39
+ this.bindTransfer();
40
+ this.bindBanners();
41
+
42
+ this.el("roi_panel_close")?.addEventListener("click", () => {
43
+ window.PlexoraToolLoader?.hideToolPanel("roi");
44
+ });
45
+
46
+ this._unsubscribe = this.store.onChange(() => this.render());
47
+
48
+ // A tab closed with regions still queued loses them, and the user has no
49
+ // way to know that from looking at the panel. Registered through the
50
+ // plugin's cleanup list so it goes when the plugin does.
51
+ const beforeUnload = (event) => {
52
+ if (!this.store.hasUnsavedWork) return;
53
+ event.preventDefault();
54
+ event.returnValue = "";
55
+ };
56
+ window.addEventListener("beforeunload", beforeUnload);
57
+ this.ctx.onCleanup?.(() => window.removeEventListener("beforeunload", beforeUnload));
58
+ this.ctx.onCleanup?.(() => this.destroy());
59
+ }
60
+
61
+ /** ViewerSidebar's restore hook. Loading happens here so it is awaited in
62
+ * the same place core awaits every other module's saved state. */
63
+ async fetchSaved() {
64
+ await this.store.load();
65
+ return null;
66
+ }
67
+
68
+ applyOrDefault() {
69
+ this.renderer.attach();
70
+ this.render();
71
+ }
72
+
73
+ /** Called by toolLoader when this panel becomes the selected one. */
74
+ onShow() {
75
+ this.renderer.attach();
76
+ this.tools.arm();
77
+ this.render();
78
+ }
79
+
80
+ /**
81
+ * The eye on this tool's card: draw the regions, or stop drawing them.
82
+ *
83
+ * Every other plugin's layer is a cell layer core switches off by itself.
84
+ * ROI's is its own overlay canvas, so core has nothing to switch and the
85
+ * card's toggle would be inert without this.
86
+ *
87
+ * Turning the overlay off disarms the tools as well. A pen that goes on
88
+ * drawing invisible regions -- and goes on swallowing V/P/F/R -- while its
89
+ * layer says "off" is the same failure onHide() exists to prevent, arrived at
90
+ * from the other direction. Turning it back on re-arms only when this is
91
+ * still the selected tool: a background layer being made visible is a request
92
+ * to SEE it, not to type into it.
93
+ */
94
+ onVisibilityChange(visible) {
95
+ this.renderer.setEnabled(Boolean(visible));
96
+ if (!visible) {
97
+ this.tools.disarm();
98
+ this.el("roi_map_info_dialog")?.close();
99
+ return;
100
+ }
101
+ this.renderer.attach();
102
+ if (window.PlexoraToolLoader?.activeTool() === "roi") this.tools.arm();
103
+ this.renderer.schedule();
104
+ }
105
+
106
+ /**
107
+ * Called when another tool is opened over this one, or this one is closed.
108
+ *
109
+ * This is the hook ROI genuinely needs: its handlers are on the viewer
110
+ * canvas and the document, neither of which is hidden along with the panel.
111
+ * Left armed, ROI would keep drawing over another tool's session and keep
112
+ * swallowing V/P/F/R.
113
+ */
114
+ onHide() {
115
+ this.tools.disarm();
116
+ this.renderer.setEnabled(false);
117
+ // The info dialog lives inside the panel, so hiding the panel takes it
118
+ // off the screen without closing it -- and it would be waiting, open,
119
+ // over whatever the user came back to.
120
+ this.el("roi_map_info_dialog")?.close();
121
+ }
122
+
123
+ destroy() {
124
+ this._unsubscribe?.();
125
+ if (this._messageTimer) clearTimeout(this._messageTimer);
126
+ if (this._nameTimer) clearTimeout(this._nameTimer);
127
+ this.tools.destroy();
128
+ this.renderer.destroy();
129
+ this.store.destroy();
130
+ }
131
+
132
+ el(id) {
133
+ return document.getElementById(id);
134
+ }
135
+
136
+ // -- wiring ----------------------------------------------------------
137
+
138
+ bindToolbar() {
139
+ for (const button of document.querySelectorAll("#roi_toolbar .roi-tool")) {
140
+ button.addEventListener("click", () => this.tools.setTool(button.dataset.tool));
141
+ }
142
+ }
143
+
144
+ bindCategoryForm() {
145
+ const input = this.el("roi_category_name");
146
+ const add = () => {
147
+ this.createCategory(input.value);
148
+ input.value = "";
149
+ };
150
+ // Enter and the [+] button, bound separately rather than through a
151
+ // form's submit -- see the note in panel.html on why there is no form.
152
+ this.el("roi_category_add_button")?.addEventListener("click", add);
153
+ input?.addEventListener("keydown", (event) => {
154
+ if (event.key !== "Enter") return;
155
+ event.preventDefault();
156
+ add();
157
+ });
158
+ }
159
+
160
+ bindSelection() {
161
+ const name = this.el("roi_selection_name");
162
+ name?.addEventListener("input", () => {
163
+ // Debounced, so typing a name is one operation rather than one per
164
+ // keystroke -- and committed on blur too, since a user who types a
165
+ // name and immediately clicks the image expects it kept.
166
+ if (this._nameTimer) clearTimeout(this._nameTimer);
167
+ this._nameTimer = setTimeout(() => this.commitName(name.value), 500);
168
+ });
169
+ name?.addEventListener("blur", () => this.commitName(name.value));
170
+ name?.addEventListener("keydown", (event) => {
171
+ if (event.key === "Enter") {
172
+ event.preventDefault();
173
+ name.blur();
174
+ }
175
+ });
176
+
177
+ this.el("roi_selection_category")?.addEventListener("change", (event) => {
178
+ this.recategorize(event.target.value);
179
+ });
180
+ this.el("roi_selection_locked")?.addEventListener("click", () => {
181
+ this.setLocked(!this.store.selected?.locked);
182
+ });
183
+ this.el("roi_selection_delete")?.addEventListener("click", () => {
184
+ this.tools.deleteSelected();
185
+ });
186
+ }
187
+
188
+ bindTransfer() {
189
+ this.el("roi_export_button")?.addEventListener("click", () => this.exportGeoJSON());
190
+ this.el("roi_export_download")?.addEventListener("click", () => this.exportGeoJSON());
191
+
192
+ const picker = this.el("roi_import_file");
193
+ this.el("roi_import_button")?.addEventListener("click", () => picker?.click());
194
+ picker?.addEventListener("change", async () => {
195
+ const file = picker.files && picker.files[0];
196
+ picker.value = ""; // so re-picking the same file fires again
197
+ if (file) await this.importGeoJSON(file);
198
+ });
199
+
200
+ this.el("roi_save_to_source")?.addEventListener("click", () => this.saveToSource());
201
+ this.el("roi_map_to_cells")?.addEventListener("click", () => this.mapToCells());
202
+ this.el("roi_destination_close")?.addEventListener("click", () => this.closeDestination());
203
+
204
+ this.el("roi_map_info")?.addEventListener("click", () => this.openMapInfo());
205
+ const info = this.el("roi_map_info_dialog");
206
+ this.el("roi_map_info_close")?.addEventListener("click", () => info?.close());
207
+ // Clicking outside the dialog closes it. The <dialog> element IS the
208
+ // backdrop's hit area -- the visible panel is its padding box -- so a
209
+ // click landing on the dialog itself and not on anything inside it is a
210
+ // click on the backdrop.
211
+ info?.addEventListener("click", (event) => {
212
+ if (event.target === info) info.close();
213
+ });
214
+
215
+ const destination = this.el("roi_destination_name");
216
+ // Live, so the line underneath always names the entry the button is
217
+ // about to write -- including while it is being renamed.
218
+ destination?.addEventListener("input", () => this.renderDestination());
219
+ // The field is opened by the save button and the user is already typing
220
+ // in it, so Enter finishes the job they started rather than making them
221
+ // travel back to the button. Escape backs out, as it does everywhere
222
+ // else on this panel. Neither reaches the drawing tools: they ignore
223
+ // keys while an input has focus (see RoiInteraction.acceptsKeys).
224
+ destination?.addEventListener("keydown", (event) => {
225
+ if (event.key === "Enter") {
226
+ event.preventDefault();
227
+ this.saveToSource();
228
+ } else if (event.key === "Escape") {
229
+ event.preventDefault();
230
+ this.closeDestination();
231
+ }
232
+ });
233
+ }
234
+
235
+ bindBanners() {
236
+ this.el("roi_conflict_reload")?.addEventListener("click", async () => {
237
+ await this.store.reloadRemote();
238
+ this.renderer.invalidate();
239
+ this.renderer.schedule();
240
+ });
241
+ this.el("roi_conflict_keep")?.addEventListener("click", async () => {
242
+ await this.store.keepMineAndExport();
243
+ this.renderer.invalidate();
244
+ this.renderer.schedule();
245
+ });
246
+ }
247
+
248
+ // -- categories ------------------------------------------------------
249
+
250
+ createCategory(rawLabel) {
251
+ const label = (rawLabel || "").trim();
252
+ if (!label) return;
253
+ if (this.store.categories.some((c) => c.label.toLowerCase() === label.toLowerCase())) {
254
+ this.notify(`There is already a category called "${label}".`);
255
+ return;
256
+ }
257
+ const category = {
258
+ id: RoiStore.newId("c"),
259
+ label,
260
+ color: RoiSidebarController.nextColor(this.store.categories.length),
261
+ visible: true,
262
+ locked: false,
263
+ sort_order: this.store.categories.length,
264
+ };
265
+ this.store.commit({
266
+ label: "Add category",
267
+ redo: [{ op: "category.create", category }],
268
+ undo: [{ op: "category.delete", id: category.id, orphans: "delete" }],
269
+ });
270
+ // Selected immediately: the reason to make a category is to draw in it.
271
+ this.store.setActiveCategory(category.id);
272
+ }
273
+
274
+ updateCategory(id, changes) {
275
+ const category = this.store.category(id);
276
+ if (!category) return;
277
+ const before = {};
278
+ for (const key of Object.keys(changes)) before[key] = category[key];
279
+ this.store.commit({
280
+ label: "Edit category",
281
+ redo: [{ op: "category.update", id, changes }],
282
+ undo: [{ op: "category.update", id, changes: before }],
283
+ });
284
+ this.renderer.schedule();
285
+ }
286
+
287
+ /**
288
+ * Delete a category, having asked what happens to what is in it.
289
+ *
290
+ * There is no default answer, and the prompt is not skippable when the
291
+ * category has regions: silently deleting somebody's annotations because
292
+ * they tidied up a label is the one outcome that cannot be undone by
293
+ * looking at the screen.
294
+ *
295
+ * Every category can be deleted, including the last one -- there is no
296
+ * reserved catch-all any more. What that costs is a place to put orphaned
297
+ * shapes: "move them" is only offered when there is somewhere to move them
298
+ * TO, and the destination is named in the prompt rather than assumed.
299
+ */
300
+ deleteCategory(id) {
301
+ const category = this.store.category(id);
302
+ if (!category) return;
303
+
304
+ const count = this.store.countFor(id);
305
+ const destination = this.store.sortedCategories().find((c) => c.id !== id) || null;
306
+ let orphans = "delete";
307
+
308
+ if (count > 0) {
309
+ const regions = `${count} ROI${count === 1 ? "" : "s"}`;
310
+ if (destination) {
311
+ const keep = window.confirm(
312
+ `"${category.label}" has ${regions}.\n\n`
313
+ + `OK: keep them and move them to "${destination.label}".\n`
314
+ + "Cancel: delete the category and its ROIs."
315
+ );
316
+ orphans = keep ? "reassign" : "delete";
317
+ }
318
+ if (orphans === "delete"
319
+ && !window.confirm(`Delete "${category.label}" and its ${regions}?`)) {
320
+ return;
321
+ }
322
+ }
323
+
324
+ const affected = this.store.features
325
+ .filter((f) => f.category_id === id)
326
+ .map((f) => JSON.parse(JSON.stringify(f)));
327
+ const image = this.store.image;
328
+ const undo = [{ op: "category.create", category: { ...category } }];
329
+ if (orphans === "delete") {
330
+ for (const feature of affected) undo.push({ op: "roi.create", image, feature });
331
+ } else {
332
+ for (const feature of affected) {
333
+ undo.push({
334
+ op: "roi.update_properties", image, id: feature.id,
335
+ changes: { category_id: id },
336
+ });
337
+ }
338
+ }
339
+
340
+ const redo = { op: "category.delete", id, orphans };
341
+ if (orphans === "reassign") redo.reassign_to = destination.id;
342
+
343
+ this.store.commit({ label: "Delete category", redo: [redo], undo });
344
+ this.renderer.invalidate();
345
+ this.renderer.schedule();
346
+ }
347
+
348
+ // -- selection -------------------------------------------------------
349
+
350
+ commitName(value) {
351
+ const feature = this.store.selected;
352
+ if (!feature) return;
353
+ const name = (value || "").trim();
354
+ if (name === (feature.name || "")) return;
355
+ this.propertyChange(feature, { name });
356
+ }
357
+
358
+ recategorize(categoryId) {
359
+ const feature = this.store.selected;
360
+ if (!feature || !this.store.category(categoryId)) return;
361
+ if (categoryId === feature.category_id) return;
362
+ this.propertyChange(feature, { category_id: categoryId });
363
+ this.renderer.schedule();
364
+ }
365
+
366
+ setLocked(locked) {
367
+ const feature = this.store.selected;
368
+ if (!feature || Boolean(feature.locked) === Boolean(locked)) return;
369
+ this.propertyChange(feature, { locked: Boolean(locked) });
370
+ this.renderer.schedule();
371
+ }
372
+
373
+ propertyChange(feature, changes) {
374
+ const before = {};
375
+ for (const key of Object.keys(changes)) before[key] = feature[key];
376
+ this.store.commit({
377
+ label: "Edit ROI",
378
+ redo: [{
379
+ op: "roi.update_properties", image: this.store.image,
380
+ id: feature.id, changes,
381
+ }],
382
+ undo: [{
383
+ op: "roi.update_properties", image: this.store.image,
384
+ id: feature.id, changes: before,
385
+ }],
386
+ });
387
+ }
388
+
389
+ // -- import / export -------------------------------------------------
390
+
391
+ async exportGeoJSON() {
392
+ // Straight from local state when there is anything unsaved or the
393
+ // server has refused: the moments a user most needs an export are the
394
+ // ones where asking the server for one would hand back a version
395
+ // without their work in it.
396
+ if (this.store.hasUnsavedWork || this.store.dimensionMismatch
397
+ || this.store.status === "failed") {
398
+ this.store.exportLocal();
399
+ this.notify("Exported this session's regions.");
400
+ return;
401
+ }
402
+ const result = await this.api.downloadExport();
403
+ if (!result.ok) {
404
+ this.store.exportLocal();
405
+ this.notify("The server could not build the export; exported locally instead.");
406
+ }
407
+ }
408
+
409
+ async importGeoJSON(file, acceptMismatch = false) {
410
+ let document;
411
+ try {
412
+ document = JSON.parse(await file.text());
413
+ } catch (error) {
414
+ this.notify("That file is not valid JSON.");
415
+ return;
416
+ }
417
+
418
+ const result = await this.api.importGeojson(document, this.store.revision, acceptMismatch);
419
+
420
+ if (!result.ok && result.status === 409) {
421
+ this.store.setStatus("conflict", "");
422
+ return;
423
+ }
424
+ if (result.data && result.data.warning === "dimension_mismatch") {
425
+ const found = result.data.found || [];
426
+ const expected = result.data.expected || [];
427
+ // Default is Cancel. Geometry from a differently-sized image lands
428
+ // somewhere entirely plausible and completely wrong, which is
429
+ // exactly the kind of mistake that is never noticed.
430
+ const proceed = window.confirm(
431
+ `These ROIs were drawn on an image ${found[0]} x ${found[1]} px.\n`
432
+ + `This image is ${expected[0]} x ${expected[1]} px.\n\n`
433
+ + "Import anyway, without transforming them?"
434
+ );
435
+ if (!proceed) return;
436
+ return this.importGeoJSON(file, true);
437
+ }
438
+ if (!result.ok || !result.data.success) {
439
+ this.notify(result.data.error || "That file could not be imported.");
440
+ return;
441
+ }
442
+
443
+ // The server applied it and told us what it did; replay the same
444
+ // operation locally so it becomes ONE undo step rather than none.
445
+ const operation = result.data.operation;
446
+ this.store.revision = result.data.revision;
447
+ this.store.applyLocal(operation);
448
+ this.store.undoStack.push({
449
+ label: `Import ${result.data.imported} ROIs`,
450
+ redo: [operation],
451
+ undo: [
452
+ { op: "roi.bulk_delete", image: this.store.image,
453
+ ids: (operation.features || []).map((f) => f.id) },
454
+ ...(operation.categories || []).map((c) => ({
455
+ op: "category.delete", id: c.id, orphans: "delete",
456
+ })),
457
+ ],
458
+ });
459
+ this.store.redoStack.length = 0;
460
+ this.renderer.invalidate();
461
+ this.renderer.schedule();
462
+ this.store.changed();
463
+ this.notify(`Imported ${result.data.imported} region${result.data.imported === 1 ? "" : "s"}.`);
464
+ }
465
+
466
+ /**
467
+ * Write the regions into the file the project came from, under the name in
468
+ * the "Save as" field.
469
+ *
470
+ * Two presses: the first opens the field on the name this project last
471
+ * saved to, the second writes. The field is where the one irreversible
472
+ * decision on this panel is made -- which entry in somebody's file gets
473
+ * overwritten -- so it is shown before the write rather than after, and
474
+ * showing it costs the press that would otherwise have gone straight
475
+ * through. Everything below runs on the second press.
476
+ *
477
+ * The name is what makes several passes possible in one file -- a second
478
+ * annotator, a second read, a version worth keeping -- and it is also what
479
+ * makes a collision possible, so this is where that gets decided:
480
+ *
481
+ * the name this project last saved to -> written, no question. That is
482
+ * the ordinary draw-more-then-save-again loop, and a dialog in front
483
+ * of every save is a toll on the safe path.
484
+ * a free name -> written, no question.
485
+ * a name already in the file, not ours -> asked once. Somebody else's
486
+ * annotations are under it and replacing them cannot be undone from
487
+ * this panel.
488
+ *
489
+ * `replace` is only ever sent as the user's answer. The server refuses an
490
+ * existing key without it, so a mistake here costs a refusal rather than
491
+ * somebody's work.
492
+ */
493
+ async saveToSource() {
494
+ const button = this.el("roi_save_to_source");
495
+ const destination = this._destination;
496
+ if (!destination || !destination.kind) return;
497
+ if (!this._destinationOpen) {
498
+ this.openDestination();
499
+ return;
500
+ }
501
+
502
+ const name = this.destinationName();
503
+ if (destination.kind === "anndata" && name !== destination.remembered
504
+ && destination.existing.includes(name)) {
505
+ const proceed = window.confirm(
506
+ `"${name}" already exists in this file.\n\n`
507
+ + "Replace it? The annotations currently stored under that name will be lost."
508
+ );
509
+ if (!proceed) return;
510
+ destination.replaceOnce = true;
511
+ }
512
+
513
+ button.disabled = true;
514
+ try {
515
+ const result = destination.kind === "anndata"
516
+ ? await window.PlexoraStatus.track("Saving ROIs to file",
517
+ this.api.saveToAnndata(name, destination.replaceOnce
518
+ || name === destination.remembered))
519
+ : await window.PlexoraStatus.track("Saving ROIs to store",
520
+ this.api.saveToSpatialdata(name));
521
+ this.afterSaveToSource(result);
522
+ } finally {
523
+ destination.replaceOnce = false;
524
+ button.disabled = false;
525
+ }
526
+ }
527
+
528
+ /** What the server made of it, said in terms of the user's own file. */
529
+ afterSaveToSource(result) {
530
+ const data = result.data || {};
531
+ const destination = this._destination;
532
+
533
+ if (!result.ok || !data.success) {
534
+ // Both refusals mean the same thing to the user -- that name is
535
+ // taken -- and both carry a free one, so the field becomes the
536
+ // suggestion rather than the user having to invent another.
537
+ if (data.error === "element_exists" || data.error === "key_exists") {
538
+ destination.existing = data.elements || data.keys || destination.existing;
539
+ if (data.suggestion) this.el("roi_destination_name").value = data.suggestion;
540
+ this.renderDestination();
541
+ this.notify(`That name is already taken${data.suggestion
542
+ ? ` -- try "${data.suggestion}".` : "."}`);
543
+ return;
544
+ }
545
+ this.notify(data.error || "The file could not be written.");
546
+ return;
547
+ }
548
+
549
+ destination.remembered = data.name || destination.remembered;
550
+ if (data.name && !destination.existing.includes(data.name)) {
551
+ destination.existing = [...destination.existing, data.name].sort();
552
+ }
553
+ // Written, so the question the field was asking has been answered. A
554
+ // refusal above returns early instead: that one is still open.
555
+ this.closeDestination();
556
+ this.notify(`Saved to ${data.element || data.key}.`);
557
+ }
558
+
559
+ /** The name in the field, or the default when it has been emptied. */
560
+ destinationName() {
561
+ const field = this.el("roi_destination_name");
562
+ const typed = (field?.value || "").trim();
563
+ return typed || (this._destination?.default_name || "");
564
+ }
565
+
566
+ /** Show the "Save as" field, with the name it is going to use selected --
567
+ * the common case is accepting it, and the next-common is typing over it
568
+ * whole, so neither should need the mouse. */
569
+ openDestination() {
570
+ this._destinationOpen = true;
571
+ this.renderSourceButton();
572
+ const field = this.el("roi_destination_name");
573
+ field?.focus();
574
+ field?.select();
575
+ }
576
+
577
+ closeDestination() {
578
+ this._destinationOpen = false;
579
+ this.renderSourceButton();
580
+ }
581
+
582
+ // -- rendering -------------------------------------------------------
583
+
584
+ render() {
585
+ this.renderBanners();
586
+ this.renderToolbar();
587
+ this.renderCategories();
588
+ this.renderSelection();
589
+ this.renderStatus();
590
+ this.renderSourceButton();
591
+ }
592
+
593
+ renderBanners() {
594
+ const blocked = this.el("roi_blocked_banner");
595
+ if (blocked) {
596
+ blocked.hidden = !this.store.dimensionMismatch;
597
+ if (this.store.dimensionMismatch) {
598
+ const [sw, sh] = this.store.storedImageSize;
599
+ const [cw, ch] = this.store.imageSize;
600
+ this.el("roi_blocked_detail").textContent =
601
+ `Drawn on ${sw} x ${sh} px; this image is ${cw} x ${ch} px.`;
602
+ }
603
+ }
604
+ const conflict = this.el("roi_conflict_banner");
605
+ if (conflict) conflict.hidden = this.store.status !== "conflict";
606
+ }
607
+
608
+ renderToolbar() {
609
+ for (const button of document.querySelectorAll("#roi_toolbar .roi-tool")) {
610
+ button.classList.toggle("is-active", button.dataset.tool === this.tools.tool);
611
+ // Select stays available whenever the pointer works at all; the
612
+ // three that MAKE a shape also need a category to put it in.
613
+ button.disabled = button.dataset.tool === "select"
614
+ ? !this.tools.ready
615
+ : !this.tools.canDraw;
616
+ }
617
+ }
618
+
619
+ renderCategories() {
620
+ const list = this.el("roi_category_list");
621
+ if (!list) return;
622
+ list.textContent = "";
623
+
624
+ for (const category of this.store.sortedCategories()) {
625
+ const row = document.createElement("div");
626
+ row.className = "roi-category";
627
+ row.classList.toggle("is-active", category.id === this.store.activeCategoryId);
628
+ row.classList.toggle("is-hidden", category.visible === false);
629
+
630
+ const swatch = document.createElement("input");
631
+ swatch.type = "color";
632
+ swatch.className = "roi-swatch";
633
+ swatch.value = category.color;
634
+ swatch.title = `Colour for ${category.label}`;
635
+ swatch.addEventListener("change", () => {
636
+ this.updateCategory(category.id, { color: swatch.value });
637
+ });
638
+
639
+ const label = document.createElement("button");
640
+ label.type = "button";
641
+ label.className = "roi-category-label";
642
+ // textContent, not innerHTML: this string is whatever the user (or
643
+ // an imported file) typed.
644
+ label.textContent = category.label;
645
+ label.title = `Draw in ${category.label}`;
646
+ label.addEventListener("click", () => this.store.setActiveCategory(category.id));
647
+ label.addEventListener("dblclick", () => this.renameCategory(category));
648
+
649
+ const count = document.createElement("span");
650
+ count.className = "roi-category-count";
651
+ count.textContent = String(this.store.countFor(category.id));
652
+
653
+ row.append(swatch, label, count);
654
+ row.append(
655
+ this.categoryButton(
656
+ category.visible === false ? "fa-eye-slash" : "fa-eye",
657
+ category.visible === false ? "Show" : "Hide",
658
+ () => this.updateCategory(category.id, { visible: category.visible === false })),
659
+ this.categoryButton(
660
+ category.locked ? "fa-lock" : "fa-lock-open",
661
+ category.locked ? "Unlock" : "Lock",
662
+ () => this.updateCategory(category.id, { locked: !category.locked })),
663
+ );
664
+ row.append(this.categoryButton("fa-trash", "Delete category",
665
+ () => this.deleteCategory(category.id), "roi-category-delete"));
666
+ list.append(row);
667
+ }
668
+
669
+ // Shown only when the list is empty, and it is what the panel says
670
+ // instead of shipping a category the user did not ask for.
671
+ const empty = this.el("roi_category_empty");
672
+ if (empty) empty.hidden = this.store.categories.length > 0;
673
+ }
674
+
675
+ categoryButton(icon, title, onClick, extraClass = "") {
676
+ const button = document.createElement("button");
677
+ button.type = "button";
678
+ button.className = `icon-button roi-category-action ${extraClass}`.trim();
679
+ button.title = title;
680
+ const glyph = document.createElement("span");
681
+ glyph.className = `fas ${icon}`;
682
+ button.append(glyph);
683
+ button.addEventListener("click", onClick);
684
+ return button;
685
+ }
686
+
687
+ renameCategory(category) {
688
+ const label = window.prompt("Category name", category.label);
689
+ if (label === null) return;
690
+ const trimmed = label.trim();
691
+ if (!trimmed || trimmed === category.label) return;
692
+ if (this.store.categories.some(
693
+ (c) => c.id !== category.id && c.label.toLowerCase() === trimmed.toLowerCase())) {
694
+ this.notify(`There is already a category called "${trimmed}".`);
695
+ return;
696
+ }
697
+ this.updateCategory(category.id, { label: trimmed });
698
+ }
699
+
700
+ renderSelection() {
701
+ const panel = this.el("roi_selection_panel");
702
+ const feature = this.store.selected;
703
+ if (!panel) return;
704
+ panel.hidden = !feature;
705
+ if (!feature) return;
706
+
707
+ const name = this.el("roi_selection_name");
708
+ // Not overwritten while it has focus: doing so would fight the user
709
+ // mid-word every time an autosave came back.
710
+ if (name && document.activeElement !== name) name.value = feature.name || "";
711
+
712
+ const select = this.el("roi_selection_category");
713
+ if (select) {
714
+ select.textContent = "";
715
+ for (const category of this.store.sortedCategories()) {
716
+ const option = document.createElement("option");
717
+ option.value = category.id;
718
+ option.textContent = category.label;
719
+ option.selected = category.id === feature.category_id;
720
+ select.append(option);
721
+ }
722
+ }
723
+
724
+ // The dot in front of the dropdown, which is the only thing telling the
725
+ // user that "Stroma" here is the same Stroma they picked a colour for.
726
+ const swatch = this.el("roi_selection_swatch");
727
+ if (swatch) {
728
+ const category = this.store.category(feature.category_id);
729
+ swatch.style.background = category ? category.color : "transparent";
730
+ }
731
+
732
+ // Which padlock is drawn is CSS's business, off `aria-pressed` -- see
733
+ // the note in panel.html. Setting it here is the whole state change.
734
+ const locked = this.el("roi_selection_locked");
735
+ if (locked) {
736
+ const on = Boolean(feature.locked);
737
+ locked.setAttribute("aria-pressed", String(on));
738
+ locked.classList.toggle("is-active", on);
739
+ locked.title = on ? "Unlock this region" : "Lock this region";
740
+ }
741
+
742
+ const note = this.el("roi_selection_note");
743
+ if (note) {
744
+ const parts = [];
745
+ if (this.store.isLocked(feature) && !feature.locked) {
746
+ parts.push("Its category is locked.");
747
+ }
748
+ if (!RoiGeometry.isVertexEditable(feature.geometry)) {
749
+ parts.push("Imported shape: it can be moved but its vertices cannot be edited.");
750
+ }
751
+ if (feature.flags && feature.flags.self_intersecting) {
752
+ parts.push("This outline crosses itself.");
753
+ }
754
+ note.textContent = parts.join(" ");
755
+ // Hidden when it has nothing to say, rather than an empty line that
756
+ // still costs its margin under every selection.
757
+ note.hidden = parts.length === 0;
758
+ }
759
+ }
760
+
761
+ renderStatus() {
762
+ const text = this.el("roi_status_text");
763
+ const dot = this.el("roi_status_dot");
764
+ if (!text || !dot) return;
765
+
766
+ const labels = {
767
+ saved: "Saved",
768
+ saving: "Saving…",
769
+ dirty: "Unsaved changes",
770
+ failed: "Save failed",
771
+ conflict: "Changed elsewhere",
772
+ blocked: "Not saving",
773
+ };
774
+ text.textContent = this.store.statusDetail
775
+ ? `${labels[this.store.status]} — ${this.store.statusDetail}`
776
+ : labels[this.store.status] || "";
777
+ dot.className = `roi-status-dot roi-status-${this.store.status}`;
778
+ }
779
+
780
+ /**
781
+ * The row at the bottom: where this work goes when it leaves the panel.
782
+ *
783
+ * Every project gets a Save, because "how do I keep this?" is the same
784
+ * question whatever the project was built from -- and the answer used to be
785
+ * a small icon in the panel heading for anyone without an .h5ad, which is
786
+ * not an answer anybody finds. A native destination gets the file write; a
787
+ * CSV or image-only project gets the GeoJSON download in the same place,
788
+ * the same size, saying what it does. Never both at once.
789
+ *
790
+ * Map to cells sits beside it rather than under it. Under, it read as the
791
+ * step after saving; it is not one, and neither is a prerequisite of the
792
+ * other. Its ? is hidden and shown with it for the same reason.
793
+ *
794
+ * The heading icons stay: they are the quick path for someone who already
795
+ * knows where they are.
796
+ */
797
+ renderSourceButton() {
798
+ const native = this.el("roi_save_to_source");
799
+ const download = this.el("roi_export_download");
800
+ if (!native || !download) return;
801
+
802
+ if (this._destination === undefined) {
803
+ this._destination = null;
804
+ // Asked once, from the server: which native destination this
805
+ // project has is a fact about how it was imported, and the panel
806
+ // should show one button rather than three that mostly error.
807
+ this.api.destination().then((result) => {
808
+ const data = (result.ok && result.data.success) ? result.data : {};
809
+ this._destination = {
810
+ kind: data.kind || null,
811
+ default_name: data.default_name || "",
812
+ remembered: data.remembered || "",
813
+ existing: data.existing || [],
814
+ // Whether this project has cells at all. Carried on the
815
+ // same answer because it is part of the same question --
816
+ // what can this project's regions be written to -- and the
817
+ // panel would otherwise make a second round trip to learn
818
+ // one boolean.
819
+ hasTable: Boolean(data.has_table),
820
+ replaceOnce: false,
821
+ };
822
+ if (this._destination.kind) {
823
+ // Just "Save". It used to name the format -- "Save to
824
+ // SpatialData store" -- which is both wider than half a
825
+ // 288px row and an answer to a question the button is not
826
+ // being asked: the user knows what they imported. Where it
827
+ // lands is spelled out under the name field the moment the
828
+ // button is pressed, down to `uns/plexora/rois`, and the
829
+ // tooltip carries it for anyone hovering first.
830
+ this.el("roi_save_to_source_label").textContent = "Save";
831
+ native.title = this._destination.kind === "anndata"
832
+ ? "Save these regions into your AnnData file"
833
+ : "Save these regions into your SpatialData store";
834
+ const field = this.el("roi_destination_name");
835
+ // Seeded with where this project last saved, so the second
836
+ // save is a click rather than a name typed correctly twice.
837
+ if (field) {
838
+ field.value = this._destination.remembered
839
+ || this._destination.default_name;
840
+ }
841
+ }
842
+ this.renderSourceButton();
843
+ }).catch(() => { /* no native target; the GeoJSON download stands in */ });
844
+ }
845
+
846
+ // Nothing drawn yet means nothing to save, and a button that would
847
+ // write an empty file is worse than no button.
848
+ const kind = this._destination && this._destination.kind;
849
+ const anything = this.store.features.length > 0;
850
+ native.hidden = !kind || !anything;
851
+ download.hidden = Boolean(kind) || !anything;
852
+
853
+ // Mapping is a different offer from saving, and gated on a different
854
+ // fact: saving needs somewhere to put polygons, mapping needs rows to
855
+ // put labels on. A project that is nothing but an image has neither
856
+ // button; a CSV project has the download and this one.
857
+ const mapper = this.el("roi_map_to_cells");
858
+ const info = this.el("roi_map_info");
859
+ if (mapper) {
860
+ const canMap = Boolean(this._destination
861
+ && this._destination.hasTable) && anything;
862
+ mapper.hidden = !canMap;
863
+ // The ? goes with the button, not with the panel: an explanation of
864
+ // a control that is not on screen is a control of its own.
865
+ if (info) info.hidden = !canMap;
866
+ }
867
+
868
+ // The field cannot outlive the button that opened it: undoing the last
869
+ // region while it is open would otherwise leave a "Save as" for a save
870
+ // there is no longer anything to make.
871
+ if (native.hidden) this._destinationOpen = false;
872
+ const field = this.el("roi_destination");
873
+ if (field) field.hidden = !this._destinationOpen;
874
+ native.setAttribute("aria-expanded", this._destinationOpen ? "true" : "false");
875
+ this.renderDestination();
876
+ }
877
+
878
+ /**
879
+ * The name the two cell columns are derived from.
880
+ *
881
+ * Not `destinationName()`, which falls back to `default_name` -- that is
882
+ * `plexora_rois` for a SpatialData project, and `plexora_rois_category` is
883
+ * not a column name anybody wants to type. A blank is left blank and the
884
+ * server supplies `rois` for every format, so the two ends cannot drift.
885
+ */
886
+ mapPrefix() {
887
+ const field = this.el("roi_destination_name");
888
+ const typed = (field?.value || "").trim();
889
+ return typed || (this._destination?.remembered || "");
890
+ }
891
+
892
+ /**
893
+ * The two columns the button is about to write, named.
894
+ *
895
+ * The blank `mapPrefix()` returns is the server's cue to use `rois`, so
896
+ * that default is spelled out here too -- this is the one thing in the
897
+ * dialog that is about the user's project rather than about the feature,
898
+ * and "it depends" is not what they came to read.
899
+ */
900
+ mapColumnNames() {
901
+ const prefix = this.mapPrefix() || "rois";
902
+ return [`${prefix}_category`, `${prefix}_name`];
903
+ }
904
+
905
+ /**
906
+ * What Map to cells does, at length.
907
+ *
908
+ * Filled in at open rather than at render: the column names follow the save
909
+ * name, which the user can be typing while the panel is open, and a dialog
910
+ * nobody has looked at yet is the cheapest place in the panel to be right.
911
+ */
912
+ openMapInfo() {
913
+ const dialog = this.el("roi_map_info_dialog");
914
+ if (!dialog) return;
915
+ const [category, name] = this.mapColumnNames();
916
+ const categoryCell = this.el("roi_map_info_category");
917
+ const nameCell = this.el("roi_map_info_name");
918
+ if (categoryCell) categoryCell.textContent = category;
919
+ if (nameCell) nameCell.textContent = name;
920
+ dialog.showModal();
921
+ }
922
+
923
+ /**
924
+ * Write the ROI columns onto this project's cells.
925
+ *
926
+ * Two questions can come back unanswered -- which column holds the cell id,
927
+ * which holds the image id -- and both are asked here rather than at launch
928
+ * because this is the only action that needs them. `requirements.require`
929
+ * ignores whether the user was already offered the field and skipped it,
930
+ * which is exactly right: skipping was a fine answer until they pressed a
931
+ * button that cannot proceed without one.
932
+ *
933
+ * Branching on `needs` rather than on the message. The wording is for the
934
+ * user; a client that greps it breaks the moment it is improved.
935
+ */
936
+ async mapToCells(replace = false) {
937
+ const button = this.el("roi_map_to_cells");
938
+ if (!button || button.hidden) return;
939
+
940
+ button.disabled = true;
941
+ try {
942
+ const result = await window.PlexoraStatus.track(
943
+ "Mapping ROIs to cells",
944
+ this.api.mapToCells(this.mapPrefix(), replace));
945
+ const data = result.data || {};
946
+
947
+ if (!result.ok || !data.success) {
948
+ if (data.needs && !replace) {
949
+ const collected = await this.ctx.requirements?.require([data.needs]);
950
+ // Retried once, and only once: a second refusal means the
951
+ // answer did not fix it, and asking again in a loop is how
952
+ // a modal becomes impossible to get out of.
953
+ if (collected) return this.mapToCells(replace);
954
+ this.notify(data.error || "Plexora needs one more answer first.");
955
+ return;
956
+ }
957
+ if (data.error === "column_exists") {
958
+ const suggestion = data.suggestion;
959
+ const proceed = window.confirm(
960
+ `Your cells already have a "${this.mapPrefix() || "rois"}" mapping.\n\n`
961
+ + "Replace it? The existing values in those two columns will be lost."
962
+ + (suggestion ? `\n\nCancel to save it as "${suggestion}" instead.` : ""));
963
+ if (proceed) return this.mapToCells(true);
964
+ if (suggestion) {
965
+ const field = this.el("roi_destination_name");
966
+ if (field) field.value = suggestion;
967
+ this.renderSourceButton();
968
+ }
969
+ return;
970
+ }
971
+ this.notify(data.error || "The cells could not be annotated.");
972
+ return;
973
+ }
974
+
975
+ const columns = (data.columns || []).join(" and ");
976
+ this.notify(`${data.n_assigned} of ${data.n_cells} cells are in an ROI. `
977
+ + `Wrote ${columns}.`);
978
+ } finally {
979
+ button.disabled = false;
980
+ }
981
+ }
982
+
983
+ /**
984
+ * What the save button is about to write, spelled out underneath it.
985
+ *
986
+ * The full path rather than just the name: `uns/plexora/rois` is where this
987
+ * ends up in somebody's file, and a user who is choosing between two names
988
+ * is exactly the user who wants to see it. The other names in the file are
989
+ * listed for the same reason -- a collision should be visible before it is
990
+ * typed, not after it is refused.
991
+ */
992
+ renderDestination() {
993
+ const hint = this.el("roi_destination_hint");
994
+ const destination = this._destination;
995
+ if (!hint || !destination || !destination.kind) return;
996
+
997
+ const name = this.destinationName();
998
+ const where = destination.kind === "anndata"
999
+ ? `uns/plexora/${name}` : `shapes/${name}`;
1000
+ const others = destination.existing.filter((each) => each !== name);
1001
+ hint.textContent = others.length
1002
+ ? `Writes ${where}. Also in this file: ${others.join(", ")}.`
1003
+ : `Writes ${where}.`;
1004
+ }
1005
+
1006
+ notify(message) {
1007
+ const box = this.el("roi_message");
1008
+ if (!box) return;
1009
+ box.textContent = message;
1010
+ box.hidden = false;
1011
+ if (this._messageTimer) clearTimeout(this._messageTimer);
1012
+ this._messageTimer = setTimeout(() => { box.hidden = true; }, 6000);
1013
+ }
1014
+
1015
+ /** Distinct, readable-on-dark colours for new categories, in a fixed order
1016
+ * so the same project gets the same palette every time. */
1017
+ static nextColor(index) {
1018
+ const palette = [
1019
+ "#e05c5c", "#38bdf8", "#34d399", "#f3b845", "#c084fc",
1020
+ "#f472b6", "#22d3ee", "#a3e635", "#fb923c", "#94a3b8",
1021
+ ];
1022
+ return palette[index % palette.length];
1023
+ }
1024
+ }
1025
+
1026
+
1027
+ if (window.Plexora) {
1028
+ window.Plexora.registerPlugin({
1029
+ name: "roi",
1030
+ // ROI draws its own overlay and never colours cells, so it does not
1031
+ // claim the cell layer -- claiming is exclusive, and taking it would
1032
+ // evict whichever plugin actually needs it.
1033
+ ownsCellLayer: false,
1034
+ createSidebarController(ctx) {
1035
+ return new RoiSidebarController(ctx);
1036
+ },
1037
+ });
1038
+ }