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,1038 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* roiSidebarController.js - the panel, and the plugin's registration.
|
|
3
|
+
*
|
|
4
|
+
* Everything user-facing that is not on the image itself: the tool buttons, the
|
|
5
|
+
* category list, the selected-region fields, the save indicator, import/export,
|
|
6
|
+
* and the two banners that appear when saving cannot proceed.
|
|
7
|
+
*
|
|
8
|
+
* Every piece of user text on this panel is written with `textContent`, never
|
|
9
|
+
* `innerHTML`. Category and region names are user input, they survive a round
|
|
10
|
+
* trip through the store, and they come back through an import from a file that
|
|
11
|
+
* may have been written by anything at all -- so the one place they reach the
|
|
12
|
+
* DOM is the one place that has to be careful.
|
|
13
|
+
*
|
|
14
|
+
* Registration is at the bottom of this file because it is loaded last (see
|
|
15
|
+
* PLUGIN.scripts): by then RoiApi, RoiGeometry, RoiStore, RoiRenderer and
|
|
16
|
+
* RoiInteraction all exist.
|
|
17
|
+
*/
|
|
18
|
+
class RoiSidebarController {
|
|
19
|
+
|
|
20
|
+
constructor(ctx) {
|
|
21
|
+
this.ctx = ctx;
|
|
22
|
+
this.api = new RoiApi(ctx);
|
|
23
|
+
this.store = new RoiStore(ctx, this.api);
|
|
24
|
+
this.renderer = new RoiRenderer(ctx, this.store);
|
|
25
|
+
this.tools = new RoiInteraction(ctx, this.store, this.renderer);
|
|
26
|
+
this.tools.onNotify = (message) => this.notify(message);
|
|
27
|
+
this._messageTimer = null;
|
|
28
|
+
this._nameTimer = null;
|
|
29
|
+
this._unsubscribe = null;
|
|
30
|
+
this._destinationOpen = false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// -- lifecycle -------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
setup() {
|
|
36
|
+
this.bindToolbar();
|
|
37
|
+
this.bindCategoryForm();
|
|
38
|
+
this.bindSelection();
|
|
39
|
+
this.bindTransfer();
|
|
40
|
+
this.bindBanners();
|
|
41
|
+
|
|
42
|
+
this.el("roi_panel_close")?.addEventListener("click", () => {
|
|
43
|
+
window.PlexoraToolLoader?.hideToolPanel("roi");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
this._unsubscribe = this.store.onChange(() => this.render());
|
|
47
|
+
|
|
48
|
+
// A tab closed with regions still queued loses them, and the user has no
|
|
49
|
+
// way to know that from looking at the panel. Registered through the
|
|
50
|
+
// plugin's cleanup list so it goes when the plugin does.
|
|
51
|
+
const beforeUnload = (event) => {
|
|
52
|
+
if (!this.store.hasUnsavedWork) return;
|
|
53
|
+
event.preventDefault();
|
|
54
|
+
event.returnValue = "";
|
|
55
|
+
};
|
|
56
|
+
window.addEventListener("beforeunload", beforeUnload);
|
|
57
|
+
this.ctx.onCleanup?.(() => window.removeEventListener("beforeunload", beforeUnload));
|
|
58
|
+
this.ctx.onCleanup?.(() => this.destroy());
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** ViewerSidebar's restore hook. Loading happens here so it is awaited in
|
|
62
|
+
* the same place core awaits every other module's saved state. */
|
|
63
|
+
async fetchSaved() {
|
|
64
|
+
await this.store.load();
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
applyOrDefault() {
|
|
69
|
+
this.renderer.attach();
|
|
70
|
+
this.render();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Called by toolLoader when this panel becomes the selected one. */
|
|
74
|
+
onShow() {
|
|
75
|
+
this.renderer.attach();
|
|
76
|
+
this.tools.arm();
|
|
77
|
+
this.render();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The eye on this tool's card: draw the regions, or stop drawing them.
|
|
82
|
+
*
|
|
83
|
+
* Every other plugin's layer is a cell layer core switches off by itself.
|
|
84
|
+
* ROI's is its own overlay canvas, so core has nothing to switch and the
|
|
85
|
+
* card's toggle would be inert without this.
|
|
86
|
+
*
|
|
87
|
+
* Turning the overlay off disarms the tools as well. A pen that goes on
|
|
88
|
+
* drawing invisible regions -- and goes on swallowing V/P/F/R -- while its
|
|
89
|
+
* layer says "off" is the same failure onHide() exists to prevent, arrived at
|
|
90
|
+
* from the other direction. Turning it back on re-arms only when this is
|
|
91
|
+
* still the selected tool: a background layer being made visible is a request
|
|
92
|
+
* to SEE it, not to type into it.
|
|
93
|
+
*/
|
|
94
|
+
onVisibilityChange(visible) {
|
|
95
|
+
this.renderer.setEnabled(Boolean(visible));
|
|
96
|
+
if (!visible) {
|
|
97
|
+
this.tools.disarm();
|
|
98
|
+
this.el("roi_map_info_dialog")?.close();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
this.renderer.attach();
|
|
102
|
+
if (window.PlexoraToolLoader?.activeTool() === "roi") this.tools.arm();
|
|
103
|
+
this.renderer.schedule();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Called when another tool is opened over this one, or this one is closed.
|
|
108
|
+
*
|
|
109
|
+
* This is the hook ROI genuinely needs: its handlers are on the viewer
|
|
110
|
+
* canvas and the document, neither of which is hidden along with the panel.
|
|
111
|
+
* Left armed, ROI would keep drawing over another tool's session and keep
|
|
112
|
+
* swallowing V/P/F/R.
|
|
113
|
+
*/
|
|
114
|
+
onHide() {
|
|
115
|
+
this.tools.disarm();
|
|
116
|
+
this.renderer.setEnabled(false);
|
|
117
|
+
// The info dialog lives inside the panel, so hiding the panel takes it
|
|
118
|
+
// off the screen without closing it -- and it would be waiting, open,
|
|
119
|
+
// over whatever the user came back to.
|
|
120
|
+
this.el("roi_map_info_dialog")?.close();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
destroy() {
|
|
124
|
+
this._unsubscribe?.();
|
|
125
|
+
if (this._messageTimer) clearTimeout(this._messageTimer);
|
|
126
|
+
if (this._nameTimer) clearTimeout(this._nameTimer);
|
|
127
|
+
this.tools.destroy();
|
|
128
|
+
this.renderer.destroy();
|
|
129
|
+
this.store.destroy();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
el(id) {
|
|
133
|
+
return document.getElementById(id);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// -- wiring ----------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
bindToolbar() {
|
|
139
|
+
for (const button of document.querySelectorAll("#roi_toolbar .roi-tool")) {
|
|
140
|
+
button.addEventListener("click", () => this.tools.setTool(button.dataset.tool));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
bindCategoryForm() {
|
|
145
|
+
const input = this.el("roi_category_name");
|
|
146
|
+
const add = () => {
|
|
147
|
+
this.createCategory(input.value);
|
|
148
|
+
input.value = "";
|
|
149
|
+
};
|
|
150
|
+
// Enter and the [+] button, bound separately rather than through a
|
|
151
|
+
// form's submit -- see the note in panel.html on why there is no form.
|
|
152
|
+
this.el("roi_category_add_button")?.addEventListener("click", add);
|
|
153
|
+
input?.addEventListener("keydown", (event) => {
|
|
154
|
+
if (event.key !== "Enter") return;
|
|
155
|
+
event.preventDefault();
|
|
156
|
+
add();
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
bindSelection() {
|
|
161
|
+
const name = this.el("roi_selection_name");
|
|
162
|
+
name?.addEventListener("input", () => {
|
|
163
|
+
// Debounced, so typing a name is one operation rather than one per
|
|
164
|
+
// keystroke -- and committed on blur too, since a user who types a
|
|
165
|
+
// name and immediately clicks the image expects it kept.
|
|
166
|
+
if (this._nameTimer) clearTimeout(this._nameTimer);
|
|
167
|
+
this._nameTimer = setTimeout(() => this.commitName(name.value), 500);
|
|
168
|
+
});
|
|
169
|
+
name?.addEventListener("blur", () => this.commitName(name.value));
|
|
170
|
+
name?.addEventListener("keydown", (event) => {
|
|
171
|
+
if (event.key === "Enter") {
|
|
172
|
+
event.preventDefault();
|
|
173
|
+
name.blur();
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
this.el("roi_selection_category")?.addEventListener("change", (event) => {
|
|
178
|
+
this.recategorize(event.target.value);
|
|
179
|
+
});
|
|
180
|
+
this.el("roi_selection_locked")?.addEventListener("click", () => {
|
|
181
|
+
this.setLocked(!this.store.selected?.locked);
|
|
182
|
+
});
|
|
183
|
+
this.el("roi_selection_delete")?.addEventListener("click", () => {
|
|
184
|
+
this.tools.deleteSelected();
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
bindTransfer() {
|
|
189
|
+
this.el("roi_export_button")?.addEventListener("click", () => this.exportGeoJSON());
|
|
190
|
+
this.el("roi_export_download")?.addEventListener("click", () => this.exportGeoJSON());
|
|
191
|
+
|
|
192
|
+
const picker = this.el("roi_import_file");
|
|
193
|
+
this.el("roi_import_button")?.addEventListener("click", () => picker?.click());
|
|
194
|
+
picker?.addEventListener("change", async () => {
|
|
195
|
+
const file = picker.files && picker.files[0];
|
|
196
|
+
picker.value = ""; // so re-picking the same file fires again
|
|
197
|
+
if (file) await this.importGeoJSON(file);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
this.el("roi_save_to_source")?.addEventListener("click", () => this.saveToSource());
|
|
201
|
+
this.el("roi_map_to_cells")?.addEventListener("click", () => this.mapToCells());
|
|
202
|
+
this.el("roi_destination_close")?.addEventListener("click", () => this.closeDestination());
|
|
203
|
+
|
|
204
|
+
this.el("roi_map_info")?.addEventListener("click", () => this.openMapInfo());
|
|
205
|
+
const info = this.el("roi_map_info_dialog");
|
|
206
|
+
this.el("roi_map_info_close")?.addEventListener("click", () => info?.close());
|
|
207
|
+
// Clicking outside the dialog closes it. The <dialog> element IS the
|
|
208
|
+
// backdrop's hit area -- the visible panel is its padding box -- so a
|
|
209
|
+
// click landing on the dialog itself and not on anything inside it is a
|
|
210
|
+
// click on the backdrop.
|
|
211
|
+
info?.addEventListener("click", (event) => {
|
|
212
|
+
if (event.target === info) info.close();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const destination = this.el("roi_destination_name");
|
|
216
|
+
// Live, so the line underneath always names the entry the button is
|
|
217
|
+
// about to write -- including while it is being renamed.
|
|
218
|
+
destination?.addEventListener("input", () => this.renderDestination());
|
|
219
|
+
// The field is opened by the save button and the user is already typing
|
|
220
|
+
// in it, so Enter finishes the job they started rather than making them
|
|
221
|
+
// travel back to the button. Escape backs out, as it does everywhere
|
|
222
|
+
// else on this panel. Neither reaches the drawing tools: they ignore
|
|
223
|
+
// keys while an input has focus (see RoiInteraction.acceptsKeys).
|
|
224
|
+
destination?.addEventListener("keydown", (event) => {
|
|
225
|
+
if (event.key === "Enter") {
|
|
226
|
+
event.preventDefault();
|
|
227
|
+
this.saveToSource();
|
|
228
|
+
} else if (event.key === "Escape") {
|
|
229
|
+
event.preventDefault();
|
|
230
|
+
this.closeDestination();
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
bindBanners() {
|
|
236
|
+
this.el("roi_conflict_reload")?.addEventListener("click", async () => {
|
|
237
|
+
await this.store.reloadRemote();
|
|
238
|
+
this.renderer.invalidate();
|
|
239
|
+
this.renderer.schedule();
|
|
240
|
+
});
|
|
241
|
+
this.el("roi_conflict_keep")?.addEventListener("click", async () => {
|
|
242
|
+
await this.store.keepMineAndExport();
|
|
243
|
+
this.renderer.invalidate();
|
|
244
|
+
this.renderer.schedule();
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// -- categories ------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
createCategory(rawLabel) {
|
|
251
|
+
const label = (rawLabel || "").trim();
|
|
252
|
+
if (!label) return;
|
|
253
|
+
if (this.store.categories.some((c) => c.label.toLowerCase() === label.toLowerCase())) {
|
|
254
|
+
this.notify(`There is already a category called "${label}".`);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const category = {
|
|
258
|
+
id: RoiStore.newId("c"),
|
|
259
|
+
label,
|
|
260
|
+
color: RoiSidebarController.nextColor(this.store.categories.length),
|
|
261
|
+
visible: true,
|
|
262
|
+
locked: false,
|
|
263
|
+
sort_order: this.store.categories.length,
|
|
264
|
+
};
|
|
265
|
+
this.store.commit({
|
|
266
|
+
label: "Add category",
|
|
267
|
+
redo: [{ op: "category.create", category }],
|
|
268
|
+
undo: [{ op: "category.delete", id: category.id, orphans: "delete" }],
|
|
269
|
+
});
|
|
270
|
+
// Selected immediately: the reason to make a category is to draw in it.
|
|
271
|
+
this.store.setActiveCategory(category.id);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
updateCategory(id, changes) {
|
|
275
|
+
const category = this.store.category(id);
|
|
276
|
+
if (!category) return;
|
|
277
|
+
const before = {};
|
|
278
|
+
for (const key of Object.keys(changes)) before[key] = category[key];
|
|
279
|
+
this.store.commit({
|
|
280
|
+
label: "Edit category",
|
|
281
|
+
redo: [{ op: "category.update", id, changes }],
|
|
282
|
+
undo: [{ op: "category.update", id, changes: before }],
|
|
283
|
+
});
|
|
284
|
+
this.renderer.schedule();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Delete a category, having asked what happens to what is in it.
|
|
289
|
+
*
|
|
290
|
+
* There is no default answer, and the prompt is not skippable when the
|
|
291
|
+
* category has regions: silently deleting somebody's annotations because
|
|
292
|
+
* they tidied up a label is the one outcome that cannot be undone by
|
|
293
|
+
* looking at the screen.
|
|
294
|
+
*
|
|
295
|
+
* Every category can be deleted, including the last one -- there is no
|
|
296
|
+
* reserved catch-all any more. What that costs is a place to put orphaned
|
|
297
|
+
* shapes: "move them" is only offered when there is somewhere to move them
|
|
298
|
+
* TO, and the destination is named in the prompt rather than assumed.
|
|
299
|
+
*/
|
|
300
|
+
deleteCategory(id) {
|
|
301
|
+
const category = this.store.category(id);
|
|
302
|
+
if (!category) return;
|
|
303
|
+
|
|
304
|
+
const count = this.store.countFor(id);
|
|
305
|
+
const destination = this.store.sortedCategories().find((c) => c.id !== id) || null;
|
|
306
|
+
let orphans = "delete";
|
|
307
|
+
|
|
308
|
+
if (count > 0) {
|
|
309
|
+
const regions = `${count} ROI${count === 1 ? "" : "s"}`;
|
|
310
|
+
if (destination) {
|
|
311
|
+
const keep = window.confirm(
|
|
312
|
+
`"${category.label}" has ${regions}.\n\n`
|
|
313
|
+
+ `OK: keep them and move them to "${destination.label}".\n`
|
|
314
|
+
+ "Cancel: delete the category and its ROIs."
|
|
315
|
+
);
|
|
316
|
+
orphans = keep ? "reassign" : "delete";
|
|
317
|
+
}
|
|
318
|
+
if (orphans === "delete"
|
|
319
|
+
&& !window.confirm(`Delete "${category.label}" and its ${regions}?`)) {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const affected = this.store.features
|
|
325
|
+
.filter((f) => f.category_id === id)
|
|
326
|
+
.map((f) => JSON.parse(JSON.stringify(f)));
|
|
327
|
+
const image = this.store.image;
|
|
328
|
+
const undo = [{ op: "category.create", category: { ...category } }];
|
|
329
|
+
if (orphans === "delete") {
|
|
330
|
+
for (const feature of affected) undo.push({ op: "roi.create", image, feature });
|
|
331
|
+
} else {
|
|
332
|
+
for (const feature of affected) {
|
|
333
|
+
undo.push({
|
|
334
|
+
op: "roi.update_properties", image, id: feature.id,
|
|
335
|
+
changes: { category_id: id },
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const redo = { op: "category.delete", id, orphans };
|
|
341
|
+
if (orphans === "reassign") redo.reassign_to = destination.id;
|
|
342
|
+
|
|
343
|
+
this.store.commit({ label: "Delete category", redo: [redo], undo });
|
|
344
|
+
this.renderer.invalidate();
|
|
345
|
+
this.renderer.schedule();
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// -- selection -------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
commitName(value) {
|
|
351
|
+
const feature = this.store.selected;
|
|
352
|
+
if (!feature) return;
|
|
353
|
+
const name = (value || "").trim();
|
|
354
|
+
if (name === (feature.name || "")) return;
|
|
355
|
+
this.propertyChange(feature, { name });
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
recategorize(categoryId) {
|
|
359
|
+
const feature = this.store.selected;
|
|
360
|
+
if (!feature || !this.store.category(categoryId)) return;
|
|
361
|
+
if (categoryId === feature.category_id) return;
|
|
362
|
+
this.propertyChange(feature, { category_id: categoryId });
|
|
363
|
+
this.renderer.schedule();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
setLocked(locked) {
|
|
367
|
+
const feature = this.store.selected;
|
|
368
|
+
if (!feature || Boolean(feature.locked) === Boolean(locked)) return;
|
|
369
|
+
this.propertyChange(feature, { locked: Boolean(locked) });
|
|
370
|
+
this.renderer.schedule();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
propertyChange(feature, changes) {
|
|
374
|
+
const before = {};
|
|
375
|
+
for (const key of Object.keys(changes)) before[key] = feature[key];
|
|
376
|
+
this.store.commit({
|
|
377
|
+
label: "Edit ROI",
|
|
378
|
+
redo: [{
|
|
379
|
+
op: "roi.update_properties", image: this.store.image,
|
|
380
|
+
id: feature.id, changes,
|
|
381
|
+
}],
|
|
382
|
+
undo: [{
|
|
383
|
+
op: "roi.update_properties", image: this.store.image,
|
|
384
|
+
id: feature.id, changes: before,
|
|
385
|
+
}],
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// -- import / export -------------------------------------------------
|
|
390
|
+
|
|
391
|
+
async exportGeoJSON() {
|
|
392
|
+
// Straight from local state when there is anything unsaved or the
|
|
393
|
+
// server has refused: the moments a user most needs an export are the
|
|
394
|
+
// ones where asking the server for one would hand back a version
|
|
395
|
+
// without their work in it.
|
|
396
|
+
if (this.store.hasUnsavedWork || this.store.dimensionMismatch
|
|
397
|
+
|| this.store.status === "failed") {
|
|
398
|
+
this.store.exportLocal();
|
|
399
|
+
this.notify("Exported this session's regions.");
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const result = await this.api.downloadExport();
|
|
403
|
+
if (!result.ok) {
|
|
404
|
+
this.store.exportLocal();
|
|
405
|
+
this.notify("The server could not build the export; exported locally instead.");
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async importGeoJSON(file, acceptMismatch = false) {
|
|
410
|
+
let document;
|
|
411
|
+
try {
|
|
412
|
+
document = JSON.parse(await file.text());
|
|
413
|
+
} catch (error) {
|
|
414
|
+
this.notify("That file is not valid JSON.");
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const result = await this.api.importGeojson(document, this.store.revision, acceptMismatch);
|
|
419
|
+
|
|
420
|
+
if (!result.ok && result.status === 409) {
|
|
421
|
+
this.store.setStatus("conflict", "");
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (result.data && result.data.warning === "dimension_mismatch") {
|
|
425
|
+
const found = result.data.found || [];
|
|
426
|
+
const expected = result.data.expected || [];
|
|
427
|
+
// Default is Cancel. Geometry from a differently-sized image lands
|
|
428
|
+
// somewhere entirely plausible and completely wrong, which is
|
|
429
|
+
// exactly the kind of mistake that is never noticed.
|
|
430
|
+
const proceed = window.confirm(
|
|
431
|
+
`These ROIs were drawn on an image ${found[0]} x ${found[1]} px.\n`
|
|
432
|
+
+ `This image is ${expected[0]} x ${expected[1]} px.\n\n`
|
|
433
|
+
+ "Import anyway, without transforming them?"
|
|
434
|
+
);
|
|
435
|
+
if (!proceed) return;
|
|
436
|
+
return this.importGeoJSON(file, true);
|
|
437
|
+
}
|
|
438
|
+
if (!result.ok || !result.data.success) {
|
|
439
|
+
this.notify(result.data.error || "That file could not be imported.");
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// The server applied it and told us what it did; replay the same
|
|
444
|
+
// operation locally so it becomes ONE undo step rather than none.
|
|
445
|
+
const operation = result.data.operation;
|
|
446
|
+
this.store.revision = result.data.revision;
|
|
447
|
+
this.store.applyLocal(operation);
|
|
448
|
+
this.store.undoStack.push({
|
|
449
|
+
label: `Import ${result.data.imported} ROIs`,
|
|
450
|
+
redo: [operation],
|
|
451
|
+
undo: [
|
|
452
|
+
{ op: "roi.bulk_delete", image: this.store.image,
|
|
453
|
+
ids: (operation.features || []).map((f) => f.id) },
|
|
454
|
+
...(operation.categories || []).map((c) => ({
|
|
455
|
+
op: "category.delete", id: c.id, orphans: "delete",
|
|
456
|
+
})),
|
|
457
|
+
],
|
|
458
|
+
});
|
|
459
|
+
this.store.redoStack.length = 0;
|
|
460
|
+
this.renderer.invalidate();
|
|
461
|
+
this.renderer.schedule();
|
|
462
|
+
this.store.changed();
|
|
463
|
+
this.notify(`Imported ${result.data.imported} region${result.data.imported === 1 ? "" : "s"}.`);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Write the regions into the file the project came from, under the name in
|
|
468
|
+
* the "Save as" field.
|
|
469
|
+
*
|
|
470
|
+
* Two presses: the first opens the field on the name this project last
|
|
471
|
+
* saved to, the second writes. The field is where the one irreversible
|
|
472
|
+
* decision on this panel is made -- which entry in somebody's file gets
|
|
473
|
+
* overwritten -- so it is shown before the write rather than after, and
|
|
474
|
+
* showing it costs the press that would otherwise have gone straight
|
|
475
|
+
* through. Everything below runs on the second press.
|
|
476
|
+
*
|
|
477
|
+
* The name is what makes several passes possible in one file -- a second
|
|
478
|
+
* annotator, a second read, a version worth keeping -- and it is also what
|
|
479
|
+
* makes a collision possible, so this is where that gets decided:
|
|
480
|
+
*
|
|
481
|
+
* the name this project last saved to -> written, no question. That is
|
|
482
|
+
* the ordinary draw-more-then-save-again loop, and a dialog in front
|
|
483
|
+
* of every save is a toll on the safe path.
|
|
484
|
+
* a free name -> written, no question.
|
|
485
|
+
* a name already in the file, not ours -> asked once. Somebody else's
|
|
486
|
+
* annotations are under it and replacing them cannot be undone from
|
|
487
|
+
* this panel.
|
|
488
|
+
*
|
|
489
|
+
* `replace` is only ever sent as the user's answer. The server refuses an
|
|
490
|
+
* existing key without it, so a mistake here costs a refusal rather than
|
|
491
|
+
* somebody's work.
|
|
492
|
+
*/
|
|
493
|
+
async saveToSource() {
|
|
494
|
+
const button = this.el("roi_save_to_source");
|
|
495
|
+
const destination = this._destination;
|
|
496
|
+
if (!destination || !destination.kind) return;
|
|
497
|
+
if (!this._destinationOpen) {
|
|
498
|
+
this.openDestination();
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const name = this.destinationName();
|
|
503
|
+
if (destination.kind === "anndata" && name !== destination.remembered
|
|
504
|
+
&& destination.existing.includes(name)) {
|
|
505
|
+
const proceed = window.confirm(
|
|
506
|
+
`"${name}" already exists in this file.\n\n`
|
|
507
|
+
+ "Replace it? The annotations currently stored under that name will be lost."
|
|
508
|
+
);
|
|
509
|
+
if (!proceed) return;
|
|
510
|
+
destination.replaceOnce = true;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
button.disabled = true;
|
|
514
|
+
try {
|
|
515
|
+
const result = destination.kind === "anndata"
|
|
516
|
+
? await window.PlexoraStatus.track("Saving ROIs to file",
|
|
517
|
+
this.api.saveToAnndata(name, destination.replaceOnce
|
|
518
|
+
|| name === destination.remembered))
|
|
519
|
+
: await window.PlexoraStatus.track("Saving ROIs to store",
|
|
520
|
+
this.api.saveToSpatialdata(name));
|
|
521
|
+
this.afterSaveToSource(result);
|
|
522
|
+
} finally {
|
|
523
|
+
destination.replaceOnce = false;
|
|
524
|
+
button.disabled = false;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** What the server made of it, said in terms of the user's own file. */
|
|
529
|
+
afterSaveToSource(result) {
|
|
530
|
+
const data = result.data || {};
|
|
531
|
+
const destination = this._destination;
|
|
532
|
+
|
|
533
|
+
if (!result.ok || !data.success) {
|
|
534
|
+
// Both refusals mean the same thing to the user -- that name is
|
|
535
|
+
// taken -- and both carry a free one, so the field becomes the
|
|
536
|
+
// suggestion rather than the user having to invent another.
|
|
537
|
+
if (data.error === "element_exists" || data.error === "key_exists") {
|
|
538
|
+
destination.existing = data.elements || data.keys || destination.existing;
|
|
539
|
+
if (data.suggestion) this.el("roi_destination_name").value = data.suggestion;
|
|
540
|
+
this.renderDestination();
|
|
541
|
+
this.notify(`That name is already taken${data.suggestion
|
|
542
|
+
? ` -- try "${data.suggestion}".` : "."}`);
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
this.notify(data.error || "The file could not be written.");
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
destination.remembered = data.name || destination.remembered;
|
|
550
|
+
if (data.name && !destination.existing.includes(data.name)) {
|
|
551
|
+
destination.existing = [...destination.existing, data.name].sort();
|
|
552
|
+
}
|
|
553
|
+
// Written, so the question the field was asking has been answered. A
|
|
554
|
+
// refusal above returns early instead: that one is still open.
|
|
555
|
+
this.closeDestination();
|
|
556
|
+
this.notify(`Saved to ${data.element || data.key}.`);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** The name in the field, or the default when it has been emptied. */
|
|
560
|
+
destinationName() {
|
|
561
|
+
const field = this.el("roi_destination_name");
|
|
562
|
+
const typed = (field?.value || "").trim();
|
|
563
|
+
return typed || (this._destination?.default_name || "");
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** Show the "Save as" field, with the name it is going to use selected --
|
|
567
|
+
* the common case is accepting it, and the next-common is typing over it
|
|
568
|
+
* whole, so neither should need the mouse. */
|
|
569
|
+
openDestination() {
|
|
570
|
+
this._destinationOpen = true;
|
|
571
|
+
this.renderSourceButton();
|
|
572
|
+
const field = this.el("roi_destination_name");
|
|
573
|
+
field?.focus();
|
|
574
|
+
field?.select();
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
closeDestination() {
|
|
578
|
+
this._destinationOpen = false;
|
|
579
|
+
this.renderSourceButton();
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// -- rendering -------------------------------------------------------
|
|
583
|
+
|
|
584
|
+
render() {
|
|
585
|
+
this.renderBanners();
|
|
586
|
+
this.renderToolbar();
|
|
587
|
+
this.renderCategories();
|
|
588
|
+
this.renderSelection();
|
|
589
|
+
this.renderStatus();
|
|
590
|
+
this.renderSourceButton();
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
renderBanners() {
|
|
594
|
+
const blocked = this.el("roi_blocked_banner");
|
|
595
|
+
if (blocked) {
|
|
596
|
+
blocked.hidden = !this.store.dimensionMismatch;
|
|
597
|
+
if (this.store.dimensionMismatch) {
|
|
598
|
+
const [sw, sh] = this.store.storedImageSize;
|
|
599
|
+
const [cw, ch] = this.store.imageSize;
|
|
600
|
+
this.el("roi_blocked_detail").textContent =
|
|
601
|
+
`Drawn on ${sw} x ${sh} px; this image is ${cw} x ${ch} px.`;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
const conflict = this.el("roi_conflict_banner");
|
|
605
|
+
if (conflict) conflict.hidden = this.store.status !== "conflict";
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
renderToolbar() {
|
|
609
|
+
for (const button of document.querySelectorAll("#roi_toolbar .roi-tool")) {
|
|
610
|
+
button.classList.toggle("is-active", button.dataset.tool === this.tools.tool);
|
|
611
|
+
// Select stays available whenever the pointer works at all; the
|
|
612
|
+
// three that MAKE a shape also need a category to put it in.
|
|
613
|
+
button.disabled = button.dataset.tool === "select"
|
|
614
|
+
? !this.tools.ready
|
|
615
|
+
: !this.tools.canDraw;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
renderCategories() {
|
|
620
|
+
const list = this.el("roi_category_list");
|
|
621
|
+
if (!list) return;
|
|
622
|
+
list.textContent = "";
|
|
623
|
+
|
|
624
|
+
for (const category of this.store.sortedCategories()) {
|
|
625
|
+
const row = document.createElement("div");
|
|
626
|
+
row.className = "roi-category";
|
|
627
|
+
row.classList.toggle("is-active", category.id === this.store.activeCategoryId);
|
|
628
|
+
row.classList.toggle("is-hidden", category.visible === false);
|
|
629
|
+
|
|
630
|
+
const swatch = document.createElement("input");
|
|
631
|
+
swatch.type = "color";
|
|
632
|
+
swatch.className = "roi-swatch";
|
|
633
|
+
swatch.value = category.color;
|
|
634
|
+
swatch.title = `Colour for ${category.label}`;
|
|
635
|
+
swatch.addEventListener("change", () => {
|
|
636
|
+
this.updateCategory(category.id, { color: swatch.value });
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
const label = document.createElement("button");
|
|
640
|
+
label.type = "button";
|
|
641
|
+
label.className = "roi-category-label";
|
|
642
|
+
// textContent, not innerHTML: this string is whatever the user (or
|
|
643
|
+
// an imported file) typed.
|
|
644
|
+
label.textContent = category.label;
|
|
645
|
+
label.title = `Draw in ${category.label}`;
|
|
646
|
+
label.addEventListener("click", () => this.store.setActiveCategory(category.id));
|
|
647
|
+
label.addEventListener("dblclick", () => this.renameCategory(category));
|
|
648
|
+
|
|
649
|
+
const count = document.createElement("span");
|
|
650
|
+
count.className = "roi-category-count";
|
|
651
|
+
count.textContent = String(this.store.countFor(category.id));
|
|
652
|
+
|
|
653
|
+
row.append(swatch, label, count);
|
|
654
|
+
row.append(
|
|
655
|
+
this.categoryButton(
|
|
656
|
+
category.visible === false ? "fa-eye-slash" : "fa-eye",
|
|
657
|
+
category.visible === false ? "Show" : "Hide",
|
|
658
|
+
() => this.updateCategory(category.id, { visible: category.visible === false })),
|
|
659
|
+
this.categoryButton(
|
|
660
|
+
category.locked ? "fa-lock" : "fa-lock-open",
|
|
661
|
+
category.locked ? "Unlock" : "Lock",
|
|
662
|
+
() => this.updateCategory(category.id, { locked: !category.locked })),
|
|
663
|
+
);
|
|
664
|
+
row.append(this.categoryButton("fa-trash", "Delete category",
|
|
665
|
+
() => this.deleteCategory(category.id), "roi-category-delete"));
|
|
666
|
+
list.append(row);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Shown only when the list is empty, and it is what the panel says
|
|
670
|
+
// instead of shipping a category the user did not ask for.
|
|
671
|
+
const empty = this.el("roi_category_empty");
|
|
672
|
+
if (empty) empty.hidden = this.store.categories.length > 0;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
categoryButton(icon, title, onClick, extraClass = "") {
|
|
676
|
+
const button = document.createElement("button");
|
|
677
|
+
button.type = "button";
|
|
678
|
+
button.className = `icon-button roi-category-action ${extraClass}`.trim();
|
|
679
|
+
button.title = title;
|
|
680
|
+
const glyph = document.createElement("span");
|
|
681
|
+
glyph.className = `fas ${icon}`;
|
|
682
|
+
button.append(glyph);
|
|
683
|
+
button.addEventListener("click", onClick);
|
|
684
|
+
return button;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
renameCategory(category) {
|
|
688
|
+
const label = window.prompt("Category name", category.label);
|
|
689
|
+
if (label === null) return;
|
|
690
|
+
const trimmed = label.trim();
|
|
691
|
+
if (!trimmed || trimmed === category.label) return;
|
|
692
|
+
if (this.store.categories.some(
|
|
693
|
+
(c) => c.id !== category.id && c.label.toLowerCase() === trimmed.toLowerCase())) {
|
|
694
|
+
this.notify(`There is already a category called "${trimmed}".`);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
this.updateCategory(category.id, { label: trimmed });
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
renderSelection() {
|
|
701
|
+
const panel = this.el("roi_selection_panel");
|
|
702
|
+
const feature = this.store.selected;
|
|
703
|
+
if (!panel) return;
|
|
704
|
+
panel.hidden = !feature;
|
|
705
|
+
if (!feature) return;
|
|
706
|
+
|
|
707
|
+
const name = this.el("roi_selection_name");
|
|
708
|
+
// Not overwritten while it has focus: doing so would fight the user
|
|
709
|
+
// mid-word every time an autosave came back.
|
|
710
|
+
if (name && document.activeElement !== name) name.value = feature.name || "";
|
|
711
|
+
|
|
712
|
+
const select = this.el("roi_selection_category");
|
|
713
|
+
if (select) {
|
|
714
|
+
select.textContent = "";
|
|
715
|
+
for (const category of this.store.sortedCategories()) {
|
|
716
|
+
const option = document.createElement("option");
|
|
717
|
+
option.value = category.id;
|
|
718
|
+
option.textContent = category.label;
|
|
719
|
+
option.selected = category.id === feature.category_id;
|
|
720
|
+
select.append(option);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// The dot in front of the dropdown, which is the only thing telling the
|
|
725
|
+
// user that "Stroma" here is the same Stroma they picked a colour for.
|
|
726
|
+
const swatch = this.el("roi_selection_swatch");
|
|
727
|
+
if (swatch) {
|
|
728
|
+
const category = this.store.category(feature.category_id);
|
|
729
|
+
swatch.style.background = category ? category.color : "transparent";
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// Which padlock is drawn is CSS's business, off `aria-pressed` -- see
|
|
733
|
+
// the note in panel.html. Setting it here is the whole state change.
|
|
734
|
+
const locked = this.el("roi_selection_locked");
|
|
735
|
+
if (locked) {
|
|
736
|
+
const on = Boolean(feature.locked);
|
|
737
|
+
locked.setAttribute("aria-pressed", String(on));
|
|
738
|
+
locked.classList.toggle("is-active", on);
|
|
739
|
+
locked.title = on ? "Unlock this region" : "Lock this region";
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const note = this.el("roi_selection_note");
|
|
743
|
+
if (note) {
|
|
744
|
+
const parts = [];
|
|
745
|
+
if (this.store.isLocked(feature) && !feature.locked) {
|
|
746
|
+
parts.push("Its category is locked.");
|
|
747
|
+
}
|
|
748
|
+
if (!RoiGeometry.isVertexEditable(feature.geometry)) {
|
|
749
|
+
parts.push("Imported shape: it can be moved but its vertices cannot be edited.");
|
|
750
|
+
}
|
|
751
|
+
if (feature.flags && feature.flags.self_intersecting) {
|
|
752
|
+
parts.push("This outline crosses itself.");
|
|
753
|
+
}
|
|
754
|
+
note.textContent = parts.join(" ");
|
|
755
|
+
// Hidden when it has nothing to say, rather than an empty line that
|
|
756
|
+
// still costs its margin under every selection.
|
|
757
|
+
note.hidden = parts.length === 0;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
renderStatus() {
|
|
762
|
+
const text = this.el("roi_status_text");
|
|
763
|
+
const dot = this.el("roi_status_dot");
|
|
764
|
+
if (!text || !dot) return;
|
|
765
|
+
|
|
766
|
+
const labels = {
|
|
767
|
+
saved: "Saved",
|
|
768
|
+
saving: "Saving…",
|
|
769
|
+
dirty: "Unsaved changes",
|
|
770
|
+
failed: "Save failed",
|
|
771
|
+
conflict: "Changed elsewhere",
|
|
772
|
+
blocked: "Not saving",
|
|
773
|
+
};
|
|
774
|
+
text.textContent = this.store.statusDetail
|
|
775
|
+
? `${labels[this.store.status]} — ${this.store.statusDetail}`
|
|
776
|
+
: labels[this.store.status] || "";
|
|
777
|
+
dot.className = `roi-status-dot roi-status-${this.store.status}`;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* The row at the bottom: where this work goes when it leaves the panel.
|
|
782
|
+
*
|
|
783
|
+
* Every project gets a Save, because "how do I keep this?" is the same
|
|
784
|
+
* question whatever the project was built from -- and the answer used to be
|
|
785
|
+
* a small icon in the panel heading for anyone without an .h5ad, which is
|
|
786
|
+
* not an answer anybody finds. A native destination gets the file write; a
|
|
787
|
+
* CSV or image-only project gets the GeoJSON download in the same place,
|
|
788
|
+
* the same size, saying what it does. Never both at once.
|
|
789
|
+
*
|
|
790
|
+
* Map to cells sits beside it rather than under it. Under, it read as the
|
|
791
|
+
* step after saving; it is not one, and neither is a prerequisite of the
|
|
792
|
+
* other. Its ? is hidden and shown with it for the same reason.
|
|
793
|
+
*
|
|
794
|
+
* The heading icons stay: they are the quick path for someone who already
|
|
795
|
+
* knows where they are.
|
|
796
|
+
*/
|
|
797
|
+
renderSourceButton() {
|
|
798
|
+
const native = this.el("roi_save_to_source");
|
|
799
|
+
const download = this.el("roi_export_download");
|
|
800
|
+
if (!native || !download) return;
|
|
801
|
+
|
|
802
|
+
if (this._destination === undefined) {
|
|
803
|
+
this._destination = null;
|
|
804
|
+
// Asked once, from the server: which native destination this
|
|
805
|
+
// project has is a fact about how it was imported, and the panel
|
|
806
|
+
// should show one button rather than three that mostly error.
|
|
807
|
+
this.api.destination().then((result) => {
|
|
808
|
+
const data = (result.ok && result.data.success) ? result.data : {};
|
|
809
|
+
this._destination = {
|
|
810
|
+
kind: data.kind || null,
|
|
811
|
+
default_name: data.default_name || "",
|
|
812
|
+
remembered: data.remembered || "",
|
|
813
|
+
existing: data.existing || [],
|
|
814
|
+
// Whether this project has cells at all. Carried on the
|
|
815
|
+
// same answer because it is part of the same question --
|
|
816
|
+
// what can this project's regions be written to -- and the
|
|
817
|
+
// panel would otherwise make a second round trip to learn
|
|
818
|
+
// one boolean.
|
|
819
|
+
hasTable: Boolean(data.has_table),
|
|
820
|
+
replaceOnce: false,
|
|
821
|
+
};
|
|
822
|
+
if (this._destination.kind) {
|
|
823
|
+
// Just "Save". It used to name the format -- "Save to
|
|
824
|
+
// SpatialData store" -- which is both wider than half a
|
|
825
|
+
// 288px row and an answer to a question the button is not
|
|
826
|
+
// being asked: the user knows what they imported. Where it
|
|
827
|
+
// lands is spelled out under the name field the moment the
|
|
828
|
+
// button is pressed, down to `uns/plexora/rois`, and the
|
|
829
|
+
// tooltip carries it for anyone hovering first.
|
|
830
|
+
this.el("roi_save_to_source_label").textContent = "Save";
|
|
831
|
+
native.title = this._destination.kind === "anndata"
|
|
832
|
+
? "Save these regions into your AnnData file"
|
|
833
|
+
: "Save these regions into your SpatialData store";
|
|
834
|
+
const field = this.el("roi_destination_name");
|
|
835
|
+
// Seeded with where this project last saved, so the second
|
|
836
|
+
// save is a click rather than a name typed correctly twice.
|
|
837
|
+
if (field) {
|
|
838
|
+
field.value = this._destination.remembered
|
|
839
|
+
|| this._destination.default_name;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
this.renderSourceButton();
|
|
843
|
+
}).catch(() => { /* no native target; the GeoJSON download stands in */ });
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// Nothing drawn yet means nothing to save, and a button that would
|
|
847
|
+
// write an empty file is worse than no button.
|
|
848
|
+
const kind = this._destination && this._destination.kind;
|
|
849
|
+
const anything = this.store.features.length > 0;
|
|
850
|
+
native.hidden = !kind || !anything;
|
|
851
|
+
download.hidden = Boolean(kind) || !anything;
|
|
852
|
+
|
|
853
|
+
// Mapping is a different offer from saving, and gated on a different
|
|
854
|
+
// fact: saving needs somewhere to put polygons, mapping needs rows to
|
|
855
|
+
// put labels on. A project that is nothing but an image has neither
|
|
856
|
+
// button; a CSV project has the download and this one.
|
|
857
|
+
const mapper = this.el("roi_map_to_cells");
|
|
858
|
+
const info = this.el("roi_map_info");
|
|
859
|
+
if (mapper) {
|
|
860
|
+
const canMap = Boolean(this._destination
|
|
861
|
+
&& this._destination.hasTable) && anything;
|
|
862
|
+
mapper.hidden = !canMap;
|
|
863
|
+
// The ? goes with the button, not with the panel: an explanation of
|
|
864
|
+
// a control that is not on screen is a control of its own.
|
|
865
|
+
if (info) info.hidden = !canMap;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// The field cannot outlive the button that opened it: undoing the last
|
|
869
|
+
// region while it is open would otherwise leave a "Save as" for a save
|
|
870
|
+
// there is no longer anything to make.
|
|
871
|
+
if (native.hidden) this._destinationOpen = false;
|
|
872
|
+
const field = this.el("roi_destination");
|
|
873
|
+
if (field) field.hidden = !this._destinationOpen;
|
|
874
|
+
native.setAttribute("aria-expanded", this._destinationOpen ? "true" : "false");
|
|
875
|
+
this.renderDestination();
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* The name the two cell columns are derived from.
|
|
880
|
+
*
|
|
881
|
+
* Not `destinationName()`, which falls back to `default_name` -- that is
|
|
882
|
+
* `plexora_rois` for a SpatialData project, and `plexora_rois_category` is
|
|
883
|
+
* not a column name anybody wants to type. A blank is left blank and the
|
|
884
|
+
* server supplies `rois` for every format, so the two ends cannot drift.
|
|
885
|
+
*/
|
|
886
|
+
mapPrefix() {
|
|
887
|
+
const field = this.el("roi_destination_name");
|
|
888
|
+
const typed = (field?.value || "").trim();
|
|
889
|
+
return typed || (this._destination?.remembered || "");
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* The two columns the button is about to write, named.
|
|
894
|
+
*
|
|
895
|
+
* The blank `mapPrefix()` returns is the server's cue to use `rois`, so
|
|
896
|
+
* that default is spelled out here too -- this is the one thing in the
|
|
897
|
+
* dialog that is about the user's project rather than about the feature,
|
|
898
|
+
* and "it depends" is not what they came to read.
|
|
899
|
+
*/
|
|
900
|
+
mapColumnNames() {
|
|
901
|
+
const prefix = this.mapPrefix() || "rois";
|
|
902
|
+
return [`${prefix}_category`, `${prefix}_name`];
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* What Map to cells does, at length.
|
|
907
|
+
*
|
|
908
|
+
* Filled in at open rather than at render: the column names follow the save
|
|
909
|
+
* name, which the user can be typing while the panel is open, and a dialog
|
|
910
|
+
* nobody has looked at yet is the cheapest place in the panel to be right.
|
|
911
|
+
*/
|
|
912
|
+
openMapInfo() {
|
|
913
|
+
const dialog = this.el("roi_map_info_dialog");
|
|
914
|
+
if (!dialog) return;
|
|
915
|
+
const [category, name] = this.mapColumnNames();
|
|
916
|
+
const categoryCell = this.el("roi_map_info_category");
|
|
917
|
+
const nameCell = this.el("roi_map_info_name");
|
|
918
|
+
if (categoryCell) categoryCell.textContent = category;
|
|
919
|
+
if (nameCell) nameCell.textContent = name;
|
|
920
|
+
dialog.showModal();
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* Write the ROI columns onto this project's cells.
|
|
925
|
+
*
|
|
926
|
+
* Two questions can come back unanswered -- which column holds the cell id,
|
|
927
|
+
* which holds the image id -- and both are asked here rather than at launch
|
|
928
|
+
* because this is the only action that needs them. `requirements.require`
|
|
929
|
+
* ignores whether the user was already offered the field and skipped it,
|
|
930
|
+
* which is exactly right: skipping was a fine answer until they pressed a
|
|
931
|
+
* button that cannot proceed without one.
|
|
932
|
+
*
|
|
933
|
+
* Branching on `needs` rather than on the message. The wording is for the
|
|
934
|
+
* user; a client that greps it breaks the moment it is improved.
|
|
935
|
+
*/
|
|
936
|
+
async mapToCells(replace = false) {
|
|
937
|
+
const button = this.el("roi_map_to_cells");
|
|
938
|
+
if (!button || button.hidden) return;
|
|
939
|
+
|
|
940
|
+
button.disabled = true;
|
|
941
|
+
try {
|
|
942
|
+
const result = await window.PlexoraStatus.track(
|
|
943
|
+
"Mapping ROIs to cells",
|
|
944
|
+
this.api.mapToCells(this.mapPrefix(), replace));
|
|
945
|
+
const data = result.data || {};
|
|
946
|
+
|
|
947
|
+
if (!result.ok || !data.success) {
|
|
948
|
+
if (data.needs && !replace) {
|
|
949
|
+
const collected = await this.ctx.requirements?.require([data.needs]);
|
|
950
|
+
// Retried once, and only once: a second refusal means the
|
|
951
|
+
// answer did not fix it, and asking again in a loop is how
|
|
952
|
+
// a modal becomes impossible to get out of.
|
|
953
|
+
if (collected) return this.mapToCells(replace);
|
|
954
|
+
this.notify(data.error || "Plexora needs one more answer first.");
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
if (data.error === "column_exists") {
|
|
958
|
+
const suggestion = data.suggestion;
|
|
959
|
+
const proceed = window.confirm(
|
|
960
|
+
`Your cells already have a "${this.mapPrefix() || "rois"}" mapping.\n\n`
|
|
961
|
+
+ "Replace it? The existing values in those two columns will be lost."
|
|
962
|
+
+ (suggestion ? `\n\nCancel to save it as "${suggestion}" instead.` : ""));
|
|
963
|
+
if (proceed) return this.mapToCells(true);
|
|
964
|
+
if (suggestion) {
|
|
965
|
+
const field = this.el("roi_destination_name");
|
|
966
|
+
if (field) field.value = suggestion;
|
|
967
|
+
this.renderSourceButton();
|
|
968
|
+
}
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
this.notify(data.error || "The cells could not be annotated.");
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
const columns = (data.columns || []).join(" and ");
|
|
976
|
+
this.notify(`${data.n_assigned} of ${data.n_cells} cells are in an ROI. `
|
|
977
|
+
+ `Wrote ${columns}.`);
|
|
978
|
+
} finally {
|
|
979
|
+
button.disabled = false;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* What the save button is about to write, spelled out underneath it.
|
|
985
|
+
*
|
|
986
|
+
* The full path rather than just the name: `uns/plexora/rois` is where this
|
|
987
|
+
* ends up in somebody's file, and a user who is choosing between two names
|
|
988
|
+
* is exactly the user who wants to see it. The other names in the file are
|
|
989
|
+
* listed for the same reason -- a collision should be visible before it is
|
|
990
|
+
* typed, not after it is refused.
|
|
991
|
+
*/
|
|
992
|
+
renderDestination() {
|
|
993
|
+
const hint = this.el("roi_destination_hint");
|
|
994
|
+
const destination = this._destination;
|
|
995
|
+
if (!hint || !destination || !destination.kind) return;
|
|
996
|
+
|
|
997
|
+
const name = this.destinationName();
|
|
998
|
+
const where = destination.kind === "anndata"
|
|
999
|
+
? `uns/plexora/${name}` : `shapes/${name}`;
|
|
1000
|
+
const others = destination.existing.filter((each) => each !== name);
|
|
1001
|
+
hint.textContent = others.length
|
|
1002
|
+
? `Writes ${where}. Also in this file: ${others.join(", ")}.`
|
|
1003
|
+
: `Writes ${where}.`;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
notify(message) {
|
|
1007
|
+
const box = this.el("roi_message");
|
|
1008
|
+
if (!box) return;
|
|
1009
|
+
box.textContent = message;
|
|
1010
|
+
box.hidden = false;
|
|
1011
|
+
if (this._messageTimer) clearTimeout(this._messageTimer);
|
|
1012
|
+
this._messageTimer = setTimeout(() => { box.hidden = true; }, 6000);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/** Distinct, readable-on-dark colours for new categories, in a fixed order
|
|
1016
|
+
* so the same project gets the same palette every time. */
|
|
1017
|
+
static nextColor(index) {
|
|
1018
|
+
const palette = [
|
|
1019
|
+
"#e05c5c", "#38bdf8", "#34d399", "#f3b845", "#c084fc",
|
|
1020
|
+
"#f472b6", "#22d3ee", "#a3e635", "#fb923c", "#94a3b8",
|
|
1021
|
+
];
|
|
1022
|
+
return palette[index % palette.length];
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
if (window.Plexora) {
|
|
1028
|
+
window.Plexora.registerPlugin({
|
|
1029
|
+
name: "roi",
|
|
1030
|
+
// ROI draws its own overlay and never colours cells, so it does not
|
|
1031
|
+
// claim the cell layer -- claiming is exclusive, and taking it would
|
|
1032
|
+
// evict whichever plugin actually needs it.
|
|
1033
|
+
ownsCellLayer: false,
|
|
1034
|
+
createSidebarController(ctx) {
|
|
1035
|
+
return new RoiSidebarController(ctx);
|
|
1036
|
+
},
|
|
1037
|
+
});
|
|
1038
|
+
}
|