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,518 @@
1
+ /**
2
+ * roiState.js - the annotations the user is editing, and getting them saved.
3
+ *
4
+ * Edits are applied HERE FIRST and sent afterwards. A vertex has to follow the
5
+ * cursor at 60fps; it cannot wait for a round trip, and a design where it does
6
+ * is one where the drawing feels broken whenever the disk is slow. So the
7
+ * client holds the authoritative working copy, the server holds the durable
8
+ * one, and a revision number keeps them honest about which is which.
9
+ *
10
+ * Three consequences that shape everything below:
11
+ *
12
+ * **Every edit is an operation, applied twice.** Once locally by `applyLocal`,
13
+ * once on the server by the same-named handler in operations.py. The two have
14
+ * to agree, which is why the local one is a small explicit interpreter rather
15
+ * than ad-hoc mutation scattered through the UI.
16
+ *
17
+ * **The queue is never dropped.** A failed save keeps its operations and keeps
18
+ * accepting more; the user goes on drawing while the server is unreachable and
19
+ * everything lands when it comes back. Losing an annotation because a laptop
20
+ * slept is not an acceptable outcome, and neither is blocking the pen on it.
21
+ *
22
+ * **A conflict is a question, not an error.** Two tabs on one project both hold
23
+ * a full copy and both autosave. Whoever saves second is told, and gets to
24
+ * choose between the other tab's version and their own -- rather than silently
25
+ * reinstating a stale world over regions somebody just drew.
26
+ */
27
+ class RoiStore {
28
+
29
+ constructor(ctx, api) {
30
+ this.ctx = ctx;
31
+ this.api = api;
32
+
33
+ this.schemaVersion = 1;
34
+ this.revision = 0;
35
+ this.image = "default";
36
+ this.categories = [];
37
+ this.features = [];
38
+ this.coordinateSpace = null;
39
+ this.imageSize = [null, null];
40
+ this.dimensionMismatch = false;
41
+ this.storedImageSize = [null, null];
42
+
43
+ this.selectionId = null;
44
+ // Null until the project has a category. There is no built-in one to
45
+ // point at, so "nothing to draw into yet" is a state the tools handle
46
+ // rather than a name invented here.
47
+ this.activeCategoryId = null;
48
+
49
+ //: 'saved' | 'saving' | 'dirty' | 'failed' | 'conflict' | 'blocked'
50
+ this.status = "saved";
51
+ this.statusDetail = "";
52
+
53
+ this.queue = [];
54
+ this.undoStack = [];
55
+ this.redoStack = [];
56
+
57
+ this._flushTimer = null;
58
+ this._retryTimer = null;
59
+ this._retryDelay = 0;
60
+ this._flushing = false;
61
+ this._listeners = new Set();
62
+ this._suspended = false;
63
+ }
64
+
65
+ //: How long after the last committed edit the batch goes out. Long enough
66
+ //: that dragging four vertices in a row is one request, short enough that
67
+ //: "Saved" appears while the user is still looking at the shape.
68
+ static get SAVE_DELAY() { return 400; }
69
+
70
+ //: Backoff for a save that could not be delivered at all (server down, laptop
71
+ //: asleep, wifi gone). Capped so a session left open overnight reconnects
72
+ //: within half a minute of the server coming back.
73
+ static get RETRY_MIN() { return 2000; }
74
+ static get RETRY_MAX() { return 30000; }
75
+
76
+ // -- listeners -------------------------------------------------------
77
+
78
+ onChange(fn) {
79
+ this._listeners.add(fn);
80
+ return () => this._listeners.delete(fn);
81
+ }
82
+
83
+ changed() {
84
+ for (const fn of this._listeners) {
85
+ try {
86
+ fn(this);
87
+ } catch (error) {
88
+ console.error("ROI: a change listener failed", error);
89
+ }
90
+ }
91
+ }
92
+
93
+ // -- loading ---------------------------------------------------------
94
+
95
+ /**
96
+ * Take the server's state as the starting point.
97
+ *
98
+ * `_suspended` is up throughout: seeding uses the same setters live editing
99
+ * does, and without it "load from the server" would immediately schedule
100
+ * "save back to the server" -- the restore-then-write-back loop the core
101
+ * sidebar guards against with isRestoring().
102
+ */
103
+ async load() {
104
+ const result = await this.api.getState();
105
+ this._suspended = true;
106
+ try {
107
+ if (!result.ok || !result.data.success) {
108
+ this.setStatus("failed", result.data.error || "could not load annotations");
109
+ return false;
110
+ }
111
+ const data = result.data;
112
+ this.schemaVersion = data.schema_version;
113
+ this.revision = data.revision;
114
+ this.image = data.image || "default";
115
+ this.categories = data.categories || [];
116
+ this.features = data.features || [];
117
+ this.coordinateSpace = data.coordinate_space || null;
118
+ this.imageSize = data.image_size || [null, null];
119
+ this.storedImageSize = data.stored_image_size || [null, null];
120
+ this.dimensionMismatch = Boolean(data.dimension_mismatch);
121
+ this.queue = [];
122
+ this.undoStack = [];
123
+ this.redoStack = [];
124
+
125
+ if (!this.categories.some((c) => c.id === this.activeCategoryId)) {
126
+ this.activeCategoryId = (this.categories[0] || {}).id || null;
127
+ }
128
+ // Nothing is drawn and nothing may be edited while the stored
129
+ // annotations belong to a different image than the one on screen.
130
+ // They would render perfectly plausibly in the wrong places, which
131
+ // is the failure mode worth refusing outright.
132
+ this.setStatus(this.dimensionMismatch ? "blocked" : "saved", "");
133
+ return true;
134
+ } finally {
135
+ this._suspended = false;
136
+ this.changed();
137
+ }
138
+ }
139
+
140
+ get editable() {
141
+ return !this.dimensionMismatch && this.status !== "conflict";
142
+ }
143
+
144
+ // -- reading ---------------------------------------------------------
145
+
146
+ category(id) {
147
+ return this.categories.find((c) => c.id === id) || null;
148
+ }
149
+
150
+ feature(id) {
151
+ return this.features.find((f) => f.id === id) || null;
152
+ }
153
+
154
+ get selected() {
155
+ return this.selectionId ? this.feature(this.selectionId) : null;
156
+ }
157
+
158
+ get activeCategory() {
159
+ return this.category(this.activeCategoryId) || this.categories[0] || null;
160
+ }
161
+
162
+ /** Categories in the order the panel lists them and the renderer draws them. */
163
+ sortedCategories() {
164
+ return [...this.categories].sort((a, b) => (a.sort_order - b.sort_order)
165
+ || a.label.localeCompare(b.label));
166
+ }
167
+
168
+ countFor(categoryId) {
169
+ return this.features.filter((f) => f.category_id === categoryId).length;
170
+ }
171
+
172
+ /** Whether this shape's geometry may be changed. A lock on the category
173
+ * applies to everything in it; a lock on either blocks moving, reshaping
174
+ * and deleting, but never renaming or reclassifying. */
175
+ isLocked(feature) {
176
+ if (!feature) return true;
177
+ if (feature.locked) return true;
178
+ const category = this.category(feature.category_id);
179
+ return Boolean(category && category.locked);
180
+ }
181
+
182
+ isVisible(feature) {
183
+ const category = this.category(feature.category_id);
184
+ return !category || category.visible !== false;
185
+ }
186
+
187
+ /** What the renderer draws and what the pointer can hit: the same list, so
188
+ * a shape can never be invisible and still selectable. */
189
+ visibleFeatures() {
190
+ if (this.dimensionMismatch) return [];
191
+ return this.features.filter((f) => this.isVisible(f));
192
+ }
193
+
194
+ // -- editing ---------------------------------------------------------
195
+
196
+ select(id) {
197
+ if (this.selectionId === id) return;
198
+ this.selectionId = id;
199
+ this.changed();
200
+ }
201
+
202
+ setActiveCategory(id) {
203
+ if (!this.category(id) || this.activeCategoryId === id) return;
204
+ this.activeCategoryId = id;
205
+ this.changed();
206
+ }
207
+
208
+ /**
209
+ * Apply an edit locally, record how to undo it, and queue it for the server.
210
+ *
211
+ * `entry` is {label, redo:[ops], undo:[ops]}. The redo ops are what gets
212
+ * sent; the undo ops are what a Ctrl+Z sends INSTEAD -- undo is a new edit
213
+ * at a new revision, never a rewind of the store. Rewinding would mean the
214
+ * revision could go backwards, and the whole conflict check turns on it
215
+ * only ever going forwards.
216
+ */
217
+ commit(entry) {
218
+ if (!this.editable) return false;
219
+ for (const op of entry.redo) this.applyLocal(op);
220
+ this.undoStack.push(entry);
221
+ this.redoStack.length = 0;
222
+ this.enqueue(entry.redo);
223
+ this.changed();
224
+ return true;
225
+ }
226
+
227
+ undo() {
228
+ const entry = this.undoStack.pop();
229
+ if (!entry || !this.editable) return false;
230
+ for (const op of entry.undo) this.applyLocal(op);
231
+ this.redoStack.push(entry);
232
+ this.enqueue(entry.undo);
233
+ this.changed();
234
+ return true;
235
+ }
236
+
237
+ redo() {
238
+ const entry = this.redoStack.pop();
239
+ if (!entry || !this.editable) return false;
240
+ for (const op of entry.redo) this.applyLocal(op);
241
+ this.undoStack.push(entry);
242
+ this.enqueue(entry.redo);
243
+ this.changed();
244
+ return true;
245
+ }
246
+
247
+ /**
248
+ * The client's half of operations.py.
249
+ *
250
+ * Kept deliberately small and literal so the two can be read side by side.
251
+ * The server re-validates everything; this exists so the screen updates now
252
+ * rather than in a round trip's time.
253
+ */
254
+ applyLocal(op) {
255
+ const image = op.image || this.image;
256
+ if (image !== this.image && op.op.startsWith("roi.")) return;
257
+
258
+ switch (op.op) {
259
+ case "category.create":
260
+ this.categories.push({ ...op.category });
261
+ break;
262
+ case "category.update": {
263
+ const category = this.category(op.id);
264
+ if (category) Object.assign(category, op.changes);
265
+ break;
266
+ }
267
+ case "category.delete": {
268
+ if (op.orphans === "delete") {
269
+ this.features = this.features.filter((f) => f.category_id !== op.id);
270
+ } else if (op.reassign_to) {
271
+ // No default target: the server refuses a reassign without
272
+ // one, so guessing here would only put the two copies out
273
+ // of step for as long as the round trip takes.
274
+ for (const feature of this.features) {
275
+ if (feature.category_id === op.id) feature.category_id = op.reassign_to;
276
+ }
277
+ }
278
+ this.categories = this.categories.filter((c) => c.id !== op.id);
279
+ if (this.activeCategoryId === op.id) {
280
+ this.activeCategoryId = (this.categories[0] || {}).id || null;
281
+ }
282
+ break;
283
+ }
284
+ case "roi.create":
285
+ this.features.push({ ...op.feature });
286
+ break;
287
+ case "roi.update_geometry": {
288
+ const feature = this.feature(op.id);
289
+ if (feature) {
290
+ feature.geometry = op.geometry;
291
+ if (op.flags) feature.flags = op.flags;
292
+ }
293
+ break;
294
+ }
295
+ case "roi.update_properties": {
296
+ const feature = this.feature(op.id);
297
+ if (feature) Object.assign(feature, op.changes);
298
+ break;
299
+ }
300
+ case "roi.delete":
301
+ this.features = this.features.filter((f) => f.id !== op.id);
302
+ if (this.selectionId === op.id) this.selectionId = null;
303
+ break;
304
+ case "roi.bulk_delete": {
305
+ const ids = new Set(op.ids || []);
306
+ this.features = this.features.filter((f) => !ids.has(f.id));
307
+ if (ids.has(this.selectionId)) this.selectionId = null;
308
+ break;
309
+ }
310
+ case "roi.bulk_create":
311
+ for (const category of op.categories || []) {
312
+ if (!this.category(category.id)) this.categories.push({ ...category });
313
+ }
314
+ for (const feature of op.features || []) {
315
+ if (!this.feature(feature.id)) this.features.push({ ...feature });
316
+ }
317
+ break;
318
+ default:
319
+ console.warn("ROI: unknown operation", op.op);
320
+ }
321
+ }
322
+
323
+ // -- saving ----------------------------------------------------------
324
+
325
+ enqueue(ops) {
326
+ if (this._suspended) return;
327
+ this.queue.push(...ops);
328
+ this.setStatus("dirty", "");
329
+ this.scheduleFlush();
330
+ }
331
+
332
+ scheduleFlush(delay = RoiStore.SAVE_DELAY) {
333
+ if (this._flushTimer) clearTimeout(this._flushTimer);
334
+ this._flushTimer = setTimeout(() => {
335
+ this._flushTimer = null;
336
+ this.flush();
337
+ }, delay);
338
+ }
339
+
340
+ /**
341
+ * Send everything queued as one request.
342
+ *
343
+ * One request rather than one per operation, and the whole queue rather
344
+ * than a slice: the server applies a batch atomically, so a batch either
345
+ * lands entirely or changes nothing, and there is never a half-applied edit
346
+ * to reason about.
347
+ */
348
+ async flush() {
349
+ if (this._flushing || !this.queue.length) return;
350
+ if (this.status === "conflict" || this.dimensionMismatch) return;
351
+
352
+ this._flushing = true;
353
+ const batch = this.queue.slice();
354
+ const baseRevision = this.revision;
355
+ this.setStatus("saving", "");
356
+ const task = window.PlexoraStatus?.begin("Saving ROIs");
357
+
358
+ let result;
359
+ try {
360
+ result = await this.api.postOperations(baseRevision, batch);
361
+ } catch (error) {
362
+ // Never delivered: the operations are still ours and still valid.
363
+ // Keep them, back off, and let the user go on drawing.
364
+ task?.done();
365
+ this._flushing = false;
366
+ this.setStatus("dirty", "not saved yet -- retrying");
367
+ this.scheduleRetry();
368
+ return;
369
+ }
370
+
371
+ this._flushing = false;
372
+
373
+ if (result.ok && result.data.success) {
374
+ this.queue.splice(0, batch.length);
375
+ this.revision = result.data.revision;
376
+ this._retryDelay = 0;
377
+ task?.done();
378
+ this.setStatus(this.queue.length ? "dirty" : "saved", "");
379
+ if (this.queue.length) this.scheduleFlush(0);
380
+ return;
381
+ }
382
+
383
+ if (result.status === 409) {
384
+ // Somebody else wrote first. Freeze the queue -- replaying it over
385
+ // their work is exactly the silent overwrite this check exists to
386
+ // stop -- and ask.
387
+ task?.fail("ROI conflict");
388
+ this.revision = result.data.revision ?? this.revision;
389
+ this.setStatus("conflict", "");
390
+ return;
391
+ }
392
+
393
+ if (result.status === 422) {
394
+ task?.fail("ROI image changed");
395
+ this.dimensionMismatch = true;
396
+ this.storedImageSize = result.data.stored_image_size || this.storedImageSize;
397
+ this.setStatus("blocked", result.data.error || "");
398
+ return;
399
+ }
400
+
401
+ // A 4xx means the operations themselves are wrong, so retrying sends the
402
+ // same rejection forever. Stop, keep the local geometry, and say so --
403
+ // the export button is the way out and it works from local state.
404
+ task?.fail("ROI save failed");
405
+ this.setStatus("failed", result.data.error || "the server rejected this change");
406
+ }
407
+
408
+ scheduleRetry() {
409
+ this._retryDelay = this._retryDelay
410
+ ? Math.min(this._retryDelay * 2, RoiStore.RETRY_MAX)
411
+ : RoiStore.RETRY_MIN;
412
+ if (this._retryTimer) clearTimeout(this._retryTimer);
413
+ this._retryTimer = setTimeout(() => {
414
+ this._retryTimer = null;
415
+ if (this._retryDelay >= RoiStore.RETRY_MAX) {
416
+ this.setStatus("failed", "cannot reach the server -- still retrying");
417
+ }
418
+ this.flush();
419
+ }, this._retryDelay);
420
+ }
421
+
422
+ setStatus(status, detail) {
423
+ if (this.status === status && this.statusDetail === detail) return;
424
+ this.status = status;
425
+ this.statusDetail = detail || "";
426
+ this.changed();
427
+ }
428
+
429
+ get hasUnsavedWork() {
430
+ return this.queue.length > 0 || this.status === "conflict";
431
+ }
432
+
433
+ // -- conflict resolution ---------------------------------------------
434
+
435
+ /** Take the other session's version. Local edits that never landed are
436
+ * discarded, which is why the panel offers the export beside it. */
437
+ async reloadRemote() {
438
+ this.queue = [];
439
+ this._retryDelay = 0;
440
+ this.status = "saved";
441
+ this.selectionId = null;
442
+ return this.load();
443
+ }
444
+
445
+ /** Get this session's work out to a file first, then take the remote one.
446
+ * Built from local state rather than the server's, because the server's is
447
+ * precisely the version that does not have these edits in it. */
448
+ async keepMineAndExport() {
449
+ this.exportLocal();
450
+ return this.reloadRemote();
451
+ }
452
+
453
+ /**
454
+ * The emergency export: the same document the server produces, built here.
455
+ *
456
+ * Works when the server does not: a failed save, a conflict, a changed
457
+ * image. That is the whole point of it -- the moments a user most needs
458
+ * their annotations out of the tab are the moments the normal path is the
459
+ * thing that is broken.
460
+ */
461
+ exportLocal() {
462
+ const document = this.toGeoJSON();
463
+ const blob = new Blob([JSON.stringify(document, null, 2)],
464
+ { type: "application/geo+json" });
465
+ RoiApi.saveBlob(blob, `${this.ctx.datasource}_rois.geojson`);
466
+ }
467
+
468
+ toGeoJSON() {
469
+ const categories = Object.fromEntries(this.categories.map((c) => [c.id, c]));
470
+ return {
471
+ type: "FeatureCollection",
472
+ plexora: {
473
+ schema_version: this.schemaVersion,
474
+ datasource: this.ctx.datasource,
475
+ exported_at: new Date().toISOString(),
476
+ coordinate_space: this.coordinateSpace,
477
+ categories: this.categories,
478
+ },
479
+ features: this.features.map((feature) => {
480
+ const category = categories[feature.category_id] || {};
481
+ return {
482
+ type: "Feature",
483
+ id: feature.id,
484
+ geometry: feature.geometry,
485
+ properties: {
486
+ name: feature.name || "",
487
+ category_id: feature.category_id,
488
+ category: category.label || "",
489
+ category_color: category.color || "",
490
+ locked: Boolean(feature.locked),
491
+ created_at: feature.created_at || null,
492
+ updated_at: feature.updated_at || null,
493
+ source_roi_id: feature.source_roi_id || null,
494
+ },
495
+ };
496
+ }),
497
+ };
498
+ }
499
+
500
+ // -- teardown --------------------------------------------------------
501
+
502
+ destroy() {
503
+ if (this._flushTimer) clearTimeout(this._flushTimer);
504
+ if (this._retryTimer) clearTimeout(this._retryTimer);
505
+ this._flushTimer = this._retryTimer = null;
506
+ this._listeners.clear();
507
+ }
508
+
509
+ /** A short, unique-enough id. Generated on the client so a shape exists the
510
+ * moment it is drawn instead of after a round trip -- the server rejects a
511
+ * duplicate rather than trusting it. */
512
+ static newId(prefix) {
513
+ const uuid = (window.crypto && window.crypto.randomUUID)
514
+ ? window.crypto.randomUUID()
515
+ : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
516
+ return `${prefix}-${uuid}`;
517
+ }
518
+ }