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,1117 @@
1
+ /**
2
+ * @class ViewerSidebar - unified controls for marker gating and image channels.
3
+ *
4
+ * ## Where it looks for its markup
5
+ *
6
+ * By default: the whole document, by id, exactly as it always did. The
7
+ * `options` argument scopes it instead -- `{root, idPrefix, persist}` -- so a
8
+ * SECOND instance can drive a second copy of the same markup elsewhere on the
9
+ * page without the two finding each other's elements.
10
+ *
11
+ * That exists for Figure Builder's Quick Edit, which mounts the channel
12
+ * controls beside a small preview of a figure panel. The alternative was a
13
+ * second channel widget written from scratch, which is two implementations of
14
+ * colour, contrast and channel ordering that agree until the day one of them
15
+ * is fixed -- and a user who has to learn the second one.
16
+ *
17
+ * `persist: false` is the other half of it: a scoped instance must never write
18
+ * the project's saved channel list, because the channels it is showing belong
19
+ * to a figure panel rather than to the project. Note that `persistChannelList`
20
+ * also reads main.js globals that only exist on the viewer page, so this is a
21
+ * correctness guard and not merely a policy one.
22
+ *
23
+ * Every default is the previous behaviour, and the viewer passes no options at
24
+ * all.
25
+ */
26
+ class ViewerSidebar {
27
+ constructor(config, columns, dataLayer, eventHandler, channelList, options) {
28
+ this.config = config;
29
+ this.columns = [...columns];
30
+ this.dataLayer = dataLayer;
31
+ this.eventHandler = eventHandler;
32
+ this.channelList = channelList;
33
+ const settings = options || {};
34
+ //: Where this instance's markup lives. `document` for the viewer's own
35
+ //: sidebar; an element for anything mounting a second copy.
36
+ this.root = settings.root || document;
37
+ //: Prepended to every id this instance looks up or generates, so two
38
+ //: copies of the same markup can coexist.
39
+ this.idPrefix = settings.idPrefix || "";
40
+ //: Whether changes here are written to the project's channel list.
41
+ this.persist = settings.persist !== false;
42
+ //: Full channel name -> index in the image. main.js's `imageChannels`
43
+ //: by default; injectable because that binding only exists on the
44
+ //: viewer page, and a scoped instance runs where it does not.
45
+ this.channelIndexMap = settings.channelIndex || null;
46
+ this.databaseDescription = {};
47
+ this.channelSlots = [];
48
+ this.channelSlotSliders = new Map();
49
+ // Remembers a manually-set intensity range per marker name (not per slot), so
50
+ // switching a slot's marker away and back doesn't discard what the user tuned.
51
+ this.markerRangeOverrides = new Map();
52
+ this.colorPickers = new Map();
53
+ this.markerSelects = new Map();
54
+ this._saveChannelsTimer = null;
55
+ this._restoring = false;
56
+ // Add-on modules (e.g. gating) that extend the sidebar without core needing to know
57
+ // their concrete type -- see registerModule() and pluginRegistry.js.
58
+ this.sidebarModules = [];
59
+ this.maxChannelSlots = 15;
60
+ this.initialChannelSlots = 4;
61
+ this.defaultColors = [
62
+ { label: "Blue", hex: "#2388ff", rgb: { r: 35, g: 136, b: 255 } },
63
+ { label: "Red", hex: "#ff2d2d", rgb: { r: 255, g: 45, b: 45 } },
64
+ { label: "Green", hex: "#2bd46f", rgb: { r: 43, g: 212, b: 111 } },
65
+ { label: "White", hex: "#ffffff", rgb: { r: 255, g: 255, b: 255 } },
66
+ ];
67
+ // Per-channel range sliders live in [0, 255] byte units by default
68
+ // (matching the quantized WebP tile data 1:1) and switch to raw
69
+ // 16-bit units when HD is on (see getImageRange/toImageConnectorRange/
70
+ // autoChannel below, and frag.glsl's u8_r_range for why this matters:
71
+ // without it the slider's domain doesn't match the encoded data's
72
+ // domain, which is what caused visible banding).
73
+ window.addEventListener("plexora:hd-mode-changed", (e) => this.onHdModeChanged(Boolean(e.detail?.enabled)));
74
+ }
75
+
76
+ /**
77
+ * One of this instance's elements, by its unprefixed id.
78
+ *
79
+ * With the defaults this is exactly `document.getElementById(name)`. A
80
+ * scoped instance looks inside its own root and for its own prefixed id,
81
+ * so the two never find each other's controls -- and an element the scoped
82
+ * copy does not mount comes back null, which every caller here already
83
+ * handles because the viewer renders no channel section for an RGB image.
84
+ */
85
+ el(name) {
86
+ const id = this.idPrefix + name;
87
+ return this.root.getElementById
88
+ ? this.root.getElementById(id)
89
+ : this.root.querySelector(`[id="${id}"]`);
90
+ }
91
+
92
+ q(selector) {
93
+ return this.root.querySelector(selector);
94
+ }
95
+
96
+ /** The id this instance gives one of its per-slot elements. */
97
+ slotId(name, index) {
98
+ return `${this.idPrefix}${name}_${index}`;
99
+ }
100
+
101
+ isHdMode() {
102
+ return Boolean(window.__plexora?.seaDragonViewer?.viewerManagerVMain?.isHdMode?.());
103
+ }
104
+
105
+ /**
106
+ * @function quantWindow
107
+ * The server's quantization window {qmin, qmax} for a channel -- the only
108
+ * thing needed to convert between the raw 16-bit units ranges are stored
109
+ * in and the [0, 255] byte domain the slider and shader work in.
110
+ *
111
+ * Sourced from the channel's image stats (get_image_channel_stats), NOT
112
+ * from the GMM packet. Both carry identical qmin/qmax -- they come from
113
+ * the same server-side get_channel_quantization_window() -- but the GMM
114
+ * packet only arrives after a ~1 s GaussianMixture fit, while stats takes
115
+ * a few ms and is fetched on activation anyway. Reading it from the GMM
116
+ * was why restoring saved channels ran a fit per channel purely to
117
+ * convert a range they had already saved. Falls back to the GMM packet so
118
+ * a caller that somehow has one but no stats still works.
119
+ */
120
+ quantWindow(name) {
121
+ const fullName = this.dataLayer.getFullChannelName(name);
122
+ const stats = this.databaseDescription[fullName];
123
+ if (stats && stats.qmax !== undefined) return stats;
124
+ return this.channelList.hasChannelGMM[name];
125
+ }
126
+
127
+ /**
128
+ * @function rawToByteRange
129
+ * Maps a [min, max] pair in raw 16-bit units into the [0, 255] byte
130
+ * domain the server actually quantized against (packet.qmin/qmax --
131
+ * see quantWindow), clamped to a valid byte range.
132
+ */
133
+ rawToByteRange([rmin, rmax], packet) {
134
+ const span = Math.max(packet.qmax - packet.qmin, 1);
135
+ const toByte = (v) => Math.min(255, Math.max(0, Math.round(((v - packet.qmin) / span) * 255)));
136
+ return [toByte(rmin), toByte(rmax)];
137
+ }
138
+
139
+ /**
140
+ * @function byteToRawRange
141
+ * Inverse of rawToByteRange: maps a [min, max] pair in [0, 255] byte
142
+ * units back into raw 16-bit units.
143
+ */
144
+ byteToRawRange([bmin, bmax], packet) {
145
+ const span = Math.max(packet.qmax - packet.qmin, 1);
146
+ const toRaw = (v) => packet.qmin + (v / 255) * span;
147
+ return [toRaw(bmin), toRaw(bmax)];
148
+ }
149
+
150
+ /**
151
+ * @function toRawRangeForSlot
152
+ * slot.range is byte-domain in default mode, raw in HD mode (see
153
+ * onHdModeChanged) -- this always returns raw 16-bit units, for
154
+ * persistence (persistChannelList) which must stay domain-independent
155
+ * since it's read back across sessions/mode changes on restore.
156
+ * Falls back to slot.range unconverted if no quantization window is
157
+ * cached yet (shouldn't happen for an active/auto-leveled slot).
158
+ */
159
+ toRawRangeForSlot(slot) {
160
+ if (this.isHdMode()) return slot.range;
161
+ const packet = this.quantWindow(slot.name);
162
+ if (!packet) return slot.range;
163
+ return this.byteToRawRange(slot.range, packet);
164
+ }
165
+
166
+ /**
167
+ * @function onHdModeChanged
168
+ * Remaps every active channel slot's current range (whatever the user
169
+ * has it set to, default or manually adjusted) into the newly-active
170
+ * domain, so the visible contrast window doesn't silently jump/reset
171
+ * on toggle, then updates the slider bounds and repaints.
172
+ */
173
+ onHdModeChanged(enabled) {
174
+ this.channelSlots.forEach((slot) => {
175
+ if (!slot.name) return;
176
+ const packet = this.quantWindow(slot.name);
177
+ if (!packet) return;
178
+ slot.range = enabled
179
+ ? this.byteToRawRange(slot.range, packet)
180
+ : this.rawToByteRange(slot.range, packet);
181
+ this.setSlotRange(slot.index, slot.range, slot.userRangeChanged);
182
+ if (slot.expanded) {
183
+ this.redrawChannelSlider(slot);
184
+ } else {
185
+ slot.sliderDirty = true;
186
+ }
187
+ });
188
+ }
189
+
190
+ async init(databaseDescription) {
191
+ this.databaseDescription = databaseDescription;
192
+ this.setupSidebarShell();
193
+ this.sidebarModules.forEach((m) => m.setup && m.setup());
194
+ this.bindActions();
195
+
196
+ const maxLabel = this.el("max-channels");
197
+ if (maxLabel) maxLabel.textContent = this.maxChannelSlots;
198
+
199
+ const [savedChannels, ...moduleSaved] = await Promise.all([
200
+ this.dataLayer.getSavedChannelList(),
201
+ ...this.sidebarModules.map((m) => (m.fetchSaved ? m.fetchSaved() : Promise.resolve(null))),
202
+ ]);
203
+
204
+ // Suppressed while restoring: applySavedChannels/a module's own apply-from-saved
205
+ // reuse the same setters live edits use, which otherwise schedule an autosave on
206
+ // every call - turning "load from DB" into "load from DB, then immediately write
207
+ // back to DB".
208
+ this._restoring = true;
209
+ if (savedChannels && savedChannels.length) {
210
+ await this.applySavedChannels(savedChannels);
211
+ } else {
212
+ this.initChannelSlots();
213
+ this.applyInitialChannels();
214
+ }
215
+
216
+ this.sidebarModules.forEach((m, i) => m.applyOrDefault && m.applyOrDefault(moduleSaved[i]));
217
+ this._restoring = false;
218
+
219
+ if (!(savedChannels && savedChannels.length)) this.persistChannelList();
220
+ this.sidebarModules.forEach((m, i) => m.persistIfNeeded && m.persistIfNeeded(Boolean(moduleSaved[i] && moduleSaved[i].length)));
221
+ }
222
+
223
+ /**
224
+ * Registers a plugin's sidebar controller (see pluginRegistry.js). The controller
225
+ * may implement setup(), fetchSaved(), applyOrDefault(savedRows), and
226
+ * persistIfNeeded(hadSaved) -- all optional, called at the matching point in init()
227
+ * above, alongside the core channel-slot restore flow.
228
+ */
229
+ registerModule(moduleController) {
230
+ this.sidebarModules.push(moduleController);
231
+ }
232
+
233
+ /**
234
+ * Registers a module discovered after init() already ran (see toolLoader.js /
235
+ * main.js's activateAddonModule) -- e.g. a tool that was lazily loaded when the
236
+ * user opened it mid-session, rather than known at page-boot time. Runs the same
237
+ * setup()/fetchSaved()/applyOrDefault()/persistIfNeeded() lifecycle init() applies
238
+ * to every module registered up front (lines above), just for this one module,
239
+ * so a late module restores its saved state identically either way.
240
+ */
241
+ async registerModuleLate(moduleController) {
242
+ this.sidebarModules.push(moduleController);
243
+ moduleController.setup && moduleController.setup();
244
+ const saved = moduleController.fetchSaved ? await moduleController.fetchSaved() : null;
245
+ this._restoring = true;
246
+ if (moduleController.applyOrDefault) await moduleController.applyOrDefault(saved);
247
+ this._restoring = false;
248
+ if (moduleController.persistIfNeeded) moduleController.persistIfNeeded(Boolean(saved && saved.length));
249
+ }
250
+
251
+ /**
252
+ * Forget a module whose plugin has been removed (toolLoader's Remove
253
+ * button, via main.js's deactivatePlugin).
254
+ *
255
+ * Registration is for the life of the page everywhere else, so this had no
256
+ * counterpart until a tool could be taken away again. Without it a dead
257
+ * controller stays in the list and goes on being handed setup(),
258
+ * fetchSaved() and applyOrDefault() -- against a panel whose markup has been
259
+ * removed from the page, so every element handle it takes is null.
260
+ */
261
+ unregisterModule(moduleController) {
262
+ const index = this.sidebarModules.indexOf(moduleController);
263
+ if (index < 0) return false;
264
+ this.sidebarModules.splice(index, 1);
265
+ return true;
266
+ }
267
+
268
+ isRestoring() {
269
+ return this._restoring;
270
+ }
271
+
272
+ setupSidebarShell() {
273
+ const collapseButton = this.el("sidebar_collapse_button");
274
+ const expandButton = this.el("sidebar_expand_button");
275
+ const shell = this.el("bodyDiv");
276
+ const toggleSidebar = () => {
277
+ if (shell) {
278
+ shell.classList.toggle("sidebar-collapsed");
279
+ }
280
+ };
281
+ if (collapseButton) {
282
+ collapseButton.addEventListener("click", toggleSidebar);
283
+ }
284
+ if (expandButton) {
285
+ expandButton.addEventListener("click", toggleSidebar);
286
+ }
287
+ this.setupChannelSectionCollapse();
288
+ }
289
+
290
+ /**
291
+ * Fold the Image Channels section away, the way a plugin's card folds.
292
+ *
293
+ * A class on the section and nothing else: the channel rows, their sliders
294
+ * and their colour pickers stay in the DOM, so unfolding is instant and
295
+ * every handle the slider code took at build time is still pointing at a
296
+ * node on the page. Rebuilding the list on each fold would redraw every
297
+ * d3 slider for a control the user is only tidying out of the way.
298
+ *
299
+ * Deliberately NOT persisted and NOT draggable: this is core's own section,
300
+ * fixed above the plugin stack, and folding it is a passing choice about
301
+ * screen space rather than part of the project's state.
302
+ *
303
+ * Absent for an RGB image, where index.html renders no channel section.
304
+ */
305
+ setupChannelSectionCollapse() {
306
+ const section = this.el("image_channel_section");
307
+ const toggle = this.el("image_channel_collapse");
308
+ if (!section || !toggle) return;
309
+ toggle.addEventListener("click", () => {
310
+ const collapsed = section.classList.toggle("is-collapsed");
311
+ toggle.setAttribute("aria-expanded", String(!collapsed));
312
+ });
313
+ }
314
+
315
+ bindActions() {
316
+ const addButton = this.el("add_channel_button");
317
+ // Guarded: a scoped instance may mount a subset of the markup, and the
318
+ // viewer itself renders no channel section at all for an RGB image.
319
+ if (addButton) addButton.addEventListener("click", () => this.addFirstAvailableChannel());
320
+
321
+ window.addEventListener("resize", () => {
322
+ this.redrawChannelSliders();
323
+ });
324
+ }
325
+
326
+ initChannelSlots() {
327
+ const slotList = this.el("channel_slot_list");
328
+ if (!slotList) return;
329
+ slotList.innerHTML = "";
330
+ this.channelSlots = [...Array(this.initialChannelSlots).keys()].map((slotIndex) => {
331
+ const color = this.getDefaultColor(slotIndex);
332
+ const name = this.columns[slotIndex] || "";
333
+ const slot = {
334
+ index: slotIndex,
335
+ name,
336
+ color: color.rgb,
337
+ colorHex: color.hex,
338
+ enabled: slotIndex === 0 && Boolean(name),
339
+ visible: Boolean(name),
340
+ expanded: false,
341
+ sliderDirty: false,
342
+ range: this.getImageRange(name),
343
+ userColorChanged: false,
344
+ userRangeChanged: false,
345
+ autoLeveled: false,
346
+ autoLeveling: false,
347
+ };
348
+ slotList.appendChild(this.createChannelSlot(slot));
349
+ return slot;
350
+ });
351
+ this.updateSelectedCount();
352
+ this.redrawChannelSliders();
353
+ }
354
+
355
+ createChannelSlot(slot) {
356
+ const row = document.createElement("div");
357
+ row.classList.add("channel-slot");
358
+ row.classList.toggle("is-hidden", !slot.visible);
359
+ row.classList.toggle("is-disabled", !slot.enabled);
360
+ row.setAttribute("data-slot", slot.index);
361
+ row.style.setProperty("--slot-color", slot.colorHex);
362
+
363
+ const top = document.createElement("div");
364
+ top.classList.add("channel-slot-top");
365
+ row.appendChild(top);
366
+
367
+ const toggle = document.createElement("input");
368
+ toggle.type = "checkbox";
369
+ toggle.classList.add("channel-toggle-switch");
370
+ toggle.checked = slot.enabled;
371
+ toggle.title = "Toggle channel";
372
+ toggle.addEventListener("change", (event) => {
373
+ this.setSlotEnabled(slot.index, event.target.checked);
374
+ });
375
+ top.appendChild(toggle);
376
+
377
+ const colorMount = document.createElement("div");
378
+ top.appendChild(colorMount);
379
+ const colorPicker = new ColorSwatchPicker(colorMount, {
380
+ value: slot.colorHex,
381
+ onChange: (hex) => this.setSlotColor(slot.index, hex, true),
382
+ });
383
+ this.colorPickers.set(slot.index, colorPicker);
384
+
385
+ const comboMount = document.createElement("div");
386
+ top.appendChild(comboMount);
387
+ const markerSelect = new SearchableSelect(comboMount, {
388
+ options: this.columns,
389
+ value: slot.name,
390
+ placeholder: "Select marker…",
391
+ describeOption: (name) => this.describeMarkerOption(name, slot.index),
392
+ onChange: (name) => this.setSlotMarker(slot.index, name, { keepColor: true, enable: true }),
393
+ });
394
+ this.markerSelects.set(slot.index, markerSelect);
395
+
396
+ const expandToggle = document.createElement("button");
397
+ expandToggle.type = "button";
398
+ expandToggle.classList.add("channel-slot-expand-toggle");
399
+ expandToggle.classList.toggle("is-expanded", Boolean(slot.expanded));
400
+ expandToggle.title = "Show threshold range";
401
+ expandToggle.innerHTML = '<span class="fas fa-chevron-down"></span>';
402
+ expandToggle.addEventListener("click", () => this.toggleSlotExpanded(slot.index));
403
+ top.appendChild(expandToggle);
404
+
405
+ const remove = document.createElement("button");
406
+ remove.type = "button";
407
+ remove.classList.add("slot-remove-button");
408
+ remove.title = "Remove channel slot";
409
+ remove.innerHTML = '<span class="fas fa-xmark"></span>';
410
+ remove.addEventListener("click", () => this.removeChannelSlot(slot.index));
411
+ top.appendChild(remove);
412
+
413
+ const detail = document.createElement("div");
414
+ detail.classList.add("channel-slot-detail");
415
+ detail.classList.toggle("is-expanded", Boolean(slot.expanded));
416
+
417
+ const detailHeader = document.createElement("div");
418
+ detailHeader.classList.add("slot-detail-header");
419
+
420
+ const values = document.createElement("div");
421
+ values.classList.add("range-readout", "slot-range-readout");
422
+ values.innerHTML = `<span id="${this.slotId("channel_slot_min", slot.index)}">0.00</span>`
423
+ + `<span id="${this.slotId("channel_slot_max", slot.index)}">0.00</span>`;
424
+ detailHeader.appendChild(values);
425
+
426
+ const auto = document.createElement("button");
427
+ auto.type = "button";
428
+ auto.classList.add("slot-auto-button");
429
+ auto.title = "Auto-set threshold range from data";
430
+ auto.textContent = "Auto";
431
+ auto.addEventListener("click", () => this.autoChannel(slot.index, { force: true }));
432
+ detailHeader.appendChild(auto);
433
+
434
+ detail.appendChild(detailHeader);
435
+
436
+ const slider = document.createElement("div");
437
+ slider.classList.add("sidebar-slider");
438
+ slider.setAttribute("id", this.slotId("channel_slot_slider", slot.index));
439
+ detail.appendChild(slider);
440
+
441
+ row.appendChild(detail);
442
+
443
+ return row;
444
+ }
445
+
446
+ applyInitialChannels() {
447
+ this.channelSlots.forEach((slot) => {
448
+ if (slot.name && slot.enabled) {
449
+ this.activateChannel(slot);
450
+ // Every other slot-enable path (setSlotEnabled, setSlotMarker) auto-levels
451
+ // right after activating -- this default/no-saved-channels path was missing
452
+ // it, so the first channel on a brand-new datasource sat at the full [0, 255]
453
+ // range until the user happened to touch it.
454
+ this.autoLevelChannelIfNeeded(slot);
455
+ }
456
+ });
457
+ this.updateSelectedCount();
458
+ }
459
+
460
+ setSlotMarker(slotIndex, name, options = {}) {
461
+ const slot = this.channelSlots[slotIndex];
462
+ if (!slot || !name) return;
463
+ const markerChanged = slot.name !== name;
464
+ const enablesSlot = options.enable && !slot.enabled;
465
+ const revealsSlot = options.reveal && !slot.visible;
466
+ if (!markerChanged && !enablesSlot && !revealsSlot && !options.force) return;
467
+ if (slot.name && slot.enabled && markerChanged) {
468
+ this.deactivateChannel(slot);
469
+ }
470
+ this.disableDuplicateChannels(name, slotIndex);
471
+ slot.name = name;
472
+ if (markerChanged) {
473
+ const override = this.markerRangeOverrides.get(name);
474
+ if (override) {
475
+ slot.range = [...override];
476
+ slot.userRangeChanged = true;
477
+ slot.autoLeveled = true;
478
+ } else {
479
+ slot.range = this.getImageRange(name);
480
+ slot.userRangeChanged = false;
481
+ slot.autoLeveled = false;
482
+ }
483
+ slot.autoLeveling = false;
484
+ slot.expanded = true;
485
+ slot.sliderDirty = true;
486
+ }
487
+ if (!options.keepColor) {
488
+ this.setSlotColor(slotIndex, this.getDefaultColor(slotIndex).hex, false);
489
+ }
490
+ if (options.enable) {
491
+ slot.enabled = true;
492
+ }
493
+ if (options.reveal || options.enable) {
494
+ slot.visible = true;
495
+ }
496
+ this.syncSlotDom(slot);
497
+ this.applySlotExpansion(slot);
498
+ if (slot.enabled) {
499
+ this.activateChannel(slot);
500
+ this.autoLevelChannelIfNeeded(slot);
501
+ }
502
+ this.updateSelectedCount();
503
+ this.scheduleSaveChannels();
504
+ }
505
+
506
+ setSlotEnabled(slotIndex, enabled) {
507
+ const slot = this.channelSlots[slotIndex];
508
+ if (!slot || !slot.name) return;
509
+ const wasEnabled = slot.enabled;
510
+ slot.enabled = enabled;
511
+ slot.visible = true;
512
+ if (enabled) {
513
+ this.disableDuplicateChannels(slot.name, slotIndex);
514
+ this.activateChannel(slot);
515
+ if (!wasEnabled) {
516
+ this.autoLevelChannelIfNeeded(slot);
517
+ }
518
+ } else {
519
+ this.deactivateChannel(slot);
520
+ }
521
+ this.syncSlotDom(slot);
522
+ this.updateSelectedCount();
523
+ this.scheduleSaveChannels();
524
+ }
525
+
526
+ setSlotColor(slotIndex, hex, userColorChanged) {
527
+ const slot = this.channelSlots[slotIndex];
528
+ if (!slot) return;
529
+ slot.colorHex = hex;
530
+ slot.color = this.hexToRgb(hex);
531
+ slot.userColorChanged = Boolean(userColorChanged || slot.userColorChanged);
532
+ this.syncSlotDom(slot);
533
+ if (slot.enabled && slot.name) {
534
+ this.eventHandler.trigger(ChannelList.events.COLOR_TRANSFER_CHANGE, {
535
+ name: slot.name,
536
+ type: "white",
537
+ color: d3.rgb(slot.color.r, slot.color.g, slot.color.b),
538
+ });
539
+ }
540
+ this.scheduleSaveChannels();
541
+ }
542
+
543
+ /** Where a channel sits in the image, by full name. */
544
+ channelIndexOf(fullName) {
545
+ if (this.channelIndexMap) return this.channelIndexMap[fullName];
546
+ return imageChannels[fullName];
547
+ }
548
+
549
+ activateChannel(slot) {
550
+ const fullName = this.dataLayer.getFullChannelName(slot.name);
551
+ const channelIdx = this.channelIndexOf(fullName);
552
+ if (channelIdx === undefined) return;
553
+ this.channelList.image_channels[slot.name] = slot.range;
554
+ this.channelList.rangeConnector[channelIdx] = this.toImageConnectorRange(slot.range);
555
+ this.channelList.colorConnector[channelIdx] = { color: slot.color };
556
+ if (!this.channelList.selections.includes(slot.name)) {
557
+ this.channelList.selections.push(slot.name);
558
+ }
559
+ this.channelList.sel[fullName] = slot.range;
560
+ this.eventHandler.trigger(ChannelList.events.CHANNELS_CHANGE, {
561
+ selections: this.channelList.selections,
562
+ name: slot.name,
563
+ status: true,
564
+ });
565
+ this.eventHandler.trigger(ChannelList.events.COLOR_TRANSFER_CHANGE, {
566
+ name: slot.name,
567
+ type: "white",
568
+ color: d3.rgb(slot.color.r, slot.color.g, slot.color.b),
569
+ });
570
+ this.eventHandler.trigger(ChannelList.events.BRUSH_MOVE, {
571
+ name: slot.name,
572
+ dataRange: [...slot.range],
573
+ });
574
+
575
+ // HD mode's slider domain is the real per-channel image_min/image_max
576
+ // (default mode uses a fixed [0, 255] byte domain and never needs
577
+ // this -- see getImageRange). getRawImageRange already falls back to
578
+ // a generic bit range when stats haven't been fetched yet, so
579
+ // activation itself isn't gated on this fetch -- just refresh the
580
+ // slot's range once real stats land, unless the user has already
581
+ // touched this slot's slider.
582
+ if (this.isHdMode() && !slot.userRangeChanged) {
583
+ this.channelList.ensureChannelStats(slot.name).then(() => {
584
+ if (slot.userRangeChanged) return;
585
+ slot.range = this.getRawImageRange(slot.name);
586
+ this.channelList.sel[fullName] = slot.range;
587
+ this.syncSlotDom(slot);
588
+ });
589
+ }
590
+ }
591
+
592
+ deactivateChannel(slot) {
593
+ const fullName = this.dataLayer.getFullChannelName(slot.name);
594
+ this.channelList.selections = _.pull(this.channelList.selections, slot.name);
595
+ delete this.channelList.sel[fullName];
596
+ this.eventHandler.trigger(ChannelList.events.CHANNELS_CHANGE, {
597
+ selections: this.channelList.selections,
598
+ name: slot.name,
599
+ status: false,
600
+ });
601
+ }
602
+
603
+ disableDuplicateChannels(name, currentSlotIndex) {
604
+ this.channelSlots.forEach((slot) => {
605
+ if (slot.index !== currentSlotIndex && slot.enabled && slot.name === name) {
606
+ slot.enabled = false;
607
+ this.deactivateChannel(slot);
608
+ this.syncSlotDom(slot);
609
+ }
610
+ });
611
+ }
612
+
613
+ redrawChannelSliders() {
614
+ this.channelSlots.filter((slot) => slot.visible && slot.expanded).forEach((slot) => this.redrawChannelSlider(slot));
615
+ }
616
+
617
+ redrawChannelSlider(slot) {
618
+ if (!slot || !slot.name) return;
619
+ this.updateSlotReadout(slot);
620
+ if (!slot.expanded) {
621
+ slot.sliderDirty = true;
622
+ return;
623
+ }
624
+ slot.sliderDirty = false;
625
+ const target = this.el(`channel_slot_slider_${slot.index}`);
626
+ if (!target) return;
627
+ target.innerHTML = "";
628
+ const range = this.getImageRange(slot.name);
629
+ const width = Math.max(180, target.getBoundingClientRect().width - 16);
630
+ const slider = d3.sliderBottom(d3.scaleLog())
631
+ .min(Math.max(range[0], 1))
632
+ .max(Math.max(range[1], 2))
633
+ .width(width)
634
+ .ticks(0)
635
+ .tickValues([])
636
+ .default([Math.max(slot.range[0], 1), Math.max(slot.range[1], 2)])
637
+ .fill("#38bdf8")
638
+ .handle(d3.symbol().type(d3.symbolCircle).size(120))
639
+ .on("onchange", (value) => this.setSlotRange(slot.index, value, true))
640
+ .on("end", () => this.scheduleSaveChannels());
641
+
642
+ this.channelSlotSliders.set(slot.index, slider);
643
+ d3.select(target)
644
+ .append("svg")
645
+ .attr("width", width + 16)
646
+ .attr("height", 44)
647
+ .append("g")
648
+ .attr("transform", "translate(8,18)")
649
+ .call(slider);
650
+ }
651
+
652
+ toggleSlotExpanded(slotIndex) {
653
+ const slot = this.channelSlots[slotIndex];
654
+ if (!slot) return;
655
+ slot.expanded = !slot.expanded;
656
+ this.applySlotExpansion(slot);
657
+ }
658
+
659
+ applySlotExpansion(slot) {
660
+ const row = this.q(`.channel-slot[data-slot="${slot.index}"]`);
661
+ if (!row) return;
662
+ const detail = row.querySelector(".channel-slot-detail");
663
+ const toggle = row.querySelector(".channel-slot-expand-toggle");
664
+ if (detail) detail.classList.toggle("is-expanded", Boolean(slot.expanded));
665
+ if (toggle) toggle.classList.toggle("is-expanded", Boolean(slot.expanded));
666
+ if (slot.expanded && (slot.sliderDirty || !this.channelSlotSliders.has(slot.index))) {
667
+ this.redrawChannelSlider(slot);
668
+ }
669
+ }
670
+
671
+ describeMarkerOption(name, currentSlotIndex) {
672
+ const activeElsewhere = this.channelSlots.some(
673
+ (slot) => slot.index !== currentSlotIndex && slot.enabled && slot.name === name
674
+ );
675
+ return activeElsewhere ? "active elsewhere" : "";
676
+ }
677
+
678
+ setSlotRange(slotIndex, values, userChanged = false) {
679
+ const slot = this.channelSlots[slotIndex];
680
+ if (!slot) return;
681
+ slot.range = this.normalizeRange(values, true);
682
+ if (userChanged) {
683
+ slot.userRangeChanged = true;
684
+ if (slot.name) {
685
+ this.markerRangeOverrides.set(slot.name, [...slot.range]);
686
+ }
687
+ }
688
+ this.channelList.image_channels[slot.name] = slot.range;
689
+ this.updateSlotReadout(slot);
690
+ if (slot.enabled && slot.name) {
691
+ this.eventHandler.trigger(ChannelList.events.BRUSH_MOVE, {
692
+ name: slot.name,
693
+ dataRange: [...slot.range],
694
+ });
695
+ }
696
+ }
697
+
698
+ autoLevelChannelIfNeeded(slot) {
699
+ if (!slot || slot.autoLeveled || slot.autoLeveling || slot.userRangeChanged) {
700
+ return;
701
+ }
702
+ window.setTimeout(() => this.autoChannel(slot.index), 0);
703
+ }
704
+
705
+ /**
706
+ * @function applyAutoRange
707
+ * Push an auto-derived [vmin, vmax] (raw 16-bit units) onto a slot,
708
+ * converting into the slider's active domain. Shared by the provisional
709
+ * and final passes of autoChannel so they cannot drift apart.
710
+ */
711
+ applyAutoRange(slotIndex, slot, rawRange, packet) {
712
+ slot.range = this.isHdMode() ? rawRange : this.rawToByteRange(rawRange, packet);
713
+ const slider = this.channelSlotSliders.get(slotIndex);
714
+ if (slider) {
715
+ slider.silentValue(slot.range);
716
+ }
717
+ this.setSlotRange(slotIndex, slot.range, false);
718
+ this.redrawChannelSlider(slot);
719
+ // setSlotRange(..., false) deliberately doesn't autosave (auto-leveling isn't a
720
+ // user edit) -- but persistChannelList's raw-unit conversion (toRawRangeForSlot)
721
+ // needs the channel's quantization window, and the very first activation of a
722
+ // channel autosaves (via setSlotEnabled/setSlotMarker's own scheduleSaveChannels
723
+ // call, 400ms after activation) before the fetch that supplies it can finish.
724
+ // Without this, that early save silently persists the still-default byte-domain
725
+ // slot.range mislabeled as raw units, which then gets reinterpreted as raw on
726
+ // every future restore and rescaled into a near-zero sliver of the real range.
727
+ // Re-scheduling a save now that the window exists corrects it.
728
+ this.scheduleSaveChannels();
729
+ }
730
+
731
+ /**
732
+ * @function autoChannel
733
+ * Auto-level a slot in two passes, because the good answer is slow.
734
+ *
735
+ * The authoritative window is get_channel_gmm's vmin/vmax -- a
736
+ * GaussianMixture(3) fit costing 0.2-1.9 s per channel. A new slot starts
737
+ * at [0, 255], the whole byte domain, which against a quantization ceiling
738
+ * of the channel's full-plane max renders the tissue near-black. So until
739
+ * the fit landed the channel was drawn but invisible: measured 6.6 s on a
740
+ * freshly opened project before anything appeared on screen.
741
+ *
742
+ * Pass 1 applies vmin_hint/vmax_hint from get_image_channel_stats (a few
743
+ * ms, percentiles of the same log-intensity distribution; worst case 15
744
+ * byte-levels off the GMM across the reference slide's 19 channels, versus
745
+ * ~234 for the [0, 255] default). Pass 2 replaces it with the real fit.
746
+ *
747
+ * Both passes re-check userRangeChanged, so a user who grabs the slider
748
+ * mid-flight is never overridden by a late-arriving fit.
749
+ */
750
+ async autoChannel(slotIndex, options = {}) {
751
+ const slot = this.channelSlots[slotIndex];
752
+ if (!slot || !slot.name) return;
753
+ if (!options.force && (slot.userRangeChanged || slot.autoLeveled || slot.autoLeveling)) {
754
+ return;
755
+ }
756
+ const markerName = slot.name;
757
+ const stillCurrent = () => slot.name === markerName &&
758
+ (options.force || !slot.userRangeChanged);
759
+ slot.autoLeveling = true;
760
+ try {
761
+ // Pass 1 -- provisional, so the channel is visible right away.
762
+ await this.channelList.ensureChannelStats(markerName);
763
+ if (!stillCurrent()) return;
764
+ const stats = this.quantWindow(markerName);
765
+ if (stats && stats.vmin_hint !== undefined) {
766
+ this.applyAutoRange(slotIndex, slot, [stats.vmin_hint, stats.vmax_hint], stats);
767
+ }
768
+
769
+ // Pass 2 -- the real fit. Slow and produces no other visible sign
770
+ // that anything is happening, so it reports to the status indicator.
771
+ if (!(markerName in this.channelList.hasChannelGMM)) {
772
+ const task = window.PlexoraStatus?.begin("Auto-contrast");
773
+ try {
774
+ await this.channelList.getAndDrawChannelGMM(markerName);
775
+ } finally {
776
+ task?.done();
777
+ }
778
+ }
779
+ if (!stillCurrent()) return;
780
+ const packet = this.channelList.hasChannelGMM[markerName];
781
+ if (!packet) return;
782
+ this.applyAutoRange(slotIndex, slot, [packet.vmin, packet.vmax],
783
+ this.quantWindow(markerName) || packet);
784
+ slot.autoLeveled = true;
785
+ } finally {
786
+ slot.autoLeveling = false;
787
+ }
788
+ }
789
+
790
+ addFirstAvailableChannel() {
791
+ const emptySlot = this.channelSlots.find((slot) => !slot.visible);
792
+ const activeNames = this.channelSlots.filter((slot) => slot.enabled).map((slot) => slot.name);
793
+ const usedNames = this.channelSlots.filter((slot) => slot.name).map((slot) => slot.name);
794
+ const slot = emptySlot || this.createAdditionalSlot(usedNames);
795
+ if (!slot) return;
796
+ const next = slot.name && !activeNames.includes(slot.name)
797
+ ? slot.name
798
+ : this.columns.find((name) => !usedNames.includes(name)) || this.columns.find((name) => !activeNames.includes(name));
799
+ if (next) {
800
+ this.setSlotMarker(slot.index, next, { keepColor: true, enable: false, reveal: true });
801
+ }
802
+ }
803
+
804
+ createAdditionalSlot(usedNames) {
805
+ if (this.channelSlots.length >= this.maxChannelSlots) return null;
806
+ const slotIndex = this.channelSlots.length;
807
+ const color = this.getDefaultColor(slotIndex);
808
+ const name = this.columns.find((column) => !usedNames.includes(column)) || "";
809
+ const slot = {
810
+ index: slotIndex,
811
+ name,
812
+ color: color.rgb,
813
+ colorHex: color.hex,
814
+ enabled: false,
815
+ visible: true,
816
+ expanded: false,
817
+ sliderDirty: false,
818
+ range: this.getImageRange(name),
819
+ userColorChanged: false,
820
+ userRangeChanged: false,
821
+ autoLeveled: false,
822
+ autoLeveling: false,
823
+ };
824
+ this.channelSlots.push(slot);
825
+ this.el("channel_slot_list")?.appendChild(this.createChannelSlot(slot));
826
+ return slot;
827
+ }
828
+
829
+ removeChannelSlot(slotIndex) {
830
+ const slot = this.channelSlots[slotIndex];
831
+ if (!slot) return;
832
+ if (slot.enabled && slot.name) {
833
+ this.deactivateChannel(slot);
834
+ }
835
+ const color = this.getDefaultColor(slotIndex);
836
+ slot.name = "";
837
+ slot.range = this.getImageRange("");
838
+ slot.enabled = false;
839
+ slot.visible = false;
840
+ slot.expanded = false;
841
+ slot.sliderDirty = false;
842
+ slot.color = color.rgb;
843
+ slot.colorHex = color.hex;
844
+ slot.userColorChanged = false;
845
+ slot.userRangeChanged = false;
846
+ slot.autoLeveled = false;
847
+ slot.autoLeveling = false;
848
+ this.channelSlotSliders.delete(slotIndex);
849
+ this.syncSlotDom(slot);
850
+ this.applySlotExpansion(slot);
851
+ this.updateSelectedCount();
852
+ this.scheduleSaveChannels();
853
+ }
854
+
855
+ syncSlotDom(slot) {
856
+ const row = this.q(`.channel-slot[data-slot="${slot.index}"]`);
857
+ if (!row) return;
858
+ row.classList.toggle("is-hidden", !slot.visible);
859
+ row.classList.toggle("is-disabled", !slot.enabled);
860
+ row.style.setProperty("--slot-color", slot.colorHex);
861
+ const toggle = row.querySelector(".channel-toggle-switch");
862
+ if (toggle) toggle.checked = slot.enabled;
863
+ const colorPicker = this.colorPickers.get(slot.index);
864
+ if (colorPicker) colorPicker.setValue(slot.colorHex);
865
+ const markerSelect = this.markerSelects.get(slot.index);
866
+ if (markerSelect) markerSelect.setValue(slot.name);
867
+ this.updateSlotReadout(slot);
868
+ }
869
+
870
+ async applySavedChannels(rows) {
871
+ const activeRows = rows.filter((row) => row && row.channel_active);
872
+ const slotList = this.el("channel_slot_list");
873
+ if (!slotList) return;
874
+ slotList.innerHTML = "";
875
+ this.channelSlots = [];
876
+ this.channelSlotSliders.clear();
877
+ this.colorPickers.clear();
878
+ this.markerSelects.clear();
879
+
880
+ const count = Math.min(Math.max(activeRows.length, this.initialChannelSlots), this.maxChannelSlots);
881
+ // Slots beyond the active rows aren't assigned by a saved row at all, but they should
882
+ // still show a marker (off) rather than sit empty, matching the pre-restore default look.
883
+ const usedNames = new Set(activeRows.slice(0, count).map((row) => row.channel));
884
+ const fallbackNames = this.columns.filter((name) => !usedNames.has(name));
885
+ let fallbackIdx = 0;
886
+ for (let i = 0; i < count; i++) {
887
+ const color = this.getDefaultColor(i);
888
+ const name = i < activeRows.length ? "" : (fallbackNames[fallbackIdx++] || "");
889
+ const slot = {
890
+ index: i,
891
+ name,
892
+ color: color.rgb,
893
+ colorHex: color.hex,
894
+ enabled: false,
895
+ visible: true,
896
+ expanded: false,
897
+ sliderDirty: false,
898
+ range: this.getImageRange(name),
899
+ userColorChanged: false,
900
+ userRangeChanged: false,
901
+ autoLeveled: false,
902
+ autoLeveling: false,
903
+ };
904
+ this.channelSlots.push(slot);
905
+ slotList.appendChild(this.createChannelSlot(slot));
906
+ }
907
+
908
+ // Prefetch every restored channel's stats up front, in parallel. The loop
909
+ // below needs each channel's quantization window to convert its saved range
910
+ // out of raw 16-bit units, and awaiting that per iteration made restore
911
+ // strictly serial. It used to await get_channel_gmm here instead -- a ~1 s
912
+ // fit per channel, purely to read the two qmin/qmax fields riding along on
913
+ // the GMM packet -- which cost 5.8 s to restore 3 channels on a cold server.
914
+ // The saved range already IS the auto-level result; no fit is needed to
915
+ // redisplay it.
916
+ const restoredNames = activeRows.slice(0, count).map((row) => row.channel);
917
+ const restoreTask = window.PlexoraStatus?.begin("Restoring");
918
+ try {
919
+ await Promise.all(restoredNames.map((name) =>
920
+ this.channelList.ensureChannelStats(name).catch(() => {})));
921
+ } finally {
922
+ restoreTask?.done();
923
+ }
924
+
925
+ for (const [i, row] of activeRows.slice(0, count).entries()) {
926
+ const slot = this.channelSlots[i];
927
+ if (!slot) continue;
928
+ this.setSlotMarker(slot.index, row.channel, { keepColor: true, enable: true, force: true });
929
+ // setSlotMarker's markerChanged branch always resets userRangeChanged/autoLeveled
930
+ // to false and (since slot.enabled) synchronously schedules autoLevelChannelIfNeeded
931
+ // -> autoChannel via setTimeout(0). We're restoring an explicit saved range below,
932
+ // so shut that off now, before the loop's first await yields control -- setTimeout
933
+ // always fires after this synchronous turn, so autoChannel will see these flags and
934
+ // bail out instead of racing this loop's own GMM fetch/range restore (double-fetching
935
+ // get_channel_gmm/get_image_channel_stats and sometimes clobbering the restored range
936
+ // with an auto-leveled one once its own deferred fetch resolves).
937
+ slot.userRangeChanged = true;
938
+ slot.autoLeveled = true;
939
+ this.setSlotColor(slot.index, this.rgbToHex(row.r, row.g, row.b), true);
940
+ // row.start/row.end are always raw 16-bit units (see
941
+ // persistChannelList/toRawRangeForSlot) -- convert to the
942
+ // currently-active domain before assigning to slot.range.
943
+ let range = [row.start, row.end];
944
+ if (!this.isHdMode()) {
945
+ const packet = this.quantWindow(slot.name);
946
+ if (packet) {
947
+ range = this.rawToByteRange(range, packet);
948
+ }
949
+ }
950
+ this.setSlotRange(slot.index, range, true);
951
+ slot.expanded = false;
952
+ this.applySlotExpansion(slot);
953
+ }
954
+
955
+ // The GMM is only needed for the auto button's vmin/vmax and the
956
+ // distribution curves drawn under each slider -- neither blocks display,
957
+ // so fetch them after the channels are already on screen. Unawaited on
958
+ // purpose; failures are the fetch's own problem, not the restore's.
959
+ restoredNames.forEach((name) => {
960
+ if (!(name in this.channelList.hasChannelGMM)) {
961
+ this.channelList.getAndDrawChannelGMM(name).catch(() => {});
962
+ }
963
+ });
964
+
965
+ this.updateSelectedCount();
966
+ }
967
+
968
+ rgbToHex(r, g, b) {
969
+ const toHex = (value) => Math.max(0, Math.min(255, Math.round(value))).toString(16).padStart(2, "0");
970
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
971
+ }
972
+
973
+ persistChannelList() {
974
+ // A scoped instance is showing a figure panel's channels, not the
975
+ // project's, and writing them here would overwrite the project's saved
976
+ // list with somebody else's. It also has none of the main.js globals
977
+ // this method reads, so the guard is load-bearing rather than a policy.
978
+ if (!this.persist) return Promise.resolve(null);
979
+ const listChannels = {};
980
+ // Must cover every entry map_channels (imageChannelsIdx, all real image
981
+ // channels) can reference server-side -- NOT this.columns (gating markers
982
+ // only). A structural/counterstain channel like DNA is commonly a real
983
+ // image channel with no corresponding feature-table column (no histogram),
984
+ // so it's excluded from this.columns but still present in imageChannelsIdx;
985
+ // basing this loop on this.columns left it with no listChannels entry at
986
+ // all, which the server then KeyErrors on since map_channels expects one
987
+ // for every image channel. getImageRange(name) already resolves a DNA-like
988
+ // channel's real image_min/image_max fine -- it just was never called for it.
989
+ Object.values(imageChannelsIdx).forEach((name) => {
990
+ listChannels[name] = this.channelList.image_channels[name] || this.getRawImageRange(name);
991
+ });
992
+ const activeChannels = {};
993
+ const listColors = {};
994
+ const listRanges = {};
995
+ const bitMax = this.dataLayer.imageBitRange[1];
996
+ this.channelSlots.forEach((slot) => {
997
+ if (!slot.name || !slot.enabled) return;
998
+ const fullName = this.dataLayer.getFullChannelName(slot.name);
999
+ const idx = imageChannels[fullName];
1000
+ if (idx === undefined) return;
1001
+ // Persisted state must be domain-independent (always raw 16-bit
1002
+ // units) since it's read back across sessions and across mode
1003
+ // changes -- slot.range itself is byte-domain in default mode
1004
+ // (see toRawRangeForSlot).
1005
+ const rawRange = this.toRawRangeForSlot(slot);
1006
+ activeChannels[idx] = true;
1007
+ listColors[idx] = { color: { ...slot.color, opacity: 1 } };
1008
+ listRanges[idx] = [rawRange[0] / bitMax, rawRange[1] / bitMax];
1009
+ listChannels[slot.name] = rawRange;
1010
+ });
1011
+ return this.dataLayer.saveChannelList(imageChannelsIdx, activeChannels, listColors, listRanges, listChannels);
1012
+ }
1013
+
1014
+ /**
1015
+ * Stop this project's channel list being written while somebody else is
1016
+ * driving the sidebar.
1017
+ *
1018
+ * `_restoring` already covers the sidebar's own startup restore. This is the
1019
+ * same guard for a caller OUTSIDE it: Figure Builder loads a captured
1020
+ * panel's channels into the live viewer through the ordinary setters, and
1021
+ * those setters schedule an autosave -- so without this, looking at a
1022
+ * figure panel would overwrite the project's own saved channels with the
1023
+ * figure's, permanently, with nothing on screen to say so.
1024
+ *
1025
+ * A counter rather than a flag: the restore and a plugin's session can
1026
+ * overlap, and the one that finishes first must not re-enable saving for
1027
+ * the one still running.
1028
+ */
1029
+ suspendPersistence() {
1030
+ this._persistenceSuspended = (this._persistenceSuspended || 0) + 1;
1031
+ window.clearTimeout(this._saveChannelsTimer);
1032
+ }
1033
+
1034
+ resumePersistence() {
1035
+ this._persistenceSuspended = Math.max(0, (this._persistenceSuspended || 0) - 1);
1036
+ }
1037
+
1038
+ scheduleSaveChannels() {
1039
+ if (this._restoring || this._persistenceSuspended) return;
1040
+ window.clearTimeout(this._saveChannelsTimer);
1041
+ this._saveChannelsTimer = window.setTimeout(() => {
1042
+ // Chained (not just debounced): a slow/out-of-order response from an earlier
1043
+ // save could otherwise land after a newer one and silently overwrite it.
1044
+ this._channelSaveChain = (this._channelSaveChain || Promise.resolve()).then(() => this.persistChannelList());
1045
+ }, 400);
1046
+ }
1047
+
1048
+ updateSelectedCount() {
1049
+ const count = this.channelSlots.filter((slot) => slot.enabled && slot.name).length;
1050
+ const countElement = this.el("num-selected-channels");
1051
+ if (countElement) countElement.textContent = count;
1052
+ const addButton = this.el("add_channel_button");
1053
+ if (addButton) addButton.disabled = this.channelSlots.filter((slot) => slot.visible).length >= this.maxChannelSlots;
1054
+ }
1055
+
1056
+ updateSlotReadout(slot) {
1057
+ const min = this.el(`channel_slot_min_${slot.index}`);
1058
+ const max = this.el(`channel_slot_max_${slot.index}`);
1059
+ if (min) min.textContent = this.formatValue(slot.range[0]);
1060
+ if (max) max.textContent = this.formatValue(slot.range[1]);
1061
+ }
1062
+
1063
+ // Raw 16-bit bounds for a channel, regardless of current mode -- the
1064
+ // stable representation used for persistence and as the HD-mode slider
1065
+ // domain. getImageRange() below is the mode-aware wrapper UI code should
1066
+ // normally call instead.
1067
+ getRawImageRange(name) {
1068
+ if (!name) return [0, 1];
1069
+ const fullName = this.dataLayer.getFullChannelName(name);
1070
+ const desc = this.databaseDescription[fullName] || {};
1071
+ return [desc.image_min || this.dataLayer.imageBitRange[0] || 0, desc.image_max || this.dataLayer.imageBitRange[1] || 65536];
1072
+ }
1073
+
1074
+ getImageRange(name) {
1075
+ if (!name) return [0, 1];
1076
+ if (!this.isHdMode()) {
1077
+ // Default mode: the slider works directly in the same [0, 255]
1078
+ // byte domain the server quantized into -- see rawToByteRange.
1079
+ return [0, 255];
1080
+ }
1081
+ return this.getRawImageRange(name);
1082
+ }
1083
+
1084
+ toImageConnectorRange(values) {
1085
+ if (!this.isHdMode()) {
1086
+ return [values[0] / 255, values[1] / 255];
1087
+ }
1088
+ const defaultRange = this.dataLayer.imageBitRange;
1089
+ return [values[0] / defaultRange[1], values[1] / defaultRange[1]];
1090
+ }
1091
+
1092
+ normalizeRange(values, keepFloat) {
1093
+ const sorted = [...values].map((value) => parseFloat(value)).sort((a, b) => a - b);
1094
+ if (keepFloat) {
1095
+ return sorted;
1096
+ }
1097
+ return [Math.floor(sorted[0]), Math.ceil(sorted[1])];
1098
+ }
1099
+
1100
+ formatValue(value) {
1101
+ return Number.parseFloat(value || 0).toFixed(2);
1102
+ }
1103
+
1104
+ hexToRgb(hex) {
1105
+ const cleaned = hex.replace("#", "");
1106
+ const value = parseInt(cleaned, 16);
1107
+ return {
1108
+ r: (value >> 16) & 255,
1109
+ g: (value >> 8) & 255,
1110
+ b: value & 255,
1111
+ };
1112
+ }
1113
+
1114
+ getDefaultColor(slotIndex) {
1115
+ return this.defaultColors[slotIndex % this.defaultColors.length];
1116
+ }
1117
+ }