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.
- plexora/__init__.py +129 -0
- plexora/__main__.py +15 -0
- plexora/_url.py +105 -0
- plexora/api/__init__.py +55 -0
- plexora/api/dataset.py +398 -0
- plexora/api/http.py +19 -0
- plexora/api/plugin.py +541 -0
- plexora/api/store.py +221 -0
- plexora/cli.py +777 -0
- plexora/client/dist/354_bundle.js +2 -0
- plexora/client/dist/354_bundle.js.LICENSE.txt +20 -0
- plexora/client/dist/418_bundle.js +2 -0
- plexora/client/dist/418_bundle.js.LICENSE.txt +1 -0
- plexora/client/dist/693b935cb1f907814be9c6199a68c217.svg +19 -0
- plexora/client/dist/713c134ff47cd328a5b711526f151082.svg +18 -0
- plexora/client/dist/770_bundle.js +1 -0
- plexora/client/dist/ff743f408972e0e96ca9153ed5ae4c83.svg +23 -0
- plexora/client/dist/vendor_bundle.js +2 -0
- plexora/client/dist/vendor_bundle.js.LICENSE.txt +308 -0
- plexora/client/external/openseadragon-bin-2.4.0/LICENSE.txt +28 -0
- plexora/client/external/openseadragon-bin-2.4.0/canvas-overlay-hd.js +140 -0
- plexora/client/external/openseadragon-bin-2.4.0/changelog.txt +501 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/.gitattributes +17 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/LICENSE.txt +116 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/Toolbar.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/fullpage.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/home.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/next.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/previous.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/rotateleft.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/rotateright.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/zoom.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/README.md +9 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-scalebar.js +562 -0
- plexora/client/src/css/import.css +474 -0
- plexora/client/src/css/main.css +903 -0
- plexora/client/src/css/openProject.css +363 -0
- plexora/client/src/css/quickView.css +166 -0
- plexora/client/src/css/tokens.css +51 -0
- plexora/client/src/css/viewer.css +1655 -0
- plexora/client/src/img/apple-touch-icon.png +0 -0
- plexora/client/src/img/favicon.ico +0 -0
- plexora/client/src/img/logo.ai +8892 -33
- plexora/client/src/img/logo.svg +1 -0
- plexora/client/src/img/logo_with_text.ai +9011 -34
- plexora/client/src/img/logo_with_text.svg +1 -0
- plexora/client/src/js/main.js +704 -0
- plexora/client/src/js/pluginRegistry.js +108 -0
- plexora/client/src/js/services/appStatus.js +286 -0
- plexora/client/src/js/services/browsePicker.js +73 -0
- plexora/client/src/js/services/dataLayer.js +400 -0
- plexora/client/src/js/services/datasetContext.js +143 -0
- plexora/client/src/js/services/glRenderer.js +143 -0
- plexora/client/src/js/services/importFormValidation.js +257 -0
- plexora/client/src/js/services/numericData.js +100 -0
- plexora/client/src/js/services/passVariablesToFrontend.js +20 -0
- plexora/client/src/js/services/simpleEventHandler.js +25 -0
- plexora/client/src/js/vendor.js +29 -0
- plexora/client/src/js/views/channelList.js +663 -0
- plexora/client/src/js/views/colorSwatchPicker.js +202 -0
- plexora/client/src/js/views/columnClassifier.js +146 -0
- plexora/client/src/js/views/coordinateField.js +133 -0
- plexora/client/src/js/views/dataSourceField.js +276 -0
- plexora/client/src/js/views/imageViewer.js +3306 -0
- plexora/client/src/js/views/miniMap.js +787 -0
- plexora/client/src/js/views/navbarControls.js +178 -0
- plexora/client/src/js/views/openProjectPage.js +239 -0
- plexora/client/src/js/views/projectEdit.js +335 -0
- plexora/client/src/js/views/quickViewLanding.js +136 -0
- plexora/client/src/js/views/rainbow.js +326 -0
- plexora/client/src/js/views/requirementsModal.js +572 -0
- plexora/client/src/js/views/rgbImageViewer.js +115 -0
- plexora/client/src/js/views/searchableSelect.js +411 -0
- plexora/client/src/js/views/segmentationProgress.js +122 -0
- plexora/client/src/js/views/toolLoader.js +747 -0
- plexora/client/src/js/views/viewerControls.js +689 -0
- plexora/client/src/js/views/viewerManager.js +434 -0
- plexora/client/src/js/views/viewerSidebar.js +1117 -0
- plexora/client/src/js/workers/tileDecoder.js +130 -0
- plexora/client/src/shaders/frag.glsl +528 -0
- plexora/client/src/shaders/vert.glsl +12 -0
- plexora/client/templates/base.html +212 -0
- plexora/client/templates/index.html +238 -0
- plexora/client/templates/open_project.html +96 -0
- plexora/client/templates/project_columns.html +86 -0
- plexora/client/templates/project_edit.html +196 -0
- plexora/client/templates/upload.html +118 -0
- plexora/connect.py +528 -0
- plexora/datasource.py +782 -0
- plexora/jupyter.py +406 -0
- plexora/notebook_env.py +232 -0
- plexora/paths.py +486 -0
- plexora/plugins/__init__.py +12 -0
- plexora/plugins/cell_explorer/__init__.py +100 -0
- plexora/plugins/cell_explorer/server/__init__.py +11 -0
- plexora/plugins/cell_explorer/server/routes.py +167 -0
- plexora/plugins/cell_explorer/server/state.py +281 -0
- plexora/plugins/cell_explorer/server/values.py +145 -0
- plexora/plugins/cell_explorer/server/variables.py +367 -0
- plexora/plugins/cell_explorer/static/cellExplorerApi.js +158 -0
- plexora/plugins/cell_explorer/static/cellExplorerColors.js +316 -0
- plexora/plugins/cell_explorer/static/cellExplorerContinuous.js +403 -0
- plexora/plugins/cell_explorer/static/cellExplorerFigureBridge.js +133 -0
- plexora/plugins/cell_explorer/static/cellExplorerLegend.js +188 -0
- plexora/plugins/cell_explorer/static/cellExplorerRoiBridge.js +822 -0
- plexora/plugins/cell_explorer/static/cellExplorerSidebarController.js +826 -0
- plexora/plugins/cell_explorer/static/cellExplorerState.js +372 -0
- plexora/plugins/cell_explorer/static/cell_explorer.css +752 -0
- plexora/plugins/cell_explorer/templates/cell_explorer/panel.html +137 -0
- plexora/plugins/figure_builder/__init__.py +125 -0
- plexora/plugins/figure_builder/server/__init__.py +7 -0
- plexora/plugins/figure_builder/server/compose.py +365 -0
- plexora/plugins/figure_builder/server/export.py +784 -0
- plexora/plugins/figure_builder/server/export_jobs.py +148 -0
- plexora/plugins/figure_builder/server/operations.py +556 -0
- plexora/plugins/figure_builder/server/pixels.py +142 -0
- plexora/plugins/figure_builder/server/provenance.py +174 -0
- plexora/plugins/figure_builder/server/render.py +278 -0
- plexora/plugins/figure_builder/server/repository.py +701 -0
- plexora/plugins/figure_builder/server/routes.py +594 -0
- plexora/plugins/figure_builder/server/schema.py +1023 -0
- plexora/plugins/figure_builder/server/sources.py +120 -0
- plexora/plugins/figure_builder/server/textmetrics.py +155 -0
- plexora/plugins/figure_builder/static/figureActions.js +363 -0
- plexora/plugins/figure_builder/static/figureBuilderApi.js +255 -0
- plexora/plugins/figure_builder/static/figureCanvas.js +2362 -0
- plexora/plugins/figure_builder/static/figureCaptureBoxes.js +369 -0
- plexora/plugins/figure_builder/static/figureCaptureDock.js +466 -0
- plexora/plugins/figure_builder/static/figureCaptureTool.js +1034 -0
- plexora/plugins/figure_builder/static/figureConfirm.js +136 -0
- plexora/plugins/figure_builder/static/figureContextBar.js +1193 -0
- plexora/plugins/figure_builder/static/figureContextMenu.js +253 -0
- plexora/plugins/figure_builder/static/figureDocumentState.js +275 -0
- plexora/plugins/figure_builder/static/figureExportUi.js +174 -0
- plexora/plugins/figure_builder/static/figureLibrary.js +247 -0
- plexora/plugins/figure_builder/static/figureQuickEdit.js +628 -0
- plexora/plugins/figure_builder/static/figureRichText.js +634 -0
- plexora/plugins/figure_builder/static/figureSceneSnapshot.js +361 -0
- plexora/plugins/figure_builder/static/figureSchema.js +218 -0
- plexora/plugins/figure_builder/static/figureSidebarController.js +1159 -0
- plexora/plugins/figure_builder/static/figureTextEditor.js +503 -0
- plexora/plugins/figure_builder/static/figureTextPanel.js +491 -0
- plexora/plugins/figure_builder/static/figureViewOptions.js +273 -0
- plexora/plugins/figure_builder/static/figureWorkspace.js +2030 -0
- plexora/plugins/figure_builder/static/figure_builder.css +3292 -0
- plexora/plugins/figure_builder/templates/figure_builder/library.html +74 -0
- plexora/plugins/figure_builder/templates/figure_builder/workspace.html +27 -0
- plexora/plugins/figure_builder/templates/figure_builder/workspace_body.html +458 -0
- plexora/plugins/gating/__init__.py +76 -0
- plexora/plugins/gating/server/__init__.py +0 -0
- plexora/plugins/gating/server/anndata_gates.py +367 -0
- plexora/plugins/gating/server/database.py +16 -0
- plexora/plugins/gating/server/model.py +320 -0
- plexora/plugins/gating/server/routes.py +235 -0
- plexora/plugins/gating/static/csvGatingList.js +947 -0
- plexora/plugins/gating/static/gating.css +289 -0
- plexora/plugins/gating/static/gatingApi.js +221 -0
- plexora/plugins/gating/static/gatingSidebarController.js +513 -0
- plexora/plugins/gating/templates/gating/legacy.html +26 -0
- plexora/plugins/gating/templates/gating/panel.html +69 -0
- plexora/plugins/roi/__init__.py +95 -0
- plexora/plugins/roi/server/__init__.py +8 -0
- plexora/plugins/roi/server/adapters.py +655 -0
- plexora/plugins/roi/server/geojson.py +267 -0
- plexora/plugins/roi/server/geometry.py +181 -0
- plexora/plugins/roi/server/mapping.py +169 -0
- plexora/plugins/roi/server/operations.py +293 -0
- plexora/plugins/roi/server/repository.py +215 -0
- plexora/plugins/roi/server/routes.py +409 -0
- plexora/plugins/roi/server/schema.py +323 -0
- plexora/plugins/roi/static/roi.css +583 -0
- plexora/plugins/roi/static/roiApi.js +177 -0
- plexora/plugins/roi/static/roiFigureBridge.js +107 -0
- plexora/plugins/roi/static/roiGeometry.js +350 -0
- plexora/plugins/roi/static/roiRenderer.js +278 -0
- plexora/plugins/roi/static/roiSidebarController.js +1038 -0
- plexora/plugins/roi/static/roiState.js +518 -0
- plexora/plugins/roi/static/roiTools.js +993 -0
- plexora/plugins/roi/templates/roi/panel.html +240 -0
- plexora/proxy.py +57 -0
- plexora/server/models/adapters/__init__.py +94 -0
- plexora/server/models/adapters/anndata_adapter.py +388 -0
- plexora/server/models/adapters/base.py +93 -0
- plexora/server/models/adapters/classify.py +192 -0
- plexora/server/models/adapters/csv_adapter.py +105 -0
- plexora/server/models/adapters/inspection.py +265 -0
- plexora/server/models/adapters/spatialdata_adapter.py +248 -0
- plexora/server/models/centroid_tiles.py +305 -0
- plexora/server/models/data_model.py +1659 -0
- plexora/server/models/database_model.py +169 -0
- plexora/server/models/project.py +1270 -0
- plexora/server/plugins.py +199 -0
- plexora/server/routes/browse_routes.py +41 -0
- plexora/server/routes/data_routes.py +294 -0
- plexora/server/routes/import_routes.py +603 -0
- plexora/server/routes/page_routes.py +139 -0
- plexora/server/routes/project_routes.py +438 -0
- plexora/server/routes/quick_view_routes.py +62 -0
- plexora/server/routes/system_routes.py +39 -0
- plexora/server/routes/tool_routes.py +418 -0
- plexora/server/utils/addHEColumns.py +11 -0
- plexora/server/utils/fast_png.py +97 -0
- plexora/server/utils/fullConversion.py +50 -0
- plexora/server/utils/native_dialog.py +168 -0
- plexora/server/utils/pre_normalization.py +47 -0
- plexora/server/utils/segmentation_pyramid.py +598 -0
- plexora/server/utils/smallestenclosingcircle.py +126 -0
- plexora/server/utils/tiffsurgeon.py +372 -0
- plexora/server_cli.py +48 -0
- plexora/test.py +0 -0
- plexora-0.0.1.dist-info/METADATA +341 -0
- plexora-0.0.1.dist-info/RECORD +248 -0
- plexora-0.0.1.dist-info/WHEEL +5 -0
- plexora-0.0.1.dist-info/entry_points.txt +12 -0
- plexora-0.0.1.dist-info/licenses/LICENSE +207 -0
- 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
|
+
}
|