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,822 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cellExplorerRoiBridge.js - what is inside that region?
|
|
3
|
+
*
|
|
4
|
+
* An ROI on its own is a shape. The question anyone drawing one is actually
|
|
5
|
+
* asking is what the cells inside it are, and until now answering it meant
|
|
6
|
+
* exporting the region and counting somewhere else. This joins the two plugins
|
|
7
|
+
* that already hold both halves of the answer.
|
|
8
|
+
*
|
|
9
|
+
* ## The split, which this file does not cross
|
|
10
|
+
*
|
|
11
|
+
* ROI owns geometry. It says which region the pointer is over and
|
|
12
|
+
* where that region is on the screen, through the
|
|
13
|
+
* `plexora:roi-hover` / `plexora:roi-unhover` events, and it
|
|
14
|
+
* knows nothing about metadata.
|
|
15
|
+
* Cell Explorer owns the variable, its categories and their colours. It has
|
|
16
|
+
* no idea what an ROI is.
|
|
17
|
+
* this file is the only thing that knows both, and it is a LISTENER --
|
|
18
|
+
* it never reaches into RoiStore, never edits a shape, and
|
|
19
|
+
* never asks the server for anything of its own.
|
|
20
|
+
*
|
|
21
|
+
* Written this way so the ROI plugin stays usable without Cell Explorer
|
|
22
|
+
* installed and vice versa: with no listener the events fall on the floor, and
|
|
23
|
+
* with no ROI plugin loaded nothing ever fires.
|
|
24
|
+
*
|
|
25
|
+
* ## Why hovering is cheap
|
|
26
|
+
*
|
|
27
|
+
* Every cell's centre is fetched ONCE per session, from core's own
|
|
28
|
+
* `NumericData.loadCells()` -- the same request the viewer already makes -- and
|
|
29
|
+
* indexed into a coarse grid. A hover then tests only the cells in the buckets
|
|
30
|
+
* the region's bounding box touches, so the cost follows the size of the region
|
|
31
|
+
* rather than the size of the slide.
|
|
32
|
+
*
|
|
33
|
+
* Two caches, deliberately separate, because they go stale for different
|
|
34
|
+
* reasons: WHICH cells are in a region depends only on geometry, and WHAT they
|
|
35
|
+
* are depends only on the column. Switching cell_type -> phenotype therefore
|
|
36
|
+
* re-tallies against cells already known to be inside, without recomputing
|
|
37
|
+
* membership and without the region being touched at all.
|
|
38
|
+
*/
|
|
39
|
+
class CellExplorerRoiBridge {
|
|
40
|
+
|
|
41
|
+
//: Bars before the tail is folded into `Other`. Nothing is folded away to
|
|
42
|
+
//: reveal a single row -- five plus an `Other` standing for one category
|
|
43
|
+
//: would be a worse card than six real ones, so the fold needs two.
|
|
44
|
+
static TOP_CATEGORIES = 5;
|
|
45
|
+
|
|
46
|
+
//: How long the card survives the pointer leaving the region, so it can be
|
|
47
|
+
//: walked into and its bars inspected.
|
|
48
|
+
static GRACE_MS = 180;
|
|
49
|
+
|
|
50
|
+
//: Distance from the region's bounding box, and from the edge of the image.
|
|
51
|
+
static ANCHOR_GAP = 14;
|
|
52
|
+
static EDGE_PAD = 8;
|
|
53
|
+
|
|
54
|
+
//: Past this share of the track there is no room outside the bar for its
|
|
55
|
+
//: own number, so the number moves inside it.
|
|
56
|
+
static COUNT_INSIDE_PERCENT = 78;
|
|
57
|
+
|
|
58
|
+
//: Regions whose membership is worth keeping. A handful, because the point
|
|
59
|
+
//: is re-hovering the same few shapes, not holding a slide's worth.
|
|
60
|
+
static MEMBER_CACHE = 16;
|
|
61
|
+
|
|
62
|
+
//: `Other` is not a category and must not look like one, so it takes a
|
|
63
|
+
//: neutral of its own rather than the next palette colour.
|
|
64
|
+
static OTHER_COLOR = "#79839a";
|
|
65
|
+
|
|
66
|
+
constructor(ctx, state) {
|
|
67
|
+
this.ctx = ctx;
|
|
68
|
+
this.state = state;
|
|
69
|
+
this.viewer = ctx.viewer?.viewer || null;
|
|
70
|
+
|
|
71
|
+
//: The `plexora:roi-hover` detail the card is currently describing.
|
|
72
|
+
this.hover = null;
|
|
73
|
+
this.card = null;
|
|
74
|
+
this.expanded = false;
|
|
75
|
+
this._hideTimer = null;
|
|
76
|
+
//: Bumped on every hover and on close, so a summary whose position
|
|
77
|
+
//: lookup was still in flight cannot paint over a later one.
|
|
78
|
+
this._token = 0;
|
|
79
|
+
|
|
80
|
+
this._positions = null;
|
|
81
|
+
this._positionsPromise = null;
|
|
82
|
+
//: roiId -> {geometry, indices}, insertion-ordered as an LRU.
|
|
83
|
+
this._members = new Map();
|
|
84
|
+
//: {source, table} -- cell id -> code for one `state.data` object.
|
|
85
|
+
this._lookup = null;
|
|
86
|
+
//: Columns already reported for a poor id overlap; see warnOnOverlap.
|
|
87
|
+
this._warned = new Set();
|
|
88
|
+
|
|
89
|
+
this._onHover = (event) => this.hoverEntered(event.detail);
|
|
90
|
+
this._onUnhover = (event) => this.hoverLeft(event.detail);
|
|
91
|
+
|
|
92
|
+
window.addEventListener("plexora:roi-hover", this._onHover);
|
|
93
|
+
window.addEventListener("plexora:roi-unhover", this._onUnhover);
|
|
94
|
+
// Nothing is listened for on the viewer here. An anchor taken in client
|
|
95
|
+
// pixels does go stale the moment the picture moves, but ROI owns where
|
|
96
|
+
// its shapes are and re-announces the hover with a fresh anchor while
|
|
97
|
+
// one is standing -- so the card follows the region through a pan
|
|
98
|
+
// instead of being closed by it. Closing was worse than it sounds: with
|
|
99
|
+
// the pointer sitting still afterwards, no further enter was ever
|
|
100
|
+
// dispatched, and the card could not be brought back without leaving the
|
|
101
|
+
// region and coming back to it.
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
destroy() {
|
|
105
|
+
window.removeEventListener("plexora:roi-hover", this._onHover);
|
|
106
|
+
window.removeEventListener("plexora:roi-unhover", this._onUnhover);
|
|
107
|
+
this.close();
|
|
108
|
+
// On <body>, so it outlives this panel's markup and has to be handed
|
|
109
|
+
// back explicitly -- the same reason the legend and the variable picker
|
|
110
|
+
// are torn down by name in the controller's destroy().
|
|
111
|
+
this.card?.remove();
|
|
112
|
+
this.card = null;
|
|
113
|
+
this._members.clear();
|
|
114
|
+
this._lookup = null;
|
|
115
|
+
this._positions = null;
|
|
116
|
+
this._positionsPromise = null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// -- hover ---------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The pointer has entered a region -- or the picture has moved under one it
|
|
123
|
+
* was already in.
|
|
124
|
+
*
|
|
125
|
+
* Deliberately not `async`. The overwhelmingly common case is a second and
|
|
126
|
+
* later hover, where every input is already in memory, and awaiting an
|
|
127
|
+
* already-resolved promise there would still put the card a frame behind the
|
|
128
|
+
* pointer. Only the first hover of a session has anything to wait for, and
|
|
129
|
+
* it says so on the card rather than appearing to have ignored the pointer.
|
|
130
|
+
*/
|
|
131
|
+
hoverEntered(detail) {
|
|
132
|
+
if (!detail || !detail.id) return;
|
|
133
|
+
this.cancelHide();
|
|
134
|
+
|
|
135
|
+
// Panning or zooming with the pointer at rest inside a region: ROI
|
|
136
|
+
// re-announces the same shape with a fresh anchor, and all that has to
|
|
137
|
+
// happen is the card following it. Re-summarising would rebuild an
|
|
138
|
+
// identical card once per frame for the length of the gesture.
|
|
139
|
+
if (this.isSameHover(detail) && this.card && !this.card.hidden) {
|
|
140
|
+
this.hover = detail;
|
|
141
|
+
this.place(detail.anchorRect, detail.viewportRect);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
this.hover = detail;
|
|
146
|
+
this.expanded = false;
|
|
147
|
+
const token = ++this._token;
|
|
148
|
+
if (!this.canSummarise()) {
|
|
149
|
+
this.close();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (this._positions) {
|
|
154
|
+
this.paint(detail, this._positions);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// First hover of the session, with every cell's position still on its
|
|
159
|
+
// way. The card opens now, saying what it is doing: a hover that appears
|
|
160
|
+
// to do nothing for a second reads as a hover that was missed, and the
|
|
161
|
+
// pointer moves on before the answer arrives.
|
|
162
|
+
this.renderPending(detail);
|
|
163
|
+
this.place(detail.anchorRect, detail.viewportRect);
|
|
164
|
+
this.positions().then((positions) => {
|
|
165
|
+
// Everything can have changed across that wait: the pointer may have
|
|
166
|
+
// left, moved to another region, or the user may have switched to a
|
|
167
|
+
// continuous column.
|
|
168
|
+
if (token !== this._token || this.hover !== detail || !positions) return;
|
|
169
|
+
if (!this.canSummarise()) {
|
|
170
|
+
this.close();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
this.paint(detail, positions);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The same shape, unchanged, as the card is already describing. Name as
|
|
178
|
+
* well as geometry, so a rename still redraws the header. */
|
|
179
|
+
isSameHover(detail) {
|
|
180
|
+
const current = this.hover;
|
|
181
|
+
return Boolean(current && current.id === detail.id
|
|
182
|
+
&& current.geometry === detail.geometry
|
|
183
|
+
&& current.name === detail.name);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
paint(detail, positions) {
|
|
187
|
+
const summary = this.summarise(detail, positions);
|
|
188
|
+
if (!summary) {
|
|
189
|
+
this.close();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
this.render(summary);
|
|
193
|
+
this.place(detail.anchorRect, detail.viewportRect);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Fetch the positions before anything is hovering.
|
|
198
|
+
*
|
|
199
|
+
* Called when the user asks for the ROI tool, which is the earliest honest
|
|
200
|
+
* signal that regions are about to be drawn. On a slide with a million cells
|
|
201
|
+
* the request behind this takes long enough to be the difference between a
|
|
202
|
+
* card that appears with the pointer and one that appears after it.
|
|
203
|
+
*/
|
|
204
|
+
warm() {
|
|
205
|
+
this.positions();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
hoverLeft(detail) {
|
|
209
|
+
if (!this.hover) return;
|
|
210
|
+
// Ignored when it names a region the card is not describing -- ROI
|
|
211
|
+
// reports the leave before the enter when the pointer crosses straight
|
|
212
|
+
// from one shape into another.
|
|
213
|
+
if (detail?.id && detail.id !== this.hover.id) return;
|
|
214
|
+
this.cancelHide();
|
|
215
|
+
// Not closed outright: the pointer has to be able to travel off the
|
|
216
|
+
// region and onto the card to read a bar. Entering the card cancels
|
|
217
|
+
// this; see ensureCard.
|
|
218
|
+
this._hideTimer = setTimeout(() => this.close(), CellExplorerRoiBridge.GRACE_MS);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
cancelHide() {
|
|
222
|
+
if (this._hideTimer) clearTimeout(this._hideTimer);
|
|
223
|
+
this._hideTimer = null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
close() {
|
|
227
|
+
this.cancelHide();
|
|
228
|
+
this._token += 1;
|
|
229
|
+
this.hover = null;
|
|
230
|
+
this.expanded = false;
|
|
231
|
+
if (this.card) this.card.hidden = true;
|
|
232
|
+
this.hideTooltip();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The active variable changed under an open card.
|
|
237
|
+
*
|
|
238
|
+
* Only the tally is stale. Membership is a fact about geometry and the
|
|
239
|
+
* geometry has not moved, so this recomputes in place -- which is what lets
|
|
240
|
+
* switching cell_type -> phenotype change the answer without the region
|
|
241
|
+
* being redrawn, reloaded or recreated.
|
|
242
|
+
*/
|
|
243
|
+
refreshOpenCard() {
|
|
244
|
+
if (!this.hover || !this.card || this.card.hidden) return;
|
|
245
|
+
if (!this.canSummarise()) {
|
|
246
|
+
this.close();
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (!this._positions) return;
|
|
250
|
+
const summary = this.summarise(this.hover, this._positions);
|
|
251
|
+
if (!summary) return;
|
|
252
|
+
this.render(summary);
|
|
253
|
+
// Re-clamped rather than re-anchored: hiding categories takes bars off
|
|
254
|
+
// the card, and one that had been pushed up to fit can sit where it
|
|
255
|
+
// belongs again.
|
|
256
|
+
this.place(this.hover.anchorRect, this.hover.viewportRect);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Categorical only.
|
|
261
|
+
*
|
|
262
|
+
* A continuous variable has no categories to be a composition OF, and a
|
|
263
|
+
* mean or a histogram is a different question nobody asked by hovering.
|
|
264
|
+
* The region stays drawn, hoverable and editable; only the card is
|
|
265
|
+
* withheld, until a categorical column is chosen.
|
|
266
|
+
*/
|
|
267
|
+
canSummarise() {
|
|
268
|
+
const column = this.state.column;
|
|
269
|
+
if (!column || this.state.kindFor(column) !== "categorical") return false;
|
|
270
|
+
const data = this.state.data;
|
|
271
|
+
return Boolean(data && data.kind === "categorical" && data.ids && data.ids.length);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// -- cell positions ------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
positions() {
|
|
277
|
+
if (this._positions) return Promise.resolve(this._positions);
|
|
278
|
+
if (!this._positionsPromise) {
|
|
279
|
+
this._positionsPromise = this.loadPositions()
|
|
280
|
+
.then((index) => {
|
|
281
|
+
this._positions = index;
|
|
282
|
+
return index;
|
|
283
|
+
})
|
|
284
|
+
.catch((error) => {
|
|
285
|
+
console.error("Cell Explorer: could not load cell positions "
|
|
286
|
+
+ "for region summaries", error);
|
|
287
|
+
// Cleared, so a transient failure does not disable the
|
|
288
|
+
// feature for the rest of the session.
|
|
289
|
+
this._positionsPromise = null;
|
|
290
|
+
return null;
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
return this._positionsPromise;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Every cell's centre, once per session.
|
|
298
|
+
*
|
|
299
|
+
* Through core's own NumericData, which already memoises this request and
|
|
300
|
+
* already answers it whether the project draws tiled labels or legacy
|
|
301
|
+
* centroids -- the alternative was a second endpoint returning the same
|
|
302
|
+
* three columns to the same browser.
|
|
303
|
+
*
|
|
304
|
+
* These coordinates are RAW full-resolution image pixels, which is exactly
|
|
305
|
+
* the space ROI geometry is stored in, so they are used unscaled. The
|
|
306
|
+
* `* 2 ** extraZoomLevels` in core's legacy centroid cache belongs to the
|
|
307
|
+
* OSD-inflated overlay that cache feeds, and applying it here would put
|
|
308
|
+
* every cell in the wrong place.
|
|
309
|
+
*/
|
|
310
|
+
async loadPositions() {
|
|
311
|
+
const source = this.ctx.viewer?.numericData;
|
|
312
|
+
if (!source?.loadCells) return null;
|
|
313
|
+
const { ids, centers } = await source.loadCells();
|
|
314
|
+
if (!ids?.length || !centers) return null;
|
|
315
|
+
return CellExplorerRoiBridge.buildIndex(ids, centers, this.bucketSpan());
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
bucketSpan() {
|
|
319
|
+
const width = Number(this.ctx.config?.tileWidth);
|
|
320
|
+
return Number.isFinite(width) && width > 0 ? width : 512;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* A coarse grid over the cells, so a hover costs the region's area rather
|
|
325
|
+
* than the slide's cell count.
|
|
326
|
+
*
|
|
327
|
+
* Without it every hover is a point-in-polygon test per cell -- a million
|
|
328
|
+
* of them to describe a region holding four hundred, on every shape the
|
|
329
|
+
* pointer crosses. Buckets are keyed by one number rather than an "x|y"
|
|
330
|
+
* string because building a million strings is the expensive half of
|
|
331
|
+
* indexing a million cells.
|
|
332
|
+
*/
|
|
333
|
+
static buildIndex(ids, centers, span) {
|
|
334
|
+
let maxX = 0;
|
|
335
|
+
let maxY = 0;
|
|
336
|
+
for (let i = 0, p = 0; i < ids.length; i += 1, p += 2) {
|
|
337
|
+
if (centers[p] > maxX) maxX = centers[p];
|
|
338
|
+
if (centers[p + 1] > maxY) maxY = centers[p + 1];
|
|
339
|
+
}
|
|
340
|
+
const columns = Math.floor(maxX / span) + 1;
|
|
341
|
+
const buckets = new Map();
|
|
342
|
+
for (let i = 0, p = 0; i < ids.length; i += 1, p += 2) {
|
|
343
|
+
const key = Math.floor(centers[p + 1] / span) * columns
|
|
344
|
+
+ Math.floor(centers[p] / span);
|
|
345
|
+
const bucket = buckets.get(key);
|
|
346
|
+
if (bucket) bucket.push(i);
|
|
347
|
+
else buckets.set(key, [i]);
|
|
348
|
+
}
|
|
349
|
+
return { ids, centers, span, columns, buckets };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// -- membership ----------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
/** Indices into the position arrays for the cells inside this shape. */
|
|
355
|
+
static membersIn(geometry, index) {
|
|
356
|
+
if (typeof RoiGeometry === "undefined" || !geometry || !index) return null;
|
|
357
|
+
const box = RoiGeometry.bounds(geometry);
|
|
358
|
+
if (!box) return [];
|
|
359
|
+
const { span, columns, buckets, centers } = index;
|
|
360
|
+
const firstColumn = Math.max(0, Math.floor(box.minX / span));
|
|
361
|
+
const lastColumn = Math.max(0, Math.floor(box.maxX / span));
|
|
362
|
+
const firstRow = Math.max(0, Math.floor(box.minY / span));
|
|
363
|
+
const lastRow = Math.max(0, Math.floor(box.maxY / span));
|
|
364
|
+
|
|
365
|
+
const found = [];
|
|
366
|
+
for (let row = firstRow; row <= lastRow; row += 1) {
|
|
367
|
+
for (let column = firstColumn; column <= lastColumn; column += 1) {
|
|
368
|
+
const bucket = buckets.get(row * columns + column);
|
|
369
|
+
if (!bucket) continue;
|
|
370
|
+
for (let b = 0; b < bucket.length; b += 1) {
|
|
371
|
+
const i = bucket[b];
|
|
372
|
+
const x = centers[i * 2];
|
|
373
|
+
const y = centers[i * 2 + 1];
|
|
374
|
+
// The bounding box first: four comparisons reject most of
|
|
375
|
+
// what a fringe bucket holds, and a ray cast over every
|
|
376
|
+
// ring is far more than four comparisons.
|
|
377
|
+
if (x < box.minX || x > box.maxX || y < box.minY || y > box.maxY) continue;
|
|
378
|
+
if (RoiGeometry.containsPoint(geometry, x, y)) found.push(i);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return found;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
members(detail, index) {
|
|
386
|
+
const cached = this._members.get(detail.id);
|
|
387
|
+
// Geometry objects are REPLACED on every edit and never mutated, so
|
|
388
|
+
// object identity alone says whether this is still the same shape --
|
|
389
|
+
// the same trick RoiRenderer.pathFor uses to cache its Path2D objects,
|
|
390
|
+
// and the reason a reshaped region recomputes for free.
|
|
391
|
+
if (cached && cached.geometry === detail.geometry) return cached.indices;
|
|
392
|
+
|
|
393
|
+
const indices = CellExplorerRoiBridge.membersIn(detail.geometry, index);
|
|
394
|
+
if (!indices) return null;
|
|
395
|
+
this._members.delete(detail.id);
|
|
396
|
+
this._members.set(detail.id, { geometry: detail.geometry, indices });
|
|
397
|
+
while (this._members.size > CellExplorerRoiBridge.MEMBER_CACHE) {
|
|
398
|
+
this._members.delete(this._members.keys().next().value);
|
|
399
|
+
}
|
|
400
|
+
return indices;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// -- composition ---------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* cell id -> category code for the column on screen, as a dense array.
|
|
407
|
+
*
|
|
408
|
+
* Dense rather than a Map because ids run from one to the cell count: four
|
|
409
|
+
* bytes each beats a hash entry each by an order of magnitude at a million
|
|
410
|
+
* cells, which is the same reasoning behind CellExplorerColors' own lookup
|
|
411
|
+
* table. -1 marks an id this column has no row for, which is NOT the same
|
|
412
|
+
* thing as Unassigned -- see tally().
|
|
413
|
+
*/
|
|
414
|
+
codeById() {
|
|
415
|
+
const data = this.state.data;
|
|
416
|
+
if (!data || data.kind !== "categorical") return null;
|
|
417
|
+
// Keyed on the payload object, which select() replaces wholesale for
|
|
418
|
+
// every column, so there is nothing to invalidate by hand.
|
|
419
|
+
if (this._lookup?.source === data) return this._lookup;
|
|
420
|
+
const { ids, codes } = data;
|
|
421
|
+
let maxId = 0;
|
|
422
|
+
for (let i = 0; i < ids.length; i += 1) {
|
|
423
|
+
if (ids[i] > maxId) maxId = ids[i];
|
|
424
|
+
}
|
|
425
|
+
const table = new Int32Array(maxId + 1).fill(-1);
|
|
426
|
+
for (let i = 0; i < ids.length; i += 1) table[ids[i]] = codes[i];
|
|
427
|
+
this._lookup = { source: data, table };
|
|
428
|
+
return this._lookup;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Count members by category code.
|
|
433
|
+
*
|
|
434
|
+
* Two kinds of absence, kept apart. An id this column has no row for is not
|
|
435
|
+
* counted at all -- it is not part of this variable's population, and
|
|
436
|
+
* putting it in the total would make every percentage on the card wrong. A
|
|
437
|
+
* code past the end of the dictionary IS a value, the missing one, and is
|
|
438
|
+
* counted as Unassigned. The same distinction CellExplorerColors draws when
|
|
439
|
+
* it decides which cells get the unassigned swatch.
|
|
440
|
+
*/
|
|
441
|
+
static tally(indices, cellIds, table, categoryCount) {
|
|
442
|
+
const counts = new Uint32Array(categoryCount);
|
|
443
|
+
let unassigned = 0;
|
|
444
|
+
let total = 0;
|
|
445
|
+
for (let n = 0; n < indices.length; n += 1) {
|
|
446
|
+
const id = cellIds[indices[n]];
|
|
447
|
+
const code = id < table.length ? table[id] : -1;
|
|
448
|
+
if (code < 0) continue;
|
|
449
|
+
total += 1;
|
|
450
|
+
if (code < categoryCount) counts[code] += 1;
|
|
451
|
+
else unassigned += 1;
|
|
452
|
+
}
|
|
453
|
+
return { counts, unassigned, total };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
summarise(detail, index) {
|
|
457
|
+
const lookup = this.codeById();
|
|
458
|
+
const indices = this.members(detail, index);
|
|
459
|
+
if (!lookup || !indices) return null;
|
|
460
|
+
|
|
461
|
+
const column = this.state.column;
|
|
462
|
+
const categories = this.state.descriptor(column)?.categories || [];
|
|
463
|
+
const tally = CellExplorerRoiBridge.tally(
|
|
464
|
+
indices, index.ids, lookup.table, categories.length);
|
|
465
|
+
// The id-overlap check is asked of the whole population, before the
|
|
466
|
+
// legend takes anything out of it: hiding four categories is not
|
|
467
|
+
// evidence that the two id spaces disagree.
|
|
468
|
+
this.warnOnOverlap(column, indices.length, tally.total);
|
|
469
|
+
|
|
470
|
+
const shown = this.rows(column, categories, tally);
|
|
471
|
+
// Summed from what is on the card rather than taken from the tally, so
|
|
472
|
+
// the header, the bars and their percentages all count one population.
|
|
473
|
+
// A total that included hidden categories would leave every bar shorter
|
|
474
|
+
// than the share of the picture it stands for.
|
|
475
|
+
let total = 0;
|
|
476
|
+
for (const row of shown.rows) total += row.count;
|
|
477
|
+
|
|
478
|
+
return {
|
|
479
|
+
name: detail.name || "Region",
|
|
480
|
+
column,
|
|
481
|
+
total,
|
|
482
|
+
hiddenCategories: shown.hidden,
|
|
483
|
+
rows: shown.rows,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* One row per VISIBLE category present in the region, carrying the exact
|
|
489
|
+
* colour Cell Explorer is drawing that category with right now.
|
|
490
|
+
*
|
|
491
|
+
* Colours are resolved fresh on every render rather than cached, in the same
|
|
492
|
+
* order the lookup table resolves them -- the user's override, else the
|
|
493
|
+
* palette colour for the category's position -- so recolouring a category
|
|
494
|
+
* from the legend shows up on the next render with nothing to invalidate.
|
|
495
|
+
*
|
|
496
|
+
* Hidden categories are left out entirely, counts and all. The legend's
|
|
497
|
+
* checkboxes are how somebody narrows the question they are asking of the
|
|
498
|
+
* slide: with the stroma hidden, the picture under the pointer is the
|
|
499
|
+
* immune cells, and a card that went on reporting the stroma would be
|
|
500
|
+
* describing something other than what is on screen. The count of what was
|
|
501
|
+
* dropped goes back with the rows so the card can say so rather than
|
|
502
|
+
* quietly shrink.
|
|
503
|
+
*
|
|
504
|
+
* @returns {{rows: Array, hidden: number}}
|
|
505
|
+
*/
|
|
506
|
+
rows(column, categories, tally) {
|
|
507
|
+
const overrides = this.state.categorical(column).colors || {};
|
|
508
|
+
const hiddenLabels = this.state.hiddenSet(column);
|
|
509
|
+
const rows = [];
|
|
510
|
+
let hidden = 0;
|
|
511
|
+
for (let i = 0; i < categories.length; i += 1) {
|
|
512
|
+
const count = tally.counts[i];
|
|
513
|
+
if (!count) continue;
|
|
514
|
+
const label = categories[i].value;
|
|
515
|
+
if (hiddenLabels.has(label)) {
|
|
516
|
+
hidden += 1;
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
rows.push({
|
|
520
|
+
label,
|
|
521
|
+
count,
|
|
522
|
+
color: overrides[label] || CellExplorerColors.defaultCategoryColor(i),
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
if (tally.unassigned) {
|
|
526
|
+
const label = CellExplorerColors.UNASSIGNED_LABEL;
|
|
527
|
+
// Unassigned is a row of the legend like any other and can be
|
|
528
|
+
// hidden like any other -- see state.allLabels.
|
|
529
|
+
if (hiddenLabels.has(label)) {
|
|
530
|
+
hidden += 1;
|
|
531
|
+
} else {
|
|
532
|
+
rows.push({
|
|
533
|
+
label,
|
|
534
|
+
count: tally.unassigned,
|
|
535
|
+
color: overrides[label] || CellExplorerColors.UNASSIGNED,
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return { rows, hidden };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Rank by abundance and fold the tail into one honest `Other`.
|
|
544
|
+
*
|
|
545
|
+
* Only categories actually present are ranked: a variable with forty labels
|
|
546
|
+
* of which six occur in this region should show six bars, not thirty-four
|
|
547
|
+
* empty ones. `Other` therefore appears only when the region really does
|
|
548
|
+
* hold more kinds of cell than the card is showing, and it carries the true
|
|
549
|
+
* combined count rather than a remainder inferred from the visible bars.
|
|
550
|
+
*/
|
|
551
|
+
static rankCategories(rows, topN = CellExplorerRoiBridge.TOP_CATEGORIES) {
|
|
552
|
+
const ranked = [...rows].sort(
|
|
553
|
+
(a, b) => b.count - a.count || String(a.label).localeCompare(String(b.label)));
|
|
554
|
+
if (ranked.length <= topN + 1) return { rows: ranked, folded: 0 };
|
|
555
|
+
|
|
556
|
+
const shown = ranked.slice(0, topN);
|
|
557
|
+
const rest = ranked.slice(topN);
|
|
558
|
+
let count = 0;
|
|
559
|
+
for (const row of rest) count += row.count;
|
|
560
|
+
shown.push({
|
|
561
|
+
label: "Other",
|
|
562
|
+
count,
|
|
563
|
+
color: CellExplorerRoiBridge.OTHER_COLOR,
|
|
564
|
+
other: true,
|
|
565
|
+
});
|
|
566
|
+
return { rows: shown, folded: rest.length };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** 987, 1.2k, 14.3k -- a count read at a glance beside a bar, not an exact
|
|
570
|
+
* figure. The exact one is in the bar's own tooltip. */
|
|
571
|
+
static formatCount(value) {
|
|
572
|
+
const n = Number(value) || 0;
|
|
573
|
+
if (n < 1000) return String(n);
|
|
574
|
+
if (n < 1e6) return `${CellExplorerRoiBridge.trimZero(n / 1000)}k`;
|
|
575
|
+
return `${CellExplorerRoiBridge.trimZero(n / 1e6)}m`;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
static trimZero(value) {
|
|
579
|
+
const text = value.toFixed(1);
|
|
580
|
+
return text.endsWith(".0") ? text.slice(0, -2) : text;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** The two id spaces -- core's /get_all_cells and this column's payload --
|
|
584
|
+
* come from one table and should agree. Said once per column when they do
|
|
585
|
+
* not, because counting a fraction of the cells looks exactly like a
|
|
586
|
+
* region that happens to be sparse. */
|
|
587
|
+
warnOnOverlap(column, candidates, matched) {
|
|
588
|
+
if (!candidates || this._warned.has(column)) return;
|
|
589
|
+
if (matched >= candidates * 0.5) return;
|
|
590
|
+
this._warned.add(column);
|
|
591
|
+
console.warn(`Cell Explorer: only ${matched} of ${candidates} cells inside `
|
|
592
|
+
+ `this region have a value for "${column}". The cell ids from `
|
|
593
|
+
+ "/get_all_cells and from the column payload may not agree.");
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// -- the card ------------------------------------------------------------
|
|
597
|
+
|
|
598
|
+
ensureCard() {
|
|
599
|
+
if (this.card) return this.card;
|
|
600
|
+
const card = document.createElement("div");
|
|
601
|
+
card.className = "cex-roi-card";
|
|
602
|
+
card.hidden = true;
|
|
603
|
+
|
|
604
|
+
// Entering the card is the other half of the grace period: the pointer
|
|
605
|
+
// has left the region by definition, and closing on that would make the
|
|
606
|
+
// bars impossible to reach.
|
|
607
|
+
card.addEventListener("mouseenter", () => this.cancelHide());
|
|
608
|
+
card.addEventListener("mouseleave", () => this.close());
|
|
609
|
+
|
|
610
|
+
const head = document.createElement("div");
|
|
611
|
+
head.className = "cex-roi-card-head";
|
|
612
|
+
const name = document.createElement("span");
|
|
613
|
+
name.className = "cex-roi-card-name";
|
|
614
|
+
const total = document.createElement("span");
|
|
615
|
+
total.className = "cex-roi-card-total";
|
|
616
|
+
head.append(name, total);
|
|
617
|
+
|
|
618
|
+
const variable = document.createElement("div");
|
|
619
|
+
variable.className = "cex-roi-card-variable";
|
|
620
|
+
|
|
621
|
+
const bars = document.createElement("div");
|
|
622
|
+
bars.className = "cex-roi-card-bars";
|
|
623
|
+
|
|
624
|
+
const more = document.createElement("button");
|
|
625
|
+
more.type = "button";
|
|
626
|
+
more.className = "cex-roi-card-more";
|
|
627
|
+
more.hidden = true;
|
|
628
|
+
more.addEventListener("click", () => {
|
|
629
|
+
this.expanded = !this.expanded;
|
|
630
|
+
this.refreshOpenCard();
|
|
631
|
+
// Re-clamped, not re-anchored: the card grew, and the only thing
|
|
632
|
+
// that may have to change is how far down the screen it can start.
|
|
633
|
+
if (this.hover) this.place(this.hover.anchorRect, this.hover.viewportRect);
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
const tip = document.createElement("div");
|
|
637
|
+
tip.className = "cex-roi-card-tip";
|
|
638
|
+
tip.hidden = true;
|
|
639
|
+
|
|
640
|
+
card.append(head, variable, bars, more, tip);
|
|
641
|
+
// On <body> rather than in the viewer's subtree: the anchor arrives in
|
|
642
|
+
// client pixels, and nothing an ancestor does with overflow can then
|
|
643
|
+
// clip a card that sits near the edge of the image.
|
|
644
|
+
document.body.appendChild(card);
|
|
645
|
+
this.card = card;
|
|
646
|
+
return card;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
render(summary) {
|
|
650
|
+
const card = this.ensureCard();
|
|
651
|
+
const ranked = this.expanded
|
|
652
|
+
? CellExplorerRoiBridge.rankCategories(summary.rows, summary.rows.length)
|
|
653
|
+
: CellExplorerRoiBridge.rankCategories(summary.rows);
|
|
654
|
+
|
|
655
|
+
card.classList.remove("is-pending");
|
|
656
|
+
card.querySelector(".cex-roi-card-name").textContent = summary.name;
|
|
657
|
+
card.querySelector(".cex-roi-card-total").textContent =
|
|
658
|
+
`${CellExplorerRoiBridge.formatCount(summary.total)} cells`;
|
|
659
|
+
// Said out loud whenever the legend is filtering, because the counts on
|
|
660
|
+
// this card are of the shown categories only. Without it the card is
|
|
661
|
+
// indistinguishable from one describing a region that genuinely holds
|
|
662
|
+
// nothing else, which is a different fact about the slide.
|
|
663
|
+
card.querySelector(".cex-roi-card-variable").textContent = summary.hiddenCategories
|
|
664
|
+
? `${summary.column || ""} · ${summary.hiddenCategories} hidden`
|
|
665
|
+
: (summary.column || "");
|
|
666
|
+
|
|
667
|
+
const bars = card.querySelector(".cex-roi-card-bars");
|
|
668
|
+
bars.textContent = "";
|
|
669
|
+
if (ranked.rows.length) {
|
|
670
|
+
for (const row of ranked.rows) bars.appendChild(this.buildBar(row, summary.total));
|
|
671
|
+
} else {
|
|
672
|
+
const empty = document.createElement("p");
|
|
673
|
+
empty.className = "cex-roi-card-empty";
|
|
674
|
+
empty.textContent = summary.hiddenCategories
|
|
675
|
+
? "Every category in this region is hidden."
|
|
676
|
+
: "No cells in this region.";
|
|
677
|
+
bars.appendChild(empty);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const more = card.querySelector(".cex-roi-card-more");
|
|
681
|
+
more.hidden = !(this.expanded || ranked.folded > 0);
|
|
682
|
+
more.textContent = this.expanded ? "Collapse" : "Show all ›";
|
|
683
|
+
card.classList.toggle("is-expanded", this.expanded);
|
|
684
|
+
this.hideTooltip();
|
|
685
|
+
card.hidden = false;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* The card before there is anything to put in it.
|
|
690
|
+
*
|
|
691
|
+
* Only ever seen once per session, while every cell's position is being
|
|
692
|
+
* fetched. It exists because the alternative is silence: the pointer enters
|
|
693
|
+
* a region, nothing appears, and the natural reading is that the hover did
|
|
694
|
+
* not register -- so the user moves on, and the card they were waiting for
|
|
695
|
+
* opens over a region they have already left.
|
|
696
|
+
*/
|
|
697
|
+
renderPending(detail) {
|
|
698
|
+
const card = this.ensureCard();
|
|
699
|
+
card.classList.add("is-pending");
|
|
700
|
+
card.classList.remove("is-expanded");
|
|
701
|
+
card.querySelector(".cex-roi-card-name").textContent = detail.name || "Region";
|
|
702
|
+
card.querySelector(".cex-roi-card-total").textContent = "";
|
|
703
|
+
card.querySelector(".cex-roi-card-variable").textContent = this.state.column || "";
|
|
704
|
+
|
|
705
|
+
const bars = card.querySelector(".cex-roi-card-bars");
|
|
706
|
+
bars.textContent = "";
|
|
707
|
+
const waiting = document.createElement("p");
|
|
708
|
+
waiting.className = "cex-roi-card-empty";
|
|
709
|
+
waiting.textContent = "Counting cells…";
|
|
710
|
+
bars.appendChild(waiting);
|
|
711
|
+
|
|
712
|
+
card.querySelector(".cex-roi-card-more").hidden = true;
|
|
713
|
+
this.hideTooltip();
|
|
714
|
+
card.hidden = false;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
buildBar(row, total) {
|
|
718
|
+
const percent = total ? (row.count / total) * 100 : 0;
|
|
719
|
+
const bar = document.createElement("div");
|
|
720
|
+
bar.className = "cex-roi-bar";
|
|
721
|
+
if (row.other) bar.classList.add("is-other");
|
|
722
|
+
|
|
723
|
+
const label = document.createElement("span");
|
|
724
|
+
label.className = "cex-roi-bar-label";
|
|
725
|
+
label.textContent = row.label;
|
|
726
|
+
bar.appendChild(label);
|
|
727
|
+
|
|
728
|
+
const track = document.createElement("div");
|
|
729
|
+
track.className = "cex-roi-bar-track";
|
|
730
|
+
|
|
731
|
+
const fill = document.createElement("div");
|
|
732
|
+
fill.className = "cex-roi-bar-fill";
|
|
733
|
+
// Against a fixed 0-100% track, never normalised to the largest
|
|
734
|
+
// category: a fortieth of the cells has to LOOK like a fortieth, or two
|
|
735
|
+
// regions cannot be compared by eye -- which is what the card is for.
|
|
736
|
+
fill.style.width = `${percent.toFixed(2)}%`;
|
|
737
|
+
fill.style.background = row.color;
|
|
738
|
+
track.appendChild(fill);
|
|
739
|
+
|
|
740
|
+
const count = document.createElement("span");
|
|
741
|
+
count.className = "cex-roi-bar-count";
|
|
742
|
+
count.textContent = CellExplorerRoiBridge.formatCount(row.count);
|
|
743
|
+
// Hugging the end of its own bar rather than lining up in a column down
|
|
744
|
+
// the right-hand side: the number belongs to that bar, and a numeric
|
|
745
|
+
// column pulls the eye away from the shape the ranking exists to show.
|
|
746
|
+
// Past the point where it would run off the end it moves inside.
|
|
747
|
+
if (percent >= CellExplorerRoiBridge.COUNT_INSIDE_PERCENT) {
|
|
748
|
+
count.classList.add("is-inside");
|
|
749
|
+
count.style.right = `calc(${(100 - percent).toFixed(2)}% + 6px)`;
|
|
750
|
+
} else {
|
|
751
|
+
count.style.left = `calc(${percent.toFixed(2)}% + 6px)`;
|
|
752
|
+
}
|
|
753
|
+
track.appendChild(count);
|
|
754
|
+
bar.appendChild(track);
|
|
755
|
+
|
|
756
|
+
// The exact figures are one hover away rather than printed on every
|
|
757
|
+
// row: six permanent percentages is a table, and the bars stop being
|
|
758
|
+
// readable as a shape.
|
|
759
|
+
const detail = `${row.label} / ${row.count.toLocaleString()} cells · `
|
|
760
|
+
+ `${percent.toFixed(1)}%`;
|
|
761
|
+
bar.addEventListener("mouseenter", () => this.showTooltip(bar, detail));
|
|
762
|
+
bar.addEventListener("mouseleave", () => this.hideTooltip());
|
|
763
|
+
return bar;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
showTooltip(bar, text) {
|
|
767
|
+
const tip = this.card?.querySelector(".cex-roi-card-tip");
|
|
768
|
+
if (!tip) return;
|
|
769
|
+
tip.textContent = text;
|
|
770
|
+
tip.hidden = false;
|
|
771
|
+
// Positioned against the card, which is its offset parent, so it
|
|
772
|
+
// travels with the card and needs no client-space arithmetic.
|
|
773
|
+
const above = bar.offsetTop - tip.offsetHeight - 4;
|
|
774
|
+
tip.style.top = `${above >= 0 ? above : bar.offsetTop + bar.offsetHeight + 4}px`;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
hideTooltip() {
|
|
778
|
+
const tip = this.card?.querySelector(".cex-roi-card-tip");
|
|
779
|
+
if (tip) tip.hidden = true;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Put the card beside the region, once.
|
|
784
|
+
*
|
|
785
|
+
* Computed when the region is entered and left alone afterwards. A card
|
|
786
|
+
* that followed the pointer would be unreadable inside a large region, and
|
|
787
|
+
* would slide away from the bar somebody is reaching for. It flips to the
|
|
788
|
+
* other side, or slides up, only as far as it must to stay inside the
|
|
789
|
+
* image -- the bounds come from the ROI plugin along with the anchor, so
|
|
790
|
+
* "inside" means inside the picture rather than inside the window.
|
|
791
|
+
*/
|
|
792
|
+
place(anchorRect, viewportRect) {
|
|
793
|
+
const card = this.card;
|
|
794
|
+
if (!card || !anchorRect) return;
|
|
795
|
+
const pad = CellExplorerRoiBridge.EDGE_PAD;
|
|
796
|
+
const gap = CellExplorerRoiBridge.ANCHOR_GAP;
|
|
797
|
+
const bounds = viewportRect || {
|
|
798
|
+
left: 0,
|
|
799
|
+
top: 0,
|
|
800
|
+
right: window.innerWidth,
|
|
801
|
+
bottom: window.innerHeight,
|
|
802
|
+
};
|
|
803
|
+
const box = card.getBoundingClientRect();
|
|
804
|
+
|
|
805
|
+
let left = anchorRect.right + gap;
|
|
806
|
+
if (left + box.width > bounds.right - pad) left = anchorRect.left - gap - box.width;
|
|
807
|
+
if (left < bounds.left + pad) {
|
|
808
|
+
// Neither side fits, which is a region wider than the space around
|
|
809
|
+
// it. Sit over it instead, still inside the picture.
|
|
810
|
+
left = Math.min(
|
|
811
|
+
Math.max(anchorRect.left + gap, bounds.left + pad),
|
|
812
|
+
Math.max(bounds.right - box.width - pad, bounds.left + pad));
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
let top = anchorRect.top;
|
|
816
|
+
if (top + box.height > bounds.bottom - pad) top = bounds.bottom - pad - box.height;
|
|
817
|
+
if (top < bounds.top + pad) top = bounds.top + pad;
|
|
818
|
+
|
|
819
|
+
card.style.left = `${Math.round(left)}px`;
|
|
820
|
+
card.style.top = `${Math.round(top)}px`;
|
|
821
|
+
}
|
|
822
|
+
}
|