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,1117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @class ViewerSidebar - unified controls for marker gating and image channels.
|
|
3
|
+
*
|
|
4
|
+
* ## Where it looks for its markup
|
|
5
|
+
*
|
|
6
|
+
* By default: the whole document, by id, exactly as it always did. The
|
|
7
|
+
* `options` argument scopes it instead -- `{root, idPrefix, persist}` -- so a
|
|
8
|
+
* SECOND instance can drive a second copy of the same markup elsewhere on the
|
|
9
|
+
* page without the two finding each other's elements.
|
|
10
|
+
*
|
|
11
|
+
* That exists for Figure Builder's Quick Edit, which mounts the channel
|
|
12
|
+
* controls beside a small preview of a figure panel. The alternative was a
|
|
13
|
+
* second channel widget written from scratch, which is two implementations of
|
|
14
|
+
* colour, contrast and channel ordering that agree until the day one of them
|
|
15
|
+
* is fixed -- and a user who has to learn the second one.
|
|
16
|
+
*
|
|
17
|
+
* `persist: false` is the other half of it: a scoped instance must never write
|
|
18
|
+
* the project's saved channel list, because the channels it is showing belong
|
|
19
|
+
* to a figure panel rather than to the project. Note that `persistChannelList`
|
|
20
|
+
* also reads main.js globals that only exist on the viewer page, so this is a
|
|
21
|
+
* correctness guard and not merely a policy one.
|
|
22
|
+
*
|
|
23
|
+
* Every default is the previous behaviour, and the viewer passes no options at
|
|
24
|
+
* all.
|
|
25
|
+
*/
|
|
26
|
+
class ViewerSidebar {
|
|
27
|
+
constructor(config, columns, dataLayer, eventHandler, channelList, options) {
|
|
28
|
+
this.config = config;
|
|
29
|
+
this.columns = [...columns];
|
|
30
|
+
this.dataLayer = dataLayer;
|
|
31
|
+
this.eventHandler = eventHandler;
|
|
32
|
+
this.channelList = channelList;
|
|
33
|
+
const settings = options || {};
|
|
34
|
+
//: Where this instance's markup lives. `document` for the viewer's own
|
|
35
|
+
//: sidebar; an element for anything mounting a second copy.
|
|
36
|
+
this.root = settings.root || document;
|
|
37
|
+
//: Prepended to every id this instance looks up or generates, so two
|
|
38
|
+
//: copies of the same markup can coexist.
|
|
39
|
+
this.idPrefix = settings.idPrefix || "";
|
|
40
|
+
//: Whether changes here are written to the project's channel list.
|
|
41
|
+
this.persist = settings.persist !== false;
|
|
42
|
+
//: Full channel name -> index in the image. main.js's `imageChannels`
|
|
43
|
+
//: by default; injectable because that binding only exists on the
|
|
44
|
+
//: viewer page, and a scoped instance runs where it does not.
|
|
45
|
+
this.channelIndexMap = settings.channelIndex || null;
|
|
46
|
+
this.databaseDescription = {};
|
|
47
|
+
this.channelSlots = [];
|
|
48
|
+
this.channelSlotSliders = new Map();
|
|
49
|
+
// Remembers a manually-set intensity range per marker name (not per slot), so
|
|
50
|
+
// switching a slot's marker away and back doesn't discard what the user tuned.
|
|
51
|
+
this.markerRangeOverrides = new Map();
|
|
52
|
+
this.colorPickers = new Map();
|
|
53
|
+
this.markerSelects = new Map();
|
|
54
|
+
this._saveChannelsTimer = null;
|
|
55
|
+
this._restoring = false;
|
|
56
|
+
// Add-on modules (e.g. gating) that extend the sidebar without core needing to know
|
|
57
|
+
// their concrete type -- see registerModule() and pluginRegistry.js.
|
|
58
|
+
this.sidebarModules = [];
|
|
59
|
+
this.maxChannelSlots = 15;
|
|
60
|
+
this.initialChannelSlots = 4;
|
|
61
|
+
this.defaultColors = [
|
|
62
|
+
{ label: "Blue", hex: "#2388ff", rgb: { r: 35, g: 136, b: 255 } },
|
|
63
|
+
{ label: "Red", hex: "#ff2d2d", rgb: { r: 255, g: 45, b: 45 } },
|
|
64
|
+
{ label: "Green", hex: "#2bd46f", rgb: { r: 43, g: 212, b: 111 } },
|
|
65
|
+
{ label: "White", hex: "#ffffff", rgb: { r: 255, g: 255, b: 255 } },
|
|
66
|
+
];
|
|
67
|
+
// Per-channel range sliders live in [0, 255] byte units by default
|
|
68
|
+
// (matching the quantized WebP tile data 1:1) and switch to raw
|
|
69
|
+
// 16-bit units when HD is on (see getImageRange/toImageConnectorRange/
|
|
70
|
+
// autoChannel below, and frag.glsl's u8_r_range for why this matters:
|
|
71
|
+
// without it the slider's domain doesn't match the encoded data's
|
|
72
|
+
// domain, which is what caused visible banding).
|
|
73
|
+
window.addEventListener("plexora:hd-mode-changed", (e) => this.onHdModeChanged(Boolean(e.detail?.enabled)));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* One of this instance's elements, by its unprefixed id.
|
|
78
|
+
*
|
|
79
|
+
* With the defaults this is exactly `document.getElementById(name)`. A
|
|
80
|
+
* scoped instance looks inside its own root and for its own prefixed id,
|
|
81
|
+
* so the two never find each other's controls -- and an element the scoped
|
|
82
|
+
* copy does not mount comes back null, which every caller here already
|
|
83
|
+
* handles because the viewer renders no channel section for an RGB image.
|
|
84
|
+
*/
|
|
85
|
+
el(name) {
|
|
86
|
+
const id = this.idPrefix + name;
|
|
87
|
+
return this.root.getElementById
|
|
88
|
+
? this.root.getElementById(id)
|
|
89
|
+
: this.root.querySelector(`[id="${id}"]`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
q(selector) {
|
|
93
|
+
return this.root.querySelector(selector);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The id this instance gives one of its per-slot elements. */
|
|
97
|
+
slotId(name, index) {
|
|
98
|
+
return `${this.idPrefix}${name}_${index}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
isHdMode() {
|
|
102
|
+
return Boolean(window.__plexora?.seaDragonViewer?.viewerManagerVMain?.isHdMode?.());
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @function quantWindow
|
|
107
|
+
* The server's quantization window {qmin, qmax} for a channel -- the only
|
|
108
|
+
* thing needed to convert between the raw 16-bit units ranges are stored
|
|
109
|
+
* in and the [0, 255] byte domain the slider and shader work in.
|
|
110
|
+
*
|
|
111
|
+
* Sourced from the channel's image stats (get_image_channel_stats), NOT
|
|
112
|
+
* from the GMM packet. Both carry identical qmin/qmax -- they come from
|
|
113
|
+
* the same server-side get_channel_quantization_window() -- but the GMM
|
|
114
|
+
* packet only arrives after a ~1 s GaussianMixture fit, while stats takes
|
|
115
|
+
* a few ms and is fetched on activation anyway. Reading it from the GMM
|
|
116
|
+
* was why restoring saved channels ran a fit per channel purely to
|
|
117
|
+
* convert a range they had already saved. Falls back to the GMM packet so
|
|
118
|
+
* a caller that somehow has one but no stats still works.
|
|
119
|
+
*/
|
|
120
|
+
quantWindow(name) {
|
|
121
|
+
const fullName = this.dataLayer.getFullChannelName(name);
|
|
122
|
+
const stats = this.databaseDescription[fullName];
|
|
123
|
+
if (stats && stats.qmax !== undefined) return stats;
|
|
124
|
+
return this.channelList.hasChannelGMM[name];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* @function rawToByteRange
|
|
129
|
+
* Maps a [min, max] pair in raw 16-bit units into the [0, 255] byte
|
|
130
|
+
* domain the server actually quantized against (packet.qmin/qmax --
|
|
131
|
+
* see quantWindow), clamped to a valid byte range.
|
|
132
|
+
*/
|
|
133
|
+
rawToByteRange([rmin, rmax], packet) {
|
|
134
|
+
const span = Math.max(packet.qmax - packet.qmin, 1);
|
|
135
|
+
const toByte = (v) => Math.min(255, Math.max(0, Math.round(((v - packet.qmin) / span) * 255)));
|
|
136
|
+
return [toByte(rmin), toByte(rmax)];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* @function byteToRawRange
|
|
141
|
+
* Inverse of rawToByteRange: maps a [min, max] pair in [0, 255] byte
|
|
142
|
+
* units back into raw 16-bit units.
|
|
143
|
+
*/
|
|
144
|
+
byteToRawRange([bmin, bmax], packet) {
|
|
145
|
+
const span = Math.max(packet.qmax - packet.qmin, 1);
|
|
146
|
+
const toRaw = (v) => packet.qmin + (v / 255) * span;
|
|
147
|
+
return [toRaw(bmin), toRaw(bmax)];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* @function toRawRangeForSlot
|
|
152
|
+
* slot.range is byte-domain in default mode, raw in HD mode (see
|
|
153
|
+
* onHdModeChanged) -- this always returns raw 16-bit units, for
|
|
154
|
+
* persistence (persistChannelList) which must stay domain-independent
|
|
155
|
+
* since it's read back across sessions/mode changes on restore.
|
|
156
|
+
* Falls back to slot.range unconverted if no quantization window is
|
|
157
|
+
* cached yet (shouldn't happen for an active/auto-leveled slot).
|
|
158
|
+
*/
|
|
159
|
+
toRawRangeForSlot(slot) {
|
|
160
|
+
if (this.isHdMode()) return slot.range;
|
|
161
|
+
const packet = this.quantWindow(slot.name);
|
|
162
|
+
if (!packet) return slot.range;
|
|
163
|
+
return this.byteToRawRange(slot.range, packet);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* @function onHdModeChanged
|
|
168
|
+
* Remaps every active channel slot's current range (whatever the user
|
|
169
|
+
* has it set to, default or manually adjusted) into the newly-active
|
|
170
|
+
* domain, so the visible contrast window doesn't silently jump/reset
|
|
171
|
+
* on toggle, then updates the slider bounds and repaints.
|
|
172
|
+
*/
|
|
173
|
+
onHdModeChanged(enabled) {
|
|
174
|
+
this.channelSlots.forEach((slot) => {
|
|
175
|
+
if (!slot.name) return;
|
|
176
|
+
const packet = this.quantWindow(slot.name);
|
|
177
|
+
if (!packet) return;
|
|
178
|
+
slot.range = enabled
|
|
179
|
+
? this.byteToRawRange(slot.range, packet)
|
|
180
|
+
: this.rawToByteRange(slot.range, packet);
|
|
181
|
+
this.setSlotRange(slot.index, slot.range, slot.userRangeChanged);
|
|
182
|
+
if (slot.expanded) {
|
|
183
|
+
this.redrawChannelSlider(slot);
|
|
184
|
+
} else {
|
|
185
|
+
slot.sliderDirty = true;
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async init(databaseDescription) {
|
|
191
|
+
this.databaseDescription = databaseDescription;
|
|
192
|
+
this.setupSidebarShell();
|
|
193
|
+
this.sidebarModules.forEach((m) => m.setup && m.setup());
|
|
194
|
+
this.bindActions();
|
|
195
|
+
|
|
196
|
+
const maxLabel = this.el("max-channels");
|
|
197
|
+
if (maxLabel) maxLabel.textContent = this.maxChannelSlots;
|
|
198
|
+
|
|
199
|
+
const [savedChannels, ...moduleSaved] = await Promise.all([
|
|
200
|
+
this.dataLayer.getSavedChannelList(),
|
|
201
|
+
...this.sidebarModules.map((m) => (m.fetchSaved ? m.fetchSaved() : Promise.resolve(null))),
|
|
202
|
+
]);
|
|
203
|
+
|
|
204
|
+
// Suppressed while restoring: applySavedChannels/a module's own apply-from-saved
|
|
205
|
+
// reuse the same setters live edits use, which otherwise schedule an autosave on
|
|
206
|
+
// every call - turning "load from DB" into "load from DB, then immediately write
|
|
207
|
+
// back to DB".
|
|
208
|
+
this._restoring = true;
|
|
209
|
+
if (savedChannels && savedChannels.length) {
|
|
210
|
+
await this.applySavedChannels(savedChannels);
|
|
211
|
+
} else {
|
|
212
|
+
this.initChannelSlots();
|
|
213
|
+
this.applyInitialChannels();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
this.sidebarModules.forEach((m, i) => m.applyOrDefault && m.applyOrDefault(moduleSaved[i]));
|
|
217
|
+
this._restoring = false;
|
|
218
|
+
|
|
219
|
+
if (!(savedChannels && savedChannels.length)) this.persistChannelList();
|
|
220
|
+
this.sidebarModules.forEach((m, i) => m.persistIfNeeded && m.persistIfNeeded(Boolean(moduleSaved[i] && moduleSaved[i].length)));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Registers a plugin's sidebar controller (see pluginRegistry.js). The controller
|
|
225
|
+
* may implement setup(), fetchSaved(), applyOrDefault(savedRows), and
|
|
226
|
+
* persistIfNeeded(hadSaved) -- all optional, called at the matching point in init()
|
|
227
|
+
* above, alongside the core channel-slot restore flow.
|
|
228
|
+
*/
|
|
229
|
+
registerModule(moduleController) {
|
|
230
|
+
this.sidebarModules.push(moduleController);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Registers a module discovered after init() already ran (see toolLoader.js /
|
|
235
|
+
* main.js's activateAddonModule) -- e.g. a tool that was lazily loaded when the
|
|
236
|
+
* user opened it mid-session, rather than known at page-boot time. Runs the same
|
|
237
|
+
* setup()/fetchSaved()/applyOrDefault()/persistIfNeeded() lifecycle init() applies
|
|
238
|
+
* to every module registered up front (lines above), just for this one module,
|
|
239
|
+
* so a late module restores its saved state identically either way.
|
|
240
|
+
*/
|
|
241
|
+
async registerModuleLate(moduleController) {
|
|
242
|
+
this.sidebarModules.push(moduleController);
|
|
243
|
+
moduleController.setup && moduleController.setup();
|
|
244
|
+
const saved = moduleController.fetchSaved ? await moduleController.fetchSaved() : null;
|
|
245
|
+
this._restoring = true;
|
|
246
|
+
if (moduleController.applyOrDefault) await moduleController.applyOrDefault(saved);
|
|
247
|
+
this._restoring = false;
|
|
248
|
+
if (moduleController.persistIfNeeded) moduleController.persistIfNeeded(Boolean(saved && saved.length));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Forget a module whose plugin has been removed (toolLoader's Remove
|
|
253
|
+
* button, via main.js's deactivatePlugin).
|
|
254
|
+
*
|
|
255
|
+
* Registration is for the life of the page everywhere else, so this had no
|
|
256
|
+
* counterpart until a tool could be taken away again. Without it a dead
|
|
257
|
+
* controller stays in the list and goes on being handed setup(),
|
|
258
|
+
* fetchSaved() and applyOrDefault() -- against a panel whose markup has been
|
|
259
|
+
* removed from the page, so every element handle it takes is null.
|
|
260
|
+
*/
|
|
261
|
+
unregisterModule(moduleController) {
|
|
262
|
+
const index = this.sidebarModules.indexOf(moduleController);
|
|
263
|
+
if (index < 0) return false;
|
|
264
|
+
this.sidebarModules.splice(index, 1);
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
isRestoring() {
|
|
269
|
+
return this._restoring;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
setupSidebarShell() {
|
|
273
|
+
const collapseButton = this.el("sidebar_collapse_button");
|
|
274
|
+
const expandButton = this.el("sidebar_expand_button");
|
|
275
|
+
const shell = this.el("bodyDiv");
|
|
276
|
+
const toggleSidebar = () => {
|
|
277
|
+
if (shell) {
|
|
278
|
+
shell.classList.toggle("sidebar-collapsed");
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
if (collapseButton) {
|
|
282
|
+
collapseButton.addEventListener("click", toggleSidebar);
|
|
283
|
+
}
|
|
284
|
+
if (expandButton) {
|
|
285
|
+
expandButton.addEventListener("click", toggleSidebar);
|
|
286
|
+
}
|
|
287
|
+
this.setupChannelSectionCollapse();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Fold the Image Channels section away, the way a plugin's card folds.
|
|
292
|
+
*
|
|
293
|
+
* A class on the section and nothing else: the channel rows, their sliders
|
|
294
|
+
* and their colour pickers stay in the DOM, so unfolding is instant and
|
|
295
|
+
* every handle the slider code took at build time is still pointing at a
|
|
296
|
+
* node on the page. Rebuilding the list on each fold would redraw every
|
|
297
|
+
* d3 slider for a control the user is only tidying out of the way.
|
|
298
|
+
*
|
|
299
|
+
* Deliberately NOT persisted and NOT draggable: this is core's own section,
|
|
300
|
+
* fixed above the plugin stack, and folding it is a passing choice about
|
|
301
|
+
* screen space rather than part of the project's state.
|
|
302
|
+
*
|
|
303
|
+
* Absent for an RGB image, where index.html renders no channel section.
|
|
304
|
+
*/
|
|
305
|
+
setupChannelSectionCollapse() {
|
|
306
|
+
const section = this.el("image_channel_section");
|
|
307
|
+
const toggle = this.el("image_channel_collapse");
|
|
308
|
+
if (!section || !toggle) return;
|
|
309
|
+
toggle.addEventListener("click", () => {
|
|
310
|
+
const collapsed = section.classList.toggle("is-collapsed");
|
|
311
|
+
toggle.setAttribute("aria-expanded", String(!collapsed));
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
bindActions() {
|
|
316
|
+
const addButton = this.el("add_channel_button");
|
|
317
|
+
// Guarded: a scoped instance may mount a subset of the markup, and the
|
|
318
|
+
// viewer itself renders no channel section at all for an RGB image.
|
|
319
|
+
if (addButton) addButton.addEventListener("click", () => this.addFirstAvailableChannel());
|
|
320
|
+
|
|
321
|
+
window.addEventListener("resize", () => {
|
|
322
|
+
this.redrawChannelSliders();
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
initChannelSlots() {
|
|
327
|
+
const slotList = this.el("channel_slot_list");
|
|
328
|
+
if (!slotList) return;
|
|
329
|
+
slotList.innerHTML = "";
|
|
330
|
+
this.channelSlots = [...Array(this.initialChannelSlots).keys()].map((slotIndex) => {
|
|
331
|
+
const color = this.getDefaultColor(slotIndex);
|
|
332
|
+
const name = this.columns[slotIndex] || "";
|
|
333
|
+
const slot = {
|
|
334
|
+
index: slotIndex,
|
|
335
|
+
name,
|
|
336
|
+
color: color.rgb,
|
|
337
|
+
colorHex: color.hex,
|
|
338
|
+
enabled: slotIndex === 0 && Boolean(name),
|
|
339
|
+
visible: Boolean(name),
|
|
340
|
+
expanded: false,
|
|
341
|
+
sliderDirty: false,
|
|
342
|
+
range: this.getImageRange(name),
|
|
343
|
+
userColorChanged: false,
|
|
344
|
+
userRangeChanged: false,
|
|
345
|
+
autoLeveled: false,
|
|
346
|
+
autoLeveling: false,
|
|
347
|
+
};
|
|
348
|
+
slotList.appendChild(this.createChannelSlot(slot));
|
|
349
|
+
return slot;
|
|
350
|
+
});
|
|
351
|
+
this.updateSelectedCount();
|
|
352
|
+
this.redrawChannelSliders();
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
createChannelSlot(slot) {
|
|
356
|
+
const row = document.createElement("div");
|
|
357
|
+
row.classList.add("channel-slot");
|
|
358
|
+
row.classList.toggle("is-hidden", !slot.visible);
|
|
359
|
+
row.classList.toggle("is-disabled", !slot.enabled);
|
|
360
|
+
row.setAttribute("data-slot", slot.index);
|
|
361
|
+
row.style.setProperty("--slot-color", slot.colorHex);
|
|
362
|
+
|
|
363
|
+
const top = document.createElement("div");
|
|
364
|
+
top.classList.add("channel-slot-top");
|
|
365
|
+
row.appendChild(top);
|
|
366
|
+
|
|
367
|
+
const toggle = document.createElement("input");
|
|
368
|
+
toggle.type = "checkbox";
|
|
369
|
+
toggle.classList.add("channel-toggle-switch");
|
|
370
|
+
toggle.checked = slot.enabled;
|
|
371
|
+
toggle.title = "Toggle channel";
|
|
372
|
+
toggle.addEventListener("change", (event) => {
|
|
373
|
+
this.setSlotEnabled(slot.index, event.target.checked);
|
|
374
|
+
});
|
|
375
|
+
top.appendChild(toggle);
|
|
376
|
+
|
|
377
|
+
const colorMount = document.createElement("div");
|
|
378
|
+
top.appendChild(colorMount);
|
|
379
|
+
const colorPicker = new ColorSwatchPicker(colorMount, {
|
|
380
|
+
value: slot.colorHex,
|
|
381
|
+
onChange: (hex) => this.setSlotColor(slot.index, hex, true),
|
|
382
|
+
});
|
|
383
|
+
this.colorPickers.set(slot.index, colorPicker);
|
|
384
|
+
|
|
385
|
+
const comboMount = document.createElement("div");
|
|
386
|
+
top.appendChild(comboMount);
|
|
387
|
+
const markerSelect = new SearchableSelect(comboMount, {
|
|
388
|
+
options: this.columns,
|
|
389
|
+
value: slot.name,
|
|
390
|
+
placeholder: "Select marker…",
|
|
391
|
+
describeOption: (name) => this.describeMarkerOption(name, slot.index),
|
|
392
|
+
onChange: (name) => this.setSlotMarker(slot.index, name, { keepColor: true, enable: true }),
|
|
393
|
+
});
|
|
394
|
+
this.markerSelects.set(slot.index, markerSelect);
|
|
395
|
+
|
|
396
|
+
const expandToggle = document.createElement("button");
|
|
397
|
+
expandToggle.type = "button";
|
|
398
|
+
expandToggle.classList.add("channel-slot-expand-toggle");
|
|
399
|
+
expandToggle.classList.toggle("is-expanded", Boolean(slot.expanded));
|
|
400
|
+
expandToggle.title = "Show threshold range";
|
|
401
|
+
expandToggle.innerHTML = '<span class="fas fa-chevron-down"></span>';
|
|
402
|
+
expandToggle.addEventListener("click", () => this.toggleSlotExpanded(slot.index));
|
|
403
|
+
top.appendChild(expandToggle);
|
|
404
|
+
|
|
405
|
+
const remove = document.createElement("button");
|
|
406
|
+
remove.type = "button";
|
|
407
|
+
remove.classList.add("slot-remove-button");
|
|
408
|
+
remove.title = "Remove channel slot";
|
|
409
|
+
remove.innerHTML = '<span class="fas fa-xmark"></span>';
|
|
410
|
+
remove.addEventListener("click", () => this.removeChannelSlot(slot.index));
|
|
411
|
+
top.appendChild(remove);
|
|
412
|
+
|
|
413
|
+
const detail = document.createElement("div");
|
|
414
|
+
detail.classList.add("channel-slot-detail");
|
|
415
|
+
detail.classList.toggle("is-expanded", Boolean(slot.expanded));
|
|
416
|
+
|
|
417
|
+
const detailHeader = document.createElement("div");
|
|
418
|
+
detailHeader.classList.add("slot-detail-header");
|
|
419
|
+
|
|
420
|
+
const values = document.createElement("div");
|
|
421
|
+
values.classList.add("range-readout", "slot-range-readout");
|
|
422
|
+
values.innerHTML = `<span id="${this.slotId("channel_slot_min", slot.index)}">0.00</span>`
|
|
423
|
+
+ `<span id="${this.slotId("channel_slot_max", slot.index)}">0.00</span>`;
|
|
424
|
+
detailHeader.appendChild(values);
|
|
425
|
+
|
|
426
|
+
const auto = document.createElement("button");
|
|
427
|
+
auto.type = "button";
|
|
428
|
+
auto.classList.add("slot-auto-button");
|
|
429
|
+
auto.title = "Auto-set threshold range from data";
|
|
430
|
+
auto.textContent = "Auto";
|
|
431
|
+
auto.addEventListener("click", () => this.autoChannel(slot.index, { force: true }));
|
|
432
|
+
detailHeader.appendChild(auto);
|
|
433
|
+
|
|
434
|
+
detail.appendChild(detailHeader);
|
|
435
|
+
|
|
436
|
+
const slider = document.createElement("div");
|
|
437
|
+
slider.classList.add("sidebar-slider");
|
|
438
|
+
slider.setAttribute("id", this.slotId("channel_slot_slider", slot.index));
|
|
439
|
+
detail.appendChild(slider);
|
|
440
|
+
|
|
441
|
+
row.appendChild(detail);
|
|
442
|
+
|
|
443
|
+
return row;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
applyInitialChannels() {
|
|
447
|
+
this.channelSlots.forEach((slot) => {
|
|
448
|
+
if (slot.name && slot.enabled) {
|
|
449
|
+
this.activateChannel(slot);
|
|
450
|
+
// Every other slot-enable path (setSlotEnabled, setSlotMarker) auto-levels
|
|
451
|
+
// right after activating -- this default/no-saved-channels path was missing
|
|
452
|
+
// it, so the first channel on a brand-new datasource sat at the full [0, 255]
|
|
453
|
+
// range until the user happened to touch it.
|
|
454
|
+
this.autoLevelChannelIfNeeded(slot);
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
this.updateSelectedCount();
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
setSlotMarker(slotIndex, name, options = {}) {
|
|
461
|
+
const slot = this.channelSlots[slotIndex];
|
|
462
|
+
if (!slot || !name) return;
|
|
463
|
+
const markerChanged = slot.name !== name;
|
|
464
|
+
const enablesSlot = options.enable && !slot.enabled;
|
|
465
|
+
const revealsSlot = options.reveal && !slot.visible;
|
|
466
|
+
if (!markerChanged && !enablesSlot && !revealsSlot && !options.force) return;
|
|
467
|
+
if (slot.name && slot.enabled && markerChanged) {
|
|
468
|
+
this.deactivateChannel(slot);
|
|
469
|
+
}
|
|
470
|
+
this.disableDuplicateChannels(name, slotIndex);
|
|
471
|
+
slot.name = name;
|
|
472
|
+
if (markerChanged) {
|
|
473
|
+
const override = this.markerRangeOverrides.get(name);
|
|
474
|
+
if (override) {
|
|
475
|
+
slot.range = [...override];
|
|
476
|
+
slot.userRangeChanged = true;
|
|
477
|
+
slot.autoLeveled = true;
|
|
478
|
+
} else {
|
|
479
|
+
slot.range = this.getImageRange(name);
|
|
480
|
+
slot.userRangeChanged = false;
|
|
481
|
+
slot.autoLeveled = false;
|
|
482
|
+
}
|
|
483
|
+
slot.autoLeveling = false;
|
|
484
|
+
slot.expanded = true;
|
|
485
|
+
slot.sliderDirty = true;
|
|
486
|
+
}
|
|
487
|
+
if (!options.keepColor) {
|
|
488
|
+
this.setSlotColor(slotIndex, this.getDefaultColor(slotIndex).hex, false);
|
|
489
|
+
}
|
|
490
|
+
if (options.enable) {
|
|
491
|
+
slot.enabled = true;
|
|
492
|
+
}
|
|
493
|
+
if (options.reveal || options.enable) {
|
|
494
|
+
slot.visible = true;
|
|
495
|
+
}
|
|
496
|
+
this.syncSlotDom(slot);
|
|
497
|
+
this.applySlotExpansion(slot);
|
|
498
|
+
if (slot.enabled) {
|
|
499
|
+
this.activateChannel(slot);
|
|
500
|
+
this.autoLevelChannelIfNeeded(slot);
|
|
501
|
+
}
|
|
502
|
+
this.updateSelectedCount();
|
|
503
|
+
this.scheduleSaveChannels();
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
setSlotEnabled(slotIndex, enabled) {
|
|
507
|
+
const slot = this.channelSlots[slotIndex];
|
|
508
|
+
if (!slot || !slot.name) return;
|
|
509
|
+
const wasEnabled = slot.enabled;
|
|
510
|
+
slot.enabled = enabled;
|
|
511
|
+
slot.visible = true;
|
|
512
|
+
if (enabled) {
|
|
513
|
+
this.disableDuplicateChannels(slot.name, slotIndex);
|
|
514
|
+
this.activateChannel(slot);
|
|
515
|
+
if (!wasEnabled) {
|
|
516
|
+
this.autoLevelChannelIfNeeded(slot);
|
|
517
|
+
}
|
|
518
|
+
} else {
|
|
519
|
+
this.deactivateChannel(slot);
|
|
520
|
+
}
|
|
521
|
+
this.syncSlotDom(slot);
|
|
522
|
+
this.updateSelectedCount();
|
|
523
|
+
this.scheduleSaveChannels();
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
setSlotColor(slotIndex, hex, userColorChanged) {
|
|
527
|
+
const slot = this.channelSlots[slotIndex];
|
|
528
|
+
if (!slot) return;
|
|
529
|
+
slot.colorHex = hex;
|
|
530
|
+
slot.color = this.hexToRgb(hex);
|
|
531
|
+
slot.userColorChanged = Boolean(userColorChanged || slot.userColorChanged);
|
|
532
|
+
this.syncSlotDom(slot);
|
|
533
|
+
if (slot.enabled && slot.name) {
|
|
534
|
+
this.eventHandler.trigger(ChannelList.events.COLOR_TRANSFER_CHANGE, {
|
|
535
|
+
name: slot.name,
|
|
536
|
+
type: "white",
|
|
537
|
+
color: d3.rgb(slot.color.r, slot.color.g, slot.color.b),
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
this.scheduleSaveChannels();
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Where a channel sits in the image, by full name. */
|
|
544
|
+
channelIndexOf(fullName) {
|
|
545
|
+
if (this.channelIndexMap) return this.channelIndexMap[fullName];
|
|
546
|
+
return imageChannels[fullName];
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
activateChannel(slot) {
|
|
550
|
+
const fullName = this.dataLayer.getFullChannelName(slot.name);
|
|
551
|
+
const channelIdx = this.channelIndexOf(fullName);
|
|
552
|
+
if (channelIdx === undefined) return;
|
|
553
|
+
this.channelList.image_channels[slot.name] = slot.range;
|
|
554
|
+
this.channelList.rangeConnector[channelIdx] = this.toImageConnectorRange(slot.range);
|
|
555
|
+
this.channelList.colorConnector[channelIdx] = { color: slot.color };
|
|
556
|
+
if (!this.channelList.selections.includes(slot.name)) {
|
|
557
|
+
this.channelList.selections.push(slot.name);
|
|
558
|
+
}
|
|
559
|
+
this.channelList.sel[fullName] = slot.range;
|
|
560
|
+
this.eventHandler.trigger(ChannelList.events.CHANNELS_CHANGE, {
|
|
561
|
+
selections: this.channelList.selections,
|
|
562
|
+
name: slot.name,
|
|
563
|
+
status: true,
|
|
564
|
+
});
|
|
565
|
+
this.eventHandler.trigger(ChannelList.events.COLOR_TRANSFER_CHANGE, {
|
|
566
|
+
name: slot.name,
|
|
567
|
+
type: "white",
|
|
568
|
+
color: d3.rgb(slot.color.r, slot.color.g, slot.color.b),
|
|
569
|
+
});
|
|
570
|
+
this.eventHandler.trigger(ChannelList.events.BRUSH_MOVE, {
|
|
571
|
+
name: slot.name,
|
|
572
|
+
dataRange: [...slot.range],
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
// HD mode's slider domain is the real per-channel image_min/image_max
|
|
576
|
+
// (default mode uses a fixed [0, 255] byte domain and never needs
|
|
577
|
+
// this -- see getImageRange). getRawImageRange already falls back to
|
|
578
|
+
// a generic bit range when stats haven't been fetched yet, so
|
|
579
|
+
// activation itself isn't gated on this fetch -- just refresh the
|
|
580
|
+
// slot's range once real stats land, unless the user has already
|
|
581
|
+
// touched this slot's slider.
|
|
582
|
+
if (this.isHdMode() && !slot.userRangeChanged) {
|
|
583
|
+
this.channelList.ensureChannelStats(slot.name).then(() => {
|
|
584
|
+
if (slot.userRangeChanged) return;
|
|
585
|
+
slot.range = this.getRawImageRange(slot.name);
|
|
586
|
+
this.channelList.sel[fullName] = slot.range;
|
|
587
|
+
this.syncSlotDom(slot);
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
deactivateChannel(slot) {
|
|
593
|
+
const fullName = this.dataLayer.getFullChannelName(slot.name);
|
|
594
|
+
this.channelList.selections = _.pull(this.channelList.selections, slot.name);
|
|
595
|
+
delete this.channelList.sel[fullName];
|
|
596
|
+
this.eventHandler.trigger(ChannelList.events.CHANNELS_CHANGE, {
|
|
597
|
+
selections: this.channelList.selections,
|
|
598
|
+
name: slot.name,
|
|
599
|
+
status: false,
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
disableDuplicateChannels(name, currentSlotIndex) {
|
|
604
|
+
this.channelSlots.forEach((slot) => {
|
|
605
|
+
if (slot.index !== currentSlotIndex && slot.enabled && slot.name === name) {
|
|
606
|
+
slot.enabled = false;
|
|
607
|
+
this.deactivateChannel(slot);
|
|
608
|
+
this.syncSlotDom(slot);
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
redrawChannelSliders() {
|
|
614
|
+
this.channelSlots.filter((slot) => slot.visible && slot.expanded).forEach((slot) => this.redrawChannelSlider(slot));
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
redrawChannelSlider(slot) {
|
|
618
|
+
if (!slot || !slot.name) return;
|
|
619
|
+
this.updateSlotReadout(slot);
|
|
620
|
+
if (!slot.expanded) {
|
|
621
|
+
slot.sliderDirty = true;
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
slot.sliderDirty = false;
|
|
625
|
+
const target = this.el(`channel_slot_slider_${slot.index}`);
|
|
626
|
+
if (!target) return;
|
|
627
|
+
target.innerHTML = "";
|
|
628
|
+
const range = this.getImageRange(slot.name);
|
|
629
|
+
const width = Math.max(180, target.getBoundingClientRect().width - 16);
|
|
630
|
+
const slider = d3.sliderBottom(d3.scaleLog())
|
|
631
|
+
.min(Math.max(range[0], 1))
|
|
632
|
+
.max(Math.max(range[1], 2))
|
|
633
|
+
.width(width)
|
|
634
|
+
.ticks(0)
|
|
635
|
+
.tickValues([])
|
|
636
|
+
.default([Math.max(slot.range[0], 1), Math.max(slot.range[1], 2)])
|
|
637
|
+
.fill("#38bdf8")
|
|
638
|
+
.handle(d3.symbol().type(d3.symbolCircle).size(120))
|
|
639
|
+
.on("onchange", (value) => this.setSlotRange(slot.index, value, true))
|
|
640
|
+
.on("end", () => this.scheduleSaveChannels());
|
|
641
|
+
|
|
642
|
+
this.channelSlotSliders.set(slot.index, slider);
|
|
643
|
+
d3.select(target)
|
|
644
|
+
.append("svg")
|
|
645
|
+
.attr("width", width + 16)
|
|
646
|
+
.attr("height", 44)
|
|
647
|
+
.append("g")
|
|
648
|
+
.attr("transform", "translate(8,18)")
|
|
649
|
+
.call(slider);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
toggleSlotExpanded(slotIndex) {
|
|
653
|
+
const slot = this.channelSlots[slotIndex];
|
|
654
|
+
if (!slot) return;
|
|
655
|
+
slot.expanded = !slot.expanded;
|
|
656
|
+
this.applySlotExpansion(slot);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
applySlotExpansion(slot) {
|
|
660
|
+
const row = this.q(`.channel-slot[data-slot="${slot.index}"]`);
|
|
661
|
+
if (!row) return;
|
|
662
|
+
const detail = row.querySelector(".channel-slot-detail");
|
|
663
|
+
const toggle = row.querySelector(".channel-slot-expand-toggle");
|
|
664
|
+
if (detail) detail.classList.toggle("is-expanded", Boolean(slot.expanded));
|
|
665
|
+
if (toggle) toggle.classList.toggle("is-expanded", Boolean(slot.expanded));
|
|
666
|
+
if (slot.expanded && (slot.sliderDirty || !this.channelSlotSliders.has(slot.index))) {
|
|
667
|
+
this.redrawChannelSlider(slot);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
describeMarkerOption(name, currentSlotIndex) {
|
|
672
|
+
const activeElsewhere = this.channelSlots.some(
|
|
673
|
+
(slot) => slot.index !== currentSlotIndex && slot.enabled && slot.name === name
|
|
674
|
+
);
|
|
675
|
+
return activeElsewhere ? "active elsewhere" : "";
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
setSlotRange(slotIndex, values, userChanged = false) {
|
|
679
|
+
const slot = this.channelSlots[slotIndex];
|
|
680
|
+
if (!slot) return;
|
|
681
|
+
slot.range = this.normalizeRange(values, true);
|
|
682
|
+
if (userChanged) {
|
|
683
|
+
slot.userRangeChanged = true;
|
|
684
|
+
if (slot.name) {
|
|
685
|
+
this.markerRangeOverrides.set(slot.name, [...slot.range]);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
this.channelList.image_channels[slot.name] = slot.range;
|
|
689
|
+
this.updateSlotReadout(slot);
|
|
690
|
+
if (slot.enabled && slot.name) {
|
|
691
|
+
this.eventHandler.trigger(ChannelList.events.BRUSH_MOVE, {
|
|
692
|
+
name: slot.name,
|
|
693
|
+
dataRange: [...slot.range],
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
autoLevelChannelIfNeeded(slot) {
|
|
699
|
+
if (!slot || slot.autoLeveled || slot.autoLeveling || slot.userRangeChanged) {
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
window.setTimeout(() => this.autoChannel(slot.index), 0);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* @function applyAutoRange
|
|
707
|
+
* Push an auto-derived [vmin, vmax] (raw 16-bit units) onto a slot,
|
|
708
|
+
* converting into the slider's active domain. Shared by the provisional
|
|
709
|
+
* and final passes of autoChannel so they cannot drift apart.
|
|
710
|
+
*/
|
|
711
|
+
applyAutoRange(slotIndex, slot, rawRange, packet) {
|
|
712
|
+
slot.range = this.isHdMode() ? rawRange : this.rawToByteRange(rawRange, packet);
|
|
713
|
+
const slider = this.channelSlotSliders.get(slotIndex);
|
|
714
|
+
if (slider) {
|
|
715
|
+
slider.silentValue(slot.range);
|
|
716
|
+
}
|
|
717
|
+
this.setSlotRange(slotIndex, slot.range, false);
|
|
718
|
+
this.redrawChannelSlider(slot);
|
|
719
|
+
// setSlotRange(..., false) deliberately doesn't autosave (auto-leveling isn't a
|
|
720
|
+
// user edit) -- but persistChannelList's raw-unit conversion (toRawRangeForSlot)
|
|
721
|
+
// needs the channel's quantization window, and the very first activation of a
|
|
722
|
+
// channel autosaves (via setSlotEnabled/setSlotMarker's own scheduleSaveChannels
|
|
723
|
+
// call, 400ms after activation) before the fetch that supplies it can finish.
|
|
724
|
+
// Without this, that early save silently persists the still-default byte-domain
|
|
725
|
+
// slot.range mislabeled as raw units, which then gets reinterpreted as raw on
|
|
726
|
+
// every future restore and rescaled into a near-zero sliver of the real range.
|
|
727
|
+
// Re-scheduling a save now that the window exists corrects it.
|
|
728
|
+
this.scheduleSaveChannels();
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* @function autoChannel
|
|
733
|
+
* Auto-level a slot in two passes, because the good answer is slow.
|
|
734
|
+
*
|
|
735
|
+
* The authoritative window is get_channel_gmm's vmin/vmax -- a
|
|
736
|
+
* GaussianMixture(3) fit costing 0.2-1.9 s per channel. A new slot starts
|
|
737
|
+
* at [0, 255], the whole byte domain, which against a quantization ceiling
|
|
738
|
+
* of the channel's full-plane max renders the tissue near-black. So until
|
|
739
|
+
* the fit landed the channel was drawn but invisible: measured 6.6 s on a
|
|
740
|
+
* freshly opened project before anything appeared on screen.
|
|
741
|
+
*
|
|
742
|
+
* Pass 1 applies vmin_hint/vmax_hint from get_image_channel_stats (a few
|
|
743
|
+
* ms, percentiles of the same log-intensity distribution; worst case 15
|
|
744
|
+
* byte-levels off the GMM across the reference slide's 19 channels, versus
|
|
745
|
+
* ~234 for the [0, 255] default). Pass 2 replaces it with the real fit.
|
|
746
|
+
*
|
|
747
|
+
* Both passes re-check userRangeChanged, so a user who grabs the slider
|
|
748
|
+
* mid-flight is never overridden by a late-arriving fit.
|
|
749
|
+
*/
|
|
750
|
+
async autoChannel(slotIndex, options = {}) {
|
|
751
|
+
const slot = this.channelSlots[slotIndex];
|
|
752
|
+
if (!slot || !slot.name) return;
|
|
753
|
+
if (!options.force && (slot.userRangeChanged || slot.autoLeveled || slot.autoLeveling)) {
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
const markerName = slot.name;
|
|
757
|
+
const stillCurrent = () => slot.name === markerName &&
|
|
758
|
+
(options.force || !slot.userRangeChanged);
|
|
759
|
+
slot.autoLeveling = true;
|
|
760
|
+
try {
|
|
761
|
+
// Pass 1 -- provisional, so the channel is visible right away.
|
|
762
|
+
await this.channelList.ensureChannelStats(markerName);
|
|
763
|
+
if (!stillCurrent()) return;
|
|
764
|
+
const stats = this.quantWindow(markerName);
|
|
765
|
+
if (stats && stats.vmin_hint !== undefined) {
|
|
766
|
+
this.applyAutoRange(slotIndex, slot, [stats.vmin_hint, stats.vmax_hint], stats);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Pass 2 -- the real fit. Slow and produces no other visible sign
|
|
770
|
+
// that anything is happening, so it reports to the status indicator.
|
|
771
|
+
if (!(markerName in this.channelList.hasChannelGMM)) {
|
|
772
|
+
const task = window.PlexoraStatus?.begin("Auto-contrast");
|
|
773
|
+
try {
|
|
774
|
+
await this.channelList.getAndDrawChannelGMM(markerName);
|
|
775
|
+
} finally {
|
|
776
|
+
task?.done();
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
if (!stillCurrent()) return;
|
|
780
|
+
const packet = this.channelList.hasChannelGMM[markerName];
|
|
781
|
+
if (!packet) return;
|
|
782
|
+
this.applyAutoRange(slotIndex, slot, [packet.vmin, packet.vmax],
|
|
783
|
+
this.quantWindow(markerName) || packet);
|
|
784
|
+
slot.autoLeveled = true;
|
|
785
|
+
} finally {
|
|
786
|
+
slot.autoLeveling = false;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
addFirstAvailableChannel() {
|
|
791
|
+
const emptySlot = this.channelSlots.find((slot) => !slot.visible);
|
|
792
|
+
const activeNames = this.channelSlots.filter((slot) => slot.enabled).map((slot) => slot.name);
|
|
793
|
+
const usedNames = this.channelSlots.filter((slot) => slot.name).map((slot) => slot.name);
|
|
794
|
+
const slot = emptySlot || this.createAdditionalSlot(usedNames);
|
|
795
|
+
if (!slot) return;
|
|
796
|
+
const next = slot.name && !activeNames.includes(slot.name)
|
|
797
|
+
? slot.name
|
|
798
|
+
: this.columns.find((name) => !usedNames.includes(name)) || this.columns.find((name) => !activeNames.includes(name));
|
|
799
|
+
if (next) {
|
|
800
|
+
this.setSlotMarker(slot.index, next, { keepColor: true, enable: false, reveal: true });
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
createAdditionalSlot(usedNames) {
|
|
805
|
+
if (this.channelSlots.length >= this.maxChannelSlots) return null;
|
|
806
|
+
const slotIndex = this.channelSlots.length;
|
|
807
|
+
const color = this.getDefaultColor(slotIndex);
|
|
808
|
+
const name = this.columns.find((column) => !usedNames.includes(column)) || "";
|
|
809
|
+
const slot = {
|
|
810
|
+
index: slotIndex,
|
|
811
|
+
name,
|
|
812
|
+
color: color.rgb,
|
|
813
|
+
colorHex: color.hex,
|
|
814
|
+
enabled: false,
|
|
815
|
+
visible: true,
|
|
816
|
+
expanded: false,
|
|
817
|
+
sliderDirty: false,
|
|
818
|
+
range: this.getImageRange(name),
|
|
819
|
+
userColorChanged: false,
|
|
820
|
+
userRangeChanged: false,
|
|
821
|
+
autoLeveled: false,
|
|
822
|
+
autoLeveling: false,
|
|
823
|
+
};
|
|
824
|
+
this.channelSlots.push(slot);
|
|
825
|
+
this.el("channel_slot_list")?.appendChild(this.createChannelSlot(slot));
|
|
826
|
+
return slot;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
removeChannelSlot(slotIndex) {
|
|
830
|
+
const slot = this.channelSlots[slotIndex];
|
|
831
|
+
if (!slot) return;
|
|
832
|
+
if (slot.enabled && slot.name) {
|
|
833
|
+
this.deactivateChannel(slot);
|
|
834
|
+
}
|
|
835
|
+
const color = this.getDefaultColor(slotIndex);
|
|
836
|
+
slot.name = "";
|
|
837
|
+
slot.range = this.getImageRange("");
|
|
838
|
+
slot.enabled = false;
|
|
839
|
+
slot.visible = false;
|
|
840
|
+
slot.expanded = false;
|
|
841
|
+
slot.sliderDirty = false;
|
|
842
|
+
slot.color = color.rgb;
|
|
843
|
+
slot.colorHex = color.hex;
|
|
844
|
+
slot.userColorChanged = false;
|
|
845
|
+
slot.userRangeChanged = false;
|
|
846
|
+
slot.autoLeveled = false;
|
|
847
|
+
slot.autoLeveling = false;
|
|
848
|
+
this.channelSlotSliders.delete(slotIndex);
|
|
849
|
+
this.syncSlotDom(slot);
|
|
850
|
+
this.applySlotExpansion(slot);
|
|
851
|
+
this.updateSelectedCount();
|
|
852
|
+
this.scheduleSaveChannels();
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
syncSlotDom(slot) {
|
|
856
|
+
const row = this.q(`.channel-slot[data-slot="${slot.index}"]`);
|
|
857
|
+
if (!row) return;
|
|
858
|
+
row.classList.toggle("is-hidden", !slot.visible);
|
|
859
|
+
row.classList.toggle("is-disabled", !slot.enabled);
|
|
860
|
+
row.style.setProperty("--slot-color", slot.colorHex);
|
|
861
|
+
const toggle = row.querySelector(".channel-toggle-switch");
|
|
862
|
+
if (toggle) toggle.checked = slot.enabled;
|
|
863
|
+
const colorPicker = this.colorPickers.get(slot.index);
|
|
864
|
+
if (colorPicker) colorPicker.setValue(slot.colorHex);
|
|
865
|
+
const markerSelect = this.markerSelects.get(slot.index);
|
|
866
|
+
if (markerSelect) markerSelect.setValue(slot.name);
|
|
867
|
+
this.updateSlotReadout(slot);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
async applySavedChannels(rows) {
|
|
871
|
+
const activeRows = rows.filter((row) => row && row.channel_active);
|
|
872
|
+
const slotList = this.el("channel_slot_list");
|
|
873
|
+
if (!slotList) return;
|
|
874
|
+
slotList.innerHTML = "";
|
|
875
|
+
this.channelSlots = [];
|
|
876
|
+
this.channelSlotSliders.clear();
|
|
877
|
+
this.colorPickers.clear();
|
|
878
|
+
this.markerSelects.clear();
|
|
879
|
+
|
|
880
|
+
const count = Math.min(Math.max(activeRows.length, this.initialChannelSlots), this.maxChannelSlots);
|
|
881
|
+
// Slots beyond the active rows aren't assigned by a saved row at all, but they should
|
|
882
|
+
// still show a marker (off) rather than sit empty, matching the pre-restore default look.
|
|
883
|
+
const usedNames = new Set(activeRows.slice(0, count).map((row) => row.channel));
|
|
884
|
+
const fallbackNames = this.columns.filter((name) => !usedNames.has(name));
|
|
885
|
+
let fallbackIdx = 0;
|
|
886
|
+
for (let i = 0; i < count; i++) {
|
|
887
|
+
const color = this.getDefaultColor(i);
|
|
888
|
+
const name = i < activeRows.length ? "" : (fallbackNames[fallbackIdx++] || "");
|
|
889
|
+
const slot = {
|
|
890
|
+
index: i,
|
|
891
|
+
name,
|
|
892
|
+
color: color.rgb,
|
|
893
|
+
colorHex: color.hex,
|
|
894
|
+
enabled: false,
|
|
895
|
+
visible: true,
|
|
896
|
+
expanded: false,
|
|
897
|
+
sliderDirty: false,
|
|
898
|
+
range: this.getImageRange(name),
|
|
899
|
+
userColorChanged: false,
|
|
900
|
+
userRangeChanged: false,
|
|
901
|
+
autoLeveled: false,
|
|
902
|
+
autoLeveling: false,
|
|
903
|
+
};
|
|
904
|
+
this.channelSlots.push(slot);
|
|
905
|
+
slotList.appendChild(this.createChannelSlot(slot));
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// Prefetch every restored channel's stats up front, in parallel. The loop
|
|
909
|
+
// below needs each channel's quantization window to convert its saved range
|
|
910
|
+
// out of raw 16-bit units, and awaiting that per iteration made restore
|
|
911
|
+
// strictly serial. It used to await get_channel_gmm here instead -- a ~1 s
|
|
912
|
+
// fit per channel, purely to read the two qmin/qmax fields riding along on
|
|
913
|
+
// the GMM packet -- which cost 5.8 s to restore 3 channels on a cold server.
|
|
914
|
+
// The saved range already IS the auto-level result; no fit is needed to
|
|
915
|
+
// redisplay it.
|
|
916
|
+
const restoredNames = activeRows.slice(0, count).map((row) => row.channel);
|
|
917
|
+
const restoreTask = window.PlexoraStatus?.begin("Restoring");
|
|
918
|
+
try {
|
|
919
|
+
await Promise.all(restoredNames.map((name) =>
|
|
920
|
+
this.channelList.ensureChannelStats(name).catch(() => {})));
|
|
921
|
+
} finally {
|
|
922
|
+
restoreTask?.done();
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
for (const [i, row] of activeRows.slice(0, count).entries()) {
|
|
926
|
+
const slot = this.channelSlots[i];
|
|
927
|
+
if (!slot) continue;
|
|
928
|
+
this.setSlotMarker(slot.index, row.channel, { keepColor: true, enable: true, force: true });
|
|
929
|
+
// setSlotMarker's markerChanged branch always resets userRangeChanged/autoLeveled
|
|
930
|
+
// to false and (since slot.enabled) synchronously schedules autoLevelChannelIfNeeded
|
|
931
|
+
// -> autoChannel via setTimeout(0). We're restoring an explicit saved range below,
|
|
932
|
+
// so shut that off now, before the loop's first await yields control -- setTimeout
|
|
933
|
+
// always fires after this synchronous turn, so autoChannel will see these flags and
|
|
934
|
+
// bail out instead of racing this loop's own GMM fetch/range restore (double-fetching
|
|
935
|
+
// get_channel_gmm/get_image_channel_stats and sometimes clobbering the restored range
|
|
936
|
+
// with an auto-leveled one once its own deferred fetch resolves).
|
|
937
|
+
slot.userRangeChanged = true;
|
|
938
|
+
slot.autoLeveled = true;
|
|
939
|
+
this.setSlotColor(slot.index, this.rgbToHex(row.r, row.g, row.b), true);
|
|
940
|
+
// row.start/row.end are always raw 16-bit units (see
|
|
941
|
+
// persistChannelList/toRawRangeForSlot) -- convert to the
|
|
942
|
+
// currently-active domain before assigning to slot.range.
|
|
943
|
+
let range = [row.start, row.end];
|
|
944
|
+
if (!this.isHdMode()) {
|
|
945
|
+
const packet = this.quantWindow(slot.name);
|
|
946
|
+
if (packet) {
|
|
947
|
+
range = this.rawToByteRange(range, packet);
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
this.setSlotRange(slot.index, range, true);
|
|
951
|
+
slot.expanded = false;
|
|
952
|
+
this.applySlotExpansion(slot);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// The GMM is only needed for the auto button's vmin/vmax and the
|
|
956
|
+
// distribution curves drawn under each slider -- neither blocks display,
|
|
957
|
+
// so fetch them after the channels are already on screen. Unawaited on
|
|
958
|
+
// purpose; failures are the fetch's own problem, not the restore's.
|
|
959
|
+
restoredNames.forEach((name) => {
|
|
960
|
+
if (!(name in this.channelList.hasChannelGMM)) {
|
|
961
|
+
this.channelList.getAndDrawChannelGMM(name).catch(() => {});
|
|
962
|
+
}
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
this.updateSelectedCount();
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
rgbToHex(r, g, b) {
|
|
969
|
+
const toHex = (value) => Math.max(0, Math.min(255, Math.round(value))).toString(16).padStart(2, "0");
|
|
970
|
+
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
persistChannelList() {
|
|
974
|
+
// A scoped instance is showing a figure panel's channels, not the
|
|
975
|
+
// project's, and writing them here would overwrite the project's saved
|
|
976
|
+
// list with somebody else's. It also has none of the main.js globals
|
|
977
|
+
// this method reads, so the guard is load-bearing rather than a policy.
|
|
978
|
+
if (!this.persist) return Promise.resolve(null);
|
|
979
|
+
const listChannels = {};
|
|
980
|
+
// Must cover every entry map_channels (imageChannelsIdx, all real image
|
|
981
|
+
// channels) can reference server-side -- NOT this.columns (gating markers
|
|
982
|
+
// only). A structural/counterstain channel like DNA is commonly a real
|
|
983
|
+
// image channel with no corresponding feature-table column (no histogram),
|
|
984
|
+
// so it's excluded from this.columns but still present in imageChannelsIdx;
|
|
985
|
+
// basing this loop on this.columns left it with no listChannels entry at
|
|
986
|
+
// all, which the server then KeyErrors on since map_channels expects one
|
|
987
|
+
// for every image channel. getImageRange(name) already resolves a DNA-like
|
|
988
|
+
// channel's real image_min/image_max fine -- it just was never called for it.
|
|
989
|
+
Object.values(imageChannelsIdx).forEach((name) => {
|
|
990
|
+
listChannels[name] = this.channelList.image_channels[name] || this.getRawImageRange(name);
|
|
991
|
+
});
|
|
992
|
+
const activeChannels = {};
|
|
993
|
+
const listColors = {};
|
|
994
|
+
const listRanges = {};
|
|
995
|
+
const bitMax = this.dataLayer.imageBitRange[1];
|
|
996
|
+
this.channelSlots.forEach((slot) => {
|
|
997
|
+
if (!slot.name || !slot.enabled) return;
|
|
998
|
+
const fullName = this.dataLayer.getFullChannelName(slot.name);
|
|
999
|
+
const idx = imageChannels[fullName];
|
|
1000
|
+
if (idx === undefined) return;
|
|
1001
|
+
// Persisted state must be domain-independent (always raw 16-bit
|
|
1002
|
+
// units) since it's read back across sessions and across mode
|
|
1003
|
+
// changes -- slot.range itself is byte-domain in default mode
|
|
1004
|
+
// (see toRawRangeForSlot).
|
|
1005
|
+
const rawRange = this.toRawRangeForSlot(slot);
|
|
1006
|
+
activeChannels[idx] = true;
|
|
1007
|
+
listColors[idx] = { color: { ...slot.color, opacity: 1 } };
|
|
1008
|
+
listRanges[idx] = [rawRange[0] / bitMax, rawRange[1] / bitMax];
|
|
1009
|
+
listChannels[slot.name] = rawRange;
|
|
1010
|
+
});
|
|
1011
|
+
return this.dataLayer.saveChannelList(imageChannelsIdx, activeChannels, listColors, listRanges, listChannels);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* Stop this project's channel list being written while somebody else is
|
|
1016
|
+
* driving the sidebar.
|
|
1017
|
+
*
|
|
1018
|
+
* `_restoring` already covers the sidebar's own startup restore. This is the
|
|
1019
|
+
* same guard for a caller OUTSIDE it: Figure Builder loads a captured
|
|
1020
|
+
* panel's channels into the live viewer through the ordinary setters, and
|
|
1021
|
+
* those setters schedule an autosave -- so without this, looking at a
|
|
1022
|
+
* figure panel would overwrite the project's own saved channels with the
|
|
1023
|
+
* figure's, permanently, with nothing on screen to say so.
|
|
1024
|
+
*
|
|
1025
|
+
* A counter rather than a flag: the restore and a plugin's session can
|
|
1026
|
+
* overlap, and the one that finishes first must not re-enable saving for
|
|
1027
|
+
* the one still running.
|
|
1028
|
+
*/
|
|
1029
|
+
suspendPersistence() {
|
|
1030
|
+
this._persistenceSuspended = (this._persistenceSuspended || 0) + 1;
|
|
1031
|
+
window.clearTimeout(this._saveChannelsTimer);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
resumePersistence() {
|
|
1035
|
+
this._persistenceSuspended = Math.max(0, (this._persistenceSuspended || 0) - 1);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
scheduleSaveChannels() {
|
|
1039
|
+
if (this._restoring || this._persistenceSuspended) return;
|
|
1040
|
+
window.clearTimeout(this._saveChannelsTimer);
|
|
1041
|
+
this._saveChannelsTimer = window.setTimeout(() => {
|
|
1042
|
+
// Chained (not just debounced): a slow/out-of-order response from an earlier
|
|
1043
|
+
// save could otherwise land after a newer one and silently overwrite it.
|
|
1044
|
+
this._channelSaveChain = (this._channelSaveChain || Promise.resolve()).then(() => this.persistChannelList());
|
|
1045
|
+
}, 400);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
updateSelectedCount() {
|
|
1049
|
+
const count = this.channelSlots.filter((slot) => slot.enabled && slot.name).length;
|
|
1050
|
+
const countElement = this.el("num-selected-channels");
|
|
1051
|
+
if (countElement) countElement.textContent = count;
|
|
1052
|
+
const addButton = this.el("add_channel_button");
|
|
1053
|
+
if (addButton) addButton.disabled = this.channelSlots.filter((slot) => slot.visible).length >= this.maxChannelSlots;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
updateSlotReadout(slot) {
|
|
1057
|
+
const min = this.el(`channel_slot_min_${slot.index}`);
|
|
1058
|
+
const max = this.el(`channel_slot_max_${slot.index}`);
|
|
1059
|
+
if (min) min.textContent = this.formatValue(slot.range[0]);
|
|
1060
|
+
if (max) max.textContent = this.formatValue(slot.range[1]);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// Raw 16-bit bounds for a channel, regardless of current mode -- the
|
|
1064
|
+
// stable representation used for persistence and as the HD-mode slider
|
|
1065
|
+
// domain. getImageRange() below is the mode-aware wrapper UI code should
|
|
1066
|
+
// normally call instead.
|
|
1067
|
+
getRawImageRange(name) {
|
|
1068
|
+
if (!name) return [0, 1];
|
|
1069
|
+
const fullName = this.dataLayer.getFullChannelName(name);
|
|
1070
|
+
const desc = this.databaseDescription[fullName] || {};
|
|
1071
|
+
return [desc.image_min || this.dataLayer.imageBitRange[0] || 0, desc.image_max || this.dataLayer.imageBitRange[1] || 65536];
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
getImageRange(name) {
|
|
1075
|
+
if (!name) return [0, 1];
|
|
1076
|
+
if (!this.isHdMode()) {
|
|
1077
|
+
// Default mode: the slider works directly in the same [0, 255]
|
|
1078
|
+
// byte domain the server quantized into -- see rawToByteRange.
|
|
1079
|
+
return [0, 255];
|
|
1080
|
+
}
|
|
1081
|
+
return this.getRawImageRange(name);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
toImageConnectorRange(values) {
|
|
1085
|
+
if (!this.isHdMode()) {
|
|
1086
|
+
return [values[0] / 255, values[1] / 255];
|
|
1087
|
+
}
|
|
1088
|
+
const defaultRange = this.dataLayer.imageBitRange;
|
|
1089
|
+
return [values[0] / defaultRange[1], values[1] / defaultRange[1]];
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
normalizeRange(values, keepFloat) {
|
|
1093
|
+
const sorted = [...values].map((value) => parseFloat(value)).sort((a, b) => a - b);
|
|
1094
|
+
if (keepFloat) {
|
|
1095
|
+
return sorted;
|
|
1096
|
+
}
|
|
1097
|
+
return [Math.floor(sorted[0]), Math.ceil(sorted[1])];
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
formatValue(value) {
|
|
1101
|
+
return Number.parseFloat(value || 0).toFixed(2);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
hexToRgb(hex) {
|
|
1105
|
+
const cleaned = hex.replace("#", "");
|
|
1106
|
+
const value = parseInt(cleaned, 16);
|
|
1107
|
+
return {
|
|
1108
|
+
r: (value >> 16) & 255,
|
|
1109
|
+
g: (value >> 8) & 255,
|
|
1110
|
+
b: value & 255,
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
getDefaultColor(slotIndex) {
|
|
1115
|
+
return this.defaultColors[slotIndex % this.defaultColors.length];
|
|
1116
|
+
}
|
|
1117
|
+
}
|