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,822 @@
1
+ /**
2
+ * cellExplorerRoiBridge.js - what is inside that region?
3
+ *
4
+ * An ROI on its own is a shape. The question anyone drawing one is actually
5
+ * asking is what the cells inside it are, and until now answering it meant
6
+ * exporting the region and counting somewhere else. This joins the two plugins
7
+ * that already hold both halves of the answer.
8
+ *
9
+ * ## The split, which this file does not cross
10
+ *
11
+ * ROI owns geometry. It says which region the pointer is over and
12
+ * where that region is on the screen, through the
13
+ * `plexora:roi-hover` / `plexora:roi-unhover` events, and it
14
+ * knows nothing about metadata.
15
+ * Cell Explorer owns the variable, its categories and their colours. It has
16
+ * no idea what an ROI is.
17
+ * this file is the only thing that knows both, and it is a LISTENER --
18
+ * it never reaches into RoiStore, never edits a shape, and
19
+ * never asks the server for anything of its own.
20
+ *
21
+ * Written this way so the ROI plugin stays usable without Cell Explorer
22
+ * installed and vice versa: with no listener the events fall on the floor, and
23
+ * with no ROI plugin loaded nothing ever fires.
24
+ *
25
+ * ## Why hovering is cheap
26
+ *
27
+ * Every cell's centre is fetched ONCE per session, from core's own
28
+ * `NumericData.loadCells()` -- the same request the viewer already makes -- and
29
+ * indexed into a coarse grid. A hover then tests only the cells in the buckets
30
+ * the region's bounding box touches, so the cost follows the size of the region
31
+ * rather than the size of the slide.
32
+ *
33
+ * Two caches, deliberately separate, because they go stale for different
34
+ * reasons: WHICH cells are in a region depends only on geometry, and WHAT they
35
+ * are depends only on the column. Switching cell_type -> phenotype therefore
36
+ * re-tallies against cells already known to be inside, without recomputing
37
+ * membership and without the region being touched at all.
38
+ */
39
+ class CellExplorerRoiBridge {
40
+
41
+ //: Bars before the tail is folded into `Other`. Nothing is folded away to
42
+ //: reveal a single row -- five plus an `Other` standing for one category
43
+ //: would be a worse card than six real ones, so the fold needs two.
44
+ static TOP_CATEGORIES = 5;
45
+
46
+ //: How long the card survives the pointer leaving the region, so it can be
47
+ //: walked into and its bars inspected.
48
+ static GRACE_MS = 180;
49
+
50
+ //: Distance from the region's bounding box, and from the edge of the image.
51
+ static ANCHOR_GAP = 14;
52
+ static EDGE_PAD = 8;
53
+
54
+ //: Past this share of the track there is no room outside the bar for its
55
+ //: own number, so the number moves inside it.
56
+ static COUNT_INSIDE_PERCENT = 78;
57
+
58
+ //: Regions whose membership is worth keeping. A handful, because the point
59
+ //: is re-hovering the same few shapes, not holding a slide's worth.
60
+ static MEMBER_CACHE = 16;
61
+
62
+ //: `Other` is not a category and must not look like one, so it takes a
63
+ //: neutral of its own rather than the next palette colour.
64
+ static OTHER_COLOR = "#79839a";
65
+
66
+ constructor(ctx, state) {
67
+ this.ctx = ctx;
68
+ this.state = state;
69
+ this.viewer = ctx.viewer?.viewer || null;
70
+
71
+ //: The `plexora:roi-hover` detail the card is currently describing.
72
+ this.hover = null;
73
+ this.card = null;
74
+ this.expanded = false;
75
+ this._hideTimer = null;
76
+ //: Bumped on every hover and on close, so a summary whose position
77
+ //: lookup was still in flight cannot paint over a later one.
78
+ this._token = 0;
79
+
80
+ this._positions = null;
81
+ this._positionsPromise = null;
82
+ //: roiId -> {geometry, indices}, insertion-ordered as an LRU.
83
+ this._members = new Map();
84
+ //: {source, table} -- cell id -> code for one `state.data` object.
85
+ this._lookup = null;
86
+ //: Columns already reported for a poor id overlap; see warnOnOverlap.
87
+ this._warned = new Set();
88
+
89
+ this._onHover = (event) => this.hoverEntered(event.detail);
90
+ this._onUnhover = (event) => this.hoverLeft(event.detail);
91
+
92
+ window.addEventListener("plexora:roi-hover", this._onHover);
93
+ window.addEventListener("plexora:roi-unhover", this._onUnhover);
94
+ // Nothing is listened for on the viewer here. An anchor taken in client
95
+ // pixels does go stale the moment the picture moves, but ROI owns where
96
+ // its shapes are and re-announces the hover with a fresh anchor while
97
+ // one is standing -- so the card follows the region through a pan
98
+ // instead of being closed by it. Closing was worse than it sounds: with
99
+ // the pointer sitting still afterwards, no further enter was ever
100
+ // dispatched, and the card could not be brought back without leaving the
101
+ // region and coming back to it.
102
+ }
103
+
104
+ destroy() {
105
+ window.removeEventListener("plexora:roi-hover", this._onHover);
106
+ window.removeEventListener("plexora:roi-unhover", this._onUnhover);
107
+ this.close();
108
+ // On <body>, so it outlives this panel's markup and has to be handed
109
+ // back explicitly -- the same reason the legend and the variable picker
110
+ // are torn down by name in the controller's destroy().
111
+ this.card?.remove();
112
+ this.card = null;
113
+ this._members.clear();
114
+ this._lookup = null;
115
+ this._positions = null;
116
+ this._positionsPromise = null;
117
+ }
118
+
119
+ // -- hover ---------------------------------------------------------------
120
+
121
+ /**
122
+ * The pointer has entered a region -- or the picture has moved under one it
123
+ * was already in.
124
+ *
125
+ * Deliberately not `async`. The overwhelmingly common case is a second and
126
+ * later hover, where every input is already in memory, and awaiting an
127
+ * already-resolved promise there would still put the card a frame behind the
128
+ * pointer. Only the first hover of a session has anything to wait for, and
129
+ * it says so on the card rather than appearing to have ignored the pointer.
130
+ */
131
+ hoverEntered(detail) {
132
+ if (!detail || !detail.id) return;
133
+ this.cancelHide();
134
+
135
+ // Panning or zooming with the pointer at rest inside a region: ROI
136
+ // re-announces the same shape with a fresh anchor, and all that has to
137
+ // happen is the card following it. Re-summarising would rebuild an
138
+ // identical card once per frame for the length of the gesture.
139
+ if (this.isSameHover(detail) && this.card && !this.card.hidden) {
140
+ this.hover = detail;
141
+ this.place(detail.anchorRect, detail.viewportRect);
142
+ return;
143
+ }
144
+
145
+ this.hover = detail;
146
+ this.expanded = false;
147
+ const token = ++this._token;
148
+ if (!this.canSummarise()) {
149
+ this.close();
150
+ return;
151
+ }
152
+
153
+ if (this._positions) {
154
+ this.paint(detail, this._positions);
155
+ return;
156
+ }
157
+
158
+ // First hover of the session, with every cell's position still on its
159
+ // way. The card opens now, saying what it is doing: a hover that appears
160
+ // to do nothing for a second reads as a hover that was missed, and the
161
+ // pointer moves on before the answer arrives.
162
+ this.renderPending(detail);
163
+ this.place(detail.anchorRect, detail.viewportRect);
164
+ this.positions().then((positions) => {
165
+ // Everything can have changed across that wait: the pointer may have
166
+ // left, moved to another region, or the user may have switched to a
167
+ // continuous column.
168
+ if (token !== this._token || this.hover !== detail || !positions) return;
169
+ if (!this.canSummarise()) {
170
+ this.close();
171
+ return;
172
+ }
173
+ this.paint(detail, positions);
174
+ });
175
+ }
176
+
177
+ /** The same shape, unchanged, as the card is already describing. Name as
178
+ * well as geometry, so a rename still redraws the header. */
179
+ isSameHover(detail) {
180
+ const current = this.hover;
181
+ return Boolean(current && current.id === detail.id
182
+ && current.geometry === detail.geometry
183
+ && current.name === detail.name);
184
+ }
185
+
186
+ paint(detail, positions) {
187
+ const summary = this.summarise(detail, positions);
188
+ if (!summary) {
189
+ this.close();
190
+ return;
191
+ }
192
+ this.render(summary);
193
+ this.place(detail.anchorRect, detail.viewportRect);
194
+ }
195
+
196
+ /**
197
+ * Fetch the positions before anything is hovering.
198
+ *
199
+ * Called when the user asks for the ROI tool, which is the earliest honest
200
+ * signal that regions are about to be drawn. On a slide with a million cells
201
+ * the request behind this takes long enough to be the difference between a
202
+ * card that appears with the pointer and one that appears after it.
203
+ */
204
+ warm() {
205
+ this.positions();
206
+ }
207
+
208
+ hoverLeft(detail) {
209
+ if (!this.hover) return;
210
+ // Ignored when it names a region the card is not describing -- ROI
211
+ // reports the leave before the enter when the pointer crosses straight
212
+ // from one shape into another.
213
+ if (detail?.id && detail.id !== this.hover.id) return;
214
+ this.cancelHide();
215
+ // Not closed outright: the pointer has to be able to travel off the
216
+ // region and onto the card to read a bar. Entering the card cancels
217
+ // this; see ensureCard.
218
+ this._hideTimer = setTimeout(() => this.close(), CellExplorerRoiBridge.GRACE_MS);
219
+ }
220
+
221
+ cancelHide() {
222
+ if (this._hideTimer) clearTimeout(this._hideTimer);
223
+ this._hideTimer = null;
224
+ }
225
+
226
+ close() {
227
+ this.cancelHide();
228
+ this._token += 1;
229
+ this.hover = null;
230
+ this.expanded = false;
231
+ if (this.card) this.card.hidden = true;
232
+ this.hideTooltip();
233
+ }
234
+
235
+ /**
236
+ * The active variable changed under an open card.
237
+ *
238
+ * Only the tally is stale. Membership is a fact about geometry and the
239
+ * geometry has not moved, so this recomputes in place -- which is what lets
240
+ * switching cell_type -> phenotype change the answer without the region
241
+ * being redrawn, reloaded or recreated.
242
+ */
243
+ refreshOpenCard() {
244
+ if (!this.hover || !this.card || this.card.hidden) return;
245
+ if (!this.canSummarise()) {
246
+ this.close();
247
+ return;
248
+ }
249
+ if (!this._positions) return;
250
+ const summary = this.summarise(this.hover, this._positions);
251
+ if (!summary) return;
252
+ this.render(summary);
253
+ // Re-clamped rather than re-anchored: hiding categories takes bars off
254
+ // the card, and one that had been pushed up to fit can sit where it
255
+ // belongs again.
256
+ this.place(this.hover.anchorRect, this.hover.viewportRect);
257
+ }
258
+
259
+ /**
260
+ * Categorical only.
261
+ *
262
+ * A continuous variable has no categories to be a composition OF, and a
263
+ * mean or a histogram is a different question nobody asked by hovering.
264
+ * The region stays drawn, hoverable and editable; only the card is
265
+ * withheld, until a categorical column is chosen.
266
+ */
267
+ canSummarise() {
268
+ const column = this.state.column;
269
+ if (!column || this.state.kindFor(column) !== "categorical") return false;
270
+ const data = this.state.data;
271
+ return Boolean(data && data.kind === "categorical" && data.ids && data.ids.length);
272
+ }
273
+
274
+ // -- cell positions ------------------------------------------------------
275
+
276
+ positions() {
277
+ if (this._positions) return Promise.resolve(this._positions);
278
+ if (!this._positionsPromise) {
279
+ this._positionsPromise = this.loadPositions()
280
+ .then((index) => {
281
+ this._positions = index;
282
+ return index;
283
+ })
284
+ .catch((error) => {
285
+ console.error("Cell Explorer: could not load cell positions "
286
+ + "for region summaries", error);
287
+ // Cleared, so a transient failure does not disable the
288
+ // feature for the rest of the session.
289
+ this._positionsPromise = null;
290
+ return null;
291
+ });
292
+ }
293
+ return this._positionsPromise;
294
+ }
295
+
296
+ /**
297
+ * Every cell's centre, once per session.
298
+ *
299
+ * Through core's own NumericData, which already memoises this request and
300
+ * already answers it whether the project draws tiled labels or legacy
301
+ * centroids -- the alternative was a second endpoint returning the same
302
+ * three columns to the same browser.
303
+ *
304
+ * These coordinates are RAW full-resolution image pixels, which is exactly
305
+ * the space ROI geometry is stored in, so they are used unscaled. The
306
+ * `* 2 ** extraZoomLevels` in core's legacy centroid cache belongs to the
307
+ * OSD-inflated overlay that cache feeds, and applying it here would put
308
+ * every cell in the wrong place.
309
+ */
310
+ async loadPositions() {
311
+ const source = this.ctx.viewer?.numericData;
312
+ if (!source?.loadCells) return null;
313
+ const { ids, centers } = await source.loadCells();
314
+ if (!ids?.length || !centers) return null;
315
+ return CellExplorerRoiBridge.buildIndex(ids, centers, this.bucketSpan());
316
+ }
317
+
318
+ bucketSpan() {
319
+ const width = Number(this.ctx.config?.tileWidth);
320
+ return Number.isFinite(width) && width > 0 ? width : 512;
321
+ }
322
+
323
+ /**
324
+ * A coarse grid over the cells, so a hover costs the region's area rather
325
+ * than the slide's cell count.
326
+ *
327
+ * Without it every hover is a point-in-polygon test per cell -- a million
328
+ * of them to describe a region holding four hundred, on every shape the
329
+ * pointer crosses. Buckets are keyed by one number rather than an "x|y"
330
+ * string because building a million strings is the expensive half of
331
+ * indexing a million cells.
332
+ */
333
+ static buildIndex(ids, centers, span) {
334
+ let maxX = 0;
335
+ let maxY = 0;
336
+ for (let i = 0, p = 0; i < ids.length; i += 1, p += 2) {
337
+ if (centers[p] > maxX) maxX = centers[p];
338
+ if (centers[p + 1] > maxY) maxY = centers[p + 1];
339
+ }
340
+ const columns = Math.floor(maxX / span) + 1;
341
+ const buckets = new Map();
342
+ for (let i = 0, p = 0; i < ids.length; i += 1, p += 2) {
343
+ const key = Math.floor(centers[p + 1] / span) * columns
344
+ + Math.floor(centers[p] / span);
345
+ const bucket = buckets.get(key);
346
+ if (bucket) bucket.push(i);
347
+ else buckets.set(key, [i]);
348
+ }
349
+ return { ids, centers, span, columns, buckets };
350
+ }
351
+
352
+ // -- membership ----------------------------------------------------------
353
+
354
+ /** Indices into the position arrays for the cells inside this shape. */
355
+ static membersIn(geometry, index) {
356
+ if (typeof RoiGeometry === "undefined" || !geometry || !index) return null;
357
+ const box = RoiGeometry.bounds(geometry);
358
+ if (!box) return [];
359
+ const { span, columns, buckets, centers } = index;
360
+ const firstColumn = Math.max(0, Math.floor(box.minX / span));
361
+ const lastColumn = Math.max(0, Math.floor(box.maxX / span));
362
+ const firstRow = Math.max(0, Math.floor(box.minY / span));
363
+ const lastRow = Math.max(0, Math.floor(box.maxY / span));
364
+
365
+ const found = [];
366
+ for (let row = firstRow; row <= lastRow; row += 1) {
367
+ for (let column = firstColumn; column <= lastColumn; column += 1) {
368
+ const bucket = buckets.get(row * columns + column);
369
+ if (!bucket) continue;
370
+ for (let b = 0; b < bucket.length; b += 1) {
371
+ const i = bucket[b];
372
+ const x = centers[i * 2];
373
+ const y = centers[i * 2 + 1];
374
+ // The bounding box first: four comparisons reject most of
375
+ // what a fringe bucket holds, and a ray cast over every
376
+ // ring is far more than four comparisons.
377
+ if (x < box.minX || x > box.maxX || y < box.minY || y > box.maxY) continue;
378
+ if (RoiGeometry.containsPoint(geometry, x, y)) found.push(i);
379
+ }
380
+ }
381
+ }
382
+ return found;
383
+ }
384
+
385
+ members(detail, index) {
386
+ const cached = this._members.get(detail.id);
387
+ // Geometry objects are REPLACED on every edit and never mutated, so
388
+ // object identity alone says whether this is still the same shape --
389
+ // the same trick RoiRenderer.pathFor uses to cache its Path2D objects,
390
+ // and the reason a reshaped region recomputes for free.
391
+ if (cached && cached.geometry === detail.geometry) return cached.indices;
392
+
393
+ const indices = CellExplorerRoiBridge.membersIn(detail.geometry, index);
394
+ if (!indices) return null;
395
+ this._members.delete(detail.id);
396
+ this._members.set(detail.id, { geometry: detail.geometry, indices });
397
+ while (this._members.size > CellExplorerRoiBridge.MEMBER_CACHE) {
398
+ this._members.delete(this._members.keys().next().value);
399
+ }
400
+ return indices;
401
+ }
402
+
403
+ // -- composition ---------------------------------------------------------
404
+
405
+ /**
406
+ * cell id -> category code for the column on screen, as a dense array.
407
+ *
408
+ * Dense rather than a Map because ids run from one to the cell count: four
409
+ * bytes each beats a hash entry each by an order of magnitude at a million
410
+ * cells, which is the same reasoning behind CellExplorerColors' own lookup
411
+ * table. -1 marks an id this column has no row for, which is NOT the same
412
+ * thing as Unassigned -- see tally().
413
+ */
414
+ codeById() {
415
+ const data = this.state.data;
416
+ if (!data || data.kind !== "categorical") return null;
417
+ // Keyed on the payload object, which select() replaces wholesale for
418
+ // every column, so there is nothing to invalidate by hand.
419
+ if (this._lookup?.source === data) return this._lookup;
420
+ const { ids, codes } = data;
421
+ let maxId = 0;
422
+ for (let i = 0; i < ids.length; i += 1) {
423
+ if (ids[i] > maxId) maxId = ids[i];
424
+ }
425
+ const table = new Int32Array(maxId + 1).fill(-1);
426
+ for (let i = 0; i < ids.length; i += 1) table[ids[i]] = codes[i];
427
+ this._lookup = { source: data, table };
428
+ return this._lookup;
429
+ }
430
+
431
+ /**
432
+ * Count members by category code.
433
+ *
434
+ * Two kinds of absence, kept apart. An id this column has no row for is not
435
+ * counted at all -- it is not part of this variable's population, and
436
+ * putting it in the total would make every percentage on the card wrong. A
437
+ * code past the end of the dictionary IS a value, the missing one, and is
438
+ * counted as Unassigned. The same distinction CellExplorerColors draws when
439
+ * it decides which cells get the unassigned swatch.
440
+ */
441
+ static tally(indices, cellIds, table, categoryCount) {
442
+ const counts = new Uint32Array(categoryCount);
443
+ let unassigned = 0;
444
+ let total = 0;
445
+ for (let n = 0; n < indices.length; n += 1) {
446
+ const id = cellIds[indices[n]];
447
+ const code = id < table.length ? table[id] : -1;
448
+ if (code < 0) continue;
449
+ total += 1;
450
+ if (code < categoryCount) counts[code] += 1;
451
+ else unassigned += 1;
452
+ }
453
+ return { counts, unassigned, total };
454
+ }
455
+
456
+ summarise(detail, index) {
457
+ const lookup = this.codeById();
458
+ const indices = this.members(detail, index);
459
+ if (!lookup || !indices) return null;
460
+
461
+ const column = this.state.column;
462
+ const categories = this.state.descriptor(column)?.categories || [];
463
+ const tally = CellExplorerRoiBridge.tally(
464
+ indices, index.ids, lookup.table, categories.length);
465
+ // The id-overlap check is asked of the whole population, before the
466
+ // legend takes anything out of it: hiding four categories is not
467
+ // evidence that the two id spaces disagree.
468
+ this.warnOnOverlap(column, indices.length, tally.total);
469
+
470
+ const shown = this.rows(column, categories, tally);
471
+ // Summed from what is on the card rather than taken from the tally, so
472
+ // the header, the bars and their percentages all count one population.
473
+ // A total that included hidden categories would leave every bar shorter
474
+ // than the share of the picture it stands for.
475
+ let total = 0;
476
+ for (const row of shown.rows) total += row.count;
477
+
478
+ return {
479
+ name: detail.name || "Region",
480
+ column,
481
+ total,
482
+ hiddenCategories: shown.hidden,
483
+ rows: shown.rows,
484
+ };
485
+ }
486
+
487
+ /**
488
+ * One row per VISIBLE category present in the region, carrying the exact
489
+ * colour Cell Explorer is drawing that category with right now.
490
+ *
491
+ * Colours are resolved fresh on every render rather than cached, in the same
492
+ * order the lookup table resolves them -- the user's override, else the
493
+ * palette colour for the category's position -- so recolouring a category
494
+ * from the legend shows up on the next render with nothing to invalidate.
495
+ *
496
+ * Hidden categories are left out entirely, counts and all. The legend's
497
+ * checkboxes are how somebody narrows the question they are asking of the
498
+ * slide: with the stroma hidden, the picture under the pointer is the
499
+ * immune cells, and a card that went on reporting the stroma would be
500
+ * describing something other than what is on screen. The count of what was
501
+ * dropped goes back with the rows so the card can say so rather than
502
+ * quietly shrink.
503
+ *
504
+ * @returns {{rows: Array, hidden: number}}
505
+ */
506
+ rows(column, categories, tally) {
507
+ const overrides = this.state.categorical(column).colors || {};
508
+ const hiddenLabels = this.state.hiddenSet(column);
509
+ const rows = [];
510
+ let hidden = 0;
511
+ for (let i = 0; i < categories.length; i += 1) {
512
+ const count = tally.counts[i];
513
+ if (!count) continue;
514
+ const label = categories[i].value;
515
+ if (hiddenLabels.has(label)) {
516
+ hidden += 1;
517
+ continue;
518
+ }
519
+ rows.push({
520
+ label,
521
+ count,
522
+ color: overrides[label] || CellExplorerColors.defaultCategoryColor(i),
523
+ });
524
+ }
525
+ if (tally.unassigned) {
526
+ const label = CellExplorerColors.UNASSIGNED_LABEL;
527
+ // Unassigned is a row of the legend like any other and can be
528
+ // hidden like any other -- see state.allLabels.
529
+ if (hiddenLabels.has(label)) {
530
+ hidden += 1;
531
+ } else {
532
+ rows.push({
533
+ label,
534
+ count: tally.unassigned,
535
+ color: overrides[label] || CellExplorerColors.UNASSIGNED,
536
+ });
537
+ }
538
+ }
539
+ return { rows, hidden };
540
+ }
541
+
542
+ /**
543
+ * Rank by abundance and fold the tail into one honest `Other`.
544
+ *
545
+ * Only categories actually present are ranked: a variable with forty labels
546
+ * of which six occur in this region should show six bars, not thirty-four
547
+ * empty ones. `Other` therefore appears only when the region really does
548
+ * hold more kinds of cell than the card is showing, and it carries the true
549
+ * combined count rather than a remainder inferred from the visible bars.
550
+ */
551
+ static rankCategories(rows, topN = CellExplorerRoiBridge.TOP_CATEGORIES) {
552
+ const ranked = [...rows].sort(
553
+ (a, b) => b.count - a.count || String(a.label).localeCompare(String(b.label)));
554
+ if (ranked.length <= topN + 1) return { rows: ranked, folded: 0 };
555
+
556
+ const shown = ranked.slice(0, topN);
557
+ const rest = ranked.slice(topN);
558
+ let count = 0;
559
+ for (const row of rest) count += row.count;
560
+ shown.push({
561
+ label: "Other",
562
+ count,
563
+ color: CellExplorerRoiBridge.OTHER_COLOR,
564
+ other: true,
565
+ });
566
+ return { rows: shown, folded: rest.length };
567
+ }
568
+
569
+ /** 987, 1.2k, 14.3k -- a count read at a glance beside a bar, not an exact
570
+ * figure. The exact one is in the bar's own tooltip. */
571
+ static formatCount(value) {
572
+ const n = Number(value) || 0;
573
+ if (n < 1000) return String(n);
574
+ if (n < 1e6) return `${CellExplorerRoiBridge.trimZero(n / 1000)}k`;
575
+ return `${CellExplorerRoiBridge.trimZero(n / 1e6)}m`;
576
+ }
577
+
578
+ static trimZero(value) {
579
+ const text = value.toFixed(1);
580
+ return text.endsWith(".0") ? text.slice(0, -2) : text;
581
+ }
582
+
583
+ /** The two id spaces -- core's /get_all_cells and this column's payload --
584
+ * come from one table and should agree. Said once per column when they do
585
+ * not, because counting a fraction of the cells looks exactly like a
586
+ * region that happens to be sparse. */
587
+ warnOnOverlap(column, candidates, matched) {
588
+ if (!candidates || this._warned.has(column)) return;
589
+ if (matched >= candidates * 0.5) return;
590
+ this._warned.add(column);
591
+ console.warn(`Cell Explorer: only ${matched} of ${candidates} cells inside `
592
+ + `this region have a value for "${column}". The cell ids from `
593
+ + "/get_all_cells and from the column payload may not agree.");
594
+ }
595
+
596
+ // -- the card ------------------------------------------------------------
597
+
598
+ ensureCard() {
599
+ if (this.card) return this.card;
600
+ const card = document.createElement("div");
601
+ card.className = "cex-roi-card";
602
+ card.hidden = true;
603
+
604
+ // Entering the card is the other half of the grace period: the pointer
605
+ // has left the region by definition, and closing on that would make the
606
+ // bars impossible to reach.
607
+ card.addEventListener("mouseenter", () => this.cancelHide());
608
+ card.addEventListener("mouseleave", () => this.close());
609
+
610
+ const head = document.createElement("div");
611
+ head.className = "cex-roi-card-head";
612
+ const name = document.createElement("span");
613
+ name.className = "cex-roi-card-name";
614
+ const total = document.createElement("span");
615
+ total.className = "cex-roi-card-total";
616
+ head.append(name, total);
617
+
618
+ const variable = document.createElement("div");
619
+ variable.className = "cex-roi-card-variable";
620
+
621
+ const bars = document.createElement("div");
622
+ bars.className = "cex-roi-card-bars";
623
+
624
+ const more = document.createElement("button");
625
+ more.type = "button";
626
+ more.className = "cex-roi-card-more";
627
+ more.hidden = true;
628
+ more.addEventListener("click", () => {
629
+ this.expanded = !this.expanded;
630
+ this.refreshOpenCard();
631
+ // Re-clamped, not re-anchored: the card grew, and the only thing
632
+ // that may have to change is how far down the screen it can start.
633
+ if (this.hover) this.place(this.hover.anchorRect, this.hover.viewportRect);
634
+ });
635
+
636
+ const tip = document.createElement("div");
637
+ tip.className = "cex-roi-card-tip";
638
+ tip.hidden = true;
639
+
640
+ card.append(head, variable, bars, more, tip);
641
+ // On <body> rather than in the viewer's subtree: the anchor arrives in
642
+ // client pixels, and nothing an ancestor does with overflow can then
643
+ // clip a card that sits near the edge of the image.
644
+ document.body.appendChild(card);
645
+ this.card = card;
646
+ return card;
647
+ }
648
+
649
+ render(summary) {
650
+ const card = this.ensureCard();
651
+ const ranked = this.expanded
652
+ ? CellExplorerRoiBridge.rankCategories(summary.rows, summary.rows.length)
653
+ : CellExplorerRoiBridge.rankCategories(summary.rows);
654
+
655
+ card.classList.remove("is-pending");
656
+ card.querySelector(".cex-roi-card-name").textContent = summary.name;
657
+ card.querySelector(".cex-roi-card-total").textContent =
658
+ `${CellExplorerRoiBridge.formatCount(summary.total)} cells`;
659
+ // Said out loud whenever the legend is filtering, because the counts on
660
+ // this card are of the shown categories only. Without it the card is
661
+ // indistinguishable from one describing a region that genuinely holds
662
+ // nothing else, which is a different fact about the slide.
663
+ card.querySelector(".cex-roi-card-variable").textContent = summary.hiddenCategories
664
+ ? `${summary.column || ""} · ${summary.hiddenCategories} hidden`
665
+ : (summary.column || "");
666
+
667
+ const bars = card.querySelector(".cex-roi-card-bars");
668
+ bars.textContent = "";
669
+ if (ranked.rows.length) {
670
+ for (const row of ranked.rows) bars.appendChild(this.buildBar(row, summary.total));
671
+ } else {
672
+ const empty = document.createElement("p");
673
+ empty.className = "cex-roi-card-empty";
674
+ empty.textContent = summary.hiddenCategories
675
+ ? "Every category in this region is hidden."
676
+ : "No cells in this region.";
677
+ bars.appendChild(empty);
678
+ }
679
+
680
+ const more = card.querySelector(".cex-roi-card-more");
681
+ more.hidden = !(this.expanded || ranked.folded > 0);
682
+ more.textContent = this.expanded ? "Collapse" : "Show all ›";
683
+ card.classList.toggle("is-expanded", this.expanded);
684
+ this.hideTooltip();
685
+ card.hidden = false;
686
+ }
687
+
688
+ /**
689
+ * The card before there is anything to put in it.
690
+ *
691
+ * Only ever seen once per session, while every cell's position is being
692
+ * fetched. It exists because the alternative is silence: the pointer enters
693
+ * a region, nothing appears, and the natural reading is that the hover did
694
+ * not register -- so the user moves on, and the card they were waiting for
695
+ * opens over a region they have already left.
696
+ */
697
+ renderPending(detail) {
698
+ const card = this.ensureCard();
699
+ card.classList.add("is-pending");
700
+ card.classList.remove("is-expanded");
701
+ card.querySelector(".cex-roi-card-name").textContent = detail.name || "Region";
702
+ card.querySelector(".cex-roi-card-total").textContent = "";
703
+ card.querySelector(".cex-roi-card-variable").textContent = this.state.column || "";
704
+
705
+ const bars = card.querySelector(".cex-roi-card-bars");
706
+ bars.textContent = "";
707
+ const waiting = document.createElement("p");
708
+ waiting.className = "cex-roi-card-empty";
709
+ waiting.textContent = "Counting cells…";
710
+ bars.appendChild(waiting);
711
+
712
+ card.querySelector(".cex-roi-card-more").hidden = true;
713
+ this.hideTooltip();
714
+ card.hidden = false;
715
+ }
716
+
717
+ buildBar(row, total) {
718
+ const percent = total ? (row.count / total) * 100 : 0;
719
+ const bar = document.createElement("div");
720
+ bar.className = "cex-roi-bar";
721
+ if (row.other) bar.classList.add("is-other");
722
+
723
+ const label = document.createElement("span");
724
+ label.className = "cex-roi-bar-label";
725
+ label.textContent = row.label;
726
+ bar.appendChild(label);
727
+
728
+ const track = document.createElement("div");
729
+ track.className = "cex-roi-bar-track";
730
+
731
+ const fill = document.createElement("div");
732
+ fill.className = "cex-roi-bar-fill";
733
+ // Against a fixed 0-100% track, never normalised to the largest
734
+ // category: a fortieth of the cells has to LOOK like a fortieth, or two
735
+ // regions cannot be compared by eye -- which is what the card is for.
736
+ fill.style.width = `${percent.toFixed(2)}%`;
737
+ fill.style.background = row.color;
738
+ track.appendChild(fill);
739
+
740
+ const count = document.createElement("span");
741
+ count.className = "cex-roi-bar-count";
742
+ count.textContent = CellExplorerRoiBridge.formatCount(row.count);
743
+ // Hugging the end of its own bar rather than lining up in a column down
744
+ // the right-hand side: the number belongs to that bar, and a numeric
745
+ // column pulls the eye away from the shape the ranking exists to show.
746
+ // Past the point where it would run off the end it moves inside.
747
+ if (percent >= CellExplorerRoiBridge.COUNT_INSIDE_PERCENT) {
748
+ count.classList.add("is-inside");
749
+ count.style.right = `calc(${(100 - percent).toFixed(2)}% + 6px)`;
750
+ } else {
751
+ count.style.left = `calc(${percent.toFixed(2)}% + 6px)`;
752
+ }
753
+ track.appendChild(count);
754
+ bar.appendChild(track);
755
+
756
+ // The exact figures are one hover away rather than printed on every
757
+ // row: six permanent percentages is a table, and the bars stop being
758
+ // readable as a shape.
759
+ const detail = `${row.label} / ${row.count.toLocaleString()} cells · `
760
+ + `${percent.toFixed(1)}%`;
761
+ bar.addEventListener("mouseenter", () => this.showTooltip(bar, detail));
762
+ bar.addEventListener("mouseleave", () => this.hideTooltip());
763
+ return bar;
764
+ }
765
+
766
+ showTooltip(bar, text) {
767
+ const tip = this.card?.querySelector(".cex-roi-card-tip");
768
+ if (!tip) return;
769
+ tip.textContent = text;
770
+ tip.hidden = false;
771
+ // Positioned against the card, which is its offset parent, so it
772
+ // travels with the card and needs no client-space arithmetic.
773
+ const above = bar.offsetTop - tip.offsetHeight - 4;
774
+ tip.style.top = `${above >= 0 ? above : bar.offsetTop + bar.offsetHeight + 4}px`;
775
+ }
776
+
777
+ hideTooltip() {
778
+ const tip = this.card?.querySelector(".cex-roi-card-tip");
779
+ if (tip) tip.hidden = true;
780
+ }
781
+
782
+ /**
783
+ * Put the card beside the region, once.
784
+ *
785
+ * Computed when the region is entered and left alone afterwards. A card
786
+ * that followed the pointer would be unreadable inside a large region, and
787
+ * would slide away from the bar somebody is reaching for. It flips to the
788
+ * other side, or slides up, only as far as it must to stay inside the
789
+ * image -- the bounds come from the ROI plugin along with the anchor, so
790
+ * "inside" means inside the picture rather than inside the window.
791
+ */
792
+ place(anchorRect, viewportRect) {
793
+ const card = this.card;
794
+ if (!card || !anchorRect) return;
795
+ const pad = CellExplorerRoiBridge.EDGE_PAD;
796
+ const gap = CellExplorerRoiBridge.ANCHOR_GAP;
797
+ const bounds = viewportRect || {
798
+ left: 0,
799
+ top: 0,
800
+ right: window.innerWidth,
801
+ bottom: window.innerHeight,
802
+ };
803
+ const box = card.getBoundingClientRect();
804
+
805
+ let left = anchorRect.right + gap;
806
+ if (left + box.width > bounds.right - pad) left = anchorRect.left - gap - box.width;
807
+ if (left < bounds.left + pad) {
808
+ // Neither side fits, which is a region wider than the space around
809
+ // it. Sit over it instead, still inside the picture.
810
+ left = Math.min(
811
+ Math.max(anchorRect.left + gap, bounds.left + pad),
812
+ Math.max(bounds.right - box.width - pad, bounds.left + pad));
813
+ }
814
+
815
+ let top = anchorRect.top;
816
+ if (top + box.height > bounds.bottom - pad) top = bounds.bottom - pad - box.height;
817
+ if (top < bounds.top + pad) top = bounds.top + pad;
818
+
819
+ card.style.left = `${Math.round(left)}px`;
820
+ card.style.top = `${Math.round(top)}px`;
821
+ }
822
+ }