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,367 @@
|
|
|
1
|
+
"""Which columns can be coloured by, and whether each is categories or numbers.
|
|
2
|
+
|
|
3
|
+
The inference lives here, on the server, for two reasons. It needs the values,
|
|
4
|
+
which the browser does not have until it asks for them -- and asking for a
|
|
5
|
+
column in order to find out whether it is worth offering defeats the point. And
|
|
6
|
+
it has to give the same answer to every caller: the legend's category order, the
|
|
7
|
+
codes in the binary payload and the colour a category gets are all derived from
|
|
8
|
+
one descriptor, so a second opinion computed anywhere else would silently
|
|
9
|
+
mislabel cells.
|
|
10
|
+
|
|
11
|
+
Three judgements are made, in increasing order of how wrong they can be:
|
|
12
|
+
|
|
13
|
+
**Categorical or continuous.** Strings, booleans and anything the source
|
|
14
|
+
declared as a categorical are categories. Floats are numbers. The awkward middle
|
|
15
|
+
is a low-cardinality integer -- `leiden` 0..12, `grade` 1..3, a 0/1 flag -- which
|
|
16
|
+
is stored as a number and means a category. Those are called categorical and
|
|
17
|
+
marked `ambiguous`, which is what puts a two-button override in the panel. Only
|
|
18
|
+
for those: an override control on every variable is noise on the ninety percent
|
|
19
|
+
that were never in doubt.
|
|
20
|
+
|
|
21
|
+
**Identifier-like.** A column with a distinct value for nearly every cell is not
|
|
22
|
+
a variable, it is a name. Colouring by it produces a picture with as many
|
|
23
|
+
colours as cells, which is indistinguishable from noise and takes a while to
|
|
24
|
+
draw. Those are flagged `identifier_like` and marked in the dropdown, but they
|
|
25
|
+
are still offered and still selectable -- "nearly every" is a heuristic, and
|
|
26
|
+
somebody's 40-sample cohort in a million-cell table is a real thing to want.
|
|
27
|
+
The flag's one hard effect is that such a column is never chosen automatically.
|
|
28
|
+
|
|
29
|
+
**Too many categories.** Between roughly thirty and a hundred, colours stop
|
|
30
|
+
being tellable apart but the picture is still useful with search and isolation,
|
|
31
|
+
so it renders with a notice. Past that it needs an explicit confirmation.
|
|
32
|
+
|
|
33
|
+
Descriptor fields, and one deliberate omission: there is no `inferred_kind`
|
|
34
|
+
alongside `kind`. They would always have been equal -- the server does not see
|
|
35
|
+
the user's overrides, which live in the plugin's own state and are applied in
|
|
36
|
+
the browser. What the client needs is what inference decided (`kind`) and
|
|
37
|
+
whether to offer the override at all (`ambiguous`), and for an ambiguous column
|
|
38
|
+
BOTH the category list and the numeric stats are sent, so flipping the override
|
|
39
|
+
is instant and needs no second request.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import math
|
|
45
|
+
import re
|
|
46
|
+
|
|
47
|
+
import numpy as np
|
|
48
|
+
|
|
49
|
+
#: How a missing value arrives once the values have been flattened to text.
|
|
50
|
+
#: One list, used both to build the category list and to encode the values, so
|
|
51
|
+
#: a value counted as missing in the legend is the same one drawn transparent.
|
|
52
|
+
MISSING_TOKENS = ("", "nan", "NaN", "NAN", "None", "NA", "N/A", "<NA>",
|
|
53
|
+
"null", "NULL", "NaT")
|
|
54
|
+
|
|
55
|
+
#: At or below this many distinct whole numbers, a numeric column is treated as
|
|
56
|
+
#: categories rather than a scale -- and flagged, since it is a guess. Chosen
|
|
57
|
+
#: to cover cluster labels and scores comfortably (leiden rarely exceeds ~30)
|
|
58
|
+
#: while leaving a genuine measurement recorded in whole units alone.
|
|
59
|
+
CATEGORICAL_INTEGER_LIMIT = 32
|
|
60
|
+
|
|
61
|
+
#: Above this share of distinct values, over a table big enough for the share to
|
|
62
|
+
#: mean anything, a column behaves like a name rather than a variable.
|
|
63
|
+
IDENTIFIER_UNIQUE_RATIO = 0.95
|
|
64
|
+
IDENTIFIER_MIN_ROWS = 1000
|
|
65
|
+
|
|
66
|
+
#: Where a legend stops being readable, and where it stops being sensible.
|
|
67
|
+
NOTICE_CATEGORY_COUNT = 30
|
|
68
|
+
CONFIRM_CATEGORY_COUNT = 100
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def eligible_columns(dataset) -> list[str]:
|
|
72
|
+
"""Every annotation column, in the source's own order.
|
|
73
|
+
|
|
74
|
+
Nothing is held back. An earlier version of this dropped the columns that
|
|
75
|
+
say how the table is READ -- the cell id, x, y -- on the grounds that
|
|
76
|
+
colouring by x draws a gradient across the slide rather than a finding. That
|
|
77
|
+
reasoning is sound about what is USUALLY wanted and wrong about what is
|
|
78
|
+
sometimes needed: a gradient across the slide is exactly how you check that
|
|
79
|
+
coordinates were imported the right way up, and a table's `sample_id` is a
|
|
80
|
+
perfectly good thing to colour by. A prefilter also cannot be argued with
|
|
81
|
+
from the panel, so a column it decided against simply was not there, with
|
|
82
|
+
nothing on screen to say why.
|
|
83
|
+
|
|
84
|
+
What replaced it is a flag, not a filter: `identifier_like` marks the
|
|
85
|
+
columns that will draw as many colours as cells, the dropdown says so, and
|
|
86
|
+
the choice stays the user's. See `_looks_like_an_identifier`.
|
|
87
|
+
|
|
88
|
+
Source order rather than sorted: a table's column order usually means
|
|
89
|
+
something to whoever produced it, and re-sorting makes a familiar file
|
|
90
|
+
unfamiliar. The dropdown searches by name for finding one in a wide table.
|
|
91
|
+
"""
|
|
92
|
+
seen = set()
|
|
93
|
+
columns = []
|
|
94
|
+
for name in dataset.table.metadata_columns:
|
|
95
|
+
if name in seen:
|
|
96
|
+
continue
|
|
97
|
+
seen.add(name)
|
|
98
|
+
columns.append(name)
|
|
99
|
+
return columns
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def describe_all(dataset) -> list[dict]:
|
|
103
|
+
"""A descriptor per eligible column.
|
|
104
|
+
|
|
105
|
+
Cached against the datasource, so opening the dropdown on a five-million-row
|
|
106
|
+
table does not re-scan every column each time. `Dataset.cached` entries die
|
|
107
|
+
with the load generation, so a project whose data was re-read cannot be
|
|
108
|
+
served descriptors derived from the previous table.
|
|
109
|
+
"""
|
|
110
|
+
return dataset.cached(
|
|
111
|
+
("cell_explorer", "variables"),
|
|
112
|
+
lambda: [describe(dataset, name) for name in eligible_columns(dataset)],
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def describe(dataset, column: str) -> dict:
|
|
117
|
+
"""One column's descriptor. Raises KeyError if the project has no such
|
|
118
|
+
column."""
|
|
119
|
+
values = dataset.table.metadata_values(column)
|
|
120
|
+
return _describe_values(
|
|
121
|
+
column,
|
|
122
|
+
values.values,
|
|
123
|
+
declared=values.categories,
|
|
124
|
+
is_cell_id=bool(dataset.schema and dataset.schema.cell_id == column),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def find(dataset, column: str) -> dict | None:
|
|
129
|
+
"""The descriptor for one column out of the cached set, or None.
|
|
130
|
+
|
|
131
|
+
Read from the cached list rather than recomputed, because the category order
|
|
132
|
+
in it is what the value codes are indexed against -- deriving it a second
|
|
133
|
+
time invites the two drifting apart, which is not an error anywhere, just
|
|
134
|
+
every cell labelled as its neighbour.
|
|
135
|
+
"""
|
|
136
|
+
for descriptor in describe_all(dataset):
|
|
137
|
+
if descriptor["name"] == column:
|
|
138
|
+
return descriptor
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# --------------------------------------------------------------------------
|
|
143
|
+
# Inference
|
|
144
|
+
# --------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def _describe_values(column, values, declared=None, is_cell_id=False) -> dict:
|
|
147
|
+
values = np.asarray(values)
|
|
148
|
+
total = int(values.size)
|
|
149
|
+
text, missing = as_text(values)
|
|
150
|
+
valid = int(total - int(missing.sum()))
|
|
151
|
+
|
|
152
|
+
descriptor = {
|
|
153
|
+
"name": column,
|
|
154
|
+
"n": total,
|
|
155
|
+
"n_missing": int(total - valid),
|
|
156
|
+
"ambiguous": False,
|
|
157
|
+
"identifier_like": False,
|
|
158
|
+
"notice": None,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
numeric = _numeric_values(values)
|
|
162
|
+
distinct = int(np.unique(text[~missing]).size) if valid else 0
|
|
163
|
+
descriptor["n_unique"] = distinct
|
|
164
|
+
|
|
165
|
+
if _looks_like_an_identifier(column, distinct, valid, is_cell_id):
|
|
166
|
+
# A label, not a veto. The panel marks it and declines to choose it on
|
|
167
|
+
# the user's behalf; picking it deliberately works like any other
|
|
168
|
+
# column, which is why it is described just as fully as one.
|
|
169
|
+
descriptor["identifier_like"] = True
|
|
170
|
+
|
|
171
|
+
kind = _infer_kind(values, numeric, declared, distinct, valid)
|
|
172
|
+
# The override is offered exactly where the guess was a guess: a numeric
|
|
173
|
+
# column called categorical because its values are few and whole. Everything
|
|
174
|
+
# else was decided by something that is not in doubt -- text is labels,
|
|
175
|
+
# floats with a spread are a scale, a declared categorical says so itself.
|
|
176
|
+
ambiguous = numeric is not None and kind == "categorical" and declared is None
|
|
177
|
+
descriptor["kind"] = kind
|
|
178
|
+
descriptor["ambiguous"] = ambiguous
|
|
179
|
+
|
|
180
|
+
if kind == "categorical":
|
|
181
|
+
descriptor.update(_categorical_payload(text, missing, declared))
|
|
182
|
+
# Both halves for an ambiguous column, which costs nothing (it has at most
|
|
183
|
+
# CATEGORICAL_INTEGER_LIMIT categories) and makes flipping the override a
|
|
184
|
+
# client-side relabel of data already in hand rather than a refetch.
|
|
185
|
+
if kind == "continuous" or ambiguous:
|
|
186
|
+
descriptor["stats"] = _continuous_stats(numeric)
|
|
187
|
+
|
|
188
|
+
return descriptor
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _infer_kind(values, numeric, declared, distinct, valid) -> str:
|
|
192
|
+
if declared is not None:
|
|
193
|
+
# The source called it a categorical. Nothing derived from the values
|
|
194
|
+
# beats the file saying so -- that is the one signal that survives a
|
|
195
|
+
# column of stringified integers.
|
|
196
|
+
return "categorical"
|
|
197
|
+
if values.dtype.kind == "b":
|
|
198
|
+
# Two categories, not a 0-1 gradient. Rendering True/False on a
|
|
199
|
+
# continuous ramp gives two colours that both look like "some of the
|
|
200
|
+
# scale" rather than two labels.
|
|
201
|
+
return "categorical"
|
|
202
|
+
if numeric is None:
|
|
203
|
+
return "categorical"
|
|
204
|
+
if valid and _all_whole(numeric) and distinct <= CATEGORICAL_INTEGER_LIMIT:
|
|
205
|
+
return "categorical"
|
|
206
|
+
return "continuous"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _looks_like_an_identifier(column, distinct, valid, is_cell_id) -> bool:
|
|
210
|
+
if is_cell_id:
|
|
211
|
+
return True
|
|
212
|
+
if valid < IDENTIFIER_MIN_ROWS:
|
|
213
|
+
# Below this, a high distinct ratio says nothing: forty cells with forty
|
|
214
|
+
# phenotypes is a small experiment, not a barcode column.
|
|
215
|
+
return False
|
|
216
|
+
return distinct / valid > IDENTIFIER_UNIQUE_RATIO
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _numeric_values(values):
|
|
220
|
+
"""The column as float64, or None if it is not numbers.
|
|
221
|
+
|
|
222
|
+
Text is deliberately NOT parsed back into numbers here. A CSV column of
|
|
223
|
+
"1"/"2"/"3" is a column of labels as far as this is concerned -- and it is
|
|
224
|
+
already handled, because a text column is categorical, which is what those
|
|
225
|
+
are. Coercing would turn cluster ids into a gradient.
|
|
226
|
+
"""
|
|
227
|
+
if values.dtype.kind in "iuf":
|
|
228
|
+
return values.astype(np.float64, copy=False)
|
|
229
|
+
if values.dtype.kind == "b":
|
|
230
|
+
return values.astype(np.float64)
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _all_whole(numeric) -> bool:
|
|
235
|
+
finite = numeric[np.isfinite(numeric)]
|
|
236
|
+
return bool(finite.size) and bool(np.all(finite == np.round(finite)))
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def as_text(values):
|
|
240
|
+
"""(text, missing) for any column.
|
|
241
|
+
|
|
242
|
+
One normalization, shared with the encoder, so a value that becomes the
|
|
243
|
+
category "Tumor" in the legend becomes the code for "Tumor" in the payload.
|
|
244
|
+
Doing this twice with two rules is how a legend ends up describing a picture
|
|
245
|
+
it does not match.
|
|
246
|
+
"""
|
|
247
|
+
values = np.asarray(values)
|
|
248
|
+
if values.size == 0:
|
|
249
|
+
return np.empty(0, dtype="<U1"), np.zeros(0, dtype=bool)
|
|
250
|
+
|
|
251
|
+
if values.dtype.kind in "fc":
|
|
252
|
+
numeric = values.astype(np.float64, copy=False)
|
|
253
|
+
missing = ~np.isfinite(numeric)
|
|
254
|
+
finite = numeric[~missing]
|
|
255
|
+
text = np.empty(values.shape, dtype=object)
|
|
256
|
+
if finite.size and np.all(finite == np.round(finite)):
|
|
257
|
+
# Whole numbers get whole-number labels. A cluster column stored as
|
|
258
|
+
# float otherwise reads "3.0" in the legend, which looks like a
|
|
259
|
+
# measurement rather than the label it is.
|
|
260
|
+
text[~missing] = finite.astype(np.int64).astype(str)
|
|
261
|
+
else:
|
|
262
|
+
text[~missing] = finite.astype(str)
|
|
263
|
+
text[missing] = ""
|
|
264
|
+
return text.astype(str), missing
|
|
265
|
+
|
|
266
|
+
if values.dtype.kind in "iub":
|
|
267
|
+
return values.astype(str), np.zeros(values.shape, dtype=bool)
|
|
268
|
+
|
|
269
|
+
# Object or string. None, pandas' NA and an empty CSV cell all survive the
|
|
270
|
+
# cast as text, so they are recognised by name rather than by dtype.
|
|
271
|
+
text = values.astype(str)
|
|
272
|
+
return text, np.isin(text, MISSING_TOKENS)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# --------------------------------------------------------------------------
|
|
276
|
+
# Payloads
|
|
277
|
+
# --------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
def _categorical_payload(text, missing, declared) -> dict:
|
|
280
|
+
present = text[~missing]
|
|
281
|
+
if present.size:
|
|
282
|
+
labels, counts = np.unique(present, return_counts=True)
|
|
283
|
+
tally = dict(zip(labels.tolist(), counts.tolist()))
|
|
284
|
+
else:
|
|
285
|
+
tally = {}
|
|
286
|
+
|
|
287
|
+
order = _category_order(tally.keys(), declared)
|
|
288
|
+
categories = [{"value": value, "count": tally[value]} for value in order]
|
|
289
|
+
n_categories = len(categories)
|
|
290
|
+
return {
|
|
291
|
+
"categories": categories,
|
|
292
|
+
"n_categories": n_categories,
|
|
293
|
+
"notice": _cardinality_notice(n_categories),
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _category_order(present, declared) -> list[str]:
|
|
298
|
+
"""Legend order: what the file said, else a natural sort.
|
|
299
|
+
|
|
300
|
+
Declared levels that no cell actually has are dropped. They are real levels
|
|
301
|
+
in the file, but a legend row that cannot be shown or hidden and colours
|
|
302
|
+
nothing is a row that only takes up space -- and on a subset project (one
|
|
303
|
+
image out of twenty) most of the declared levels are typically absent.
|
|
304
|
+
|
|
305
|
+
A value present in the data but missing from the declared list is appended
|
|
306
|
+
rather than dropped, because dropping it would leave those cells silently
|
|
307
|
+
uncoloured.
|
|
308
|
+
"""
|
|
309
|
+
present = set(present)
|
|
310
|
+
if declared:
|
|
311
|
+
known = [str(value) for value in declared if str(value) in present]
|
|
312
|
+
extra = sorted(present - set(known), key=natural_key)
|
|
313
|
+
return known + extra
|
|
314
|
+
return sorted(present, key=natural_key)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
_NUMBER_RUN = re.compile(r"(\d+)")
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def natural_key(value: str):
|
|
321
|
+
"""Sort "Cluster 2" before "Cluster 10".
|
|
322
|
+
|
|
323
|
+
Plain lexicographic order puts 10 before 2, which makes a legend of numbered
|
|
324
|
+
clusters read as though the numbering were arbitrary. The tuples never
|
|
325
|
+
compare a number against a string: the leading 0/1 separates them first.
|
|
326
|
+
"""
|
|
327
|
+
return tuple(
|
|
328
|
+
(1, int(part), "") if part.isdigit() else (0, 0, part.lower())
|
|
329
|
+
for part in _NUMBER_RUN.split(str(value)) if part != ""
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _cardinality_notice(n_categories):
|
|
334
|
+
if n_categories > CONFIRM_CATEGORY_COUNT:
|
|
335
|
+
return "high_cardinality"
|
|
336
|
+
if n_categories > NOTICE_CATEGORY_COUNT:
|
|
337
|
+
return "many_categories"
|
|
338
|
+
return None
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _continuous_stats(numeric) -> dict:
|
|
342
|
+
"""min/max plus the robust range the display defaults to.
|
|
343
|
+
|
|
344
|
+
p01/p99 rather than the literal extremes, because one outlying cell
|
|
345
|
+
compresses every other cell into a few percent of the ramp -- a picture that
|
|
346
|
+
is technically correct and shows nothing. The true min and max are still
|
|
347
|
+
reported so the panel can say what was clipped away.
|
|
348
|
+
"""
|
|
349
|
+
if numeric is None:
|
|
350
|
+
return {}
|
|
351
|
+
finite = numeric[np.isfinite(numeric)]
|
|
352
|
+
if not finite.size:
|
|
353
|
+
return {}
|
|
354
|
+
low, mid, high = (float(v) for v in np.percentile(finite, [1, 50, 99]))
|
|
355
|
+
if not math.isfinite(low) or not math.isfinite(high):
|
|
356
|
+
return {}
|
|
357
|
+
return {
|
|
358
|
+
"min": float(finite.min()),
|
|
359
|
+
"max": float(finite.max()),
|
|
360
|
+
"p01": low,
|
|
361
|
+
"p50": mid,
|
|
362
|
+
"p99": high,
|
|
363
|
+
# A column where every cell holds the same number has a zero-width
|
|
364
|
+
# scale. Said here rather than left for the client to rediscover by
|
|
365
|
+
# dividing by it.
|
|
366
|
+
"constant": bool(finite.min() == finite.max()),
|
|
367
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cellExplorerApi.js - the four server calls, and the value cache.
|
|
3
|
+
*
|
|
4
|
+
* Two things here are load-bearing beyond "fetch some JSON".
|
|
5
|
+
*
|
|
6
|
+
* **Stale responses can never win.** Selecting a column starts a request;
|
|
7
|
+
* selecting another one starts a second. They can finish in either order, and
|
|
8
|
+
* the first finishing last would repaint the image with the previous variable's
|
|
9
|
+
* colours under the current variable's legend. Every request carries the
|
|
10
|
+
* generation it was issued at, the caller compares it on arrival, and the
|
|
11
|
+
* outgoing one is aborted besides. Both, not either: an abort is best-effort
|
|
12
|
+
* and a response already in flight still resolves.
|
|
13
|
+
*
|
|
14
|
+
* **Values are cached, so the cheap interactions stay cheap.** Palette,
|
|
15
|
+
* opacity, category colour, visibility and range never refetch -- they rebuild
|
|
16
|
+
* the lookup table from the arrays already here. Switching back to a column
|
|
17
|
+
* looked at a moment ago is likewise free. Bounded to eight columns, least
|
|
18
|
+
* recently used first, because each entry is a value per cell and a browser tab
|
|
19
|
+
* holding twenty of them on a five-million-cell table is a tab that stops.
|
|
20
|
+
*/
|
|
21
|
+
class CellExplorerApi {
|
|
22
|
+
|
|
23
|
+
/** How many decoded columns to keep. Enough that going back and forth
|
|
24
|
+
* between the two or three variables somebody is comparing is instant. */
|
|
25
|
+
static CACHE_LIMIT = 8;
|
|
26
|
+
|
|
27
|
+
constructor(ctx) {
|
|
28
|
+
this.ctx = ctx;
|
|
29
|
+
this.datasource = ctx.datasource;
|
|
30
|
+
this._cache = new Map();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
url(path, params) {
|
|
34
|
+
const query = new URLSearchParams({ datasource: this.datasource, ...params });
|
|
35
|
+
return `${this.ctx.url(`plugins/cell_explorer/api/${path}`)}?${query}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async variables(signal) {
|
|
39
|
+
const response = await fetch(this.url("variables"), { signal });
|
|
40
|
+
if (!response.ok) throw new Error(`variables: ${response.status}`);
|
|
41
|
+
return response.json();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async state(signal) {
|
|
45
|
+
const response = await fetch(this.url("state"), { signal });
|
|
46
|
+
if (response.status === 422) {
|
|
47
|
+
// Written by a newer Plexora. Reported rather than thrown, because
|
|
48
|
+
// the panel still works -- it just must not save over it.
|
|
49
|
+
return { ...(await response.json()), unreadable: true };
|
|
50
|
+
}
|
|
51
|
+
if (!response.ok) throw new Error(`state: ${response.status}`);
|
|
52
|
+
return response.json();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async save(revision, settings) {
|
|
56
|
+
const response = await fetch(
|
|
57
|
+
this.ctx.url("plugins/cell_explorer/api/state"),
|
|
58
|
+
{
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: { "Content-Type": "application/json" },
|
|
61
|
+
body: JSON.stringify({ datasource: this.datasource, revision, settings }),
|
|
62
|
+
},
|
|
63
|
+
);
|
|
64
|
+
const payload = await response.json().catch(() => ({}));
|
|
65
|
+
if (response.status === 409) {
|
|
66
|
+
return { ...payload, conflict: true };
|
|
67
|
+
}
|
|
68
|
+
if (!response.ok) throw new Error(payload.error || `save: ${response.status}`);
|
|
69
|
+
return payload;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
cacheKey(column, kind) {
|
|
73
|
+
return `${this.datasource}:${column}:${kind || "auto"}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* One column's values, from the cache or the network.
|
|
78
|
+
*
|
|
79
|
+
* @param column the metadata column
|
|
80
|
+
* @param kind "categorical" | "continuous" to override the server's
|
|
81
|
+
* inference, or null to take it
|
|
82
|
+
* @param signal an AbortSignal for the caller's current generation
|
|
83
|
+
*/
|
|
84
|
+
async values(column, kind, signal) {
|
|
85
|
+
const key = this.cacheKey(column, kind);
|
|
86
|
+
const cached = this._cache.get(key);
|
|
87
|
+
if (cached) {
|
|
88
|
+
// Re-inserted so it counts as recently used. A Map iterates in
|
|
89
|
+
// insertion order, which is what makes the eviction below LRU
|
|
90
|
+
// rather than first-in.
|
|
91
|
+
this._cache.delete(key);
|
|
92
|
+
this._cache.set(key, cached);
|
|
93
|
+
return cached;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const params = kind ? { column, kind } : { column };
|
|
97
|
+
const response = await fetch(this.url("values", params), { signal });
|
|
98
|
+
if (!response.ok) {
|
|
99
|
+
const detail = await response.json().catch(() => ({}));
|
|
100
|
+
throw new Error(detail.error || `values: ${response.status}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const decoded = CellExplorerApi.decode(
|
|
104
|
+
await response.arrayBuffer(),
|
|
105
|
+
response.headers.get("X-Value-Kind") || "categorical",
|
|
106
|
+
Number(response.headers.get("X-Cell-Count") || 0),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
this._cache.set(key, decoded);
|
|
110
|
+
while (this._cache.size > CellExplorerApi.CACHE_LIMIT) {
|
|
111
|
+
this._cache.delete(this._cache.keys().next().value);
|
|
112
|
+
}
|
|
113
|
+
return decoded;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Drop everything. For when the table underneath has changed. */
|
|
117
|
+
clearCache() {
|
|
118
|
+
this._cache.clear();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Unpack one of the two record layouts (see server/values.py).
|
|
123
|
+
*
|
|
124
|
+
* categorical id uint32, code uint16 -- 6 bytes, no alignment
|
|
125
|
+
* continuous id uint32, value float32 -- 8 bytes, 4-byte aligned
|
|
126
|
+
*
|
|
127
|
+
* The continuous case is read as two interleaved typed-array views over the
|
|
128
|
+
* same buffer, which is a handful of milliseconds for a million cells. The
|
|
129
|
+
* categorical stride is 6, so no typed array can be laid over it and a
|
|
130
|
+
* DataView loop is the honest way to read it.
|
|
131
|
+
*/
|
|
132
|
+
static decode(buffer, kind, count) {
|
|
133
|
+
if (kind === "continuous") {
|
|
134
|
+
const stride = 8;
|
|
135
|
+
const rows = count || Math.floor(buffer.byteLength / stride);
|
|
136
|
+
const asUint = new Uint32Array(buffer, 0, rows * 2);
|
|
137
|
+
const asFloat = new Float32Array(buffer, 0, rows * 2);
|
|
138
|
+
const ids = new Uint32Array(rows);
|
|
139
|
+
const values = new Float32Array(rows);
|
|
140
|
+
for (let i = 0; i < rows; i += 1) {
|
|
141
|
+
ids[i] = asUint[i * 2];
|
|
142
|
+
values[i] = asFloat[i * 2 + 1];
|
|
143
|
+
}
|
|
144
|
+
return { kind, ids, values, count: rows };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const stride = 6;
|
|
148
|
+
const rows = count || Math.floor(buffer.byteLength / stride);
|
|
149
|
+
const view = new DataView(buffer);
|
|
150
|
+
const ids = new Uint32Array(rows);
|
|
151
|
+
const codes = new Uint16Array(rows);
|
|
152
|
+
for (let i = 0; i < rows; i += 1) {
|
|
153
|
+
ids[i] = view.getUint32(i * stride, true);
|
|
154
|
+
codes[i] = view.getUint16(i * stride + 4, true);
|
|
155
|
+
}
|
|
156
|
+
return { kind: "categorical", ids, codes, count: rows };
|
|
157
|
+
}
|
|
158
|
+
}
|