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,3306 @@
1
+ /**
2
+ * viewer.js.
3
+ *
4
+ * @class ImageViewer to render multiplexed imaging data (based on OpenSeadragon)
5
+ */
6
+
7
+ /* todo
8
+ 1. major - the viewer managers should not be looking up the same renderTF
9
+ */
10
+
11
+ // Small standalone helper (not a class method) -- used by drawLegendVector's
12
+ // PDF export path to turn a channel's stored #rrggbb colorHex into the r/g/b
13
+ // triplet jsPDF's setFillColor() takes.
14
+ function hexToRgb(hex) {
15
+ const match = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex || "");
16
+ if (!match) return { r: 255, g: 255, b: 255 };
17
+ return {
18
+ r: parseInt(match[1], 16),
19
+ g: parseInt(match[2], 16),
20
+ b: parseInt(match[3], 16),
21
+ };
22
+ }
23
+
24
+ /**
25
+ * Byte-budgeted LRU of WebGL tile textures.
26
+ *
27
+ * Replaces a 24-entry round-robin slot table that could never register a hit:
28
+ * it tracked labels but handed every slot the SAME single WebGLTexture (one
29
+ * gl.createTexture() in GLRenderer), and its "is it already resident" check
30
+ * only compared against the most recently bound slot. The net effect was a
31
+ * full gl.texImage2D re-upload of every visible tile of every channel on every
32
+ * frame -- 1 MB per u8 1024x1024 tile, so tens of MB per frame at 7+ channels,
33
+ * which on its own is enough to stop a pan from being smooth.
34
+ *
35
+ * Keys must include the pixel format, not just tile identity: the source's
36
+ * getTileKey() does not distinguish the HD (16-bit) variant from the default
37
+ * 8-bit one, so a format-blind key could serve a u8 texture to a u16 draw.
38
+ */
39
+ class GLTileTextureCache {
40
+ constructor(gl, byteBudget) {
41
+ this.gl = gl;
42
+ this.byteBudget = byteBudget;
43
+ this.entries = new Map(); // insertion order == LRU order
44
+ this.bytes = 0;
45
+ }
46
+
47
+ /**
48
+ * @returns {{texture: WebGLTexture, resident: boolean}} `resident` is true
49
+ * when the texture already holds this key's pixels, so the caller can skip
50
+ * the upload entirely.
51
+ */
52
+ acquire(key, byteLength) {
53
+ const existing = this.entries.get(key);
54
+ if (existing) {
55
+ // Re-insert to move this entry to the most-recently-used end.
56
+ this.entries.delete(key);
57
+ this.entries.set(key, existing);
58
+ return { texture: existing.texture, resident: true };
59
+ }
60
+ while (this.bytes + byteLength > this.byteBudget && this.entries.size > 0) {
61
+ const [oldestKey, oldest] = this.entries.entries().next().value;
62
+ this.entries.delete(oldestKey);
63
+ this.bytes -= oldest.byteLength;
64
+ this.gl.deleteTexture(oldest.texture);
65
+ }
66
+ const texture = this.gl.createTexture();
67
+ this.entries.set(key, { texture, byteLength });
68
+ this.bytes += byteLength;
69
+ return { texture, resident: false };
70
+ }
71
+
72
+ clear() {
73
+ for (const entry of this.entries.values()) {
74
+ this.gl.deleteTexture(entry.texture);
75
+ }
76
+ this.entries.clear();
77
+ this.bytes = 0;
78
+ }
79
+ }
80
+
81
+
82
+ /**
83
+ * Round-robin pool of tile-decode workers (see workers/tileDecoder.js).
84
+ *
85
+ * Decoding is per tile per channel, so the work scales with channel count: a
86
+ * CPU profile put it at ~2% of a 7-channel pan but ~60% of a 15-channel one.
87
+ * Spreading it across workers both takes it off the main thread and lets
88
+ * several tiles decode in parallel.
89
+ *
90
+ * Falls back to null from create() if Worker/OffscreenCanvas are unavailable,
91
+ * and callers then decode inline -- the inline path has to exist anyway for
92
+ * the HD and segmentation formats.
93
+ */
94
+ class TileDecoderPool {
95
+ static create(url, size) {
96
+ if (typeof Worker === "undefined" || typeof OffscreenCanvas === "undefined") {
97
+ return null;
98
+ }
99
+ try {
100
+ return new TileDecoderPool(url, size);
101
+ } catch (err) {
102
+ console.warn("Tile decode workers unavailable, decoding inline:", err);
103
+ return null;
104
+ }
105
+ }
106
+
107
+ constructor(url, size) {
108
+ this.pending = new Map();
109
+ this.nextId = 0;
110
+ this.next = 0;
111
+ this.workers = [];
112
+ for (let i = 0; i < size; i++) {
113
+ const worker = new Worker(url);
114
+ worker.onmessage = (event) => {
115
+ const { id, ok, array, width, height, error, unsupported } = event.data;
116
+ const entry = this.pending.get(id);
117
+ if (!entry) {
118
+ return;
119
+ }
120
+ this.pending.delete(id);
121
+ if (ok) {
122
+ entry.resolve({ array, width, height });
123
+ } else {
124
+ const err = new Error(error);
125
+ // Distinguishes "this worker cannot handle this input" from
126
+ // "the worker broke": the caller falls back rather than
127
+ // treating it as a failure.
128
+ err.unsupported = !!unsupported;
129
+ entry.reject(err);
130
+ }
131
+ };
132
+ worker.onerror = (err) => {
133
+ // A worker-level failure orphans everything queued on it, so
134
+ // reject all outstanding work rather than leaving tiles hung on
135
+ // promises OSD is awaiting.
136
+ for (const [id, entry] of this.pending) {
137
+ entry.reject(err instanceof Error ? err : new Error(String(err && err.message)));
138
+ this.pending.delete(id);
139
+ }
140
+ };
141
+ this.workers.push(worker);
142
+ }
143
+ }
144
+
145
+ /**
146
+ * @param buffer - the raw tile bytes as delivered by the server
147
+ * @param kind - "webp" (default 8-bit path) or "gray16png" (HD path)
148
+ * @returns Promise<{array: Uint8Array, width: number, height: number}>
149
+ */
150
+ decode(buffer, kind) {
151
+ const id = this.nextId++;
152
+ const worker = this.workers[this.next];
153
+ this.next = (this.next + 1) % this.workers.length;
154
+ return new Promise((resolve, reject) => {
155
+ this.pending.set(id, { resolve, reject });
156
+ // `buffer` is deliberately copied, not transferred: it belongs to
157
+ // OpenSeadragon's XHR response and detaching it here would corrupt
158
+ // whatever OSD does with the response afterwards. The decoded
159
+ // result, which is the large one, IS transferred back.
160
+ worker.postMessage({ id, buffer, kind });
161
+ });
162
+ }
163
+
164
+ terminate() {
165
+ this.workers.forEach((w) => w.terminate());
166
+ this.workers = [];
167
+ this.pending.clear();
168
+ }
169
+ }
170
+
171
+
172
+ class ImageViewer {
173
+ // Vars
174
+ viewerManagers = [];
175
+
176
+ //: Filled masks hide the tissue underneath at full strength, and outlines
177
+ //: read poorly at low strength. One control has to serve both, so this is a
178
+ //: compromise leaning towards keeping the image visible.
179
+ static DEFAULT_CELL_LAYER_OPACITY = 0.7;
180
+
181
+ //: The modes drawn from the label tiles, and therefore the ones the sidebar
182
+ //: order restacks. Centroids are drawn on the overlay canvas instead, which
183
+ //: is above every tile whatever the order says -- see centroidDrawList.
184
+ static MASK_MODES = ["outlines", "filled"];
185
+
186
+ //: The stand-in layer used while no plugin has registered one. Named so a
187
+ //: per-tile canvas keyed by it can never collide with a plugin's name.
188
+ static CORE_LAYER = "__core__";
189
+
190
+ //: How big centroid dots are drawn, as a multiplier on the zoom-adaptive
191
+ //: size. A multiplier rather than a size: the adaptive part is what keeps
192
+ //: points visible when the whole slide is on screen and from swamping the
193
+ //: tissue at full zoom, and a fixed size chosen at one zoom is wrong at the
194
+ //: other. Bounds are generous because cell density varies by orders of
195
+ //: magnitude between a tissue microarray core and a whole section.
196
+ static DEFAULT_CENTROID_SCALE = 1;
197
+ static MIN_CENTROID_SCALE = 0.4;
198
+ static MAX_CENTROID_SCALE = 3;
199
+
200
+ /**
201
+ * Constructor for ImageViewer.
202
+ *
203
+ * @param config - the cinfiguration file (json)
204
+ * @param imgMetadata - image metadata from ome
205
+ * @param numericData - custom numeric data layer
206
+ * @param eventHandler - the event handler for distributing interface and data updates
207
+ */
208
+ constructor(config, dataLayer, imgMetadata, numericData, eventHandler) {
209
+ this.ready = false;
210
+ this.config = config;
211
+ this.dataLayer = dataLayer;
212
+ // Every plugin currently colouring cells, keyed by name. Each entry is
213
+ // one LAYER -- see registerCellLayer for the record's shape -- and
214
+ // several may be live at once, which is what lets a phenotype map and a
215
+ // gate be looked at together rather than one replacing the other.
216
+ //
217
+ // Order is held separately because it is the z-order the layers
218
+ // composite in (bottom first) and the user sets it by dragging cards in
219
+ // the sidebar; membership and stacking change independently.
220
+ this._cellLayers = new Map();
221
+ this._cellLayerOrder = [];
222
+ // Which layer the shared controls, picking and gating act on. Exactly
223
+ // one, or none: "visible" is a property of every layer, "active" is a
224
+ // property of the session, and conflating the two is what made opening a
225
+ // second tool silently take the first one's colours away.
226
+ this._activeCellLayer = null;
227
+ // Which of the three representations the cell layer draws when NO plugin
228
+ // has registered one. Core's own mode, kept so a plain viewer -- or one
229
+ // running only Thresholding before it registers -- draws exactly what it
230
+ // always did. Once layers exist, each carries its own mode.
231
+ this.cellDisplayMode = "outlines";
232
+ // The record coreLayerView() hands the renderers. One object, refreshed
233
+ // in place, because it is read once per label tile per frame.
234
+ this._coreLayerView = {
235
+ name: ImageViewer.CORE_LAYER,
236
+ provider: null,
237
+ lut: null,
238
+ mode: this.cellDisplayMode,
239
+ userMode: null,
240
+ supportedModes: null,
241
+ opacity: 1,
242
+ visible: true,
243
+ filterIds: null,
244
+ filterRequest: 0,
245
+ styleCache: new Map(),
246
+ };
247
+ // Centroid dot size, as a multiplier -- see setCentroidPointScale.
248
+ this.centroidPointScale = ImageViewer.DEFAULT_CENTROID_SCALE;
249
+ this.channelList = null;
250
+ this.imgMetadata = imgMetadata;
251
+ this.numericData = numericData;
252
+ this.eventHandler = eventHandler;
253
+ this.pickingChanged = false;
254
+ this._cacheKeys = {};
255
+ this._picking = [];
256
+ this.pickedId = -1;
257
+ this.glReady = new Promise((resolve) => {
258
+ this.resolveGLReady = resolve;
259
+ });
260
+
261
+ this.centers = [];
262
+ this.ids = [];
263
+ this.centroidManifest = null;
264
+ this.centroidTiles = new Map();
265
+ this.centroidFilter = {};
266
+ this.centroidFilterSignature = "{}";
267
+ this.centroidTileTimer = null;
268
+ this.centroidTileRequest = 0;
269
+ this.centroidFirstLoad = true;
270
+ this.centroidMode = "tiled";
271
+ this.fullResolutionCenters = [];
272
+ this.idToCenterOffset = new Map();
273
+ this.centroidIdSet = null;
274
+ this.centroidsReady = false;
275
+ this.centroidsLoading = null;
276
+ this.segmentationReady = false;
277
+ this.segmentationLoading = null;
278
+ // Core's own gate -- the one that applies when no plugin has registered
279
+ // a layer. A registered layer carries its own `filterIds` instead, so
280
+ // one tool's gate cannot subtract cells from another tool's colours.
281
+ this.segmentationFilterIds = null;
282
+ this.segmentationFilterRequest = 0;
283
+
284
+ // Viewer
285
+ this.viewer = {};
286
+
287
+ // OSD plugins
288
+ this.show_scalebar = true;
289
+ this.show_centroids = false;
290
+
291
+ // Transfer function constant
292
+ this.numTFBins = 1024;
293
+
294
+ // Transfer function per channel (min,max, start color, end color)
295
+ this.channelTF = [];
296
+
297
+ for (let i = 0; i < this.config["imageData"].length; i = i + 1) {
298
+ const start_color = d3.rgb(0, 0, 0);
299
+ const end_color = d3.rgb(255, 255, 255);
300
+
301
+ const tf_def = this.createTFArray(0, 65535, start_color, end_color, this.numTFBins);
302
+ tf_def.name = this.config["imageData"][i].name;
303
+
304
+ this.channelTF.push(tf_def);
305
+ }
306
+
307
+ // Applying TF to selection, subset, or all
308
+ this.show_subset = false;
309
+ this.show_selection = true;
310
+
311
+ // Hide Loader
312
+ this.setLoading(false);
313
+
314
+ // Config viewer
315
+ const viewer_config = {
316
+ id: "openseadragon",
317
+ prefixUrl: plexoraUrl("client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/"),
318
+ minZoomImageRatio: 0.1,
319
+ maxZoomPixelRatio: 15,
320
+ compositeOperation: "lighter",
321
+ loadTilesWithAjax: true,
322
+ immediateRender: false,
323
+ // OpenSeadragon 6 creates ONE TileCache on the viewer and hands the
324
+ // same instance to every TiledImage, so this budget has to cover
325
+ // (visible tiles x active channels), not just visible tiles. At
326
+ // 1024^2 tiles the old value of 100 was below a single viewport's
327
+ // worth once ~8 channels were on, so OSD kept evicting tiles it was
328
+ // about to redraw -- refetch and redecode mid-pan, which is the
329
+ // classic tile-popping stutter. Each cached tile also pins a ~1 MB
330
+ // decoded Uint8Array, so this doubles as the memory ceiling.
331
+ maxImageCacheCount: Math.min(512, Math.max(200, ((config["imageData"] || []).length) * 40)),
332
+ // Left at OSD's default of 0 (unlimited), OSD opens every queued
333
+ // tile request at once; the browser then serves them ~6 at a time
334
+ // per origin in issue order, so tiles for where the viewport USED to
335
+ // be block the ones where it is now. Capping the in-flight set keeps
336
+ // OSD's own ordering in charge.
337
+ imageLoaderLimit: 10,
338
+ timeout: 90000,
339
+ collectionMode: false,
340
+ preload: false,
341
+ homeFillsViewer: true,
342
+ visibilityRatio: 0,
343
+ // Force the canvas drawer: our per-tile WebGL colorize pass needs
344
+ // the 'tile-drawing' event's 2D `rendered` context, which is only
345
+ // guaranteed under the canvas drawer (OSD 6's WebGL drawer has no
346
+ // documented custom-shader hook as of this writing).
347
+ drawer: "canvas",
348
+ };
349
+
350
+ // Instantiate the real OpenSeadragon viewer
351
+ this.viewer = OpenSeadragon(viewer_config);
352
+ // Lets the navbar status indicator report tiles that are still
353
+ // streaming in -- see appStatus.js watchViewer(), which tracks each
354
+ // TiledImage rather than the viewer's own aggregate.
355
+ window.PlexoraStatus?.watchViewer(this.viewer);
356
+ this.initProjectLabel();
357
+ this.initLegend();
358
+ this.initMiniMap();
359
+ this.addScaleBar();
360
+ this.selectionPolygonToDraw = [];
361
+
362
+ // OSD's own full-page button only resizes the #openseadragon element
363
+ // itself (it reparents that element to <body>), leaving the sidebar
364
+ // behind. Redirect it to a native Fullscreen API toggle on the whole
365
+ // app shell (sidebar + viewer) instead.
366
+ this.viewer.addHandler("pre-full-page", (event) => {
367
+ event.preventDefaultAction = true;
368
+ const shell = document.getElementById("bodyDiv");
369
+ if (!shell) {
370
+ return;
371
+ }
372
+ if (document.fullscreenElement) {
373
+ document.exitFullscreen();
374
+ } else {
375
+ shell.requestFullscreen();
376
+ }
377
+ });
378
+
379
+ // Get and shrink all button images
380
+ this.parent = d3.select(`#openseadragon`);
381
+ this.parent.selectAll('img')
382
+ .attr('height', 40);
383
+
384
+ // Force controls to bottom right
385
+ const controlsAnchor = this.parent.select('img').node().parentElement.parentElement.parentElement.parentElement;
386
+ controlsAnchor.style.right = 'unset';
387
+ controlsAnchor.style.top = 'unset';
388
+ controlsAnchor.style.left = '40vh';
389
+ controlsAnchor.style.bottom = '2vh';
390
+
391
+ // Flexible use of textures. Marker and constant textures occupy fixed
392
+ // texture units at the top of the range; tile textures do NOT get a
393
+ // unit each -- the fragment shader's u_tile sampler is pinned to unit 0
394
+ // once in GLRenderer.toBuffers(), so the tile being drawn is always
395
+ // bound to unit 0 and the cache below decides which texture object that
396
+ // is.
397
+ const constantTextures = ["ids", "centers", "ranges", "pickings"];
398
+ const otherOffset = 32 - constantTextures.length;
399
+ const renderer = new GLRenderer();
400
+ const nMarkers = 4;
401
+ const markerOffset = otherOffset - nMarkers;
402
+ const markerTextureKeys = [...Array(nMarkers).keys()];
403
+ renderer._otherOffset = otherOffset;
404
+ renderer._markerOffset = markerOffset;
405
+ renderer._markerTextures = markerTextureKeys.map(() => "");
406
+ renderer._constantTextures = constantTextures;
407
+ renderer._activeMarkerTexture = 0;
408
+ renderer._nextMarkerTexture = 0;
409
+ // 384 MB holds ~384 u8 1024x1024 tiles, comfortably more than the
410
+ // visible tiles x active channels a viewport can ask for (7+ channels x
411
+ // ~6 tiles), so panning back over recent ground re-binds instead of
412
+ // re-uploading. QuPath's equivalent tile cache is a similar fraction of
413
+ // available memory.
414
+ renderer._tileTextureCache = new GLTileTextureCache(renderer.gl, 384 * 1024 * 1024);
415
+ this.glRenderer = renderer;
416
+
417
+ const indexOfTexture = this.indexOfTexture.bind(this);
418
+ const selectTexture = this.selectTexture.bind(this);
419
+ const resolveGLReady = this.resolveGLReady;
420
+
421
+ renderer.loadArray = function (e, w, h) {
422
+ // Allow for custom drawing in webGL
423
+ var gl = this.gl;
424
+ const { source } = e.tiledImage;
425
+ const tileArgs = [e.tile.level, e.tile.x, e.tile.y];
426
+ const format = e.tile._format || `u${source.format}`;
427
+ const okFormat = ["u16", "u32", "u8"].includes(format);
428
+ const pixels = e.tile._array;
429
+
430
+ // Clear before starting all the draw calls
431
+ gl.clearColor(0, 0, 0, 0);
432
+ gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
433
+
434
+ if (okFormat) {
435
+ // Format is part of the key because getTileKey() does not
436
+ // distinguish the HD 16-bit variant of a tile from the default
437
+ // 8-bit one -- without it a cache hit could bind u8 pixels to a
438
+ // draw that reads them as u16.
439
+ const cacheKey = `${source.getTileKey(...tileArgs)}|${format}`;
440
+ const { texture, resident } = this._tileTextureCache.acquire(
441
+ cacheKey, pixels ? pixels.byteLength : 0);
442
+ if (resident) {
443
+ // Already on the GPU: bind it and skip the upload. This is
444
+ // the whole point of the cache -- it turns a per-tile,
445
+ // per-frame megabyte transfer into a bind.
446
+ gl.activeTexture(gl.TEXTURE0);
447
+ gl.bindTexture(gl.TEXTURE_2D, texture);
448
+ } else {
449
+ selectTexture(gl, texture, 0);
450
+ const textureArgs = {
451
+ u16: [gl.RG8UI, w, h, 0, gl.RG_INTEGER],
452
+ u32: [gl.RGBA8UI, w, h, 0, gl.RGBA_INTEGER],
453
+ u8: [gl.R8UI, w, h, 0, gl.RED_INTEGER],
454
+ }[format];
455
+
456
+ // Send the tile into the texture.
457
+ gl.texImage2D(gl.TEXTURE_2D, 0, ...textureArgs, gl.UNSIGNED_BYTE, pixels);
458
+ }
459
+ }
460
+
461
+ this.gl_arguments.tile_shape_2fv = new Float32Array([w, h]);
462
+
463
+ // Call gl-drawing after loading
464
+ this["gl-drawing"].call(this);
465
+ gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
466
+ return gl.canvas;
467
+ };
468
+
469
+ renderer.vShader = plexoraUrl("client/src/shaders/vert.glsl");
470
+ renderer.fShader = plexoraUrl("client/src/shaders/frag.glsl");
471
+
472
+ // Default tile-drawing behavior, invoked as the "callback" from the
473
+ // custom handler below.
474
+ function tileDrawingDefault(e) {
475
+ var w = e.rendered.canvas.width;
476
+ var h = e.rendered.canvas.height;
477
+ var gl_w = renderer.width;
478
+ var gl_h = renderer.height;
479
+
480
+ // Render a webGL canvas to an input canvas
481
+ var output = renderer.loadArray(e, w, h);
482
+ e.rendered.drawImage(output, 0, 0, gl_w, gl_h, 0, 0, w, h);
483
+ }
484
+
485
+ const { floatRange } = this.numericData;
486
+ const findCurrentChannel = this.findCurrentChannel.bind(this);
487
+ const selectCenterProps = this.selectCenterProps.bind(this);
488
+ const labelOutlinesEnabled = () => !!this.viewerManagerVMain?.sel_outlines;
489
+ const modeFlags = () => this.modeFlags;
490
+ // Custom tile-drawing handler
491
+ const tileDrawingCustom = async (callback, e) => {
492
+ // Read parameters from each tile
493
+ const { source } = e.tiledImage;
494
+ const { tileFormat } = source;
495
+ const w = e.rendered.canvas.width;
496
+ const h = e.rendered.canvas.height;
497
+
498
+ if (tileFormat != 32) {
499
+ // A tile's URL never changes, so derive sub_url once and keep it
500
+ // on the tile. This was a getUrl() + String.split() allocation
501
+ // per tile per channel per frame.
502
+ let sub_url = e.tile._subUrl;
503
+ if (sub_url === undefined) {
504
+ const group = e.tile.getUrl().split("/");
505
+ sub_url = group[group.length - 3];
506
+ e.tile._subUrl = sub_url;
507
+ }
508
+ const channel = findCurrentChannel(sub_url);
509
+ const range = _.get(channel, "range", floatRange);
510
+ const color = _.get(channel, "color", d3.color("white"));
511
+ const floatColor = toFloatColor(color);
512
+ // The fast/default tile path quantizes 16-bit -> 8-bit
513
+ // server-side, linear against the channel's true max (see
514
+ // get_channel_quantization_window). The shader works directly
515
+ // in that same [0, 255] byte domain -- u_tile_range is
516
+ // expressed in byte units too in this mode (see
517
+ // viewerSidebar.js's getImageRange/toImageConnectorRange),
518
+ // so no reconstruction back into 16-bit units is needed here.
519
+ const tileFmt = e.tile._format === "u8" ? 8 : 16;
520
+ const modes = modeFlags();
521
+
522
+ // `e.rendered` is this tile's OWN persistent 2D context -- OSD
523
+ // resolves it per tile via DrawerBase.getDataToDraw() (the tile
524
+ // cache) and blits it onto the canvas right after this handler
525
+ // returns. So when it already holds exactly this colorization
526
+ // there is nothing to do.
527
+ //
528
+ // This early return is the single biggest win available on the
529
+ // client. A CPU profile of a 7-channel pan attributed 81.9% of
530
+ // all wall time to one call: the WebGL-canvas -> 2D-canvas blit
531
+ // at the end of this path, running ~103 times per frame at
532
+ // ~2.3 ms each, because OSD re-raises tile-drawing for every
533
+ // visible tile of every channel on every frame. The pixels were
534
+ // almost always identical to the previous frame's.
535
+ //
536
+ // The signature covers everything the draw below depends on:
537
+ // tile identity (cacheKey also changes when HD swaps the pixel
538
+ // data), the pixel format, and the channel's colour/range/mode.
539
+ const sig = `${e.tile.cacheKey}|${tileFmt}|${floatColor}|${range}|${modes.edge},${modes.or}`;
540
+ if (e.rendered._plexoraSig === sig) {
541
+ return;
542
+ }
543
+
544
+ if (!e.tile._array) {
545
+ // Not loaded yet -- e.g. right after the HD toggle forces
546
+ // every visible tile to redraw immediately, before the
547
+ // freshly-invalidated tile's fetch/decode has finished.
548
+ // Falling through with pixels=undefined would still reach
549
+ // gl.texImage2D, which allocates the texture with whatever
550
+ // GPU memory happened to be there -- rendered as solid
551
+ // static instead of skipping this frame.
552
+ e.rendered._plexoraSig = null;
553
+ e.rendered.fillStyle = "black";
554
+ e.rendered.fillRect(0, 0, w, h);
555
+ console.warn("Missing Array for tile:", e.tile.getUrl(), "- skipping rendering");
556
+ return;
557
+ }
558
+
559
+ // The black fill is load-bearing, not redundant: the shader
560
+ // emits alpha 0.9, so the GL output composites over whatever is
561
+ // already in this reused canvas.
562
+ e.rendered.fillStyle = "black";
563
+ e.rendered.fillRect(0, 0, w, h);
564
+
565
+ // Store channel color and range to send to shader
566
+ renderer.gl_arguments = {
567
+ ...selectCenterProps(e.tile, source),
568
+ centers: [],
569
+ id_end_1i: 0,
570
+ picked_end_1i: 0,
571
+ color_3fv: new Float32Array(floatColor),
572
+ range_2fv: new Float32Array(range),
573
+ fmt_1i: tileFmt,
574
+ };
575
+ callback(e);
576
+ e.rendered._plexoraSig = sig;
577
+ return;
578
+ }
579
+
580
+ // Label/segmentation tiles must stay transparent outside outlines
581
+ // so image channels underneath remain visible. Not signature-cached:
582
+ // the label tiled image is a single item (so this is not multiplied
583
+ // by the channel count), and what it draws also depends on each
584
+ // layer's gate and colours, which rerenderSegmentationTiles()
585
+ // rebuilds out of band.
586
+ e.rendered.clearRect(0, 0, w, h);
587
+
588
+ if (e.tile._layerContexts) {
589
+ if (labelOutlinesEnabled()) {
590
+ // BLIT ORDER IS Z-ORDER, and it is the whole of the stacking
591
+ // model for mask layers: bottom first, each one painting over
592
+ // whatever the layers below it left. Reordering the sidebar
593
+ // therefore costs a redraw and never a re-render -- the tile
594
+ // canvases already hold the right pixels.
595
+ //
596
+ // Opacity is applied HERE rather than baked into a layer's
597
+ // pixels for the same reason. This branch already re-runs
598
+ // every frame, so a slider drag costs one extra blit
599
+ // argument; folding it into renderLabelTile would instead
600
+ // re-derive every visible tile's boundaries on every pointer
601
+ // move.
602
+ const previous = e.rendered.globalAlpha;
603
+ let alpha = previous;
604
+ for (const layer of this.maskDrawList()) {
605
+ const context = e.tile._layerContexts.get(layer.name);
606
+ if (!context) continue;
607
+ // A layer with no colours is the plain white cell layer,
608
+ // which predates the opacity control and must keep
609
+ // compositing at full strength -- see layerAlpha.
610
+ const next = layer.lut ? layer.opacity : 1;
611
+ if (next !== alpha) {
612
+ e.rendered.globalAlpha = next;
613
+ alpha = next;
614
+ }
615
+ e.rendered.drawImage(context.canvas, 0, 0, w, h);
616
+ }
617
+ if (alpha !== previous) e.rendered.globalAlpha = previous;
618
+ }
619
+ return;
620
+ }
621
+
622
+ if (!e.tile._array) {
623
+ console.warn("Missing Array for tile:", e.tile.getUrl(), "- skipping rendering");
624
+ return;
625
+ }
626
+
627
+ // Use new parameters for this tile
628
+ renderer.gl_arguments = {
629
+ ...selectCenterProps(e.tile, source),
630
+ color_3fv: new Float32Array([1, 1, 1]),
631
+ range_2fv: new Float32Array([0, 1]),
632
+ fmt_1i: 32,
633
+ };
634
+
635
+ // Start webGL rendering
636
+ callback(e);
637
+ };
638
+
639
+ renderer["gl-drawing"] = function () {
640
+ const args = this.gl_arguments;
641
+
642
+ // Send color and range to shader
643
+ this.gl.uniform2fv(this.u_tile_shape, args.tile_shape_2fv);
644
+ this.gl.uniform4iv(this.u_marker_sample, args.marker_sample_4iv);
645
+ this.gl.uniform2iv(this.u_magnitude_shape, args.magnitude_2iv);
646
+ this.gl.uniform1f(this.u_tile_fraction, args.tile_fraction_1f);
647
+ this.gl.uniform1f(this.u_tile_scale, args.tile_scale_1f);
648
+ this.gl.uniform1f(this.u_pie_radius, args.pie_radius_1f);
649
+ this.gl.uniform1i(this.u_picked_end, args.picked_end_1i);
650
+ this.gl.uniform2fv(this.u_tile_origin, args.origin_2fv);
651
+ this.gl.uniform3fv(this.u_tile_color, args.color_3fv);
652
+ this.gl.uniform2fv(this.u_tile_range, args.range_2fv);
653
+ this.gl.uniform2iv(this.u_draw_mode, args.modes_2i);
654
+ this.gl.uniform2fv(this.u_x_bounds, args.x_bounds_2fv);
655
+ this.gl.uniform2fv(this.u_y_bounds, args.y_bounds_2fv);
656
+ this.gl.uniform1i(this.u_tile_fmt, args.fmt_1i);
657
+ this.gl.uniform1i(this.u_id_end, args.id_end_1i);
658
+ };
659
+
660
+ renderer["gl-loaded"] = function (program) {
661
+ // Uniform variables for coloring
662
+ this.u_ids_shape = this.gl.getUniformLocation(program, "u_ids_shape");
663
+ this.u_tile_shape = this.gl.getUniformLocation(program, "u_tile_shape");
664
+ this.u_cell_range_shape = this.gl.getUniformLocation(program, "u_cell_range_shape");
665
+ this.u_center_shape = this.gl.getUniformLocation(program, "u_center_shape");
666
+ this.u_picking_shape = this.gl.getUniformLocation(program, "u_picking_shape");
667
+ this.u_marker_sample = this.gl.getUniformLocation(program, "u_marker_sample");
668
+ this.u_magnitude_shape = this.gl.getUniformLocation(program, "u_magnitude_shape");
669
+ this.u_tile_fraction = this.gl.getUniformLocation(program, "u_tile_fraction");
670
+ this.u_tile_scale = this.gl.getUniformLocation(program, "u_tile_scale");
671
+ this.u_pie_radius = this.gl.getUniformLocation(program, "u_pie_radius");
672
+ this.u_tile_origin = this.gl.getUniformLocation(program, "u_tile_origin");
673
+ this.u_tile_range = this.gl.getUniformLocation(program, "u_tile_range");
674
+ this.u_tile_color = this.gl.getUniformLocation(program, "u_tile_color");
675
+ this.u_draw_mode = this.gl.getUniformLocation(program, "u_draw_mode");
676
+ this.u_x_bounds = this.gl.getUniformLocation(program, "u_x_bounds");
677
+ this.u_y_bounds = this.gl.getUniformLocation(program, "u_y_bounds");
678
+ this.u_tile_fmt = this.gl.getUniformLocation(program, "u_tile_fmt");
679
+ this.u_picked_end = this.gl.getUniformLocation(program, "u_picked_end");
680
+ this.u_id_end = this.gl.getUniformLocation(program, "u_id_end");
681
+
682
+ // Texture for colormap
683
+ const u_ids = this.gl.getUniformLocation(program, "u_ids");
684
+ const u_cell_ranges = this.gl.getUniformLocation(program, "u_cell_ranges");
685
+ const u_centers = this.gl.getUniformLocation(program, "u_centers");
686
+ const u_pickings = this.gl.getUniformLocation(program, "u_pickings");
687
+ this.gl.uniform1i(u_ids, indexOfTexture("ids", null));
688
+ this.gl.uniform1i(u_cell_ranges, indexOfTexture("ranges", null));
689
+ this.gl.uniform1i(u_centers, indexOfTexture("centers", null));
690
+ this.gl.uniform1i(u_pickings, indexOfTexture("pickings", null));
691
+ for (const i of [0, 1, 2, 3]) {
692
+ const u_mag_i = this.gl.getUniformLocation(program, `u_mag_${i}`);
693
+ this.gl.uniform1i(u_mag_i, i + this._markerOffset);
694
+ }
695
+ setTimeout(() => resolveGLReady(), 0);
696
+ };
697
+
698
+ const matchTile = (e, { x, y, level }) => {
699
+ const grid = e.tiledImage.tilesMatrix[level];
700
+ return ((grid || {})[x] || {})[y] || {};
701
+ };
702
+
703
+ const decodeLabelTile = (responseArray) => {
704
+ const upng = window.UPNG;
705
+ if (upng) {
706
+ const img = upng.decode(responseArray);
707
+ if (img.ctype == 6 && img.depth == 8) {
708
+ return {
709
+ data: img.data.slice(0, 4 * img.width * img.height),
710
+ width: img.width,
711
+ height: img.height,
712
+ };
713
+ }
714
+ }
715
+
716
+ const pngBuffer = new Buffer(responseArray);
717
+ const pngArray = PNG.sync.read(pngBuffer);
718
+ return {
719
+ data: pngArray.data.slice(0, 4 * pngArray.width * pngArray.height),
720
+ width: pngArray.width,
721
+ height: pngArray.height,
722
+ };
723
+ };
724
+
725
+ // `layer` is one entry of the cell-layer registry, or coreLayerView()
726
+ // when no plugin has registered one -- it carries the gate, the colours
727
+ // and the mode this pass draws with. Passed in rather than read off
728
+ // `this` because several layers are rendered from the SAME decoded tile,
729
+ // once each, and they must not see each other's state.
730
+ const renderLabelTile = (tileArray, width, height, layer) => {
731
+ const allowedIds = layer.filterIds;
732
+ // Per-cell colour from the plugin owning this layer, or null for the
733
+ // plain white layer this has always drawn. Read once per tile rather
734
+ // than per pixel, and destructured so the inner loop does no
735
+ // property lookups at all.
736
+ const lut = layer.lut;
737
+ const lutColors = lut?.colors || null;
738
+ const lutMaxId = lutColors ? lut.maxId : 0;
739
+ const lutMap = lutColors ? null : (lut?.map || null);
740
+ // Filled draws the whole cell rather than its boundary. Only
741
+ // possible where the labels are stored whole -- a pyramid that was
742
+ // pre-reduced to outlines has no interior pixels to paint, so there
743
+ // is nothing to fill and asking for it changes nothing.
744
+ const fillMode = layer.mode === "filled"
745
+ && this.config?.segmentationMode === "filled";
746
+ // Datasources imported with "Outline cells while viewing" store the
747
+ // labels whole rather than pre-reduced to boundaries (config
748
+ // segmentationMode = "filled"), so the boundary is derived here --
749
+ // once per tile at load, not per frame. Everything else arrives
750
+ // already reduced, where every labelled pixel *is* an outline.
751
+ // Filled skips the derivation entirely, which is also the cheaper
752
+ // path: no neighbour unpacking and no eight-way test per pixel.
753
+ const deriveOutlines = this.config?.segmentationMode === "filled" && !fillMode;
754
+ const canvas = document.createElement("canvas");
755
+ canvas.width = width;
756
+ canvas.height = height;
757
+ const context = canvas.getContext("2d");
758
+ const imageData = context.createImageData(width, height);
759
+ const output = imageData.data;
760
+
761
+ // Unpacked up front so the neighbour lookups below are plain array
762
+ // reads instead of re-decoding four bytes per tap.
763
+ let ids = null;
764
+ if (deriveOutlines) {
765
+ ids = new Uint32Array(width * height);
766
+ for (let p = 0; p < ids.length; p += 1) {
767
+ const i = p * 4;
768
+ ids[p] = tileArray[i]
769
+ + tileArray[i + 1] * 256
770
+ + tileArray[i + 2] * 65536
771
+ + tileArray[i + 3] * 16777216;
772
+ }
773
+ }
774
+
775
+ for (let i = 0; i < tileArray.length; i += 4) {
776
+ const cellId = ids
777
+ ? ids[i >> 2]
778
+ : tileArray[i]
779
+ + tileArray[i + 1] * 256
780
+ + tileArray[i + 2] * 65536
781
+ + tileArray[i + 3] * 16777216;
782
+ if (!cellId || (allowedIds && !allowedIds.has(cellId))) continue;
783
+ // White at the layer's long-standing alpha unless a plugin says
784
+ // otherwise. Looked up BEFORE the boundary derivation below: a
785
+ // hidden category or a cell with no value is skipped here, so it
786
+ // never pays for the eight-neighbour test -- which is what makes
787
+ // hiding most of a legend faster to draw, not slower.
788
+ let red = 255;
789
+ let green = 255;
790
+ let blue = 255;
791
+ let alpha = 220;
792
+ if (lutColors) {
793
+ // Above maxId means the LUT simply does not describe this
794
+ // cell -- a segmentation object with no row in the table.
795
+ // Transparent, like every other kind of no-value.
796
+ if (cellId > lutMaxId) continue;
797
+ const offset = cellId * 4;
798
+ alpha = lutColors[offset + 3];
799
+ if (!alpha) continue;
800
+ red = lutColors[offset];
801
+ green = lutColors[offset + 1];
802
+ blue = lutColors[offset + 2];
803
+ } else if (lutMap) {
804
+ const entry = lutMap.get(cellId);
805
+ if (!entry || !entry[3]) continue;
806
+ [red, green, blue, alpha] = entry;
807
+ }
808
+ if (ids) {
809
+ const p = i >> 2;
810
+ const x = p % width;
811
+ const y = (p - x) / width;
812
+ // Eight-neighbour, matching the "exact" method the offline
813
+ // pyramid writer uses (segmentation_pyramid.py). Four-
814
+ // neighbour leaves cells that meet only corner-to-corner
815
+ // sharing an unbroken block of white, which reads as one
816
+ // large cell; measured against a precomputed pyramid of the
817
+ // same mask it also drew ~28% fewer boundary pixels overall.
818
+ //
819
+ // Compared against raw ids, not the gated subset: a cell's
820
+ // edge against a filtered-out neighbour is still its edge.
821
+ // A tile's own border counts as "same" rather than as a
822
+ // boundary -- treating it as one would draw a bright grid
823
+ // along every tile seam. The cost is that a real boundary
824
+ // landing exactly on a seam loses that pixel; a cell simply
825
+ // continuing across the seam stays correct, which is the
826
+ // overwhelmingly common case.
827
+ const up = y > 0;
828
+ const down = y < height - 1;
829
+ const left = x > 0;
830
+ const right = x < width - 1;
831
+ const onBoundary =
832
+ (left && ids[p - 1] !== cellId)
833
+ || (right && ids[p + 1] !== cellId)
834
+ || (up && ids[p - width] !== cellId)
835
+ || (down && ids[p + width] !== cellId)
836
+ || (up && left && ids[p - width - 1] !== cellId)
837
+ || (up && right && ids[p - width + 1] !== cellId)
838
+ || (down && left && ids[p + width - 1] !== cellId)
839
+ || (down && right && ids[p + width + 1] !== cellId);
840
+ if (!onBoundary) continue;
841
+ }
842
+ output[i] = red;
843
+ output[i + 1] = green;
844
+ output[i + 2] = blue;
845
+ output[i + 3] = alpha;
846
+ }
847
+ context.putImageData(imageData, 0, 0);
848
+ return context;
849
+ };
850
+ this.renderLabelTile = renderLabelTile;
851
+ // Decode workers. Capped at 4: decode is memory-bandwidth bound, and
852
+ // more workers than that mostly adds ~3 MB of live intermediates each
853
+ // without decoding faster.
854
+ const decoderPool = TileDecoderPool.create(
855
+ plexoraUrl("client/src/js/workers/tileDecoder.js"),
856
+ Math.max(2, Math.min(4, navigator.hardwareConcurrency || 4)),
857
+ );
858
+ this.decoderPool = decoderPool;
859
+
860
+ // Same algorithm as the worker, for when workers aren't available.
861
+ const decodeWebpInline = async (responseArray) => {
862
+ const blob = new Blob([responseArray], { type: "image/webp" });
863
+ const bitmap = await createImageBitmap(blob, { premultiplyAlpha: "none", colorSpaceConversion: "none" });
864
+ const { width, height } = bitmap; // capture before close() -- closing zeroes these
865
+ const canvas = new OffscreenCanvas(width, height);
866
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
867
+ ctx.drawImage(bitmap, 0, 0);
868
+ const rgba = ctx.getImageData(0, 0, width, height).data;
869
+ bitmap.close();
870
+ const array = new Uint8Array(width * height);
871
+ for (let i = 0; i < array.length; i++) {
872
+ array[i] = rgba[i * 4];
873
+ }
874
+ return { array, width, height };
875
+ };
876
+
877
+
878
+ // tile-loaded handling: decode the raw tile bytes ourselves. OpenSeadragon
879
+ // 6.x exposes the underlying XHR on `e.tileRequest` (a stable, non-deprecated
880
+ // property) with `response` as an ArrayBuffer of the raw compressed tile (OSD
881
+ // uses responseType: "arraybuffer" for AJAX-loaded tiles), so no forked
882
+ // ImageJob is needed to reach it. Registered directly as an async function:
883
+ // OpenSeadragon awaits a handler's returned promise (raiseEventAwaiting) before
884
+ // considering the tile loaded, so no explicit getCompletionCallback() is needed.
885
+ const forceRepaint = this.forceRepaint.bind(this);
886
+ const handleTileLoaded = async (e) => {
887
+ const { source } = e.tiledImage;
888
+ const { tileFormat } = source;
889
+ try {
890
+ const responseArray = e.tileRequest?.response;
891
+ if (tileFormat == 32) {
892
+ e.tile._isLabel = true;
893
+ if (!e.tile?._array && responseArray) {
894
+ const decoded = decodeLabelTile(responseArray);
895
+ e.tile._array = decoded.data;
896
+ e.tile._format = "u32";
897
+ // Kept so a layer turned back on later can be rendered
898
+ // into this tile without re-deriving the shape from the
899
+ // canvas it no longer has.
900
+ e.tile._labelWidth = decoded.width;
901
+ e.tile._labelHeight = decoded.height;
902
+ this.renderTileLayers(e.tile);
903
+ }
904
+ if (e.tile?._layerContexts) {
905
+ return;
906
+ }
907
+ }
908
+ // Trigger loading of image
909
+ const tileArgs = [e.tile.level, e.tile.x, e.tile.y];
910
+ const tl = source.toTileLevels(...tileArgs);
911
+ if (tl.relativeImageScale < 1) {
912
+ const tile = matchTile(e, tl.outputTile);
913
+ if (tile?._array && tile?._format) {
914
+ e.tile._format = tile._format;
915
+ e.tile._array = tile._array;
916
+ }
917
+ if (e.tile?._array) {
918
+ return;
919
+ }
920
+ }
921
+ else if (tileFormat == 32) {
922
+ return;
923
+ }
924
+ else if (responseArray) {
925
+ const sig = new Uint8Array(responseArray, 0, 4);
926
+ const isWebp = sig[0] === 0x52 && sig[1] === 0x49 && sig[2] === 0x46 && sig[3] === 0x46; // "RIFF"
927
+ if (isWebp) {
928
+ // Fast/default tile path: quantized 8-bit, single value
929
+ // per pixel. Decoded via createImageBitmap (not
930
+ // UPNG.js, which can't parse WebP) -- confirmed safe
931
+ // for this opaque/single-channel case by an in-browser
932
+ // spike; segmentation tiles never take this path (see
933
+ // decodeLabelTile) because the same decode approach
934
+ // was found to corrupt RGB wherever alpha=0.
935
+ //
936
+ // Preferably in a worker: the canvas readback and the
937
+ // per-pixel RGBA->R strip cost ~60% of a 15-channel pan
938
+ // when run on the main thread. The inline branch is the
939
+ // fallback when workers or OffscreenCanvas are absent.
940
+ let decoded;
941
+ try {
942
+ decoded = decoderPool
943
+ ? await decoderPool.decode(responseArray, "webp")
944
+ : await decodeWebpInline(responseArray);
945
+ } catch (workerErr) {
946
+ // A worker failure must never cost us the tile --
947
+ // OSD is awaiting this handler, and a rejection here
948
+ // would leave the tile permanently blank.
949
+ console.warn("Worker tile decode failed, falling back inline:", workerErr);
950
+ decoded = await decodeWebpInline(responseArray);
951
+ }
952
+ e.tile._array = decoded.array;
953
+ e.tile._format = "u8";
954
+ } else {
955
+ // HD (16-bit grayscale PNG) and segmentation tiles.
956
+ //
957
+ // The HD path goes to the worker, which inflates with the
958
+ // browser's native DecompressionStream rather than
959
+ // UPNG.js/pako. A CPU profile of a 7-channel HD pan put
960
+ // 81% of ALL time in pako's JS inflate. Segmentation
961
+ // tiles stay on UPNG here: they are RGBA8 rather than
962
+ // gray16, and there is only one label layer so the cost
963
+ // is not multiplied by the channel count.
964
+ let decoded = null;
965
+ if (decoderPool && tileFormat != 32) {
966
+ try {
967
+ decoded = await decoderPool.decode(responseArray, "gray16png");
968
+ } catch (workerErr) {
969
+ if (!workerErr.unsupported) {
970
+ console.warn("Worker HD decode failed, falling back to UPNG:", workerErr);
971
+ }
972
+ }
973
+ }
974
+ if (decoded) {
975
+ e.tile._array = decoded.array;
976
+ e.tile._format = "u16";
977
+ } else {
978
+ const img = window.UPNG.decode(responseArray);
979
+ if (img.ctype == 0 && img.depth == 16) {
980
+ e.tile._array = img.data.slice(0, 2 * img.width * img.height);
981
+ e.tile._format = "u16";
982
+ } else if (img.ctype == 6 && img.depth == 8) {
983
+ e.tile._array = img.data.slice(0, 4 * img.width * img.height);
984
+ e.tile._format = "u32";
985
+ }
986
+ }
987
+ }
988
+ }
989
+ } catch (err) {
990
+ console.log("Load Error, Refreshing", err, e.tile.getUrl());
991
+ forceRepaint();
992
+ }
993
+ };
994
+
995
+
996
+ // Disable canvas image smoothing once per drawer canvas, not once per
997
+ // tile per frame. This used to live in a "tile-drawn" handler that also
998
+ // ran _.size() over the entire shared tile cache and re-fetched the 2D
999
+ // context for every tile of every channel on every frame -- O(tiles^2)
1000
+ // work per frame whose only lasting effect was these four flags. (The
1001
+ // _imagesLoadedCount it maintained is an OpenSeadragon 2.x field that
1002
+ // OSD 6 no longer reads.)
1003
+ const disableSmoothing = () => {
1004
+ const canvas = this.viewer?.drawer?.canvas;
1005
+ const context = canvas?.getContext?.("2d");
1006
+ if (!context) {
1007
+ return;
1008
+ }
1009
+ context.mozImageSmoothingEnabled = false;
1010
+ context.webkitImageSmoothingEnabled = false;
1011
+ context.msImageSmoothingEnabled = false;
1012
+ context.imageSmoothingEnabled = false;
1013
+ };
1014
+ this.viewer.addHandler("open", disableSmoothing);
1015
+ this.viewer.addHandler("resize", disableSmoothing);
1016
+
1017
+ this.viewer.addHandler("tile-unloaded", (e) => {
1018
+ delete e.tile._array;
1019
+ // The per-layer canvases are the larger half of a label tile's
1020
+ // footprint -- one 4 MB RGBA canvas per visible layer against the
1021
+ // 4 MB decoded array -- and they used to be left behind here
1022
+ // entirely, so panning a slide with the mask on grew the heap for
1023
+ // as long as the session lasted.
1024
+ e.tile._layerContexts?.clear();
1025
+ delete e.tile._layerContexts;
1026
+ });
1027
+
1028
+ // GL initialization: on 'open', size the GL canvas, compile shaders,
1029
+ // then wire the real tile-loaded/tile-drawing handlers and force
1030
+ // existing items to redraw. viewerManager.js manually re-raises 'open'
1031
+ // after adding the label tiled image, so this runs more than once by
1032
+ // design.
1033
+ const initGL = () => {
1034
+ renderer.width = renderer.width || this.config.tileWidth;
1035
+ renderer.height = renderer.height || this.config.tileHeight;
1036
+ renderer.updateShape(renderer.width, renderer.height);
1037
+ renderer.init().then(() => {
1038
+ this.viewer.addHandler("tile-loaded", handleTileLoaded);
1039
+ this.viewer.addHandler("tile-drawing", (e) => tileDrawingCustom(tileDrawingDefault, e));
1040
+
1041
+ const world = this.viewer.world;
1042
+ for (let i = 0; i < world.getItemCount(); i++) {
1043
+ world.getItemAt(i)._needsDraw = true;
1044
+ }
1045
+ world.update();
1046
+ });
1047
+ };
1048
+ this.viewer.addHandler("open", initGL);
1049
+
1050
+ // No tile-cache monitor here on purpose. OpenSeadragon 6 evicts against
1051
+ // maxImageCacheCount itself (TileCache._freeOldRecordRoutine) and does
1052
+ // it correctly; the interval that used to live here summed
1053
+ // item._tileCache._tilesLoaded.length across every item, but OSD hands
1054
+ // the SAME TileCache instance to every TiledImage, so the total was
1055
+ // multiplied by the channel count and the threshold tripped at roughly
1056
+ // 1000/N real tiles -- then called an evictor that freed nothing and
1057
+ // corrupted OSD's LRU bookkeeping (see clearTileCache).
1058
+
1059
+ this.viewer.scalebar({
1060
+ location: OpenSeadragon.ScalebarLocation.BOTTOM_RIGHT,
1061
+ minWidth: "100px",
1062
+ type: OpenSeadragon.ScalebarType.MICROSCOPY,
1063
+ stayInsideImage: true,
1064
+ pixelsPerMeter: this.getPixelsPerMeter(),
1065
+ fontColor: "rgb(255, 255, 255)",
1066
+ color: "rgb(255, 255, 255)",
1067
+ backgroundColor: "rgba(0, 0, 0, 0.45)",
1068
+ barThickness: 3,
1069
+ });
1070
+ this.styleScaleBar();
1071
+
1072
+ // Add event mouse handler (cell selection)
1073
+ this.viewer.addHandler("canvas-nonprimary-press", (e) => {
1074
+ // Right click (cell selection)
1075
+ if (event.button === 2) {
1076
+ const { numericData } = this;
1077
+ const { source } = e.eventSource;
1078
+ const tiledImage = this.viewer.world.getItemAt(0);
1079
+ const imageCoords = source.getImagePixel(tiledImage, e.position);
1080
+ return numericData.getNearestCell(...imageCoords).then((item) => {
1081
+ if (item !== null && item !== undefined) {
1082
+ // Check if user is doing multi-selection or not
1083
+ let clearPriors = true;
1084
+ if (e.originalEvent.ctrlKey) {
1085
+ clearPriors = false;
1086
+ }
1087
+ // Trigger event
1088
+ const imageClick = ImageViewer.events.imageClickedMultiSel;
1089
+ this.eventHandler.trigger(imageClick, { item, clearPriors });
1090
+ }
1091
+ });
1092
+ }
1093
+ });
1094
+
1095
+ let that = this;
1096
+
1097
+ let primaryTracker = new OpenSeadragon.MouseTracker({
1098
+ element: that.viewer.canvas,
1099
+ nonPrimaryReleaseHandler(event) {
1100
+ if (that.selectButton.classList.contains('selected') && !that.lassoing) {
1101
+ const webPoint = event.position;
1102
+ // Convert that to viewport coordinates, the lingua franca of OpenSeadragon coordinates.
1103
+ const viewportPoint = that.viewer.viewport.pointFromPixel(webPoint);
1104
+ // Convert from viewport coordinates to image coordinates.
1105
+ let imagePoint = that.viewer.world.getItemAt(0).viewportToImageCoordinates(viewportPoint);
1106
+ const zoomScale = 2 ** config.extraZoomLevels;
1107
+ imagePoint = { x: imagePoint.x / zoomScale, y: imagePoint.y / zoomScale }
1108
+ return that.dataLayer.getNearestCell(imagePoint.x, imagePoint.y)
1109
+ .then(selectedItem => {
1110
+ if (selectedItem !== null && selectedItem !== undefined) {
1111
+ // Check if user is doing multi-selection or not
1112
+ let clearPriors = true;
1113
+ if (event.originalEvent.ctrlKey) {
1114
+ clearPriors = false;
1115
+ }
1116
+ // Trigger event
1117
+ that.eventHandler.trigger(ImageViewer.events.imageClickedMultiSel, {
1118
+ selectedItem,
1119
+ clearPriors
1120
+ });
1121
+ }
1122
+ })
1123
+ }
1124
+ }
1125
+ })
1126
+
1127
+ this.canvasOverlay = new OpenSeadragon.CanvasOverlayHd(this.viewer, {
1128
+ onRedraw: function (opts) {
1129
+ const context = opts.context;
1130
+ //area selection polygon
1131
+ if (that.selectionPolygonToDraw && that.selectionPolygonToDraw.length > 0) {
1132
+ var d = that.selectionPolygonToDraw;
1133
+ context.globalAlpha = 0.7;
1134
+ context.strokeStyle = 'orange';
1135
+ context.lineWidth = 10;
1136
+ context.beginPath();
1137
+ d.forEach(function (xVal, i) {
1138
+ if (i === 0) {
1139
+ context.moveTo(d[i].x, d[i].y);
1140
+ } else {
1141
+ context.lineTo(d[i].x, d[i].y);
1142
+ }
1143
+ });
1144
+ context.closePath();
1145
+ context.stroke();
1146
+ // context.globalAlpha = 1.0;
1147
+ }
1148
+ if (that.shouldDrawCentroids()) {
1149
+ that.drawCentroids(context, opts.zoom);
1150
+ }
1151
+ },
1152
+ });
1153
+ this.viewer.addHandler("animation", () => this.scheduleCentroidTileUpdate());
1154
+ this.viewer.addHandler("animation-finish", () => this.scheduleCentroidTileUpdate(0));
1155
+ this.viewer.addHandler("resize", () => this.scheduleCentroidTileUpdate(0));
1156
+ this.viewer.addHandler("open", () => this.scheduleCentroidTileUpdate(0));
1157
+ }
1158
+
1159
+ /**
1160
+ * @function init - initializes OSD channel and selection-provider options
1161
+ * @param viewerManager - Viewer Manager Instance
1162
+ * @param channelList - ChannelList instance
1163
+ * @param cellLayer - optional plugin instance implementing
1164
+ * { getSelectedIds(filter), supportsColorCoding(), getColorCodedRanges() }.
1165
+ * Claims the cell layer if given; null for a plain viewer.
1166
+ * @param centers - List of image pixel coordinates per cell
1167
+ * @param ids - List of integer ids per cell
1168
+ */
1169
+ async init(viewerManager, channelList, cellLayer, centers, ids) {
1170
+ this.channelList = channelList;
1171
+ if (cellLayer) {
1172
+ this.registerCellLayer(cellLayer.pluginName || "unknown", cellLayer);
1173
+ }
1174
+ this.centers = centers || [];
1175
+ this.ids = ids || [];
1176
+ // Instantiate viewer managers
1177
+ this.viewerManagerVMain = viewerManager;
1178
+ this.viewerManagers.push(this.viewerManagerVMain);
1179
+ this.setLoading(true);
1180
+ try {
1181
+ if (!this.noLabel) {
1182
+ await this.waitForGLReady();
1183
+ }
1184
+ const renderer = this.glRenderer;
1185
+ renderer.texture_mag = [renderer.gl.createTexture(), renderer.gl.createTexture(), renderer.gl.createTexture(), renderer.gl.createTexture()];
1186
+ renderer.texture_ids = renderer.gl.createTexture();
1187
+ renderer.texture_mask = renderer.gl.createTexture();
1188
+ renderer.texture_ranges = renderer.gl.createTexture();
1189
+ renderer.texture_centers = renderer.gl.createTexture();
1190
+ renderer.texture_pickings = renderer.gl.createTexture();
1191
+ this.bindPickings(renderer, []);
1192
+ this.ready = true;
1193
+ if (ids.length && centers.length) {
1194
+ this.bindSegmentationBuffers(ids, centers);
1195
+ this.clearTileCache(true);
1196
+ await this.forceRepaint();
1197
+ }
1198
+ } finally {
1199
+ this.setLoading(false);
1200
+ }
1201
+
1202
+ }
1203
+
1204
+ waitForGLReady(timeoutMs = 5000) {
1205
+ return Promise.race([
1206
+ this.glReady,
1207
+ new Promise((resolve) => setTimeout(resolve, timeoutMs)),
1208
+ ]);
1209
+ }
1210
+
1211
+
1212
+ /**
1213
+ * The provider of the ACTIVE cell layer, or null.
1214
+ *
1215
+ * "Active" is the layer the shared controls, picking and gating act on --
1216
+ * one at a time, or none. Every caller that asks "who holds the cell layer"
1217
+ * means this one; the other layers are still on screen, they just are not
1218
+ * the thing being worked on.
1219
+ */
1220
+ get cellLayer() {
1221
+ return this._cellLayers.get(this._activeCellLayer)?.provider || null;
1222
+ }
1223
+
1224
+ /**
1225
+ * The name of the active cell layer's plugin, or null.
1226
+ */
1227
+ get cellLayerOwner() {
1228
+ return this._activeCellLayer;
1229
+ }
1230
+
1231
+ /** One layer's record by name, or null. */
1232
+ getCellLayer(name) {
1233
+ return this._cellLayers.get(name) || null;
1234
+ }
1235
+
1236
+ /** Every registered layer, bottom of the stack first. */
1237
+ cellLayers() {
1238
+ return this._cellLayerOrder
1239
+ .map((name) => this._cellLayers.get(name))
1240
+ .filter(Boolean);
1241
+ }
1242
+
1243
+ /**
1244
+ * The layer core draws with when NO plugin has registered one.
1245
+ *
1246
+ * A stand-in with the same shape as a real record, so the renderers below
1247
+ * take one code path rather than branching on "is anything registered" at
1248
+ * every read. Mutated in place rather than rebuilt: this is called once per
1249
+ * label tile per frame.
1250
+ */
1251
+ coreLayerView() {
1252
+ const view = this._coreLayerView;
1253
+ view.mode = this.cellDisplayMode;
1254
+ view.filterIds = this.segmentationFilterIds;
1255
+ return view;
1256
+ }
1257
+
1258
+ /**
1259
+ * @function maskDrawList - which layers the label tiles draw, bottom first.
1260
+ *
1261
+ * The returned order IS the z-order: tileDrawingCustom blits them in this
1262
+ * sequence, so the last one wins wherever they overlap.
1263
+ *
1264
+ * With nothing registered this is core's own layer, which is what keeps a
1265
+ * plain viewer -- and one running only Thresholding -- pixel-identical to
1266
+ * what it drew before layers existed. Once a plugin registers, core's plain
1267
+ * white layer steps aside entirely: turning every plugin layer off then
1268
+ * draws nothing, rather than falling back to white outlines the user did
1269
+ * not ask for.
1270
+ */
1271
+ maskDrawList() {
1272
+ if (!this._cellLayers.size) {
1273
+ return [this.coreLayerView()];
1274
+ }
1275
+ const list = [];
1276
+ for (const name of this._cellLayerOrder) {
1277
+ const layer = this._cellLayers.get(name);
1278
+ if (layer?.visible && ImageViewer.MASK_MODES.includes(layer.mode)) {
1279
+ list.push(layer);
1280
+ }
1281
+ }
1282
+ return list;
1283
+ }
1284
+
1285
+ /**
1286
+ * @function centroidDrawList - which layers the point overlay draws.
1287
+ *
1288
+ * Same ordering rule, but a DIFFERENT stack: points are drawn onto core's
1289
+ * overlay canvas, which sits above every label tile whatever the sidebar
1290
+ * says. So dragging reorders points among themselves and masks among
1291
+ * themselves, and a centroid layer is always over a mask layer.
1292
+ */
1293
+ centroidDrawList() {
1294
+ if (!this._cellLayers.size) {
1295
+ return [this.coreLayerView()];
1296
+ }
1297
+ return this._cellLayerOrder
1298
+ .map((name) => this._cellLayers.get(name))
1299
+ .filter((layer) => layer?.visible && layer.mode === "centroids");
1300
+ }
1301
+
1302
+ /**
1303
+ * @function registerCellLayer - add (or re-adopt) a plugin's cell layer.
1304
+ *
1305
+ * Registering the SAME name again keeps everything the layer already holds
1306
+ * -- colours, gate, mode, opacity -- which is what makes switching a tool
1307
+ * away and back instant rather than a reload. Only unregisterCellLayer
1308
+ * throws state away.
1309
+ *
1310
+ * A new layer goes on TOP of the stack: a tool the user just opened is the
1311
+ * one they are looking at.
1312
+ *
1313
+ * @param name - the plugin's name
1314
+ * @param provider - object implementing the selection-provider shape:
1315
+ * { getSelectedIds(filter), supportsColorCoding(), getColorCodedRanges(),
1316
+ * eval_mode }
1317
+ * @param options - { mode, opacity, visible, makeActive, supportedModes }
1318
+ * @returns the layer record
1319
+ */
1320
+ registerCellLayer(name, provider, options = {}) {
1321
+ if (!name) return null;
1322
+ let layer = this._cellLayers.get(name);
1323
+ if (!layer) {
1324
+ layer = {
1325
+ name,
1326
+ provider: provider || null,
1327
+ // Per-cell colour, or null for the plain white cell layer.
1328
+ //
1329
+ // A lookup table rather than per-cell geometry on purpose:
1330
+ // changing a palette, hiding a category or moving a continuous
1331
+ // range then recolours without refetching anything or rebuilding
1332
+ // a single boundary. See renderLabelTile.
1333
+ //
1334
+ // Shape: { colors: Uint8Array of 4*(maxId+1) RGBA bytes, maxId }
1335
+ // dense, or { map: Map<cellId, [r,g,b,a]> } when the ids are too
1336
+ // sparse for that to be worth allocating. Alpha 0 means "do not
1337
+ // draw this cell" -- it is how both "hidden category" and "no
1338
+ // value for this cell" arrive, so neither needs its own channel.
1339
+ lut: null,
1340
+ //: "none" | "centroids" | "outlines" | "filled". Starts at none:
1341
+ //: nothing is drawn over the image until something asks, and the
1342
+ //: asking is viewerControls.enableCellLayer.
1343
+ mode: "none",
1344
+ //: Set once the user picks a mode for this layer themselves, so
1345
+ //: a plugin restoring a stored preference can tell "not chosen
1346
+ //: yet" from "chosen, and the choice was the same".
1347
+ userMode: null,
1348
+ //: Which of the four the plugin can actually draw, or null for
1349
+ //: "whatever the project can". The shared Cells control offers
1350
+ //: the intersection.
1351
+ supportedModes: null,
1352
+ opacity: ImageViewer.DEFAULT_CELL_LAYER_OPACITY,
1353
+ visible: true,
1354
+ //: This layer's own gate. Per layer so one tool's selection
1355
+ //: cannot subtract cells from another tool's colours.
1356
+ filterIds: null,
1357
+ filterRequest: 0,
1358
+ //: Distinct fill strings, memoized so the centroid path does not
1359
+ //: build a colour string per point per frame. Keyed on the packed
1360
+ //: RGB, so it is bounded by the number of colours in use rather
1361
+ //: than the cell count.
1362
+ styleCache: new Map(),
1363
+ };
1364
+ this._cellLayers.set(name, layer);
1365
+ this._cellLayerOrder.push(name);
1366
+ } else if (provider) {
1367
+ layer.provider = provider;
1368
+ }
1369
+ if (options.supportedModes) layer.supportedModes = [...options.supportedModes];
1370
+ if (options.mode) layer.mode = options.mode;
1371
+ if (options.opacity !== undefined) this.setLayerOpacity(name, options.opacity);
1372
+ if (options.visible !== undefined) layer.visible = Boolean(options.visible);
1373
+ if (options.makeActive !== false) this.setActiveCellLayer(name);
1374
+ return layer;
1375
+ }
1376
+
1377
+ /**
1378
+ * @function unregisterCellLayer - drop a layer and everything it held.
1379
+ *
1380
+ * The teardown counterpart of register: colours, gate and per-tile canvases
1381
+ * all go. A no-op for a name that is not registered, so a plugin shutting
1382
+ * down cannot clear a layer someone else owns.
1383
+ */
1384
+ unregisterCellLayer(name) {
1385
+ if (!this._cellLayers.has(name)) return false;
1386
+ this._cellLayers.delete(name);
1387
+ this._cellLayerOrder = this._cellLayerOrder.filter((entry) => entry !== name);
1388
+ if (this._activeCellLayer === name) {
1389
+ // The topmost survivor takes over rather than leaving nothing
1390
+ // active, so removing the tool being looked at does not strand the
1391
+ // shared controls while other layers are still on screen.
1392
+ this._activeCellLayer = this._cellLayerOrder.length
1393
+ ? this._cellLayerOrder[this._cellLayerOrder.length - 1]
1394
+ : null;
1395
+ }
1396
+ this.applyCellColor();
1397
+ return true;
1398
+ }
1399
+
1400
+ /**
1401
+ * @function setActiveCellLayer - which layer the shared controls act on.
1402
+ *
1403
+ * Pixels do not change: every layer that was visible stays visible. This is
1404
+ * about where the Cells control, the opacity slider, picking and the gate
1405
+ * flows point.
1406
+ *
1407
+ * @returns the displaced layer's name, or null
1408
+ */
1409
+ setActiveCellLayer(name) {
1410
+ const next = name && this._cellLayers.has(name) ? name : null;
1411
+ const previous = this._activeCellLayer;
1412
+ if (previous === next) return null;
1413
+ this._activeCellLayer = next;
1414
+ return previous;
1415
+ }
1416
+
1417
+ /**
1418
+ * @function setCellLayerVisible - draw this layer, or stop drawing it.
1419
+ *
1420
+ * Hiding DROPS the layer's per-tile canvases and KEEPS its lookup table.
1421
+ * That split is what makes a loaded-but-hidden plugin cheap enough that
1422
+ * there is no need to cap how many may be loaded: the canvases are ~4 MB
1423
+ * per label tile in view and rebuild in a few milliseconds from the decoded
1424
+ * array that is still on the tile, while the table is four bytes per cell
1425
+ * and is the expensive thing to recompute (a request, or a pass over a
1426
+ * column). A hidden layer also stops growing as the user pans, because it
1427
+ * renders nothing into newly loaded tiles.
1428
+ */
1429
+ setCellLayerVisible(name, visible) {
1430
+ const layer = this._cellLayers.get(name);
1431
+ if (!layer) return false;
1432
+ const next = Boolean(visible);
1433
+ if (layer.visible === next) return false;
1434
+ layer.visible = next;
1435
+ if (next) {
1436
+ this.rerenderSegmentationTiles(name);
1437
+ } else {
1438
+ this.dropLayerContexts(name);
1439
+ }
1440
+ this.viewer?.forceRedraw?.();
1441
+ return true;
1442
+ }
1443
+
1444
+ /**
1445
+ * @function setCellLayerMode - how one layer draws its cells.
1446
+ *
1447
+ * "none" | "centroids" | "outlines" | "filled". Only the transitions that
1448
+ * change what the tile canvases HOLD cost a re-render: moving between
1449
+ * outlines and filled, and moving in or out of the mask stack at all.
1450
+ * Everything else is a redraw.
1451
+ */
1452
+ setCellLayerMode(name, mode) {
1453
+ const layer = this._cellLayers.get(name);
1454
+ if (!layer) return false;
1455
+ const next = mode || "none";
1456
+ layer.userMode = next;
1457
+ if (next === layer.mode) return false;
1458
+ const wasMask = ImageViewer.MASK_MODES.includes(layer.mode);
1459
+ const nowMask = ImageViewer.MASK_MODES.includes(next);
1460
+ const filledChanged = layer.mode === "filled" || next === "filled";
1461
+ layer.mode = next;
1462
+ if (wasMask !== nowMask || (nowMask && filledChanged)) {
1463
+ this.applyCellColor(nowMask ? name : null);
1464
+ } else {
1465
+ this.viewer?.forceRedraw?.();
1466
+ }
1467
+ return true;
1468
+ }
1469
+
1470
+ /**
1471
+ * @function setLayerOpacity - how strongly one layer sits over what is
1472
+ * under it.
1473
+ *
1474
+ * Composite-time, so this is a redraw and never a re-render: the tile
1475
+ * canvases already hold the right pixels and only the blit's alpha changes.
1476
+ * That is what makes dragging the slider smooth on a mask with thousands of
1477
+ * cells in view.
1478
+ */
1479
+ setLayerOpacity(name, value) {
1480
+ const layer = this._cellLayers.get(name);
1481
+ if (!layer) return false;
1482
+ const next = Math.max(0, Math.min(1, Number(value)));
1483
+ if (!Number.isFinite(next) || next === layer.opacity) return false;
1484
+ layer.opacity = next;
1485
+ this.viewer?.forceRedraw?.();
1486
+ return true;
1487
+ }
1488
+
1489
+ /**
1490
+ * @function setCellLayerOrder - restack the layers, bottom first.
1491
+ *
1492
+ * Composite order only, so this costs one redraw however many cells are on
1493
+ * screen -- which is what lets the sidebar cards be dragged live. Names that
1494
+ * are not registered are ignored, and registered names the caller did not
1495
+ * mention keep their relative places underneath, so a partial order can
1496
+ * never drop a layer off the stack.
1497
+ */
1498
+ setCellLayerOrder(names) {
1499
+ const wanted = (names || []).filter((name) => this._cellLayers.has(name));
1500
+ const mentioned = new Set(wanted);
1501
+ const rest = this._cellLayerOrder.filter((name) => !mentioned.has(name));
1502
+ const next = [...rest, ...wanted];
1503
+ const same = next.length === this._cellLayerOrder.length
1504
+ && next.every((name, index) => name === this._cellLayerOrder[index]);
1505
+ if (same) return false;
1506
+ this._cellLayerOrder = next;
1507
+ this.viewer?.forceRedraw?.();
1508
+ return true;
1509
+ }
1510
+
1511
+ /**
1512
+ * @function setCellColorLUT - colour one layer's cells by id.
1513
+ *
1514
+ * Gated on the layer existing, and silently so: a plugin that has been
1515
+ * removed may still have an in-flight request whose response lands
1516
+ * afterwards, and applying it would repaint cells for a tool that is no
1517
+ * longer there. Returning false lets the caller notice; ignoring the return
1518
+ * value is also correct, because doing nothing IS the right outcome.
1519
+ *
1520
+ * Note what is NOT checked: whether this layer is the active one. A tool the
1521
+ * user has switched away from but left visible goes on showing its own
1522
+ * colours, and goes on being allowed to update them.
1523
+ *
1524
+ * @param name - the layer's plugin name
1525
+ * @param lut - { colors: Uint8Array, maxId } | { map: Map } | null to clear
1526
+ * @returns whether the LUT was applied
1527
+ */
1528
+ setCellColorLUT(name, lut) {
1529
+ const layer = this._cellLayers.get(name);
1530
+ if (!layer) return false;
1531
+ layer.lut = lut || null;
1532
+ layer.styleCache = new Map();
1533
+ this.applyCellColor(name);
1534
+ return true;
1535
+ }
1536
+
1537
+ /**
1538
+ * @function setCellDisplayMode - core's own mode, for a viewer with no
1539
+ * plugin layers.
1540
+ *
1541
+ * Only "filled" changes what renderLabelTile produces, so only that
1542
+ * transition costs a re-render; switching between centroids and outlines is
1543
+ * a matter of which layer is showing, which viewerControls handles.
1544
+ */
1545
+ setCellDisplayMode(mode) {
1546
+ const next = mode || "outlines";
1547
+ if (next === this.cellDisplayMode) return false;
1548
+ const wasFilled = this.cellDisplayMode === "filled";
1549
+ this.cellDisplayMode = next;
1550
+ if (wasFilled || next === "filled") {
1551
+ this.applyCellColor();
1552
+ }
1553
+ return true;
1554
+ }
1555
+
1556
+ /**
1557
+ * @function layerAlpha - the alpha one layer composites at.
1558
+ *
1559
+ * 1 whenever the layer has no colours. The opacity control belongs to the
1560
+ * plugin that owns the colours, so a plain viewer -- or one running only
1561
+ * Thresholding -- must not inherit its default and start drawing dimmer
1562
+ * outlines than it did before any of this existed.
1563
+ */
1564
+ layerAlpha(layer) {
1565
+ return layer?.lut ? layer.opacity : 1;
1566
+ }
1567
+
1568
+ /**
1569
+ * @function cellColorStyle - a canvas fill for one cell, or null to skip it.
1570
+ *
1571
+ * Alpha is treated as a yes/no here rather than blended in: the centroid
1572
+ * layer already carries its own globalAlpha, and stacking a second per-point
1573
+ * alpha on top of it makes a hidden cell "mostly invisible" instead of
1574
+ * absent. Strings are memoized because this runs per point per frame.
1575
+ */
1576
+ cellColorStyle(cellId, layer) {
1577
+ const lut = layer?.lut;
1578
+ if (!lut) return null;
1579
+ let r;
1580
+ let g;
1581
+ let b;
1582
+ if (lut.colors) {
1583
+ if (cellId > lut.maxId) return null;
1584
+ const offset = cellId * 4;
1585
+ if (!lut.colors[offset + 3]) return null;
1586
+ r = lut.colors[offset];
1587
+ g = lut.colors[offset + 1];
1588
+ b = lut.colors[offset + 2];
1589
+ } else {
1590
+ const entry = lut.map?.get(cellId);
1591
+ if (!entry || !entry[3]) return null;
1592
+ [r, g, b] = entry;
1593
+ }
1594
+ const key = (r << 16) | (g << 8) | b;
1595
+ let style = layer.styleCache.get(key);
1596
+ if (style === undefined) {
1597
+ style = `rgb(${r},${g},${b})`;
1598
+ layer.styleCache.set(key, style);
1599
+ }
1600
+ return style;
1601
+ }
1602
+
1603
+ /**
1604
+ * @function applyCellColor - redraw the cell layer after a colour change.
1605
+ *
1606
+ * Re-renders the label tiles (their pixels carry the colours) and forces one
1607
+ * viewer redraw, which is also what repaints the centroid overlay. Geometry
1608
+ * is untouched: nothing is refetched and no boundary is re-derived for a
1609
+ * tile whose ids have not changed.
1610
+ *
1611
+ * @param name - re-render only this layer's canvases, leaving the rest of
1612
+ * the stack alone. That is what makes a gate edit over a phenotype map
1613
+ * cost the gate layer and nothing else.
1614
+ */
1615
+ applyCellColor(name = null) {
1616
+ this.rerenderSegmentationTiles(name);
1617
+ this.viewer?.forceRedraw?.();
1618
+ }
1619
+
1620
+ /**
1621
+ * @function indexOfTexture - return the texture unit for a named texture
1622
+ * @param label - the texture key label
1623
+ * @param scope - "M" for a marker magnitude texture, null for a constant
1624
+ * @returns number
1625
+ *
1626
+ * Tile textures are deliberately absent here: they are not assigned a unit
1627
+ * each (the shader's u_tile sampler is pinned to unit 0), they are keyed by
1628
+ * content in GLTileTextureCache instead.
1629
+ */
1630
+ indexOfTexture(label, scope = null) {
1631
+ const renderer = this.glRenderer;
1632
+ // magnitudes
1633
+ if (scope == "M") {
1634
+ const index = renderer._markerTextures.indexOf(label);
1635
+ if (index > -1) {
1636
+ return index;
1637
+ }
1638
+ const newIndex = renderer._nextMarkerTexture;
1639
+ const maximum = renderer._markerTextures.length;
1640
+ renderer._nextMarkerTexture = (newIndex + 1) % maximum;
1641
+ renderer._markerTextures[newIndex] = label;
1642
+ return newIndex + renderer._markerOffset;
1643
+ }
1644
+ // other
1645
+ const index = renderer._constantTextures.indexOf(label);
1646
+ if (index > -1) {
1647
+ return index + renderer._otherOffset;
1648
+ }
1649
+ return -1;
1650
+ }
1651
+
1652
+
1653
+ /**
1654
+ * @function findMarkerTexture - Check if texture label is active.
1655
+ * @param label - marker texture label
1656
+ * @returns number
1657
+ */
1658
+ findMarkerTexture(label) {
1659
+ const renderer = this.glRenderer;
1660
+ return renderer._markerTextures.indexOf(label);
1661
+ }
1662
+
1663
+ /**
1664
+ * @function findCurrentChannel - Return given channel for partial url
1665
+ * @param sub_url - partial url
1666
+ * @returns - current channel
1667
+ */
1668
+ findCurrentChannel(sub_url) {
1669
+ const channels = Object.values(this.currentChannels);
1670
+ return channels.find((e) => e.sub_url == sub_url);
1671
+ }
1672
+
1673
+ /**
1674
+ * Flag for webGL rendering.
1675
+ *
1676
+ * @type {boolean}
1677
+ */
1678
+ get ready() {
1679
+ return this._ready || false;
1680
+ }
1681
+
1682
+ set ready(bool) {
1683
+ this._ready = bool;
1684
+ this.viewerManagers.forEach(({ viewer }) => {
1685
+ viewer.world._needsDraw = bool;
1686
+ });
1687
+ }
1688
+
1689
+ /**
1690
+ * Flags for mode of webGL rendering.
1691
+ *
1692
+ * @typedef {object} ModeFlags
1693
+ * @property {boolean} edge - render outlines
1694
+ * @property {boolean} or - render pie charts
1695
+ * @type {ModeFlags}
1696
+ */
1697
+ get modeFlags() {
1698
+ return {
1699
+ edge: !!this.viewerManagerVMain?.sel_outlines,
1700
+ or: this.cellLayer?.supportsColorCoding?.() ? this.cellLayer.eval_mode == "or" : false,
1701
+ };
1702
+ }
1703
+
1704
+ /**
1705
+ * Color-coded (multi-range gate) keys for webGL rendering. Empty unless
1706
+ * the active selection provider supports color coding (see the scope
1707
+ * note on colorCodedRanges below) -- kept opt-in by design, not a
1708
+ * generalized concept every plugin needs to implement.
1709
+ *
1710
+ * @type {Array}
1711
+ */
1712
+ get colorCodedKeys() {
1713
+ const keys = Object.keys(this.colorCodedRanges);
1714
+ return [...keys.sort()];
1715
+ }
1716
+
1717
+ /**
1718
+ * Color-coded (multi-range gate) selections. This is deliberately not a
1719
+ * generalized "selection" concept -- it's the cell-layer owner's per-channel
1720
+ * threshold-range rendering path (u_cell_range_shape/texture_ranges), only
1721
+ * ever populated when the active provider opts in via supportsColorCoding().
1722
+ *
1723
+ * @type {Array}
1724
+ */
1725
+ get colorCodedRanges() {
1726
+ if (!this.cellLayer?.supportsColorCoding?.()) {
1727
+ return {};
1728
+ }
1729
+ return this.cellLayer.getColorCodedRanges?.() || {};
1730
+ }
1731
+
1732
+ /**
1733
+ * Channel selections.
1734
+ *
1735
+ * @type {Array}
1736
+ */
1737
+ get currentChannels() {
1738
+ return this.channelList.currentChannels || {};
1739
+ }
1740
+
1741
+ /**
1742
+ * @function toCacheKey - generate cache keys of gl properties
1743
+ * @param keys - active marker channels
1744
+ * @param markerLists - data for each marker
1745
+ * @returns string
1746
+ */
1747
+ toCacheKey(keys, markerLists) {
1748
+ const precisions = [2 ** 25, 2 ** 25, 255, 255, 255];
1749
+ const tuples = keys.map((channel, i) => {
1750
+ const idx = 1 + this.selectMaskIndex(channel);
1751
+ const keyData = markerLists[i] || [];
1752
+ const hashes = keyData.map((r, j) => {
1753
+ // use precision for each item
1754
+ const integral = r * precisions[j];
1755
+ return parseInt(integral).toString(36);
1756
+ });
1757
+ return [idx, ...hashes].join("-");
1758
+ });
1759
+ return tuples.join("-");
1760
+ }
1761
+
1762
+ /**
1763
+ * Wrapper to set/get pickedIds
1764
+ *
1765
+ * @type {string}
1766
+ */
1767
+
1768
+ get pickedIds() {
1769
+ return this._picking;
1770
+ }
1771
+
1772
+ set pickedIds(key) {
1773
+ this._picking = key;
1774
+ this.pickingChanged = true;
1775
+ }
1776
+
1777
+ /**
1778
+ * Cache key for the per-cell range webGL buffer.
1779
+ *
1780
+ * @type {string}
1781
+ */
1782
+
1783
+ get selectionCacheKey() {
1784
+ return this._cacheKeys.selection;
1785
+ }
1786
+
1787
+ set selectionCacheKey(key) {
1788
+ this._cacheKeys.selection = key;
1789
+ }
1790
+
1791
+ /**
1792
+ * Cache key for most webGL buffers.
1793
+ *
1794
+ * @type {string}
1795
+ */
1796
+
1797
+ get markerCacheKey() {
1798
+ return this._cacheKeys.main;
1799
+ }
1800
+
1801
+ set markerCacheKey(key) {
1802
+ this._cacheKeys.main = key;
1803
+ }
1804
+
1805
+ /**
1806
+ * @function loadBuffers - loads segmentation mask data to WebGL
1807
+ */
1808
+ async loadBuffers() {
1809
+ const keys = this.colorCodedKeys;
1810
+ const rangeLists = this.selectRanges(keys);
1811
+ const changes = this.updateCache(keys, rangeLists);
1812
+ const { markersChanged, rangesChanged } = changes;
1813
+
1814
+ // Bind picked ids
1815
+ if (this.pickingChanged || rangesChanged) {
1816
+ this.bindPickings(this.glRenderer, this.pickedIds);
1817
+ this.pickingChanged = false;
1818
+ }
1819
+ // Bind buffers per-channel
1820
+ if (rangesChanged) {
1821
+ const ranges = [];
1822
+ for (const rangeTuple of rangeLists) {
1823
+ for (const value of rangeTuple) {
1824
+ ranges.push(value);
1825
+ }
1826
+ }
1827
+ this.bindRanges(this.glRenderer, ranges, 5);
1828
+ }
1829
+ // Bind or-mode buffers per-cell
1830
+ if (markersChanged) {
1831
+ const newKeys = keys.filter((k) => {
1832
+ return this.findMarkerTexture(k) == -1;
1833
+ });
1834
+ const m = await this.numericData.getAllFloat32Entries(newKeys);
1835
+ const nNew = newKeys.length;
1836
+ // Deinterleave in one linear pass instead of one full-array
1837
+ // .filter() per key (was O(nNew^2 * cellCount)).
1838
+ const perKey = newKeys.map(() => new Float32Array(m.length / nNew));
1839
+ for (let i = 0, o = 0; i < m.length; i += nNew, o++) {
1840
+ for (let ki = 0; ki < nNew; ki++) {
1841
+ perKey[ki][o] = m[i + ki];
1842
+ }
1843
+ }
1844
+ newKeys.forEach((k, ki) => {
1845
+ const mk = perKey[ki];
1846
+ // Attempt to bind marker magnitude texture
1847
+ try {
1848
+ this.bindMagnitudes(this.glRenderer, mk, k);
1849
+ } catch (e) {
1850
+ if (e instanceof TypeError) {
1851
+ console.warn(`Unable to bind ${k} marker texture.`);
1852
+ } else {
1853
+ throw e;
1854
+ }
1855
+ }
1856
+ });
1857
+ }
1858
+ }
1859
+
1860
+ /**
1861
+ * @function selectCenterProps - return cell centers properties
1862
+ * @param tile - openseadragon tile
1863
+ * @param source - openseadragon tile source
1864
+ * @typedef {object} CenterProps
1865
+ * @property {number} pie_radius_1f - radius of or-mode circles
1866
+ * @property {number} magnitude_2iv - the shape of each magnitude array
1867
+ * @property {number} id_end_1i - the last id in list of ids
1868
+ * @property {number} picked_end_1i - the last picked id index
1869
+ * @property {number} marker_sample_4iv - indices of magnitudes
1870
+ * @property {number} modes_2i - the currently active mode flags
1871
+ * @property {number} tile_fraction_1f - subtile fraction <=1
1872
+ * @property {number} tile_scale_1f - image tile scale >=1
1873
+ * @property {Array} x_bounds_2fv - subtile start/end in x
1874
+ * @property {Array} y_bounds_2fv - subtile start/end in y
1875
+ * @property {Array} origin_2fv - origin at texture resolution
1876
+ * @returns CenterProps
1877
+ */
1878
+ selectCenterProps(tile, source) {
1879
+ const renderer = this.glRenderer;
1880
+ const modes = this.modeFlags;
1881
+ const w = this.config.tileWidth;
1882
+ const h = this.config.tileHeight;
1883
+ const lastId = (this.idCount || 0) - 1;
1884
+ const lastPick = this.pickedIds.length - 1;
1885
+ const tileArgs = [tile.level, tile.x, tile.y];
1886
+ const tl = source.toTileLevels(...tileArgs);
1887
+ const { outputTile, relativeImageScale } = tl;
1888
+ const origin = [outputTile.x * w, outputTile.y * h];
1889
+ const bounds = source.toMagnifiedBounds(...tileArgs);
1890
+ // Assume uniform shape of all magnitude buffers
1891
+ const magnitude_2iv = this.toTextureShape(renderer.gl, this.idCount);
1892
+ const markerSamples = [0, 1, 2, 3].map((i) => {
1893
+ const label = renderer._markerTextures[i];
1894
+ return this.colorCodedKeys.indexOf(label);
1895
+ });
1896
+ return {
1897
+ pie_radius_1f: 8.5,
1898
+ magnitude_2iv: magnitude_2iv,
1899
+ id_end_1i: Math.max(lastId, 0),
1900
+ picked_end_1i: Math.max(lastPick, -1),
1901
+ modes_2i: [modes.edge, modes.or],
1902
+ key_end_1i: this.colorCodedKeys.length,
1903
+ marker_sample_4iv: markerSamples,
1904
+ tile_scale_1f: Math.max(relativeImageScale, 1.0),
1905
+ tile_fraction_1f: Math.min(relativeImageScale, 1.0),
1906
+ x_bounds_2fv: new Float32Array(bounds.x),
1907
+ y_bounds_2fv: new Float32Array(bounds.y),
1908
+ origin_2fv: new Float32Array(origin),
1909
+ };
1910
+ }
1911
+
1912
+ /**
1913
+ * @function selectRanges - per-marker threshold ranges for the shader
1914
+ * @param keys - active marker channels
1915
+ * @returns - lists of min, max, r, g, b values
1916
+ */
1917
+ selectRanges(keys) {
1918
+ const rangeLists = [];
1919
+ const selections = this.colorCodedRanges;
1920
+ for (const key of keys) {
1921
+ const range = selections[key].map((x) => parseFloat(x));
1922
+ const color = this.selectMaskColor(key);
1923
+ const floatColor = toFloatColor(color);
1924
+ const rangeTuple = range.concat(floatColor);
1925
+ rangeLists.push(rangeTuple);
1926
+ }
1927
+
1928
+ return rangeLists;
1929
+ }
1930
+
1931
+ /**
1932
+ * @function updateCache - update cache keys
1933
+ * @param keys - active marker channels
1934
+ * @param rangeLists - lists of min, max, r, g, b values
1935
+ * @typedef {object} Changes
1936
+ * @property {boolean} markersChanged - if marker lists have changed
1937
+ * @property {boolean} rangesChanged - if the range table changed
1938
+ * @returns Changes
1939
+ */
1940
+ updateCache(keys, rangeLists) {
1941
+ const markerCacheKey = this.toCacheKey(keys, []);
1942
+ const markersChanged = this.markerCacheKey !== markerCacheKey;
1943
+ if (markersChanged) {
1944
+ this.markerCacheKey = markerCacheKey;
1945
+ }
1946
+ const selectionCacheKey = this.toCacheKey(keys, rangeLists);
1947
+ const rangesChanged = this.selectionCacheKey !== selectionCacheKey;
1948
+ if (rangesChanged) {
1949
+ this.selectionCacheKey = selectionCacheKey;
1950
+ }
1951
+ return { markersChanged, rangesChanged };
1952
+ }
1953
+
1954
+ /**
1955
+ * @function selectMaskColor - select color for mask
1956
+ * @param channel - the channel label
1957
+ * @typedef {object} Color
1958
+ * @property {number} r - 0-255
1959
+ * @property {number} g - 0-255
1960
+ * @property {number} b - 0-255
1961
+ * @returns Color
1962
+ */
1963
+ selectMaskColor(channel) {
1964
+ const white = {
1965
+ r: 255,
1966
+ g: 255,
1967
+ b: 255,
1968
+ };
1969
+ if (!channel) {
1970
+ return white;
1971
+ }
1972
+ const channels = this.currentChannels;
1973
+ const idxString = (this.selectMaskIndex(channel) + 1).toString();
1974
+ if (idxString == "0" || !Object.keys(channels).includes(idxString)) {
1975
+ return white;
1976
+ }
1977
+ const data = channels[idxString];
1978
+ return data.color;
1979
+ }
1980
+
1981
+ /**
1982
+ * @function selectMaskIndex - select index for mask
1983
+ * @param channel - the channel label
1984
+ * @returns number
1985
+ */
1986
+ selectMaskIndex(channel) {
1987
+ const columns = this.channelList?.columns || [];
1988
+ return columns.indexOf(channel);
1989
+ }
1990
+
1991
+ /**
1992
+ * @function selectTexture - activate a WebGL texture
1993
+ * @param gl - the WebGL2 context
1994
+ * @param texture - the WebGL2 texture
1995
+ * @param idx - the texture index
1996
+ */
1997
+ selectTexture(gl, texture, idx) {
1998
+ if (texture === undefined) {
1999
+ throw new TypeError(`Cannot bind undefined to texture ${idx}.`);
2000
+ }
2001
+ // Set texture for GLSL
2002
+ gl.activeTexture(gl["TEXTURE" + idx]);
2003
+ gl.bindTexture(gl.TEXTURE_2D, texture);
2004
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1);
2005
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
2006
+
2007
+ // Assign texture parameters
2008
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
2009
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
2010
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
2011
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
2012
+ }
2013
+
2014
+ /**
2015
+ * @function toTextureShape - shape of texture data
2016
+ * @param gl - the WebGL2 context
2017
+ * @param length - the 1D length of the data
2018
+ * @returns Array
2019
+ */
2020
+ toTextureShape(gl, length) {
2021
+ // MAX_TEXTURE_SIZE is constant for the context, but gl.getParameter is a
2022
+ // synchronous round trip to the driver that stalls the GL pipeline --
2023
+ // and selectCenterProps() calls this once per tile per frame, so it was
2024
+ // running tens of times a frame purely to re-read a constant.
2025
+ if (this._maxTextureSize === undefined) {
2026
+ this._maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE);
2027
+ }
2028
+ const width = this._maxTextureSize;
2029
+ const height = Math.max(1, Math.ceil(length / width));
2030
+ return [width, height];
2031
+ }
2032
+
2033
+ /**
2034
+ * @function packFloat32 - pack Float32 Texture
2035
+ * @param a - the texture data as an array
2036
+ * @param width - the texture width
2037
+ * @param height - the texture height
2038
+ * @returns array
2039
+ */
2040
+ packFloat32(a, width, height) {
2041
+ // Create 2D array of pixels
2042
+ const full_size = width * height;
2043
+ const arr = new ArrayBuffer(4 * full_size);
2044
+ const view = new DataView(arr);
2045
+ for (const i in a) {
2046
+ view.setFloat32(4 * i, a[i], true);
2047
+ }
2048
+ return new Float32Array(arr);
2049
+ }
2050
+
2051
+ /**
2052
+ * @function packUint32 - pack Uint32 Texture
2053
+ * @param a - the texture data as an array
2054
+ * @param width - the texture width
2055
+ * @param height - the texture height
2056
+ * @returns array
2057
+ */
2058
+ packUint32(a, width, height) {
2059
+ // Create 2D array of pixels
2060
+ const full_size = width * height;
2061
+ const arr = new ArrayBuffer(4 * full_size);
2062
+ const view = new DataView(arr);
2063
+ for (const i in a) {
2064
+ view.setUint32(4 * i, a[i], true);
2065
+ }
2066
+ return new Uint8Array(arr);
2067
+ }
2068
+
2069
+ /**
2070
+ * @function setIntegerTexture - set an integer texture
2071
+ * @param gl - the WebGL2 context
2072
+ * @param idx - texture index
2073
+ * @param texture - the WebGL2 texture
2074
+ * @param values - the texture data as 2d array
2075
+ */
2076
+ setIntegerTexture(gl, idx, texture, values) {
2077
+ const [width, height] = this.toTextureShape(gl, values.length);
2078
+ const pixels = this.packUint32(values, width, height);
2079
+ // Set texture for GLSL
2080
+ this.selectTexture(gl, texture, idx);
2081
+ // Send an empty array to the texture
2082
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8UI, width, height, 0, gl.RGBA_INTEGER, gl.UNSIGNED_BYTE, pixels);
2083
+ }
2084
+
2085
+ /**
2086
+ * @function setFloatTexture - set a floating point texture
2087
+ * @param gl - the WebGL2 context
2088
+ * @param idx - texture index
2089
+ * @param texture - the WebGL2 texture
2090
+ * @param values - the texture data as 2d array
2091
+ * @param width - the texture width
2092
+ * @param height - the texture height
2093
+ */
2094
+ setFloatTexture(gl, idx, texture, values, width, height) {
2095
+ this.selectTexture(gl, texture, idx);
2096
+ const pixels = this.packFloat32(values, width, height);
2097
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.R32F, width, height, 0, gl.RED, gl.FLOAT, pixels);
2098
+ }
2099
+
2100
+ /**
2101
+ * @function bindLabels - bind segmentation mask ids
2102
+ * @param renderer - the GL renderer
2103
+ * @param values - the texture data as 2d array
2104
+ */
2105
+ bindLabels(renderer, values) {
2106
+ // Add id mask map
2107
+ const idx = this.indexOfTexture("ids", null);
2108
+ const ids_2iv = this.toTextureShape(renderer.gl, values.length);
2109
+ renderer.gl.uniform2iv(renderer.u_ids_shape, ids_2iv);
2110
+ this.setIntegerTexture(renderer.gl, idx, renderer.texture_ids, values);
2111
+ }
2112
+
2113
+ /**
2114
+ * @function bindMagnitudes - bind segmentation mask magnitudes
2115
+ * @param renderer - the GL renderer
2116
+ * @param values - the texture data as 2d array
2117
+ * @param key - the marker label
2118
+ */
2119
+ bindMagnitudes(renderer, values, key) {
2120
+ // Add a mask magnitude map
2121
+ const idx = this.indexOfTexture(key, "M");
2122
+ const texture = renderer.texture_mag[idx - renderer._markerOffset];
2123
+ const [width, height] = this.toTextureShape(renderer.gl, values.length);
2124
+ this.setFloatTexture(renderer.gl, idx, texture, values, width, height);
2125
+ }
2126
+
2127
+ /**
2128
+ * @function bindCenters - bind segmentation mask centers
2129
+ * @param renderer - the GL renderer
2130
+ * @param values - the texture data as 2d array
2131
+ */
2132
+ bindCenters(renderer, values) {
2133
+ // Add a mask center map
2134
+ const idx = this.indexOfTexture("centers", null);
2135
+ const [width, height] = this.toTextureShape(renderer.gl, values.length);
2136
+ renderer.gl.uniform3iv(renderer.u_center_shape, [width, height, 2]);
2137
+ this.setFloatTexture(renderer.gl, idx, renderer.texture_centers, values, width, height);
2138
+ }
2139
+
2140
+ /**
2141
+ * @function bindRanges - bind the per-cell range table
2142
+ * @param renderer - the GL renderer
2143
+ * @param values - the texture data as 2d array
2144
+ * @param width - the texture width
2145
+ */
2146
+ bindRanges(renderer, values, width) {
2147
+ // Add a mask range map
2148
+ const idx = this.indexOfTexture("ranges", null);
2149
+ const height = Math.floor(values.length / width);
2150
+ const range_2iv = [width, height];
2151
+ renderer.gl.uniform2iv(renderer.u_cell_range_shape, range_2iv);
2152
+ this.setFloatTexture(renderer.gl, idx, renderer.texture_ranges, values, width, height);
2153
+ }
2154
+
2155
+ /**
2156
+ * @function bindPickings - bind segmentation mask pickings
2157
+ * @param renderer - the GL renderer
2158
+ * @param values - the texture data as 2d array
2159
+ */
2160
+ bindPickings(renderer, values) {
2161
+ // Add a mask gating map
2162
+ const idx = this.indexOfTexture("pickings", null);
2163
+ const picking_2iv = this.toTextureShape(renderer.gl, values.length);
2164
+ renderer.gl.uniform2iv(renderer.u_picking_shape, picking_2iv);
2165
+ this.setIntegerTexture(renderer.gl, idx, renderer.texture_pickings, values);
2166
+ }
2167
+
2168
+ // =================================================================================================================
2169
+ // Tile cache management
2170
+ // =================================================================================================================
2171
+
2172
+ /**
2173
+ * @function createTFArray - creates an array of colors as a transfer/lookup table for pixel values.
2174
+ * @param min - the minimum value
2175
+ * @param max - the maximum value
2176
+ * @param rgb1 - the start color (min)
2177
+ * @param rgb2 - the end color (max)
2178
+ * @param numBins - the bins for the color interpolation steps
2179
+ * @typedef {object} TF
2180
+ * @property {Array} tf - color list
2181
+ * @property {number} min - min cutoff
2182
+ * @property {number} max - max cutoff
2183
+ * @property {number} num_bins - number of bins
2184
+ * @property {object} start_color - lower limit color
2185
+ * @property {object} end_color - upper limit color
2186
+ * @returns TF
2187
+ */
2188
+ createTFArray(min, max, rgb1, rgb2, numBins) {
2189
+ const tfArray = [];
2190
+
2191
+ const numBinsF = parseFloat(numBins);
2192
+ const col1 = d3.rgb(rgb1);
2193
+ const col2 = d3.rgb(rgb2);
2194
+
2195
+ for (let i = 0; i < numBins; i++) {
2196
+ const rgbTupel = {};
2197
+ const lerpFactor = i / (numBinsF - 1.0);
2198
+
2199
+ rgbTupel.r = col1.r + (col2.r - col1.r) * lerpFactor;
2200
+ rgbTupel.g = col1.g + (col2.g - col1.g) * lerpFactor;
2201
+ rgbTupel.b = col1.b + (col2.b - col1.b) * lerpFactor;
2202
+
2203
+ const lerpCol = d3.rgb(rgbTupel.r, rgbTupel.g, rgbTupel.b);
2204
+ tfArray.push(lerpCol);
2205
+ }
2206
+
2207
+ return {
2208
+ min: min,
2209
+ max: max,
2210
+ start_color: rgb1,
2211
+ end_color: rgb2,
2212
+ num_bins: numBins,
2213
+ tf: tfArray,
2214
+ };
2215
+ }
2216
+
2217
+ /**
2218
+ * @function forceRepaint - for all active viewers repaint the canvas
2219
+ */
2220
+ async forceRepaint() {
2221
+ if (!this.ready) {
2222
+ return;
2223
+ }
2224
+ this.ready = false;
2225
+ if (this.idCount) {
2226
+ await this.loadBuffers();
2227
+ }
2228
+ this.ready = true;
2229
+ // Trigger change of full cache
2230
+ this.viewerManagers.forEach(({ viewer }) => {
2231
+ viewer.forceRedraw();
2232
+ });
2233
+ }
2234
+ /**
2235
+ * @function scheduleRepaint - coalesce repaints onto the next animation frame
2236
+ *
2237
+ * For anything driven by a continuous gesture. The contrast sliders emit a
2238
+ * BRUSH_MOVE per input event (viewerSidebar.js -> main.js ->
2239
+ * updateChannelRange), and each one used to run a full forceRepaint, so a
2240
+ * single drag queued dozens of complete redraws of every visible tile of
2241
+ * every channel. Collapsing them to one repaint per frame keeps the drag
2242
+ * responsive without changing what is ultimately drawn.
2243
+ */
2244
+ scheduleRepaint() {
2245
+ if (this._repaintScheduled) {
2246
+ return;
2247
+ }
2248
+ this._repaintScheduled = true;
2249
+ requestAnimationFrame(() => {
2250
+ this._repaintScheduled = false;
2251
+ this.forceRepaint();
2252
+ });
2253
+ }
2254
+
2255
+
2256
+ /**
2257
+ * @function updateActiveChannels
2258
+ * @param name - image channel name
2259
+ * @param action - "add" or "remove"
2260
+ */
2261
+ updateActiveChannels(name, action) {
2262
+ const channelIdx = imageChannels[name];
2263
+
2264
+ if (action == "add") {
2265
+ this.viewerManagers.forEach((vM) => {
2266
+ vM.channel_add(channelIdx);
2267
+ });
2268
+ } else {
2269
+ this.viewerManagers.forEach((vM) => {
2270
+ vM.channel_remove(channelIdx);
2271
+ });
2272
+ }
2273
+
2274
+ // A no-op while the lens is closed, which is what lets this sit on a
2275
+ // channel mutator at all. Only this one may need to fetch: a colour or
2276
+ // range change is served from the mini-map's cached greyscale.
2277
+ this.miniMap?.invalidate({ refetch: true });
2278
+ this.forceRepaint();
2279
+ }
2280
+
2281
+ /**
2282
+ * @function updateChannelRange
2283
+ * @param name - image channel name
2284
+ * @param tfmin - minimum
2285
+ * @param tfmax - maximum
2286
+ */
2287
+ updateChannelRange(name, tfmin, tfmax) {
2288
+ // In the default (non-HD) mode tfmin/tfmax already arrive in [0, 255]
2289
+ // byte units (see viewerSidebar.js) -- divide by 255, not the full
2290
+ // 16-bit range, so this matches what the shader's u8 path now reads
2291
+ // u_tile_range as directly (see frag.glsl's u8_r_range). HD mode
2292
+ // keeps the original raw-16-bit-unit behavior.
2293
+ const hd = Boolean(this.viewerManagerVMain?.isHdMode?.());
2294
+ const range = hd ? this.numericData.intRange : [0, 255];
2295
+ const channelIdx = imageChannels[name];
2296
+ if (this.currentChannels[channelIdx]) {
2297
+ let channelRange = [tfmin / range[1], tfmax / range[1]];
2298
+ this.currentChannels[channelIdx].range = channelRange;
2299
+ this.channelList.rangeConnector[channelIdx] = channelRange;
2300
+ }
2301
+ this.miniMap?.invalidate();
2302
+ this.scheduleRepaint();
2303
+ }
2304
+
2305
+ /**
2306
+ * @function updateChannelColors
2307
+ * @param name - image channel name
2308
+ * @param color - rgb object with values 0-255
2309
+ */
2310
+ updateChannelColors(name, color) {
2311
+ const channelIdx = imageChannels[name];
2312
+ if (this.currentChannels[channelIdx]) {
2313
+ this.channelList.colorConnector[channelIdx] = { color: color };
2314
+ this.currentChannels[channelIdx].color = color;
2315
+ }
2316
+ // Also gesture-driven (dragging in the colour picker), so coalesce.
2317
+ this.miniMap?.invalidate();
2318
+ this.scheduleRepaint();
2319
+ }
2320
+
2321
+ /**
2322
+ * @function updateRenderingMode
2323
+ * @param mode - subset or selection
2324
+ */
2325
+ updateRenderingMode(mode) {
2326
+ // Mode is a string: 'show-subset', 'show-selection'
2327
+ if (mode === "show-subset") {
2328
+ this.show_subset = !this.show_subset;
2329
+ }
2330
+ if (mode === "show-selection") {
2331
+ this.show_selection = !this.show_selection;
2332
+ }
2333
+
2334
+ this.forceRepaint();
2335
+ }
2336
+
2337
+ async updateCentroidVisibility(isVisible) {
2338
+ this.show_centroids = isVisible;
2339
+ if (isVisible) {
2340
+ await this.ensureCentroidsReady(true);
2341
+ this.scheduleCentroidTileUpdate(0, true);
2342
+ }
2343
+ this.refreshCentroidOverlay();
2344
+ }
2345
+
2346
+ async updateCentroidFallback(isFallback) {
2347
+ // Only ever called with true (turn centroids on as a one-time default/
2348
+ // fallback, e.g. no segmentation registered or outlines failed to load).
2349
+ // Routes through updateCentroidVisibility so show_centroids and the
2350
+ // Cells control agree from the start -- previously this set a separate
2351
+ // force_centroids flag that shouldDrawCentroids() OR'd in permanently,
2352
+ // so turning centroids off afterward couldn't actually do it.
2353
+ // Remembered so a mask that lands later can tell "centroids because
2354
+ // there was nothing else" from "centroids because the user asked", and
2355
+ // take the drawing over only in the first case.
2356
+ this.centroidsFromFallback = Boolean(isFallback);
2357
+ if (!isFallback) return;
2358
+ await this.updateCentroidVisibility(true);
2359
+ // The control has to show what is actually drawn. Through adoptMode
2360
+ // rather than the ordinary selection path: the drawing has already been
2361
+ // switched above, and re-entering that path would recurse -- this method
2362
+ // is called from inside it, when a mask fails to load.
2363
+ window.__plexora?.viewerControls?.adoptMode?.("centroids");
2364
+ }
2365
+
2366
+ shouldDrawCentroids() {
2367
+ return this.show_centroids;
2368
+ }
2369
+
2370
+ updateCentroidIds() {
2371
+ this.scheduleCentroidTileUpdate(0, true);
2372
+ }
2373
+
2374
+ updateCentroidFilter(filter = {}, showSpinner = false) {
2375
+ this.centroidFilter = filter || {};
2376
+ const signature = JSON.stringify(this.centroidFilter);
2377
+ if (signature !== this.centroidFilterSignature) {
2378
+ this.centroidFilterSignature = signature;
2379
+ this.centroidTiles.clear();
2380
+ this.centroidTileRequest += 1;
2381
+ }
2382
+ if (this.centroidMode === "legacy") {
2383
+ const hasFilter = !!(this.centroidFilter && Object.keys(this.centroidFilter).length);
2384
+ const provider = this.cellLayer;
2385
+ const idsPromise = (hasFilter && provider)
2386
+ ? provider.getSelectedIds(this.centroidFilter)
2387
+ : Promise.resolve(null);
2388
+ idsPromise.then((ids) => {
2389
+ this.centroidIdSet = hasFilter ? (ids instanceof Set ? ids : new Set()) : null;
2390
+ this.refreshCentroidOverlay();
2391
+ });
2392
+ return;
2393
+ }
2394
+ this.scheduleCentroidTileUpdate(0, showSpinner);
2395
+ this.refreshCentroidOverlay();
2396
+ }
2397
+
2398
+ refreshCentroidOverlay() {
2399
+ if (this.canvasOverlay) {
2400
+ this.canvasOverlay.resize();
2401
+ this.canvasOverlay.clear();
2402
+ this.canvasOverlay._updateCanvas();
2403
+ }
2404
+ }
2405
+
2406
+ async ensureCentroidsReady(showSpinner = false) {
2407
+ if (this.centroidsReady) return;
2408
+ if (this.centroidsLoading) {
2409
+ await this.centroidsLoading;
2410
+ return;
2411
+ }
2412
+ this.centroidsLoading = (async () => {
2413
+ if (showSpinner) {
2414
+ this.setLoading(true);
2415
+ }
2416
+ try {
2417
+ this.centroidManifest = await this.dataLayer.getCentroidManifest();
2418
+ if (!this.centroidManifest) {
2419
+ const { ids, centers } = await this.numericData.loadCells();
2420
+ this.ids = ids || [];
2421
+ this.centers = centers || [];
2422
+ this.prepareLegacyCentroidCache();
2423
+ this.centroidMode = "legacy";
2424
+ } else {
2425
+ this.centroidMode = "tiled";
2426
+ }
2427
+ this.centroidsReady = true;
2428
+ } finally {
2429
+ if (showSpinner) {
2430
+ this.setLoading(false);
2431
+ }
2432
+ this.centroidsLoading = null;
2433
+ }
2434
+ })();
2435
+ await this.centroidsLoading;
2436
+ }
2437
+
2438
+ scheduleCentroidTileUpdate(delay = 100, showSpinner = false) {
2439
+ if (!this.shouldDrawCentroids() || !this.centroidsReady) return;
2440
+ if (this.centroidMode === "legacy") {
2441
+ this.refreshCentroidOverlay();
2442
+ return;
2443
+ }
2444
+ if (this.centroidTileTimer) {
2445
+ clearTimeout(this.centroidTileTimer);
2446
+ }
2447
+ this.centroidTileTimer = setTimeout(() => {
2448
+ this.updateVisibleCentroidTiles(showSpinner);
2449
+ }, delay);
2450
+ }
2451
+
2452
+ async updateVisibleCentroidTiles(showSpinner = false) {
2453
+ if (!this.shouldDrawCentroids() || !this.centroidManifest || !this.viewer?.viewport) return;
2454
+ const tileState = this.getVisibleCentroidTileState();
2455
+ if (!tileState) return;
2456
+ const { level, tiles, keepKeys } = tileState;
2457
+ for (const key of this.centroidTiles.keys()) {
2458
+ if (!keepKeys.has(key)) {
2459
+ this.centroidTiles.delete(key);
2460
+ }
2461
+ }
2462
+ const missing = tiles.filter((tile) => !this.centroidTiles.has(tile.key));
2463
+ if (!missing.length) {
2464
+ this.refreshCentroidOverlay();
2465
+ return;
2466
+ }
2467
+ const requestId = ++this.centroidTileRequest;
2468
+ const shouldSpin = showSpinner || this.centroidFirstLoad;
2469
+ if (shouldSpin) {
2470
+ this.setLoading(true);
2471
+ }
2472
+ try {
2473
+ const payload = missing.map(({ x, y }) => ({ x, y }));
2474
+ const buffer = await this.dataLayer.getCentroidTiles(level, payload, this.centroidFilter, 50000);
2475
+ if (requestId !== this.centroidTileRequest || !buffer) return;
2476
+ const grouped = this.decodeCentroidTileBuffer(buffer, level);
2477
+ for (const tile of missing) {
2478
+ this.centroidTiles.set(tile.key, grouped.get(tile.key) || {
2479
+ ids: new Uint32Array(0),
2480
+ centers: new Float32Array(0),
2481
+ });
2482
+ }
2483
+ this.centroidFirstLoad = false;
2484
+ this.refreshCentroidOverlay();
2485
+ } finally {
2486
+ if (shouldSpin) {
2487
+ this.setLoading(false);
2488
+ }
2489
+ }
2490
+ }
2491
+
2492
+ prepareLegacyCentroidCache() {
2493
+ const centers = this.centers || [];
2494
+ const ids = this.ids || [];
2495
+ const zoomScale = 2 ** (this.config.extraZoomLevels || 0);
2496
+ this.fullResolutionCenters = new Float32Array(centers.length);
2497
+ this.idToCenterOffset = new Map();
2498
+ // Coarse spatial bucket index (same floor-by-tile-span idea as
2499
+ // centroid_tiles.py's tile bucketing) so a full redraw only walks
2500
+ // points near the current viewport instead of every cell.
2501
+ this.legacyCentroidBucketSpan = Math.max(1, this.config.tileWidth || 512);
2502
+ this.legacyCentroidBuckets = new Map();
2503
+ for (let i = 0; i < centers.length; i += 2) {
2504
+ const x = centers[i] * zoomScale;
2505
+ const y = centers[i + 1] * zoomScale;
2506
+ this.fullResolutionCenters[i] = x;
2507
+ this.fullResolutionCenters[i + 1] = y;
2508
+ if (ids[i / 2] !== undefined) {
2509
+ this.idToCenterOffset.set(Number(ids[i / 2]), i);
2510
+ }
2511
+ const bucketKey = this.legacyCentroidBucketKey(x, y);
2512
+ let bucket = this.legacyCentroidBuckets.get(bucketKey);
2513
+ if (!bucket) {
2514
+ bucket = [];
2515
+ this.legacyCentroidBuckets.set(bucketKey, bucket);
2516
+ }
2517
+ bucket.push(i);
2518
+ }
2519
+ }
2520
+
2521
+ legacyCentroidBucketKey(x, y) {
2522
+ const span = this.legacyCentroidBucketSpan;
2523
+ return `${Math.floor(x / span)}_${Math.floor(y / span)}`;
2524
+ }
2525
+
2526
+ getVisibleCentroidTileState() {
2527
+ const item = this.viewer.world.getItemAt(0);
2528
+ if (!item) return null;
2529
+ const bounds = this.viewer.viewport.getBounds(true);
2530
+ const imageBounds = item.viewportToImageRectangle(bounds);
2531
+ const coordinateScale = 2 ** (this.config.extraZoomLevels || 0);
2532
+ const sourceBounds = {
2533
+ x: imageBounds.x / coordinateScale,
2534
+ y: imageBounds.y / coordinateScale,
2535
+ width: imageBounds.width / coordinateScale,
2536
+ height: imageBounds.height / coordinateScale,
2537
+ };
2538
+ const level = this.getCentroidLevel();
2539
+ const tileSpan = this.centroidManifest.tile_size * (2 ** level);
2540
+ const maxTx = Math.max(0, Math.ceil(this.centroidManifest.width / tileSpan) - 1);
2541
+ const maxTy = Math.max(0, Math.ceil(this.centroidManifest.height / tileSpan) - 1);
2542
+ const minX = Math.max(0, Math.floor(sourceBounds.x / tileSpan) - 1);
2543
+ const minY = Math.max(0, Math.floor(sourceBounds.y / tileSpan) - 1);
2544
+ const maxX = Math.min(maxTx, Math.floor((sourceBounds.x + sourceBounds.width) / tileSpan) + 1);
2545
+ const maxY = Math.min(maxTy, Math.floor((sourceBounds.y + sourceBounds.height) / tileSpan) + 1);
2546
+ const tiles = [];
2547
+ const keepKeys = new Set();
2548
+ for (let y = minY; y <= maxY; y += 1) {
2549
+ for (let x = minX; x <= maxX; x += 1) {
2550
+ const key = this.centroidTileKey(level, x, y);
2551
+ tiles.push({ level, x, y, key });
2552
+ keepKeys.add(key);
2553
+ }
2554
+ }
2555
+ return { level, tiles, keepKeys };
2556
+ }
2557
+
2558
+ getCentroidLevel() {
2559
+ const item = this.viewer.world.getItemAt(0);
2560
+ let imageZoom = 1;
2561
+ try {
2562
+ imageZoom = item.viewportToImageZoom(this.viewer.viewport.getZoom(true));
2563
+ } catch (e) {
2564
+ imageZoom = 1;
2565
+ }
2566
+ const level = Math.floor(Math.max(0, Math.log2(1 / Math.max(imageZoom, 0.0001))));
2567
+ return Math.min(Math.max(0, level), Math.max(0, (this.centroidManifest?.level_count || 1) - 1));
2568
+ }
2569
+
2570
+ centroidTileKey(level, x, y) {
2571
+ return `${level}/${x}/${y}`;
2572
+ }
2573
+
2574
+ decodeCentroidTileBuffer(buffer, level) {
2575
+ const view = new DataView(buffer);
2576
+ const groups = new Map();
2577
+ const tileSpan = this.centroidManifest.tile_size * (2 ** level);
2578
+ const coordinateScale = 2 ** (this.config.extraZoomLevels || 0);
2579
+ for (let offset = 0; offset + 12 <= view.byteLength; offset += 12) {
2580
+ const id = view.getUint32(offset, true);
2581
+ const x = view.getFloat32(offset + 4, true);
2582
+ const y = view.getFloat32(offset + 8, true);
2583
+ const tx = Math.max(0, Math.floor(x / tileSpan));
2584
+ const ty = Math.max(0, Math.floor(y / tileSpan));
2585
+ const key = this.centroidTileKey(level, tx, ty);
2586
+ if (!groups.has(key)) {
2587
+ groups.set(key, { ids: [], centers: [] });
2588
+ }
2589
+ const group = groups.get(key);
2590
+ group.ids.push(id);
2591
+ group.centers.push(x * coordinateScale, y * coordinateScale);
2592
+ }
2593
+ const typedGroups = new Map();
2594
+ groups.forEach((group, key) => {
2595
+ typedGroups.set(key, {
2596
+ ids: new Uint32Array(group.ids),
2597
+ centers: new Float32Array(group.centers),
2598
+ });
2599
+ });
2600
+ return typedGroups;
2601
+ }
2602
+
2603
+ async ensureSegmentationReady(showSpinner = false) {
2604
+ if (this.segmentationReady || this.noLabel) return;
2605
+ if (this.segmentationLoading) {
2606
+ await this.segmentationLoading;
2607
+ return;
2608
+ }
2609
+ this.segmentationLoading = (async () => {
2610
+ if (showSpinner) {
2611
+ this.setLoading(true);
2612
+ }
2613
+ try {
2614
+ if (!this.centers.length || !this.ids.length) {
2615
+ const { ids, centers } = await this.numericData.loadCells();
2616
+ this.ids = ids || [];
2617
+ this.centers = centers || [];
2618
+ }
2619
+ this.bindSegmentationBuffers(this.ids, this.centers);
2620
+ this.viewerManagerVMain.load_label_image();
2621
+ this.segmentationReady = true;
2622
+ this.clearTileCache(true);
2623
+ await this.forceRepaint();
2624
+ } finally {
2625
+ if (showSpinner) {
2626
+ this.setLoading(false);
2627
+ }
2628
+ this.segmentationLoading = null;
2629
+ }
2630
+ })();
2631
+ await this.segmentationLoading;
2632
+ }
2633
+
2634
+ /**
2635
+ * @function updateSegmentationFilter - restrict one layer to a set of cells.
2636
+ *
2637
+ * @param name - which layer's gate to write. Defaults to the active layer;
2638
+ * pass a name explicitly from a plugin's own gate flow so a tool the user
2639
+ * has switched away from still updates its OWN cells rather than whichever
2640
+ * layer happens to be active. Falls back to core's gate when nothing is
2641
+ * registered at all.
2642
+ */
2643
+ async updateSegmentationFilter(filter = {}, showSpinner = false, name = undefined) {
2644
+ if (this.noLabel || !this.viewerManagerVMain?.sel_outlines) return;
2645
+ const target = name === undefined ? this._activeCellLayer : name;
2646
+ const layer = target ? this._cellLayers.get(target) : null;
2647
+ // A named layer that has since been removed: nothing to filter, and
2648
+ // nothing to fall through to -- writing core's gate here would apply a
2649
+ // dead tool's selection to every other layer on screen.
2650
+ if (target && !layer) return;
2651
+ const requestId = layer
2652
+ ? (layer.filterRequest += 1)
2653
+ : (this.segmentationFilterRequest += 1);
2654
+ const current = () => (layer ? layer.filterRequest : this.segmentationFilterRequest);
2655
+ const gates = filter || {};
2656
+ const hasGates = Object.keys(gates).length > 0;
2657
+ const provider = layer ? layer.provider : null;
2658
+ if (showSpinner) {
2659
+ this.setLoading(true);
2660
+ }
2661
+ try {
2662
+ await this.ensureSegmentationReady(false);
2663
+ let ids = null;
2664
+ if (hasGates && provider?.getSelectedIds) {
2665
+ const selected = await provider.getSelectedIds(gates);
2666
+ if (requestId !== current()) return;
2667
+ ids = selected instanceof Set ? selected : null;
2668
+ }
2669
+ if (layer) {
2670
+ layer.filterIds = ids;
2671
+ } else {
2672
+ this.segmentationFilterIds = ids;
2673
+ }
2674
+ this.rerenderSegmentationTiles(layer ? layer.name : null);
2675
+ this.viewer.forceRedraw();
2676
+ } finally {
2677
+ if (showSpinner) {
2678
+ this.setLoading(false);
2679
+ }
2680
+ }
2681
+ }
2682
+
2683
+ /** Walk every loaded label tile. The tilesMatrix nesting is OpenSeadragon's,
2684
+ * and three call sites needed the same four loops to reach through it. */
2685
+ forEachLabelTile(visit) {
2686
+ if (!this.viewer?.world) return;
2687
+ for (let i = 0; i < this.viewer.world.getItemCount(); i += 1) {
2688
+ const item = this.viewer.world.getItemAt(i);
2689
+ if (item?.source?.tileFormat != 32) continue;
2690
+ const matrix = item.tilesMatrix || {};
2691
+ Object.keys(matrix).forEach((level) => {
2692
+ Object.keys(matrix[level] || {}).forEach((x) => {
2693
+ Object.keys(matrix[level][x] || {}).forEach((y) => {
2694
+ const tile = matrix[level][x][y];
2695
+ if (tile) visit(tile);
2696
+ });
2697
+ });
2698
+ });
2699
+ }
2700
+ }
2701
+
2702
+ /**
2703
+ * @function renderTileLayers - build one tile's canvas per drawn layer.
2704
+ *
2705
+ * One decoded array, N passes over it, N cached canvases -- which is the
2706
+ * whole reason several layers can be stacked without refetching a thing.
2707
+ *
2708
+ * @param tile - an OpenSeadragon label tile carrying `_array`
2709
+ * @param draw - the layers to draw, from maskDrawList()
2710
+ * @param only - rebuild just this layer's canvas, and CREATE any layer that
2711
+ * has none yet (that is what repopulates a layer the user turned back on).
2712
+ * Null rebuilds everything.
2713
+ */
2714
+ renderTileLayers(tile, draw = null, only = null) {
2715
+ if (!this.renderLabelTile || !tile?._array) return null;
2716
+ const width = tile._labelWidth;
2717
+ const height = tile._labelHeight;
2718
+ if (!width || !height) return tile._layerContexts || null;
2719
+ let contexts = tile._layerContexts;
2720
+ if (!contexts) {
2721
+ contexts = new Map();
2722
+ tile._layerContexts = contexts;
2723
+ }
2724
+ const layers = draw || this.maskDrawList();
2725
+ const wanted = new Set();
2726
+ for (const layer of layers) {
2727
+ wanted.add(layer.name);
2728
+ if (only && layer.name !== only && contexts.has(layer.name)) continue;
2729
+ contexts.set(layer.name,
2730
+ this.renderLabelTile(tile._array, width, height, layer));
2731
+ }
2732
+ // A layer that has left the stack releases its canvas now rather than
2733
+ // waiting for the tile itself to be evicted.
2734
+ for (const name of [...contexts.keys()]) {
2735
+ if (!wanted.has(name)) contexts.delete(name);
2736
+ }
2737
+ return contexts;
2738
+ }
2739
+
2740
+ /** Release one layer's cached canvases across every loaded label tile. Its
2741
+ * lookup table is deliberately kept -- see setCellLayerVisible. */
2742
+ dropLayerContexts(name) {
2743
+ this.forEachLabelTile((tile) => {
2744
+ tile._layerContexts?.delete(name);
2745
+ });
2746
+ }
2747
+
2748
+ /**
2749
+ * @function rerenderSegmentationTiles - rebuild the label tiles' pixels.
2750
+ *
2751
+ * @param name - rebuild only this layer's canvases. Layers that are in the
2752
+ * stack but have no canvas for a tile are still created, so this doubles
2753
+ * as the "bring a re-shown layer back" path.
2754
+ */
2755
+ rerenderSegmentationTiles(name = null) {
2756
+ if (!this.renderLabelTile) return;
2757
+ const draw = this.maskDrawList();
2758
+ this.forEachLabelTile((tile) => {
2759
+ if (!tile._array || !tile._layerContexts) return;
2760
+ this.renderTileLayers(tile, draw, name);
2761
+ });
2762
+ }
2763
+
2764
+ bindSegmentationBuffers(ids, centers) {
2765
+ if (!ids?.length || !centers?.length) return;
2766
+ const renderer = this.glRenderer;
2767
+ renderer.texture_mag = renderer.texture_mag || [renderer.gl.createTexture(), renderer.gl.createTexture(), renderer.gl.createTexture(), renderer.gl.createTexture()];
2768
+ renderer.texture_ids = renderer.texture_ids || renderer.gl.createTexture();
2769
+ renderer.texture_centers = renderer.texture_centers || renderer.gl.createTexture();
2770
+ renderer.texture_pickings = renderer.texture_pickings || renderer.gl.createTexture();
2771
+ renderer.texture_ranges = renderer.texture_ranges || renderer.gl.createTexture();
2772
+ this.bindCenters(renderer, centers);
2773
+ this.bindPickings(renderer, this.pickedIds || []);
2774
+ this.bindLabels(renderer, ids);
2775
+ this.idCount = ids.length;
2776
+ }
2777
+
2778
+ initProjectLabel() {
2779
+ const wrapper = document.getElementById("openseadragon_wrapper");
2780
+ if (!wrapper || document.getElementById("viewer_project_label")) return;
2781
+ const label = document.createElement("div");
2782
+ label.id = "viewer_project_label";
2783
+ label.className = "viewer-project-label";
2784
+ label.textContent = datasource || "";
2785
+ wrapper.appendChild(label);
2786
+ }
2787
+ initLegend() {
2788
+ const wrapper = document.getElementById("openseadragon_wrapper");
2789
+ if (!wrapper || document.getElementById("viewer_channel_legend")) return;
2790
+ const legend = document.createElement("div");
2791
+ legend.id = "viewer_channel_legend";
2792
+ legend.className = "viewer-channel-legend";
2793
+ wrapper.appendChild(legend);
2794
+ this.eventHandler.bind(ChannelList.events.COLOR_TRANSFER_CHANGE, () => this.updateLegend());
2795
+ this.eventHandler.bind(ChannelList.events.CHANNELS_CHANGE, () => this.updateLegend());
2796
+ this.updateLegend();
2797
+ }
2798
+
2799
+ /**
2800
+ * The overview lens in the bottom-left corner. Built here alongside the
2801
+ * project label and the channel legend because it is the same kind of
2802
+ * thing: a DOM overlay on #openseadragon_wrapper that core owns.
2803
+ *
2804
+ * Guarded on `typeof` rather than window.MiniMap -- `class` in a classic
2805
+ * script creates a global LEXICAL binding, which never becomes a property
2806
+ * of window, so window.MiniMap is undefined even when the script loaded.
2807
+ *
2808
+ * Everything expensive about the mini-map is deferred to the first time
2809
+ * the user opens it; constructing it here costs a few DOM nodes.
2810
+ */
2811
+ initMiniMap() {
2812
+ if (typeof MiniMap === "undefined") {
2813
+ return;
2814
+ }
2815
+ this.miniMap = new MiniMap(this);
2816
+ }
2817
+
2818
+ getActiveLegendChannels() {
2819
+ return (window.__plexora?.viewerSidebar?.channelSlots || []).filter((slot) => slot.enabled && slot.name);
2820
+ }
2821
+
2822
+ updateLegend() {
2823
+ const legend = document.getElementById("viewer_channel_legend");
2824
+ if (!legend) return;
2825
+ const active = this.getActiveLegendChannels();
2826
+ legend.innerHTML = active.map((slot) => '<span class="legend-row"><span class="legend-swatch" style="background:' + slot.colorHex + '"></span><span class="legend-name">' + slot.name + '</span></span>').join("");
2827
+ legend.style.display = active.length ? "flex" : "none";
2828
+ }
2829
+
2830
+ drawLegendOnCanvas(ctx, width, height) {
2831
+ const active = this.getActiveLegendChannels();
2832
+ if (!active.length) return;
2833
+ const rowH = 26;
2834
+ const padding = 12;
2835
+ const swatchSize = 15;
2836
+ ctx.font = "15px sans-serif";
2837
+ const textWidths = active.map((slot) => ctx.measureText(slot.name).width);
2838
+ const boxW = Math.max.apply(null, textWidths) + swatchSize + padding * 2 + 8;
2839
+ const boxH = active.length * rowH + padding * 2;
2840
+ const x = width - boxW - 16;
2841
+ const y = height - boxH - 60;
2842
+ ctx.fillStyle = "rgba(17, 24, 39, 0.86)";
2843
+ ctx.fillRect(x, y, boxW, boxH);
2844
+ active.forEach((slot, i) => {
2845
+ const rowY = y + padding + i * rowH;
2846
+ ctx.fillStyle = slot.colorHex;
2847
+ ctx.fillRect(x + padding, rowY, swatchSize, swatchSize);
2848
+ ctx.fillStyle = "#f1f5f9";
2849
+ ctx.fillText(slot.name, x + padding + swatchSize + 8, rowY + swatchSize);
2850
+ });
2851
+ }
2852
+
2853
+ // Maps a DOM overlay element's on-screen box into the coordinate space
2854
+ // of the full-resolution OSD drawer canvas, so PDF export can place
2855
+ // vector shapes/text exactly where the same overlay appears on screen
2856
+ // (canvas.width/height is the backing-store pixel size; getBoundingClientRect
2857
+ // is CSS layout size -- the ratio between them is the scale factor).
2858
+ getOverlayRectInCanvasSpace(el) {
2859
+ const canvasEl = this.viewer?.drawer?.canvas;
2860
+ if (!canvasEl || !el) return null;
2861
+ const canvasRect = canvasEl.getBoundingClientRect();
2862
+ const elRect = el.getBoundingClientRect();
2863
+ if (!canvasRect.width || !canvasRect.height) return null;
2864
+ const scaleX = canvasEl.width / canvasRect.width;
2865
+ const scaleY = canvasEl.height / canvasRect.height;
2866
+ return {
2867
+ x: (elRect.left - canvasRect.left) * scaleX,
2868
+ y: (elRect.top - canvasRect.top) * scaleY,
2869
+ width: elRect.width * scaleX,
2870
+ height: elRect.height * scaleY,
2871
+ scale: scaleX,
2872
+ };
2873
+ }
2874
+
2875
+ // Vector (line + real text, not a rasterized image) redraw of the
2876
+ // on-screen scale bar for PDF export -- positioned/sized from the live
2877
+ // DOM element rather than recomputing the microscopy scalebar plugin's
2878
+ // own round-number sizing logic a second time.
2879
+ drawScalebarVector(pdf) {
2880
+ const instance = this.viewer?.scalebarInstance;
2881
+ const element = instance?.divElt;
2882
+ if (!element || element.style.display === "none") return;
2883
+ const rect = this.getOverlayRectInCanvasSpace(element);
2884
+ if (!rect || !rect.width) return;
2885
+ const barY = rect.y + rect.height;
2886
+ pdf.setDrawColor(255, 255, 255);
2887
+ pdf.setLineWidth(Math.max(1, (instance.barThickness || 3) * rect.scale));
2888
+ pdf.line(rect.x, barY, rect.x + rect.width, barY);
2889
+ pdf.setFont("helvetica", "normal");
2890
+ pdf.setFontSize(Math.max(6, (parseFloat(instance.fontSize) || 12) * rect.scale));
2891
+ pdf.setTextColor(255, 255, 255);
2892
+ pdf.text(element.textContent || "", rect.x + rect.width / 2, rect.y + rect.height / 2, {
2893
+ align: "center",
2894
+ baseline: "middle",
2895
+ });
2896
+ }
2897
+
2898
+ // Vector redraw of the channel legend for PDF export: a real filled
2899
+ // rect per swatch and real text per name (editable in Illustrator),
2900
+ // positioned from the on-screen legend DOM so it matches item-for-item.
2901
+ drawLegendVector(pdf) {
2902
+ const legendEl = document.getElementById("viewer_channel_legend");
2903
+ const active = this.getActiveLegendChannels();
2904
+ if (!legendEl || !active.length || legendEl.style.display === "none") return;
2905
+ const rect = this.getOverlayRectInCanvasSpace(legendEl);
2906
+ if (!rect || !rect.width) return;
2907
+
2908
+ pdf.saveGraphicsState();
2909
+ pdf.setGState(new pdf.GState({ opacity: 0.86 }));
2910
+ pdf.setFillColor(17, 24, 39);
2911
+ pdf.roundedRect(rect.x, rect.y, rect.width, rect.height, 3 * rect.scale, 3 * rect.scale, "F");
2912
+ pdf.restoreGraphicsState();
2913
+
2914
+ const rows = legendEl.querySelectorAll(".legend-row");
2915
+ pdf.setFont("helvetica", "normal");
2916
+ rows.forEach((row, i) => {
2917
+ const slot = active[i];
2918
+ const swatchRect = this.getOverlayRectInCanvasSpace(row.querySelector(".legend-swatch"));
2919
+ if (!slot || !swatchRect) return;
2920
+ const { r, g, b } = hexToRgb(slot.colorHex);
2921
+ pdf.setFillColor(r, g, b);
2922
+ pdf.rect(swatchRect.x, swatchRect.y, swatchRect.width, swatchRect.height, "F");
2923
+ pdf.setFontSize(Math.max(6, 15 * rect.scale));
2924
+ pdf.setTextColor(241, 245, 249);
2925
+ pdf.text(slot.name, swatchRect.x + swatchRect.width + 8 * rect.scale, swatchRect.y + swatchRect.height / 2, {
2926
+ baseline: "middle",
2927
+ });
2928
+ });
2929
+ }
2930
+
2931
+ // Vector redraw of the top-left project-name label for PDF export.
2932
+ drawProjectLabelVector(pdf) {
2933
+ const labelEl = document.getElementById("viewer_project_label");
2934
+ if (!labelEl) return;
2935
+ const rect = this.getOverlayRectInCanvasSpace(labelEl);
2936
+ if (!rect || !rect.width) return;
2937
+
2938
+ pdf.saveGraphicsState();
2939
+ pdf.setGState(new pdf.GState({ opacity: 0.86 }));
2940
+ pdf.setFillColor(17, 24, 39);
2941
+ pdf.roundedRect(rect.x, rect.y, rect.width, rect.height, 3 * rect.scale, 3 * rect.scale, "F");
2942
+ pdf.restoreGraphicsState();
2943
+
2944
+ pdf.setFont("helvetica", "bold");
2945
+ pdf.setFontSize(Math.max(6, 13 * rect.scale));
2946
+ pdf.setTextColor(241, 245, 249);
2947
+ pdf.text(labelEl.textContent || "", rect.x + rect.width / 2, rect.y + rect.height / 2, {
2948
+ align: "center",
2949
+ baseline: "middle",
2950
+ });
2951
+ }
2952
+
2953
+
2954
+ addScaleBar() {
2955
+ let pixelsPerMeter;
2956
+ if (this.imgMetadata) {
2957
+ if (this.show_scalebar) {
2958
+ let unitConvert;
2959
+ if (this.imgMetadata.physical_size_x_unit === "µm" || this.imgMetadata.physical_size_x_unit === "um") {
2960
+ unitConvert = 1000000;
2961
+ } else if (this.imgMetadata.physical_size_x_unit === "nm") {
2962
+ unitConvert = 1000000000;
2963
+ } else if (this.imgMetadata.physical_size_x_unit === "cm") {
2964
+ unitConvert = 100;
2965
+ } else if (this.imgMetadata.physical_size_x_unit === "m") {
2966
+ unitConvert = 1;
2967
+ } else {
2968
+ unitConvert = 0;
2969
+ }
2970
+ pixelsPerMeter = unitConvert * this.imgMetadata.physical_size_x;
2971
+ } else {
2972
+ pixelsPerMeter = 0;
2973
+ }
2974
+ pixelsPerMeter = this.show_scalebar ? this.getPixelsPerMeter() : null;
2975
+
2976
+ this.viewer.scalebar({
2977
+ location: OpenSeadragon.ScalebarLocation.BOTTOM_RIGHT,
2978
+ minWidth: "100px",
2979
+ type: OpenSeadragon.ScalebarType.MICROSCOPY,
2980
+ stayInsideImage: false,
2981
+ pixelsPerMeter: pixelsPerMeter,
2982
+ fontColor: "rgb(255, 255, 255)",
2983
+ color: "rgb(255, 255, 255)",
2984
+ backgroundColor: "rgba(0, 0, 0, 0.45)",
2985
+ barThickness: 3,
2986
+ });
2987
+ this.styleScaleBar();
2988
+ }
2989
+ }
2990
+
2991
+ getPixelsPerMeter() {
2992
+ const physicalSizeX = Number(this.imgMetadata?.physical_size_x);
2993
+ if (!physicalSizeX) return null;
2994
+ const unitsPerMeter = {
2995
+ "\u00b5m": 1000000,
2996
+ "um": 1000000,
2997
+ "nm": 1000000000,
2998
+ "cm": 100,
2999
+ "m": 1,
3000
+ }[this.imgMetadata?.physical_size_x_unit];
3001
+ if (!unitsPerMeter) return null;
3002
+ return unitsPerMeter / physicalSizeX;
3003
+ }
3004
+
3005
+ setScalebarVisible(visible) {
3006
+ this.show_scalebar = visible;
3007
+ if (!this.viewer?.scalebarInstance) return;
3008
+ this.viewer.scalebar({ pixelsPerMeter: visible ? this.getPixelsPerMeter() : null });
3009
+ }
3010
+
3011
+ styleScaleBar() {
3012
+ const element = this.viewer?.scalebarInstance?.divElt;
3013
+ if (!element) return;
3014
+ element.style.zIndex = "220";
3015
+ element.style.position = "absolute";
3016
+ element.style.padding = "2px 4px";
3017
+ element.style.borderRadius = "3px";
3018
+ }
3019
+
3020
+ /**
3021
+ * @function drawCentroids - the point representation of every visible layer.
3022
+ *
3023
+ * WHICH POINTS EXIST is not per layer, and cannot be with the tiled centroid
3024
+ * source: the gate is applied server-side when the tiles are fetched
3025
+ * (updateCentroidFilter), and there is one set of tiles. So a gating layer
3026
+ * that is visible but NOT active draws its colours over the point set the
3027
+ * ACTIVE layer asked for. Colour is per layer; membership is not. Splitting
3028
+ * that would mean a tile set per layer, which is a fetch per layer per pan.
3029
+ */
3030
+ drawCentroids(context, imageZoom = 1) {
3031
+ if (this.centroidMode === "legacy") {
3032
+ this.drawLegacyCentroids(context, imageZoom);
3033
+ return;
3034
+ }
3035
+ if (!this.centroidTiles.size || !this.viewer?.viewport) return;
3036
+ const item = this.viewer.world.getItemAt(0);
3037
+ if (!item) return;
3038
+ const layers = this.centroidDrawList();
3039
+ if (!layers.length) return;
3040
+ const bounds = this.viewer.viewport.getBounds(true);
3041
+ const imageBounds = item.viewportToImageRectangle(bounds);
3042
+ const minX = imageBounds.x;
3043
+ const minY = imageBounds.y;
3044
+ const maxX = imageBounds.x + imageBounds.width;
3045
+ const maxY = imageBounds.y + imageBounds.height;
3046
+ const safeImageZoom = Math.max(imageZoom, 0.0001);
3047
+ const radius = this.getCentroidScreenRadius(safeImageZoom) / safeImageZoom;
3048
+ // One pass per layer, bottom of the stack first, so the same drag that
3049
+ // reorders the mask layers reorders these. They all land on the same
3050
+ // overlay canvas, which is above every mask tile -- see centroidDrawList.
3051
+ for (const layer of layers) {
3052
+ const colored = Boolean(layer.lut);
3053
+ context.save();
3054
+ context.globalAlpha = 0.9 * this.layerAlpha(layer);
3055
+ context.fillStyle = "#ffdd55";
3056
+ context.strokeStyle = "rgba(0, 0, 0, 0.8)";
3057
+ context.lineWidth = 1.6 / safeImageZoom;
3058
+ // Only reassigned when the colour actually changes. Canvas state
3059
+ // changes are the expensive part of this loop, and a categorical
3060
+ // variable draws long runs of one colour.
3061
+ let currentFill = "#ffdd55";
3062
+ const drawPoint = (x, y, cellId) => {
3063
+ if (x < minX || x > maxX || y < minY || y > maxY) {
3064
+ return;
3065
+ }
3066
+ if (colored) {
3067
+ const style = this.cellColorStyle(cellId, layer);
3068
+ if (!style) return;
3069
+ if (style !== currentFill) {
3070
+ context.fillStyle = style;
3071
+ currentFill = style;
3072
+ }
3073
+ }
3074
+ context.beginPath();
3075
+ context.arc(x, y, radius, 0, Math.PI * 2);
3076
+ context.fill();
3077
+ context.stroke();
3078
+ };
3079
+ this.centroidTiles.forEach((tile) => {
3080
+ const centers = tile.centers || [];
3081
+ // Parallel to `centers`, two coordinates per id -- see
3082
+ // decodeCentroidTileBuffer, which builds both from the same record.
3083
+ const ids = tile.ids;
3084
+ for (let i = 0; i < centers.length; i += 2) {
3085
+ drawPoint(centers[i], centers[i + 1], ids ? ids[i >> 1] : 0);
3086
+ }
3087
+ });
3088
+ context.restore();
3089
+ }
3090
+ }
3091
+
3092
+ drawLegacyCentroids(context, imageZoom = 1) {
3093
+ const centers = this.fullResolutionCenters || [];
3094
+ if (!centers.length || !this.viewer?.viewport) return;
3095
+ const item = this.viewer.world.getItemAt(0);
3096
+ if (!item) return;
3097
+ const layers = this.centroidDrawList();
3098
+ if (!layers.length) return;
3099
+ const bounds = this.viewer.viewport.getBounds(true);
3100
+ const imageBounds = item.viewportToImageRectangle(bounds);
3101
+ const minX = imageBounds.x;
3102
+ const minY = imageBounds.y;
3103
+ const maxX = imageBounds.x + imageBounds.width;
3104
+ const maxY = imageBounds.y + imageBounds.height;
3105
+ const safeImageZoom = Math.max(imageZoom, 0.0001);
3106
+ const radius = this.getCentroidScreenRadius(safeImageZoom) / safeImageZoom;
3107
+ for (const layer of layers) {
3108
+ const colored = Boolean(layer.lut);
3109
+ context.save();
3110
+ context.globalAlpha = 0.9 * this.layerAlpha(layer);
3111
+ context.fillStyle = "#ffdd55";
3112
+ context.strokeStyle = "rgba(0, 0, 0, 0.8)";
3113
+ context.lineWidth = 1.6 / safeImageZoom;
3114
+ let currentFill = "#ffdd55";
3115
+ const drawAtOffset = (i) => {
3116
+ const x = centers[i];
3117
+ const y = centers[i + 1];
3118
+ if (x < minX || x > maxX || y < minY || y > maxY) {
3119
+ return;
3120
+ }
3121
+ if (colored) {
3122
+ // this.ids is parallel to `centers` at two coordinates per
3123
+ // id, the same relationship the tiled path has -- so one
3124
+ // colour lookup works for both without either knowing about
3125
+ // the other.
3126
+ const style = this.cellColorStyle(this.ids[i >> 1], layer);
3127
+ if (!style) return;
3128
+ if (style !== currentFill) {
3129
+ context.fillStyle = style;
3130
+ currentFill = style;
3131
+ }
3132
+ }
3133
+ context.beginPath();
3134
+ context.arc(x, y, radius, 0, Math.PI * 2);
3135
+ context.fill();
3136
+ context.stroke();
3137
+ };
3138
+ if (this.centroidIdSet instanceof Set) {
3139
+ this.centroidIdSet.forEach((id) => {
3140
+ const offset = this.idToCenterOffset.get(Number(id));
3141
+ if (offset !== undefined) {
3142
+ drawAtOffset(offset);
3143
+ }
3144
+ });
3145
+ } else if (this.legacyCentroidBuckets) {
3146
+ // Only walk buckets overlapping the current viewport instead of
3147
+ // every cell in the dataset.
3148
+ const span = this.legacyCentroidBucketSpan;
3149
+ const minTx = Math.floor(minX / span);
3150
+ const maxTx = Math.floor(maxX / span);
3151
+ const minTy = Math.floor(minY / span);
3152
+ const maxTy = Math.floor(maxY / span);
3153
+ for (let tx = minTx; tx <= maxTx; tx += 1) {
3154
+ for (let ty = minTy; ty <= maxTy; ty += 1) {
3155
+ const bucket = this.legacyCentroidBuckets.get(`${tx}_${ty}`);
3156
+ if (!bucket) continue;
3157
+ for (const offset of bucket) {
3158
+ drawAtOffset(offset);
3159
+ }
3160
+ }
3161
+ }
3162
+ } else {
3163
+ for (let i = 0; i < centers.length; i += 2) {
3164
+ drawAtOffset(i);
3165
+ }
3166
+ }
3167
+ context.restore();
3168
+ }
3169
+ }
3170
+
3171
+ getCentroidScreenRadius(imageZoom) {
3172
+ const overviewLevel = Math.max(0, Math.log2(1 / imageZoom));
3173
+ // The clamp is applied to the adaptive part and the user's multiplier
3174
+ // to the result, so asking for bigger dots stays bigger at every zoom
3175
+ // rather than being flattened by the ceiling at the wide end.
3176
+ return this.centroidPointScale
3177
+ * Math.max(2.5, Math.min(7, 3.5 + overviewLevel * 0.8));
3178
+ }
3179
+
3180
+ /**
3181
+ * @function setCentroidPointScale - how big centroid dots are drawn.
3182
+ *
3183
+ * A redraw and nothing else: the points, their positions and their colours
3184
+ * are all unchanged, so this costs one canvas pass over what is already in
3185
+ * view. Dragging the slider is as cheap as panning.
3186
+ *
3187
+ * @param value - multiplier, clamped to [MIN, MAX]_CENTROID_SCALE
3188
+ * @returns whether anything changed
3189
+ */
3190
+ setCentroidPointScale(value) {
3191
+ const asked = Number(value);
3192
+ if (!Number.isFinite(asked)) return false;
3193
+ const next = Math.max(ImageViewer.MIN_CENTROID_SCALE,
3194
+ Math.min(ImageViewer.MAX_CENTROID_SCALE, asked));
3195
+ if (next === this.centroidPointScale) return false;
3196
+ this.centroidPointScale = next;
3197
+ this.viewer?.forceRedraw?.();
3198
+ return true;
3199
+ }
3200
+
3201
+ downloadCurrentView(format = "png") {
3202
+ if (format === "pdf") {
3203
+ this.exportPdf();
3204
+ return;
3205
+ }
3206
+
3207
+ // PNG has no vector concept, so the scale bar and legend are baked
3208
+ // in as raster pixels here, same as before.
3209
+ const baseCanvas = this.viewer?.scalebarInstance && this.show_scalebar
3210
+ ? this.viewer.scalebarInstance.getImageWithScalebarAsCanvas()
3211
+ : this.viewer?.drawer?.canvas;
3212
+ if (!baseCanvas) return;
3213
+
3214
+ const canvas = document.createElement("canvas");
3215
+ canvas.width = baseCanvas.width;
3216
+ canvas.height = baseCanvas.height;
3217
+ const ctx = canvas.getContext("2d");
3218
+ ctx.drawImage(baseCanvas, 0, 0);
3219
+ this.drawLegendOnCanvas(ctx, canvas.width, canvas.height);
3220
+
3221
+ const link = document.createElement("a");
3222
+ link.download = `${datasource || "plexora"}_current_view.png`;
3223
+ link.href = canvas.toDataURL("image/png");
3224
+ document.body.appendChild(link);
3225
+ link.click();
3226
+ document.body.removeChild(link);
3227
+ }
3228
+
3229
+ // PDF export: the microscopy image itself stays a raster embed (it's a
3230
+ // bitmap by nature), but the scale bar, legend and project label are all
3231
+ // drawn as real vector shapes/text on top -- editable as separate
3232
+ // objects in Illustrator, not baked into the image pixels.
3233
+ exportPdf() {
3234
+ const baseCanvas = this.viewer?.drawer?.canvas;
3235
+ if (!baseCanvas) return;
3236
+ const width = baseCanvas.width;
3237
+ const height = baseCanvas.height;
3238
+
3239
+ const pdf = new jsPDF({
3240
+ orientation: width >= height ? "landscape" : "portrait",
3241
+ unit: "px",
3242
+ format: [width, height],
3243
+ });
3244
+ pdf.addImage(baseCanvas.toDataURL("image/png"), "PNG", 0, 0, width, height);
3245
+
3246
+ if (this.show_scalebar) this.drawScalebarVector(pdf);
3247
+ this.drawLegendVector(pdf);
3248
+ this.drawProjectLabelVector(pdf);
3249
+
3250
+ pdf.save(`${datasource || "plexora"}_current_view.pdf`);
3251
+ }
3252
+
3253
+ setLoading(isLoading) {
3254
+ const loader = document.getElementById("openseadragon_loader");
3255
+ if (loader) {
3256
+ loader.style.display = isLoading ? "flex" : "none";
3257
+ }
3258
+ }
3259
+
3260
+
3261
+ /**
3262
+ * @function clearTileCache - drop loaded tiles so they get refetched
3263
+ * @param onlySegmentation - if true, only clear the segmentation/label layer
3264
+ * (tileFormat 32) instead of every channel's tile pyramid
3265
+ *
3266
+ * Uses TiledImage.reset(), OpenSeadragon 6's supported entry point, which
3267
+ * delegates to TileCache.clearTilesFor() and properly unloads each tile and
3268
+ * decrements _cachesLoadedCount.
3269
+ *
3270
+ * The previous hand-rolled version (and its evictLeastRecentlyUsedTiles
3271
+ * sibling, plus a 30s interval that called it) was written against
3272
+ * OpenSeadragon 2.x, where _tilesLoaded held {tile: ...} records. OSD 6
3273
+ * stores Tile objects directly, so `tileRecord.tile` was always undefined:
3274
+ * tile.unload() never fired and nothing was actually freed -- yet the
3275
+ * splice()/= [] calls still tore real tiles out of OSD's LRU list while
3276
+ * leaving _cachesLoadedCount untouched. That left _freeOldRecordRoutine
3277
+ * with no eviction candidates, so the cache grew without bound and the
3278
+ * resulting GC pauses showed up as stutter during panning.
3279
+ */
3280
+ clearTileCache(onlySegmentation = false) {
3281
+ if (!this.viewer || !this.viewer.world) {
3282
+ return;
3283
+ }
3284
+ for (let i = 0; i < this.viewer.world.getItemCount(); i++) {
3285
+ const item = this.viewer.world.getItemAt(i);
3286
+ if (onlySegmentation && item?.source?.tileFormat !== 32) continue;
3287
+ item?.reset?.();
3288
+ }
3289
+ this.viewer.forceRedraw();
3290
+ }
3291
+ }
3292
+
3293
+ // Static vars
3294
+ ImageViewer.events = {
3295
+ imageClickedMultiSel: "image_clicked_multi_selection",
3296
+ renderingMode: "renderingMode"
3297
+ };
3298
+
3299
+ /**
3300
+ * @function toFloatColor - convert 0-255 rgb color to 0-1 float array
3301
+ * @param color - rgb object with values 0-255
3302
+ * @returns array
3303
+ */
3304
+ function toFloatColor(color) {
3305
+ return [color.r / 255, color.g / 255, color.b / 255];
3306
+ }