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,947 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @class CSVGatingList - A view to select and deselect channels for gating, and to set gates (filter ranges) in the tabular data
|
|
3
|
+
*/
|
|
4
|
+
class CSVGatingList {
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @constructor
|
|
8
|
+
* @param config the cinfiguration file (json)
|
|
9
|
+
* @param columns - all the channel names
|
|
10
|
+
* @param dataLayer - the data layer (stub) that executes server requests and holds client side data
|
|
11
|
+
* @param eventHandler - the event handler for distributing interface and data updates
|
|
12
|
+
*/
|
|
13
|
+
constructor(ctx) {
|
|
14
|
+
// Everything arrives through the plugin context. This class used to
|
|
15
|
+
// read `__plexora`, `imageChannels` and `datasource` straight off
|
|
16
|
+
// window, which tied it to core's global names and to script order.
|
|
17
|
+
this.ctx = ctx;
|
|
18
|
+
this.config = ctx.config;
|
|
19
|
+
this.columns = [...ctx.columns];
|
|
20
|
+
this.databaseDescription = {};
|
|
21
|
+
this.maxSelections = ctx.config.maxSelections;
|
|
22
|
+
this.eventHandler = ctx.eventHandler;
|
|
23
|
+
this.dataLayer = ctx.dataLayer;
|
|
24
|
+
this.api = new GatingApi(ctx);
|
|
25
|
+
this.dataset = ctx.dataset;
|
|
26
|
+
this.datasource = ctx.datasource;
|
|
27
|
+
this.selections = {};
|
|
28
|
+
this.hasGatingGMM = {};
|
|
29
|
+
this.gatingIDs = {};
|
|
30
|
+
this.sliders = new Map();
|
|
31
|
+
this.container = d3.select("#csv_gating_list");
|
|
32
|
+
// Gating vars
|
|
33
|
+
this.global_channel_list = ctx.channelList;
|
|
34
|
+
this.global_image_channels = ctx.dataset.image.index;
|
|
35
|
+
this.gating_default_range = [0, 65536];
|
|
36
|
+
this.gating_channels = this.initGatingChannels();
|
|
37
|
+
this.gating_list = null;
|
|
38
|
+
// Download vars
|
|
39
|
+
this.download_panel_visible = false;
|
|
40
|
+
this.download_input1 = null;
|
|
41
|
+
this.download_input2 = null;
|
|
42
|
+
// Eval settings
|
|
43
|
+
this.eval_mode = 'and'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Selects a channel as active and adds the respective visual components to the channel panel in the list view
|
|
48
|
+
* @param name - the channel to set and display as selected
|
|
49
|
+
*/
|
|
50
|
+
selectChannel(name) {
|
|
51
|
+
const fullName = this.dataLayer.getFullChannelName(name);
|
|
52
|
+
const values = this.gating_channels[fullName];
|
|
53
|
+
this.selections[fullName] = values;
|
|
54
|
+
this.sliders.get(name).value(values);
|
|
55
|
+
this.eventHandler.trigger(CSVGatingList.events.GATING_BRUSH_MOVE, this.selections);
|
|
56
|
+
if (!(name in this.hasGatingGMM)) {
|
|
57
|
+
this.getAndDrawGatingGMM(name).then(() => {
|
|
58
|
+
this.eventHandler.trigger(CSVGatingList.events.SELECTION_CHANGED, this.selections);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Removes a channel form the current selection
|
|
65
|
+
* @param name - the name of the channel to remove
|
|
66
|
+
*/
|
|
67
|
+
removeChannel(name) {
|
|
68
|
+
// Delete
|
|
69
|
+
const fullName = this.dataLayer.getFullChannelName(name);
|
|
70
|
+
delete this.selections[fullName];
|
|
71
|
+
|
|
72
|
+
// Trigger
|
|
73
|
+
this.eventHandler.trigger(CSVGatingList.events.SELECTION_CHANGED, this.selections);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* initializes the view (channel list)
|
|
78
|
+
* @param dd - database description
|
|
79
|
+
* @param seaDragonViewer - the ImageViewer instance
|
|
80
|
+
* @returns {Promise<void>}
|
|
81
|
+
*/
|
|
82
|
+
init(dd, seaDragonViewer) {
|
|
83
|
+
this.databaseDescription = dd;
|
|
84
|
+
this.seaDragonViewer = seaDragonViewer;
|
|
85
|
+
document.getElementById('drag-and-drop-info').style.display = "none";
|
|
86
|
+
// Hide the Loader
|
|
87
|
+
document.getElementById('csv_gating_list_loader').style.display = "none";
|
|
88
|
+
this.gating_list = document.getElementById("csv_gating_list");
|
|
89
|
+
let list = document.createElement("ul");
|
|
90
|
+
list.classList.add("list-group")
|
|
91
|
+
list.setAttribute("id", "gating_list_ul")
|
|
92
|
+
this.gating_list.appendChild(list)
|
|
93
|
+
// What gets a row, and a slider.
|
|
94
|
+
//
|
|
95
|
+
// The project's recorded marker/metadata split, resolved once by core
|
|
96
|
+
// for every plugin (datasetContext.js's table.markers). This used to be
|
|
97
|
+
// derived here -- "every column with a histogram that is not id/x/y" --
|
|
98
|
+
// and that cannot tell a stain from a measurement: Area, Eccentricity
|
|
99
|
+
// and a numeric slide label all have perfectly good histograms, and in
|
|
100
|
+
// a CSV they sit in the same header as the markers, which is the entire
|
|
101
|
+
// reason the import step asks. Gating offered a threshold slider for
|
|
102
|
+
// every one of them.
|
|
103
|
+
//
|
|
104
|
+
// Feature-table columns, never the image channel list. An image
|
|
105
|
+
// channel's display name (from OME-XML/antibody nomenclature) and its
|
|
106
|
+
// feature-table column (adata.var_names/gene symbols, or a plain CSV
|
|
107
|
+
// header) are frequently different strings for the same marker, and
|
|
108
|
+
// every gating query (get_gated_cells, get_gating_gmm) keys directly
|
|
109
|
+
// off the feature-table column. Any correspondence to a specific image
|
|
110
|
+
// channel (if ever needed) would be positional (marker i <-> image
|
|
111
|
+
// channel i), not by matching names.
|
|
112
|
+
this.columns = [...this.dataset.table.markers];
|
|
113
|
+
_.each(this.columns, (column, index) => {
|
|
114
|
+
|
|
115
|
+
let channelID = `channel_${index}`;
|
|
116
|
+
this.gatingIDs[column] = channelID;
|
|
117
|
+
// div for each row in gating list
|
|
118
|
+
let listItemParentDiv = document.createElement("div");
|
|
119
|
+
listItemParentDiv.classList.add("list-group-item");
|
|
120
|
+
listItemParentDiv.classList.add("container");
|
|
121
|
+
listItemParentDiv.classList.add("gating-list-content");
|
|
122
|
+
// row
|
|
123
|
+
let row = document.createElement("div");
|
|
124
|
+
row.classList.add("row");
|
|
125
|
+
listItemParentDiv.appendChild(row);
|
|
126
|
+
// row
|
|
127
|
+
let row2 = document.createElement("div");
|
|
128
|
+
row2.classList.add("row");
|
|
129
|
+
listItemParentDiv.appendChild(row2);
|
|
130
|
+
|
|
131
|
+
// column within row that contains the name of the gating
|
|
132
|
+
let nameCol = document.createElement("div");
|
|
133
|
+
nameCol.classList.add("col-md-4");
|
|
134
|
+
nameCol.classList.add("gating-col");
|
|
135
|
+
row.appendChild(nameCol);
|
|
136
|
+
|
|
137
|
+
// column within row that cintains the slider for the gating
|
|
138
|
+
let sliderCol = document.createElement("div");
|
|
139
|
+
sliderCol.classList.add("col-md-12");
|
|
140
|
+
sliderCol.classList.add("csv_gating-slider");
|
|
141
|
+
sliderCol.setAttribute('id', "csv_gating-slider_" + channelID)
|
|
142
|
+
row2.appendChild(sliderCol);
|
|
143
|
+
|
|
144
|
+
// column within row that contains svg for color pickers
|
|
145
|
+
let svgCol = document.createElement("div");
|
|
146
|
+
svgCol.classList.add("col-md-4");
|
|
147
|
+
svgCol.classList.add("ms-auto");
|
|
148
|
+
svgCol.classList.add("gating-col");
|
|
149
|
+
svgCol.classList.add("gating-svg-wrapper");
|
|
150
|
+
svgCol.classList.add("col-svg-wrapper");
|
|
151
|
+
row.appendChild(svgCol);
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
let svg = d3.select(svgCol)
|
|
155
|
+
.append("svg")
|
|
156
|
+
.attr("width", 30)
|
|
157
|
+
.attr("height", 15)
|
|
158
|
+
svgCol.style.display = "none";
|
|
159
|
+
|
|
160
|
+
let gatingName = document.createElement("span");
|
|
161
|
+
gatingName.classList.add('gating-name');
|
|
162
|
+
gatingName.classList.add('list-button');
|
|
163
|
+
gatingName.textContent = column;
|
|
164
|
+
nameCol.appendChild(gatingName);
|
|
165
|
+
listItemParentDiv.addEventListener("click", e => {
|
|
166
|
+
return this.toggleChannelPanel(e, svgCol);
|
|
167
|
+
})
|
|
168
|
+
list.appendChild(listItemParentDiv);
|
|
169
|
+
|
|
170
|
+
//add and hide gating sliders (will be visible when gating is active)
|
|
171
|
+
const fullName = this.dataLayer.getFullChannelName(column);
|
|
172
|
+
const sliderRange = [this.databaseDescription[fullName].min, this.databaseDescription[fullName].max];
|
|
173
|
+
this.gating_channels[fullName] = sliderRange;
|
|
174
|
+
const gatingListEl = document.getElementById("csv_gating_list");
|
|
175
|
+
const swidth = gatingListEl.getBoundingClientRect().width;
|
|
176
|
+
this.addSlider(column, swidth, sliderRange, sliderRange);
|
|
177
|
+
d3.select('div#csv_gating-slider_' + channelID).style('display', "none");
|
|
178
|
+
|
|
179
|
+
let autoCol = document.createElement("div");
|
|
180
|
+
autoCol.classList.add("col-md-4");
|
|
181
|
+
autoCol.classList.add("ms-auto");
|
|
182
|
+
autoCol.classList.add("csv_gating-auto")
|
|
183
|
+
autoCol.setAttribute('id', "csv_gating-auto_" + channelID)
|
|
184
|
+
autoCol.classList.add("gating-col");
|
|
185
|
+
autoCol.classList.add("gating-svg-wrapper");
|
|
186
|
+
row.appendChild(autoCol);
|
|
187
|
+
|
|
188
|
+
let autoBtn = document.createElement("button");
|
|
189
|
+
autoBtn.classList.add('auto-btn');
|
|
190
|
+
autoBtn.classList.add('auto-loading');
|
|
191
|
+
autoBtn.setAttribute('id', "auto-btn-gating_" + channelID);
|
|
192
|
+
autoBtn.textContent = "auto";
|
|
193
|
+
autoBtn.addEventListener("click", async () => {
|
|
194
|
+
const shortName = this.dataLayer.getShortChannelName(fullName);
|
|
195
|
+
await this.autoGate(shortName);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
autoCol.appendChild(autoBtn);
|
|
199
|
+
autoBtn.addEventListener("click", e => e.stopPropagation());
|
|
200
|
+
d3.select(autoCol).style('display', "none");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
var dropzone = new Dropzone("#csv_gating_list", {
|
|
204
|
+
url: this.ctx.url("plugins/gating/upload_gates"),
|
|
205
|
+
clickable: false,
|
|
206
|
+
disablePreview: true,
|
|
207
|
+
createImageThumbnails: false
|
|
208
|
+
});
|
|
209
|
+
dropzone.on("sending", (file, xhr, formData) => {
|
|
210
|
+
formData.append("datasource", this.datasource);
|
|
211
|
+
});
|
|
212
|
+
dropzone.on("queuecomplete", (file, xhr, formData) => {
|
|
213
|
+
return this.applyGates()
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// Adding upload when you press on the up arrow
|
|
217
|
+
let arrow = document.getElementById('gating_upload_icon')
|
|
218
|
+
arrow.onclick = () => {
|
|
219
|
+
let elem = document.getElementById('gating-upload-from-arrow');
|
|
220
|
+
if (elem && document.createEvent) {
|
|
221
|
+
let evt = document.createEvent("MouseEvents");
|
|
222
|
+
evt.initEvent("click", true, false);
|
|
223
|
+
elem.dispatchEvent(evt);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
document.getElementById("gating-upload-from-arrow").onchange = async () => {
|
|
227
|
+
if (document.getElementById("gating-upload-from-arrow").files) {
|
|
228
|
+
let file = document.getElementById("gating-upload-from-arrow").files[0]
|
|
229
|
+
let formData = new FormData();
|
|
230
|
+
formData.append("file", file);
|
|
231
|
+
await this.api.submitGatingUpload(formData);
|
|
232
|
+
document.getElementById("gating-upload-from-arrow").value = []
|
|
233
|
+
await this.applyGates('file')
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
// Adding dropzone for CSV_Gating_List
|
|
239
|
+
let parent = document.getElementById('csv_gating_list');
|
|
240
|
+
let rect = parent.getBoundingClientRect();
|
|
241
|
+
parent.addEventListener("dragover", (ev) => {
|
|
242
|
+
document.getElementById('gating_list_ul').style.display = "none";
|
|
243
|
+
document.getElementById('drag-and-drop-info').style.display = "block";
|
|
244
|
+
})
|
|
245
|
+
parent.addEventListener("dragleave", (ev) => {
|
|
246
|
+
if (ev.x > rect.left + rect.width || ev.x < rect.left
|
|
247
|
+
|| ev.y > rect.top + rect.height || ev.y < rect.top) {
|
|
248
|
+
document.getElementById('gating_list_ul').style.display = "block";
|
|
249
|
+
document.getElementById('drag-and-drop-info').style.display = "none";
|
|
250
|
+
}
|
|
251
|
+
})
|
|
252
|
+
parent.addEventListener("drop", (ev) => {
|
|
253
|
+
document.getElementById('gating_list_ul').style.display = "block";
|
|
254
|
+
document.getElementById('drag-and-drop-info').style.display = "none";
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
// Add events
|
|
258
|
+
this.addDownloadEvents();
|
|
259
|
+
this.addEventsLinked();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* @function applyGates
|
|
264
|
+
* Applies settings (from file or db) to the gates in the tool
|
|
265
|
+
* @parms {String} source Whether it is from new file upload or saved
|
|
266
|
+
*/
|
|
267
|
+
async applyGates(source) {
|
|
268
|
+
let gates;
|
|
269
|
+
if (source === 'file') {
|
|
270
|
+
gates = await this.api.getUploadedGatingCsvValues();
|
|
271
|
+
} else {
|
|
272
|
+
gates = await this.api.getSavedGatingList();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
this.eventHandler.trigger(CSVGatingList.events.RESET_GATINGLIST)
|
|
276
|
+
_.each(gates, async (col) => {
|
|
277
|
+
if (col.channel == 'Lasso') {
|
|
278
|
+
// Lasso-drawn regions are no longer supported; ignore any
|
|
279
|
+
// legacy 'Lasso' rows from older saved/exported gate lists.
|
|
280
|
+
} else {
|
|
281
|
+
let shortName = this.dataLayer.getShortChannelName(col.channel);
|
|
282
|
+
let channelID = this.gatingIDs[shortName];
|
|
283
|
+
if (this.sliders.get(shortName)) {
|
|
284
|
+
let toggle_off
|
|
285
|
+
if (!col.gate_active && col.channel in this.selections) {
|
|
286
|
+
toggle_off = true;
|
|
287
|
+
} else {
|
|
288
|
+
toggle_off = false;
|
|
289
|
+
}
|
|
290
|
+
this.gating_channels[col.channel] = [col.gate_start, col.gate_end];
|
|
291
|
+
if (col.gate_active) {
|
|
292
|
+
// IF the channel isn't active, make it so
|
|
293
|
+
if (!this.selections[col.channel]) {
|
|
294
|
+
let selector = `#csv_gating-slider_${channelID}`;
|
|
295
|
+
document.querySelector(selector).click();
|
|
296
|
+
}
|
|
297
|
+
this.selections[col.channel] = [col.gate_start, col.gate_end];
|
|
298
|
+
|
|
299
|
+
// Update the slider values to reflect the new gate
|
|
300
|
+
const slider = this.sliders.get(shortName);
|
|
301
|
+
if (slider) {
|
|
302
|
+
// Apply the same data type conversion as in addSlider
|
|
303
|
+
const fullName = this.dataLayer.getFullChannelName(shortName);
|
|
304
|
+
const channelRange = [this.databaseDescription[fullName].min, this.databaseDescription[fullName].max];
|
|
305
|
+
const factor = Math.pow(10, this.dataLayer.gateDecimals(channelRange));
|
|
306
|
+
const v0 = Math.floor(parseFloat(col.gate_start) * factor) / factor;
|
|
307
|
+
const v1 = Math.ceil(parseFloat(col.gate_end) * factor) / factor;
|
|
308
|
+
|
|
309
|
+
slider.silentValue([v0, v1]);
|
|
310
|
+
// Update the input fields
|
|
311
|
+
d3.select('#gating_slider-input_' + channelID + '_0').attr('value', v0);
|
|
312
|
+
d3.select('#gating_slider-input_' + channelID + '_0').property('value', v0);
|
|
313
|
+
d3.select('#gating_slider-input_' + channelID + '_1').attr('value', v1);
|
|
314
|
+
d3.select('#gating_slider-input_' + channelID + '_1').property('value', v1);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
} else {
|
|
318
|
+
// If channel is currently active, but shouldn't be, update it
|
|
319
|
+
if (toggle_off) {
|
|
320
|
+
let selector = `#csv_gating-slider_${channelID}`;
|
|
321
|
+
document.querySelector(selector).click();
|
|
322
|
+
}
|
|
323
|
+
delete this.selections[col.channel];
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
})
|
|
328
|
+
// Trigger brush
|
|
329
|
+
this.eventHandler.trigger(CSVGatingList.events.SELECTION_CHANGED, this.selections);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* @function autoGate - applies thresholds based on Gaussian Mixture Model
|
|
334
|
+
* @param name - the name of the channel to apply it to
|
|
335
|
+
*/
|
|
336
|
+
async autoGate(shortName) {
|
|
337
|
+
const fullName = this.dataLayer.getFullChannelName(shortName);
|
|
338
|
+
// A column with no mixture to find -- a flag, a constant, a nearly
|
|
339
|
+
// empty channel -- comes back without a gate rather than with one
|
|
340
|
+
// derived from noise. Leave the slider where the user put it.
|
|
341
|
+
const packet = this.hasGatingGMM[shortName];
|
|
342
|
+
if (packet?.gate === undefined) return;
|
|
343
|
+
const input = packet['gate'].toFixed(7);
|
|
344
|
+
const channelRange = [this.databaseDescription[fullName].min, this.databaseDescription[fullName].max];
|
|
345
|
+
const factor = Math.pow(10, this.dataLayer.gateDecimals(channelRange));
|
|
346
|
+
const gate = Math.floor(parseFloat(input) * factor) / factor;
|
|
347
|
+
if (fullName in this.selections) {
|
|
348
|
+
const channelID = this.gatingIDs[shortName];
|
|
349
|
+
const gate_end = this.selections[fullName][1];
|
|
350
|
+
const slider = this.sliders.get(shortName);
|
|
351
|
+
const values = [gate, gate_end];
|
|
352
|
+
this.moveSliderHandles(slider, values, shortName, 'SELECTION_CHANGED');
|
|
353
|
+
d3.select('#gating_slider-input_' + channelID + '_0').attr('value', gate)
|
|
354
|
+
d3.select('#gating_slider-input_' + channelID + '_0').property('value', gate);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* @function initGatingChannels - creates the data structure for channels
|
|
360
|
+
* @return obj - allChannels and their default range
|
|
361
|
+
*/
|
|
362
|
+
initGatingChannels() {
|
|
363
|
+
|
|
364
|
+
// Init
|
|
365
|
+
const obj = {};
|
|
366
|
+
|
|
367
|
+
// Iterate to create fields
|
|
368
|
+
for (let key in this.global_image_channels) {
|
|
369
|
+
|
|
370
|
+
obj[key] = this.gating_default_range;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Return
|
|
374
|
+
return obj;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* @function toggleChannelPane - expands or collapses a channel panel in the list that was clicked on
|
|
379
|
+
* @param event - the click vent
|
|
380
|
+
* @param svgCol - the column to expand or collapse
|
|
381
|
+
*/
|
|
382
|
+
toggleChannelPanel(event, svgCol) {
|
|
383
|
+
|
|
384
|
+
// If you clicked on the svg, ignore this behavior
|
|
385
|
+
if (event.target.closest("svg")) {
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Get info
|
|
390
|
+
let parent = event.target.closest(".list-group-item");
|
|
391
|
+
let name = parent.querySelector('.gating-name').textContent;
|
|
392
|
+
let channelID = this.gatingIDs[name];
|
|
393
|
+
let status = !parent.classList.contains("active");
|
|
394
|
+
|
|
395
|
+
// If active - else inactive
|
|
396
|
+
if (status) {
|
|
397
|
+
|
|
398
|
+
// Clear everything
|
|
399
|
+
// clearOut();
|
|
400
|
+
|
|
401
|
+
// Don't add gating is the max are selected
|
|
402
|
+
if (_.size(this.selections) >= this.maxSelections) {
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Update properties and add slider
|
|
407
|
+
d3.select(parent).classed("active", true);
|
|
408
|
+
svgCol.style.display = "block";
|
|
409
|
+
d3.select('div#csv_gating-slider_' + channelID).style('display', "block")
|
|
410
|
+
d3.select('div#csv_gating-auto_' + channelID).style('display', "block");
|
|
411
|
+
|
|
412
|
+
// Add channel
|
|
413
|
+
this.selectChannel(name);
|
|
414
|
+
|
|
415
|
+
} else {
|
|
416
|
+
// Clear panel visibility
|
|
417
|
+
// clearOut();
|
|
418
|
+
|
|
419
|
+
// Remove channel and rerender
|
|
420
|
+
this.removeChannel(name);
|
|
421
|
+
|
|
422
|
+
// Hide
|
|
423
|
+
d3.select(parent).classed("active", false);
|
|
424
|
+
svgCol.style.display = "none";
|
|
425
|
+
d3.select('div#csv_gating-slider_' + channelID).style('display', "none")
|
|
426
|
+
d3.select('div#csv_gating-auto_' + channelID).style('display', "none");
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
let selectionsHeaderDiv = document.getElementById("csv_selected-gatings-header-div");
|
|
430
|
+
if (selectionsHeaderDiv) {
|
|
431
|
+
if (_.size(this.selections) >= this.maxSelections) {
|
|
432
|
+
selectionsHeaderDiv.classList.add('bold-selections-header');
|
|
433
|
+
} else {
|
|
434
|
+
selectionsHeaderDiv.classList.remove('bold-selections-header');
|
|
435
|
+
}
|
|
436
|
+
document.getElementById("csv_num-selected-gatings").textContent = _.size(this.selections);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* @function addDownloadEvents - adds eventl listeners an functionality to the download buttons
|
|
442
|
+
*/
|
|
443
|
+
addDownloadEvents() {
|
|
444
|
+
|
|
445
|
+
// Els
|
|
446
|
+
const gating_download_icon = document.querySelector('#gating_download_icon');
|
|
447
|
+
const gating_download_panel = document.querySelector('#gating_download_panel');
|
|
448
|
+
const gating_exit = document.querySelector('#gating_exit');
|
|
449
|
+
const download_gated_channel_ranges = document.querySelector('#download_gated_channel_ranges');
|
|
450
|
+
const download_gated_cell_encodings = document.querySelector('#download_gated_cell_encodings');
|
|
451
|
+
const download_input1 = document.querySelector('#download_input1');
|
|
452
|
+
const download_input2 = document.querySelector('#download_input2');
|
|
453
|
+
|
|
454
|
+
// Events ::
|
|
455
|
+
|
|
456
|
+
// Open / close download panel
|
|
457
|
+
gating_download_icon.addEventListener('click', () => {
|
|
458
|
+
// Update class var
|
|
459
|
+
this.download_panel_visible = !this.download_panel_visible;
|
|
460
|
+
// Condition to update download panel visibility
|
|
461
|
+
if (this.download_panel_visible) {
|
|
462
|
+
gating_download_panel.style.visibility = 'visible';
|
|
463
|
+
} else {
|
|
464
|
+
gating_download_panel.style.visibility = 'hidden';
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
// Close download panel
|
|
469
|
+
gating_exit.addEventListener('click', () => {
|
|
470
|
+
// Update class var
|
|
471
|
+
this.download_panel_visible = !this.download_panel_visible;
|
|
472
|
+
// Hide download panel
|
|
473
|
+
gating_download_panel.style.visibility = 'hidden';
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
// Download gated channel ranges
|
|
477
|
+
download_gated_channel_ranges.addEventListener('click', () => {
|
|
478
|
+
this.api.downloadGatingCSV(this.gating_channels, this.selections, false);
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
// Download gated channel ranges
|
|
482
|
+
download_gated_cell_encodings.addEventListener('click', () => {
|
|
483
|
+
this.api.downloadGatingCSV(this.gating_channels, this.selections, this.seaDragonViewer.pickedIds, true);
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* @function addEventsLinked
|
|
491
|
+
* ???
|
|
492
|
+
*/
|
|
493
|
+
addEventsLinked() {
|
|
494
|
+
|
|
495
|
+
// Add events to channel
|
|
496
|
+
const channelListContent = document.querySelectorAll('.channel-list-content');
|
|
497
|
+
const gatingListContent = document.querySelectorAll('.gating-list-content');
|
|
498
|
+
|
|
499
|
+
// Attach
|
|
500
|
+
// Gating markers (adata.var_names) and image channels are matched
|
|
501
|
+
// by name only when the strings happen to coincide -- the two lists
|
|
502
|
+
// are frequently different lengths (e.g. structural channels like
|
|
503
|
+
// DNA/AF1 have no marker, or var_names uses gene symbols that don't
|
|
504
|
+
// match the image's antibody-named channels), so this can never be
|
|
505
|
+
// a positional/index link. When a gating marker is selected and its
|
|
506
|
+
// exact name also appears in the image channel list, auto-open that
|
|
507
|
+
// channel for convenience; when there's no match, nothing happens
|
|
508
|
+
// and the user opens the right channel manually.
|
|
509
|
+
const attach = (targets, matches, target_class, match_class) => {
|
|
510
|
+
targets.forEach(cLC => {
|
|
511
|
+
cLC.addEventListener('click', e => {
|
|
512
|
+
|
|
513
|
+
// If event target is not an svg el (from slider)
|
|
514
|
+
const svgEls = ['path']
|
|
515
|
+
if (!svgEls.includes(e.currentTarget.tagName)) {
|
|
516
|
+
|
|
517
|
+
// Get channel name
|
|
518
|
+
const name = _.get(e.currentTarget.querySelector(`.${target_class}`), 'innerText');
|
|
519
|
+
|
|
520
|
+
// Find match el in channel list
|
|
521
|
+
if (name) {
|
|
522
|
+
const match = Array.from(matches).find(
|
|
523
|
+
row => row.querySelector(`.${match_class}`).innerText === name);
|
|
524
|
+
|
|
525
|
+
// Real click so it goes through ChannelList's own
|
|
526
|
+
// toggleChannelPanel handler (already bound per-row
|
|
527
|
+
// at row-creation time) -- no need to reconstruct
|
|
528
|
+
// its event/closure state here.
|
|
529
|
+
if (match && !Array.from(match.classList).includes('active')) {
|
|
530
|
+
match.click();
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
attach(gatingListContent, channelListContent, 'gating-name', 'channel-name');
|
|
538
|
+
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* @function addSlider - add a slider
|
|
543
|
+
* @param data - the min and max range of the slider
|
|
544
|
+
* @param activeRange - the predefined values for the lower and upper handle
|
|
545
|
+
* @param name - the name of the slider (used as part of the id)
|
|
546
|
+
* @param swidth - the pixel width of the slider
|
|
547
|
+
*/
|
|
548
|
+
addSlider(name, swidth, data, activeRange) {
|
|
549
|
+
|
|
550
|
+
if (!data) return;
|
|
551
|
+
|
|
552
|
+
const fullName = this.dataLayer.getFullChannelName(name);
|
|
553
|
+
const { xDomain, yDomain, histogramData } = this.histogramData(fullName);
|
|
554
|
+
let channelID = this.gatingIDs[name];
|
|
555
|
+
|
|
556
|
+
// data is already [min, max] (see call sites) -- gateDecimals derives
|
|
557
|
+
// slider precision from that observed span instead of the old
|
|
558
|
+
// isTransformed config flag (see dataLayer.gateDecimals for why).
|
|
559
|
+
const channelRange = [d3.min(data), d3.max(data)];
|
|
560
|
+
const gateFactor = Math.pow(10, this.dataLayer.gateDecimals(channelRange));
|
|
561
|
+
const data_min = Math.floor(channelRange[0] * gateFactor) / gateFactor;
|
|
562
|
+
const data_max = Math.ceil(channelRange[1] * gateFactor) / gateFactor;
|
|
563
|
+
const handle_min = Math.floor(parseFloat(activeRange[0]) * gateFactor) / gateFactor;
|
|
564
|
+
const handle_max = Math.ceil(parseFloat(activeRange[1]) * gateFactor) / gateFactor;
|
|
565
|
+
let f = d3.format("d")
|
|
566
|
+
//add range slider row content
|
|
567
|
+
const sliderSimple = d3.sliderBottom()
|
|
568
|
+
.min(data_min)
|
|
569
|
+
.max(data_max)
|
|
570
|
+
.width(swidth - 75)
|
|
571
|
+
.tickFormat(f)
|
|
572
|
+
.fill('orange')
|
|
573
|
+
.ticks(1)
|
|
574
|
+
.default([handle_min, handle_max])
|
|
575
|
+
.handle(
|
|
576
|
+
d3.symbol()
|
|
577
|
+
.type(d3.symbolCircle)
|
|
578
|
+
.size(100))
|
|
579
|
+
.tickValues([])
|
|
580
|
+
.on('end', (range) => {
|
|
581
|
+
const v0 = Math.floor(parseFloat(range[0]) * gateFactor) / gateFactor;
|
|
582
|
+
const v1 = Math.ceil(parseFloat(range[1]) * gateFactor) / gateFactor;
|
|
583
|
+
this.moveSliderHandles(sliderSimple, [v0, v1], name, "SELECTION_CHANGED");
|
|
584
|
+
}).on('onchange', (range) => {
|
|
585
|
+
const v0 = Math.floor(parseFloat(range[0]) * gateFactor) / gateFactor;
|
|
586
|
+
const v1 = Math.ceil(parseFloat(range[1]) * gateFactor) / gateFactor;
|
|
587
|
+
d3.select('#gating_slider-input_' + channelID + '_0').attr('value', v0)
|
|
588
|
+
d3.select('#gating_slider-input_' + channelID + '_0').property('value', v0);
|
|
589
|
+
d3.select('#gating_slider-input_' + channelID + '_1').attr('value', v1);
|
|
590
|
+
d3.select('#gating_slider-input_' + channelID + '_1').property('value', v1);
|
|
591
|
+
this.moveSliderHandles(sliderSimple, [v0, v1], name, "GATING_BRUSH_MOVE");
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
this.sliders.set(name, sliderSimple);
|
|
595
|
+
|
|
596
|
+
//create the slider svg and call the slider
|
|
597
|
+
var gSimple = d3
|
|
598
|
+
.select('#csv_gating-slider_' + channelID)
|
|
599
|
+
.append('svg')
|
|
600
|
+
.attr('class', 'svgslider')
|
|
601
|
+
.attr('id', 'csv_gating-slider_svg_' + channelID)
|
|
602
|
+
.attr('width', swidth)
|
|
603
|
+
.attr('height', 80)
|
|
604
|
+
.append('g')
|
|
605
|
+
.attr('transform', 'translate(20,40)');
|
|
606
|
+
|
|
607
|
+
let xScale = d3.scaleLinear()
|
|
608
|
+
.domain(xDomain)
|
|
609
|
+
.range([0, swidth - 73])
|
|
610
|
+
|
|
611
|
+
let yScale = d3.scaleLinear()
|
|
612
|
+
.domain(yDomain)
|
|
613
|
+
.range([0, 25])
|
|
614
|
+
|
|
615
|
+
let line = d3.line()
|
|
616
|
+
.x(d => xScale(d.x))
|
|
617
|
+
.y(d => yScale(d.y))
|
|
618
|
+
.curve(d3.curveMonotoneX)
|
|
619
|
+
|
|
620
|
+
const lines = gSimple.selectAll('.distribution_line');
|
|
621
|
+
const paths = lines.data([histogramData]).enter().append('path');
|
|
622
|
+
paths
|
|
623
|
+
.append('path')
|
|
624
|
+
.attr('d', line)
|
|
625
|
+
.attr('class', 'distribution_line')
|
|
626
|
+
.attr('transform', 'translate(0,-31)')
|
|
627
|
+
.attr('fill', 'none')
|
|
628
|
+
|
|
629
|
+
gSimple.call(sliderSimple);
|
|
630
|
+
|
|
631
|
+
//slider value to be displayed closer to the slider than default
|
|
632
|
+
d3.selectAll('.parameter-value').select('text')
|
|
633
|
+
.attr("y", 10);
|
|
634
|
+
|
|
635
|
+
//both handles
|
|
636
|
+
const { sliders } = this;
|
|
637
|
+
const handles = d3.select('#csv_gating-slider_' + channelID).selectAll(".parameter-value");
|
|
638
|
+
handles.each(function (d, i) {
|
|
639
|
+
d3.select(this).append("foreignObject")
|
|
640
|
+
.attr('id', 'c_foreignObject_' + channelID + i)
|
|
641
|
+
.attr("width", 50)
|
|
642
|
+
.attr("height", 40)
|
|
643
|
+
.attr('x', -25)
|
|
644
|
+
.attr('y', -17)
|
|
645
|
+
.style('padding', "10px")
|
|
646
|
+
.append("xhtml:body")
|
|
647
|
+
.attr('xmlns', 'http://www.w3.org/1999/xhtml')
|
|
648
|
+
.style('background', 'none')
|
|
649
|
+
.append('input')
|
|
650
|
+
.attr('y', -17)
|
|
651
|
+
.attr('id', 'gating_slider-input_' + channelID + '_' + i)
|
|
652
|
+
.attr('type', 'text')
|
|
653
|
+
.attr('class', 'input')
|
|
654
|
+
.attr('value', () => {
|
|
655
|
+
return sliders.get(name).value()[i]
|
|
656
|
+
});
|
|
657
|
+
//remove the previous text label
|
|
658
|
+
d3.select(this).select('text').remove();
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
//entering a value in the input field of a slider handle
|
|
662
|
+
const moveSliderHandles = this.moveSliderHandles.bind(this);
|
|
663
|
+
handles.selectAll('.input').on('keydown', function (event, d) {
|
|
664
|
+
// Note: `this` here is the DOM <input> (d3's `function` handler
|
|
665
|
+
// convention, needed below for `this.value`) -- gateFactor is
|
|
666
|
+
// captured from the enclosing addSlider closure instead of
|
|
667
|
+
// going through `this.dataLayer`, which doesn't exist on a DOM
|
|
668
|
+
// node (a pre-existing bug: this handler would have thrown on
|
|
669
|
+
// every Enter keypress before gateFactor existed to close over).
|
|
670
|
+
if (event.key == "Enter") {
|
|
671
|
+
const val = Math.round(parseFloat(this.value.replace("%", "")) * gateFactor) / gateFactor;
|
|
672
|
+
const vals = sliderSimple.silentValue();
|
|
673
|
+
vals[d.index] = val;
|
|
674
|
+
moveSliderHandles(sliderSimple, vals, name, "SELECTION_CHANGED");
|
|
675
|
+
}
|
|
676
|
+
})
|
|
677
|
+
|
|
678
|
+
return sliderSimple;
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
histogramData(fullName) {
|
|
682
|
+
const histogramData = this.databaseDescription[fullName].histogram;
|
|
683
|
+
const xMin = Math.floor(Math.min(...histogramData.map(e => e.x)));
|
|
684
|
+
const xMax = Math.ceil(Math.max(...histogramData.map(e => e.x)));
|
|
685
|
+
const yMax = Math.max(...histogramData.map(e => e.y));
|
|
686
|
+
return {
|
|
687
|
+
histogramData,
|
|
688
|
+
xDomain: [xMin, xMax],
|
|
689
|
+
yDomain: [yMax, 0],
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
async getGatingGMM(name, selection_ids = []) {
|
|
694
|
+
const fullName = this.dataLayer.getFullChannelName(name);
|
|
695
|
+
let packet = await this.api.getGatingGMM(fullName, selection_ids);
|
|
696
|
+
this.hasGatingGMM[name] = packet;
|
|
697
|
+
return packet;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
drawGatingGMM(name) {
|
|
701
|
+
let channelID = this.gatingIDs[name];
|
|
702
|
+
const fullName = this.dataLayer.getFullChannelName(name);
|
|
703
|
+
const { xDomain, yDomain } = this.histogramData(fullName);
|
|
704
|
+
const packet = this.hasGatingGMM[name];
|
|
705
|
+
let gmm1Data = packet['gmm_1'];
|
|
706
|
+
let gmm2Data = packet['gmm_2'];
|
|
707
|
+
|
|
708
|
+
const gatingListEl = document.getElementById("csv_gating_list");
|
|
709
|
+
const swidth = gatingListEl.getBoundingClientRect().width;
|
|
710
|
+
|
|
711
|
+
let xScale = d3.scaleLinear()
|
|
712
|
+
.domain(xDomain)
|
|
713
|
+
.range([0, swidth - 73])
|
|
714
|
+
|
|
715
|
+
let yScale = d3.scaleLinear()
|
|
716
|
+
.domain(yDomain)
|
|
717
|
+
.range([0, 25])
|
|
718
|
+
|
|
719
|
+
let line = d3.line()
|
|
720
|
+
.x(d => xScale(d.x))
|
|
721
|
+
.y(d => yScale(d.y))
|
|
722
|
+
.curve(d3.curveMonotoneX)
|
|
723
|
+
|
|
724
|
+
let gSimple = d3.select('#csv_gating-slider_svg_' + channelID + ' g')
|
|
725
|
+
|
|
726
|
+
gSimple.selectAll('.gmm1_line')
|
|
727
|
+
.data([gmm1Data])
|
|
728
|
+
.enter()
|
|
729
|
+
.append('path')
|
|
730
|
+
.attr('d', line)
|
|
731
|
+
.attr('class', 'gmm_line')
|
|
732
|
+
.attr('class', 'gmm_line_' + name)
|
|
733
|
+
.attr('id', 'gmm1_line_' + name)
|
|
734
|
+
.attr('transform', 'translate(0,-31)')
|
|
735
|
+
.attr('fill', 'none')
|
|
736
|
+
.attr('stroke', 'blue')
|
|
737
|
+
|
|
738
|
+
gSimple.selectAll('.gmm2_line')
|
|
739
|
+
.data([gmm2Data])
|
|
740
|
+
.enter()
|
|
741
|
+
.append('path')
|
|
742
|
+
.attr('d', line)
|
|
743
|
+
.attr('class', 'gmm_line')
|
|
744
|
+
.attr('class', 'gmm_line_' + name)
|
|
745
|
+
.attr('id', 'gmm2_line_' + name)
|
|
746
|
+
.attr('transform', 'translate(0,-31)')
|
|
747
|
+
.attr('fill', 'none')
|
|
748
|
+
.attr('stroke', 'red')
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
async getAndDrawGatingGMM(name) {
|
|
752
|
+
await this.getGatingGMM(name);
|
|
753
|
+
|
|
754
|
+
const channelID = this.gatingIDs[name];
|
|
755
|
+
const autoBtn = document.getElementById(`auto-btn-gating_${channelID}`);
|
|
756
|
+
autoBtn.classList.remove("auto-loading")
|
|
757
|
+
|
|
758
|
+
this.drawGatingGMM(name);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* @function resetGatingList - resets all channels in the list to its initial range
|
|
763
|
+
*/
|
|
764
|
+
resetGatingList() {
|
|
765
|
+
let gatingList = Object.keys(this.selections);
|
|
766
|
+
_.each(gatingList, col => {
|
|
767
|
+
let shortName = this.dataLayer.getShortChannelName(col);
|
|
768
|
+
let channelID = this.gatingIDs[shortName];
|
|
769
|
+
let gating_selector = `#csv_gating-slider_${channelID}`;
|
|
770
|
+
document.querySelector(gating_selector).click();
|
|
771
|
+
});
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* @function moveSliderHandles - move the slider handles and input fields so that input fields don't overlap when handles are close
|
|
776
|
+
*
|
|
777
|
+
* @param slider - the slider affected
|
|
778
|
+
* @param vals - holds the new positions
|
|
779
|
+
* @param name - the name of the slider
|
|
780
|
+
* @param eventName - the name of the event
|
|
781
|
+
*/
|
|
782
|
+
moveSliderHandles(slider, vals, name, eventName) {
|
|
783
|
+
const fullName = this.dataLayer.getFullChannelName(name);
|
|
784
|
+
const channelID = this.gatingIDs[name];
|
|
785
|
+
this.gating_channels[fullName] = vals;
|
|
786
|
+
this.selections[fullName] = vals;
|
|
787
|
+
slider.silentValue(vals);
|
|
788
|
+
const diff = Math.abs(vals[1] - vals[0]);
|
|
789
|
+
const total = Math.abs(slider.max() - slider.min());
|
|
790
|
+
const percentage = diff / total;
|
|
791
|
+
if (percentage < 0.15) {
|
|
792
|
+
console.log('slider handles overlap..do something');
|
|
793
|
+
d3.select('#c_foreignObject_' + channelID + 1).attr('x', 5);
|
|
794
|
+
} else {
|
|
795
|
+
d3.select('#c_foreignObject_' + channelID + 1).attr('x', -25);
|
|
796
|
+
}
|
|
797
|
+
const packet = this.selections;
|
|
798
|
+
this.eventHandler.trigger(CSVGatingList.events[eventName], packet);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* @function dist - caclulates the distance between two rects
|
|
803
|
+
* @param el1
|
|
804
|
+
* @param el2
|
|
805
|
+
* @param buffer
|
|
806
|
+
* @returns {number}
|
|
807
|
+
*/
|
|
808
|
+
dist(el1, el2, buffer) {
|
|
809
|
+
var rect1 = el1.getBoundingClientRect();
|
|
810
|
+
var rect2 = el2.getBoundingClientRect();
|
|
811
|
+
return rect2.left - rect1.right;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// ==== ImageViewer cell-layer provider contract (see imageViewer.js) ====
|
|
815
|
+
// Replaces ImageViewer's former direct dataLayer.getGatedCellIds() calls --
|
|
816
|
+
// the gating-specific "gates" shape now stays entirely on this side of the seam.
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* @function rebuildSliders - re-measure and redraw every legacy-list slider.
|
|
820
|
+
*
|
|
821
|
+
* Slider widths come from a measured bounding box, so a window resize
|
|
822
|
+
* invalidates them. Called by the resize listener the plugin registers in
|
|
823
|
+
* bindEvents(), which is torn down with the plugin -- this used to be a
|
|
824
|
+
* script-scope listener reading a global, with no way to detach it.
|
|
825
|
+
*/
|
|
826
|
+
rebuildSliders() {
|
|
827
|
+
const gatingListEl = document.getElementById("csv_gating_list");
|
|
828
|
+
if (!gatingListEl) return;
|
|
829
|
+
const swidth = gatingListEl.getBoundingClientRect().width;
|
|
830
|
+
this.sliders.forEach((slider, name) => {
|
|
831
|
+
const gatingID = this.gatingIDs[name];
|
|
832
|
+
d3.select('div#csv_gating-slider_' + gatingID).select('svg').remove();
|
|
833
|
+
const fullName = this.dataLayer.getFullChannelName(name);
|
|
834
|
+
const sliderRange = [this.databaseDescription[fullName].min, this.databaseDescription[fullName].max];
|
|
835
|
+
this.addSlider(name, swidth, sliderRange, slider.value());
|
|
836
|
+
if (this.hasGatingGMM[name]) {
|
|
837
|
+
this.drawGatingGMM(name);
|
|
838
|
+
}
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* @function getSelectedIds - resolve a gates filter to matching cell ids
|
|
844
|
+
* @param filter - gates dict ({channel: [min, max]}); defaults to the
|
|
845
|
+
* currently active selections when omitted
|
|
846
|
+
* @returns {Promise<Set<number>>}
|
|
847
|
+
*/
|
|
848
|
+
async getSelectedIds(filter) {
|
|
849
|
+
const gates = filter || this.selections;
|
|
850
|
+
// The role, from ctx.dataset -- the last place this plugin reached into
|
|
851
|
+
// the raw config entry. What the id column is called is core's to know.
|
|
852
|
+
const idField = this.dataset.schema.cellId;
|
|
853
|
+
const rows = await this.api.getGatedCellIds(gates, [idField]);
|
|
854
|
+
if (!Array.isArray(rows)) return new Set();
|
|
855
|
+
return new Set(rows.map((row) => Number(row[idField] ?? row.id)));
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* @function supportsColorCoding - gating owns the multi-range colorized
|
|
860
|
+
* rendering path (u_cell_range_shape/texture_ranges); always true here.
|
|
861
|
+
* @returns {boolean}
|
|
862
|
+
*/
|
|
863
|
+
supportsColorCoding() {
|
|
864
|
+
return true;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* @function getColorCodedRanges - per-channel gate ranges for the
|
|
869
|
+
* colorized rendering path.
|
|
870
|
+
* @returns {object}
|
|
871
|
+
*/
|
|
872
|
+
getColorCodedRanges() {
|
|
873
|
+
return this.selections;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
//hide gating control panel when scrolled down to access all channels..
|
|
879
|
+
// $(document).ready(function()
|
|
880
|
+
// {
|
|
881
|
+
// $('#csv_gating_list').scroll(function()
|
|
882
|
+
// {
|
|
883
|
+
// var div = $(this);
|
|
884
|
+
// if (div[0].scrollHeight - div.scrollTop() < div.height()+10)
|
|
885
|
+
// {
|
|
886
|
+
// $('#seg_controls_panel').hide();
|
|
887
|
+
// }else{
|
|
888
|
+
// $('#seg_controls_panel').show();
|
|
889
|
+
// }
|
|
890
|
+
// });
|
|
891
|
+
// });
|
|
892
|
+
|
|
893
|
+
//static vars: events introduced in this class and used across the app
|
|
894
|
+
CSVGatingList.events = {
|
|
895
|
+
GATING_BRUSH_MOVE: "GATING_BRUSH_MOVE",
|
|
896
|
+
SELECTION_CHANGED: "SELECTION_CHANGED",
|
|
897
|
+
RESET_GATINGLIST: "RESET_GATINGLIST"
|
|
898
|
+
};
|
|
899
|
+
|
|
900
|
+
// Self-registers the gating plugin (see pluginRegistry.js for the shape and main.js
|
|
901
|
+
// for the call sites). Only ever loaded when gating's tool is open for the current
|
|
902
|
+
// page view, so this always runs alongside GatingSidebarController.
|
|
903
|
+
if (window.Plexora) {
|
|
904
|
+
window.Plexora.registerPlugin({
|
|
905
|
+
name: "gating",
|
|
906
|
+
// Gating decides which cells are drawn at all, by marker threshold, so
|
|
907
|
+
// it registers a cell layer of its own. It supplies no colours: the
|
|
908
|
+
// layer draws the plain white the mask has always used, restricted to
|
|
909
|
+
// the gated set. Stacked over a tool that DOES colour cells, that reads
|
|
910
|
+
// as "these are the ones inside the gate" over the phenotype map, which
|
|
911
|
+
// is the pairing the layer model exists for.
|
|
912
|
+
// See ImageViewer.registerCellLayer.
|
|
913
|
+
ownsCellLayer: true,
|
|
914
|
+
createInstance(ctx) {
|
|
915
|
+
return new CSVGatingList(ctx);
|
|
916
|
+
},
|
|
917
|
+
createSidebarController(ctx) {
|
|
918
|
+
return new GatingSidebarController(ctx);
|
|
919
|
+
},
|
|
920
|
+
bindEvents(ctx) {
|
|
921
|
+
const { eventHandler, moduleInstance, seaDragonViewer, updateSeaDragonSelection, updateCentroidsForGate, runSegmentationGate, onCleanup } = ctx;
|
|
922
|
+
// The legacy list's sliders are sized from measured widths, so they
|
|
923
|
+
// need rebuilding on resize. Registered through onCleanup so
|
|
924
|
+
// deactivating the plugin actually removes it -- this listener used
|
|
925
|
+
// to be attached at script scope with no way to detach.
|
|
926
|
+
const onResize = () => moduleInstance?.rebuildSliders?.();
|
|
927
|
+
window.addEventListener("resize", onResize);
|
|
928
|
+
onCleanup?.(() => window.removeEventListener("resize", onResize));
|
|
929
|
+
eventHandler.bind(CSVGatingList.events.SELECTION_CHANGED, () => {
|
|
930
|
+
updateSeaDragonSelection();
|
|
931
|
+
updateCentroidsForGate();
|
|
932
|
+
runSegmentationGate(true);
|
|
933
|
+
});
|
|
934
|
+
eventHandler.bind(CSVGatingList.events.GATING_BRUSH_MOVE, () => {
|
|
935
|
+
updateSeaDragonSelection();
|
|
936
|
+
runSegmentationGate(false);
|
|
937
|
+
});
|
|
938
|
+
eventHandler.bind(CSVGatingList.events.RESET_GATINGLIST, () => {
|
|
939
|
+
moduleInstance.resetGatingList();
|
|
940
|
+
seaDragonViewer.forceRepaint();
|
|
941
|
+
});
|
|
942
|
+
eventHandler.bind(ctx.coreEvents.RESET_LISTS, () => {
|
|
943
|
+
moduleInstance.resetGatingList();
|
|
944
|
+
});
|
|
945
|
+
},
|
|
946
|
+
});
|
|
947
|
+
}
|