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,628 @@
1
+ /**
2
+ * FigureQuickEdit - the microscopy view behind one panel, adjusted in place.
3
+ *
4
+ * Select a panel, press Quick Edit, and a slide-over opens on the right with a
5
+ * small live view of the source and the channel controls under it. Reframe,
6
+ * turn channels on and off, change their colours and contrast, press Done. The
7
+ * canvas never leaves the screen.
8
+ *
9
+ * ## It is not a second viewer, and that is the design
10
+ *
11
+ * There is no OpenSeadragon here, no tile stack, no overlays, no plugins. The
12
+ * mini view exists to answer two questions -- "is this the right field?" and
13
+ * "do these channels read?" -- and nothing else. Everything past that is what
14
+ * "Open in Main Viewer" is for, and it opens the REAL viewer rather than a
15
+ * cut-down copy of it. A second scientific viewer inside Figure Builder would
16
+ * be a second set of bugs and a second thing to learn.
17
+ *
18
+ * ## The channel controls ARE core's
19
+ *
20
+ * A second `ViewerSidebar` drives the markup in the slide-over, scoped to it by
21
+ * `{root, idPrefix: "fbqe_", persist: false}`. So the slots, the colour
22
+ * pickers, the marker pickers, the contrast sliders and the Auto button are
23
+ * literally the ones in the viewer's sidebar: identical behaviour, one
24
+ * implementation, and a fix to the real one lands here too.
25
+ *
26
+ * `persist: false` is load-bearing. The channels on screen belong to a FIGURE
27
+ * PANEL, not to the project, and writing them to the project's saved channel
28
+ * list would overwrite the user's own setup with a figure's, permanently.
29
+ *
30
+ * ## Why the pixels are fetched a channel at a time
31
+ *
32
+ * Each visible channel's greyscale is fetched once per framing change and kept
33
+ * (see `server/pixels.py`). Colour and contrast are then arithmetic over bytes
34
+ * already in memory, so dragging a slider repaints at once and costs NO
35
+ * network. A route that returned a composited RGB would make every tweak a
36
+ * round trip -- which is exactly the interaction this feature exists to make
37
+ * cheap. MiniMap works the same way.
38
+ *
39
+ * The compositing is the numpy in `server/render.render_panel`, which is itself
40
+ * the transcription of `frag.glsl`. Three copies of one piece of arithmetic is
41
+ * two too many, but the alternative is a preview that disagrees with the export
42
+ * -- and the windows were chosen against this arithmetic, by eye.
43
+ */
44
+ class FigureQuickEdit {
45
+
46
+ /** The alpha every channel is drawn with. `render.CHANNEL_ALPHA`, which is
47
+ * frag.glsl's, which is what the user chose their windows against. */
48
+ static get CHANNEL_ALPHA() { return 0.9; }
49
+
50
+ /** Inset of the framing rectangle inside the mini view, in CSS pixels.
51
+ * What is left over is the surrounding tissue, which is how the user knows
52
+ * which way to drag. */
53
+ static get FRAME_INSET() { return 28; }
54
+
55
+ /** Widest preview written back on Done. A preview is a convenience raster,
56
+ * never the master -- the export re-renders from the source. */
57
+ static get PREVIEW_WIDTH() { return 640; }
58
+
59
+ /** How long the view has to stand still before its pixels are re-fetched. */
60
+ static get SETTLE_MS() { return 140; }
61
+
62
+ constructor(options) {
63
+ this.workspace = options.workspace;
64
+ this.api = options.api;
65
+ this.state = options.state;
66
+ this.figureId = options.figureId;
67
+ this.onOpenInViewer = options.onOpenInViewer || (() => {});
68
+
69
+ this.root = document.getElementById("fb_quickedit");
70
+ this.canvasEl = document.getElementById("fb_quickedit_canvas");
71
+ //: {panelId, aspect, view:{cx, cy, w}} while a session is open.
72
+ this.session = null;
73
+ //: channel key -> {data, width, height, box} for the region currently
74
+ //: on screen. Cleared whenever the framing moves.
75
+ this.planes = new Map();
76
+ this.sidebar = null;
77
+ }
78
+
79
+ setup() {
80
+ if (!this.root) return;
81
+ document.getElementById("fb_quickedit_done")
82
+ ?.addEventListener("click", () => this.commit());
83
+ document.getElementById("fb_quickedit_cancel")
84
+ ?.addEventListener("click", () => this.close());
85
+ document.getElementById("fb_quickedit_close")
86
+ ?.addEventListener("click", () => this.close());
87
+ document.getElementById("fb_quickedit_main")
88
+ ?.addEventListener("click", () => this.openInViewer());
89
+ this.bindView();
90
+ }
91
+
92
+ destroy() {
93
+ this.close();
94
+ }
95
+
96
+ // -- opening and closing -------------------------------------------------
97
+
98
+ /** Whether this panel can be quick-edited at all. */
99
+ canEdit(panel) {
100
+ const source = panel && this.state.source(panel.source_id);
101
+ if (!source || source.kind !== "plexora_project" || !source.datasource) return false;
102
+ return (this.state.sourceStatus[panel.source_id]?.status || "ok") !== "missing";
103
+ }
104
+
105
+ async open(panelId) {
106
+ const panel = this.state.panel(panelId);
107
+ if (!panel || !this.canEdit(panel)) {
108
+ FigureConfirm.tell({
109
+ title: "This panel has no project image to edit.",
110
+ body: "It was captured from an image the figure no longer references.",
111
+ });
112
+ return;
113
+ }
114
+ const source = this.state.source(panel.source_id);
115
+ const place = panel.placement;
116
+ // The panel's CURRENT shape, not the shape it was captured at. A square
117
+ // capture that has since been dragged into a wide rectangle must be
118
+ // edited as a wide rectangle, or Done would put the figure back to a
119
+ // proportion the user deliberately changed.
120
+ const aspect = place && place.h_mm > 0 ? place.w_mm / place.h_mm
121
+ : (panel.scene.viewport.h > 0
122
+ ? panel.scene.viewport.w / panel.scene.viewport.h : 1);
123
+
124
+ // The same rule the main-viewer round trip uses, from the same place:
125
+ // two implementations of "which region does a panel of this shape
126
+ // show" would disagree, and the disagreement would be a panel that
127
+ // framed one thing in Quick Edit and another in the viewer.
128
+ const framed = FigureSchema.aspectViewport(
129
+ panel.scene.viewport, aspect, source.image);
130
+ this.session = {
131
+ panelId: panelId,
132
+ sourceId: panel.source_id,
133
+ source: source,
134
+ aspect: aspect,
135
+ view: {
136
+ cx: framed.x + framed.w / 2,
137
+ cy: framed.y + framed.h / 2,
138
+ w: framed.w,
139
+ },
140
+ };
141
+ this.planes.clear();
142
+
143
+ const title = document.getElementById("fb_quickedit_title");
144
+ if (title) {
145
+ title.textContent = panel.title
146
+ || source.display_name || source.datasource || "Panel";
147
+ }
148
+ this.root.hidden = false;
149
+ this.workspace.root?.classList.add("fb-quickedit-open");
150
+ this.resizeCanvas();
151
+
152
+ await this.mountChannels(panel, source);
153
+ await this.refresh();
154
+ }
155
+
156
+ close() {
157
+ if (!this.root) return;
158
+ this.root.hidden = true;
159
+ this.workspace.root?.classList.remove("fb-quickedit-open");
160
+ this.session = null;
161
+ this.planes.clear();
162
+ this.sidebar = null;
163
+ const list = document.getElementById("fbqe_channel_slot_list");
164
+ if (list) list.innerHTML = "";
165
+ }
166
+
167
+ /**
168
+ * Hand this panel to the real viewer.
169
+ *
170
+ * What has been done here is COMMITTED first. Whatever the user has already
171
+ * reframed is work, and losing it on the way to a more capable tool would
172
+ * be the one place in this flow where moving forwards costs something --
173
+ * and the viewer would then open on the field they had just moved away
174
+ * from. Cancel is still how a session is thrown away.
175
+ */
176
+ async openInViewer() {
177
+ const panelId = this.session && this.session.panelId;
178
+ if (!panelId) return;
179
+ await this.commit();
180
+ this.onOpenInViewer(panelId);
181
+ }
182
+
183
+ // -- core's channel widget, mounted here ---------------------------------
184
+
185
+ /**
186
+ * Build the second ViewerSidebar, and everything it needs to talk to.
187
+ *
188
+ * Stats for every channel are fetched UP FRONT and handed to `init` as its
189
+ * database description. Core's own ChannelList fetches them lazily and
190
+ * draws d3 curves into `#channel_list`, an element this page does not have;
191
+ * pre-fetching instead is one round trip, needs no element, and leaves the
192
+ * sidebar's arithmetic -- byte/raw conversion, Auto's hints, the slider
193
+ * domain -- reading exactly the fields it expects.
194
+ */
195
+ async mountChannels(panel, source) {
196
+ const channels = (source.channels || []).filter((channel) => channel.key);
197
+ const names = [];
198
+ const keyByName = {};
199
+ const channelIndex = {};
200
+ channels.forEach((channel, index) => {
201
+ let name = channel.fullname_at_capture || channel.key;
202
+ // Two channels with one display name would make the marker picker
203
+ // ambiguous, so the second falls back to its stable key.
204
+ if (keyByName[name] !== undefined) name = channel.key;
205
+ names.push(name);
206
+ keyByName[name] = channel.key;
207
+ channelIndex[name] = index;
208
+ });
209
+ this.session.keyByName = keyByName;
210
+
211
+ const described = {};
212
+ await Promise.all(names.map(async (name) => {
213
+ const result = await this.api.pixelInfo(
214
+ this.figureId, this.session.sourceId, keyByName[name]);
215
+ const stats = (result.ok && result.data.stats) || {};
216
+ const low = Number.isFinite(stats.min) ? stats.min : 0;
217
+ const high = Number.isFinite(stats.max) ? stats.max : 65535;
218
+ described[name] = {
219
+ image_min: low,
220
+ image_max: Math.max(high, low + 1),
221
+ // The byte domain the sliders work in is spread across what
222
+ // this channel actually contains, so a 0-255 slider is 0-255
223
+ // of something rather than of the sensor's theoretical range.
224
+ qmin: low,
225
+ qmax: Math.max(high, low + 1),
226
+ // Where Auto lands: the same percentile window the viewer's
227
+ // auto-level produces, so a channel switched on here arrives
228
+ // looking the way it would there.
229
+ vmin_hint: Number.isFinite(stats.p01) ? stats.p01 : low,
230
+ vmax_hint: Number.isFinite(stats.p999) ? stats.p999 : high,
231
+ };
232
+ }));
233
+
234
+ this.sidebar = new ViewerSidebar(
235
+ { maxSelections: names.length }, names,
236
+ this.dataLayerShim(described), new SimpleEventHandler(document.body),
237
+ this.channelListShim(described),
238
+ {
239
+ root: this.root,
240
+ idPrefix: "fbqe_",
241
+ // See the class comment: these channels belong to a figure
242
+ // panel, not to the project.
243
+ persist: false,
244
+ channelIndex: channelIndex,
245
+ });
246
+
247
+ // Recomposite on anything the widget reports. Only a channel APPEARING
248
+ // needs pixels; colour and contrast are arithmetic over what is already
249
+ // in memory, which is the whole point of fetching them separately.
250
+ const repaint = () => this.refresh();
251
+ for (const event of [ChannelList.events.CHANNELS_CHANGE,
252
+ ChannelList.events.COLOR_TRANSFER_CHANGE,
253
+ ChannelList.events.BRUSH_MOVE]) {
254
+ this.sidebar.eventHandler.bind(event, repaint);
255
+ }
256
+
257
+ await this.sidebar.init(described);
258
+ this.seedFromScene(panel, names, keyByName);
259
+ }
260
+
261
+ /**
262
+ * Put the panel's captured channels into the widget.
263
+ *
264
+ * Through the ordinary setters rather than by writing slots directly, so a
265
+ * seeded slot is indistinguishable from one the user set up by hand -- and
266
+ * so any behaviour the setters gain in future applies here too.
267
+ */
268
+ seedFromScene(panel, names, keyByName) {
269
+ const captured = (panel.scene.channels || []).filter((channel) => channel.visible !== false);
270
+ const nameFor = {};
271
+ for (const name of names) nameFor[keyByName[name]] = name;
272
+
273
+ captured.forEach((channel, index) => {
274
+ const name = nameFor[channel.key];
275
+ const slot = this.sidebar.channelSlots[index];
276
+ if (!name || !slot) return;
277
+ this.sidebar.setSlotMarker(slot.index, name,
278
+ { keepColor: true, enable: true, force: true });
279
+ // The window travels in RAW units, which is what a captured scene
280
+ // stores and what the export renders from -- so it is converted
281
+ // into whichever domain the sliders are currently in.
282
+ slot.userRangeChanged = true;
283
+ slot.autoLeveled = true;
284
+ const colour = channel.color || { r: 255, g: 255, b: 255 };
285
+ this.sidebar.setSlotColor(
286
+ slot.index, this.sidebar.rgbToHex(colour.r, colour.g, colour.b), true);
287
+ const packet = this.sidebar.quantWindow(name);
288
+ const window_ = channel.window || [0, 65535];
289
+ this.sidebar.setSlotRange(
290
+ slot.index,
291
+ packet ? this.sidebar.rawToByteRange(window_, packet) : window_, true);
292
+ });
293
+ // Anything the panel did not have is switched off, so the mini view
294
+ // starts as a picture of the panel rather than of the image.
295
+ this.sidebar.channelSlots.slice(captured.length).forEach((slot) => {
296
+ if (slot.enabled) this.sidebar.setSlotEnabled(slot.index, false);
297
+ });
298
+ }
299
+
300
+ /**
301
+ * The small part of core's DataLayer the sidebar actually uses.
302
+ *
303
+ * Names are already full names here -- there is no marker/channel
304
+ * distinction in a figure source -- so `getFullChannelName` is the
305
+ * identity, and every saved-list call is a no-op for the reason in the
306
+ * class comment.
307
+ */
308
+ dataLayerShim(described) {
309
+ return {
310
+ imageBitRange: [0, 65535],
311
+ getFullChannelName: (name) => name,
312
+ getSavedChannelList: () => Promise.resolve([]),
313
+ saveChannelList: () => Promise.resolve(null),
314
+ getImageChannelStats: (name) => Promise.resolve(described[name] || {}),
315
+ };
316
+ }
317
+
318
+ /**
319
+ * The bookkeeping half of core's ChannelList, without the widget half.
320
+ *
321
+ * The real one draws a d3 distribution curve into `#channel_list` and runs
322
+ * a server-side Gaussian fit for the Auto button. Neither is reachable
323
+ * here: that element belongs to the viewer page, and the fit is a
324
+ * datasource-level endpoint of exactly the kind Quick Edit is built to
325
+ * avoid. What the SIDEBAR uses of it is a handful of dictionaries and two
326
+ * async hooks, and those are what this provides -- so `Auto` lands on the
327
+ * percentile window from `pixel_info` (the sidebar's first pass) and stops
328
+ * there rather than waiting for a fit that will never come.
329
+ */
330
+ channelListShim(described) {
331
+ return {
332
+ selections: [],
333
+ sel: {},
334
+ image_channels: {},
335
+ rangeConnector: {},
336
+ colorConnector: {},
337
+ hasChannelGMM: {},
338
+ databaseDescription: described,
339
+ ensureChannelStats: () => Promise.resolve(),
340
+ getAndDrawChannelGMM: () => Promise.resolve(null),
341
+ };
342
+ }
343
+
344
+ // -- the mini view -------------------------------------------------------
345
+
346
+ bindView() {
347
+ const canvas = this.canvasEl;
348
+ if (!canvas) return;
349
+
350
+ canvas.addEventListener("pointerdown", (event) => {
351
+ if (!this.session || event.button !== 0) return;
352
+ canvas.setPointerCapture?.(event.pointerId);
353
+ this.drag = { x: event.clientX, y: event.clientY,
354
+ cx: this.session.view.cx, cy: this.session.view.cy };
355
+ });
356
+ canvas.addEventListener("pointermove", (event) => {
357
+ if (!this.drag || !this.session) return;
358
+ const perPixel = this.session.view.w / this.frameRect().w;
359
+ this.session.view.cx = this.drag.cx - (event.clientX - this.drag.x) * perPixel;
360
+ this.session.view.cy = this.drag.cy - (event.clientY - this.drag.y) * perPixel;
361
+ this.paint();
362
+ this.settle();
363
+ });
364
+ const stop = () => { this.drag = null; };
365
+ canvas.addEventListener("pointerup", stop);
366
+ canvas.addEventListener("pointercancel", stop);
367
+
368
+ canvas.addEventListener("wheel", (event) => {
369
+ if (!this.session) return;
370
+ event.preventDefault();
371
+ const factor = Math.exp(event.deltaY * 0.0015);
372
+ const source = this.session.source.image || {};
373
+ const widest = Math.max(source.width || 1, source.height || 1);
374
+ this.session.view.w = Math.max(16, Math.min(widest * 2,
375
+ this.session.view.w * factor));
376
+ this.paint();
377
+ this.settle();
378
+ }, { passive: false });
379
+ }
380
+
381
+ /** Re-fetch once the view has stood still. Every frame of a drag would be a
382
+ * request per channel for a picture nobody has looked at yet. */
383
+ settle() {
384
+ window.clearTimeout(this._settleTimer);
385
+ this._settleTimer = window.setTimeout(() => this.refresh(), FigureQuickEdit.SETTLE_MS);
386
+ }
387
+
388
+ resizeCanvas() {
389
+ const canvas = this.canvasEl;
390
+ if (!canvas) return;
391
+ const box = canvas.parentElement.getBoundingClientRect();
392
+ const dpr = window.devicePixelRatio || 1;
393
+ canvas.style.width = Math.round(box.width) + "px";
394
+ canvas.style.height = Math.round(box.height) + "px";
395
+ canvas.width = Math.max(1, Math.round(box.width * dpr));
396
+ canvas.height = Math.max(1, Math.round(box.height * dpr));
397
+ this.dpr = dpr;
398
+ }
399
+
400
+ /** Where the panel's own frame sits on the mini view, in CSS pixels. */
401
+ frameRect() {
402
+ const canvas = this.canvasEl;
403
+ const width = canvas.width / (this.dpr || 1);
404
+ const height = canvas.height / (this.dpr || 1);
405
+ const inset = FigureQuickEdit.FRAME_INSET;
406
+ const aspect = this.session ? this.session.aspect : 1;
407
+ let w = width - inset * 2;
408
+ let h = w / aspect;
409
+ if (h > height - inset * 2) {
410
+ h = height - inset * 2;
411
+ w = h * aspect;
412
+ }
413
+ return { x: (width - w) / 2, y: (height - h) / 2, w: w, h: h };
414
+ }
415
+
416
+ /** The image region the whole mini view covers, in full-resolution pixels. */
417
+ viewRegion() {
418
+ const canvas = this.canvasEl;
419
+ const frame = this.frameRect();
420
+ const perPixel = this.session.view.w / frame.w;
421
+ const width = canvas.width / (this.dpr || 1);
422
+ const height = canvas.height / (this.dpr || 1);
423
+ return {
424
+ x: this.session.view.cx - (width / 2) * perPixel,
425
+ y: this.session.view.cy - (height / 2) * perPixel,
426
+ w: width * perPixel,
427
+ h: height * perPixel,
428
+ perPixel: perPixel,
429
+ };
430
+ }
431
+
432
+ /** Fetch whatever the current framing needs, then repaint. */
433
+ async refresh() {
434
+ if (!this.session || !this.sidebar) return;
435
+ const region = this.viewRegion();
436
+ const image = this.session.source.image || {};
437
+ // Clamped on THIS side rather than relying on the server's clipping: a
438
+ // clipped response resampled to the requested size would stretch the
439
+ // part that exists across the whole canvas, which looks like a zoom
440
+ // nobody asked for.
441
+ const clamped = {
442
+ x: Math.max(0, region.x),
443
+ y: Math.max(0, region.y),
444
+ };
445
+ clamped.w = Math.min(image.width || region.w, region.x + region.w) - clamped.x;
446
+ clamped.h = Math.min(image.height || region.h, region.y + region.h) - clamped.y;
447
+ if (!(clamped.w > 0) || !(clamped.h > 0)) {
448
+ this.paint();
449
+ return;
450
+ }
451
+ const out = {
452
+ w: Math.max(1, Math.round(clamped.w / region.perPixel)),
453
+ h: Math.max(1, Math.round(clamped.h / region.perPixel)),
454
+ };
455
+ const signature = [clamped.x, clamped.y, clamped.w, clamped.h, out.w, out.h]
456
+ .map((value) => Math.round(value)).join(":");
457
+
458
+ const wanted = this.activeSlots().map((slot) => this.session.keyByName[slot.name]);
459
+ const fetches = wanted.filter((key) => {
460
+ const held = this.planes.get(key);
461
+ return !held || held.signature !== signature;
462
+ });
463
+ await Promise.all(fetches.map(async (key) => {
464
+ const result = await this.api.readPixels(this.figureId, this.session.sourceId, {
465
+ channel: key, x: clamped.x, y: clamped.y, w: clamped.w, h: clamped.h,
466
+ out_w: out.w, out_h: out.h,
467
+ });
468
+ if (!result.ok) return;
469
+ this.planes.set(key, {
470
+ signature: signature, data: result.data,
471
+ width: result.width, height: result.height,
472
+ // Where these pixels belong on the canvas.
473
+ left: (clamped.x - region.x) / region.perPixel,
474
+ top: (clamped.y - region.y) / region.perPixel,
475
+ });
476
+ }));
477
+ this.paint();
478
+ }
479
+
480
+ activeSlots() {
481
+ if (!this.sidebar) return [];
482
+ return this.sidebar.channelSlots.filter(
483
+ (slot) => slot.enabled && slot.name && this.session.keyByName[slot.name]);
484
+ }
485
+
486
+ /**
487
+ * Composite what is held and draw it.
488
+ *
489
+ * Same arithmetic as `server/render.render_panel`: clip each channel into
490
+ * its window, multiply by its colour and the shared alpha, add, clip. A
491
+ * preview that composited differently from the exporter would be a picture
492
+ * of a figure nobody is going to get.
493
+ */
494
+ paint() {
495
+ const canvas = this.canvasEl;
496
+ const context = canvas && canvas.getContext("2d");
497
+ if (!context || !this.session) return;
498
+ const dpr = this.dpr || 1;
499
+ context.setTransform(dpr, 0, 0, dpr, 0, 0);
500
+ const width = canvas.width / dpr;
501
+ const height = canvas.height / dpr;
502
+ context.clearRect(0, 0, width, height);
503
+ context.fillStyle = "#000000";
504
+ context.fillRect(0, 0, width, height);
505
+
506
+ const slots = this.activeSlots();
507
+ const held = slots
508
+ .map((slot) => ({ slot: slot, plane: this.planes.get(this.session.keyByName[slot.name]) }))
509
+ .filter((entry) => entry.plane);
510
+ if (held.length) {
511
+ const first = held[0].plane;
512
+ const pixels = new ImageData(first.width, first.height);
513
+ const out = pixels.data;
514
+ for (let index = 0; index < first.width * first.height; index += 1) {
515
+ out[index * 4 + 3] = 255;
516
+ }
517
+ for (const { slot, plane } of held) {
518
+ if (plane.width !== first.width || plane.height !== first.height) continue;
519
+ const raw = this.sidebar.toRawRangeForSlot(slot);
520
+ const low = raw[0];
521
+ const span = Math.max(1e-6, raw[1] - raw[0]);
522
+ const weight = FigureQuickEdit.CHANNEL_ALPHA;
523
+ const colour = [slot.color.r * weight, slot.color.g * weight, slot.color.b * weight];
524
+ for (let index = 0; index < plane.data.length; index += 1) {
525
+ const t = Math.min(1, Math.max(0, (plane.data[index] - low) / span));
526
+ if (t <= 0) continue;
527
+ const at = index * 4;
528
+ out[at] = Math.min(255, out[at] + t * colour[0]);
529
+ out[at + 1] = Math.min(255, out[at + 1] + t * colour[1]);
530
+ out[at + 2] = Math.min(255, out[at + 2] + t * colour[2]);
531
+ }
532
+ }
533
+ const bitmap = document.createElement("canvas");
534
+ bitmap.width = first.width;
535
+ bitmap.height = first.height;
536
+ bitmap.getContext("2d").putImageData(pixels, 0, 0);
537
+ context.drawImage(bitmap, first.left, first.top, first.width, first.height);
538
+ }
539
+
540
+ // Everything outside the frame is dimmed rather than hidden: it is the
541
+ // surrounding tissue, and it is how the user knows which way to drag.
542
+ const frame = this.frameRect();
543
+ context.fillStyle = "rgba(0, 0, 0, 0.55)";
544
+ context.fillRect(0, 0, width, frame.y);
545
+ context.fillRect(0, frame.y + frame.h, width, height - frame.y - frame.h);
546
+ context.fillRect(0, frame.y, frame.x, frame.h);
547
+ context.fillRect(frame.x + frame.w, frame.y, width - frame.x - frame.w, frame.h);
548
+ context.strokeStyle = "rgba(120, 190, 255, 0.95)";
549
+ context.lineWidth = 1;
550
+ context.strokeRect(frame.x + 0.5, frame.y + 0.5, frame.w - 1, frame.h - 1);
551
+ }
552
+
553
+ // -- committing ------------------------------------------------------------
554
+
555
+ /**
556
+ * Write the reframed field and the channels back onto the panel.
557
+ *
558
+ * ONE commit on the workspace's own document state -- never a second
559
+ * FigureDocumentState. Two of them on one figure in one tab would hold two
560
+ * revisions of it and make each other stale on every save, in the same
561
+ * window, with the user doing nothing wrong.
562
+ */
563
+ async commit() {
564
+ const session = this.session;
565
+ const panel = session && this.state.panel(session.panelId);
566
+ if (!panel) {
567
+ this.close();
568
+ return;
569
+ }
570
+ const height = session.view.w / session.aspect;
571
+ const scene = {
572
+ ...JSON.parse(JSON.stringify(panel.scene)),
573
+ viewport: {
574
+ x: Math.round(session.view.cx - session.view.w / 2),
575
+ y: Math.round(session.view.cy - height / 2),
576
+ w: Math.round(session.view.w),
577
+ h: Math.round(height),
578
+ },
579
+ channels: this.activeSlots().map((slot) => {
580
+ const raw = this.sidebar.toRawRangeForSlot(slot);
581
+ return {
582
+ key: session.keyByName[slot.name],
583
+ fullname_at_capture: slot.name,
584
+ color: { ...slot.color },
585
+ window: [raw[0], raw[1]],
586
+ visible: true,
587
+ };
588
+ }),
589
+ captured_at: new Date().toISOString(),
590
+ };
591
+ const renderRevision = panel.render_revision + 1;
592
+ const changes = { scene: scene, render_revision: renderRevision };
593
+
594
+ const stored = await this.state.commit(
595
+ [{ op: "update_panel", panel_id: session.panelId, changes: changes }],
596
+ (draft) => { Object.assign(draft.panels[session.panelId], changes); });
597
+
598
+ if (stored) await this.uploadPreview(session.panelId, renderRevision);
599
+ this.close();
600
+ }
601
+
602
+ /**
603
+ * A fresh preview, cut out of what is on screen.
604
+ *
605
+ * Channels only. Overlays are not composited here, which matches what the
606
+ * EXPORT renders -- a preview that showed a phenotype colouring the
607
+ * exported figure will not have would be the one place in this tool where
608
+ * the raster lies about the deliverable.
609
+ */
610
+ async uploadPreview(panelId, renderRevision) {
611
+ const frame = this.frameRect();
612
+ const dpr = this.dpr || 1;
613
+ const out = document.createElement("canvas");
614
+ const width = Math.min(FigureQuickEdit.PREVIEW_WIDTH, Math.round(frame.w * dpr));
615
+ out.width = Math.max(1, width);
616
+ out.height = Math.max(1, Math.round(width / (this.session?.aspect || 1)));
617
+ out.getContext("2d").drawImage(
618
+ this.canvasEl,
619
+ frame.x * dpr, frame.y * dpr, frame.w * dpr, frame.h * dpr,
620
+ 0, 0, out.width, out.height);
621
+
622
+ const blob = await new Promise((resolve) =>
623
+ out.toBlob(resolve, "image/webp", 0.9));
624
+ if (!blob) return;
625
+ await this.api.putPreview(this.figureId, panelId, renderRevision, blob,
626
+ { width: out.width, height: out.height });
627
+ }
628
+ }