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,1159 @@
1
+ /**
2
+ * FigureBuilderSidebarController - Figure Builder inside the viewer.
3
+ *
4
+ * The name is core's: this is whatever `createSidebarController` returns, and
5
+ * core calls that hook for every plugin. Figure Builder no longer HAS a sidebar
6
+ * panel. It declares no panels at all, so it gets no card in the tool column,
7
+ * and everything it shows the user is on the image itself
8
+ * (figureCaptureDock, figureCaptureBoxes, figureCaptureTool).
9
+ *
10
+ * That is not tidiness. The controls were split between the two -- capture on
11
+ * the image, "which figure?" in the sidebar -- and the two halves of one
12
+ * decision in two places is worse than either place on its own. It also means
13
+ * the sidebar no longer offers a way to close this tool, so the dock carries
14
+ * one, and it takes the plugin all the way out.
15
+ *
16
+ * The figure canvas is NOT here either. It used to be rendered beside the image
17
+ * in a second workspace slot; composing a figure and looking down a microscope
18
+ * are different activities and half a window was not enough for either, so the
19
+ * canvas has its own page and its own URL (figureWorkspace, on
20
+ * `/plugins/figure_builder/figure/<id>`) and everything in this file that
21
+ * wanted it navigates instead.
22
+ *
23
+ * ## Captures do not need a figure
24
+ *
25
+ * A capture goes into a session strip first and into a figure second. Making the
26
+ * user answer "which figure?" before they have decided which regions are worth
27
+ * keeping is a management decision demanded at the worst possible moment -- and
28
+ * the honest answer, early on, is usually "I do not know yet". So:
29
+ *
30
+ * figure already open the capture is committed to it immediately, exactly
31
+ * as before, and survives a reload
32
+ * no figure open the capture is held in this page's memory and the
33
+ * strip marks it as not yet in a figure. It is written
34
+ * out the moment one is chosen, in ONE batch.
35
+ *
36
+ * The second case is the one with a cost, and it is stated rather than hidden:
37
+ * the strip marks those captures, and leaving the page while any are unattached
38
+ * asks first. They are memory, and memory does not survive a navigation.
39
+ *
40
+ * ## Every capture leaves a mark
41
+ *
42
+ * The region a capture came from stays outlined on the image for the rest of the
43
+ * session (figureCaptureBoxes). Selecting one -- from the strip or by clicking
44
+ * its outline -- highlights both and puts the viewer back over that field. It
45
+ * restores NOTHING about the rendering, on purpose: going back to a region,
46
+ * changing the channels and capturing it again is how two panels of one field
47
+ * under two renderings are made, and it has to be the easy thing to do.
48
+ * Reopening a panel's whole captured scene is a different action, is reached
49
+ * from the canvas, and says so on screen while it is running.
50
+ *
51
+ * Which figure is "current" is remembered in localStorage rather than on the
52
+ * server. It is a property of this browser and this person's train of thought,
53
+ * not of the figure or of the project: two people (or two windows) working on
54
+ * different figures from the same image is ordinary, and a server-side "current
55
+ * figure" would make each of them keep switching the other's back.
56
+ */
57
+ class FigureBuilderSidebarController {
58
+
59
+ static get STORAGE_KEY() { return "plexora.figure_builder.currentFigure"; }
60
+
61
+ /** Core's name for this plugin, which is also the handle the tool loader
62
+ * answers to. */
63
+ static get TOOL() { return "figure_builder"; }
64
+
65
+ /**
66
+ * The panel a capture becomes.
67
+ *
68
+ * Static and pure so the shape is checked without a browser -- one place
69
+ * for the defaults means a field added to the format has one place to be
70
+ * remembered in, whichever path built the panel.
71
+ */
72
+ static panelFor(capture, source) {
73
+ return {
74
+ panel_id: FigureSchema.newPanelId(),
75
+ source_id: source.source_id,
76
+ // The scene was taken before the source existed -- see onCaptured --
77
+ // so this is where the two are joined.
78
+ scene: { ...capture.scene, source_id: source.source_id },
79
+ // Straight to the tray: composition is a different sitting from
80
+ // exploration, and forcing a layout decision at the moment of
81
+ // capture is what makes people stop capturing.
82
+ placement: null,
83
+ label: { text: "", auto: true, visible: true },
84
+ // No calibration, no scale bar. A bar drawn from an assumed pixel
85
+ // size looks exactly like one that is right.
86
+ scalebar: { visible: Boolean(source.pixel_size), target_um: null },
87
+ legend: { channels: false, plugins: false },
88
+ render_revision: 1,
89
+ };
90
+ }
91
+
92
+ constructor(ctx) {
93
+ this.ctx = ctx;
94
+ this.datasource = ctx.datasource;
95
+ this.api = new FigureBuilderApi({ url: ctx.url });
96
+
97
+ this.figures = [];
98
+ this.figureId = null;
99
+ this.state = null;
100
+
101
+ //: This session's captures, newest first. Each is
102
+ //: {id, scene, preview, url, panelId} -- panelId null until it is in a
103
+ //: figure. The list dies with the page, which is also why every entry in
104
+ //: it is from THIS datasource: navigating to another image reloads.
105
+ this.captures = [];
106
+ //: The one the user is looking at, in the strip and on the image at the
107
+ //: same time. One id, not a flag per capture, because "selected" is a
108
+ //: property of the session and two things render it.
109
+ this.selected = null;
110
+ //: Attachment runs in a chain rather than in parallel: two captures a
111
+ //: moment apart would otherwise both read "nothing attached yet" and
112
+ //: write the same panel twice.
113
+ this._attaching = null;
114
+
115
+ //: {panelId, stash, report} while a panel's view is loaded into the
116
+ //: live viewer. Null the rest of the time -- which is what every
117
+ //: handler below tests, rather than a set of booleans.
118
+ this.editing = null;
119
+ //: A panel the figure page asked for before it navigated here. Held
120
+ //: until the document is open, because the panel being asked for is
121
+ //: inside it.
122
+ this.pendingPanelId = null;
123
+
124
+ //: What the dock says about itself. Both are transient: nothing here is
125
+ //: worth persisting, and a stale "Saved" is worse than none.
126
+ this.statusText = "";
127
+ this.failure = "";
128
+ //: The "where do these go?" dialog, built here rather than rendered
129
+ //: into a panel, because there is no panel. Appended to <body>: a
130
+ //: <dialog> inside a hidden ancestor is one showModal() opens onto
131
+ //: nothing.
132
+ this.chooser = null;
133
+
134
+ this.capture = new FigureCaptureTool(ctx, {
135
+ toolName: FigureBuilderSidebarController.TOOL,
136
+ onCapture: (rect, screenRect, preview) => this.onCaptured(rect, preview),
137
+ onStateChange: () => this.renderDock(),
138
+ // The lock is the selection: when the frame stops being on a
139
+ // capture, the strip and the boxes stop saying it is.
140
+ onUnpin: () => this.deselect(),
141
+
142
+ });
143
+ // The boxes share the capture tool's coordinate frame rather than
144
+ // carrying a second copy of the arithmetic: selecting a capture aims
145
+ // the frame at its box, and two copies would agree right up until the
146
+ // day one of them was fixed.
147
+ this.boxes = new FigureCaptureBoxes(ctx, {
148
+ tool: this.capture,
149
+ onSelect: (id) => this.selectCapture(id),
150
+ });
151
+ this.dock = new FigureCaptureDock({
152
+ onToggleCapture: () => this.toggleCapture(),
153
+ onSelectCapture: (id) => this.selectCapture(id),
154
+ onRemoveCapture: (id) => this.removeCapture(id),
155
+ onNewFigure: () => this.createFigure(),
156
+ onChooseFigure: () => this.askWhereToPut(),
157
+ onOpenCanvas: () => this.goToCanvas(),
158
+ onUpdatePanel: () => this.updatePanel(),
159
+ onCancelEdit: () => this.cancelEdit(),
160
+ onClose: () => this.close(),
161
+ });
162
+
163
+ //: Unattached captures are memory. Leaving with some still in the strip
164
+ //: is the one way to lose work here, so it is the one thing that asks.
165
+ this._onBeforeUnload = (event) => {
166
+ if (!this.unattached()) return;
167
+ event.preventDefault();
168
+ event.returnValue = "";
169
+ };
170
+
171
+ // Through the plugin's own cleanup list so the viewer, document and
172
+ // window listeners this tool installs go when the plugin does. Left
173
+ // behind, a drag meant for another tool would move a viewfinder over an
174
+ // image nobody is capturing from.
175
+ ctx.onCleanup?.(() => this.destroy());
176
+ }
177
+
178
+ /** How many captures are not in a figure yet. */
179
+ unattached() {
180
+ return this.captures.filter((capture) => !capture.panelId).length;
181
+ }
182
+
183
+ // -- lifecycle -------------------------------------------------------
184
+
185
+ setup() {
186
+ this.buildChooser();
187
+ window.addEventListener("beforeunload", this._onBeforeUnload);
188
+ this.mount();
189
+ }
190
+
191
+ destroy() {
192
+ window.removeEventListener("beforeunload", this._onBeforeUnload);
193
+ this.capture.destroy();
194
+ this.boxes.destroy();
195
+ this.dock.destroy();
196
+ this.chooser?.remove();
197
+ this.chooser = null;
198
+ for (const capture of this.captures) {
199
+ if (capture.url) URL.revokeObjectURL(capture.url);
200
+ }
201
+ this.captures = [];
202
+ this.selected = null;
203
+ }
204
+
205
+ /**
206
+ * Take Figure Builder off the page altogether.
207
+ *
208
+ * Through the loader rather than by tearing down in place: the loader owns
209
+ * the record of which tools are loaded, and a controller that dismantled
210
+ * itself behind its back would leave an entry pointing at a dead object --
211
+ * and re-opening from the Tools menu would then do nothing at all.
212
+ */
213
+ async close() {
214
+ const waiting = this.unattached();
215
+ // FigureConfirm and not `window.confirm`, for the reasons in its
216
+ // docstring. On this page it lands on <body> rather than in the
217
+ // workspace, which is where it should be: the viewer is dark, and
218
+ // core's tokens are the right ones to inherit here.
219
+ if (waiting && !await FigureConfirm.ask({
220
+ title: "Close Figure Builder?",
221
+ body: FigureSchema.countPhrase(waiting, "capture")
222
+ + " not yet in a figure will be lost.",
223
+ confirm: "Close and discard",
224
+ })) {
225
+ return;
226
+ }
227
+ // Belt and braces: the loader's removeTool reaches destroy() through
228
+ // deactivatePlugin, and on a page where the loader is somehow absent
229
+ // this is still the honest thing to do.
230
+ if (window.PlexoraToolLoader?.removeTool) {
231
+ window.PlexoraToolLoader.removeTool(FigureBuilderSidebarController.TOOL);
232
+ } else {
233
+ this.destroy();
234
+ }
235
+ }
236
+
237
+ onShow() {
238
+ // The library may have changed on another page since this tool was
239
+ // last looked at -- a figure created there, or deleted.
240
+ this.mount();
241
+ this.fetchSaved().then(() => this.render());
242
+ }
243
+
244
+ /**
245
+ * Another tool was opened over this one.
246
+ *
247
+ * The FRAME stands down, because it listens on the viewer and on the
248
+ * document, neither of which goes away here -- and a drag meant for the tool
249
+ * the user just opened must not also redraw a viewfinder.
250
+ *
251
+ * The DOCK does not. It is the session: the captures in it, and the regions
252
+ * they came from, are the work, and a tool whose work vanished because the
253
+ * user glanced at ROI would be a tool nobody trusted with an hour of it.
254
+ * Figure Builder leaves the page when its own Close is pressed, and that is
255
+ * the only time.
256
+ */
257
+ onHide() {
258
+ this.capture.disarm();
259
+ this.renderDock();
260
+ }
261
+
262
+ /**
263
+ * Everything this tool opens with: the library, and which figure was last
264
+ * being worked on.
265
+ *
266
+ * One request, made in parallel with core's channel restore by the sidebar
267
+ * -- which is why this is a fetch that returns rather than a fetch that
268
+ * renders.
269
+ */
270
+ async fetchSaved() {
271
+ const result = await this.api.listFigures();
272
+ if (!result.ok) {
273
+ this.fail("The figure library could not be read.");
274
+ return null;
275
+ }
276
+ this.figures = result.data.figures.filter((figure) => figure.readable);
277
+ return this.figures;
278
+ }
279
+
280
+ applyOrDefault() {
281
+ // A request left by the figure page before it navigated here outranks
282
+ // the remembered figure: the user asked for this panel by
283
+ // double-clicking it a moment ago, and landing on a different figure
284
+ // would be the tool ignoring the thing they just did.
285
+ const pending = this.takePendingEdit();
286
+ const remembered = pending ? pending.figure_id : this.readRemembered();
287
+ const exists = this.figures.some((figure) => figure.figure_id === remembered);
288
+ this.figureId = exists ? remembered : null;
289
+ this.pendingPanelId = (pending && exists) ? pending.panel_id : null;
290
+ //: The whole request, not just the panel: it also says which SHAPE the
291
+ //: panel is now and where the user expects to end up. Kept rather than
292
+ //: reduced to an id, because both of those are decisions made on the
293
+ //: canvas that this page has no other way to learn.
294
+ this.pendingEdit = (pending && exists) ? pending : null;
295
+ this.render();
296
+ if (this.figureId) this.openFigure(this.figureId);
297
+ }
298
+
299
+
300
+ // -- capturing -------------------------------------------------------
301
+
302
+ mount() {
303
+ if (this.dock.mount()) this.renderDock();
304
+ if (this.boxes.mount()) this.renderBoxes();
305
+ }
306
+
307
+ /** Arm or stand down the viewfinder. */
308
+ toggleCapture() {
309
+ // Not while the viewer is showing a panel's borrowed scene: a capture
310
+ // taken then would be a panel of somebody else's view, and nothing on
311
+ // screen would say so.
312
+ if (this.editing) return;
313
+ if (this.capture.active) this.capture.disarm();
314
+ else this.capture.arm();
315
+ }
316
+
317
+ /**
318
+ * The shutter fired. Keep what was taken.
319
+ *
320
+ * The scene is read SYNCHRONOUSLY, before anything is awaited: it comes
321
+ * from the live viewer, and an await first would let a pan, a channel
322
+ * change or a contrast tweak land in a panel whose crop was decided before
323
+ * it. The preview and the figure can both wait; the scene cannot.
324
+ */
325
+ async onCaptured(rect, previewPromise) {
326
+ const capture = {
327
+ id: FigureSchema.newId("cap"),
328
+ // No source id yet -- there may be no figure to own a source. It is
329
+ // filled in by panelFor() when the capture reaches one.
330
+ scene: FigureScene.capture(this.ctx, "", rect),
331
+ preview: null,
332
+ url: null,
333
+ panelId: null,
334
+ };
335
+ this.captures.unshift(capture);
336
+ // Selected, but NOT centred on: the viewer is already exactly there,
337
+ // and flying it to where it is would be a jolt with no cause. Only an
338
+ // explicit click on a strip item or a box moves the viewer.
339
+ //
340
+ // Locking is free here for the same reason -- the frame is already on
341
+ // what it just took, so pinTo() moves nothing -- and it makes the next
342
+ // press of the shutter re-take this exact region after a channel
343
+ // change. Panning or redrawing the frame lets go of it again, which is
344
+ // what keeps "one frame, four places" working.
345
+ this.selected = this.capture.pinTo(
346
+ capture.scene.viewport, this.labelFor(capture.id)) ? capture.id : null;
347
+ this.renderDock();
348
+ this.renderBoxes();
349
+
350
+ const preview = await previewPromise;
351
+ if (preview) {
352
+ capture.preview = preview;
353
+ // An object URL, not the preview route: the thumbnail is then there
354
+ // the moment the shutter closes, and it is the only way an
355
+ // unattached capture -- which the server has never seen -- can show
356
+ // one at all.
357
+ capture.url = URL.createObjectURL(preview.blob);
358
+ }
359
+ this.renderDock();
360
+ await this.attachCaptures();
361
+ }
362
+
363
+ /**
364
+ * Go back to a capture, arm the shutter, and lock onto it.
365
+ *
366
+ * The FIELD, and nothing else. The channels, the windows, the colours and
367
+ * the overlays stay exactly as the user has them, so the obvious next move
368
+ * -- change the rendering, capture the same region again -- produces a
369
+ * second panel in pixel-level concordance with the first. Restoring the
370
+ * captured scene here would take that away, and a "go back" that silently
371
+ * rewrote the viewer's colours would be the more surprising of the two.
372
+ *
373
+ * Locked, not merely aimed: while a capture is selected the shutter takes
374
+ * THAT region, so the second panel is the same region and not a freehand
375
+ * rectangle over roughly the same tissue. The lock is the selection -- see
376
+ * deselect() for the other end of it.
377
+ */
378
+ selectCapture(id) {
379
+ const capture = this.captures.find((entry) => entry.id === id);
380
+ if (!capture) return;
381
+ // Clicking a capture is asking to work on that region, and everything
382
+ // that follows -- the frame landing on it, the shutter locking onto it
383
+ // -- is invisible with the mode off. Arming here rather than making the
384
+ // user find the orb first is also what stops the click reading as "that
385
+ // did nothing": with no frame on screen, going back to a region moves
386
+ // the viewer and leaves nothing behind saying why.
387
+ //
388
+ // Not while a panel's view is on loan, for the reason toggleCapture()
389
+ // gives: a shot taken then is a panel of somebody else's scene.
390
+ if (!this.editing) this.capture.arm();
391
+ // Quietly: the flight below moves the viewer, and a lock that noticed
392
+ // would let go of the very selection being made.
393
+ this.capture.unpin(true);
394
+ this.selected = id;
395
+ this.renderDock();
396
+ this.renderBoxes();
397
+
398
+ const rect = capture.scene.viewport;
399
+ const label = this.labelFor(id);
400
+ this.boxes.centerOn(rect, () => {
401
+ // A second click while this one was in the air wins; landing the
402
+ // frame now would put it on the region the user just left.
403
+ if (this.selected !== id) return;
404
+ // One call, not aimAt-then-pinTo: those took two readings of where
405
+ // the region is, a moment apart, and the viewer is still settling
406
+ // for a while after it says it has stopped -- so the two disagreed
407
+ // by a few pixels and the lock was refused on the capture the user
408
+ // had just clicked. lockOn() takes one reading and uses it for both.
409
+ if (!this.capture.lockOn(rect, label)) this.deselect();
410
+ });
411
+ }
412
+
413
+ /**
414
+ * The frame let go of the region it was locked to.
415
+ *
416
+ * Called by the tool when the user pans, zooms, redraws the frame or drags
417
+ * it somewhere else. The highlight on the image and the active item in the
418
+ * strip both mean "the shutter will take this one", so when that stops
419
+ * being true they have to stop saying it.
420
+ */
421
+ deselect() {
422
+ if (!this.selected) return;
423
+ this.selected = null;
424
+ this.renderDock();
425
+ this.renderBoxes();
426
+ }
427
+
428
+ /** What to call a capture on the frame: the same number its box and its
429
+ * thumbnail carry, so all three are obviously the one thing. */
430
+ labelFor(id) {
431
+ const index = this.captures.findIndex((capture) => capture.id === id);
432
+ return index < 0 ? "" : "Capture " + (index + 1);
433
+ }
434
+
435
+ /**
436
+ * Write every unattached capture into the open figure, as one batch.
437
+ *
438
+ * One call is one undo step, so a burst of six captures that reach a figure
439
+ * together undo as the one thing the user did. Nothing happens at all when
440
+ * no figure is open -- that is the whole point of the strip.
441
+ */
442
+ attachCaptures() {
443
+ this._attaching = (this._attaching || Promise.resolve())
444
+ .then(() => this.attachOnce())
445
+ .catch((error) => {
446
+ console.error("figure_builder: captures could not be attached", error);
447
+ return false;
448
+ });
449
+ return this._attaching;
450
+ }
451
+
452
+ async attachOnce() {
453
+ if (!this.figureId || !this.state || !this.state.document) return false;
454
+ const pending = this.captures.filter((capture) => !capture.panelId);
455
+ if (!pending.length) return true;
456
+
457
+ const source = await this.ensureSource();
458
+ if (!source) {
459
+ this.fail("This image could not be registered as a source.");
460
+ return false;
461
+ }
462
+
463
+ // Oldest first, so the tray reads in the order the captures were taken
464
+ // -- the strip shows them newest first because that is where the eye
465
+ // goes, but the figure is a record and records run forwards.
466
+ const ordered = pending.slice().reverse();
467
+ const panels = ordered.map((capture) =>
468
+ FigureBuilderSidebarController.panelFor(capture, source));
469
+
470
+ this.setStatus("Saving captures…");
471
+ const stored = await this.state.commit(
472
+ panels.map((panel) => ({ op: "add_panel", panel: panel })),
473
+ (draft) => { for (const panel of panels) draft.panels[panel.panel_id] = panel; });
474
+ if (!stored) return false;
475
+
476
+ // The panel is committed first and the preview uploaded after. The scene
477
+ // is the master and the raster is a convenience -- so an upload that
478
+ // fails leaves a panel that still re-renders correctly at export,
479
+ // whereas the other order would leave an orphaned raster and no record
480
+ // of what it was.
481
+ for (let index = 0; index < ordered.length; index += 1) {
482
+ const capture = ordered[index];
483
+ const panel = panels[index];
484
+ capture.panelId = panel.panel_id;
485
+ if (capture.preview) {
486
+ await this.api.putPreview(this.figureId, panel.panel_id, 1,
487
+ capture.preview.blob,
488
+ { width: capture.preview.width, height: capture.preview.height });
489
+ }
490
+ }
491
+ this.render();
492
+ return true;
493
+ }
494
+
495
+ /**
496
+ * Drop a capture from the strip.
497
+ *
498
+ * If it already reached the figure it is a panel, and it goes from there
499
+ * too: a strip and a canvas that disagree about what was kept is worse than
500
+ * either answer on its own. Its box goes with it -- an outline on the image
501
+ * pointing at a capture nobody can open is a mark with nothing behind it.
502
+ */
503
+ async removeCapture(id) {
504
+ // Anything in flight finishes first: a capture removed while its panel
505
+ // is halfway to the server would leave that panel in the figure with
506
+ // nothing in the strip pointing at it. Waiting costs a moment and makes
507
+ // the two paths agree -- after this, the capture either has a panel to
508
+ // remove or never got one.
509
+ if (this._attaching) await this._attaching;
510
+
511
+ const index = this.captures.findIndex((capture) => capture.id === id);
512
+ if (index < 0) return;
513
+ const [capture] = this.captures.splice(index, 1);
514
+ if (capture.url) URL.revokeObjectURL(capture.url);
515
+ // The lock and the highlight are one state, so a capture that is gone
516
+ // must not leave the shutter still aimed at where it used to be.
517
+ if (this.selected === id) {
518
+ this.selected = null;
519
+ this.capture.unpin(true);
520
+ }
521
+ this.renderDock();
522
+ this.renderBoxes();
523
+
524
+ if (!capture.panelId || !this.state || !this.state.document) return;
525
+ await this.state.commit(
526
+ [{ op: "remove_panels", panel_ids: [capture.panelId] }],
527
+ (draft) => { delete draft.panels[capture.panelId]; });
528
+ }
529
+
530
+ // -- choosing where captures go --------------------------------------
531
+
532
+ /**
533
+ * The "where do these go?" dialog.
534
+ *
535
+ * A native <dialog> is modal, focus-trapped and Esc-dismissible without a
536
+ * line of script, and it cannot end up behind the canvas the way a
537
+ * positioned div can. Built here rather than rendered by the server for the
538
+ * same reason the dock is: this plugin has no panel to render it into, and
539
+ * core has no slot over the image.
540
+ */
541
+ buildChooser() {
542
+ if (this.chooser || !document.body) return;
543
+ const dialog = document.createElement("dialog");
544
+ dialog.id = "fb_destination_dialog";
545
+ dialog.className = "fb-dialog";
546
+ dialog.innerHTML = `
547
+ <h2>Where would you like to add these captures?</h2>
548
+ <p class="fb-muted" data-role="summary"></p>
549
+
550
+ <div class="fb-dialog-actions">
551
+ <button class="sidebar-action" type="button" data-role="new">
552
+ <span class="fas fa-plus"></span> Create new figure
553
+ </button>
554
+ <button class="sidebar-action secondary" type="button" data-role="existing">
555
+ <span class="fas fa-folder-open"></span> Open existing figure
556
+ </button>
557
+ </div>
558
+
559
+ <div data-role="pick" hidden>
560
+ <label class="control-label" for="fb_destination_select">Figure</label>
561
+ <select id="fb_destination_select" class="fb-select" aria-label="Figure"></select>
562
+ <div class="fb-dialog-actions">
563
+ <button class="sidebar-action" type="button" data-role="open">Open</button>
564
+ </div>
565
+ </div>
566
+
567
+ <div class="fb-dialog-actions fb-dialog-footer">
568
+ <button class="sidebar-action secondary" type="button" data-role="cancel">Cancel</button>
569
+ </div>`;
570
+ dialog.addEventListener("click", (event) => {
571
+ const role = event.target.closest("[data-role]")?.dataset.role;
572
+ if (role === "new") this.chooseNew();
573
+ else if (role === "existing") this.offerExisting();
574
+ else if (role === "open") this.chooseExisting();
575
+ else if (role === "cancel") this.closeChooser();
576
+ });
577
+ document.body.appendChild(dialog);
578
+ this.chooser = dialog;
579
+ }
580
+
581
+ part(role) {
582
+ return this.chooser?.querySelector(`[data-role="${role}"]`) || null;
583
+ }
584
+
585
+ /**
586
+ * Ask, once, at the moment the answer is needed.
587
+ *
588
+ * Which is when the user goes to compose -- not when they opened the tool,
589
+ * and not when they took their first capture.
590
+ */
591
+ askWhereToPut() {
592
+ if (!this.chooser) return;
593
+ const summary = this.part("summary");
594
+ if (summary) {
595
+ const pending = this.unattached();
596
+ summary.textContent = pending
597
+ ? FigureSchema.countPhrase(pending, "capture") + " waiting."
598
+ : "Nothing is waiting — this only chooses where the next ones go.";
599
+ }
600
+ const pick = this.part("pick");
601
+ if (pick) pick.hidden = true;
602
+ const existing = this.part("existing");
603
+ if (existing) existing.disabled = this.figures.length === 0;
604
+ this.chooser.showModal?.();
605
+ }
606
+
607
+ closeChooser() {
608
+ this.chooser?.close?.();
609
+ }
610
+
611
+ /**
612
+ * "A new figure" from the destination dialog: make it, then go and look at it.
613
+ *
614
+ * A navigation, not a pane. `goToCanvas` is what runs, so the waiting
615
+ * captures are written into the new figure BEFORE the page changes and a
616
+ * failed write cancels the trip -- unattached captures are memory, and this
617
+ * navigation ends the memory.
618
+ */
619
+ async chooseNew() {
620
+ this.closeChooser();
621
+ // Only if one was actually created: opening the canvas onto a figure
622
+ // that failed to be made is an empty page and no explanation.
623
+ if (await this.createFigure()) await this.goToCanvas();
624
+ }
625
+
626
+ offerExisting() {
627
+ const select = this.chooser?.querySelector("#fb_destination_select");
628
+ if (select) {
629
+ select.innerHTML = this.figures.map((figure) =>
630
+ `<option value="${FigureSchema.escapeHtml(figure.figure_id)}">`
631
+ + `${FigureSchema.escapeHtml(figure.title || "Untitled figure")}</option>`).join("");
632
+ }
633
+ const pick = this.part("pick");
634
+ if (pick) pick.hidden = false;
635
+ }
636
+
637
+ async chooseExisting() {
638
+ const select = this.chooser?.querySelector("#fb_destination_select");
639
+ const figureId = select && select.value;
640
+ if (!figureId) return;
641
+ this.closeChooser();
642
+ // Chooses where the next captures go, and nothing else. It used to open
643
+ // the canvas beside the image as well, which answered a question the
644
+ // user had not asked and took half the viewer to do it.
645
+ await this.selectFigure(figureId);
646
+ }
647
+
648
+ /**
649
+ * Leave for the Figure Canvas, once there is somewhere for the captures to go.
650
+ *
651
+ * A navigation, not a pane. The canvas used to open beside the image, which
652
+ * gave the figure half a window and the slide the other half -- and neither
653
+ * job enough room to do. Composing a figure is a different activity from
654
+ * looking down a microscope, so it gets the whole page and its own URL, and
655
+ * this button is the door.
656
+ *
657
+ * The order is the whole of the care here: unattached captures are MEMORY,
658
+ * and this navigation ends the memory. So everything waiting is written into
659
+ * the figure first, and a write that fails stops the navigation rather than
660
+ * carrying the captures off the page -- the strip keeps them and says why.
661
+ */
662
+ async goToCanvas() {
663
+ if (!this.figureId || !this.state || !this.state.document) {
664
+ this.askWhereToPut();
665
+ return;
666
+ }
667
+ const stored = await this.attachCaptures();
668
+ if (!stored) {
669
+ this.fail("These captures could not be saved, so the canvas was not opened.");
670
+ return;
671
+ }
672
+ this.rememberOrigin();
673
+ window.location.href = this.api.figureHref(this.figureId);
674
+ }
675
+
676
+ /**
677
+ * Leave a note saying the canvas was opened from HERE.
678
+ *
679
+ * The figure page's back arrow reads it. Without it that arrow always goes
680
+ * to the Figures library, which is the wrong door for the commonest trip
681
+ * there is: capture a few fields, go and look at the figure, come back to
682
+ * the slide for one more. The tool is named in the href, so arriving back
683
+ * finds the dock already on the image rather than a viewer with no way to
684
+ * capture from it.
685
+ *
686
+ * Keyed by figure and kept in sessionStorage, so it is this tab's answer
687
+ * about this figure and a note left over from another one is ignored.
688
+ */
689
+ rememberOrigin() {
690
+ if (!this.figureId || !this.datasource) return;
691
+ try {
692
+ window.sessionStorage.setItem("plexora:figure-builder-origin",
693
+ JSON.stringify({
694
+ figure_id: this.figureId,
695
+ href: window.location.pathname + "?tool=figure_builder",
696
+ label: this.datasource,
697
+ }));
698
+ } catch (error) {
699
+ /* see readRemembered -- the navigation is still worth making */
700
+ }
701
+ }
702
+
703
+ // -- actions ---------------------------------------------------------
704
+
705
+ readRemembered() {
706
+ try {
707
+ return window.localStorage.getItem(FigureBuilderSidebarController.STORAGE_KEY) || null;
708
+ } catch (error) {
709
+ // Private-browsing modes throw rather than returning null. Losing
710
+ // the remembered figure is a small inconvenience; throwing here
711
+ // would take the tool down with it.
712
+ return null;
713
+ }
714
+ }
715
+
716
+ remember(figureId) {
717
+ try {
718
+ if (figureId) window.localStorage.setItem(FigureBuilderSidebarController.STORAGE_KEY, figureId);
719
+ else window.localStorage.removeItem(FigureBuilderSidebarController.STORAGE_KEY);
720
+ } catch (error) {
721
+ /* see readRemembered */
722
+ }
723
+ }
724
+
725
+ async createFigure() {
726
+ this.setStatus("Creating…");
727
+ const result = await this.api.createFigure("");
728
+ if (!result.ok) {
729
+ this.fail("Could not create a figure.");
730
+ return null;
731
+ }
732
+ this.figures.unshift({
733
+ figure_id: result.data.figure_id,
734
+ title: result.data.document.title,
735
+ readable: true,
736
+ revision: result.data.document.revision,
737
+ page_count: result.data.document.pages.length,
738
+ panel_count: 0,
739
+ sources: [],
740
+ has_thumbnail: false,
741
+ });
742
+ await this.selectFigure(result.data.figure_id);
743
+ return result.data.figure_id;
744
+ }
745
+
746
+ selectFigure(figureId) {
747
+ this.figureId = figureId || null;
748
+ this.remember(this.figureId);
749
+ this.render();
750
+ return this.figureId ? this.openFigure(this.figureId) : Promise.resolve();
751
+ }
752
+
753
+ async openFigure(figureId) {
754
+ this.state = new FigureDocumentState({ api: this.api, figureId: figureId });
755
+ this.state.on("status", (payload) => this.renderStatus(payload));
756
+ this.state.on("change", () => this.render());
757
+ const opened = await this.state.load();
758
+ if (!opened) {
759
+ this.state = null;
760
+ this.render();
761
+ return;
762
+ }
763
+ this.render();
764
+
765
+ // Only after the document is open: the panel being asked for is in it.
766
+ if (this.pendingPanelId) {
767
+ const panelId = this.pendingPanelId;
768
+ const request = this.pendingEdit;
769
+ this.pendingPanelId = null;
770
+ this.pendingEdit = null;
771
+ this.remember(this.figureId);
772
+ this.editPanel(panelId, request);
773
+ }
774
+
775
+ // Every route into a figure passes through here, so this is the one
776
+ // place the waiting captures have to be written out from.
777
+ await this.attachCaptures();
778
+ }
779
+
780
+ // -- editing a panel's view ------------------------------------------
781
+
782
+ /**
783
+ * Reopen the view a panel was captured from.
784
+ *
785
+ * A panel belonging to ANOTHER image navigates: main.js boots per page and
786
+ * the server holds one loaded datasource, so "swap the image under the live
787
+ * viewer" is not something this app can do without pretending. The request
788
+ * is left in sessionStorage and picked up on arrival.
789
+ *
790
+ * A panel belonging to THIS image is restored in place, which is the case
791
+ * that matters -- it is what makes adjusting a capture feel like adjusting
792
+ * the viewer rather than like reloading a page.
793
+ */
794
+ editPanel(panelId, request) {
795
+ const panel = this.state?.panel(panelId);
796
+ const source = panel && this.state.source(panel.source_id);
797
+ if (!panel || !source) return;
798
+
799
+ if (source.kind !== "plexora_project" || !source.datasource) {
800
+ this.fail("This panel has no project image to reopen.");
801
+ return;
802
+ }
803
+ if (source.datasource !== this.datasource) {
804
+ // Hand the request to the page that CAN show it, the same way the
805
+ // figure canvas does: the note in sessionStorage is read once on
806
+ // arrival (takePendingEdit). Passed on whole, so the panel's shape
807
+ // and where the user expects to end up survive the second hop.
808
+ try {
809
+ window.sessionStorage.setItem("plexora:figure-builder-pending",
810
+ JSON.stringify({ ...(request || {}),
811
+ figure_id: this.figureId, panel_id: panelId }));
812
+ } catch (error) {
813
+ /* Private-browsing modes throw; the navigation is still worth doing. */
814
+ }
815
+ window.location.href = this.api.url(encodeURIComponent(source.datasource))
816
+ + "?tool=figure_builder";
817
+ return;
818
+ }
819
+ this.beginEdit(panelId, request);
820
+ }
821
+
822
+ /**
823
+ * Load a panel's scene into the live viewer, and outline its frame.
824
+ *
825
+ * The viewer's CURRENT state is stashed first, because this is a temporary
826
+ * loan and Cancel has to put the user back where they were -- not where the
827
+ * project last saved, and not wherever the last panel they looked at was.
828
+ *
829
+ * Then the panel's own edges are drawn on the image, using the capture
830
+ * frame in FRAMING mode: the same locked outline that going back to a
831
+ * capture produces, with the shutter taken off it. Without it the user is
832
+ * looking at a viewer showing roughly the right place and has no way to see
833
+ * what the panel will actually contain -- which is most of what they came
834
+ * here to decide.
835
+ *
836
+ * The rect is the panel's CURRENT shape (`FigureSchema.aspectViewport`),
837
+ * not the shape it was captured at: a square capture dragged into a wide
838
+ * strip has to be reframed as a wide strip.
839
+ */
840
+ async beginEdit(panelId, request) {
841
+ const panel = this.state.panel(panelId);
842
+ if (!panel) return;
843
+
844
+ this.capture.disarm();
845
+ this.editing = {
846
+ panelId: panelId,
847
+ // Captured, not merely remembered: the stash goes back through the
848
+ // same restore path, so returning is exactly as faithful as
849
+ // arriving.
850
+ stash: FigureScene.capture(this.ctx, panel.source_id,
851
+ FigureScene.currentViewport(this.ctx)),
852
+ //: Where to go when this session ends. The canvas sends the user
853
+ //: here and expects them back; the dock's own Edit does not.
854
+ returnTo: (request && request.return_to) || null,
855
+ };
856
+ this.render();
857
+
858
+ const report = await FigureScene.restore(this.ctx, panel.scene);
859
+ this.editing.report = report;
860
+ this.render();
861
+ this.frameThePanel(panel, request);
862
+ }
863
+
864
+ /**
865
+ * Put the framing outline on the panel's region.
866
+ *
867
+ * Armed AFTER the restore and only once the viewer has stopped moving:
868
+ * `lockOn` refuses a region it cannot currently project as a frame, and
869
+ * OpenSeadragon carries on settling for a while after it says it has
870
+ * finished -- the same thing that used to make a clicked capture come back
871
+ * unselected.
872
+ */
873
+ frameThePanel(panel, request) {
874
+ const aspect = Number(request && request.aspect) || 0;
875
+ const source = this.state.source(panel.source_id);
876
+ const rect = FigureSchema.aspectViewport(
877
+ panel.scene.viewport, aspect, source && source.image);
878
+
879
+ this.boxes.centerOn(rect, () => {
880
+ if (!this.editing) return;
881
+ this.capture.arm();
882
+ // A frame, not a viewfinder: a capture taken through it would be a
883
+ // second panel of a borrowed scene.
884
+ this.capture.setFraming(true);
885
+ this.capture.lockOn(rect, panel.title || "this panel");
886
+ this.renderDock();
887
+ });
888
+ }
889
+
890
+ /**
891
+ * Write what is on screen back onto the panel.
892
+ *
893
+ * Both halves move together: the scene AND a fresh preview at a new render
894
+ * revision. Updating one without the other is what leaves a panel whose
895
+ * raster shows one thing and whose export shows another.
896
+ *
897
+ * The region comes from the PINNED FRAME rather than from the panel's
898
+ * stored viewport. That is the deliberate change that makes this a round
899
+ * trip rather than a re-render: the frame follows the region while the
900
+ * viewer moves, and the user may have dragged it somewhere else entirely --
901
+ * which is the whole reason to open a panel in the main viewer.
902
+ */
903
+ async updatePanel() {
904
+ const session = this.editing;
905
+ const panel = session && this.state.panel(session.panelId);
906
+ if (!panel) return;
907
+ this.setStatus("Updating…");
908
+
909
+ const viewport = this.capture.pinned
910
+ ? this.capture.clamp(this.capture.pinned)
911
+ : panel.scene.viewport;
912
+ const scene = FigureScene.capture(this.ctx, panel.source_id, viewport);
913
+ const renderRevision = panel.render_revision + 1;
914
+ const changes = { scene: scene, render_revision: renderRevision };
915
+
916
+ const stored = await this.state.commit(
917
+ [{ op: "update_panel", panel_id: session.panelId, changes: changes }],
918
+ (draft) => { Object.assign(draft.panels[session.panelId], changes); });
919
+ if (!stored) return;
920
+
921
+ const screenRect = this.capture.toScreenRect(viewport);
922
+ const preview = screenRect ? await this.capture.previewBlob(screenRect) : null;
923
+ if (preview) {
924
+ await this.api.putPreview(this.figureId, session.panelId, renderRevision,
925
+ preview.blob, { width: preview.width, height: preview.height });
926
+ // The strip shows this session's captures, and one of them may be
927
+ // this panel: its thumbnail is now a picture of something that has
928
+ // been edited since.
929
+ const shown = this.captures.find((capture) => capture.panelId === session.panelId);
930
+ if (shown) {
931
+ if (shown.url) URL.revokeObjectURL(shown.url);
932
+ shown.preview = preview;
933
+ shown.url = URL.createObjectURL(preview.blob);
934
+ }
935
+ }
936
+ const returnTo = session.returnTo;
937
+ this.endEdit();
938
+ // Back where the user came from, and only then: a navigation before the
939
+ // write would take them to a canvas showing the panel they just edited,
940
+ // unedited. The note goes with them, because the way back out of the
941
+ // canvas is now this viewer rather than the library.
942
+ if (returnTo === "canvas") {
943
+ this.rememberOrigin();
944
+ window.location.href = this.api.figureHref(this.figureId);
945
+ }
946
+ }
947
+
948
+ /** Put the viewer back where it was and leave the panel alone. */
949
+ async cancelEdit() {
950
+ const stash = this.editing?.stash;
951
+ const returnTo = this.editing?.returnTo;
952
+ this.endEdit();
953
+ if (returnTo === "canvas") {
954
+ // Nothing was changed, so there is nothing to restore for -- the
955
+ // page is about to go.
956
+ this.rememberOrigin();
957
+ window.location.href = this.api.figureHref(this.figureId);
958
+ return;
959
+ }
960
+ if (stash) await FigureScene.restore(this.ctx, stash);
961
+ }
962
+
963
+ endEdit() {
964
+ this.editing = null;
965
+ this.capture.setFraming(false);
966
+ this.capture.unpin(true);
967
+ this.capture.disarm();
968
+ this.render();
969
+ }
970
+
971
+ /**
972
+ * A request left by the figure page before it navigated here.
973
+ *
974
+ * Read once and cleared, so a reload of this page does not silently reopen
975
+ * an edit the user has already finished with.
976
+ */
977
+ takePendingEdit() {
978
+ try {
979
+ const raw = window.sessionStorage.getItem("plexora:figure-builder-pending");
980
+ if (!raw) return null;
981
+ window.sessionStorage.removeItem("plexora:figure-builder-pending");
982
+ return JSON.parse(raw);
983
+ } catch (error) {
984
+ return null;
985
+ }
986
+ }
987
+
988
+ /**
989
+ * Make sure this image is one of the figure's sources, and answer with it.
990
+ *
991
+ * Registered lazily -- on the first capture that reaches a figure, not when
992
+ * the tool opens -- because a figure should not acquire a reference to
993
+ * every project the user happened to look at while it was selected. Sources
994
+ * are what the provenance page lists and what "this source has changed" is
995
+ * checked against; a list padded with images no panel came from makes both
996
+ * of those harder to read for no gain.
997
+ */
998
+ async ensureSource() {
999
+ if (!this.state || !this.state.document) return null;
1000
+ const existing = this.state.sourceForDatasource(this.datasource);
1001
+ if (existing) return existing;
1002
+
1003
+ const described = await this.api.describeSource(this.datasource);
1004
+ if (!described.ok) return null;
1005
+
1006
+ const source = { ...described.data.source, source_id: FigureSchema.newSourceId() };
1007
+ // Physical pixel size is not in the project record -- it lives in the
1008
+ // OME metadata, behind a loader. Fetched here, for the datasource that
1009
+ // is on screen and therefore already loaded, so no other source's
1010
+ // status check can ever cause a load. Absent stays absent: a figure
1011
+ // with no calibration disables its scale bars rather than inventing
1012
+ // one.
1013
+ source.pixel_size = await this.readPixelSize();
1014
+
1015
+ const stored = await this.state.commit(
1016
+ [{ op: "add_source", source: source }],
1017
+ (draft) => { draft.sources[source.source_id] = source; });
1018
+ return stored ? source : null;
1019
+ }
1020
+
1021
+ async readPixelSize() {
1022
+ try {
1023
+ const response = await fetch(
1024
+ this.ctx.url("get_ome_metadata") + "?" + new URLSearchParams({ datasource: this.datasource }));
1025
+ if (!response.ok) return null;
1026
+ const metadata = await response.json();
1027
+ const pixels = metadata?.images?.[0]?.pixels || {};
1028
+ const value = Number(pixels.physical_size_x);
1029
+ if (!(value > 0)) return null;
1030
+ return {
1031
+ value: value,
1032
+ unit: pixels.physical_size_x_unit || "µm",
1033
+ source: "metadata",
1034
+ };
1035
+ } catch (error) {
1036
+ return null;
1037
+ }
1038
+ }
1039
+
1040
+ // -- rendering -------------------------------------------------------
1041
+
1042
+ setStatus(text) {
1043
+ this.statusText = text || "";
1044
+ // A banner that outlives the thing it was about is a banner people
1045
+ // learn to ignore. Anything that gets far enough to report progress has
1046
+ // superseded the last failure.
1047
+ if (text) this.failure = "";
1048
+ this.renderDock();
1049
+ }
1050
+
1051
+ fail(message) {
1052
+ this.failure = message;
1053
+ this.statusText = "";
1054
+ this.renderDock();
1055
+ }
1056
+
1057
+ renderStatus(payload) {
1058
+ this.setStatus({
1059
+ loading: "Opening…",
1060
+ saving: "Saving…",
1061
+ saved: "Saved",
1062
+ unsaved: "Unsaved",
1063
+ failed: payload.detail || "Save failed",
1064
+ conflict: "Changed elsewhere",
1065
+ unreadable: "Cannot be opened",
1066
+ }[payload.status] || "");
1067
+ }
1068
+
1069
+ render() {
1070
+ this.renderDock();
1071
+ this.renderBoxes();
1072
+ }
1073
+
1074
+ /** Everything the dock draws, in one place, from one read of the state. */
1075
+ renderDock() {
1076
+ const open = Boolean(this.figureId && this.state && this.state.document);
1077
+ this.dock.render({
1078
+ armed: this.capture.active,
1079
+ figureTitle: open ? (this.state.title || "Untitled figure") : null,
1080
+ meta: this.metaLine(open),
1081
+ error: this.failure,
1082
+ editing: this.editing ? this.editSession() : null,
1083
+ selected: this.selected,
1084
+ captures: this.captures.map((capture) => ({
1085
+ id: capture.id,
1086
+ url: capture.url,
1087
+ pending: !capture.panelId,
1088
+ caption: this.captionFor(capture),
1089
+ })),
1090
+ });
1091
+ }
1092
+
1093
+ /** The boxes on the image are the captures, in the same order and with the
1094
+ * same selection -- one list, drawn twice. */
1095
+ renderBoxes() {
1096
+ this.boxes.setBoxes(this.captures.map((capture) => ({
1097
+ id: capture.id,
1098
+ rect: capture.scene.viewport,
1099
+ })));
1100
+ this.boxes.setSelected(this.selected);
1101
+ }
1102
+
1103
+ metaLine(open) {
1104
+ const parts = [];
1105
+ if (open) {
1106
+ const document_ = this.state.document;
1107
+ parts.push(FigureSchema.countPhrase(Object.keys(document_.panels).length, "panel"));
1108
+ parts.push(FigureSchema.countPhrase(document_.pages.length, "page"));
1109
+ }
1110
+ if (this.statusText) parts.push(this.statusText);
1111
+ return parts.join(" · ");
1112
+ }
1113
+
1114
+ /** How wide the captured field is, in the units the source can support. */
1115
+ captionFor(capture) {
1116
+ const source = this.state && this.state.document
1117
+ ? this.state.sourceForDatasource(this.datasource)
1118
+ : null;
1119
+ const span = FigureSchema.physicalWidthUm(source, capture.scene.viewport);
1120
+ return span
1121
+ ? FigureSchema.formatMicrons(span) + " wide"
1122
+ : Math.round(capture.scene.viewport.w) + " px wide";
1123
+ }
1124
+
1125
+ /**
1126
+ * What the dock says while a panel's view is on loan to the live viewer.
1127
+ *
1128
+ * The capture half of the dock is hidden for as long as it runs: capturing
1129
+ * a new view into a figure while the viewer is showing a borrowed state
1130
+ * would produce a panel of somebody else's scene, and the user would have
1131
+ * no way to tell.
1132
+ */
1133
+ editSession() {
1134
+ const panel = this.state?.panel(this.editing.panelId);
1135
+ const notes = [];
1136
+ const report = this.editing.report;
1137
+ if (report) {
1138
+ if (report.missing_channels.length) {
1139
+ notes.push("Not in this image any more: " + report.missing_channels.join(", ")
1140
+ + ". Nothing was substituted.");
1141
+ }
1142
+ const skipped = Object.keys(report.plugins)
1143
+ .filter((name) => report.plugins[name] !== "ok");
1144
+ if (skipped.length) {
1145
+ notes.push("Open " + skipped.join(", ")
1146
+ + " to restore that overlay; the panel keeps what was captured either way.");
1147
+ }
1148
+ }
1149
+ return { label: (panel && panel.title) || "this panel", notes: notes };
1150
+ }
1151
+ }
1152
+
1153
+ window.Plexora.registerPlugin({
1154
+ name: "figure_builder",
1155
+ createSidebarController: (ctx) => new FigureBuilderSidebarController(ctx),
1156
+ // Figure Builder captures whatever another plugin drew; claiming the cell
1157
+ // layer would evict the plugin whose colours are the thing being captured.
1158
+ ownsCellLayer: false,
1159
+ });