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,993 @@
1
+ /**
2
+ * roiTools.js - what the pointer and the keyboard do.
3
+ *
4
+ * The whole file turns on one variable, `this.state`, holding one of:
5
+ *
6
+ * idle.select drawing.polygon editing.vertex
7
+ * panning.temporary drawing.freehand editing.move
8
+ * drawing.rectangle
9
+ *
10
+ * Written as a state machine rather than the obvious set of booleans
11
+ * (isDrawing, isDragging, isMoving...) because those combinations multiply and
12
+ * most of them are meaningless. "Dragging while drawing while panning" has no
13
+ * behaviour anyone intended, but a handler reading three flags has to have one.
14
+ *
15
+ * ## Sharing the pointer with the viewer
16
+ *
17
+ * OpenSeadragon wants the same events this does. The precedence is explicit
18
+ * rather than left to whichever handler happens to run first:
19
+ *
20
+ * wheel always zooms. Never intercepted, at any time -- a
21
+ * drawing tool that breaks zoom on a 100,000px slide
22
+ * is a drawing tool nobody can use.
23
+ * Space + drag always pans, whatever tool is selected.
24
+ * drag on empty image pans (Select tool) -- so navigation still works
25
+ * without switching tools.
26
+ * drag on a shape moves it.
27
+ * drag on a handle moves that vertex.
28
+ * drag with a draw tool draws.
29
+ *
30
+ * Suppression is per event (`event.preventDefaultAction = true`) rather than
31
+ * `viewer.setMouseNavEnabled(false)`, because the decision is per gesture: the
32
+ * same drag pans or draws depending on what is under it and what is held, and a
33
+ * viewer-wide switch cannot express that without being toggled from six places
34
+ * and eventually being left in the wrong position.
35
+ *
36
+ * The gesture is decided at press and does not change mid-drag. Pressing Space
37
+ * halfway through drawing a stroke does not turn that stroke into a pan.
38
+ */
39
+ class RoiInteraction {
40
+
41
+ constructor(ctx, store, renderer) {
42
+ this.ctx = ctx;
43
+ this.store = store;
44
+ this.renderer = renderer;
45
+ this.viewer = ctx.viewer?.viewer || null;
46
+
47
+ this.tool = "select";
48
+ this.state = "idle.select";
49
+ this.armed = false;
50
+
51
+ this.draftPoints = [];
52
+ this.drag = null; // {id, origin|vertex, before} once a drag is committed
53
+ //: What a press landed on, before it is known whether this gesture is a
54
+ //: click or a drag. Resolved by whichever of the two arrives next.
55
+ this.pending = null;
56
+ this.stateBeforeSpace = null;
57
+ this.spaceHeld = false;
58
+
59
+ //: What the pointer is over, plus the frame handle throttling the moves
60
+ //: that decide it. Kept outside the state machine on purpose: hovering
61
+ //: never edits anything, so giving it states of its own would multiply
62
+ //: the combinations above for no behaviour anyone wanted.
63
+ this.hoverId = null;
64
+ this._hoverGeometry = null;
65
+ this._hoverPosition = null;
66
+ this._hoverFrame = 0;
67
+ this._anchorFrame = 0;
68
+ this._hoverTracker = null;
69
+ this._unsubscribeStore = null;
70
+
71
+ this._handlers = [];
72
+ this._onKeyDown = (event) => this.keyDown(event);
73
+ this._onKeyUp = (event) => this.keyUp(event);
74
+ this._onBlur = () => this.releaseSpace();
75
+ }
76
+
77
+ //: How close, in SCREEN pixels, counts as "on" a vertex or an edge.
78
+ //: Converted to image pixels per gesture, because a fixed image-pixel
79
+ //: tolerance is unusably tight zoomed out and absurdly loose zoomed in.
80
+ static get GRAB_RADIUS() { return 9; }
81
+
82
+ //: Smallest shape worth keeping, in SCREEN pixels squared. A stray click
83
+ //: sequence makes a region a few pixels across that is invisible, unhittable
84
+ //: and permanent. Measured on screen rather than in image pixels because
85
+ //: there is no such thing as a biologically-too-small region -- only one too
86
+ //: small to have been drawn on purpose at this zoom.
87
+ static get MIN_AREA() { return 24; }
88
+
89
+ //: Freehand simplification tolerance, in screen pixels. Roughly "keep the
90
+ //: shape within a pixel and a half of what was drawn, at the zoom it was
91
+ //: drawn at".
92
+ static get SIMPLIFY_EPSILON() { return 1.5; }
93
+
94
+ // -- lifecycle -------------------------------------------------------
95
+
96
+ /** Start listening. Called when the panel is shown, not when the plugin
97
+ * loads: a hidden panel whose shortcuts still fire is a tool acting on a
98
+ * window the user is not looking at. */
99
+ arm() {
100
+ if (this.armed || !this.viewer) return;
101
+ this.armed = true;
102
+
103
+ const on = (name, fn) => {
104
+ this.viewer.addHandler(name, fn);
105
+ this._handlers.push([name, fn]);
106
+ };
107
+ on("canvas-press", (e) => this.press(e));
108
+ on("canvas-drag", (e) => this.dragging(e));
109
+ on("canvas-drag-end", (e) => this.dragEnd(e));
110
+ on("canvas-click", (e) => this.click(e));
111
+ on("canvas-double-click", (e) => this.doubleClick(e));
112
+
113
+ // Whether the tools are usable depends on the world having an image in
114
+ // it, and that is not a fact about the annotations -- so no store change
115
+ // ever announces it. A panel opened while the first tile is still on the
116
+ // way would otherwise render its toolbar disabled and stay that way
117
+ // until some unrelated edit happened to repaint it, which on a project
118
+ // that already has categories is never.
119
+ this._onWorldChange = () => {
120
+ this.applyCursor();
121
+ this.store.changed();
122
+ };
123
+ this.viewer.world?.addHandler("add-item", this._onWorldChange);
124
+ this.viewer.world?.addHandler("remove-item", this._onWorldChange);
125
+
126
+ // Hover is tracked apart from the canvas-* gestures above because OSD
127
+ // does not report a plain move through them, and because a hover is an
128
+ // observation rather than an edit -- it never touches the store.
129
+ this._hoverTracker = new OpenSeadragon.MouseTracker({
130
+ element: this.viewer.canvas,
131
+ moveHandler: (event) => this.pointerMove(event),
132
+ leaveHandler: () => this.clearHover(),
133
+ });
134
+
135
+ // A standing hover survives the picture moving under it, so the anchor
136
+ // that went out with it has to be re-sent -- see reanchorHover.
137
+ on("viewport-change", () => this.viewportMoved());
138
+
139
+ // A hovered ROI can be deleted, hidden or reshaped from the panel while
140
+ // the pointer sits perfectly still, and no pointer event will say so.
141
+ this._unsubscribeStore = this.store.onChange?.(() => this.revalidateHover());
142
+
143
+ document.addEventListener("keydown", this._onKeyDown);
144
+ document.addEventListener("keyup", this._onKeyUp);
145
+ window.addEventListener("blur", this._onBlur);
146
+
147
+ this.renderer.setEnabled(true);
148
+ this.applyCursor();
149
+ }
150
+
151
+ /** Stop listening and cancel anything half-drawn.
152
+ *
153
+ * Symmetrical with arm() and called on every switch away, because the
154
+ * listeners above are on the VIEWER and the DOCUMENT -- neither of which
155
+ * goes away when this plugin's panel is hidden. Left attached, ROI would go
156
+ * on drawing over another tool's session and swallowing its keystrokes. */
157
+ disarm() {
158
+ if (!this.armed) return;
159
+ this.armed = false;
160
+
161
+ this.cancelDraft();
162
+ for (const [name, fn] of this._handlers) this.viewer.removeHandler(name, fn);
163
+ this._handlers = [];
164
+
165
+ if (this._onWorldChange) {
166
+ this.viewer.world?.removeHandler("add-item", this._onWorldChange);
167
+ this.viewer.world?.removeHandler("remove-item", this._onWorldChange);
168
+ this._onWorldChange = null;
169
+ }
170
+
171
+ document.removeEventListener("keydown", this._onKeyDown);
172
+ document.removeEventListener("keyup", this._onKeyUp);
173
+ window.removeEventListener("blur", this._onBlur);
174
+
175
+ // clearHover() before the tracker goes, so anything listening for the
176
+ // leave hears it -- a panel that closes with a summary card still
177
+ // floating over the image has left litter nothing else will clean up.
178
+ this.clearHover();
179
+ this._hoverTracker?.destroy?.();
180
+ this._hoverTracker = null;
181
+ this._unsubscribeStore?.();
182
+ this._unsubscribeStore = null;
183
+
184
+ this.releaseSpace();
185
+ this.state = "idle.select";
186
+ this.renderer.setEnabled(false);
187
+ this.setCursor("");
188
+ }
189
+
190
+ destroy() {
191
+ this.disarm();
192
+ }
193
+
194
+ // -- coordinates -----------------------------------------------------
195
+
196
+ /**
197
+ * Screen position -> full-resolution image pixel.
198
+ *
199
+ * Through the tile source's own getImagePixel where it exists (viewerManager
200
+ * installs it on every channel), which already accounts for extraZoomLevels
201
+ * -- the pyramid's extra levels, currently 0, so the divide is by 1 today
202
+ * and correct if that ever changes. The longhand below is the same
203
+ * arithmetic for a source that predates it.
204
+ */
205
+ toImage(position) {
206
+ const item = this.viewer?.world?.getItemAt(0);
207
+ if (!item) return null;
208
+ if (item.source && typeof item.source.getImagePixel === "function") {
209
+ const [x, y] = item.source.getImagePixel(item, position);
210
+ return [x, y];
211
+ }
212
+ const viewportPoint = this.viewer.viewport.pointFromPixel(position);
213
+ const imagePoint = item.viewportToImageCoordinates(viewportPoint);
214
+ const scale = 2 ** (this.ctx.config?.extraZoomLevels || 0);
215
+ return [imagePoint.x / scale, imagePoint.y / scale];
216
+ }
217
+
218
+ /** Screen pixels -> image pixels at the current zoom, for tolerances. */
219
+ imageDistance(screenPixels) {
220
+ try {
221
+ const item = this.viewer.world.getItemAt(0);
222
+ const zoom = item.viewportToImageZoom(this.viewer.viewport.getZoom(true));
223
+ return zoom > 0 ? screenPixels / zoom : screenPixels;
224
+ } catch (error) {
225
+ return screenPixels;
226
+ }
227
+ }
228
+
229
+ /** Confine a drawn point to the image.
230
+ *
231
+ * Read off config every time rather than cached: `__plexora.refreshDataset`
232
+ * mutates that object in place when the project's read spec changes, and a
233
+ * cached copy would silently go stale. */
234
+ clamp(point) {
235
+ return RoiGeometry.clamp(point, this.ctx.config?.width, this.ctx.config?.height);
236
+ }
237
+
238
+ /** Can the pointer do anything on the image right now? */
239
+ get ready() {
240
+ if (!this.store.editable) return false;
241
+ // No active channel means no tiled image, hence no coordinate frame to
242
+ // draw in. Drawing is disabled rather than silently producing garbage.
243
+ return Boolean(this.viewer?.world?.getItemCount());
244
+ }
245
+
246
+ /**
247
+ * Can a NEW shape be made right now?
248
+ *
249
+ * Separate from `ready` because selecting, undoing and deleting must keep
250
+ * working with no categories -- undoing the deletion of the last one is
251
+ * exactly when a user needs them to. A project starts with none (the user
252
+ * names their own), so the draw tools wait for one instead of filing the
253
+ * shape under a label nobody chose.
254
+ */
255
+ get canDraw() {
256
+ return this.ready && Boolean(this.store.activeCategory);
257
+ }
258
+
259
+ // -- tools -----------------------------------------------------------
260
+
261
+ setTool(tool) {
262
+ if (this.tool === tool) return;
263
+ this.cancelDraft();
264
+ this.tool = tool;
265
+ this.state = tool === "select" ? "idle.select" : `drawing.${tool}`;
266
+ this.applyCursor();
267
+ this.store.changed();
268
+ if (tool !== "select" && !this.store.activeCategory) this.needCategory();
269
+ }
270
+
271
+ /** Say why nothing happens, once, rather than letting clicks vanish. */
272
+ needCategory() {
273
+ this.notify("Add a category first -- every region belongs to one.");
274
+ }
275
+
276
+ setCursor(cursor) {
277
+ if (this.viewer?.canvas) this.viewer.canvas.style.cursor = cursor;
278
+ }
279
+
280
+ applyCursor() {
281
+ if (!this.armed) return;
282
+ if (this.spaceHeld) return this.setCursor("grab");
283
+ if (!this.ready) return this.setCursor("");
284
+ // A crosshair over an image that cannot take a shape is a promise the
285
+ // pointer does not keep.
286
+ this.setCursor(this.tool !== "select" && this.canDraw ? "crosshair" : "default");
287
+ }
288
+
289
+ // -- pointer ---------------------------------------------------------
290
+
291
+ press(event) {
292
+ if (!this.ready) return;
293
+ if (this.spaceHeld) {
294
+ this.state = "panning.temporary";
295
+ return;
296
+ }
297
+
298
+ const point = this.toImage(event.position);
299
+ if (!point) return;
300
+
301
+ if (this.tool === "select") {
302
+ // Only RECORD what is under the pointer. Which gesture this is --
303
+ // a click that selects, or a drag that moves -- is not known yet,
304
+ // and committing to "move" here is what made a plain click leave
305
+ // the machine in editing.move: `canvas-click` is guarded on
306
+ // idle.select, so it never ran, and clicking a second shape or
307
+ // clicking empty space to deselect both silently did nothing.
308
+ // The gesture is decided on the first canvas-drag instead.
309
+ this.drag = null;
310
+ this.pending = { hit: this.hitTest(point), point };
311
+ this.state = "idle.select";
312
+ return;
313
+ }
314
+
315
+ if (!this.canDraw) {
316
+ // Back to a state a drag cannot act on. setTool put the machine in
317
+ // `drawing.<tool>` optimistically, and the rectangle case reads a
318
+ // drag origin this refused press never set.
319
+ this.state = "idle.select";
320
+ return this.needCategory();
321
+ }
322
+
323
+ if (this.tool === "freehand") {
324
+ this.state = "drawing.freehand";
325
+ this.draftPoints = [this.clamp(point)];
326
+ this.showDraft("freehand");
327
+ } else if (this.tool === "rectangle") {
328
+ this.state = "drawing.rectangle";
329
+ this.drag = { origin: this.clamp(point) };
330
+ this.draftPoints = [];
331
+ }
332
+ }
333
+
334
+ dragging(event) {
335
+ if (!this.armed) return;
336
+ if (this.spaceHeld || this.state === "panning.temporary") return; // viewer pans
337
+
338
+ const point = this.toImage(event.position);
339
+ if (!point) return;
340
+
341
+ // First movement of a press that landed on something: NOW it is a drag,
342
+ // so commit to which kind. A press that never moves stays idle.select
343
+ // and is handled as a click.
344
+ if (this.state === "idle.select" && this.pending) {
345
+ const { hit, point: origin } = this.pending;
346
+ this.pending = null;
347
+ if (hit && hit.vertex) {
348
+ this.state = "editing.vertex";
349
+ this.drag = { id: hit.feature.id, vertex: hit.vertex, before: hit.feature.geometry };
350
+ } else if (hit) {
351
+ this.state = "editing.move";
352
+ this.drag = { id: hit.feature.id, origin, before: hit.feature.geometry };
353
+ }
354
+ // Nothing under the pointer: left alone, so the viewer pans and
355
+ // navigation keeps working without leaving the Select tool.
356
+ }
357
+
358
+ // Three of the cases below read a gesture the press set up, and the
359
+ // gesture can be cancelled while the button is still down: Esc, a tool
360
+ // shortcut and a re-arm all call cancelDraft, which nulls `drag` but
361
+ // leaves `drawing.rectangle`/`editing.*` in place -- and the mouse goes
362
+ // on sending canvas-drag the whole time. Without this the next movement
363
+ // is a null dereference in a pointer handler.
364
+ const needsGesture = this.state === "editing.vertex"
365
+ || this.state === "editing.move"
366
+ || this.state === "drawing.rectangle";
367
+ if (needsGesture && !this.drag) return;
368
+
369
+ switch (this.state) {
370
+ case "editing.vertex": {
371
+ event.preventDefaultAction = true;
372
+ const feature = this.store.feature(this.drag.id);
373
+ if (!feature) return;
374
+ const rings = feature.geometry.coordinates.map((ring) => ring.slice());
375
+ const points = RoiGeometry.openRing(rings[this.drag.vertex.ring]);
376
+ points[this.drag.vertex.index] = this.clamp(point);
377
+ rings[this.drag.vertex.ring] = RoiGeometry.closeRing(points);
378
+ // Local only: the server hears about this once, at drag end.
379
+ feature.geometry = { type: feature.geometry.type, coordinates: rings };
380
+ this.renderer.invalidate(feature.id);
381
+ this.renderer.schedule();
382
+ return;
383
+ }
384
+ case "editing.move": {
385
+ event.preventDefaultAction = true;
386
+ const feature = this.store.feature(this.drag.id);
387
+ if (!feature) return;
388
+ feature.geometry = RoiGeometry.translate(
389
+ this.drag.before, point[0] - this.drag.origin[0], point[1] - this.drag.origin[1]);
390
+ this.renderer.invalidate(feature.id);
391
+ this.renderer.schedule();
392
+ return;
393
+ }
394
+ case "drawing.freehand": {
395
+ event.preventDefaultAction = true;
396
+ this.draftPoints.push(this.clamp(point));
397
+ this.showDraft("freehand");
398
+ return;
399
+ }
400
+ case "drawing.rectangle": {
401
+ event.preventDefaultAction = true;
402
+ const [x0, y0] = this.drag.origin;
403
+ const [x1, y1] = this.clamp(point);
404
+ this.draftPoints = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
405
+ this.showDraft("rectangle");
406
+ return;
407
+ }
408
+ case "drawing.polygon":
409
+ // A polygon is built from clicks; a drag while one is in
410
+ // progress is the user repositioning the view.
411
+ return;
412
+ default:
413
+ return;
414
+ }
415
+ }
416
+
417
+ dragEnd(event) {
418
+ if (!this.armed) return;
419
+
420
+ switch (this.state) {
421
+ case "editing.vertex":
422
+ case "editing.move": {
423
+ event.preventDefaultAction = true; // no flick-momentum pan
424
+ const feature = this.store.feature(this.drag?.id);
425
+ const before = this.drag?.before;
426
+ this.drag = null;
427
+ this.state = "idle.select";
428
+ if (!feature || !before) return;
429
+ // One operation, and so one undo step, for a drag of any length.
430
+ this.commitGeometry(feature, before, feature.geometry);
431
+ return;
432
+ }
433
+ case "drawing.freehand": {
434
+ event.preventDefaultAction = true;
435
+ this.finishFreehand();
436
+ return;
437
+ }
438
+ case "drawing.rectangle": {
439
+ event.preventDefaultAction = true;
440
+ const points = this.draftPoints.slice();
441
+ this.drag = null;
442
+ this.hideDraft();
443
+ this.state = "drawing.rectangle";
444
+ this.createFrom(points);
445
+ return;
446
+ }
447
+ default:
448
+ this.state = this.tool === "select" ? "idle.select" : `drawing.${this.tool}`;
449
+ }
450
+ }
451
+
452
+ click(event) {
453
+ if (!this.ready || this.spaceHeld) return;
454
+ const point = this.toImage(event.position);
455
+ if (!point) return;
456
+
457
+ if (this.tool === "polygon") {
458
+ if (!this.canDraw) return this.needCategory();
459
+ event.preventDefaultAction = true;
460
+ this.state = "drawing.polygon";
461
+ this.draftPoints.push(this.clamp(point));
462
+ this.showDraft("polygon");
463
+ return;
464
+ }
465
+
466
+ if (this.tool === "select" && this.state === "idle.select") {
467
+ // Suppressed so a click to select does not also zoom the viewer.
468
+ // Double-click and the wheel still zoom, which is where the user
469
+ // reaches for zoom anyway.
470
+ event.preventDefaultAction = true;
471
+ // Reuse what the press already found -- same point, same answer --
472
+ // rather than hit-testing the whole image a second time.
473
+ const hit = this.pending ? this.pending.hit : this.hitTest(point);
474
+ this.pending = null;
475
+ this.store.select(hit ? hit.feature.id : null);
476
+ this.renderer.schedule();
477
+ }
478
+ }
479
+
480
+ doubleClick(event) {
481
+ if (this.tool === "polygon" && this.draftPoints.length) {
482
+ event.preventDefaultAction = true;
483
+ this.finishPolygon();
484
+ }
485
+ }
486
+
487
+ // -- hit testing -----------------------------------------------------
488
+
489
+ /** What is under this image-space point: a vertex of the selection first,
490
+ * then a shape. Hidden categories are excluded because they are excluded
491
+ * from `visibleFeatures` -- there is one list, so an invisible shape can
492
+ * never be clicked by accident. */
493
+ hitTest(point) {
494
+ const tolerance = this.imageDistance(RoiInteraction.GRAB_RADIUS);
495
+ const selected = this.store.selected;
496
+
497
+ if (selected && this.store.isVisible(selected) && !this.store.isLocked(selected)
498
+ && RoiGeometry.isVertexEditable(selected.geometry)) {
499
+ const vertex = RoiGeometry.nearestVertex(
500
+ selected.geometry, point[0], point[1], tolerance);
501
+ if (vertex) return { feature: selected, vertex };
502
+ }
503
+
504
+ const candidates = this.store.visibleFeatures();
505
+ // Back to front, so the shape drawn on top is the one picked -- which is
506
+ // what the user sees and therefore what they mean.
507
+ for (let i = candidates.length - 1; i >= 0; i--) {
508
+ const feature = candidates[i];
509
+ if (RoiGeometry.containsPoint(feature.geometry, point[0], point[1])
510
+ || RoiGeometry.distanceToBoundary(feature.geometry, point[0], point[1]) <= tolerance) {
511
+ return { feature, vertex: null };
512
+ }
513
+ }
514
+ return null;
515
+ }
516
+
517
+ // -- hover -------------------------------------------------------------
518
+
519
+ /**
520
+ * Which ROI the pointer is over, published for whoever wants to say
521
+ * something about it.
522
+ *
523
+ * This plugin deliberately does not know what a composition summary is. It
524
+ * owns geometry, so it answers "which region, and where is it on screen";
525
+ * Cell Explorer's bridge answers "what is inside it". The two CustomEvents
526
+ * below are the entire seam between them -- which is why the payload
527
+ * carries geometry and an anchor rectangle rather than a live feature
528
+ * reference, so neither side ends up reaching into the other's state.
529
+ *
530
+ * Tracked with a MouseTracker rather than another viewer handler because
531
+ * OSD's canvas-* events have no plain move: canvas-drag is a gesture.
532
+ */
533
+ pointerMove(event) {
534
+ if (!this.armed) return;
535
+ // Mid-gesture there is no such thing as hovering. A committed drag, a
536
+ // half-placed draft or a Space-pan all mean the pointer is busy, and
537
+ // lighting up whatever it crosses would be noise laid over the gesture.
538
+ if (this.drag || this.spaceHeld || this.draftPoints.length) {
539
+ this.clearHover();
540
+ return;
541
+ }
542
+ if (!event || !event.position) return;
543
+ this._hoverPosition = event.position;
544
+ // One hit test per frame at most: pointer moves arrive far faster than
545
+ // anything downstream can redraw, and the answer cannot change more
546
+ // often than the picture does.
547
+ if (this._hoverFrame) return;
548
+ this._hoverFrame = requestAnimationFrame(() => {
549
+ this._hoverFrame = 0;
550
+ this.resolveHover();
551
+ });
552
+ }
553
+
554
+ resolveHover() {
555
+ if (!this.armed || !this._hoverPosition) return;
556
+ const point = this.toImage(this._hoverPosition);
557
+ if (!point) return;
558
+ const hit = this.hitTest(point);
559
+ this.setHover(hit ? hit.feature : null);
560
+ }
561
+
562
+ /** Enter and leave, deduplicated. Moving around inside one ROI is not a
563
+ * succession of hovers, and re-announcing it every frame is exactly the
564
+ * cursor-chasing an anchored card is meant to avoid. */
565
+ setHover(feature) {
566
+ const id = feature ? feature.id : null;
567
+ if (id === this.hoverId) return;
568
+ if (this.hoverId) this.dispatchUnhover(this.hoverId);
569
+ this.hoverId = id;
570
+ // The renderer already knows how to emphasise a hovered shape: a second
571
+ // stroke over the top, which leaves the saved colour and width alone.
572
+ this.renderer.hoverId = id;
573
+ this.renderer.schedule();
574
+ if (feature) this.dispatchHover(feature);
575
+ }
576
+
577
+ clearHover() {
578
+ if (this._hoverFrame) {
579
+ cancelAnimationFrame(this._hoverFrame);
580
+ this._hoverFrame = 0;
581
+ }
582
+ if (this._anchorFrame) {
583
+ cancelAnimationFrame(this._anchorFrame);
584
+ this._anchorFrame = 0;
585
+ }
586
+ this._hoverPosition = null;
587
+ this.setHover(null);
588
+ }
589
+
590
+ /**
591
+ * The picture moved while the pointer was inside an ROI.
592
+ *
593
+ * Anchors go out in client pixels, so a pan or a zoom leaves whoever is
594
+ * showing something beside a region pointing at empty image. The obvious
595
+ * answer -- have them close on the first viewport change -- is the wrong
596
+ * one: the pointer has not moved, so no further enter is ever dispatched,
597
+ * and the region has to be left and re-entered before anything can be seen
598
+ * again. That reads as a hover the tool simply missed. Re-announcing the
599
+ * hover with a fresh anchor keeps the two together instead.
600
+ *
601
+ * One dispatch per frame at most, because OSD reports a viewport change on
602
+ * every frame of a spring and a hover cannot change more often than the
603
+ * picture does.
604
+ */
605
+ viewportMoved() {
606
+ if (!this.hoverId || this._anchorFrame) return;
607
+ this._anchorFrame = requestAnimationFrame(() => {
608
+ this._anchorFrame = 0;
609
+ this.reanchorHover();
610
+ });
611
+ }
612
+
613
+ reanchorHover() {
614
+ if (!this.armed || !this.hoverId) return;
615
+ const before = this.hoverId;
616
+ // Zooming can slide a shape out from under a pointer that never moved,
617
+ // so what is under it is asked again rather than assumed. setHover
618
+ // announces the change itself, anchor included, when there is one.
619
+ if (this._hoverPosition) {
620
+ const point = this.toImage(this._hoverPosition);
621
+ if (point) this.setHover(this.hitTest(point)?.feature || null);
622
+ }
623
+ if (!this.hoverId || this.hoverId !== before) return;
624
+ const feature = this.store.feature(this.hoverId);
625
+ if (feature) this.dispatchHover(feature);
626
+ }
627
+
628
+ /**
629
+ * The store changed under a stationary pointer.
630
+ *
631
+ * Deleting, hiding or reshaping an ROI produces no pointer event, so
632
+ * without this a summary card would go on describing a region that has left
633
+ * the screen, or describe the old outline of one that just moved. Geometry
634
+ * objects are replaced rather than mutated on every edit, so object
635
+ * identity is all it takes to tell a reshape from a rename.
636
+ */
637
+ revalidateHover() {
638
+ if (!this.hoverId) return;
639
+ const feature = this.store.feature(this.hoverId);
640
+ if (!feature || !this.store.isVisible(feature)) {
641
+ const gone = this.hoverId;
642
+ this.hoverId = null;
643
+ this.renderer.hoverId = null;
644
+ this.renderer.schedule();
645
+ this.dispatchUnhover(gone);
646
+ return;
647
+ }
648
+ if (feature.geometry !== this._hoverGeometry) this.dispatchHover(feature);
649
+ }
650
+
651
+ dispatchHover(feature) {
652
+ this._hoverGeometry = feature.geometry;
653
+ this.emit("plexora:roi-hover", {
654
+ id: feature.id,
655
+ name: feature.name || "",
656
+ categoryId: feature.category_id === undefined ? null : feature.category_id,
657
+ geometry: feature.geometry,
658
+ anchorRect: this.screenRect(feature.geometry),
659
+ viewportRect: this.canvasRect(),
660
+ });
661
+ }
662
+
663
+ dispatchUnhover(id) {
664
+ this._hoverGeometry = null;
665
+ this.emit("plexora:roi-unhover", { id });
666
+ }
667
+
668
+ emit(name, detail) {
669
+ try {
670
+ window.dispatchEvent(new CustomEvent(name, { detail }));
671
+ } catch (error) {
672
+ console.error(`roiTools: could not dispatch ${name}`, error);
673
+ }
674
+ }
675
+
676
+ /** The image canvas in client coordinates, so a listener can keep whatever
677
+ * it puts on screen inside the picture rather than inside the window. */
678
+ canvasRect() {
679
+ const box = this.viewer?.canvas?.getBoundingClientRect?.();
680
+ if (!box) return null;
681
+ return { left: box.left, top: box.top, right: box.right, bottom: box.bottom };
682
+ }
683
+
684
+ /**
685
+ * An ROI's bounding box in CLIENT pixels.
686
+ *
687
+ * Client rather than container coordinates because a listener has no
688
+ * business knowing which element the viewer happens to be mounted in, and
689
+ * because whatever it anchors there can then be positioned outside the
690
+ * viewer's subtree, where no ancestor's overflow can clip it.
691
+ *
692
+ * Taken once, when the ROI is entered -- see setHover.
693
+ */
694
+ screenRect(geometry) {
695
+ const item = this.viewer?.world?.getItemAt(0);
696
+ const box = geometry ? RoiGeometry.bounds(geometry) : null;
697
+ const canvas = this.canvasRect();
698
+ if (!item || !box || !canvas) return null;
699
+ const scale = 2 ** (this.ctx.config?.extraZoomLevels || 0);
700
+ const rect = item.imageToViewportRectangle(
701
+ box.minX * scale, box.minY * scale,
702
+ (box.maxX - box.minX) * scale, (box.maxY - box.minY) * scale);
703
+ const topLeft = this.viewer.viewport.pixelFromPoint(
704
+ new OpenSeadragon.Point(rect.x, rect.y), true);
705
+ const bottomRight = this.viewer.viewport.pixelFromPoint(
706
+ new OpenSeadragon.Point(rect.x + rect.width, rect.y + rect.height), true);
707
+ return {
708
+ left: canvas.left + topLeft.x,
709
+ top: canvas.top + topLeft.y,
710
+ right: canvas.left + bottomRight.x,
711
+ bottom: canvas.top + bottomRight.y,
712
+ };
713
+ }
714
+
715
+
716
+ // -- draft -----------------------------------------------------------
717
+
718
+ showDraft(tool) {
719
+ this.renderer.draft = { points: this.draftPoints, tool };
720
+ this.renderer.schedule();
721
+ }
722
+
723
+ hideDraft() {
724
+ this.renderer.draft = null;
725
+ this.renderer.schedule();
726
+ }
727
+
728
+ /** Throw away whatever is half-drawn.
729
+ *
730
+ * An incomplete shape is never stored. Switching tool, pressing Escape,
731
+ * closing the panel or opening another one all land here, and all of them
732
+ * discard rather than "helpfully" completing a polygon the user was still
733
+ * placing points on. */
734
+ cancelDraft() {
735
+ const had = this.draftPoints.length > 0;
736
+ this.draftPoints = [];
737
+ this.drag = null;
738
+ this.pending = null;
739
+ this.hideDraft();
740
+ if (this.state.startsWith("drawing.")) {
741
+ this.state = this.tool === "select" ? "idle.select" : `drawing.${this.tool}`;
742
+ }
743
+ return had;
744
+ }
745
+
746
+ finishPolygon() {
747
+ const points = this.draftPoints.slice();
748
+ this.draftPoints = [];
749
+ this.hideDraft();
750
+ this.createFrom(points);
751
+ }
752
+
753
+ finishFreehand() {
754
+ const raw = this.draftPoints.slice();
755
+ this.draftPoints = [];
756
+ this.hideDraft();
757
+ this.state = "drawing.freehand";
758
+
759
+ // Simplified once, here, and never again. A stroke arrives as one point
760
+ // per pointer event -- thousands of them, mostly describing sub-pixel
761
+ // wobble along a straight line. Re-simplifying on later edits would
762
+ // erode the shape a little every time it was touched.
763
+ const epsilon = this.imageDistance(RoiInteraction.SIMPLIFY_EPSILON);
764
+ const deduped = RoiGeometry.dedupe(raw, epsilon / 2);
765
+ this.createFrom(RoiGeometry.simplify(deduped, epsilon));
766
+ }
767
+
768
+ // -- creating and committing ------------------------------------------
769
+
770
+ /** Turn a finished set of points into a stored region, or reject it. */
771
+ createFrom(rawPoints) {
772
+ const points = RoiGeometry.dedupe(rawPoints, 0);
773
+ if (RoiGeometry.distinctCount(points) < 3) {
774
+ // Two clicks and a double-click, or a stray press. Nothing was
775
+ // drawn, so nothing is created -- and nothing is said, because a
776
+ // dialog about a shape the user did not mean to draw is noise.
777
+ this.renderer.schedule();
778
+ return null;
779
+ }
780
+
781
+ const minArea = this.imageDistance(1) ** 2 * RoiInteraction.MIN_AREA;
782
+ if (RoiGeometry.area(points) < minArea) {
783
+ this.notify("That shape was too small to keep.");
784
+ this.renderer.schedule();
785
+ return null;
786
+ }
787
+
788
+ const category = this.store.activeCategory;
789
+ if (!category) {
790
+ // Belt and braces: the draw tools refuse to start without one, so
791
+ // getting here means the category went away mid-draft.
792
+ this.cancelDraft();
793
+ this.needCategory();
794
+ return null;
795
+ }
796
+
797
+ const geometry = RoiGeometry.polygonFrom(points);
798
+ const categoryId = category.id;
799
+ const feature = {
800
+ id: RoiStore.newId("r"),
801
+ category_id: categoryId,
802
+ name: this.nextName(categoryId),
803
+ locked: false,
804
+ geometry,
805
+ flags: { self_intersecting: RoiGeometry.selfIntersects(geometry.coordinates[0]) },
806
+ source_roi_id: null,
807
+ };
808
+
809
+ const image = this.store.image;
810
+ this.store.commit({
811
+ label: "Draw ROI",
812
+ redo: [{ op: "roi.create", image, feature }],
813
+ undo: [{ op: "roi.delete", image, id: feature.id }],
814
+ });
815
+ this.store.select(feature.id);
816
+ this.renderer.invalidate(feature.id);
817
+ this.renderer.schedule();
818
+
819
+ if (feature.flags.self_intersecting) {
820
+ this.notify("That outline crosses itself. It has been kept exactly as drawn.");
821
+ }
822
+ return feature;
823
+ }
824
+
825
+ /** "Tumor 3" -- the category's name and the next free number in it. */
826
+ nextName(categoryId) {
827
+ const category = this.store.category(categoryId);
828
+ const base = category ? category.label : "ROI";
829
+ return `${base} ${this.store.countFor(categoryId) + 1}`;
830
+ }
831
+
832
+ commitGeometry(feature, before, after) {
833
+ if (JSON.stringify(before) === JSON.stringify(after)) return;
834
+ const image = this.store.image;
835
+ const flags = {
836
+ self_intersecting: RoiGeometry.isVertexEditable(after)
837
+ ? RoiGeometry.selfIntersects(after.coordinates[0])
838
+ : Boolean(feature.flags && feature.flags.self_intersecting),
839
+ };
840
+ // Applied locally already -- the shape followed the cursor. Put it back
841
+ // first so commit()'s own applyLocal is not a no-op that leaves the undo
842
+ // entry describing a change that never appeared to happen.
843
+ feature.geometry = before;
844
+ this.store.commit({
845
+ label: "Edit ROI",
846
+ redo: [{ op: "roi.update_geometry", image, id: feature.id, geometry: after, flags }],
847
+ undo: [{
848
+ op: "roi.update_geometry", image, id: feature.id, geometry: before,
849
+ flags: feature.flags || { self_intersecting: false },
850
+ }],
851
+ });
852
+ this.renderer.invalidate(feature.id);
853
+ this.renderer.schedule();
854
+ }
855
+
856
+ deleteSelected() {
857
+ const feature = this.store.selected;
858
+ if (!feature) return false;
859
+ if (this.store.isLocked(feature)) {
860
+ this.notify("That ROI is locked.");
861
+ return false;
862
+ }
863
+ const image = this.store.image;
864
+ // No confirmation: deleting one region is a thing users do constantly,
865
+ // and a dialog every time makes annotating miserable. Undo is the safety
866
+ // net, and it is a better one -- it restores the shape rather than
867
+ // asking about it in advance.
868
+ this.store.commit({
869
+ label: "Delete ROI",
870
+ redo: [{ op: "roi.delete", image, id: feature.id }],
871
+ undo: [{ op: "roi.create", image, feature: JSON.parse(JSON.stringify(feature)) }],
872
+ });
873
+ this.renderer.invalidate(feature.id);
874
+ this.renderer.schedule();
875
+ return true;
876
+ }
877
+
878
+ // -- keyboard --------------------------------------------------------
879
+
880
+ /**
881
+ * Whether a keystroke belongs to this tool.
882
+ *
883
+ * The two ways it does not: the user is typing (a category named "Var" must
884
+ * not switch to Rectangle on the R), or this tool's panel is not the one on
885
+ * screen. Neither guard existed anywhere in Plexora before -- ROI is the
886
+ * first thing here to want document-level shortcuts -- so this is the
887
+ * convention rather than a copy of one.
888
+ */
889
+ acceptsKeys() {
890
+ if (!this.armed || !this.ready) return false;
891
+ const active = document.activeElement;
892
+ if (active) {
893
+ const tag = active.tagName;
894
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return false;
895
+ if (active.isContentEditable) return false;
896
+ }
897
+ const loader = window.PlexoraToolLoader;
898
+ if (loader && typeof loader.activeTool === "function" && loader.activeTool() !== "roi") {
899
+ return false;
900
+ }
901
+ return true;
902
+ }
903
+
904
+ keyDown(event) {
905
+ if (!this.acceptsKeys()) return;
906
+
907
+ const meta = event.ctrlKey || event.metaKey;
908
+ if (meta && event.key.toLowerCase() === "z") {
909
+ event.preventDefault();
910
+ if (event.shiftKey) this.store.redo();
911
+ else this.store.undo();
912
+ this.renderer.invalidate();
913
+ this.renderer.schedule();
914
+ return;
915
+ }
916
+ if (meta && event.key.toLowerCase() === "y") {
917
+ event.preventDefault();
918
+ this.store.redo();
919
+ this.renderer.invalidate();
920
+ this.renderer.schedule();
921
+ return;
922
+ }
923
+ if (meta) return; // leave every other accelerator to the browser
924
+
925
+ switch (event.key) {
926
+ case " ":
927
+ if (!this.spaceHeld) {
928
+ this.spaceHeld = true;
929
+ this.stateBeforeSpace = this.state;
930
+ this.applyCursor();
931
+ }
932
+ event.preventDefault(); // Space scrolls the page otherwise
933
+ return;
934
+ case "Escape":
935
+ // Draft first, then selection: Escape means "back out of the
936
+ // most immediate thing", and a half-drawn polygon is more
937
+ // immediate than a selection made a minute ago.
938
+ if (!this.cancelDraft()) this.store.select(null);
939
+ this.renderer.schedule();
940
+ return;
941
+ case "Enter":
942
+ if (this.tool === "polygon" && this.draftPoints.length) {
943
+ event.preventDefault();
944
+ this.finishPolygon();
945
+ }
946
+ return;
947
+ case "Backspace":
948
+ if (this.state === "drawing.polygon" && this.draftPoints.length) {
949
+ event.preventDefault();
950
+ this.draftPoints.pop();
951
+ this.showDraft("polygon");
952
+ return;
953
+ }
954
+ event.preventDefault();
955
+ this.deleteSelected();
956
+ return;
957
+ case "Delete":
958
+ event.preventDefault();
959
+ this.deleteSelected();
960
+ return;
961
+ default:
962
+ break;
963
+ }
964
+
965
+ const shortcut = { v: "select", p: "polygon", f: "freehand", r: "rectangle" };
966
+ const tool = shortcut[event.key.toLowerCase()];
967
+ if (tool) {
968
+ event.preventDefault();
969
+ this.setTool(tool);
970
+ }
971
+ }
972
+
973
+ keyUp(event) {
974
+ if (event.key === " ") this.releaseSpace();
975
+ }
976
+
977
+ /** Also called on window blur: alt-tabbing away while Space is down means
978
+ * the keyup lands in another window, and without this the viewer would be
979
+ * stuck in pan mode with no way to tell why. */
980
+ releaseSpace() {
981
+ if (!this.spaceHeld) return;
982
+ this.spaceHeld = false;
983
+ if (this.state === "panning.temporary") {
984
+ this.state = this.stateBeforeSpace || "idle.select";
985
+ }
986
+ this.stateBeforeSpace = null;
987
+ this.applyCursor();
988
+ }
989
+
990
+ notify(message) {
991
+ if (this.onNotify) this.onNotify(message);
992
+ }
993
+ }