mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-10-10 21:40:56 +08:00
Merge branch 'main' into colinmegill/geneset-prototype
This commit is contained in:
@@ -5,7 +5,7 @@ on:
|
|||||||
- cron: '0 8 7 * 2'
|
- cron: '0 8 7 * 2'
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
JEST_ENV: prod
|
JEST_ENV: prod
|
||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
- name: Build docker image
|
- name: Build docker image
|
||||||
run: docker build .
|
run: docker build .
|
||||||
|
|
||||||
cellxgene-master-with-python-and-anndata-versions:
|
cellxgene-main-with-python-and-anndata-versions:
|
||||||
name: python versions x anndata versions
|
name: python versions x anndata versions
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
@@ -86,8 +86,8 @@ jobs:
|
|||||||
- name: Tests
|
- name: Tests
|
||||||
run: cd cellxgene && make unit-test ${{ matrix.test-suite }}
|
run: cd cellxgene && make unit-test ${{ matrix.test-suite }}
|
||||||
|
|
||||||
cellxgene-master-with-anndata-master:
|
cellxgene-main-with-anndata-master:
|
||||||
name: cellxgene master with anndata master
|
name: cellxgene main with anndata master
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: Deploy via single cell infra repo
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: master
|
branches: main
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ name: Push Tests
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: master
|
branches: main
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: '*'
|
branches: "*"
|
||||||
|
|
||||||
env:
|
env:
|
||||||
JEST_ENV: prod
|
JEST_ENV: prod
|
||||||
@@ -32,7 +32,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
pip install flake8
|
pip install flake8
|
||||||
cd client
|
cd client
|
||||||
npm i "eslint" "eslint-config-airbnb" "eslint-config-prettier" "eslint-loader" "eslint-plugin-filenames" "eslint-plugin-import" "eslint-plugin-jest" "eslint-plugin-jsx-a11y" "eslint-plugin-react" "eslint-plugin-react-hooks" "eslint-plugin-prettier"
|
npm install
|
||||||
- name: Lint with flake8
|
- name: Lint with flake8
|
||||||
run: |
|
run: |
|
||||||
make lint-server
|
make lint-server
|
||||||
@@ -41,7 +41,6 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
make lint
|
make lint
|
||||||
|
|
||||||
|
|
||||||
unit-test:
|
unit-test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -73,7 +72,7 @@ jobs:
|
|||||||
cd client && ./node_modules/codecov/bin/codecov --yml=../.codecov.yml --root=../ --gcov-root=../ -C -F frontend,javascript,unitTest
|
cd client && ./node_modules/codecov/bin/codecov --yml=../.codecov.yml --root=../ --gcov-root=../ -C -F frontend,javascript,unitTest
|
||||||
|
|
||||||
smoke-tests:
|
smoke-tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: macos-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- name: Set up Python 3.7
|
- name: Set up Python 3.7
|
||||||
|
|||||||
+5
-5
@@ -19,11 +19,7 @@ venv/
|
|||||||
cellxgene/
|
cellxgene/
|
||||||
|
|
||||||
# client build
|
# client build
|
||||||
server/common/web/static/css/
|
server/common/web/static/*
|
||||||
server/common/web/static/img/
|
|
||||||
server/common/web/static/media/
|
|
||||||
server/common/web/static/fonts/
|
|
||||||
server/common/web/static/js/
|
|
||||||
server/common/web/templates/
|
server/common/web/templates/
|
||||||
server/common/web/csp-hashes.json
|
server/common/web/csp-hashes.json
|
||||||
|
|
||||||
@@ -47,9 +43,13 @@ npm-debug.log
|
|||||||
__pycache__
|
__pycache__
|
||||||
*.DS_Store*
|
*.DS_Store*
|
||||||
data
|
data
|
||||||
|
tags
|
||||||
|
|
||||||
# Jekyll
|
# Jekyll
|
||||||
docs/_site/
|
docs/_site/
|
||||||
docs/Gemfile.lock
|
docs/Gemfile.lock
|
||||||
|
|
||||||
client/.eslintcache
|
client/.eslintcache
|
||||||
|
|
||||||
|
# E2E Testing
|
||||||
|
ignoreE2E*
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ pydist: build
|
|||||||
|
|
||||||
# RELEASE HELPERS
|
# RELEASE HELPERS
|
||||||
|
|
||||||
# create new version to commit to master
|
# create new version to commit to main
|
||||||
.PHONY: release-stage-1
|
.PHONY: release-stage-1
|
||||||
release-stage-1: dev-env bump clean-lite gen-package-lock
|
release-stage-1: dev-env bump clean-lite gen-package-lock
|
||||||
@echo "Version bumped part:$(PART) and client built. Ready to commit and push"
|
@echo "Version bumped part:$(PART) and client built. Ready to commit and push"
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ _an interactive explorer for single-cell transcriptomics data_
|
|||||||
[](https://zenodo.org/badge/latestdoi/105615409) [](https://pypi.org/project/cellxgene/) [](https://pypistats.org/packages/cellxgene) [](https://github.com/chanzuckerberg/cellxgene/pulse)
|
[](https://zenodo.org/badge/latestdoi/105615409) [](https://pypi.org/project/cellxgene/) [](https://pypistats.org/packages/cellxgene) [](https://github.com/chanzuckerberg/cellxgene/pulse)
|
||||||
[](https://github.com/chanzuckerberg/cellxgene/actions?query=workflow%3A%22Push+Tests%22)
|
[](https://github.com/chanzuckerberg/cellxgene/actions?query=workflow%3A%22Push+Tests%22)
|
||||||
[](https://github.com/chanzuckerberg/cellxgene/actions?query=workflow%3A%22Compatibility+Tests%22)
|
[](https://github.com/chanzuckerberg/cellxgene/actions?query=workflow%3A%22Compatibility+Tests%22)
|
||||||

|

|
||||||
|
|
||||||
cellxgene (pronounced "cell-by-gene") is an interactive data explorer for single-cell transcriptomics datasets, such as those coming from the [Human Cell Atlas](https://humancellatlas.org). Leveraging modern web development techniques to enable fast visualizations of at least 1 million cells, we hope to enable biologists and computational researchers to explore their data.
|
cellxgene (pronounced "cell-by-gene") is an interactive data explorer for single-cell transcriptomics datasets, such as those coming from the [Human Cell Atlas](https://humancellatlas.org). Leveraging modern web development techniques to enable fast visualizations of at least 1 million cells, we hope to enable biologists and computational researchers to explore their data.
|
||||||
|
|
||||||
Whether you need to visualize one thousand cells or one million, cellxgene helps you gain insight into your single-cell data.
|
Whether you need to visualize one thousand cells or one million, cellxgene helps you gain insight into your single-cell data.
|
||||||
|
|
||||||
<img src="https://github.com/chanzuckerberg/cellxgene/raw/master/docs/images/crossfilter.gif" width="350" height="200" hspace="30"><img src="https://github.com/chanzuckerberg/cellxgene/raw/master/docs/images/category-breakdown.gif" width="350" height="200" hspace="30">
|
<img src="https://github.com/chanzuckerberg/cellxgene/raw/main/docs/images/crossfilter.gif" width="350" height="200" hspace="30"><img src="https://github.com/chanzuckerberg/cellxgene/raw/main/docs/images/category-breakdown.gif" width="350" height="200" hspace="30">
|
||||||
|
|
||||||
# Getting started
|
# Getting started
|
||||||
### The comprehensive guide to cellxgene
|
### The comprehensive guide to cellxgene
|
||||||
|
|||||||
@@ -11,8 +11,12 @@
|
|||||||
"dataviz"
|
"dataviz"
|
||||||
],
|
],
|
||||||
"buildpacks": [
|
"buildpacks": [
|
||||||
"heroku/nodejs",
|
{
|
||||||
"heroku/python"
|
"url": "heroku/nodejs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "heroku/python"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"stack": "heroku-18",
|
"stack": "heroku-18",
|
||||||
"env": {
|
"env": {
|
||||||
|
|||||||
+6
-25
@@ -7,6 +7,7 @@ ANNOTATIONS_FILENAME := $(shell basename $(ANNOTATIONS))
|
|||||||
.PHONY: clean
|
.PHONY: clean
|
||||||
clean:
|
clean:
|
||||||
rm -rf node_modules
|
rm -rf node_modules
|
||||||
|
rm -f __tests__/screenshots/*.png
|
||||||
|
|
||||||
.PHONY: ci
|
.PHONY: ci
|
||||||
ci:
|
ci:
|
||||||
@@ -21,39 +22,20 @@ WEBPACK_CONFIG ?= configuration/webpack/webpack.config.prod.js
|
|||||||
build:
|
build:
|
||||||
npm run build $(WEBPACK_CONFIG)
|
npm run build $(WEBPACK_CONFIG)
|
||||||
|
|
||||||
# Formatting code
|
|
||||||
|
|
||||||
.PHONY: lint
|
|
||||||
lint:
|
|
||||||
npx eslint ./src/
|
|
||||||
|
|
||||||
# Development convenience methods
|
# Development convenience methods
|
||||||
.PHONY: start-frontend
|
.PHONY: start-frontend
|
||||||
start-frontend:
|
start-frontend:
|
||||||
node server/development.js
|
node server/development.js
|
||||||
|
|
||||||
.PHONY: e2e
|
# start an instance of cellxgene and run the end-to-end tests
|
||||||
e2e:
|
|
||||||
node node_modules/jest/bin/jest.js \
|
|
||||||
--verbose false \
|
|
||||||
--config __tests__/e2e/e2eJestConfig.json \
|
|
||||||
e2e/e2e.test.js
|
|
||||||
|
|
||||||
.PHONY: e2e-annotations
|
|
||||||
e2e-annotations:
|
|
||||||
node node_modules/jest/bin/jest.js \
|
|
||||||
--verbose false \
|
|
||||||
--config __tests__/e2e/e2eJestConfig.json \
|
|
||||||
e2e/e2eAnnotations.test.js
|
|
||||||
|
|
||||||
.PHONY: smoke-test
|
.PHONY: smoke-test
|
||||||
smoke-test:
|
smoke-test:
|
||||||
start_server_and_test \
|
start_server_and_test \
|
||||||
'CXG_OPTIONS="--disable-annotations" $(MAKE) start-server' \
|
'CXG_OPTIONS="--disable-annotations" $(MAKE) start-server' \
|
||||||
$(CXG_SERVER_PORT) \
|
$(CXG_SERVER_PORT) \
|
||||||
'$(MAKE) e2e'
|
'CXG_URL_BASE="http://localhost:$(CXG_SERVER_PORT)" npm run e2e -- --verbose false'
|
||||||
|
|
||||||
|
|
||||||
|
# start an instance of cellxgene and run the end-to-end annotations tests
|
||||||
.PHONY: smoke-test-annotations
|
.PHONY: smoke-test-annotations
|
||||||
smoke-test-annotations:
|
smoke-test-annotations:
|
||||||
$(eval TMP_DIR := $(shell mktemp -d /tmp/cellxgene_XXXXXX))
|
$(eval TMP_DIR := $(shell mktemp -d /tmp/cellxgene_XXXXXX))
|
||||||
@@ -61,13 +43,12 @@ smoke-test-annotations:
|
|||||||
start_server_and_test \
|
start_server_and_test \
|
||||||
'CXG_OPTIONS="--annotations-file $(TMP_DIR)/$(ANNOTATIONS_FILENAME)" $(MAKE) start-server' \
|
'CXG_OPTIONS="--annotations-file $(TMP_DIR)/$(ANNOTATIONS_FILENAME)" $(MAKE) start-server' \
|
||||||
$(CXG_SERVER_PORT) \
|
$(CXG_SERVER_PORT) \
|
||||||
'$(MAKE) e2e-annotations'
|
'CXG_URL_BASE="http://localhost:$(CXG_SERVER_PORT)" npm run e2e-annotations -- --verbose false'
|
||||||
rm -rf $(TMP_DIR)
|
rm -rf $(TMP_DIR)
|
||||||
|
|
||||||
.PHONY: unit-test
|
.PHONY: unit-test
|
||||||
unit-test:
|
unit-test:
|
||||||
node node_modules/jest/bin/jest.js \
|
node node_modules/jest/bin/jest.js --testPathIgnorePatterns e2e
|
||||||
--testPathIgnorePatterns e2e
|
|
||||||
|
|
||||||
# pass remaining commands through to npm run
|
# pass remaining commands through to npm run
|
||||||
%:
|
%:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
exports[`did launch page launched 1`] = `"<span style=\\"width: 185px; display: flex; overflow: hidden; justify-content: flex-start;\\"><span style=\\"overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 1; min-width: 5px;\\">pbm</span><span style=\\"color: transparent; position: relative; overflow: hidden; white-space: nowrap;\\">c3k<span style=\\"position: absolute; right: 0px; color: initial;\\">c3k</span></span></span>"`;
|
exports[`did launch page launched 1`] = `"<span style=\\"width: 185px; display: flex; overflow: hidden; justify-content: flex-start;\\"><span style=\\"overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 1; min-width: 5px;\\">pbm</span><span style=\\"position: relative; overflow: hidden; white-space: nowrap;\\"><span style=\\"color: transparent;\\">c3k</span><span style=\\"position: absolute; right: 0px; color: inherit;\\">c3k</span></span></span>"`;
|
||||||
|
|
||||||
exports[`metadata loads categories and values from dataset appear 1`] = `"<div style=\\"display: flex; justify-content: space-between; align-items: baseline;\\"><div style=\\"display: flex; justify-content: flex-start; align-items: flex-start;\\"><label class=\\"bp3-control bp3-checkbox\\" for=\\"category-select-louvain\\"><input id=\\"category-select-louvain\\" data-testclass=\\"category-select\\" data-testid=\\"louvain:category-select\\" type=\\"checkbox\\" checked=\\"\\"><span class=\\"bp3-control-indicator\\"></span></label><span role=\\"menuitem\\" tabindex=\\"0\\" data-testid=\\"louvain:category-expand\\" style=\\"cursor: pointer;\\"><span class=\\"bp3-popover-wrapper\\"><span class=\\"bp3-popover-target\\"><span data-testid=\\"louvain:category-label\\" aria-label=\\"louvain\\" class=\\"\\" tabindex=\\"0\\" style=\\"max-width: 265px;\\"><span style=\\"max-width: 265px; display: flex; overflow: hidden; justify-content: flex-start;\\"><span style=\\"overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 1; min-width: 5px;\\">lou</span><span style=\\"color: transparent; position: relative; overflow: hidden; white-space: nowrap;\\">vain<span style=\\"position: absolute; right: 0px; color: initial;\\">vain</span></span></span></span></span></span><svg stroke=\\"currentColor\\" fill=\\"currentColor\\" stroke-width=\\"0\\" viewBox=\\"0 0 320 512\\" data-testclass=\\"category-expand-is-not-expanded\\" height=\\"1em\\" width=\\"1em\\" xmlns=\\"http://www.w3.org/2000/svg\\" style=\\"font-size: 10px; margin-left: 5px;\\"><path d=\\"M285.476 272.971L91.132 467.314c-9.373 9.373-24.569 9.373-33.941 0l-22.667-22.667c-9.357-9.357-9.375-24.522-.04-33.901L188.505 256 34.484 101.255c-9.335-9.379-9.317-24.544.04-33.901l22.667-22.667c9.373-9.373 24.569-9.373 33.941 0L285.475 239.03c9.373 9.372 9.373 24.568.001 33.941z\\"></path></svg></span></div><div><span class=\\"bp3-popover-wrapper\\"><span class=\\"bp3-popover-target\\"><a role=\\"button\\" data-testclass=\\"colorby\\" data-testid=\\"colorby-louvain\\" class=\\"bp3-button\\" tabindex=\\"0\\"><span icon=\\"tint\\" class=\\"bp3-icon bp3-icon-tint\\"><svg data-icon=\\"tint\\" width=\\"16\\" height=\\"16\\" viewBox=\\"0 0 16 16\\"><desc>tint</desc><path d=\\"M7.88 1s-4.9 6.28-4.9 8.9c.01 2.82 2.34 5.1 4.99 5.1 2.65-.01 5.03-2.3 5.03-5.13C12.99 7.17 7.88 1 7.88 1z\\" fill-rule=\\"evenodd\\"></path></svg></span></a></span></span></div></div><div style=\\"margin-left: 26px;\\"><div></div></div><div></div>"`;
|
exports[`metadata loads categories and values from dataset appear 1`] = `"<div style=\\"display: flex; justify-content: space-between; align-items: baseline;\\"><div style=\\"display: flex; justify-content: flex-start; align-items: flex-start;\\"><label class=\\"bp3-control bp3-checkbox\\" for=\\"category-select-louvain\\"><input id=\\"category-select-louvain\\" data-testclass=\\"category-select\\" data-testid=\\"louvain:category-select\\" type=\\"checkbox\\" checked=\\"\\"><span class=\\"bp3-control-indicator\\"></span></label><span role=\\"menuitem\\" tabindex=\\"0\\" data-testclass=\\"category-expand\\" data-testid=\\"louvain:category-expand\\" style=\\"cursor: pointer;\\"><span class=\\"bp3-popover-wrapper\\"><span class=\\"bp3-popover-target\\"><span data-testid=\\"louvain:category-label\\" aria-label=\\"louvain\\" class=\\"\\" tabindex=\\"0\\" style=\\"max-width: 265px;\\"><span style=\\"max-width: 265px; display: flex; overflow: hidden; justify-content: flex-start;\\"><span style=\\"overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 1; min-width: 5px;\\">lou</span><span style=\\"position: relative; overflow: hidden; white-space: nowrap;\\"><span style=\\"color: transparent;\\">vain</span><span style=\\"position: absolute; right: 0px; color: inherit;\\">vain</span></span></span></span></span></span><svg stroke=\\"currentColor\\" fill=\\"currentColor\\" stroke-width=\\"0\\" viewBox=\\"0 0 320 512\\" data-testclass=\\"category-expand-is-not-expanded\\" height=\\"1em\\" width=\\"1em\\" xmlns=\\"http://www.w3.org/2000/svg\\" style=\\"font-size: 10px; margin-left: 5px;\\"><path d=\\"M285.476 272.971L91.132 467.314c-9.373 9.373-24.569 9.373-33.941 0l-22.667-22.667c-9.357-9.357-9.375-24.522-.04-33.901L188.505 256 34.484 101.255c-9.335-9.379-9.317-24.544.04-33.901l22.667-22.667c9.373-9.373 24.569-9.373 33.941 0L285.475 239.03c9.373 9.372 9.373 24.568.001 33.941z\\"></path></svg></span></div><div><span class=\\"bp3-popover-wrapper\\"><span class=\\"bp3-popover-target\\"><a role=\\"button\\" data-testclass=\\"colorby\\" data-testid=\\"colorby-louvain\\" class=\\"bp3-button\\" tabindex=\\"0\\"><span icon=\\"tint\\" class=\\"bp3-icon bp3-icon-tint\\"><svg data-icon=\\"tint\\" width=\\"16\\" height=\\"16\\" viewBox=\\"0 0 16 16\\"><desc>tint</desc><path d=\\"M7.88 1s-4.9 6.28-4.9 8.9c.01 2.82 2.34 5.1 4.99 5.1 2.65-.01 5.03-2.3 5.03-5.13C12.99 7.17 7.88 1 7.88 1z\\" fill-rule=\\"evenodd\\"></path></svg></span></a></span></span></div></div><div style=\\"margin-left: 26px;\\"></div><div></div>"`;
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`annotations stacked bar graph renders 1`] = `
|
||||||
|
Array [
|
||||||
|
"<div class=\\"categorical__value___2RKaC\\" data-testclass=\\"categorical-row\\" style=\\"padding: 4px 0px 4px 7px; display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 2px; border-radius: 2px;\\"><div style=\\"margin: 0px; padding: 0px; user-select: none; width: 220px; display: flex; justify-content: space-between;\\"><div style=\\"display: flex; align-items: baseline;\\"><label for=\\"value-toggle-checkbox-TEST-CATEGORY-TEST-LABEL\\" class=\\"bp3-control bp3-checkbox\\" style=\\"margin: 0px;\\"><input id=\\"value-toggle-checkbox-TEST-CATEGORY-TEST-LABEL\\" data-testclass=\\"categorical-value-select\\" data-testid=\\"categorical-value-select-TEST-CATEGORY-TEST-LABEL\\" type=\\"checkbox\\" checked=\\"\\"><span class=\\"bp3-control-indicator\\"></span></label><span class=\\"bp3-popover-wrapper\\"><span class=\\"bp3-popover-target\\"><span data-testid=\\"categorical-value-TEST-CATEGORY-TEST-LABEL\\" data-testclass=\\"categorical-value\\" aria-label=\\"TEST-LABEL\\" class=\\"\\" tabindex=\\"0\\" style=\\"width: 63px; color: black; font-style: normal; display: inline-block; overflow: hidden; line-height: 1.1em; height: 1.1em; vertical-align: middle; margin-right: 16px;\\"><span style=\\"width: 63px; color: black; font-style: normal; display: flex; overflow: hidden; line-height: 1.1em; height: 1.1em; vertical-align: middle; margin-right: 16px; justify-content: flex-start;\\"><span style=\\"overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 1; min-width: 5px;\\">TEST-</span><span style=\\"position: relative; overflow: hidden; white-space: nowrap;\\"><span style=\\"color: transparent;\\">LABEL</span><span style=\\"position: absolute; right: 0px; color: black;\\">LABEL</span></span></span></span></span></span></div><span style=\\"flex-shrink: 0;\\"></span></div><div><span><span data-testclass=\\"categorical-value-count\\" data-testid=\\"categorical-value-count-TEST-CATEGORY-TEST-LABEL\\" style=\\"color: black;\\">0</span><svg display=\\"none\\" style=\\"margin-left: 5px; width: 11px; height: 11px; background-color: inherit;\\"></svg><span><span class=\\"bp3-popover-wrapper\\"><span class=\\"bp3-popover-target\\"><button type=\\"button\\" data-testclass=\\"seeActions\\" data-testid=\\"TEST-CATEGORY:TEST-LABEL:see-actions\\" class=\\"bp3-button bp3-minimal bp3-small\\" tabindex=\\"0\\" style=\\"margin-left: 2px; position: relative; top: -1px; min-height: 16px;\\"><span icon=\\"more\\" class=\\"bp3-icon bp3-icon-more\\"><svg data-icon=\\"more\\" width=\\"10\\" height=\\"10\\" viewBox=\\"0 0 16 16\\"><desc>more</desc><path d=\\"M2 6.03a2 2 0 100 4 2 2 0 100-4zM14 6.03a2 2 0 100 4 2 2 0 100-4zM8 6.03a2 2 0 100 4 2 2 0 100-4z\\" fill-rule=\\"evenodd\\"></path></svg></span></button></span></span></span></span></div></div>",
|
||||||
|
"<div class=\\"categorical__value___2RKaC\\" data-testclass=\\"categorical-row\\" style=\\"padding: 4px 0px 4px 7px; display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 2px; border-radius: 2px;\\"><div style=\\"margin: 0px; padding: 0px; user-select: none; width: 220px; display: flex; justify-content: space-between;\\"><div style=\\"display: flex; align-items: baseline;\\"><label for=\\"value-toggle-checkbox-TEST-CATEGORY-unassigned\\" class=\\"bp3-control bp3-checkbox\\" style=\\"margin: 0px;\\"><input id=\\"value-toggle-checkbox-TEST-CATEGORY-unassigned\\" data-testclass=\\"categorical-value-select\\" data-testid=\\"categorical-value-select-TEST-CATEGORY-unassigned\\" type=\\"checkbox\\" checked=\\"\\"><span class=\\"bp3-control-indicator\\"></span></label><span class=\\"bp3-popover-wrapper\\"><span class=\\"bp3-popover-target\\"><span data-testid=\\"categorical-value-TEST-CATEGORY-unassigned\\" data-testclass=\\"categorical-value\\" aria-label=\\"unassigned\\" class=\\"\\" tabindex=\\"0\\" style=\\"width: 63px; color: rgb(171, 171, 171); font-style: italic; display: inline-block; overflow: hidden; line-height: 1.1em; height: 1.1em; vertical-align: middle; margin-right: 16px;\\"><span style=\\"width: 63px; color: rgb(171, 171, 171); font-style: italic; display: flex; overflow: hidden; line-height: 1.1em; height: 1.1em; vertical-align: middle; margin-right: 16px; justify-content: flex-start;\\"><span style=\\"overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 1; min-width: 5px;\\">unass</span><span style=\\"position: relative; overflow: hidden; white-space: nowrap;\\"><span style=\\"color: transparent;\\">igned</span><span style=\\"position: absolute; right: 0px; color: rgb(171, 171, 171);\\">igned</span></span></span></span></span></span></div><span style=\\"flex-shrink: 0;\\"><canvas class=\\"bp3-popover-targer\\" width=\\"100\\" height=\\"11\\" style=\\"margin-right: 5px; width: 100px; height: 11px;\\"></canvas></span></div><div><span><span data-testclass=\\"categorical-value-count\\" data-testid=\\"categorical-value-count-TEST-CATEGORY-unassigned\\" style=\\"color: rgb(171, 171, 171); font-style: italic;\\">2132</span><svg display=\\"none\\" style=\\"margin-left: 5px; width: 11px; height: 11px; background-color: inherit;\\"></svg><span><span class=\\"bp3-popover-wrapper\\"><span class=\\"bp3-popover-target\\"><button type=\\"button\\" data-testclass=\\"seeActions\\" data-testid=\\"TEST-CATEGORY:unassigned:see-actions\\" class=\\"bp3-button bp3-minimal bp3-small\\" tabindex=\\"0\\" style=\\"margin-left: 2px; position: relative; top: -1px; min-height: 16px;\\"><span icon=\\"more\\" class=\\"bp3-icon bp3-icon-more\\"><svg data-icon=\\"more\\" width=\\"10\\" height=\\"10\\" viewBox=\\"0 0 16 16\\"><desc>more</desc><path d=\\"M2 6.03a2 2 0 100 4 2 2 0 100-4zM14 6.03a2 2 0 100 4 2 2 0 100-4zM8 6.03a2 2 0 100 4 2 2 0 100-4z\\" fill-rule=\\"evenodd\\"></path></svg></span></button></span></span></span></span></div></div>",
|
||||||
|
]
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`annotations stacked bar graph renders 2`] = `
|
||||||
|
Array [
|
||||||
|
"<div class=\\"categorical__value___2RKaC\\" data-testclass=\\"categorical-row\\" style=\\"padding: 4px 0px 4px 7px; display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 2px; border-radius: 2px;\\"><div style=\\"margin: 0px; padding: 0px; user-select: none; width: 220px; display: flex; justify-content: space-between;\\"><div style=\\"display: flex; align-items: baseline;\\"><label for=\\"value-toggle-checkbox-TEST-CATEGORY-TEST-LABEL\\" class=\\"bp3-control bp3-checkbox\\" style=\\"margin: 0px;\\"><input id=\\"value-toggle-checkbox-TEST-CATEGORY-TEST-LABEL\\" data-testclass=\\"categorical-value-select\\" data-testid=\\"categorical-value-select-TEST-CATEGORY-TEST-LABEL\\" type=\\"checkbox\\" checked=\\"\\"><span class=\\"bp3-control-indicator\\"></span></label><span class=\\"bp3-popover-wrapper\\"><span class=\\"bp3-popover-target\\"><span data-testid=\\"categorical-value-TEST-CATEGORY-TEST-LABEL\\" data-testclass=\\"categorical-value\\" aria-label=\\"TEST-LABEL\\" class=\\"\\" tabindex=\\"0\\" style=\\"width: 63px; color: black; font-style: normal; display: inline-block; overflow: hidden; line-height: 1.1em; height: 1.1em; vertical-align: middle; margin-right: 16px;\\"><span style=\\"width: 63px; color: black; font-style: normal; display: flex; overflow: hidden; line-height: 1.1em; height: 1.1em; vertical-align: middle; margin-right: 16px; justify-content: flex-start;\\"><span style=\\"overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 1; min-width: 5px;\\">TEST-</span><span style=\\"position: relative; overflow: hidden; white-space: nowrap;\\"><span style=\\"color: transparent;\\">LABEL</span><span style=\\"position: absolute; right: 0px; color: black;\\">LABEL</span></span></span></span></span></span></div><span style=\\"flex-shrink: 0;\\"></span></div><div><span><span data-testclass=\\"categorical-value-count\\" data-testid=\\"categorical-value-count-TEST-CATEGORY-TEST-LABEL\\" style=\\"color: black;\\">0</span><svg display=\\"none\\" style=\\"margin-left: 5px; width: 11px; height: 11px; background-color: inherit;\\"></svg><span><span class=\\"bp3-popover-wrapper\\"><span class=\\"bp3-popover-target\\"><button type=\\"button\\" data-testclass=\\"seeActions\\" data-testid=\\"TEST-CATEGORY:TEST-LABEL:see-actions\\" class=\\"bp3-button bp3-minimal bp3-small\\" tabindex=\\"0\\" style=\\"margin-left: 2px; position: relative; top: -1px; min-height: 16px;\\"><span icon=\\"more\\" class=\\"bp3-icon bp3-icon-more\\"><svg data-icon=\\"more\\" width=\\"10\\" height=\\"10\\" viewBox=\\"0 0 16 16\\"><desc>more</desc><path d=\\"M2 6.03a2 2 0 100 4 2 2 0 100-4zM14 6.03a2 2 0 100 4 2 2 0 100-4zM8 6.03a2 2 0 100 4 2 2 0 100-4z\\" fill-rule=\\"evenodd\\"></path></svg></span></button></span></span></span></span></div></div>",
|
||||||
|
"<div class=\\"categorical__value___2RKaC\\" data-testclass=\\"categorical-row\\" style=\\"padding: 4px 0px 4px 7px; display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 2px; border-radius: 2px;\\"><div style=\\"margin: 0px; padding: 0px; user-select: none; width: 220px; display: flex; justify-content: space-between;\\"><div style=\\"display: flex; align-items: baseline;\\"><label for=\\"value-toggle-checkbox-TEST-CATEGORY-unassigned\\" class=\\"bp3-control bp3-checkbox\\" style=\\"margin: 0px;\\"><input id=\\"value-toggle-checkbox-TEST-CATEGORY-unassigned\\" data-testclass=\\"categorical-value-select\\" data-testid=\\"categorical-value-select-TEST-CATEGORY-unassigned\\" type=\\"checkbox\\" checked=\\"\\"><span class=\\"bp3-control-indicator\\"></span></label><span class=\\"bp3-popover-wrapper\\"><span class=\\"bp3-popover-target\\"><span data-testid=\\"categorical-value-TEST-CATEGORY-unassigned\\" data-testclass=\\"categorical-value\\" aria-label=\\"unassigned\\" class=\\"\\" tabindex=\\"0\\" style=\\"width: 63px; color: rgb(171, 171, 171); font-style: italic; display: inline-block; overflow: hidden; line-height: 1.1em; height: 1.1em; vertical-align: middle; margin-right: 16px;\\"><span style=\\"width: 63px; color: rgb(171, 171, 171); font-style: italic; display: flex; overflow: hidden; line-height: 1.1em; height: 1.1em; vertical-align: middle; margin-right: 16px; justify-content: flex-start;\\"><span style=\\"overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 1; min-width: 5px;\\">unass</span><span style=\\"position: relative; overflow: hidden; white-space: nowrap;\\"><span style=\\"color: transparent;\\">igned</span><span style=\\"position: absolute; right: 0px; color: rgb(171, 171, 171);\\">igned</span></span></span></span></span></span></div><span style=\\"flex-shrink: 0;\\"><canvas class=\\"bp3-popover-targer\\" width=\\"100\\" height=\\"11\\" style=\\"margin-right: 5px; width: 100px; height: 11px;\\"></canvas></span></div><div><span><span data-testclass=\\"categorical-value-count\\" data-testid=\\"categorical-value-count-TEST-CATEGORY-unassigned\\" style=\\"color: rgb(171, 171, 171); font-style: italic;\\">2638</span><svg display=\\"none\\" style=\\"margin-left: 5px; width: 11px; height: 11px; background-color: inherit;\\"></svg><span><span class=\\"bp3-popover-wrapper\\"><span class=\\"bp3-popover-target\\"><button type=\\"button\\" data-testclass=\\"seeActions\\" data-testid=\\"TEST-CATEGORY:unassigned:see-actions\\" class=\\"bp3-button bp3-minimal bp3-small\\" tabindex=\\"0\\" style=\\"margin-left: 2px; position: relative; top: -1px; min-height: 16px;\\"><span icon=\\"more\\" class=\\"bp3-icon bp3-icon-more\\"><svg data-icon=\\"more\\" width=\\"10\\" height=\\"10\\" viewBox=\\"0 0 16 16\\"><desc>more</desc><path d=\\"M2 6.03a2 2 0 100 4 2 2 0 100-4zM14 6.03a2 2 0 100 4 2 2 0 100-4zM8 6.03a2 2 0 100 4 2 2 0 100-4z\\" fill-rule=\\"evenodd\\"></path></svg></span></button></span></span></span></span></div></div>",
|
||||||
|
]
|
||||||
|
`;
|
||||||
@@ -1,8 +1,22 @@
|
|||||||
|
/* eslint-disable no-await-in-loop -- await in loop is needed to emulate sequential user actions */
|
||||||
import { strict as assert } from "assert";
|
import { strict as assert } from "assert";
|
||||||
|
import {
|
||||||
|
clearInputAndTypeInto,
|
||||||
|
clickOn,
|
||||||
|
getAllByClass,
|
||||||
|
getOneElementInnerText,
|
||||||
|
typeInto,
|
||||||
|
waitByID,
|
||||||
|
waitByClass,
|
||||||
|
waitForAllByIds,
|
||||||
|
clickOnUntil,
|
||||||
|
getTestClass,
|
||||||
|
getTestId,
|
||||||
|
isElementPresent,
|
||||||
|
} from "./puppeteerUtils";
|
||||||
|
|
||||||
export const cellxgeneActions = (page, utils) => ({
|
export async function drag(testId, start, end, lasso = false) {
|
||||||
async drag(testId, start, end, lasso = false) {
|
const layout = await waitByID(testId);
|
||||||
const layout = await utils.waitByID(testId);
|
|
||||||
const elBox = await layout.boxModel();
|
const elBox = await layout.boxModel();
|
||||||
const x1 = elBox.content[0].x + start.x;
|
const x1 = elBox.content[0].x + start.x;
|
||||||
const x2 = elBox.content[0].x + end.x;
|
const x2 = elBox.content[0].x + end.x;
|
||||||
@@ -19,26 +33,45 @@ export const cellxgeneActions = (page, utils) => ({
|
|||||||
await page.mouse.move(x2, y2);
|
await page.mouse.move(x2, y2);
|
||||||
}
|
}
|
||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
},
|
}
|
||||||
|
|
||||||
async clickOnCoordinate(testId, coord) {
|
export async function clickOnCoordinate(testId, coord) {
|
||||||
const layout = await utils.waitByID(testId);
|
const layout = await expect(page).toMatchElement(getTestId(testId));
|
||||||
const elBox = await layout.boxModel();
|
const elBox = await layout.boxModel();
|
||||||
|
|
||||||
|
if (!elBox) {
|
||||||
|
throw Error("Layout's boxModel is not available!");
|
||||||
|
}
|
||||||
|
|
||||||
const x = elBox.content[0].x + coord.x;
|
const x = elBox.content[0].x + coord.x;
|
||||||
const y = elBox.content[0].y + coord.y;
|
const y = elBox.content[0].y + coord.y;
|
||||||
await page.mouse.click(x, y);
|
await page.mouse.click(x, y);
|
||||||
},
|
}
|
||||||
|
|
||||||
async getAllHistograms(testclass, testIds) {
|
export async function getAllHistograms(testclass, testIds) {
|
||||||
const histTestIds = testIds.map((tid) => `histogram-${tid}`);
|
const histTestIds = testIds.map((tid) => `histogram-${tid}`);
|
||||||
// these load asynchronously, so we need to wait for each histogram individually
|
|
||||||
await utils.waitForAllByIds(histTestIds);
|
|
||||||
const allHistograms = await utils.getAllByClass(testclass);
|
|
||||||
return allHistograms.map((hist) => hist.replace(/^histogram-/, ""));
|
|
||||||
},
|
|
||||||
|
|
||||||
async getAllCategoriesAndCounts(category) {
|
// these load asynchronously, so we need to wait for each histogram individually,
|
||||||
await utils.waitByClass("categorical-row");
|
// and they may be quite slow in some cases.
|
||||||
|
await waitForAllByIds(histTestIds, { timeout: 4 * 60 * 1000 });
|
||||||
|
|
||||||
|
const allHistograms = await getAllByClass(testclass);
|
||||||
|
|
||||||
|
const testIDs = await Promise.all(
|
||||||
|
allHistograms.map((hist) => {
|
||||||
|
return page.evaluate((elem) => {
|
||||||
|
return elem.dataset.testid;
|
||||||
|
}, hist);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return testIDs.map((id) => id.replace(/^histogram-/, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllCategoriesAndCounts(category) {
|
||||||
|
// these load asynchronously, so we have to wait for the specific category.
|
||||||
|
await waitByID(`category-${category}`);
|
||||||
|
|
||||||
return page.$$eval(
|
return page.$$eval(
|
||||||
`[data-testid="category-${category}"] [data-testclass='categorical-row']`,
|
`[data-testid="category-${category}"] [data-testclass='categorical-row']`,
|
||||||
(rows) =>
|
(rows) =>
|
||||||
@@ -51,174 +84,232 @@ export const cellxgeneActions = (page, utils) => ({
|
|||||||
const count = row.querySelector(
|
const count = row.querySelector(
|
||||||
"[data-testclass='categorical-value-count']"
|
"[data-testclass='categorical-value-count']"
|
||||||
).innerText;
|
).innerText;
|
||||||
|
|
||||||
return [cat, count];
|
return [cat, count];
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
|
|
||||||
async cellSet(num) {
|
export async function getCellSetCount(num) {
|
||||||
await utils.clickOn(`cellset-button-${num}`);
|
await clickOn(`cellset-button-${num}`);
|
||||||
return utils.getOneElementInnerText(`[data-testid='cellset-count-${num}']`);
|
return getOneElementInnerText(`[data-testid='cellset-count-${num}']`);
|
||||||
},
|
}
|
||||||
|
|
||||||
async resetCategory(category) {
|
export async function resetCategory(category) {
|
||||||
const checkboxId = `${category}:category-select`;
|
const checkboxId = `${category}:category-select`;
|
||||||
await utils.waitByID(checkboxId);
|
await waitByID(checkboxId);
|
||||||
const checkedPseudoclass = await page.$eval(
|
const checkedPseudoclass = await page.$eval(
|
||||||
`[data-testid='${checkboxId}']`,
|
`[data-testid='${checkboxId}']`,
|
||||||
(el) => el.matches(":checked")
|
(el) => el.matches(":checked")
|
||||||
);
|
);
|
||||||
if (!checkedPseudoclass) await utils.clickOn(checkboxId);
|
if (!checkedPseudoclass) await clickOn(checkboxId);
|
||||||
try {
|
|
||||||
const categoryRow = await utils.waitByID(`${category}:category-expand`);
|
const categoryRow = await waitByID(`${category}:category-expand`);
|
||||||
|
|
||||||
const isExpanded = await categoryRow.$(
|
const isExpanded = await categoryRow.$(
|
||||||
"[data-testclass='category-expand-is-expanded']"
|
"[data-testclass='category-expand-is-expanded']"
|
||||||
);
|
);
|
||||||
if (isExpanded) await utils.clickOn(`${category}:category-expand`);
|
|
||||||
} catch {}
|
|
||||||
},
|
|
||||||
|
|
||||||
async calcCoordinate(testId, xAsPercent, yAsPercent) {
|
if (isExpanded) await clickOn(`${category}:category-expand`);
|
||||||
const el = await utils.waitByID(testId);
|
}
|
||||||
|
|
||||||
|
export async function calcCoordinate(testId, xAsPercent, yAsPercent) {
|
||||||
|
const el = await waitByID(testId);
|
||||||
const size = await el.boxModel();
|
const size = await el.boxModel();
|
||||||
return {
|
return {
|
||||||
x: Math.floor(size.width * xAsPercent),
|
x: Math.floor(size.width * xAsPercent),
|
||||||
y: Math.floor(size.height * yAsPercent),
|
y: Math.floor(size.height * yAsPercent),
|
||||||
};
|
};
|
||||||
},
|
}
|
||||||
|
|
||||||
async calcDragCoordinates(testId, coordinateAsPercent) {
|
export async function calcDragCoordinates(testId, coordinateAsPercent) {
|
||||||
return {
|
return {
|
||||||
start: await this.calcCoordinate(
|
start: await calcCoordinate(
|
||||||
testId,
|
testId,
|
||||||
coordinateAsPercent.x1,
|
coordinateAsPercent.x1,
|
||||||
coordinateAsPercent.y1
|
coordinateAsPercent.y1
|
||||||
),
|
),
|
||||||
end: await this.calcCoordinate(
|
end: await calcCoordinate(
|
||||||
testId,
|
testId,
|
||||||
coordinateAsPercent.x2,
|
coordinateAsPercent.x2,
|
||||||
coordinateAsPercent.y2
|
coordinateAsPercent.y2
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
},
|
}
|
||||||
|
|
||||||
async selectCategory(category, values, reset = true) {
|
export async function selectCategory(category, values, reset = true) {
|
||||||
if (reset) await this.resetCategory(category);
|
if (reset) await resetCategory(category);
|
||||||
await utils.clickOn(`${category}:category-expand`);
|
|
||||||
await utils.clickOn(`${category}:category-select`);
|
await clickOn(`${category}:category-expand`);
|
||||||
for (const val of values) {
|
await clickOn(`${category}:category-select`);
|
||||||
await utils.clickOn(`categorical-value-select-${category}-${val}`);
|
|
||||||
|
for (const value of values) {
|
||||||
|
await clickOn(`categorical-value-select-${category}-${value}`);
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
async expandCategory(category) {
|
export async function expandCategory(category) {
|
||||||
const expand = await utils.waitByID(`${category}:category-expand`);
|
const expand = await waitByID(`${category}:category-expand`);
|
||||||
const notExpanded = await expand.$(
|
const notExpanded = await expand.$(
|
||||||
"[data-testclass='category-expand-is-not-expanded']"
|
"[data-testclass='category-expand-is-not-expanded']"
|
||||||
);
|
);
|
||||||
if (notExpanded) await utils.clickOn(`${category}:category-expand`);
|
if (notExpanded) await clickOn(`${category}:category-expand`);
|
||||||
},
|
}
|
||||||
|
|
||||||
async clip(min = 0, max = 100) {
|
export async function clip(min = 0, max = 100) {
|
||||||
await utils.clickOn("visualization-settings");
|
await clickOn("visualization-settings");
|
||||||
await utils.clearInputAndTypeInto("clip-min-input", min);
|
await clearInputAndTypeInto("clip-min-input", min);
|
||||||
await utils.clearInputAndTypeInto("clip-max-input", max);
|
await clearInputAndTypeInto("clip-max-input", max);
|
||||||
await utils.clickOn("clip-commit");
|
await clickOn("clip-commit");
|
||||||
},
|
}
|
||||||
|
|
||||||
async createCategory(categoryName) {
|
export async function createCategory(categoryName) {
|
||||||
await utils.clickOn("open-annotation-dialog");
|
await clickOnUntil("open-annotation-dialog", async () => {
|
||||||
await utils.typeInto("new-category-name", categoryName);
|
await expect(page).toMatchElement(getTestId("new-category-name"));
|
||||||
await utils.clickOn("submit-category");
|
});
|
||||||
},
|
|
||||||
|
|
||||||
async renameCategory(oldCatgoryName, newCategoryName) {
|
await typeInto("new-category-name", categoryName);
|
||||||
await utils.clickOn(`${oldCatgoryName}:see-actions`);
|
await clickOn("submit-category");
|
||||||
await utils.clickOn(`${oldCatgoryName}:edit-category-mode`);
|
}
|
||||||
await utils.clearInputAndTypeInto(
|
|
||||||
`${oldCatgoryName}:edit-category-name-text`,
|
export async function duplicateCategory(categoryName) {
|
||||||
|
await clickOn("open-annotation-dialog");
|
||||||
|
|
||||||
|
await typeInto("new-category-name", categoryName);
|
||||||
|
|
||||||
|
const dropdownOptionClass = "duplicate-category-dropdown-option";
|
||||||
|
|
||||||
|
await clickOnUntil("duplicate-category-dropdown", async () => {
|
||||||
|
await expect(page).toMatchElement(getTestClass(dropdownOptionClass));
|
||||||
|
});
|
||||||
|
|
||||||
|
const option = await expect(page).toMatchElement(
|
||||||
|
getTestClass(dropdownOptionClass)
|
||||||
|
);
|
||||||
|
|
||||||
|
await option.click();
|
||||||
|
|
||||||
|
await clickOnUntil("submit-category", async () => {
|
||||||
|
await expect(page).toMatchElement(
|
||||||
|
getTestId(`${categoryName}:category-expand`)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitByClass("autosave-complete");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameCategory(oldCategoryName, newCategoryName) {
|
||||||
|
await clickOn(`${oldCategoryName}:see-actions`);
|
||||||
|
await clickOn(`${oldCategoryName}:edit-category-mode`);
|
||||||
|
await clearInputAndTypeInto(
|
||||||
|
`${oldCategoryName}:edit-category-name-text`,
|
||||||
newCategoryName
|
newCategoryName
|
||||||
);
|
);
|
||||||
await utils.clickOn(`${oldCatgoryName}:submit-category-edit`);
|
await clickOn(`${oldCategoryName}:submit-category-edit`);
|
||||||
},
|
}
|
||||||
|
|
||||||
async deleteCategory(categoryName) {
|
export async function deleteCategory(categoryName) {
|
||||||
await utils.clickOn(`${categoryName}:see-actions`);
|
const targetId = `${categoryName}:delete-category`;
|
||||||
await utils.clickOn(`${categoryName}:delete-category`);
|
|
||||||
},
|
|
||||||
|
|
||||||
async createLabel(categoryName, labelName) {
|
await clickOnUntil(`${categoryName}:see-actions`, async () => {
|
||||||
await utils.clickOn(`${categoryName}:see-actions`);
|
await expect(page).toMatchElement(getTestId(targetId));
|
||||||
await utils.clickOn(`${categoryName}:add-new-label-to-category`);
|
});
|
||||||
await utils.typeInto(`${categoryName}:new-label-name`, labelName);
|
|
||||||
await utils.clickOn(`${categoryName}:submit-label`);
|
|
||||||
},
|
|
||||||
|
|
||||||
async deleteLabel(categoryName, labelName) {
|
await clickOn(targetId);
|
||||||
await this.expandCategory(categoryName);
|
|
||||||
await utils.clickOn(`${categoryName}:${labelName}:see-actions`);
|
|
||||||
await utils.clickOn(`${categoryName}:${labelName}:delete-label`);
|
|
||||||
},
|
|
||||||
|
|
||||||
async renameLabel(categoryName, oldLabelName, newLabelName) {
|
await assertCategoryDoesNotExist();
|
||||||
await this.expandCategory(categoryName);
|
}
|
||||||
await utils.clickOn(`${categoryName}:${oldLabelName}:see-actions`);
|
|
||||||
await utils.clickOn(`${categoryName}:${oldLabelName}:edit-label`);
|
export async function createLabel(categoryName, labelName) {
|
||||||
await utils.clearInputAndTypeInto(
|
/**
|
||||||
|
* (thuang): This explicit wait is needed, since currently showing
|
||||||
|
* the modal again quickly after the previous action dismissing the
|
||||||
|
* modal will persist the input value from the previous action.
|
||||||
|
*
|
||||||
|
* To reproduce:
|
||||||
|
* 1. Click on the plus sign to show the modal to add a new label to the category
|
||||||
|
* 2. Type `123` in the input box
|
||||||
|
* 3. Hover over your mouse over the plus sign and double click to quickly dismiss and
|
||||||
|
* invoke the modal again
|
||||||
|
* 4. You will see `123` is persisted in the input box
|
||||||
|
* 5. Expected behavior is to get an empty input box
|
||||||
|
*/
|
||||||
|
await page.waitFor(500);
|
||||||
|
|
||||||
|
await clickOn(`${categoryName}:see-actions`);
|
||||||
|
|
||||||
|
await clickOn(`${categoryName}:add-new-label-to-category`);
|
||||||
|
|
||||||
|
await typeInto(`${categoryName}:new-label-name`, labelName);
|
||||||
|
|
||||||
|
await clickOn(`${categoryName}:submit-label`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteLabel(categoryName, labelName) {
|
||||||
|
await expandCategory(categoryName);
|
||||||
|
await clickOn(`${categoryName}:${labelName}:see-actions`);
|
||||||
|
await clickOn(`${categoryName}:${labelName}:delete-label`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameLabel(categoryName, oldLabelName, newLabelName) {
|
||||||
|
await expandCategory(categoryName);
|
||||||
|
await clickOn(`${categoryName}:${oldLabelName}:see-actions`);
|
||||||
|
await clickOn(`${categoryName}:${oldLabelName}:edit-label`);
|
||||||
|
await clearInputAndTypeInto(
|
||||||
`${categoryName}:${oldLabelName}:edit-label-name`,
|
`${categoryName}:${oldLabelName}:edit-label-name`,
|
||||||
newLabelName
|
newLabelName
|
||||||
);
|
);
|
||||||
await utils.clickOn(`${categoryName}:${oldLabelName}:submit-label-edit`);
|
await clickOn(`${categoryName}:${oldLabelName}:submit-label-edit`);
|
||||||
},
|
}
|
||||||
|
|
||||||
async addGeneToSearch(geneName) {
|
export async function addGeneToSearch(geneName) {
|
||||||
await utils.typeInto("gene-search", geneName);
|
await typeInto("gene-search", geneName);
|
||||||
await page.keyboard.press("Enter");
|
await page.keyboard.press("Enter");
|
||||||
await page.waitForSelector(`[data-testid='histogram-${geneName}']`);
|
await page.waitForSelector(`[data-testid='histogram-${geneName}']`);
|
||||||
},
|
}
|
||||||
|
|
||||||
async subset(coordinatesAsPercent) {
|
export async function subset(coordinatesAsPercent) {
|
||||||
// In order to deselect the selection after the subset, make sure we have some clear part
|
// In order to deselect the selection after the subset, make sure we have some clear part
|
||||||
// of the scatterplot we can click on
|
// of the scatterplot we can click on
|
||||||
assert(coordinatesAsPercent.x2 < 0.99 || coordinatesAsPercent.y2 < 0.99);
|
assert(coordinatesAsPercent.x2 < 0.99 || coordinatesAsPercent.y2 < 0.99);
|
||||||
const lassoSelection = await this.calcDragCoordinates(
|
const lassoSelection = await calcDragCoordinates(
|
||||||
"layout-graph",
|
"layout-graph",
|
||||||
coordinatesAsPercent
|
coordinatesAsPercent
|
||||||
);
|
);
|
||||||
await this.drag(
|
await drag("layout-graph", lassoSelection.start, lassoSelection.end, true);
|
||||||
"layout-graph",
|
await clickOn("subset-button");
|
||||||
lassoSelection.start,
|
const clearCoordinate = await calcCoordinate("layout-graph", 0.5, 0.99);
|
||||||
lassoSelection.end,
|
await clickOnCoordinate("layout-graph", clearCoordinate);
|
||||||
true
|
}
|
||||||
);
|
|
||||||
await utils.clickOn("subset-button");
|
|
||||||
const clearCoordinate = await this.calcCoordinate(
|
|
||||||
"layout-graph",
|
|
||||||
0.5,
|
|
||||||
0.99
|
|
||||||
);
|
|
||||||
await this.clickOnCoordinate("layout-graph", clearCoordinate);
|
|
||||||
},
|
|
||||||
|
|
||||||
async setSellSet(cellSet, cellSetNum) {
|
export async function setSellSet(cellSet, cellSetNum) {
|
||||||
for (const selection of cellSet.filter(
|
const selections = cellSet.filter((sel) => sel.kind === "categorical");
|
||||||
(sel) => sel.kind === "categorical"
|
|
||||||
)) {
|
for (const selection of selections) {
|
||||||
await this.selectCategory(selection.metadata, selection.values, true);
|
await selectCategory(selection.metadata, selection.values, true);
|
||||||
}
|
}
|
||||||
await this.cellSet(cellSetNum);
|
|
||||||
},
|
|
||||||
|
|
||||||
async runDiffExp(cellSet1, cellSet2) {
|
await getCellSetCount(cellSetNum);
|
||||||
await this.setSellSet(cellSet1, 1);
|
}
|
||||||
await this.setSellSet(cellSet2, 2);
|
|
||||||
await utils.clickOn("diffexp-button");
|
|
||||||
},
|
|
||||||
|
|
||||||
async bulkAddGenes(geneNames) {
|
export async function runDiffExp(cellSet1, cellSet2) {
|
||||||
await utils.clickOn("section-bulk-add");
|
await setSellSet(cellSet1, 1);
|
||||||
await utils.typeInto("input-bulk-add", geneNames.join(","));
|
await setSellSet(cellSet2, 2);
|
||||||
|
await clickOn("diffexp-button");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bulkAddGenes(geneNames) {
|
||||||
|
await clickOn("section-bulk-add");
|
||||||
|
await typeInto("input-bulk-add", geneNames.join(","));
|
||||||
await page.keyboard.press("Enter");
|
await page.keyboard.press("Enter");
|
||||||
},
|
}
|
||||||
});
|
|
||||||
|
export async function assertCategoryDoesNotExist(categoryName) {
|
||||||
|
const result = await isElementPresent(
|
||||||
|
getTestId(`${categoryName}:category-label`)
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(result).toBe(false);
|
||||||
|
}
|
||||||
|
/* eslint-enable no-await-in-loop -- await in loop is needed to emulate sequential user actions */
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
export const jestEnv = process.env.JEST_ENV;
|
import * as ENV_DEFAULT from "../../../environment.default.json";
|
||||||
export const appPort = process.env.CXG_SERVER_PORT;
|
|
||||||
export const appUrlBase =
|
|
||||||
process.env.CXG_URL_BASE || `http://localhost:${appPort}`;
|
|
||||||
export const DEV = jestEnv === "dev";
|
|
||||||
export const DEBUG = jestEnv === "debug";
|
|
||||||
export const DATASET = "pbmc3k";
|
|
||||||
|
|
||||||
if (DEBUG) jest.setTimeout(100000);
|
export const jestEnv = process.env.JEST_ENV || ENV_DEFAULT.JEST_ENV;
|
||||||
if (DEV) jest.setTimeout(10000);
|
export const appUrlBase =
|
||||||
|
process.env.CXG_URL_BASE || `http://localhost:${ENV_DEFAULT.CXG_CLIENT_PORT}`;
|
||||||
|
export const DATASET = "pbmc3k";
|
||||||
|
export const isDev = jestEnv === ENV_DEFAULT.DEV;
|
||||||
|
export const isDebug = jestEnv === ENV_DEFAULT.DEBUG;
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export const datasets = {
|
|||||||
lasso: [
|
lasso: [
|
||||||
{
|
{
|
||||||
"coordinates-as-percent": { x1: 0.1, y1: 0.25, x2: 0.7, y2: 0.75 },
|
"coordinates-as-percent": { x1: 0.1, y1: 0.25, x2: 0.7, y2: 0.75 },
|
||||||
count: "1173"
|
count: "1173",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
categorical: [
|
categorical: [
|
||||||
@@ -111,7 +111,7 @@ export const datasets = {
|
|||||||
panzoom: {
|
panzoom: {
|
||||||
lasso: {
|
lasso: {
|
||||||
"coordinates-as-percent": { x1: 0.3, y1: 0.3, x2: 0.5, y2: 0.5 },
|
"coordinates-as-percent": { x1: 0.3, y1: 0.3, x2: 0.5, y2: 0.5 },
|
||||||
count: "24",
|
count: "38",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+279
-138
@@ -1,62 +1,69 @@
|
|||||||
/*
|
/**
|
||||||
Smoke test suite that will be run in Travis CI
|
* Smoke test suite that will be run in Travis CI
|
||||||
|
* Tests included in this file are expected to be relatively stable and test core features
|
||||||
Tests included in this file are expected to be relatively stable and test core features
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/* eslint-disable no-await-in-loop -- await in loop is needed to emulate sequential user actions */
|
||||||
import { appUrlBase, DATASET } from "./config";
|
import { appUrlBase, DATASET } from "./config";
|
||||||
import { setupTestBrowser } from "./testBrowser";
|
|
||||||
import { datasets } from "./data";
|
import { datasets } from "./data";
|
||||||
|
|
||||||
let browser;
|
import {
|
||||||
let page;
|
clickOn,
|
||||||
let utils;
|
getAllByClass,
|
||||||
let cxgActions;
|
getElementCoordinates,
|
||||||
|
getOneElementInnerHTML,
|
||||||
|
getTestId,
|
||||||
|
goToPage,
|
||||||
|
typeInto,
|
||||||
|
waitByID,
|
||||||
|
} from "./puppeteerUtils";
|
||||||
|
|
||||||
|
import {
|
||||||
|
addGeneToSearch,
|
||||||
|
bulkAddGenes,
|
||||||
|
calcDragCoordinates,
|
||||||
|
clip,
|
||||||
|
drag,
|
||||||
|
getAllCategoriesAndCounts,
|
||||||
|
getAllHistograms,
|
||||||
|
getCellSetCount,
|
||||||
|
runDiffExp,
|
||||||
|
selectCategory,
|
||||||
|
subset,
|
||||||
|
} from "./cellxgeneActions";
|
||||||
|
|
||||||
const data = datasets[DATASET];
|
const data = datasets[DATASET];
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
[browser, page, utils, cxgActions] = await setupTestBrowser();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await page.goto(appUrlBase);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
if (browser !== undefined) browser.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("did launch", () => {
|
describe("did launch", () => {
|
||||||
test("page launched", async () => {
|
test("page launched", async () => {
|
||||||
const element = await utils.getOneElementInnerHTML(
|
await goToPage(appUrlBase);
|
||||||
"[data-testid='header']"
|
|
||||||
);
|
|
||||||
expect(element).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("terms of service, if they are there", async () => {
|
const element = await getOneElementInnerHTML(getTestId("header"));
|
||||||
try {
|
|
||||||
await utils.clickOn("tos-cookies-accept", { timeout: 3000 });
|
expect(element).toMatchSnapshot();
|
||||||
} catch {
|
|
||||||
console.warn("No terms of service footer detected.");
|
|
||||||
}
|
|
||||||
page.waitFor(50); // give the footer a chance to disappear
|
|
||||||
const result = await page.$("[data-testid='tos-cookies-accept']");
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("metadata loads", () => {
|
describe("metadata loads", () => {
|
||||||
test("categories and values from dataset appear", async () => {
|
test("categories and values from dataset appear", async () => {
|
||||||
for (const label in data.categorical) {
|
await goToPage(appUrlBase);
|
||||||
const elem = await utils.getOneElementInnerHTML(
|
|
||||||
`[data-testid="category-${label}"]`
|
for (const label of Object.keys(data.categorical)) {
|
||||||
|
const element = await getOneElementInnerHTML(
|
||||||
|
getTestId(`category-${label}`)
|
||||||
);
|
);
|
||||||
expect(elem).toMatchSnapshot();
|
|
||||||
await utils.clickOn(`${label}:category-expand`);
|
expect(element).toMatchSnapshot();
|
||||||
const categories = await cxgActions.getAllCategoriesAndCounts(label);
|
|
||||||
|
await clickOn(`${label}:category-expand`);
|
||||||
|
|
||||||
|
const categories = await getAllCategoriesAndCounts(label);
|
||||||
|
|
||||||
expect(Object.keys(categories)).toMatchObject(
|
expect(Object.keys(categories)).toMatchObject(
|
||||||
Object.keys(data.categorical[label])
|
Object.keys(data.categorical[label])
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(Object.values(categories)).toMatchObject(
|
expect(Object.values(categories)).toMatchObject(
|
||||||
Object.values(data.categorical[label])
|
Object.values(data.categorical[label])
|
||||||
);
|
);
|
||||||
@@ -64,74 +71,98 @@ describe("metadata loads", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("continuous data appears", async () => {
|
test("continuous data appears", async () => {
|
||||||
for (const label in data.continuous) {
|
await goToPage(appUrlBase);
|
||||||
await utils.waitByID(`histogram-${label}`);
|
|
||||||
|
for (const label of Object.keys(data.continuous)) {
|
||||||
|
await waitByID(`histogram-${label}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("cell selection", () => {
|
describe("cell selection", () => {
|
||||||
test("selects all cells cellset 1", async () => {
|
test("selects all cells cellset 1", async () => {
|
||||||
const cellCount = await cxgActions.cellSet(1);
|
await goToPage(appUrlBase);
|
||||||
|
|
||||||
|
const cellCount = await getCellSetCount(1);
|
||||||
expect(cellCount).toBe(data.dataframe.nObs);
|
expect(cellCount).toBe(data.dataframe.nObs);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("selects all cells cellset 2", async () => {
|
test("selects all cells cellset 2", async () => {
|
||||||
const cellCount = await cxgActions.cellSet(2);
|
await goToPage(appUrlBase);
|
||||||
|
|
||||||
|
const cellCount = await getCellSetCount(2);
|
||||||
expect(cellCount).toBe(data.dataframe.nObs);
|
expect(cellCount).toBe(data.dataframe.nObs);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("selects cells via lasso", async () => {
|
test("selects cells via lasso", async () => {
|
||||||
|
await goToPage(appUrlBase);
|
||||||
|
|
||||||
for (const cellset of data.cellsets.lasso) {
|
for (const cellset of data.cellsets.lasso) {
|
||||||
const cellset1 = await cxgActions.calcDragCoordinates(
|
const cellset1 = await calcDragCoordinates(
|
||||||
"layout-graph",
|
"layout-graph",
|
||||||
cellset["coordinates-as-percent"]
|
cellset["coordinates-as-percent"]
|
||||||
);
|
);
|
||||||
await cxgActions.drag("layout-graph", cellset1.start, cellset1.end, true);
|
|
||||||
const cellCount = await cxgActions.cellSet(1);
|
await drag("layout-graph", cellset1.start, cellset1.end, true);
|
||||||
|
const cellCount = await getCellSetCount(1);
|
||||||
expect(cellCount).toBe(cellset.count);
|
expect(cellCount).toBe(cellset.count);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("selects cells via categorical", async () => {
|
test("selects cells via categorical", async () => {
|
||||||
|
await goToPage(appUrlBase);
|
||||||
|
|
||||||
for (const cellset of data.cellsets.categorical) {
|
for (const cellset of data.cellsets.categorical) {
|
||||||
await utils.clickOn(`${cellset.metadata}:category-expand`);
|
await clickOn(`${cellset.metadata}:category-expand`);
|
||||||
await utils.clickOn(`${cellset.metadata}:category-select`);
|
await clickOn(`${cellset.metadata}:category-select`);
|
||||||
for (const val of cellset.values) {
|
|
||||||
await utils.clickOn(
|
for (const value of cellset.values) {
|
||||||
`categorical-value-select-${cellset.metadata}-${val}`
|
await clickOn(`categorical-value-select-${cellset.metadata}-${value}`);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const cellCount = await cxgActions.cellSet(1);
|
|
||||||
|
const cellCount = await getCellSetCount(1);
|
||||||
|
|
||||||
expect(cellCount).toBe(cellset.count);
|
expect(cellCount).toBe(cellset.count);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("selects cells via continuous", async () => {
|
test("selects cells via continuous", async () => {
|
||||||
|
await goToPage(appUrlBase);
|
||||||
|
|
||||||
for (const cellset of data.cellsets.continuous) {
|
for (const cellset of data.cellsets.continuous) {
|
||||||
const histBrushableAreaId = `histogram-${cellset.metadata}-plot-brushable-area`;
|
const histBrushableAreaId = `histogram-${cellset.metadata}-plot-brushable-area`;
|
||||||
const coords = await cxgActions.calcDragCoordinates(
|
|
||||||
|
const coords = await calcDragCoordinates(
|
||||||
histBrushableAreaId,
|
histBrushableAreaId,
|
||||||
cellset["coordinates-as-percent"]
|
cellset["coordinates-as-percent"]
|
||||||
);
|
);
|
||||||
await cxgActions.drag(histBrushableAreaId, coords.start, coords.end);
|
|
||||||
const cellCount = await cxgActions.cellSet(1);
|
await drag(histBrushableAreaId, coords.start, coords.end);
|
||||||
|
|
||||||
|
const cellCount = await getCellSetCount(1);
|
||||||
|
|
||||||
expect(cellCount).toBe(cellset.count);
|
expect(cellCount).toBe(cellset.count);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("gene entry", () => {
|
describe("gene entry", () => {
|
||||||
test("search for single gene", async () =>
|
test("search for single gene", async () => {
|
||||||
cxgActions.addGeneToSearch(data.genes.search));
|
await goToPage(appUrlBase);
|
||||||
|
await addGeneToSearch(data.genes.search);
|
||||||
|
});
|
||||||
|
|
||||||
test("bulk add genes", async () => {
|
test("bulk add genes", async () => {
|
||||||
|
await goToPage(appUrlBase);
|
||||||
|
|
||||||
const testGenes = data.genes.bulkadd;
|
const testGenes = data.genes.bulkadd;
|
||||||
await cxgActions.bulkAddGenes(testGenes);
|
|
||||||
const allHistograms = await cxgActions.getAllHistograms(
|
await bulkAddGenes(testGenes);
|
||||||
|
const allHistograms = await getAllHistograms(
|
||||||
"histogram-user-gene",
|
"histogram-user-gene",
|
||||||
testGenes
|
testGenes
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(allHistograms).toEqual(expect.arrayContaining(testGenes));
|
expect(allHistograms).toEqual(expect.arrayContaining(testGenes));
|
||||||
expect(allHistograms).toHaveLength(testGenes.length);
|
expect(allHistograms).toHaveLength(testGenes.length);
|
||||||
});
|
});
|
||||||
@@ -139,31 +170,42 @@ describe("gene entry", () => {
|
|||||||
|
|
||||||
describe("differential expression", () => {
|
describe("differential expression", () => {
|
||||||
test("selects cells, saves them and performs diffexp", async () => {
|
test("selects cells, saves them and performs diffexp", async () => {
|
||||||
await cxgActions.runDiffExp(data.diffexp.cellset1, data.diffexp.cellset2);
|
await goToPage(appUrlBase);
|
||||||
const allHistograms = await cxgActions.getAllHistograms(
|
|
||||||
|
await runDiffExp(data.diffexp.cellset1, data.diffexp.cellset2);
|
||||||
|
|
||||||
|
const allHistograms = await getAllHistograms(
|
||||||
"histogram-diffexp",
|
"histogram-diffexp",
|
||||||
data.diffexp["gene-results"]
|
data.diffexp["gene-results"]
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(allHistograms).toEqual(
|
expect(allHistograms).toEqual(
|
||||||
expect.arrayContaining(data.diffexp["gene-results"])
|
expect.arrayContaining(data.diffexp["gene-results"])
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(allHistograms).toHaveLength(data.diffexp["gene-results"].length);
|
expect(allHistograms).toHaveLength(data.diffexp["gene-results"].length);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("subset", () => {
|
describe("subset", () => {
|
||||||
test("subset - cell count matches", async () => {
|
test("subset - cell count matches", async () => {
|
||||||
|
await goToPage(appUrlBase);
|
||||||
|
|
||||||
for (const select of data.subset.cellset1) {
|
for (const select of data.subset.cellset1) {
|
||||||
if (select.kind === "categorical") {
|
if (select.kind === "categorical") {
|
||||||
await cxgActions.selectCategory(select.metadata, select.values, true);
|
await selectCategory(select.metadata, select.values, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await utils.clickOn("subset-button");
|
|
||||||
for (const label in data.subset.categorical) {
|
await clickOn("subset-button");
|
||||||
const categories = await cxgActions.getAllCategoriesAndCounts(label);
|
|
||||||
|
for (const label of Object.keys(data.subset.categorical)) {
|
||||||
|
const categories = await getAllCategoriesAndCounts(label);
|
||||||
|
|
||||||
expect(Object.keys(categories)).toMatchObject(
|
expect(Object.keys(categories)).toMatchObject(
|
||||||
Object.keys(data.subset.categorical[label])
|
Object.keys(data.subset.categorical[label])
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(Object.values(categories)).toMatchObject(
|
expect(Object.values(categories)).toMatchObject(
|
||||||
Object.values(data.subset.categorical[label])
|
Object.values(data.subset.categorical[label])
|
||||||
);
|
);
|
||||||
@@ -171,79 +213,99 @@ describe("subset", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("lasso after subset", async () => {
|
test("lasso after subset", async () => {
|
||||||
|
await goToPage(appUrlBase);
|
||||||
|
|
||||||
for (const select of data.subset.cellset1) {
|
for (const select of data.subset.cellset1) {
|
||||||
if (select.kind === "categorical") {
|
if (select.kind === "categorical") {
|
||||||
await cxgActions.selectCategory(select.metadata, select.values, true);
|
await selectCategory(select.metadata, select.values, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await utils.clickOn("subset-button");
|
|
||||||
const lassoSelection = await cxgActions.calcDragCoordinates(
|
await clickOn("subset-button");
|
||||||
|
|
||||||
|
const lassoSelection = await calcDragCoordinates(
|
||||||
"layout-graph",
|
"layout-graph",
|
||||||
data.subset.lasso["coordinates-as-percent"]
|
data.subset.lasso["coordinates-as-percent"]
|
||||||
);
|
);
|
||||||
await cxgActions.drag(
|
|
||||||
"layout-graph",
|
await drag("layout-graph", lassoSelection.start, lassoSelection.end, true);
|
||||||
lassoSelection.start,
|
|
||||||
lassoSelection.end,
|
const cellCount = await getCellSetCount(1);
|
||||||
true
|
|
||||||
);
|
|
||||||
const cellCount = await cxgActions.cellSet(1);
|
|
||||||
expect(cellCount).toBe(data.subset.lasso.count);
|
expect(cellCount).toBe(data.subset.lasso.count);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("undo selection appends the top diff exp genes to user defined genes", async () => {
|
test("undo selection appends the top diff exp genes to user defined genes", async () => {
|
||||||
|
await goToPage(appUrlBase);
|
||||||
|
|
||||||
const userDefinedGenes = data.genes.bulkadd;
|
const userDefinedGenes = data.genes.bulkadd;
|
||||||
const diffExpGenes = data.diffexp["gene-results"];
|
const diffExpGenes = data.diffexp["gene-results"];
|
||||||
await cxgActions.bulkAddGenes(userDefinedGenes);
|
|
||||||
const userDefinedHistograms = await cxgActions.getAllHistograms(
|
await bulkAddGenes(userDefinedGenes);
|
||||||
|
const userDefinedHistograms = await getAllHistograms(
|
||||||
"histogram-user-gene",
|
"histogram-user-gene",
|
||||||
userDefinedGenes
|
userDefinedGenes
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(userDefinedHistograms).toEqual(
|
expect(userDefinedHistograms).toEqual(
|
||||||
expect.arrayContaining(userDefinedGenes)
|
expect.arrayContaining(userDefinedGenes)
|
||||||
);
|
);
|
||||||
await cxgActions.subset({ x1: 0.15, y1: 0.1, x2: 0.98, y2: 0.98 });
|
|
||||||
await cxgActions.runDiffExp(data.diffexp.cellset1, data.diffexp.cellset2);
|
await subset({ x1: 0.15, y1: 0.1, x2: 0.98, y2: 0.98 });
|
||||||
const diffExpHistograms = await cxgActions.getAllHistograms(
|
await runDiffExp(data.diffexp.cellset1, data.diffexp.cellset2);
|
||||||
|
|
||||||
|
const diffExpHistograms = await getAllHistograms(
|
||||||
"histogram-diffexp",
|
"histogram-diffexp",
|
||||||
diffExpGenes
|
diffExpGenes
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(diffExpHistograms).toEqual(expect.arrayContaining(diffExpGenes));
|
expect(diffExpHistograms).toEqual(expect.arrayContaining(diffExpGenes));
|
||||||
await utils.clickOn("reset-subset-button");
|
|
||||||
|
await clickOn("reset-subset-button");
|
||||||
const expected = [].concat(userDefinedGenes, diffExpGenes);
|
const expected = [].concat(userDefinedGenes, diffExpGenes);
|
||||||
const userDefinedHistogramsAfterSubset = await cxgActions.getAllHistograms(
|
const userDefinedHistogramsAfterSubset = await getAllHistograms(
|
||||||
"histogram-user-gene",
|
"histogram-user-gene",
|
||||||
expected
|
expected
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(userDefinedHistogramsAfterSubset).toEqual(
|
expect(userDefinedHistogramsAfterSubset).toEqual(
|
||||||
expect.arrayContaining(expected)
|
expect.arrayContaining(expected)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("subset selection appends the top diff exp genes to user defined genes", async () => {
|
test("subset selection appends the top diff exp genes to user defined genes", async () => {
|
||||||
|
await goToPage(appUrlBase);
|
||||||
|
|
||||||
const userDefinedGenes = data.genes.bulkadd;
|
const userDefinedGenes = data.genes.bulkadd;
|
||||||
const diffExpGenes = data.diffexp["gene-results"];
|
const diffExpGenes = data.diffexp["gene-results"];
|
||||||
await cxgActions.bulkAddGenes(userDefinedGenes);
|
|
||||||
const userDefinedHistograms = await cxgActions.getAllHistograms(
|
await bulkAddGenes(userDefinedGenes);
|
||||||
|
const userDefinedHistograms = await getAllHistograms(
|
||||||
"histogram-user-gene",
|
"histogram-user-gene",
|
||||||
userDefinedGenes
|
userDefinedGenes
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(userDefinedHistograms).toEqual(
|
expect(userDefinedHistograms).toEqual(
|
||||||
expect.arrayContaining(userDefinedGenes)
|
expect.arrayContaining(userDefinedGenes)
|
||||||
);
|
);
|
||||||
await cxgActions.subset({ x1: 0.15, y1: 0.1, x2: 0.98, y2: 0.98 });
|
|
||||||
await cxgActions.runDiffExp(data.diffexp.cellset1, data.diffexp.cellset2);
|
await subset({ x1: 0.15, y1: 0.1, x2: 0.98, y2: 0.98 });
|
||||||
const diffExpHistograms = await cxgActions.getAllHistograms(
|
await runDiffExp(data.diffexp.cellset1, data.diffexp.cellset2);
|
||||||
|
|
||||||
|
const diffExpHistograms = await getAllHistograms(
|
||||||
"histogram-diffexp",
|
"histogram-diffexp",
|
||||||
diffExpGenes
|
diffExpGenes
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(diffExpHistograms).toEqual(expect.arrayContaining(diffExpGenes));
|
expect(diffExpHistograms).toEqual(expect.arrayContaining(diffExpGenes));
|
||||||
await cxgActions.subset({ x1: 0.16, y1: 0.11, x2: 0.97, y2: 0.97 });
|
|
||||||
|
await subset({ x1: 0.16, y1: 0.11, x2: 0.97, y2: 0.97 });
|
||||||
|
|
||||||
const expected = [].concat(userDefinedGenes, diffExpGenes);
|
const expected = [].concat(userDefinedGenes, diffExpGenes);
|
||||||
const userDefinedHistogramsAfterSubset = await cxgActions.getAllHistograms(
|
const userDefinedHistogramsAfterSubset = await getAllHistograms(
|
||||||
"histogram-user-gene",
|
"histogram-user-gene",
|
||||||
expected
|
expected
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(userDefinedHistogramsAfterSubset).toEqual(
|
expect(userDefinedHistogramsAfterSubset).toEqual(
|
||||||
expect.arrayContaining(expected)
|
expect.arrayContaining(expected)
|
||||||
);
|
);
|
||||||
@@ -252,38 +314,51 @@ describe("subset", () => {
|
|||||||
|
|
||||||
describe("scatter plot", () => {
|
describe("scatter plot", () => {
|
||||||
test("scatter plot appears", async () => {
|
test("scatter plot appears", async () => {
|
||||||
await cxgActions.bulkAddGenes(Object.values(data.scatter.genes));
|
await goToPage(appUrlBase);
|
||||||
await utils.clickOn(`plot-x-${data.scatter.genes.x}`);
|
|
||||||
await utils.clickOn(`plot-y-${data.scatter.genes.y}`);
|
await bulkAddGenes(Object.values(data.scatter.genes));
|
||||||
await utils.waitByID("scatterplot");
|
await clickOn(`plot-x-${data.scatter.genes.x}`);
|
||||||
|
await clickOn(`plot-y-${data.scatter.genes.y}`);
|
||||||
|
await waitByID("scatterplot");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("clipping", () => {
|
describe("clipping", () => {
|
||||||
test("clip continuous", async () => {
|
test("clip continuous", async () => {
|
||||||
await cxgActions.clip(data.clip.min, data.clip.max);
|
await goToPage(appUrlBase);
|
||||||
|
|
||||||
|
await clip(data.clip.min, data.clip.max);
|
||||||
const histBrushableAreaId = `histogram-${data.clip.metadata}-plot-brushable-area`;
|
const histBrushableAreaId = `histogram-${data.clip.metadata}-plot-brushable-area`;
|
||||||
const coords = await cxgActions.calcDragCoordinates(
|
const coords = await calcDragCoordinates(
|
||||||
histBrushableAreaId,
|
histBrushableAreaId,
|
||||||
data.clip["coordinates-as-percent"]
|
data.clip["coordinates-as-percent"]
|
||||||
);
|
);
|
||||||
await cxgActions.drag(histBrushableAreaId, coords.start, coords.end);
|
await drag(histBrushableAreaId, coords.start, coords.end);
|
||||||
const cellCount = await cxgActions.cellSet(1);
|
const cellCount = await getCellSetCount(1);
|
||||||
expect(cellCount).toBe(data.clip.count);
|
expect(cellCount).toBe(data.clip.count);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("clip gene", async () => {
|
test("clip gene", async () => {
|
||||||
await utils.typeInto("gene-search", data.clip.gene);
|
await goToPage(appUrlBase);
|
||||||
|
|
||||||
|
await typeInto("gene-search", data.clip.gene);
|
||||||
await page.keyboard.press("Enter");
|
await page.keyboard.press("Enter");
|
||||||
|
|
||||||
await page.waitForSelector(`[data-testid='histogram-${data.clip.gene}']`);
|
await page.waitForSelector(`[data-testid='histogram-${data.clip.gene}']`);
|
||||||
await cxgActions.clip(data.clip.min, data.clip.max);
|
|
||||||
|
await clip(data.clip.min, data.clip.max);
|
||||||
|
|
||||||
const histBrushableAreaId = `histogram-${data.clip.gene}-plot-brushable-area`;
|
const histBrushableAreaId = `histogram-${data.clip.gene}-plot-brushable-area`;
|
||||||
const coords = await cxgActions.calcDragCoordinates(
|
|
||||||
|
const coords = await calcDragCoordinates(
|
||||||
histBrushableAreaId,
|
histBrushableAreaId,
|
||||||
data.clip["coordinates-as-percent"]
|
data.clip["coordinates-as-percent"]
|
||||||
);
|
);
|
||||||
await cxgActions.drag(histBrushableAreaId, coords.start, coords.end);
|
|
||||||
const cellCount = await cxgActions.cellSet(1);
|
await drag(histBrushableAreaId, coords.start, coords.end);
|
||||||
|
|
||||||
|
const cellCount = await getCellSetCount(1);
|
||||||
|
|
||||||
expect(cellCount).toBe(data.clip["gene-cell-count"]);
|
expect(cellCount).toBe(data.clip["gene-cell-count"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -291,85 +366,92 @@ describe("clipping", () => {
|
|||||||
// interact with UI elements just that they do not break
|
// interact with UI elements just that they do not break
|
||||||
describe("ui elements don't error", () => {
|
describe("ui elements don't error", () => {
|
||||||
test("color by", async () => {
|
test("color by", async () => {
|
||||||
for (const label in data.categorical) {
|
await goToPage(appUrlBase);
|
||||||
await utils.clickOn(`colorby-${label}`);
|
|
||||||
}
|
const allLabels = [
|
||||||
for (const label in data.continuous) {
|
...Object.keys(data.categorical),
|
||||||
await utils.clickOn(`colorby-${label}`);
|
...Object.keys(data.continuous),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const label of allLabels) {
|
||||||
|
await clickOn(`colorby-${label}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("color by for gene", async () => {
|
test("color by for gene", async () => {
|
||||||
await utils.typeInto("gene-search", data.genes.search);
|
await goToPage(appUrlBase);
|
||||||
|
|
||||||
|
await typeInto("gene-search", data.genes.search);
|
||||||
await page.keyboard.press("Enter");
|
await page.keyboard.press("Enter");
|
||||||
await page.waitForSelector(
|
await page.waitForSelector(
|
||||||
`[data-testid='histogram-${data.genes.search}']`
|
`[data-testid='histogram-${data.genes.search}']`
|
||||||
);
|
);
|
||||||
await utils.clickOn(`colorby-${data.genes.search}`);
|
await clickOn(`colorby-${data.genes.search}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("pan and zoom", async () => {
|
test("pan and zoom", async () => {
|
||||||
await utils.clickOn("mode-pan-zoom");
|
await goToPage(appUrlBase);
|
||||||
const panCoords = await cxgActions.calcDragCoordinates(
|
|
||||||
|
await clickOn("mode-pan-zoom");
|
||||||
|
const panCoords = await calcDragCoordinates(
|
||||||
"layout-graph",
|
"layout-graph",
|
||||||
data.pan["coordinates-as-percent"]
|
data.pan["coordinates-as-percent"]
|
||||||
);
|
);
|
||||||
await cxgActions.drag(
|
|
||||||
"layout-graph",
|
await drag("layout-graph", panCoords.start, panCoords.end, false);
|
||||||
panCoords.start,
|
|
||||||
panCoords.end,
|
|
||||||
false
|
|
||||||
);
|
|
||||||
await page.evaluate("window.scrollBy(0, 1000);");
|
await page.evaluate("window.scrollBy(0, 1000);");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("centroid labels", () => {
|
describe("centroid labels", () => {
|
||||||
test("labels are created", async () => {
|
test("labels are created", async () => {
|
||||||
|
await goToPage(appUrlBase);
|
||||||
|
|
||||||
const labels = Object.keys(data.categorical);
|
const labels = Object.keys(data.categorical);
|
||||||
|
|
||||||
await utils.clickOn(`colorby-${labels[0]}`);
|
await clickOn(`colorby-${labels[0]}`);
|
||||||
await utils.clickOn("centroid-label-toggle");
|
await clickOn("centroid-label-toggle");
|
||||||
/* eslint-disable no-await-in-loop */
|
|
||||||
// Toggle colorby for each category and check to see if labels are generated
|
// Toggle colorby for each category and check to see if labels are generated
|
||||||
for (let i = 0, { length } = labels; i < length; i += 1) {
|
for (let i = 0, { length } = labels; i < length; i += 1) {
|
||||||
const label = labels[i];
|
const label = labels[i];
|
||||||
// first label is already enabled
|
// first label is already enabled
|
||||||
if (i !== 0) await utils.clickOn(`colorby-${label}`);
|
if (i !== 0) await clickOn(`colorby-${label}`);
|
||||||
const generatedLabels = await utils.getAllByClass("centroid-label");
|
const generatedLabels = await getAllByClass("centroid-label");
|
||||||
// Number of labels generated should be equal to size of the object
|
// Number of labels generated should be equal to size of the object
|
||||||
expect(generatedLabels).toHaveLength(
|
expect(generatedLabels).toHaveLength(
|
||||||
Object.keys(data.categorical[label]).length
|
Object.keys(data.categorical[label]).length
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
/* eslint-enable no-await-in-loop */
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("graph overlay", () => {
|
describe("graph overlay", () => {
|
||||||
test("transform centroids correctly", async () => {
|
test("transform centroids correctly", async () => {
|
||||||
|
await goToPage(appUrlBase);
|
||||||
|
|
||||||
const category = Object.keys(data.categorical)[0];
|
const category = Object.keys(data.categorical)[0];
|
||||||
await utils.clickOn(`colorby-${category}`);
|
|
||||||
await utils.clickOn("centroid-label-toggle");
|
await clickOn(`colorby-${category}`);
|
||||||
await utils.clickOn("mode-pan-zoom");
|
await clickOn("centroid-label-toggle");
|
||||||
const panCoords = await cxgActions.calcDragCoordinates(
|
await clickOn("mode-pan-zoom");
|
||||||
|
|
||||||
|
const panCoords = await calcDragCoordinates(
|
||||||
"layout-graph",
|
"layout-graph",
|
||||||
data.pan["coordinates-as-percent"]
|
data.pan["coordinates-as-percent"]
|
||||||
);
|
);
|
||||||
|
|
||||||
const categoryValue = Object.keys(data.categorical[category])[0];
|
const categoryValue = Object.keys(data.categorical[category])[0];
|
||||||
const initialCoordinates = await utils.getElementCoordinates(
|
const initialCoordinates = await getElementCoordinates(
|
||||||
`${categoryValue}-centroid-label`
|
`${categoryValue}-centroid-label`
|
||||||
);
|
);
|
||||||
await cxgActions.drag(
|
|
||||||
"layout-graph",
|
await drag("layout-graph", panCoords.start, panCoords.end, false);
|
||||||
panCoords.start,
|
const terminalCoordinates = await getElementCoordinates(
|
||||||
panCoords.end,
|
|
||||||
false
|
|
||||||
);
|
|
||||||
const terminalCoordinates = await utils.getElementCoordinates(
|
|
||||||
`${categoryValue}-centroid-label`
|
`${categoryValue}-centroid-label`
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(terminalCoordinates[0] - initialCoordinates[0]).toBeCloseTo(
|
expect(terminalCoordinates[0] - initialCoordinates[0]).toBeCloseTo(
|
||||||
panCoords.end.x - panCoords.start.x
|
panCoords.end.x - panCoords.start.x
|
||||||
);
|
);
|
||||||
@@ -378,3 +460,62 @@ describe("graph overlay", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("pan zoom mode resets lasso selection", async () => {
|
||||||
|
await goToPage(appUrlBase);
|
||||||
|
|
||||||
|
const panzoomLasso = data.features.panzoom.lasso;
|
||||||
|
|
||||||
|
const lassoSelection = await calcDragCoordinates(
|
||||||
|
"layout-graph",
|
||||||
|
panzoomLasso["coordinates-as-percent"]
|
||||||
|
);
|
||||||
|
|
||||||
|
await drag("layout-graph", lassoSelection.start, lassoSelection.end, true);
|
||||||
|
await waitByID("lasso-element", { visible: true });
|
||||||
|
|
||||||
|
const initialCount = await getCellSetCount(1);
|
||||||
|
|
||||||
|
expect(initialCount).toBe(panzoomLasso.count);
|
||||||
|
|
||||||
|
await clickOn("mode-pan-zoom");
|
||||||
|
await clickOn("mode-lasso");
|
||||||
|
|
||||||
|
const modeSwitchCount = await getCellSetCount(1);
|
||||||
|
|
||||||
|
expect(modeSwitchCount).toBe(initialCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lasso moves after pan", async () => {
|
||||||
|
await goToPage(appUrlBase);
|
||||||
|
|
||||||
|
const panzoomLasso = data.features.panzoom.lasso;
|
||||||
|
const coordinatesAsPercent = panzoomLasso["coordinates-as-percent"];
|
||||||
|
|
||||||
|
const lassoSelection = await calcDragCoordinates(
|
||||||
|
"layout-graph",
|
||||||
|
coordinatesAsPercent
|
||||||
|
);
|
||||||
|
|
||||||
|
await drag("layout-graph", lassoSelection.start, lassoSelection.end, true);
|
||||||
|
await waitByID("lasso-element", { visible: true });
|
||||||
|
|
||||||
|
const initialCount = await getCellSetCount(1);
|
||||||
|
|
||||||
|
expect(initialCount).toBe(panzoomLasso.count);
|
||||||
|
|
||||||
|
await clickOn("mode-pan-zoom");
|
||||||
|
|
||||||
|
const panCoords = await calcDragCoordinates(
|
||||||
|
"layout-graph",
|
||||||
|
coordinatesAsPercent
|
||||||
|
);
|
||||||
|
|
||||||
|
await drag("layout-graph", panCoords.start, panCoords.end, false);
|
||||||
|
await clickOn("mode-lasso");
|
||||||
|
|
||||||
|
const panCount = await getCellSetCount(2);
|
||||||
|
|
||||||
|
expect(panCount).toBe(initialCount);
|
||||||
|
});
|
||||||
|
/* eslint-enable no-await-in-loop -- await in loop is needed to emulate sequential user actions */
|
||||||
|
|||||||
@@ -2,249 +2,332 @@
|
|||||||
Tests included in this file are specific to annotation features
|
Tests included in this file are specific to annotation features
|
||||||
*/
|
*/
|
||||||
import { appUrlBase, DATASET } from "./config";
|
import { appUrlBase, DATASET } from "./config";
|
||||||
import { setupTestBrowser } from "./testBrowser";
|
|
||||||
import { datasets } from "./data";
|
import { datasets } from "./data";
|
||||||
|
|
||||||
let browser;
|
import {
|
||||||
let page;
|
clickOn,
|
||||||
let utils;
|
goToPage,
|
||||||
let actions;
|
waitByClass,
|
||||||
|
waitByID,
|
||||||
|
getTestId,
|
||||||
|
getTestClass,
|
||||||
|
getAllByClass,
|
||||||
|
} from "./puppeteerUtils";
|
||||||
|
|
||||||
|
import {
|
||||||
|
assertCategoryDoesNotExist,
|
||||||
|
calcDragCoordinates,
|
||||||
|
createCategory,
|
||||||
|
createLabel,
|
||||||
|
deleteCategory,
|
||||||
|
deleteLabel,
|
||||||
|
drag,
|
||||||
|
expandCategory,
|
||||||
|
renameCategory,
|
||||||
|
renameLabel,
|
||||||
|
subset,
|
||||||
|
duplicateCategory,
|
||||||
|
} from "./cellxgeneActions";
|
||||||
|
|
||||||
const data = datasets[DATASET];
|
const data = datasets[DATASET];
|
||||||
|
|
||||||
beforeAll(async () => {
|
const perTestCategoryName = "TEST-CATEGORY";
|
||||||
[browser, page, utils, actions] = await setupTestBrowser();
|
const perTestLabelName = "TEST-LABEL";
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
async function setup(config) {
|
||||||
if (browser !== undefined) browser.close();
|
await goToPage(appUrlBase);
|
||||||
});
|
|
||||||
|
// setup the test fixtures
|
||||||
|
await createCategory(perTestCategoryName);
|
||||||
|
await createLabel(perTestCategoryName, perTestLabelName);
|
||||||
|
|
||||||
|
if (config.withSubset) {
|
||||||
|
await subset({ x1: 0.1, y1: 0.1, x2: 0.8, y2: 0.8 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await waitByClass("autosave-complete");
|
||||||
|
}
|
||||||
|
|
||||||
describe.each([
|
describe.each([
|
||||||
{ withSubset: true, tag: "subset" },
|
{ withSubset: true, tag: "subset" },
|
||||||
{ withSubset: false, tag: "whole" },
|
{ withSubset: false, tag: "whole" },
|
||||||
])("annotations", (config) => {
|
])("annotations", (config) => {
|
||||||
const perTestCategoryName = "per-test-category";
|
|
||||||
const perTestLabelName = "per-test-label";
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await page.goto(appUrlBase);
|
|
||||||
// wait for the page to load
|
|
||||||
await utils.waitByClass("autosave-complete");
|
|
||||||
// setup the test fixtures
|
|
||||||
await actions.createCategory(perTestCategoryName);
|
|
||||||
await actions.createLabel(perTestCategoryName, perTestLabelName);
|
|
||||||
if (config.withSubset)
|
|
||||||
await actions.subset({ x1: 0.1, y1: 0.1, x2: 0.8, y2: 0.8 });
|
|
||||||
await utils.waitByClass("autosave-complete");
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await deleteCategoryIfExists(perTestCategoryName);
|
|
||||||
await utils.waitByClass("autosave-complete");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("create a category", async () => {
|
test("create a category", async () => {
|
||||||
|
await setup(config);
|
||||||
|
|
||||||
const categoryName = `category-created-${config.tag}`;
|
const categoryName = `category-created-${config.tag}`;
|
||||||
|
|
||||||
await assertCategoryDoesNotExist(categoryName);
|
await assertCategoryDoesNotExist(categoryName);
|
||||||
await actions.createCategory(categoryName);
|
|
||||||
|
await createCategory(categoryName);
|
||||||
|
|
||||||
await assertCategoryExists(categoryName);
|
await assertCategoryExists(categoryName);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("delete a category", async () => {
|
test("delete a category", async () => {
|
||||||
await actions.deleteCategory(perTestCategoryName);
|
await setup(config);
|
||||||
|
|
||||||
|
await deleteCategory(perTestCategoryName);
|
||||||
await assertCategoryDoesNotExist(perTestCategoryName);
|
await assertCategoryDoesNotExist(perTestCategoryName);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rename a category", async () => {
|
test("rename a category", async () => {
|
||||||
const newCategoryName = `cluster-for-real-${config.tag}`;
|
await setup(config);
|
||||||
await actions.renameCategory(perTestCategoryName, newCategoryName);
|
|
||||||
|
const newCategoryName = `NEW-${config.tag}`;
|
||||||
|
|
||||||
|
await renameCategory(perTestCategoryName, newCategoryName);
|
||||||
await assertCategoryDoesNotExist(perTestCategoryName);
|
await assertCategoryDoesNotExist(perTestCategoryName);
|
||||||
await assertCategoryExists(newCategoryName);
|
await assertCategoryExists(newCategoryName);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("create a label", async () => {
|
test("create a label", async () => {
|
||||||
|
await setup(config);
|
||||||
|
|
||||||
const labelName = `new-label-${config.tag}`;
|
const labelName = `new-label-${config.tag}`;
|
||||||
|
|
||||||
await assertLabelDoesNotExist(perTestCategoryName, labelName);
|
await assertLabelDoesNotExist(perTestCategoryName, labelName);
|
||||||
await actions.createLabel(perTestCategoryName, labelName);
|
|
||||||
|
await createLabel(perTestCategoryName, labelName);
|
||||||
|
|
||||||
await assertLabelExists(perTestCategoryName, labelName);
|
await assertLabelExists(perTestCategoryName, labelName);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("delete a label", async () => {
|
test("delete a label", async () => {
|
||||||
await actions.deleteLabel(perTestCategoryName, perTestLabelName);
|
await setup(config);
|
||||||
|
|
||||||
|
await deleteLabel(perTestCategoryName, perTestLabelName);
|
||||||
await assertLabelDoesNotExist(perTestCategoryName, perTestLabelName);
|
await assertLabelDoesNotExist(perTestCategoryName, perTestLabelName);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rename a label", async () => {
|
test("rename a label", async () => {
|
||||||
|
await setup(config);
|
||||||
|
|
||||||
const newLabelName = "my-cool-new-label";
|
const newLabelName = "my-cool-new-label";
|
||||||
|
|
||||||
await assertLabelDoesNotExist(perTestCategoryName, newLabelName);
|
await assertLabelDoesNotExist(perTestCategoryName, newLabelName);
|
||||||
await actions.renameLabel(
|
await renameLabel(perTestCategoryName, perTestLabelName, newLabelName);
|
||||||
perTestCategoryName,
|
|
||||||
perTestLabelName,
|
|
||||||
newLabelName
|
|
||||||
);
|
|
||||||
await assertLabelDoesNotExist(perTestCategoryName, perTestLabelName);
|
await assertLabelDoesNotExist(perTestCategoryName, perTestLabelName);
|
||||||
await assertLabelExists(perTestCategoryName, newLabelName);
|
await assertLabelExists(perTestCategoryName, newLabelName);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("check cell count for a label loaded from file", async () => {
|
test("check cell count for a label loaded from file", async () => {
|
||||||
const categoryName = "cluster-test";
|
await setup(config);
|
||||||
const labelName = "four";
|
|
||||||
await actions.expandCategory(categoryName);
|
const duplicateCategoryName = "duplicate";
|
||||||
const result = await utils.waitByID(
|
await duplicateCategory(duplicateCategoryName);
|
||||||
`categorical-value-count-${categoryName}-${labelName}`
|
|
||||||
|
await page.reload({ waitUntil: ["networkidle0", "domcontentloaded"] });
|
||||||
|
|
||||||
|
const firstCategoryExpandIcon = await expect(page).toMatchElement(
|
||||||
|
getTestClass("category-expand")
|
||||||
);
|
);
|
||||||
expect(await result.evaluate((node) => node.innerText)).toBe(
|
|
||||||
data.annotationsFromFile.count.bySubsetConfig[config.withSubset]
|
await firstCategoryExpandIcon.click();
|
||||||
|
|
||||||
|
const expectedCategoryRow = await expect(page).toMatchElement(
|
||||||
|
getTestClass("categorical-row")
|
||||||
);
|
);
|
||||||
|
const expectedLabelName = await getInnerText(
|
||||||
|
expectedCategoryRow,
|
||||||
|
"categorical-value"
|
||||||
|
);
|
||||||
|
const expectedLabelCount = await getInnerText(
|
||||||
|
expectedCategoryRow,
|
||||||
|
"categorical-value-count"
|
||||||
|
);
|
||||||
|
|
||||||
|
await expandCategory(duplicateCategoryName);
|
||||||
|
|
||||||
|
const expectedCategory = await expect(page).toMatchElement(
|
||||||
|
getTestClass("category")
|
||||||
|
);
|
||||||
|
|
||||||
|
const actualCategoryRow = await expect(expectedCategory).toMatchElement(
|
||||||
|
getTestClass("categorical-row")
|
||||||
|
);
|
||||||
|
const actualLabelName = await getInnerText(
|
||||||
|
actualCategoryRow,
|
||||||
|
"categorical-value"
|
||||||
|
);
|
||||||
|
const actualLabelCount = await getInnerText(
|
||||||
|
actualCategoryRow,
|
||||||
|
"categorical-value-count"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(actualLabelName).toBe(expectedLabelName);
|
||||||
|
expect(actualLabelCount).toBe(expectedLabelCount);
|
||||||
|
|
||||||
|
async function getInnerText(element, className) {
|
||||||
|
return element.$eval(getTestClass(className), (node) => node?.innerText);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("assign cells to a label", async () => {
|
test("assign cells to a label", async () => {
|
||||||
await actions.expandCategory(perTestCategoryName);
|
await setup(config);
|
||||||
const lassoSelection = await actions.calcDragCoordinates(
|
|
||||||
|
await expandCategory(perTestCategoryName);
|
||||||
|
|
||||||
|
const lassoSelection = await calcDragCoordinates(
|
||||||
"layout-graph",
|
"layout-graph",
|
||||||
data.categoryLabel.lasso["coordinates-as-percent"]
|
data.categoryLabel.lasso["coordinates-as-percent"]
|
||||||
);
|
);
|
||||||
await actions.drag(
|
|
||||||
"layout-graph",
|
await drag("layout-graph", lassoSelection.start, lassoSelection.end, true);
|
||||||
lassoSelection.start,
|
await waitByID("lasso-element", { visible: true });
|
||||||
lassoSelection.end,
|
await clickOn(`${perTestCategoryName}:${perTestLabelName}:see-actions`);
|
||||||
true
|
await clickOn(
|
||||||
);
|
|
||||||
await utils.waitByID("lasso-element", { visible: true });
|
|
||||||
await utils.clickOn(
|
|
||||||
`${perTestCategoryName}:${perTestLabelName}:see-actions`
|
|
||||||
);
|
|
||||||
await utils.clickOn(
|
|
||||||
`${perTestCategoryName}:${perTestLabelName}:add-current-selection-to-this-label`
|
`${perTestCategoryName}:${perTestLabelName}:add-current-selection-to-this-label`
|
||||||
);
|
);
|
||||||
const result = await utils.waitByID(
|
|
||||||
|
const result = await waitByID(
|
||||||
`categorical-value-count-${perTestCategoryName}-${perTestLabelName}`
|
`categorical-value-count-${perTestCategoryName}-${perTestLabelName}`
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(await result.evaluate((node) => node.innerText)).toBe(
|
expect(await result.evaluate((node) => node.innerText)).toBe(
|
||||||
data.categoryLabel.newCount.bySubsetConfig[config.withSubset]
|
data.categoryLabel.newCount.bySubsetConfig[config.withSubset]
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("undo/redo category creation", async () => {
|
test("undo/redo category creation", async () => {
|
||||||
|
await setup(config);
|
||||||
|
|
||||||
const categoryName = `category-created-undo-${config.tag}`;
|
const categoryName = `category-created-undo-${config.tag}`;
|
||||||
|
|
||||||
await assertCategoryDoesNotExist(categoryName);
|
await assertCategoryDoesNotExist(categoryName);
|
||||||
await actions.createCategory(categoryName);
|
await createCategory(categoryName);
|
||||||
await assertCategoryExists(categoryName);
|
await assertCategoryExists(categoryName);
|
||||||
await utils.clickOn("undo");
|
await clickOn("undo");
|
||||||
await assertCategoryDoesNotExist(categoryName);
|
await assertCategoryDoesNotExist(categoryName);
|
||||||
await utils.clickOn("redo");
|
await clickOn("redo");
|
||||||
await assertCategoryExists(categoryName);
|
await assertCategoryExists(categoryName);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("undo/redo category deletion", async () => {
|
test("undo/redo category deletion", async () => {
|
||||||
|
await setup(config);
|
||||||
|
|
||||||
const categoryName = `category-deleted-undo-${config.tag}`;
|
const categoryName = `category-deleted-undo-${config.tag}`;
|
||||||
await actions.createCategory(categoryName);
|
|
||||||
|
await createCategory(categoryName);
|
||||||
await assertCategoryExists(categoryName);
|
await assertCategoryExists(categoryName);
|
||||||
await actions.deleteCategory(categoryName);
|
await deleteCategory(categoryName);
|
||||||
await assertCategoryDoesNotExist(categoryName);
|
await assertCategoryDoesNotExist(categoryName);
|
||||||
await utils.clickOn("undo");
|
await clickOn("undo");
|
||||||
await assertCategoryExists(categoryName);
|
await assertCategoryExists(categoryName);
|
||||||
await utils.clickOn("redo");
|
await clickOn("redo");
|
||||||
await assertCategoryDoesNotExist(categoryName);
|
await assertCategoryDoesNotExist(categoryName);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("undo/redo category rename", async () => {
|
test("undo/redo category rename", async () => {
|
||||||
|
await setup(config);
|
||||||
|
|
||||||
const newCategoryName = `category-renamed-undo-${config.tag}`;
|
const newCategoryName = `category-renamed-undo-${config.tag}`;
|
||||||
|
|
||||||
await assertCategoryDoesNotExist(newCategoryName);
|
await assertCategoryDoesNotExist(newCategoryName);
|
||||||
await actions.renameCategory(perTestCategoryName, newCategoryName);
|
await renameCategory(perTestCategoryName, newCategoryName);
|
||||||
await assertCategoryExists(newCategoryName);
|
await assertCategoryExists(newCategoryName);
|
||||||
await assertCategoryDoesNotExist(perTestCategoryName);
|
await assertCategoryDoesNotExist(perTestCategoryName);
|
||||||
await utils.clickOn("undo");
|
await clickOn("undo");
|
||||||
await assertCategoryExists(perTestCategoryName);
|
await assertCategoryExists(perTestCategoryName);
|
||||||
await assertCategoryDoesNotExist(newCategoryName);
|
await assertCategoryDoesNotExist(newCategoryName);
|
||||||
await utils.clickOn("redo");
|
await clickOn("redo");
|
||||||
await assertCategoryExists(newCategoryName);
|
await assertCategoryExists(newCategoryName);
|
||||||
await assertCategoryDoesNotExist(perTestCategoryName);
|
await assertCategoryDoesNotExist(perTestCategoryName);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("undo/redo label creation", async () => {
|
test("undo/redo label creation", async () => {
|
||||||
|
await setup(config);
|
||||||
|
|
||||||
const labelName = `label-created-undo-${config.tag}`;
|
const labelName = `label-created-undo-${config.tag}`;
|
||||||
|
|
||||||
await assertLabelDoesNotExist(perTestCategoryName, labelName);
|
await assertLabelDoesNotExist(perTestCategoryName, labelName);
|
||||||
await actions.createLabel(perTestCategoryName, labelName);
|
await createLabel(perTestCategoryName, labelName);
|
||||||
await assertLabelExists(perTestCategoryName, labelName);
|
await assertLabelExists(perTestCategoryName, labelName);
|
||||||
await utils.clickOn("undo");
|
await clickOn("undo");
|
||||||
await assertLabelDoesNotExist(perTestCategoryName);
|
await assertLabelDoesNotExist(perTestCategoryName);
|
||||||
await utils.clickOn("redo");
|
await clickOn("redo");
|
||||||
await assertLabelExists(perTestCategoryName, labelName);
|
await assertLabelExists(perTestCategoryName, labelName);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("undo/redo label deletion", async () => {
|
test("undo/redo label deletion", async () => {
|
||||||
await actions.deleteLabel(perTestCategoryName, perTestLabelName);
|
await setup(config);
|
||||||
|
|
||||||
|
await deleteLabel(perTestCategoryName, perTestLabelName);
|
||||||
await assertLabelDoesNotExist(perTestCategoryName);
|
await assertLabelDoesNotExist(perTestCategoryName);
|
||||||
await utils.clickOn("undo");
|
await clickOn("undo");
|
||||||
await assertLabelExists(perTestCategoryName, perTestLabelName);
|
await assertLabelExists(perTestCategoryName, perTestLabelName);
|
||||||
await utils.clickOn("redo");
|
await clickOn("redo");
|
||||||
await assertLabelDoesNotExist(perTestCategoryName);
|
await assertLabelDoesNotExist(perTestCategoryName);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("undo/redo label rename", async () => {
|
test("undo/redo label rename", async () => {
|
||||||
|
await setup(config);
|
||||||
|
|
||||||
const newLabelName = `label-renamed-undo-${config.tag}`;
|
const newLabelName = `label-renamed-undo-${config.tag}`;
|
||||||
|
|
||||||
await assertLabelDoesNotExist(perTestCategoryName, newLabelName);
|
await assertLabelDoesNotExist(perTestCategoryName, newLabelName);
|
||||||
await actions.renameLabel(
|
await renameLabel(perTestCategoryName, perTestLabelName, newLabelName);
|
||||||
perTestCategoryName,
|
|
||||||
perTestLabelName,
|
|
||||||
newLabelName
|
|
||||||
);
|
|
||||||
await assertLabelExists(perTestCategoryName, newLabelName);
|
await assertLabelExists(perTestCategoryName, newLabelName);
|
||||||
await assertLabelDoesNotExist(perTestCategoryName, perTestLabelName);
|
await assertLabelDoesNotExist(perTestCategoryName, perTestLabelName);
|
||||||
await utils.clickOn("undo");
|
await clickOn("undo");
|
||||||
await assertLabelExists(perTestCategoryName, perTestLabelName);
|
await assertLabelExists(perTestCategoryName, perTestLabelName);
|
||||||
await assertLabelDoesNotExist(perTestCategoryName, newLabelName);
|
await assertLabelDoesNotExist(perTestCategoryName, newLabelName);
|
||||||
await utils.clickOn("redo");
|
await clickOn("redo");
|
||||||
await assertLabelExists(perTestCategoryName, newLabelName);
|
await assertLabelExists(perTestCategoryName, newLabelName);
|
||||||
await assertLabelDoesNotExist(perTestCategoryName, perTestLabelName);
|
await assertLabelDoesNotExist(perTestCategoryName, perTestLabelName);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("stacked bar graph renders", async () => {
|
||||||
|
await setup(config);
|
||||||
|
|
||||||
|
await expandCategory(perTestCategoryName);
|
||||||
|
|
||||||
|
await clickOn(`colorby-louvain`);
|
||||||
|
|
||||||
|
const labels = await getAllByClass("categorical-row");
|
||||||
|
|
||||||
|
const result = await Promise.all(
|
||||||
|
labels.map((label) => {
|
||||||
|
return page.evaluate((element) => {
|
||||||
|
return element.outerHTML;
|
||||||
|
}, label);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
async function assertCategoryExists(categoryName) {
|
async function assertCategoryExists(categoryName) {
|
||||||
const handle = await utils.waitByID(`${categoryName}:category-label`);
|
const handle = await waitByID(`${categoryName}:category-label`);
|
||||||
|
|
||||||
const result = await handle.evaluate((node) =>
|
const result = await handle.evaluate((node) =>
|
||||||
node.getAttribute("aria-label")
|
node.getAttribute("aria-label")
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toBe(categoryName);
|
return expect(result).toBe(categoryName);
|
||||||
}
|
|
||||||
|
|
||||||
async function assertCategoryDoesNotExist(categoryName) {
|
|
||||||
const result = await page.$(
|
|
||||||
`[data-testid='${categoryName}:category-label']`
|
|
||||||
);
|
|
||||||
expect(result).toBeNull();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function assertLabelExists(categoryName, labelName) {
|
async function assertLabelExists(categoryName, labelName) {
|
||||||
const category = await utils.waitByID(`${categoryName}:category-expand`);
|
await expect(page).toMatchElement(
|
||||||
expect(category).not.toBeNull();
|
getTestId(`${categoryName}:category-expand`)
|
||||||
await actions.expandCategory(categoryName);
|
);
|
||||||
const previous = await utils.waitByID(
|
|
||||||
|
await expandCategory(categoryName);
|
||||||
|
|
||||||
|
const previous = await waitByID(
|
||||||
`categorical-value-${categoryName}-${labelName}`
|
`categorical-value-${categoryName}-${labelName}`
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await previous.evaluate((node) => node.getAttribute("aria-label"))
|
await previous.evaluate((node) => node.getAttribute("aria-label"))
|
||||||
).toBe(labelName);
|
).toBe(labelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function assertLabelDoesNotExist(categoryName, labelName) {
|
async function assertLabelDoesNotExist(categoryName, labelName) {
|
||||||
await actions.expandCategory(categoryName);
|
await expandCategory(categoryName);
|
||||||
const result = await page.$(
|
const result = await page.$(
|
||||||
`[data-testid='categorical-value-${categoryName}-${labelName}']`
|
`[data-testid='categorical-value-${categoryName}-${labelName}']`
|
||||||
);
|
);
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteCategoryIfExists(categoryName) {
|
|
||||||
try {
|
|
||||||
const category = await page.waitForSelector(
|
|
||||||
`[data-testid='${categoryName}:category-expand']`,
|
|
||||||
{ timeout: 200 }
|
|
||||||
);
|
|
||||||
if (category !== null) return await actions.deleteCategory(categoryName);
|
|
||||||
} catch {}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
{
|
{
|
||||||
|
"testRunner": "jest-circus/runner",
|
||||||
"preset": "jest-puppeteer",
|
"preset": "jest-puppeteer",
|
||||||
"testMatch": ["**/__tests__/**/?(*.)(spec|test).js?(x)"],
|
"testMatch": ["**/__tests__/**/?(*.)(spec|test).js?(x)"],
|
||||||
"setupFiles": ["../setupMissingGlobals.js"]
|
"setupFiles": ["../setupMissingGlobals.js"],
|
||||||
|
"setupFilesAfterEnv": ["expect-puppeteer", "./puppeteer.setup.js"],
|
||||||
|
"globalSetup": "jest-environment-puppeteer/setup",
|
||||||
|
"globalTeardown": "jest-environment-puppeteer/teardown",
|
||||||
|
"testEnvironment": "./screenshot_env.js"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
/*
|
|
||||||
NOT run in Travis CI
|
|
||||||
|
|
||||||
UX tests using puppeteer to be run locally.
|
|
||||||
|
|
||||||
To run locally, ensure you are running the client is running on port 3000.
|
|
||||||
Then run jest --verbose false --config __tests__/e2e/e2eJestConfig.json feature.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import puppeteer from "puppeteer";
|
|
||||||
import { appUrlBase, DEBUG, DEV, DATASET } from "./config";
|
|
||||||
import { puppeteerUtils, cellxgeneActions } from "./puppeteerUtils";
|
|
||||||
import { datasets } from "./data";
|
|
||||||
|
|
||||||
let browser;
|
|
||||||
let page;
|
|
||||||
let utils;
|
|
||||||
let cxgActions;
|
|
||||||
let spy;
|
|
||||||
const browserViewport = { width: 1280, height: 960 };
|
|
||||||
const data = datasets[DATASET].features;
|
|
||||||
|
|
||||||
if (DEBUG) jest.setTimeout(100000);
|
|
||||||
if (DEV) jest.setTimeout(10000);
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
const browserParams = DEV
|
|
||||||
? { headless: false, slowMo: 5 }
|
|
||||||
: DEBUG
|
|
||||||
? { headless: false, slowMo: 100, devtools: true }
|
|
||||||
: {};
|
|
||||||
browser = await puppeteer.launch(browserParams);
|
|
||||||
page = await browser.newPage();
|
|
||||||
await page.setViewport(browserViewport);
|
|
||||||
if (DEV || DEBUG) {
|
|
||||||
page.on("console", (msg) => console.log(`PAGE LOG: ${msg.text()}`));
|
|
||||||
}
|
|
||||||
page.on("pageerror", (err) => {
|
|
||||||
throw new Error(`Console error: ${err}`);
|
|
||||||
});
|
|
||||||
utils = puppeteerUtils(page);
|
|
||||||
cxgActions = cellxgeneActions(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await page.goto(appUrlBase);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
if (!DEBUG) {
|
|
||||||
browser.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("zoom interaction", async () => {
|
|
||||||
// Skip this test since UI is to hide lasso path when switching modes
|
|
||||||
test.skip("lasso visible after switching modes to pan/zoom", async () => {
|
|
||||||
const lassoSelection = await cxgActions.calcDragCoordinates(
|
|
||||||
"layout-graph",
|
|
||||||
data.panzoom.lasso["coordinates-as-percent"]
|
|
||||||
);
|
|
||||||
await cxgActions.drag(
|
|
||||||
"layout-graph",
|
|
||||||
lassoSelection.start,
|
|
||||||
lassoSelection.end,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
await utils.waitByID("lasso-element", { visible: true });
|
|
||||||
await utils.clickOn("mode-pan-zoom");
|
|
||||||
await utils.waitByID("lasso-element", { visible: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("pan zoom mode resets lasso selection", async () => {
|
|
||||||
const lassoSelection = await cxgActions.calcDragCoordinates(
|
|
||||||
"layout-graph",
|
|
||||||
data.panzoom.lasso["coordinates-as-percent"]
|
|
||||||
);
|
|
||||||
await cxgActions.drag(
|
|
||||||
"layout-graph",
|
|
||||||
lassoSelection.start,
|
|
||||||
lassoSelection.end,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
await utils.waitByID("lasso-element", { visible: true });
|
|
||||||
const initialCount = await cxgActions.cellSet(1);
|
|
||||||
expect(initialCount).toBe(data.panzoom.lasso.count);
|
|
||||||
await utils.clickOn("mode-pan-zoom");
|
|
||||||
await utils.clickOn("mode-lasso");
|
|
||||||
const modeSwitchCount = await cxgActions.cellSet(1);
|
|
||||||
expect(modeSwitchCount).toBe(initialCount);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("lasso moves after pan", async () => {
|
|
||||||
const lassoSelection = await cxgActions.calcDragCoordinates(
|
|
||||||
"layout-graph",
|
|
||||||
data.panzoom.lasso["coordinates-as-percent"]
|
|
||||||
);
|
|
||||||
await cxgActions.drag(
|
|
||||||
"layout-graph",
|
|
||||||
lassoSelection.start,
|
|
||||||
lassoSelection.end,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
await utils.waitByID("lasso-element", { visible: true });
|
|
||||||
const initialCount = await cxgActions.cellSet(1);
|
|
||||||
expect(initialCount).toBe(data.panzoom.lasso.count);
|
|
||||||
await utils.clickOn("mode-pan-zoom");
|
|
||||||
const panCoords = await cxgActions.calcDragCoordinates(
|
|
||||||
"layout-graph",
|
|
||||||
data.panzoom.lasso["coordinates-as-percent"]
|
|
||||||
);
|
|
||||||
await cxgActions.drag(
|
|
||||||
"layout-graph",
|
|
||||||
panCoords.start,
|
|
||||||
panCoords.end,
|
|
||||||
false
|
|
||||||
);
|
|
||||||
await utils.clickOn("mode-lasso");
|
|
||||||
const panCount = await cxgActions.cellSet(2);
|
|
||||||
expect(panCount).toBe(initialCount);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* `client/jest-puppeteer.config.js` is for configuring Puppeteer's launch config options
|
||||||
|
* `client/__tests__/e2e/puppeteer.setup.js` is for configuring `jest`, `browser`,
|
||||||
|
* and `page` objects
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { setDefaultOptions } from "expect-puppeteer";
|
||||||
|
import { isDebug, isDev } from "./config";
|
||||||
|
import * as ENV_DEFAULT from "../../../environment.default.json";
|
||||||
|
|
||||||
|
// (thuang): This is the max time a test can take to run.
|
||||||
|
// Since when debugging, we run slowMo and !headless, this means
|
||||||
|
// a test can take more time to finish, so we don't want
|
||||||
|
// jest to shut off the test too soon
|
||||||
|
jest.setTimeout(2 * 60 * 1000);
|
||||||
|
setDefaultOptions({ timeout: 20 * 1000 });
|
||||||
|
|
||||||
|
jest.retryTimes(ENV_DEFAULT.RETRY_ATTEMPTS);
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const userAgent = await browser.userAgent();
|
||||||
|
await page.setUserAgent(`${userAgent}bot`);
|
||||||
|
|
||||||
|
await page._client.send("Animation.setPlaybackRate", { playbackRate: 12 });
|
||||||
|
|
||||||
|
page.on("pageerror", (err) => {
|
||||||
|
throw new Error(`Console error: ${err}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
page.on("error", (err) => {
|
||||||
|
throw new Error(`Console error: ${err}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
page.on("console", async (msg) => {
|
||||||
|
if (isDev || isDebug) {
|
||||||
|
// If there is a console.error but an error is not thrown, this will ensure the test fails
|
||||||
|
console.log(`PAGE LOG: ${msg.text()}`);
|
||||||
|
if (msg.type() === "error") {
|
||||||
|
// TODO: chromium does not currently support the CSP directive on the
|
||||||
|
// line below, so we swallow this error. Remove this when the test
|
||||||
|
// suite uses a browser version that supports this directive.
|
||||||
|
if (
|
||||||
|
msg.text() ===
|
||||||
|
"Unrecognized Content-Security-Policy directive 'require-trusted-types-for'.\n"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const errorMsgText = await Promise.all(
|
||||||
|
// TODO can we do this without internal properties?
|
||||||
|
msg.args().map((arg) => arg._remoteObject.description)
|
||||||
|
);
|
||||||
|
throw new Error(`Console error: ${errorMsgText}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})().catch((error) => {
|
||||||
|
console.error("puppeteer.setup.js error", error);
|
||||||
|
});
|
||||||
@@ -1,38 +1,44 @@
|
|||||||
export const puppeteerUtils = (page) => ({
|
/* eslint-disable no-await-in-loop -- await in loop is needed to emulate sequential user actions */
|
||||||
async waitByID(testId, props = {}) {
|
export function getTestId(id) {
|
||||||
return page.waitForSelector(`[data-testid='${testId}']`, props);
|
return `[data-testid='${id}']`;
|
||||||
},
|
}
|
||||||
|
|
||||||
async waitByClass(testClass, props = {}) {
|
export function getTestClass(className) {
|
||||||
return page.waitForSelector(`[data-testclass='${testClass}']`, props);
|
return `[data-testclass='${className}']`;
|
||||||
},
|
}
|
||||||
|
|
||||||
async waitForAllByIds(testIds) {
|
export async function waitByID(testId, props = {}) {
|
||||||
|
return page.waitForSelector(getTestId(testId), props);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitByClass(testClass, props = {}) {
|
||||||
|
await page.waitForSelector(`[data-testclass='${testClass}']`, props);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForAllByIds(testIds) {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
testIds.map((testId) => page.waitForSelector(`[data-testid='${testId}']`))
|
testIds.map((testId) => page.waitForSelector(getTestId(testId)))
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
|
|
||||||
async getAllByClass(testClass) {
|
export async function getAllByClass(testClass) {
|
||||||
return page.$$eval(`[data-testclass=${testClass}]`, (eles) =>
|
return page.$$(`[data-testclass=${testClass}]`);
|
||||||
eles.map((ele) => ele.dataset.testid)
|
}
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
async typeInto(testId, text) {
|
export async function typeInto(testId, text) {
|
||||||
// blueprint's typeahead is treating typing weird, clicking & waiting first solves this
|
// blueprint's typeahead is treating typing weird, clicking & waiting first solves this
|
||||||
// only works for text without special characters
|
// only works for text without special characters
|
||||||
await this.waitByID(testId);
|
await waitByID(testId);
|
||||||
const selector = `[data-testid='${testId}']`;
|
const selector = getTestId(testId);
|
||||||
// type ahead can be annoying if you don't pause before you type
|
// type ahead can be annoying if you don't pause before you type
|
||||||
await page.click(selector);
|
await page.click(selector);
|
||||||
await page.waitFor(200);
|
await page.waitFor(200);
|
||||||
await page.type(selector, text);
|
await page.type(selector, text);
|
||||||
},
|
}
|
||||||
|
|
||||||
async clearInputAndTypeInto(testId, text) {
|
export async function clearInputAndTypeInto(testId, text) {
|
||||||
await this.waitByID(testId);
|
await waitByID(testId);
|
||||||
const selector = `[data-testid='${testId}']`;
|
const selector = getTestId(testId);
|
||||||
// only works for text without special characters
|
// only works for text without special characters
|
||||||
// type ahead can be annoying if you don't pause before you type
|
// type ahead can be annoying if you don't pause before you type
|
||||||
await page.click(selector);
|
await page.click(selector);
|
||||||
@@ -41,29 +47,85 @@ export const puppeteerUtils = (page) => ({
|
|||||||
await page.click(selector, { clickCount: 3 });
|
await page.click(selector, { clickCount: 3 });
|
||||||
await page.keyboard.press("Backspace");
|
await page.keyboard.press("Backspace");
|
||||||
await page.type(selector, text);
|
await page.type(selector, text);
|
||||||
},
|
}
|
||||||
|
|
||||||
async clickOn(testid, options = {}) {
|
export async function clickOn(testId, options = {}) {
|
||||||
await this.waitByID(testid, options);
|
await expect(page).toClick(getTestId(testId), options);
|
||||||
const click = await page.click(`[data-testid='${testid}']`);
|
}
|
||||||
await page.waitFor(50);
|
|
||||||
return click;
|
|
||||||
},
|
|
||||||
|
|
||||||
async getOneElementInnerHTML(selector, options = {}) {
|
/**
|
||||||
|
* (thuang): There are times when Puppeteer clicks on a button and the page doesn't respond.
|
||||||
|
* So I added clickOnUntil() to retry clicking until a given condition is met.
|
||||||
|
*/
|
||||||
|
export async function clickOnUntil(testId, assert) {
|
||||||
|
const MAX_RETRY = 10;
|
||||||
|
const WAIT_FOR_MS = 200;
|
||||||
|
|
||||||
|
let retry = 0;
|
||||||
|
|
||||||
|
while (retry < MAX_RETRY) {
|
||||||
|
try {
|
||||||
|
await clickOn(testId);
|
||||||
|
await assert();
|
||||||
|
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
retry += 1;
|
||||||
|
|
||||||
|
await page.waitFor(WAIT_FOR_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (retry === MAX_RETRY) {
|
||||||
|
throw Error("clickOnUntil() assertion failed!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOneElementInnerHTML(selector, options = {}) {
|
||||||
await page.waitForSelector(selector, options);
|
await page.waitForSelector(selector, options);
|
||||||
|
|
||||||
return page.$eval(selector, (el) => el.innerHTML);
|
return page.$eval(selector, (el) => el.innerHTML);
|
||||||
},
|
}
|
||||||
|
|
||||||
|
export async function getOneElementInnerText(selector) {
|
||||||
|
expect(page).toMatchElement(selector);
|
||||||
|
|
||||||
async getOneElementInnerText(selector) {
|
|
||||||
await page.waitForSelector(selector);
|
|
||||||
return page.$eval(selector, (el) => el.innerText);
|
return page.$eval(selector, (el) => el.innerText);
|
||||||
},
|
}
|
||||||
|
|
||||||
async getElementCoordinates(testid) {
|
export async function getElementCoordinates(testId) {
|
||||||
return page.$eval(`[data-testid='${testid}']`, (elem) => {
|
return page.$eval(getTestId(testId), (elem) => {
|
||||||
const { left, top } = elem.getBoundingClientRect();
|
const { left, top } = elem.getBoundingClientRect();
|
||||||
return [left, top];
|
return [left, top];
|
||||||
});
|
});
|
||||||
},
|
}
|
||||||
});
|
|
||||||
|
async function clickTermsOfService() {
|
||||||
|
if (!(await isElementPresent(getTestId("tos-cookies-accept")))) return;
|
||||||
|
|
||||||
|
await clickOn("tos-cookies-accept");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nameNewAnnotation() {
|
||||||
|
if (await isElementPresent(getTestId("annotation-dialog"))) {
|
||||||
|
await typeInto("new-annotation-name", "ignoreE2E");
|
||||||
|
await clickOn("submit-annotation");
|
||||||
|
|
||||||
|
// wait for the page to load
|
||||||
|
await waitByClass("autosave-complete");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function goToPage(url) {
|
||||||
|
await page.goto(url, {
|
||||||
|
waitUntil: "networkidle0",
|
||||||
|
});
|
||||||
|
|
||||||
|
await nameNewAnnotation();
|
||||||
|
await clickTermsOfService();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isElementPresent(selector, options) {
|
||||||
|
return Boolean(await page.$(selector, options));
|
||||||
|
}
|
||||||
|
/* eslint-enable no-await-in-loop -- await in loop is needed to emulate sequential user actions */
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
const PuppeteerEnvironment = require("jest-environment-puppeteer");
|
||||||
|
require("jest-circus");
|
||||||
|
const ENV_DEFAULT = require("../../../environment.default.json");
|
||||||
|
|
||||||
|
const takeScreenshot = require("./takeScreenshot");
|
||||||
|
|
||||||
|
class ScreenshotEnvironment extends PuppeteerEnvironment {
|
||||||
|
async handleTestEvent(event, state) {
|
||||||
|
if (["test_start", "test_done"].includes(event.name)) {
|
||||||
|
console.log("------------------event name:\n", event.name);
|
||||||
|
console.log("~~~~ Current test errors\n", new Date(), event.test.errors);
|
||||||
|
console.log("~~~~ Current test\n", new Date(), event.test);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.name === "error") {
|
||||||
|
console.log("error event:", JSON.stringify(event));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.name === "test_fn_failure" || event.name === "hook_failure") {
|
||||||
|
console.log("------------------event name:\n", event.name);
|
||||||
|
console.log(">>>> Current state\n", new Date(), state);
|
||||||
|
console.log("===> Failure event\n", new Date(), event);
|
||||||
|
|
||||||
|
// (thuang): We only want to take screenshot on the last try
|
||||||
|
if (
|
||||||
|
state.currentlyRunningTest.invocations <= ENV_DEFAULT.RETRY_ATTEMPTS
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await takeScreenshot(state.currentlyRunningTest.name, this.global.page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = ScreenshotEnvironment;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
function toFilename(name) {
|
||||||
|
return name.replace(/[^a-z0-9.-]+/gi, "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function takeScreenshot(currentTestName, page) {
|
||||||
|
const testName = toFilename(currentTestName);
|
||||||
|
|
||||||
|
// Take a screenshot at the point of failure
|
||||||
|
const date = new Date().toISOString();
|
||||||
|
const screenshotName = `${date}-${testName}.png`;
|
||||||
|
|
||||||
|
await page.screenshot({
|
||||||
|
path: `./__tests__/screenshots/ignoreE2E-screenshot-${screenshotName}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = takeScreenshot;
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import puppeteer from "puppeteer";
|
|
||||||
import { DEBUG, DEV } from "./config";
|
|
||||||
import { puppeteerUtils } from "./puppeteerUtils";
|
|
||||||
import { cellxgeneActions } from "./cellxgeneActions";
|
|
||||||
|
|
||||||
export async function setupTestBrowser() {
|
|
||||||
const browserViewport = { width: 1280, height: 960 };
|
|
||||||
const browserParams = DEV
|
|
||||||
? {
|
|
||||||
headless: false,
|
|
||||||
slowMo: 5,
|
|
||||||
args: [
|
|
||||||
`--window-size=${browserViewport.width},${browserViewport.height}`,
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: DEBUG
|
|
||||||
? {
|
|
||||||
headless: false,
|
|
||||||
slowMo: 100,
|
|
||||||
devtools: true,
|
|
||||||
args: [
|
|
||||||
`--window-size=${browserViewport.width + 560},${
|
|
||||||
browserViewport.height
|
|
||||||
}`,
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
args: [
|
|
||||||
`--window-size=${browserViewport.width},${browserViewport.height}`,
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const browser = await puppeteer.launch(browserParams);
|
|
||||||
const page = await browser.pages().then((pages) => pages[0]);
|
|
||||||
await page.setViewport(browserViewport);
|
|
||||||
if (DEV || DEBUG) {
|
|
||||||
page.on("console", async (msg) => {
|
|
||||||
// If there is a console.error but an error is not thrown, this will ensure the test fails
|
|
||||||
if (msg.type() === "error") {
|
|
||||||
// TODO: chromium does not currently support the CSP directive on the
|
|
||||||
// line below, so we swallow this error. Remove this when the test
|
|
||||||
// suite uses a browser version that supports this directive.
|
|
||||||
if (msg.text() === "Unrecognized Content-Security-Policy directive 'require-trusted-types-for'.\n") return;
|
|
||||||
const errorMsgText = await Promise.all(
|
|
||||||
// TODO can we do this without internal properties?
|
|
||||||
msg.args().map((arg) => arg._remoteObject.description)
|
|
||||||
);
|
|
||||||
throw new Error(`Console error: ${errorMsgText}`);
|
|
||||||
}
|
|
||||||
console.log(`PAGE LOG: ${msg.text()}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
page.on("pageerror", (err) => {
|
|
||||||
throw new Error(`Console error: ${err}`);
|
|
||||||
});
|
|
||||||
const utils = puppeteerUtils(page);
|
|
||||||
const cxgActions = cellxgeneActions(page, utils);
|
|
||||||
return [browser, page, utils, cxgActions];
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Ignore everything in this directory
|
||||||
|
*
|
||||||
|
# Except this file
|
||||||
|
!.gitignore
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
// these TWO statements MUST be first in the file, before any other imports
|
||||||
|
import { enableFetchMocks } from "jest-fetch-mock";
|
||||||
|
import * as serverMocks from "./serverMocks";
|
||||||
|
// OK, continue on!
|
||||||
|
|
||||||
|
import {
|
||||||
|
AnnoMatrixLoader,
|
||||||
|
clip,
|
||||||
|
isubset,
|
||||||
|
isubsetMask,
|
||||||
|
} from "../../../src/annoMatrix";
|
||||||
|
import { Dataframe } from "../../../src/util/dataframe";
|
||||||
|
|
||||||
|
enableFetchMocks();
|
||||||
|
|
||||||
|
describe("AnnoMatrix", () => {
|
||||||
|
let annoMatrix;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
fetch.resetMocks(); // reset all fetch mocking state
|
||||||
|
annoMatrix = new AnnoMatrixLoader(
|
||||||
|
serverMocks.baseDataURL,
|
||||||
|
serverMocks.schema.schema
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("basics", () => {
|
||||||
|
test("annomatrix static checks", () => {
|
||||||
|
expect(annoMatrix).toBeDefined();
|
||||||
|
expect(annoMatrix.schema).toMatchObject(serverMocks.schema.schema);
|
||||||
|
expect(annoMatrix.nObs).toEqual(serverMocks.schema.schema.dataframe.nObs);
|
||||||
|
expect(annoMatrix.nVar).toEqual(serverMocks.schema.schema.dataframe.nVar);
|
||||||
|
expect(annoMatrix.isView).toBeFalsy();
|
||||||
|
expect(annoMatrix.viewOf).toBeUndefined();
|
||||||
|
expect(annoMatrix.rowIndex).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("simple single column fetch", async () => {
|
||||||
|
fetch.once(serverMocks.annotationsObs(["name_0"]));
|
||||||
|
|
||||||
|
const df = await annoMatrix.fetch("obs", "name_0");
|
||||||
|
expect(df).toBeInstanceOf(Dataframe);
|
||||||
|
expect(df.colIndex.labels()).toEqual(["name_0"]);
|
||||||
|
expect(df.dims).toEqual([annoMatrix.nObs, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("simple multi column fetch", async () => {
|
||||||
|
fetch
|
||||||
|
.once(serverMocks.annotationsObs(["name_0"]))
|
||||||
|
.once(serverMocks.annotationsObs(["n_genes"]));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
annoMatrix.fetch("obs", ["name_0", "n_genes"])
|
||||||
|
).resolves.toBeInstanceOf(Dataframe);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fetch from field", () => {
|
||||||
|
const getLastTwo = async (field) => {
|
||||||
|
const columnNames = annoMatrix.getMatrixColumns(field).slice(-2);
|
||||||
|
fetch.mockResponses(...columnNames.map(() => serverMocks.responder));
|
||||||
|
await expect(
|
||||||
|
annoMatrix.fetch(field, columnNames)
|
||||||
|
).resolves.toBeInstanceOf(Dataframe);
|
||||||
|
};
|
||||||
|
|
||||||
|
test("obs", async () => getLastTwo("obs"));
|
||||||
|
test("var", async () => getLastTwo("var"));
|
||||||
|
test("emb", async () => getLastTwo("emb"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fetch - test all query forms", async () => {
|
||||||
|
// single string is a column name
|
||||||
|
fetch.once(serverMocks.annotationsObs(["n_genes"]));
|
||||||
|
await expect(annoMatrix.fetch("obs", "n_genes")).resolves.toBeInstanceOf(
|
||||||
|
Dataframe
|
||||||
|
);
|
||||||
|
|
||||||
|
// array of column names, expecting n_genes to be cached.
|
||||||
|
fetch.once(serverMocks.annotationsObs(["percent_mito"]));
|
||||||
|
await expect(
|
||||||
|
annoMatrix.fetch("obs", ["n_genes", "percent_mito"])
|
||||||
|
).resolves.toBeInstanceOf(Dataframe);
|
||||||
|
|
||||||
|
// more complex value filter query, enumerated
|
||||||
|
fetch.once(serverMocks.responder);
|
||||||
|
await expect(
|
||||||
|
annoMatrix.fetch("X", {
|
||||||
|
field: "var",
|
||||||
|
column: annoMatrix.schema.annotations.var.index,
|
||||||
|
value: "TYMP",
|
||||||
|
})
|
||||||
|
).resolves.toBeInstanceOf(Dataframe);
|
||||||
|
|
||||||
|
// more complex value filter query, range
|
||||||
|
const varIndex = annoMatrix.schema.annotations.var.index;
|
||||||
|
fetch
|
||||||
|
.once(
|
||||||
|
serverMocks.withExpected("/data/var", [[`var:${varIndex}`, "SUMO3"]])
|
||||||
|
)
|
||||||
|
.once(
|
||||||
|
serverMocks.withExpected("/data/var", [[`var:${varIndex}`, "TYMP"]])
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
annoMatrix.fetch("X", [
|
||||||
|
{
|
||||||
|
field: "var",
|
||||||
|
column: varIndex,
|
||||||
|
value: "SUMO3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "var",
|
||||||
|
column: varIndex,
|
||||||
|
value: "TYMP",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
).resolves.toBeInstanceOf(Dataframe);
|
||||||
|
// XXX inspect the wherecache?
|
||||||
|
});
|
||||||
|
|
||||||
|
test("push and pop views", async () => {
|
||||||
|
const am1 = clip(annoMatrix, 0.1, 0.9);
|
||||||
|
expect(am1.viewOf).toBe(annoMatrix);
|
||||||
|
expect(am1.nObs).toEqual(annoMatrix.nObs);
|
||||||
|
expect(am1.nVar).toEqual(annoMatrix.nVar);
|
||||||
|
expect(am1.rowIndex).toBe(annoMatrix.rowIndex);
|
||||||
|
|
||||||
|
const am2 = clip(annoMatrix, 0.1, 0.9);
|
||||||
|
expect(am2.viewOf).toBe(annoMatrix);
|
||||||
|
expect(am2).not.toBe(am1);
|
||||||
|
expect(am2.rowIndex).toBe(annoMatrix.rowIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("schema accessors", () => {
|
||||||
|
expect(annoMatrix.getMatrixFields()).toEqual(
|
||||||
|
expect.arrayContaining(["X", "obs", "emb", "var"])
|
||||||
|
);
|
||||||
|
expect(annoMatrix.getMatrixColumns("obs")).toEqual(
|
||||||
|
expect.arrayContaining(["name_0", "n_genes", "louvain"])
|
||||||
|
);
|
||||||
|
expect(annoMatrix.getColumnSchema("emb", "umap")).toEqual({
|
||||||
|
name: "umap",
|
||||||
|
dims: ["umap_0", "umap_1"],
|
||||||
|
type: "float32",
|
||||||
|
});
|
||||||
|
expect(annoMatrix.getColumnDimensions("emb", "umap")).toEqual([
|
||||||
|
"umap_0",
|
||||||
|
"umap_1",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
test the mask & label access to subset via isubset and isubsetMask
|
||||||
|
*/
|
||||||
|
test("isubset", async () => {
|
||||||
|
const rowList = [0, 10];
|
||||||
|
const rowMask = new Uint8Array(annoMatrix.nObs);
|
||||||
|
for (let i = 0; i < rowList.length; i += 1) {
|
||||||
|
rowMask[rowList[i]] = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const am1 = isubset(annoMatrix, rowList);
|
||||||
|
const am2 = isubsetMask(annoMatrix, rowMask);
|
||||||
|
expect(am1).not.toBe(am2);
|
||||||
|
expect(am1.nObs).toEqual(2);
|
||||||
|
expect(am1.nObs).toEqual(am2.nObs);
|
||||||
|
expect(am1.nVar).toEqual(am2.nVar);
|
||||||
|
|
||||||
|
fetch
|
||||||
|
.once(serverMocks.annotationsObs(["n_genes"]))
|
||||||
|
.once(serverMocks.annotationsObs(["n_genes"]));
|
||||||
|
const ng1 = await am1.fetch("obs", "n_genes");
|
||||||
|
const ng2 = await am2.fetch("obs", "n_genes");
|
||||||
|
expect(ng1).toHaveLength(ng2.length);
|
||||||
|
expect(ng1.colIndex.labels()).toEqual(ng2.colIndex.labels());
|
||||||
|
expect(ng1.col("n_genes").asArray()).toEqual(
|
||||||
|
ng2.col("n_genes").asArray()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("add/drop column", () => {
|
||||||
|
async function addDrop(base) {
|
||||||
|
expect(base.getMatrixColumns("obs")).not.toContain("foo");
|
||||||
|
fetch.mockRejectOnce(new Error("unknown column name"));
|
||||||
|
await expect(base.fetch("obs", "foo")).rejects.toThrow(
|
||||||
|
"unknown column name"
|
||||||
|
);
|
||||||
|
|
||||||
|
/* add */
|
||||||
|
const am1 = base.addObsColumn(
|
||||||
|
{ name: "foo", type: "float32", writable: true },
|
||||||
|
Float32Array,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
expect(base.getMatrixColumns("obs")).not.toContain("foo");
|
||||||
|
expect(am1.getMatrixColumns("obs")).toContain("foo");
|
||||||
|
const foo = await am1.fetch("obs", "foo");
|
||||||
|
expect(foo).toBeDefined();
|
||||||
|
expect(foo).toBeInstanceOf(Dataframe);
|
||||||
|
expect(foo).toHaveLength(am1.nObs);
|
||||||
|
expect(foo.col("foo").asArray()).toEqual(
|
||||||
|
new Float32Array(am1.nObs).fill(0)
|
||||||
|
);
|
||||||
|
|
||||||
|
/* drop */
|
||||||
|
const am2 = am1.dropObsColumn("foo");
|
||||||
|
expect(base.getMatrixColumns("obs")).not.toContain("foo");
|
||||||
|
expect(am2.getMatrixColumns("obs")).not.toContain("foo");
|
||||||
|
fetch.mockRejectOnce(new Error("unknown column name"));
|
||||||
|
await expect(am2.fetch("obs", "foo")).rejects.toThrow(
|
||||||
|
"unknown column name"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("add/drop column, without view", async () => {
|
||||||
|
await addDrop(annoMatrix);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("add/drop column, with view", async () => {
|
||||||
|
const am1 = clip(annoMatrix, 0.1, 0.9);
|
||||||
|
await addDrop(am1);
|
||||||
|
|
||||||
|
const am2 = isubset(am1, [0, 1, 2, 20, 30, 400]);
|
||||||
|
await addDrop(am2);
|
||||||
|
|
||||||
|
const am3 = isubset(annoMatrix, [10, 0, 7, 3]);
|
||||||
|
await addDrop(am3);
|
||||||
|
|
||||||
|
const am4 = clip(am3, 0, 1);
|
||||||
|
await addDrop(am4);
|
||||||
|
|
||||||
|
fetch.mockResponse(serverMocks.responder);
|
||||||
|
|
||||||
|
await am1.fetch("obs", am1.getMatrixColumns("obs"));
|
||||||
|
await am2.fetch("obs", am2.getMatrixColumns("obs"));
|
||||||
|
await am3.fetch("obs", am3.getMatrixColumns("obs"));
|
||||||
|
await am4.fetch("obs", am4.getMatrixColumns("obs"));
|
||||||
|
|
||||||
|
fetch.resetMocks();
|
||||||
|
|
||||||
|
await addDrop(am1);
|
||||||
|
await addDrop(am2);
|
||||||
|
await addDrop(am3);
|
||||||
|
await addDrop(am4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setObsColumnValues", () => {
|
||||||
|
async function addSetDrop(base) {
|
||||||
|
/* add column */
|
||||||
|
let am = base.addObsColumn(
|
||||||
|
{
|
||||||
|
name: "test",
|
||||||
|
type: "categorical",
|
||||||
|
categories: ["unassigned", "red", "green"],
|
||||||
|
writable: true,
|
||||||
|
},
|
||||||
|
Array,
|
||||||
|
"unassigned"
|
||||||
|
);
|
||||||
|
|
||||||
|
const testVal = await am.fetch("obs", "test");
|
||||||
|
expect(testVal.col("test").asArray()).toEqual(
|
||||||
|
new Array(am.nObs).fill("unassigned")
|
||||||
|
);
|
||||||
|
|
||||||
|
/* set values in column */
|
||||||
|
const whichRows = [1, 2, 10];
|
||||||
|
const am1 = await am.setObsColumnValues("test", whichRows, "yo");
|
||||||
|
const testVal1 = await am1.fetch("obs", "test");
|
||||||
|
const expt = new Array(am1.nObs).fill("unassigned");
|
||||||
|
for (let i = 0; i < whichRows.length; i += 1) {
|
||||||
|
const offset = am1.rowIndex.getOffset(whichRows[i]);
|
||||||
|
expt[offset] = "yo";
|
||||||
|
}
|
||||||
|
expect(testVal1).not.toBe(testVal);
|
||||||
|
expect(testVal1.col("test").asArray()).toEqual(expt);
|
||||||
|
expect(am1.getColumnSchema("obs", "test").type).toBe("categorical");
|
||||||
|
expect(am1.getColumnSchema("obs", "test").categories).toEqual(
|
||||||
|
expect.arrayContaining(["unassigned", "red", "green", "yo"])
|
||||||
|
);
|
||||||
|
|
||||||
|
/* drop column */
|
||||||
|
fetch.mockRejectOnce(new Error("unknown column name"));
|
||||||
|
am = am1.dropObsColumn("test");
|
||||||
|
await expect(am.fetch("obs", "test")).rejects.toThrow(
|
||||||
|
"unknown column name"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("set, without a view", async () => {
|
||||||
|
await addSetDrop(annoMatrix);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("set, with a view", async () => {
|
||||||
|
const am1 = clip(annoMatrix, 0.1, 0.9);
|
||||||
|
await addSetDrop(am1);
|
||||||
|
|
||||||
|
const am2 = isubset(am1, [0, 1, 2, 10, 20, 30, 400]);
|
||||||
|
await addSetDrop(am2);
|
||||||
|
|
||||||
|
const am3 = isubset(annoMatrix, [10, 1, 0, 30, 2]);
|
||||||
|
await addSetDrop(am3);
|
||||||
|
|
||||||
|
fetch.mockResponse(serverMocks.responder);
|
||||||
|
|
||||||
|
await am1.fetch("obs", am1.getMatrixColumns("obs"));
|
||||||
|
await am2.fetch("obs", am2.getMatrixColumns("obs"));
|
||||||
|
await am3.fetch("obs", am3.getMatrixColumns("obs"));
|
||||||
|
|
||||||
|
await addSetDrop(am1);
|
||||||
|
await addSetDrop(am2);
|
||||||
|
await addSetDrop(am3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,688 @@
|
|||||||
|
// these TWO statements MUST be first in the file, before any other imports
|
||||||
|
import { enableFetchMocks } from "jest-fetch-mock";
|
||||||
|
import * as serverMocks from "./serverMocks";
|
||||||
|
// OK, continue on!
|
||||||
|
|
||||||
|
import obsLouvain from "./louvain.json";
|
||||||
|
import obsNGenes from "./n_genes.json";
|
||||||
|
import embUmap from "./umap.json";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AnnoMatrixLoader,
|
||||||
|
AnnoMatrixObsCrossfilter,
|
||||||
|
isubsetMask,
|
||||||
|
} from "../../../src/annoMatrix";
|
||||||
|
import { rangeFill } from "../../../src/util/range";
|
||||||
|
|
||||||
|
enableFetchMocks();
|
||||||
|
|
||||||
|
describe("AnnoMatrixCrossfilter", () => {
|
||||||
|
let annoMatrix;
|
||||||
|
let crossfilter;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
fetch.resetMocks(); // reset all fetch mocking state
|
||||||
|
annoMatrix = new AnnoMatrixLoader(
|
||||||
|
serverMocks.baseDataURL,
|
||||||
|
serverMocks.schema.schema
|
||||||
|
);
|
||||||
|
crossfilter = new AnnoMatrixObsCrossfilter(annoMatrix);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("initial state of crossfilter", () => {
|
||||||
|
const { nObs } = annoMatrix;
|
||||||
|
|
||||||
|
expect(crossfilter).toBeDefined();
|
||||||
|
expect(crossfilter.size()).toEqual(nObs);
|
||||||
|
expect(crossfilter.annoMatrix).toBe(annoMatrix);
|
||||||
|
|
||||||
|
/* by default, everything should be selected, even if no data in cache */
|
||||||
|
expect(crossfilter.countSelected()).toEqual(nObs);
|
||||||
|
expect(crossfilter.allSelectedLabels()).toEqual(
|
||||||
|
rangeFill(new Int32Array(nObs))
|
||||||
|
);
|
||||||
|
expect(crossfilter.allSelectedMask()).toEqual(new Uint8Array(nObs).fill(1));
|
||||||
|
expect(crossfilter.fillByIsSelected(new Uint8Array(nObs), 2, 1)).toEqual(
|
||||||
|
new Uint8Array(nObs).fill(2)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("select", () => {
|
||||||
|
/*
|
||||||
|
test the selection state via crossfilter proxy
|
||||||
|
*/
|
||||||
|
|
||||||
|
test("select loads index", async () => {
|
||||||
|
/*
|
||||||
|
Select should transparently load/create dimension index.
|
||||||
|
|
||||||
|
Internal dimension names are field/col:col:col..., eg,
|
||||||
|
|
||||||
|
obs:louvain
|
||||||
|
emb:umap_0:umap_1
|
||||||
|
|
||||||
|
*/
|
||||||
|
expect(crossfilter.obsCrossfilter.dimensionNames()).toEqual([]);
|
||||||
|
expect(
|
||||||
|
crossfilter.obsCrossfilter.hasDimension("obs/louvain")
|
||||||
|
).toBeFalsy();
|
||||||
|
|
||||||
|
fetch.once(serverMocks.dataframeResponse(["louvain"], [obsLouvain]));
|
||||||
|
let newCrossfilter = await crossfilter.select("obs", "louvain", {
|
||||||
|
mode: "none",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(newCrossfilter.countSelected()).toEqual(0);
|
||||||
|
expect(
|
||||||
|
newCrossfilter.obsCrossfilter.hasDimension("obs/louvain")
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(fetch.mock.calls).toHaveLength(1);
|
||||||
|
|
||||||
|
newCrossfilter = await crossfilter.select("obs", "louvain", {
|
||||||
|
mode: "all",
|
||||||
|
});
|
||||||
|
expect(newCrossfilter.countSelected()).toEqual(annoMatrix.nObs);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("simple column select", async () => {
|
||||||
|
let xfltr;
|
||||||
|
|
||||||
|
fetch.once(serverMocks.dataframeResponse(["louvain"], [obsLouvain]));
|
||||||
|
xfltr = await crossfilter.select("obs", "louvain", {
|
||||||
|
mode: "exact",
|
||||||
|
values: ["NK cells", "B cells"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(xfltr).toBeDefined();
|
||||||
|
expect(xfltr.countSelected()).toEqual(496);
|
||||||
|
expect(xfltr.allSelectedMask()).toEqual(
|
||||||
|
Uint8Array.from(
|
||||||
|
obsLouvain.map((val) =>
|
||||||
|
val === "NK cells" || val === "B cells" ? 1 : 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(xfltr.allSelectedLabels()).toEqual(
|
||||||
|
Int32Array.from(
|
||||||
|
obsLouvain.reduce((acc, val, idx) => {
|
||||||
|
if (val === "NK cells" || val === "B cells") acc.push(idx);
|
||||||
|
return acc;
|
||||||
|
}, [])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
xfltr.fillByIsSelected(new Uint8Array(annoMatrix.nObs), 3, 1)
|
||||||
|
).toEqual(
|
||||||
|
Uint8Array.from(
|
||||||
|
obsLouvain.map((val) =>
|
||||||
|
val === "NK cells" || val === "B cells" ? 3 : 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const df = await annoMatrix.fetch("obs", "louvain");
|
||||||
|
const values = df.col("louvain").asArray();
|
||||||
|
const selected = xfltr.allSelectedMask();
|
||||||
|
values.every(
|
||||||
|
(val, idx) => !["NK cells", "B cells"].includes(val) !== !selected[idx]
|
||||||
|
);
|
||||||
|
|
||||||
|
fetch.once(
|
||||||
|
serverMocks.dataframeResponse(["n_genes"], [new Int32Array(obsNGenes)])
|
||||||
|
);
|
||||||
|
xfltr = await xfltr.select("obs", "n_genes", {
|
||||||
|
mode: "range",
|
||||||
|
lo: 0,
|
||||||
|
hi: 500,
|
||||||
|
inclusive: false,
|
||||||
|
});
|
||||||
|
expect(xfltr.countSelected()).toEqual(33);
|
||||||
|
expect(xfltr.allSelectedLabels()).toEqual(
|
||||||
|
Int32Array.from(
|
||||||
|
obsNGenes.reduce((acc, val, idx) => {
|
||||||
|
const louvain = obsLouvain[idx];
|
||||||
|
if (
|
||||||
|
val >= 0 &&
|
||||||
|
val < 500 &&
|
||||||
|
(louvain === "NK cells" || louvain === "B cells")
|
||||||
|
)
|
||||||
|
acc.push(idx);
|
||||||
|
return acc;
|
||||||
|
}, [])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
xfltr = await xfltr.selectAll();
|
||||||
|
expect(xfltr.countSelected()).toEqual(annoMatrix.nObs);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("join column select", async () => {
|
||||||
|
const varIndex = annoMatrix.schema.annotations.var.index;
|
||||||
|
|
||||||
|
const { nObs } = annoMatrix.schema.dataframe;
|
||||||
|
fetch.once(
|
||||||
|
serverMocks.dataframeResponse(
|
||||||
|
["TEST"],
|
||||||
|
[rangeFill(new Float32Array(nObs), 0, 0.1)]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const xfltr = await crossfilter.select(
|
||||||
|
"X",
|
||||||
|
{
|
||||||
|
field: "var",
|
||||||
|
column: varIndex,
|
||||||
|
value: "TYMP",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mode: "range",
|
||||||
|
lo: 0,
|
||||||
|
hi: 50,
|
||||||
|
inclusive: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(xfltr).toBeDefined();
|
||||||
|
expect(xfltr.countSelected()).toEqual(501);
|
||||||
|
|
||||||
|
const df = await annoMatrix.fetch("X", {
|
||||||
|
field: "var",
|
||||||
|
column: varIndex,
|
||||||
|
value: "TYMP",
|
||||||
|
});
|
||||||
|
const values = df.icol(0).asArray();
|
||||||
|
const selected = xfltr.allSelectedMask();
|
||||||
|
values.every((val, idx) => !(val >= 0 && val <= 50) !== !selected[idx]);
|
||||||
|
expect(selected.reduce((acc, val) => (val ? acc + 1 : acc), 0)).toEqual(
|
||||||
|
xfltr.countSelected()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("spatial column select", async () => {
|
||||||
|
fetch.once(
|
||||||
|
serverMocks.dataframeResponse(
|
||||||
|
["umap_0", "umap_1"],
|
||||||
|
[Float32Array.from(embUmap[0]), Float32Array.from(embUmap[1])]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const xfltr = await crossfilter.select("emb", "umap", {
|
||||||
|
mode: "within-rect",
|
||||||
|
minX: 0,
|
||||||
|
minY: 0,
|
||||||
|
maxX: 0.5,
|
||||||
|
maxY: 0.5,
|
||||||
|
});
|
||||||
|
expect(xfltr.countSelected()).toEqual(16);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("select on subset", async () => {
|
||||||
|
const mask = new Uint8Array(annoMatrix.nObs).fill(0);
|
||||||
|
for (let i = 0; i < mask.length; i += 2) {
|
||||||
|
mask[i] = true;
|
||||||
|
}
|
||||||
|
const annoMatrixSubset = isubsetMask(annoMatrix, mask);
|
||||||
|
expect(annoMatrixSubset.nObs).toEqual(Math.floor(annoMatrix.nObs / 2));
|
||||||
|
|
||||||
|
let xfltr = new AnnoMatrixObsCrossfilter(annoMatrixSubset);
|
||||||
|
expect(xfltr.countSelected()).toEqual(annoMatrixSubset.nObs);
|
||||||
|
|
||||||
|
fetch.once(serverMocks.dataframeResponse(["louvain"], [obsLouvain]));
|
||||||
|
xfltr = await xfltr.select("obs", "louvain", {
|
||||||
|
mode: "exact",
|
||||||
|
values: ["NK cells", "B cells"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(xfltr).toBeDefined();
|
||||||
|
expect(xfltr.countSelected()).toEqual(240);
|
||||||
|
|
||||||
|
const df = await annoMatrixSubset.fetch("obs", "louvain");
|
||||||
|
const values = df.col("louvain").asArray();
|
||||||
|
const selected = xfltr.allSelectedMask();
|
||||||
|
values.every(
|
||||||
|
(val, idx) => !["NK cells", "B cells"].includes(val) !== !selected[idx]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("select catches errors", async () => {
|
||||||
|
await expect(crossfilter.select("NADA", "foo")).rejects.toThrow(
|
||||||
|
"Unknown field name"
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(crossfilter.select("var", "foo")).rejects.toThrow(
|
||||||
|
"unable to obsSelect upon the var dimension"
|
||||||
|
);
|
||||||
|
|
||||||
|
fetch.mockRejectOnce(new Error("unknown column name"));
|
||||||
|
await expect(crossfilter.select("obs", "foo")).rejects.toThrow(
|
||||||
|
"unknown column name"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mutate matrix", () => {
|
||||||
|
/*
|
||||||
|
test the matrix mutators via crossfilter proxy
|
||||||
|
*/
|
||||||
|
async function helperAddTestCol(cf, colName, colSchema = null) {
|
||||||
|
expect(
|
||||||
|
cf.annoMatrix.getMatrixColumns("obs").includes(colName)
|
||||||
|
).toBeFalsy();
|
||||||
|
|
||||||
|
if (colSchema === null) {
|
||||||
|
colSchema = {
|
||||||
|
name: colName,
|
||||||
|
type: "categorical",
|
||||||
|
categories: ["toasty"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
colSchema.name = colName;
|
||||||
|
const initValue = colSchema.categories[0];
|
||||||
|
const xfltr = cf.addObsColumn(colSchema, Array, initValue);
|
||||||
|
expect(
|
||||||
|
xfltr.annoMatrix.schema.annotations.obs.columns.filter(
|
||||||
|
(v) => v.name === colName
|
||||||
|
)
|
||||||
|
).toHaveLength(1);
|
||||||
|
const df = await xfltr.annoMatrix.fetch("obs", colName);
|
||||||
|
expect(df.hasCol(colName)).toBeTruthy();
|
||||||
|
return xfltr;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("addObsColumn", async () => {
|
||||||
|
expect(crossfilter.countSelected()).toBe(annoMatrix.nObs);
|
||||||
|
expect(
|
||||||
|
crossfilter.annoMatrix.getMatrixColumns("obs").includes("foo")
|
||||||
|
).toBeFalsy();
|
||||||
|
const xfltr = crossfilter.addObsColumn(
|
||||||
|
{ name: "foo", type: "categorical", categories: ["A"] },
|
||||||
|
Array,
|
||||||
|
"A"
|
||||||
|
);
|
||||||
|
|
||||||
|
// check schema updates correctly.
|
||||||
|
expect(xfltr.countSelected()).toBe(annoMatrix.nObs);
|
||||||
|
expect(
|
||||||
|
xfltr.annoMatrix.getMatrixColumns("obs").includes("foo")
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(xfltr.annoMatrix.schema.annotations.obsByName.foo).toMatchObject({
|
||||||
|
name: "foo",
|
||||||
|
type: "categorical",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
xfltr.annoMatrix.schema.annotations.obs.columns.filter(
|
||||||
|
(v) => v.name === "foo"
|
||||||
|
)
|
||||||
|
).toHaveLength(1);
|
||||||
|
|
||||||
|
// check data update.
|
||||||
|
const df = await xfltr.annoMatrix.fetch("obs", "foo");
|
||||||
|
expect(
|
||||||
|
df
|
||||||
|
.col("foo")
|
||||||
|
.asArray()
|
||||||
|
.every((v) => v === "A")
|
||||||
|
).toBeTruthy();
|
||||||
|
|
||||||
|
// check that we catch dups
|
||||||
|
expect(() =>
|
||||||
|
xfltr.addObsColumn(
|
||||||
|
{ name: "foo", type: "categorical" },
|
||||||
|
Array,
|
||||||
|
"toasty"
|
||||||
|
)
|
||||||
|
).toThrow("column already exists");
|
||||||
|
expect(() =>
|
||||||
|
xfltr.addObsColumn(
|
||||||
|
{ name: "louvain", type: "categorical" },
|
||||||
|
Array,
|
||||||
|
"toasty"
|
||||||
|
)
|
||||||
|
).toThrow("column already exists");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dropObsColumn", async () => {
|
||||||
|
let xfltr;
|
||||||
|
|
||||||
|
/* check that we catch attempt to drop readonly dimension */
|
||||||
|
expect(() => crossfilter.dropObsColumn("louvain")).toThrow(
|
||||||
|
"Unknown or readonly obs column"
|
||||||
|
);
|
||||||
|
/* non-existent column */
|
||||||
|
expect(() => crossfilter.dropObsColumn("does-not-exist")).toThrow(
|
||||||
|
"Unknown or readonly obs column"
|
||||||
|
);
|
||||||
|
|
||||||
|
// add a column, then drop it.
|
||||||
|
xfltr = await helperAddTestCol(crossfilter, "foo");
|
||||||
|
xfltr = xfltr.dropObsColumn("foo");
|
||||||
|
expect(
|
||||||
|
xfltr.annoMatrix.schema.annotations.obs.columns.filter(
|
||||||
|
(v) => v.name === "foo"
|
||||||
|
)
|
||||||
|
).toHaveLength(0);
|
||||||
|
expect(xfltr.annoMatrix.schema.annotations.obsByName.foo).toBeUndefined();
|
||||||
|
fetch.mockRejectOnce(new Error("unknown column name"));
|
||||||
|
await expect(xfltr.annoMatrix.fetch("obs", "foo")).rejects.toThrow(
|
||||||
|
"unknown column name"
|
||||||
|
);
|
||||||
|
|
||||||
|
// now same, but ensure we have built an index before doing the drop
|
||||||
|
xfltr = await helperAddTestCol(crossfilter, "bar");
|
||||||
|
xfltr = await xfltr.select("obs", "bar", {
|
||||||
|
mode: "exact",
|
||||||
|
values: "whatever",
|
||||||
|
});
|
||||||
|
xfltr = xfltr.dropObsColumn("bar");
|
||||||
|
|
||||||
|
fetch.mockRejectOnce(new Error("unknown column name"));
|
||||||
|
await expect(xfltr.select("obs", "bar", { mode: "all" })).rejects.toThrow(
|
||||||
|
"unknown column name"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renameObsColumn", async () => {
|
||||||
|
let xfltr;
|
||||||
|
|
||||||
|
/* catch attempts to rename non-existent or readonly columns */
|
||||||
|
expect(() =>
|
||||||
|
crossfilter.renameObsColumn("does-not-exist", "foo")
|
||||||
|
).toThrow("Unknown or readonly obs column");
|
||||||
|
expect(() => crossfilter.renameObsColumn("louvain", "foo")).toThrow(
|
||||||
|
"Unknown or readonly obs column"
|
||||||
|
);
|
||||||
|
|
||||||
|
// add a column, then rename it.
|
||||||
|
xfltr = await helperAddTestCol(crossfilter, "foo");
|
||||||
|
xfltr = xfltr.renameObsColumn("foo", "bar");
|
||||||
|
expect(xfltr.annoMatrix.getColumnSchema("obs", "foo")).toBeUndefined();
|
||||||
|
expect(xfltr.annoMatrix.getColumnSchema("obs", "bar")).toMatchObject({
|
||||||
|
name: "bar",
|
||||||
|
type: "categorical",
|
||||||
|
});
|
||||||
|
|
||||||
|
fetch.mockRejectOnce(new Error("unknown column name"));
|
||||||
|
await expect(xfltr.annoMatrix.fetch("obs", "foo")).rejects.toThrow(
|
||||||
|
"unknown column name"
|
||||||
|
);
|
||||||
|
const df = await xfltr.annoMatrix.fetch("obs", "bar");
|
||||||
|
expect(df.hasCol("bar")).toBeTruthy();
|
||||||
|
|
||||||
|
// now same, but ensure we have built an index before doing the rename
|
||||||
|
xfltr = await helperAddTestCol(crossfilter, "bar");
|
||||||
|
xfltr = await xfltr.select("obs", "bar", {
|
||||||
|
mode: "exact",
|
||||||
|
values: "whatever",
|
||||||
|
});
|
||||||
|
xfltr = xfltr.renameObsColumn("bar", "xyz");
|
||||||
|
|
||||||
|
fetch.mockRejectOnce(new Error("unknown column name"));
|
||||||
|
await expect(xfltr.select("obs", "bar", { mode: "all" })).rejects.toThrow(
|
||||||
|
"unknown column name"
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
xfltr.select("obs", "xyz", { mode: "none" })
|
||||||
|
).resolves.toBeInstanceOf(AnnoMatrixObsCrossfilter);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("addObsAnnoCategory", async () => {
|
||||||
|
let xfltr;
|
||||||
|
|
||||||
|
// catch unknown or readonly columns
|
||||||
|
expect(() => crossfilter.addObsAnnoCategory("louvain", "mumble")).toThrow(
|
||||||
|
"Unknown or readonly obs column"
|
||||||
|
);
|
||||||
|
expect(() =>
|
||||||
|
crossfilter.addObsAnnoCategory("undefined-name", "mumble")
|
||||||
|
).toThrow("Unknown or readonly obs column");
|
||||||
|
|
||||||
|
// add a column and then add category to it
|
||||||
|
xfltr = await helperAddTestCol(crossfilter, "foo", {
|
||||||
|
name: "foo",
|
||||||
|
type: "categorical",
|
||||||
|
categories: ["unassigned"],
|
||||||
|
});
|
||||||
|
xfltr = xfltr.addObsAnnoCategory("foo", "a-new-label");
|
||||||
|
expect(xfltr.annoMatrix.getColumnSchema("obs", "foo")).toMatchObject({
|
||||||
|
name: "foo",
|
||||||
|
type: "categorical",
|
||||||
|
categories: expect.arrayContaining(["a-new-label", "unassigned"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
// do it again, dup; should throw
|
||||||
|
expect(() => xfltr.addObsAnnoCategory("foo", "a-new-label")).toThrow(
|
||||||
|
"category already exists"
|
||||||
|
);
|
||||||
|
|
||||||
|
// now same, but ensure we have built an index before doing the operation
|
||||||
|
xfltr = await helperAddTestCol(crossfilter, "bar", {
|
||||||
|
name: "bar",
|
||||||
|
type: "categorical",
|
||||||
|
categories: ["unassigned"],
|
||||||
|
});
|
||||||
|
xfltr = await xfltr.select("obs", "bar", {
|
||||||
|
mode: "exact",
|
||||||
|
values: "something",
|
||||||
|
});
|
||||||
|
xfltr = xfltr.addObsAnnoCategory("bar", "a-new-label");
|
||||||
|
expect(xfltr.annoMatrix.getColumnSchema("obs", "bar")).toMatchObject({
|
||||||
|
name: "bar",
|
||||||
|
type: "categorical",
|
||||||
|
categories: expect.arrayContaining(["a-new-label", "unassigned"]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("removeObsAnnoCategory", async () => {
|
||||||
|
let xfltr;
|
||||||
|
|
||||||
|
// catch unknown or readonly categories
|
||||||
|
await expect(() =>
|
||||||
|
crossfilter.removeObsAnnoCategory("louvain", "mumble", "unassigned")
|
||||||
|
).rejects.toThrow("Unknown or readonly obs column");
|
||||||
|
await expect(() =>
|
||||||
|
crossfilter.removeObsAnnoCategory("undefined-name", "mumble")
|
||||||
|
).rejects.toThrow("Unknown or readonly obs column");
|
||||||
|
|
||||||
|
xfltr = await helperAddTestCol(crossfilter, "foo", {
|
||||||
|
name: "foo",
|
||||||
|
type: "categorical",
|
||||||
|
categories: ["unassigned", "red", "green", "blue"],
|
||||||
|
});
|
||||||
|
xfltr = await xfltr.select("obs", "foo", { mode: "all" });
|
||||||
|
expect(
|
||||||
|
(await xfltr.annoMatrix.fetch("obs", "foo"))
|
||||||
|
.col("foo")
|
||||||
|
.asArray()
|
||||||
|
.every((v) => v === "unassigned")
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(xfltr.annoMatrix.getColumnSchema("obs", "foo")).toMatchObject({
|
||||||
|
name: "foo",
|
||||||
|
type: "categorical",
|
||||||
|
categories: expect.arrayContaining([
|
||||||
|
"unassigned",
|
||||||
|
"red",
|
||||||
|
"green",
|
||||||
|
"blue",
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
// remove an unused category
|
||||||
|
const xfltr1 = await xfltr.removeObsAnnoCategory("foo", "red", "mumble");
|
||||||
|
expect(
|
||||||
|
(await xfltr1.annoMatrix.fetch("obs", "foo"))
|
||||||
|
.col("foo")
|
||||||
|
.asArray()
|
||||||
|
.every((v) => v === "unassigned")
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(xfltr1.annoMatrix.getColumnSchema("obs", "foo")).toMatchObject({
|
||||||
|
name: "foo",
|
||||||
|
type: "categorical",
|
||||||
|
categories: expect.arrayContaining([
|
||||||
|
"unassigned",
|
||||||
|
"green",
|
||||||
|
"blue",
|
||||||
|
"mumble",
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
// remove a used category
|
||||||
|
const xfltr2 = await xfltr.removeObsAnnoCategory(
|
||||||
|
"foo",
|
||||||
|
"unassigned",
|
||||||
|
"red"
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await xfltr2.annoMatrix.fetch("obs", "foo"))
|
||||||
|
.col("foo")
|
||||||
|
.asArray()
|
||||||
|
.every((v) => v === "red")
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(xfltr2.annoMatrix.getColumnSchema("obs", "foo")).toMatchObject({
|
||||||
|
name: "foo",
|
||||||
|
type: "categorical",
|
||||||
|
categories: expect.arrayContaining(["green", "blue", "red"]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("setObsColumnValues", async () => {
|
||||||
|
// catch unknown or readonly categories
|
||||||
|
await expect(() =>
|
||||||
|
crossfilter.setObsColumnValues("louvain", [0, 1], "unassigned")
|
||||||
|
).rejects.toThrow("Unknown or readonly obs column");
|
||||||
|
await expect(() =>
|
||||||
|
crossfilter.setObsColumnValues("undefined-name", [0], "mumble")
|
||||||
|
).rejects.toThrow("Unknown or readonly obs column");
|
||||||
|
|
||||||
|
let xfltr = await helperAddTestCol(crossfilter, "foo", {
|
||||||
|
name: "foo",
|
||||||
|
type: "categorical",
|
||||||
|
categories: ["unassigned", "red", "green", "blue"],
|
||||||
|
});
|
||||||
|
xfltr = await xfltr.select("obs", "foo", { mode: "all" });
|
||||||
|
|
||||||
|
// catch unknown row label
|
||||||
|
await expect(() =>
|
||||||
|
xfltr.setObsColumnValues("foo", [-1], "red")
|
||||||
|
).rejects.toThrow("Unknown row label");
|
||||||
|
|
||||||
|
// set a few rows
|
||||||
|
expect(
|
||||||
|
(await xfltr.annoMatrix.fetch("obs", "foo"))
|
||||||
|
.col("foo")
|
||||||
|
.asArray()
|
||||||
|
.every((v) => v === "unassigned")
|
||||||
|
).toBeTruthy();
|
||||||
|
const xfltr1 = await xfltr.setObsColumnValues("foo", [0, 10], "purple");
|
||||||
|
expect(
|
||||||
|
(await xfltr1.annoMatrix.fetch("obs", "foo"))
|
||||||
|
.col("foo")
|
||||||
|
.asArray()
|
||||||
|
.every(
|
||||||
|
(v, i) =>
|
||||||
|
v === "unassigned" || (v === "purple" && (i === 0 || i === 10))
|
||||||
|
)
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(xfltr1.annoMatrix.getColumnSchema("obs", "foo")).toMatchObject({
|
||||||
|
name: "foo",
|
||||||
|
type: "categorical",
|
||||||
|
categories: expect.arrayContaining([
|
||||||
|
"unassigned",
|
||||||
|
"red",
|
||||||
|
"green",
|
||||||
|
"blue",
|
||||||
|
"purple",
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(xfltr1.countSelected()).toEqual(xfltr1.annoMatrix.nObs);
|
||||||
|
const xfltr2 = await xfltr1.select("obs", "foo", {
|
||||||
|
mode: "exact",
|
||||||
|
values: ["purple"],
|
||||||
|
});
|
||||||
|
expect(xfltr2.countSelected()).toEqual(2);
|
||||||
|
expect(xfltr2.allSelectedLabels()).toEqual(Int32Array.from([0, 10]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resetObsColumnValues", async () => {
|
||||||
|
// catch unknown or readonly categories
|
||||||
|
await expect(() =>
|
||||||
|
crossfilter.resetObsColumnValues("louvain", "red", "blue")
|
||||||
|
).rejects.toThrow("Unknown or readonly obs column");
|
||||||
|
await expect(() =>
|
||||||
|
crossfilter.resetObsColumnValues("undefined-name", "red", "blue")
|
||||||
|
).rejects.toThrow("Unknown or readonly obs column");
|
||||||
|
|
||||||
|
let xfltr = await helperAddTestCol(crossfilter, "foo", {
|
||||||
|
name: "foo",
|
||||||
|
type: "categorical",
|
||||||
|
categories: ["unassigned", "red", "green", "blue"],
|
||||||
|
});
|
||||||
|
xfltr = await xfltr.select("obs", "foo", {
|
||||||
|
mode: "exact",
|
||||||
|
values: "red",
|
||||||
|
});
|
||||||
|
|
||||||
|
// catch unknown category name label
|
||||||
|
await expect(() =>
|
||||||
|
xfltr.resetObsColumnValues("foo", "unknown-label", "red")
|
||||||
|
).rejects.toThrow("unknown category");
|
||||||
|
|
||||||
|
let xfltr1 = await xfltr.setObsColumnValues("foo", [0, 10], "purple");
|
||||||
|
xfltr1 = await xfltr1.select("obs", "foo", {
|
||||||
|
mode: "exact",
|
||||||
|
values: "purple",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
(await xfltr1.annoMatrix.fetch("obs", "foo"))
|
||||||
|
.col("foo")
|
||||||
|
.asArray()
|
||||||
|
.filter((v) => v === "purple")
|
||||||
|
).toHaveLength(2);
|
||||||
|
|
||||||
|
xfltr1 = await xfltr1.resetObsColumnValues("foo", "purple", "magenta");
|
||||||
|
expect(
|
||||||
|
(await xfltr1.annoMatrix.fetch("obs", "foo"))
|
||||||
|
.col("foo")
|
||||||
|
.asArray()
|
||||||
|
.filter((v) => v === "magenta")
|
||||||
|
).toHaveLength(2);
|
||||||
|
expect(
|
||||||
|
(await xfltr1.annoMatrix.fetch("obs", "foo"))
|
||||||
|
.col("foo")
|
||||||
|
.asArray()
|
||||||
|
.filter((v) => v === "purple")
|
||||||
|
).toHaveLength(0);
|
||||||
|
expect(xfltr1.annoMatrix.getColumnSchema("obs", "foo")).toMatchObject({
|
||||||
|
name: "foo",
|
||||||
|
type: "categorical",
|
||||||
|
categories: expect.arrayContaining([
|
||||||
|
"unassigned",
|
||||||
|
"red",
|
||||||
|
"green",
|
||||||
|
"blue",
|
||||||
|
"purple",
|
||||||
|
"magenta",
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
test("transition from empty annoMatrix", async () => {
|
||||||
|
// select before fetch needs to work
|
||||||
|
fetch.once(serverMocks.dataframeResponse(["louvain"], [obsLouvain]));
|
||||||
|
const xfltr = await crossfilter.select("obs", "louvain", {
|
||||||
|
mode: "exact",
|
||||||
|
values: "B cells",
|
||||||
|
});
|
||||||
|
expect(fetch.mock.calls).toHaveLength(1);
|
||||||
|
expect(xfltr.obsCrossfilter.hasDimension("obs/louvain")).toBeTruthy();
|
||||||
|
expect(xfltr.obsCrossfilter.all()).toBe(xfltr.annoMatrix._cache.obs);
|
||||||
|
expect(xfltr.countSelected()).toEqual(
|
||||||
|
obsLouvain.reduce(
|
||||||
|
(count, v) => (v === "B cells" ? count + 1 : count),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
|||||||
|
export const baseDataURL = "https://a.fake.url/api/v0.2";
|
||||||
|
|
||||||
|
window.CELLXGENE = {
|
||||||
|
API: {
|
||||||
|
prefix: baseDataURL,
|
||||||
|
version: "v0.2/",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export { schema } from "./schema";
|
||||||
|
export * from "./routes";
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { schema } from "./schema";
|
||||||
|
import { Dataframe, KeyIndex } from "../../../../src/util/dataframe";
|
||||||
|
import { encodeMatrixFBS } from "../../../../src/util/stateManager/matrix";
|
||||||
|
|
||||||
|
const indexedSchema = {
|
||||||
|
obsByName: Object.fromEntries(
|
||||||
|
schema.schema.annotations.obs.columns.map((v) => [v.name, v]) ?? []
|
||||||
|
),
|
||||||
|
varByName: Object.fromEntries(
|
||||||
|
schema.schema.annotations.var.columns.map((v) => [v.name, v]) ?? []
|
||||||
|
),
|
||||||
|
embByName: Object.fromEntries(
|
||||||
|
schema.schema.layout.obs.map((v) => [v.name, v]) ?? []
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeMockColumn(s, length) {
|
||||||
|
const { type } = s;
|
||||||
|
switch (type) {
|
||||||
|
case "int32":
|
||||||
|
return new Int32Array(length).fill(Math.floor(99 * Math.random()));
|
||||||
|
|
||||||
|
case "string":
|
||||||
|
return new Array(length).fill("test");
|
||||||
|
|
||||||
|
case "float32":
|
||||||
|
return new Float32Array(length).fill(99 * Math.random());
|
||||||
|
|
||||||
|
case "boolean":
|
||||||
|
return new Array(length).fill(false);
|
||||||
|
|
||||||
|
case "categorical":
|
||||||
|
return new Array(length).fill(s.categories[0]);
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error("unkonwn type");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEncodedDataframe(colNames, length, colSchemas) {
|
||||||
|
const colIndex = new KeyIndex(colNames);
|
||||||
|
const columns = colSchemas.map((s) => makeMockColumn(s, length));
|
||||||
|
const df = new Dataframe([length, colNames.length], columns, null, colIndex);
|
||||||
|
const body = encodeMatrixFBS(df);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dataframeResponse(colNames, columns) {
|
||||||
|
const colIndex = new KeyIndex(colNames);
|
||||||
|
const df = new Dataframe(
|
||||||
|
[columns[0].length, colNames.length],
|
||||||
|
columns,
|
||||||
|
null,
|
||||||
|
colIndex
|
||||||
|
);
|
||||||
|
const body = encodeMatrixFBS(df);
|
||||||
|
const headers = new Headers({
|
||||||
|
"Content-Type": "application/octet-stream",
|
||||||
|
});
|
||||||
|
return () => Promise.resolve({ body, init: { status: 200, headers } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function annotationObsResponse(request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const params = Array.from(url.searchParams.entries());
|
||||||
|
const names = params
|
||||||
|
.filter(([k]) => k === "annotation-name")
|
||||||
|
.map(([, v]) => v);
|
||||||
|
if (!names.every((n) => indexedSchema.obsByName[n])) {
|
||||||
|
return Promise.reject(new Error("bad obs annotation name in URL"));
|
||||||
|
}
|
||||||
|
const colSchemas = names.map((n) => indexedSchema.obsByName[n]);
|
||||||
|
const body = getEncodedDataframe(
|
||||||
|
names,
|
||||||
|
schema.schema.dataframe.nObs,
|
||||||
|
colSchemas
|
||||||
|
);
|
||||||
|
|
||||||
|
const headers = new Headers({
|
||||||
|
"Content-Type": "application/octet-stream",
|
||||||
|
});
|
||||||
|
return Promise.resolve({
|
||||||
|
body,
|
||||||
|
init: { status: 200, headers },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function annotationVarResponse(request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const params = Array.from(url.searchParams.entries());
|
||||||
|
const names = params
|
||||||
|
.filter(([k]) => k === "annotation-name")
|
||||||
|
.map(([, v]) => v);
|
||||||
|
if (!names.every((n) => indexedSchema.varByName[n])) {
|
||||||
|
return Promise.reject(new Error("bad var annotation name in URL"));
|
||||||
|
}
|
||||||
|
const colSchemas = names.map((n) => indexedSchema.varByName[n]);
|
||||||
|
const body = getEncodedDataframe(
|
||||||
|
names,
|
||||||
|
schema.schema.dataframe.nVar,
|
||||||
|
colSchemas
|
||||||
|
);
|
||||||
|
|
||||||
|
const headers = new Headers({
|
||||||
|
"Content-Type": "application/octet-stream",
|
||||||
|
});
|
||||||
|
return Promise.resolve({
|
||||||
|
body,
|
||||||
|
init: { status: 200, headers },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function layoutObsResponse(request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const params = Array.from(url.searchParams.entries());
|
||||||
|
const names = params.filter(([k]) => k === "layout-name").map(([, v]) => v);
|
||||||
|
if (!names.every((n) => indexedSchema.embByName[n])) {
|
||||||
|
return Promise.reject(new Error("bad layout name in URL"));
|
||||||
|
}
|
||||||
|
const dims = names.map((n) => indexedSchema.embByName[n].dims).flat();
|
||||||
|
const colSchemas = names
|
||||||
|
.map((n) => [indexedSchema.embByName[n], indexedSchema.embByName[n]])
|
||||||
|
.flat();
|
||||||
|
const body = getEncodedDataframe(
|
||||||
|
dims,
|
||||||
|
schema.schema.dataframe.nObs,
|
||||||
|
colSchemas
|
||||||
|
);
|
||||||
|
|
||||||
|
const headers = new Headers({
|
||||||
|
"Content-Type": "application/octet-stream",
|
||||||
|
});
|
||||||
|
return Promise.resolve({
|
||||||
|
body,
|
||||||
|
init: { status: 200, headers },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dataVarResponse(request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const params = Array.from(url.searchParams.entries());
|
||||||
|
|
||||||
|
const colNames = params.map((v) => `${v[0]}/${v[1]}`);
|
||||||
|
const colSchemas = colNames.map(() => schema.schema.dataframe);
|
||||||
|
const body = getEncodedDataframe(
|
||||||
|
colNames,
|
||||||
|
schema.schema.dataframe.nObs,
|
||||||
|
colSchemas
|
||||||
|
);
|
||||||
|
|
||||||
|
const headers = new Headers({
|
||||||
|
"Content-Type": "application/octet-stream",
|
||||||
|
});
|
||||||
|
return Promise.resolve({
|
||||||
|
body,
|
||||||
|
init: { status: 200, headers },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function responder(request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const { pathname } = url;
|
||||||
|
if (pathname.endsWith("/annotations/obs")) {
|
||||||
|
return annotationObsResponse(request);
|
||||||
|
}
|
||||||
|
if (pathname.endsWith("/annotations/var")) {
|
||||||
|
return annotationVarResponse(request);
|
||||||
|
}
|
||||||
|
if (pathname.endsWith("/layout/obs")) {
|
||||||
|
return layoutObsResponse(request);
|
||||||
|
}
|
||||||
|
if (pathname.endsWith("/data/var")) {
|
||||||
|
return dataVarResponse(request);
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error("bad URL"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withExpected(expectedURL, expectedParams) {
|
||||||
|
/*
|
||||||
|
Do some additional error checking
|
||||||
|
*/
|
||||||
|
return (request) => {
|
||||||
|
// if URL is bogus, reject the promise
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (!url.pathname.endsWith(expectedURL)) {
|
||||||
|
return Promise.reject(new Error("Unexpected URL!"));
|
||||||
|
}
|
||||||
|
const params = Array.from(url.searchParams.entries()).sort(
|
||||||
|
(a, b) => a[0] < b[0]
|
||||||
|
);
|
||||||
|
expectedParams = expectedParams.slice().sort((a, b) => a[0] < b[0]);
|
||||||
|
|
||||||
|
if (
|
||||||
|
params.length !== expectedParams.length ||
|
||||||
|
!params.every(
|
||||||
|
(p, i) => p[0] === expectedParams[i][0] && p[1] === expectedParams[i][1]
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return Promise.reject(new Error("unexpected name requested in URL"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return responder(request);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function annotationsObs(names) {
|
||||||
|
return withExpected(
|
||||||
|
"/annotations/obs",
|
||||||
|
names.map((name) => ["annotation-name", name])
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
export const schema = {
|
||||||
|
schema: {
|
||||||
|
annotations: {
|
||||||
|
obs: {
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: "name_0",
|
||||||
|
type: "string",
|
||||||
|
writable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "n_genes",
|
||||||
|
type: "int32",
|
||||||
|
writable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "percent_mito",
|
||||||
|
type: "float32",
|
||||||
|
writable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "n_counts",
|
||||||
|
type: "float32",
|
||||||
|
writable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "louvain",
|
||||||
|
type: "string",
|
||||||
|
writable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
index: "name_0",
|
||||||
|
},
|
||||||
|
var: {
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: "name_0",
|
||||||
|
type: "string",
|
||||||
|
writable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "n_cells",
|
||||||
|
type: "int32",
|
||||||
|
writable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
index: "name_0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dataframe: {
|
||||||
|
nObs: 2638,
|
||||||
|
nVar: 1838,
|
||||||
|
type: "float32",
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
obs: [
|
||||||
|
{
|
||||||
|
dims: ["draw_graph_fr_0", "draw_graph_fr_1"],
|
||||||
|
name: "draw_graph_fr",
|
||||||
|
type: "float32",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dims: ["pca_0", "pca_1"],
|
||||||
|
name: "pca",
|
||||||
|
type: "float32",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dims: ["tsne_0", "tsne_1"],
|
||||||
|
name: "tsne",
|
||||||
|
type: "float32",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dims: ["umap_0", "umap_1"],
|
||||||
|
name: "umap",
|
||||||
|
type: "float32",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,184 @@
|
|||||||
|
import {
|
||||||
|
_whereCacheGet,
|
||||||
|
_whereCacheCreate,
|
||||||
|
_whereCacheMerge,
|
||||||
|
} from "../../../src/annoMatrix/whereCache";
|
||||||
|
|
||||||
|
const schema = {};
|
||||||
|
|
||||||
|
describe("whereCache", () => {
|
||||||
|
test("whereCacheGet - missing cache values", () => {
|
||||||
|
expect(
|
||||||
|
_whereCacheGet({}, schema, "X", {
|
||||||
|
field: "var",
|
||||||
|
column: "foo",
|
||||||
|
value: "bar",
|
||||||
|
})
|
||||||
|
).toEqual([undefined]);
|
||||||
|
expect(
|
||||||
|
_whereCacheGet({ X: {} }, schema, "X", {
|
||||||
|
field: "var",
|
||||||
|
column: "foo",
|
||||||
|
value: "bar",
|
||||||
|
})
|
||||||
|
).toEqual([undefined]);
|
||||||
|
expect(
|
||||||
|
_whereCacheGet({ X: { var: new Map() } }, schema, "X", {
|
||||||
|
field: "var",
|
||||||
|
column: "foo",
|
||||||
|
value: "bar",
|
||||||
|
})
|
||||||
|
).toEqual([undefined]);
|
||||||
|
expect(
|
||||||
|
_whereCacheGet(
|
||||||
|
{ X: { var: new Map([["foo", new Map()]]) } },
|
||||||
|
schema,
|
||||||
|
"X",
|
||||||
|
{
|
||||||
|
field: "var",
|
||||||
|
column: "foo",
|
||||||
|
value: "bar",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
).toEqual([undefined]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("whereCacheGet - varied lookups", () => {
|
||||||
|
const whereCache = {
|
||||||
|
X: {
|
||||||
|
var: new Map([
|
||||||
|
[
|
||||||
|
"foo",
|
||||||
|
new Map([
|
||||||
|
["bar", [0]],
|
||||||
|
["baz", [1, 2]],
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
_whereCacheGet(whereCache, schema, "X", {
|
||||||
|
field: "var",
|
||||||
|
column: "foo",
|
||||||
|
value: "bar",
|
||||||
|
})
|
||||||
|
).toEqual([0]);
|
||||||
|
expect(
|
||||||
|
_whereCacheGet(whereCache, schema, "X", {
|
||||||
|
field: "var",
|
||||||
|
column: "foo",
|
||||||
|
value: "baz",
|
||||||
|
})
|
||||||
|
).toEqual([1, 2]);
|
||||||
|
expect(_whereCacheGet(whereCache, schema, "Y", {})).toEqual([undefined]);
|
||||||
|
expect(
|
||||||
|
_whereCacheGet(whereCache, schema, "X", {
|
||||||
|
field: "whoknows",
|
||||||
|
column: "whatever",
|
||||||
|
value: "snork",
|
||||||
|
})
|
||||||
|
).toEqual([undefined]);
|
||||||
|
expect(
|
||||||
|
_whereCacheGet(whereCache, schema, "X", {
|
||||||
|
field: "var",
|
||||||
|
column: "whatever",
|
||||||
|
value: "snork",
|
||||||
|
})
|
||||||
|
).toEqual([undefined]);
|
||||||
|
expect(
|
||||||
|
_whereCacheGet(whereCache, schema, "X", {
|
||||||
|
field: "var",
|
||||||
|
column: "foo",
|
||||||
|
value: "snork",
|
||||||
|
})
|
||||||
|
).toEqual([undefined]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("whereCacheCreate", () => {
|
||||||
|
const query = {
|
||||||
|
field: "queryField",
|
||||||
|
column: "queryColumn",
|
||||||
|
value: "queryValue",
|
||||||
|
};
|
||||||
|
const wc = _whereCacheCreate(
|
||||||
|
"field",
|
||||||
|
{ field: "queryField", column: "queryColumn", value: "queryValue" },
|
||||||
|
[0, 1, 2]
|
||||||
|
);
|
||||||
|
expect(wc).toBeDefined();
|
||||||
|
expect(wc).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
field: {
|
||||||
|
queryField: expect.any(Map),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(wc.field.queryField.has("queryColumn")).toEqual(true);
|
||||||
|
expect(wc.field.queryField.get("queryColumn")).toBeInstanceOf(Map);
|
||||||
|
expect(wc.field.queryField.get("queryColumn").has("queryValue")).toEqual(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
expect(_whereCacheGet(wc, schema, "field", query)).toEqual([0, 1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("whereCacheMerge", () => {
|
||||||
|
let wc;
|
||||||
|
|
||||||
|
// remember, will mutate dst
|
||||||
|
const src = _whereCacheCreate(
|
||||||
|
"field",
|
||||||
|
{ field: "queryField", column: "queryColumn", value: "foo" },
|
||||||
|
["foo"]
|
||||||
|
);
|
||||||
|
|
||||||
|
const dst1 = _whereCacheCreate(
|
||||||
|
"field",
|
||||||
|
{ field: "queryField", column: "queryColumn", value: "bar" },
|
||||||
|
["dst1"]
|
||||||
|
);
|
||||||
|
wc = _whereCacheMerge(dst1, src);
|
||||||
|
expect(
|
||||||
|
_whereCacheGet(wc, schema, "field", {
|
||||||
|
field: "queryField",
|
||||||
|
column: "queryColumn",
|
||||||
|
value: "foo",
|
||||||
|
})
|
||||||
|
).toEqual(["foo"]);
|
||||||
|
expect(
|
||||||
|
_whereCacheGet(wc, schema, "field", {
|
||||||
|
field: "queryField",
|
||||||
|
column: "queryColumn",
|
||||||
|
value: "bar",
|
||||||
|
})
|
||||||
|
).toEqual(["dst1"]);
|
||||||
|
|
||||||
|
const dst2 = _whereCacheCreate(
|
||||||
|
"field",
|
||||||
|
{ field: "queryField", column: "queryColumn", value: "bar" },
|
||||||
|
["dst2"]
|
||||||
|
);
|
||||||
|
wc = _whereCacheMerge(dst2, dst1, src);
|
||||||
|
expect(
|
||||||
|
_whereCacheGet(wc, schema, "field", {
|
||||||
|
field: "queryField",
|
||||||
|
column: "queryColumn",
|
||||||
|
value: "foo",
|
||||||
|
})
|
||||||
|
).toEqual(["foo"]);
|
||||||
|
expect(
|
||||||
|
_whereCacheGet(wc, schema, "field", {
|
||||||
|
field: "queryField",
|
||||||
|
column: "queryColumn",
|
||||||
|
value: "bar",
|
||||||
|
})
|
||||||
|
).toEqual(["dst1"]);
|
||||||
|
|
||||||
|
wc = _whereCacheMerge({}, src);
|
||||||
|
expect(wc).toEqual(src);
|
||||||
|
|
||||||
|
wc = _whereCacheMerge({ field: { queryField: new Map() } }, src);
|
||||||
|
expect(wc).toEqual(src);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,53 +1,35 @@
|
|||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
|
|
||||||
import calcCentroid from "../../src/util/centroid";
|
import calcCentroid from "../../src/util/centroid";
|
||||||
|
|
||||||
import quantile from "../../src/util/quantile";
|
import quantile from "../../src/util/quantile";
|
||||||
import * as Universe from "../../src/util/stateManager/universe";
|
|
||||||
import { matrixFBSToDataframe } from "../../src/util/stateManager/matrix";
|
import { matrixFBSToDataframe } from "../../src/util/stateManager/matrix";
|
||||||
import * as World from "../../src/util/stateManager/world";
|
|
||||||
import * as REST from "./stateManager/sampleResponses";
|
import * as REST from "./stateManager/sampleResponses";
|
||||||
import { ControlsHelpers as CH } from "../../src/util/stateManager";
|
import { indexEntireSchema } from "../../src/util/stateManager/schemaHelpers";
|
||||||
|
import { _normalizeCategoricalSchema } from "../../src/annoMatrix/schema";
|
||||||
|
|
||||||
describe("centroid", () => {
|
describe("centroid", () => {
|
||||||
let world;
|
let schema;
|
||||||
let categoricalSelection;
|
let obsAnnotations;
|
||||||
|
let obsLayout;
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
// Create world + universe
|
schema = indexEntireSchema(_.cloneDeep(REST.schema.schema));
|
||||||
let universe = Universe.createUniverseFromResponse(
|
obsAnnotations = matrixFBSToDataframe(REST.annotationsObs);
|
||||||
_.cloneDeep(REST.config),
|
obsLayout = matrixFBSToDataframe(REST.layoutObs);
|
||||||
_.cloneDeep(REST.schema)
|
|
||||||
);
|
|
||||||
|
|
||||||
universe = {
|
_normalizeCategoricalSchema(
|
||||||
...universe,
|
schema.annotations.obsByName.field3,
|
||||||
...Universe.addObsAnnotations(
|
obsAnnotations.col("field3")
|
||||||
universe,
|
|
||||||
matrixFBSToDataframe(REST.annotationsObs)
|
|
||||||
),
|
|
||||||
...Universe.addVarAnnotations(
|
|
||||||
universe,
|
|
||||||
matrixFBSToDataframe(REST.annotationsVar)
|
|
||||||
),
|
|
||||||
...Universe.addObsLayout(universe, matrixFBSToDataframe(REST.layoutObs)),
|
|
||||||
};
|
|
||||||
world = World.createWorldFromEntireUniverse(universe);
|
|
||||||
|
|
||||||
// Create categorical selection from world
|
|
||||||
categoricalSelection = CH.createCategoricalSelection(
|
|
||||||
world,
|
|
||||||
CH.selectableCategoryNames(world.schema, CH.maxCategoryItems(REST.config))
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("field4 (categorical obsAnnotation)", () => {
|
test("field4 (categorical obsAnnotation)", () => {
|
||||||
const centroidResult = calcCentroid(
|
const centroidResult = calcCentroid(
|
||||||
world.obsAnnotations,
|
schema,
|
||||||
world.obsLayout,
|
|
||||||
"field4",
|
"field4",
|
||||||
["umap_0", "umap_1"],
|
obsAnnotations,
|
||||||
categoricalSelection,
|
{ current: "umap", currentDimNames: ["umap_0", "umap_1"] },
|
||||||
world.schema.annotations.obsByName
|
obsLayout
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check to see that a centroid has been calculated for every categorical value
|
// Check to see that a centroid has been calculated for every categorical value
|
||||||
@@ -58,8 +40,8 @@ describe("centroid", () => {
|
|||||||
|
|
||||||
// This expected result assumes that all cells belong in all categorical values inside of sample response
|
// This expected result assumes that all cells belong in all categorical values inside of sample response
|
||||||
const expectedResult = [
|
const expectedResult = [
|
||||||
quantile([0.5], world.obsLayout.col("umap_0").asArray())[0],
|
quantile([0.5], obsLayout.col("umap_0").asArray())[0],
|
||||||
quantile([0.5], world.obsLayout.col("umap_1").asArray())[0],
|
quantile([0.5], obsLayout.col("umap_1").asArray())[0],
|
||||||
];
|
];
|
||||||
|
|
||||||
centroidResult.forEach((coordinate) => {
|
centroidResult.forEach((coordinate) => {
|
||||||
@@ -69,12 +51,11 @@ describe("centroid", () => {
|
|||||||
|
|
||||||
test("field3 (boolean obsAnnotation)", () => {
|
test("field3 (boolean obsAnnotation)", () => {
|
||||||
const centroidResult = calcCentroid(
|
const centroidResult = calcCentroid(
|
||||||
world.obsAnnotations,
|
schema,
|
||||||
world.obsLayout,
|
|
||||||
"field3",
|
"field3",
|
||||||
["umap_0", "umap_1"],
|
obsAnnotations,
|
||||||
categoricalSelection,
|
{ current: "umap", currentDimNames: ["umap_0", "umap_1"] },
|
||||||
world.schema.annotations.obsByName
|
obsLayout
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check to see that a centroid has been calculated for every categorical value
|
// Check to see that a centroid has been calculated for every categorical value
|
||||||
@@ -83,8 +64,8 @@ describe("centroid", () => {
|
|||||||
|
|
||||||
// This expected result assumes that all cells belong in all categorical values inside of sample response
|
// This expected result assumes that all cells belong in all categorical values inside of sample response
|
||||||
const expectedResult = [
|
const expectedResult = [
|
||||||
quantile([0.5], world.obsLayout.col("umap_0").asArray())[0],
|
quantile([0.5], obsLayout.col("umap_0").asArray())[0],
|
||||||
quantile([0.5], world.obsLayout.col("umap_1").asArray())[0],
|
quantile([0.5], obsLayout.col("umap_1").asArray())[0],
|
||||||
];
|
];
|
||||||
|
|
||||||
centroidResult.forEach((coordinate) => {
|
centroidResult.forEach((coordinate) => {
|
||||||
|
|||||||
@@ -918,82 +918,525 @@ describe("dataframe col", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("label indexing", () => {
|
describe("label indexing", () => {
|
||||||
|
describe("isLabelIndex", () => {
|
||||||
|
expect(
|
||||||
|
Dataframe.isLabelIndex(new Dataframe.IdentityInt32Index(4))
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
Dataframe.isLabelIndex(new Dataframe.DenseInt32Index([2, 4, 99]))
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
Dataframe.isLabelIndex(new Dataframe.KeyIndex(["a", 4, "toasty"]))
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(Dataframe.isLabelIndex(false)).toBeFalsy();
|
||||||
|
expect(Dataframe.isLabelIndex(undefined)).toBeFalsy();
|
||||||
|
expect(Dataframe.isLabelIndex(null)).toBeFalsy();
|
||||||
|
expect(Dataframe.isLabelIndex(true)).toBeFalsy();
|
||||||
|
expect(Dataframe.isLabelIndex([])).toBeFalsy();
|
||||||
|
expect(Dataframe.isLabelIndex({})).toBeFalsy();
|
||||||
|
expect(Dataframe.isLabelIndex(Dataframe.IdentityInt32Index)).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
test("IdentityInt32Index", () => {
|
describe("IdentityInt32Index", () => {
|
||||||
const idx = new Dataframe.IdentityInt32Index(12); // [0, 12)
|
const idx = new Dataframe.IdentityInt32Index(12); // [0, 12)
|
||||||
|
|
||||||
|
test("create", () => {
|
||||||
expect(Dataframe.isLabelIndex(idx)).toBeTruthy();
|
expect(Dataframe.isLabelIndex(idx)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
expect(idx.labels()).toEqual(new Int32Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]));
|
test("labels", () => {
|
||||||
|
expect(idx.labels()).toEqual(
|
||||||
|
new Int32Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
|
||||||
|
);
|
||||||
expect(idx.getLabel(1)).toEqual(1);
|
expect(idx.getLabel(1)).toEqual(1);
|
||||||
expect(idx.getOffset(1)).toEqual(1);
|
expect(idx.getLabels([1, 3])).toEqual([1, 3]);
|
||||||
expect(idx.getOffsets([1,3])).toEqual([1,3])
|
|
||||||
expect(idx.getLabels([1, 3])).toEqual([1,3])
|
|
||||||
expect(idx.size()).toEqual(12);
|
expect(idx.size()).toEqual(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("offsets", () => {
|
||||||
|
expect(idx.getOffset(1)).toEqual(1);
|
||||||
|
expect(idx.getOffsets([1, 3])).toEqual([1, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("subset", () => {
|
||||||
expect(idx.subset([2]).labels()).toEqual([2]);
|
expect(idx.subset([2]).labels()).toEqual([2]);
|
||||||
expect(idx.subset([2, 3, 4]).labels()).toEqual(new Int32Array([2, 3, 4]));
|
expect(idx.subset([2, 3, 4]).labels()).toEqual(new Int32Array([2, 3, 4]));
|
||||||
expect(idx.subset([0, 1, 2, 3]).labels()).toEqual(new Int32Array([0, 1, 2, 3]));
|
expect(idx.subset([0, 1, 2, 3]).labels()).toEqual(
|
||||||
|
new Int32Array([0, 1, 2, 3])
|
||||||
expect(idx.isubset([2]).labels()).toEqual([2]);
|
);
|
||||||
expect(idx.isubset([2, 3, 4]).labels()).toEqual(new Int32Array([2, 3, 4]));
|
expect(idx.subset([0, 1, 2, 3, 4])).toBeInstanceOf(
|
||||||
expect(idx.isubset([0, 1, 2, 3]).labels()).toEqual(new Int32Array([0, 1, 2, 3]));
|
Dataframe.IdentityInt32Index
|
||||||
|
);
|
||||||
expect(idx.subset([0, 1, 2, 3, 4])).toBeInstanceOf(Dataframe.IdentityInt32Index);
|
expect(idx.subset([2, 1, 0])).toBeInstanceOf(
|
||||||
expect(idx.subset([2, 1, 0])).toBeInstanceOf(Dataframe.IdentityInt32Index);
|
Dataframe.IdentityInt32Index
|
||||||
expect(idx.subset([1, 2, 3, 4])).toBeInstanceOf(Dataframe.DenseInt32Index);
|
);
|
||||||
expect(idx.subset([0, 1, 3, 4])).toBeInstanceOf(Dataframe.DenseInt32Index);
|
expect(idx.subset([1, 2, 3, 4])).toBeInstanceOf(
|
||||||
expect(idx.subset([0, 1, 2, 3, 10])).toBeInstanceOf(Dataframe.DenseInt32Index);
|
Dataframe.DenseInt32Index
|
||||||
expect(idx.subset([4, 3, 2, 1])).toBeInstanceOf(Dataframe.DenseInt32Index);
|
);
|
||||||
|
expect(idx.subset([0, 1, 3, 4])).toBeInstanceOf(
|
||||||
|
Dataframe.DenseInt32Index
|
||||||
|
);
|
||||||
|
expect(idx.subset([0, 1, 2, 3, 10])).toBeInstanceOf(
|
||||||
|
Dataframe.DenseInt32Index
|
||||||
|
);
|
||||||
|
expect(idx.subset([4, 3, 2, 1])).toBeInstanceOf(
|
||||||
|
Dataframe.DenseInt32Index
|
||||||
|
);
|
||||||
expect(idx.subset([4])).toBeInstanceOf(Dataframe.KeyIndex);
|
expect(idx.subset([4])).toBeInstanceOf(Dataframe.KeyIndex);
|
||||||
|
|
||||||
expect(idx.withLabel(99).labels()).toEqual(new Int32Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 99]));
|
|
||||||
expect(idx.dropLabel(0).labels()).toEqual(new Int32Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]));
|
|
||||||
expect(idx.dropLabel(11).labels()).toEqual(new Int32Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]));
|
|
||||||
expect(idx.dropLabel(5).labels()).toEqual(new Int32Array([0, 1, 2, 3, 4, 6, 7, 8, 9, 10, 11]));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("DenseInt32Index", () => {
|
test("isubset", () => {
|
||||||
|
expect(idx.isubset([2]).labels()).toEqual([2]);
|
||||||
|
expect(idx.isubset([2, 3, 4]).labels()).toEqual(
|
||||||
|
new Int32Array([2, 3, 4])
|
||||||
|
);
|
||||||
|
expect(idx.isubset([0, 1, 2, 3]).labels()).toEqual(
|
||||||
|
new Int32Array([0, 1, 2, 3])
|
||||||
|
);
|
||||||
|
expect(() => idx.isubset([-1001])).toThrow(RangeError);
|
||||||
|
expect(() => idx.isubset([1001])).toThrow(RangeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isubsetMask", () => {
|
||||||
|
expect(
|
||||||
|
idx
|
||||||
|
.isubsetMask([
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
])
|
||||||
|
.labels()
|
||||||
|
).toEqual(new Int32Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]));
|
||||||
|
expect(
|
||||||
|
idx
|
||||||
|
.isubsetMask([
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
])
|
||||||
|
.labels()
|
||||||
|
).toEqual(new Int32Array([]));
|
||||||
|
expect(
|
||||||
|
idx
|
||||||
|
.isubsetMask([
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
])
|
||||||
|
.labels()
|
||||||
|
).toEqual(new Int32Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]));
|
||||||
|
expect(
|
||||||
|
idx
|
||||||
|
.isubsetMask([
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
])
|
||||||
|
.labels()
|
||||||
|
).toEqual(new Int32Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 11]));
|
||||||
|
expect(
|
||||||
|
idx
|
||||||
|
.isubsetMask([
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
])
|
||||||
|
.labels()
|
||||||
|
).toEqual(new Int32Array([1, 2, 4, 5, 6, 7, 8, 9, 10]));
|
||||||
|
expect(() => idx.isubsetMask([])).toThrow(RangeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("withLabel", () => {
|
||||||
|
expect(idx.withLabel(99).labels()).toEqual(
|
||||||
|
new Int32Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 99])
|
||||||
|
);
|
||||||
|
expect(idx.withLabel(12).labels()).toEqual(
|
||||||
|
new Int32Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])
|
||||||
|
);
|
||||||
|
expect(idx.withLabels([12, 13]).labels()).toEqual(
|
||||||
|
new Int32Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dropLabel", () => {
|
||||||
|
expect(idx.dropLabel(0).labels()).toEqual(
|
||||||
|
new Int32Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
|
||||||
|
);
|
||||||
|
expect(idx.dropLabel(11).labels()).toEqual(
|
||||||
|
new Int32Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
|
||||||
|
);
|
||||||
|
expect(idx.dropLabel(5).labels()).toEqual(
|
||||||
|
new Int32Array([0, 1, 2, 3, 4, 6, 7, 8, 9, 10, 11])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DenseInt32Index", () => {
|
||||||
const idx = new Dataframe.DenseInt32Index([99, 1002, 48, 0, 22]);
|
const idx = new Dataframe.DenseInt32Index([99, 1002, 48, 0, 22]);
|
||||||
|
|
||||||
|
test("create", () => {
|
||||||
expect(Dataframe.isLabelIndex(idx)).toBeTruthy();
|
expect(Dataframe.isLabelIndex(idx)).toBeTruthy();
|
||||||
|
|
||||||
expect(idx.labels()).toEqual(new Int32Array([99, 1002, 48, 0, 22]));
|
|
||||||
expect(idx.size()).toEqual(5);
|
|
||||||
expect(idx.getOffset(1002)).toEqual(1);
|
|
||||||
expect(idx.getOffset(0)).toEqual(3);
|
|
||||||
expect(idx.getLabel(0)).toEqual(99);
|
|
||||||
expect(idx.getLabels(new Int32Array([2, 4]))).toEqual(new Int32Array([48, 22]));
|
|
||||||
expect(idx.getLabels([2, 4])).toEqual([48, 22]);
|
|
||||||
expect(idx.getOffsets([0, 48])).toEqual([3, 2]);
|
|
||||||
|
|
||||||
expect(idx.subset([1002, 0, 99]).labels()).toEqual(new Int32Array([1002, 0, 99]))
|
|
||||||
expect(idx.getOffsets(idx.subset([1002, 0, 99]).labels())).toEqual(new Int32Array([1, 3, 0]));
|
|
||||||
expect(idx.isubset([4, 1, 2]).labels()).toEqual(new Int32Array([22, 1002, 48]));
|
|
||||||
|
|
||||||
expect(idx.withLabel(88).labels()).toEqual(new Int32Array([99, 1002, 48, 0, 22, 88]));
|
|
||||||
expect(idx.withLabel(88).getOffset(88)).toEqual(5);
|
|
||||||
expect(idx.dropLabel(48).labels()).toEqual(new Int32Array([99, 1002, 0, 22]));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("KeyIndex", () => {
|
test("labels", () => {
|
||||||
|
expect(idx.labels()).toEqual(new Int32Array([99, 1002, 48, 0, 22]));
|
||||||
|
expect(idx.size()).toEqual(5);
|
||||||
|
expect(idx.getLabel(0)).toEqual(99);
|
||||||
|
expect(idx.getLabels(new Int32Array([2, 4]))).toEqual(
|
||||||
|
new Int32Array([48, 22])
|
||||||
|
);
|
||||||
|
expect(idx.getLabels([2, 4])).toEqual([48, 22]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("offsets", () => {
|
||||||
|
expect(idx.getOffset(1002)).toEqual(1);
|
||||||
|
expect(idx.getOffset(0)).toEqual(3);
|
||||||
|
expect(idx.getOffsets([0, 48])).toEqual([3, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("subset", () => {
|
||||||
|
expect(idx.subset([1002, 0, 99]).labels()).toEqual(
|
||||||
|
new Int32Array([1002, 0, 99])
|
||||||
|
);
|
||||||
|
expect(idx.getOffsets(idx.subset([1002, 0, 99]).labels())).toEqual(
|
||||||
|
new Int32Array([1, 3, 0])
|
||||||
|
);
|
||||||
|
expect(() => idx.subset([-1])).toThrow(RangeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isubset", () => {
|
||||||
|
expect(idx.isubset([4, 1, 2]).labels()).toEqual(
|
||||||
|
new Int32Array([22, 1002, 48])
|
||||||
|
);
|
||||||
|
expect(() => idx.isubset([-1001])).toThrow(RangeError);
|
||||||
|
expect(() => idx.isubset([1001])).toThrow(RangeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isubsetMask", () => {
|
||||||
|
expect(idx.isubsetMask([true, true, true, true, true]).labels()).toEqual(
|
||||||
|
new Int32Array([99, 1002, 48, 0, 22])
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
idx.isubsetMask([false, false, false, false, false]).labels()
|
||||||
|
).toEqual(new Int32Array([]));
|
||||||
|
expect(idx.isubsetMask([true, true, false, true, true]).labels()).toEqual(
|
||||||
|
new Int32Array([99, 1002, 0, 22])
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
idx.isubsetMask([false, true, true, true, false]).labels()
|
||||||
|
).toEqual(new Int32Array([1002, 48, 0]));
|
||||||
|
expect(() => idx.isubsetMask([])).toThrow(RangeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("withLabel", () => {
|
||||||
|
expect(idx.withLabel(88).labels()).toEqual(
|
||||||
|
new Int32Array([99, 1002, 48, 0, 22, 88])
|
||||||
|
);
|
||||||
|
expect(idx.withLabel(88).getOffset(88)).toEqual(5);
|
||||||
|
expect(idx.withLabels([88, 99]).labels()).toEqual(
|
||||||
|
new Int32Array([99, 1002, 48, 0, 22, 88, 99])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("dropLabel", () => {
|
||||||
|
expect(idx.dropLabel(48).labels()).toEqual(
|
||||||
|
new Int32Array([99, 1002, 0, 22])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("KeyIndex", () => {
|
||||||
const idx = new Dataframe.KeyIndex(["red", "green", "blue"]);
|
const idx = new Dataframe.KeyIndex(["red", "green", "blue"]);
|
||||||
|
|
||||||
|
test("create", () => {
|
||||||
expect(Dataframe.isLabelIndex(idx)).toBeTruthy();
|
expect(Dataframe.isLabelIndex(idx)).toBeTruthy();
|
||||||
|
expect(() => new Dataframe.KeyIndex(["dup", "dup"])).toThrow(Error);
|
||||||
|
expect(new Dataframe.KeyIndex().size()).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("labels", () => {
|
||||||
expect(idx.labels()).toEqual(["red", "green", "blue"]);
|
expect(idx.labels()).toEqual(["red", "green", "blue"]);
|
||||||
expect(idx.size()).toEqual(3);
|
expect(idx.size()).toEqual(3);
|
||||||
expect(idx.getOffset("blue")).toEqual(2);
|
|
||||||
expect(idx.getLabel(1)).toEqual("green");
|
expect(idx.getLabel(1)).toEqual("green");
|
||||||
|
expect(idx.getLabels([2, 0])).toEqual(["blue", "red"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("offsets", () => {
|
||||||
|
expect(idx.getOffset("blue")).toEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("subset", () => {
|
||||||
expect(idx.subset(["green"]).labels()).toEqual(["green"]);
|
expect(idx.subset(["green"]).labels()).toEqual(["green"]);
|
||||||
expect(idx.subset(["green", "red"]).labels()).toEqual(["green", "red"]);
|
expect(idx.subset(["green", "red"]).labels()).toEqual(["green", "red"]);
|
||||||
expect(idx.isubset([2, 1, 0]).labels()).toEqual(["blue", "green", "red"]);
|
});
|
||||||
|
|
||||||
expect(idx.withLabel("yo").labels()).toEqual(["red", "green", "blue", "yo"]);
|
test("isubset", () => {
|
||||||
|
expect(idx.isubset([2, 1, 0]).labels()).toEqual(["blue", "green", "red"]);
|
||||||
|
expect(() => idx.isubset([-1001])).toThrow(RangeError);
|
||||||
|
expect(() => idx.isubset([1001])).toThrow(RangeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isubsetMask", () => {
|
||||||
|
expect(idx.isubsetMask([true, true, true]).labels()).toEqual([
|
||||||
|
"red",
|
||||||
|
"green",
|
||||||
|
"blue",
|
||||||
|
]);
|
||||||
|
expect(idx.isubsetMask([false, false, false]).labels()).toEqual([]);
|
||||||
|
expect(idx.isubsetMask([true, false, true]).labels()).toEqual([
|
||||||
|
"red",
|
||||||
|
"blue",
|
||||||
|
]);
|
||||||
|
expect(() => idx.isubsetMask([])).toThrow(RangeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("withLabel", () => {
|
||||||
|
expect(idx.withLabel("yo").labels()).toEqual([
|
||||||
|
"red",
|
||||||
|
"green",
|
||||||
|
"blue",
|
||||||
|
"yo",
|
||||||
|
]);
|
||||||
expect(idx.withLabel("yo").getOffset("yo")).toEqual(3);
|
expect(idx.withLabel("yo").getOffset("yo")).toEqual(3);
|
||||||
|
expect(idx.withLabels(["hey", "there"]).labels()).toEqual([
|
||||||
|
"red",
|
||||||
|
"green",
|
||||||
|
"blue",
|
||||||
|
"hey",
|
||||||
|
"there",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dropLabel", () => {
|
||||||
expect(idx.dropLabel("blue").labels()).toEqual(["red", "green"]);
|
expect(idx.dropLabel("blue").labels()).toEqual(["red", "green"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
})
|
describe("corner cases", () => {
|
||||||
|
/* error/corner cases */
|
||||||
|
|
||||||
|
test("identity integer index rejects non-integer labels", () => {
|
||||||
|
const idx = new Dataframe.IdentityInt32Index(10);
|
||||||
|
expect(idx.getOffset(0)).toBe(0);
|
||||||
|
expect(idx.getOffset(9)).toBe(9);
|
||||||
|
expect(idx.getOffset(10)).toBeUndefined();
|
||||||
|
expect(idx.getOffset(-1)).toBeUndefined();
|
||||||
|
expect(idx.getOffset("sort")).toBeUndefined();
|
||||||
|
expect(idx.getOffset("length")).toBeUndefined();
|
||||||
|
expect(idx.getOffset(true)).toBeUndefined();
|
||||||
|
expect(idx.getOffset(0.001)).toBeUndefined();
|
||||||
|
expect(idx.getOffset({})).toBeUndefined();
|
||||||
|
expect(idx.getOffset([])).toBeUndefined();
|
||||||
|
expect(idx.getOffset(new Float32Array())).toBeUndefined();
|
||||||
|
expect(idx.getOffset("__proto__")).toBeUndefined();
|
||||||
|
|
||||||
|
expect(idx.getLabel(0)).toBe(0);
|
||||||
|
expect(idx.getLabel(9)).toBe(9);
|
||||||
|
expect(idx.getLabel(10)).toBeUndefined();
|
||||||
|
expect(idx.getLabel(-1)).toBeUndefined();
|
||||||
|
expect(idx.getLabel("sort")).toBeUndefined();
|
||||||
|
expect(idx.getLabel("length")).toBeUndefined();
|
||||||
|
expect(idx.getLabel(true)).toBeUndefined();
|
||||||
|
expect(idx.getLabel(0.001)).toBeUndefined();
|
||||||
|
expect(idx.getLabel({})).toBeUndefined();
|
||||||
|
expect(idx.getLabel([])).toBeUndefined();
|
||||||
|
expect(idx.getLabel(new Float32Array())).toBeUndefined();
|
||||||
|
expect(idx.getLabel("__proto__")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dense integer index rejects non-integer labels", () => {
|
||||||
|
const idx = new Dataframe.DenseInt32Index([-10, 0, 3, 9, 10]);
|
||||||
|
expect(idx.getOffset(0)).toBe(1);
|
||||||
|
expect(idx.getOffset(9)).toBe(3);
|
||||||
|
expect(idx.getOffset(1)).toBeUndefined();
|
||||||
|
expect(idx.getOffset(11)).toBeUndefined();
|
||||||
|
expect(idx.getOffset(-1)).toBeUndefined();
|
||||||
|
expect(idx.getOffset("sort")).toBeUndefined();
|
||||||
|
expect(idx.getOffset("length")).toBeUndefined();
|
||||||
|
expect(idx.getOffset(true)).toBeUndefined();
|
||||||
|
expect(idx.getOffset(0.001)).toBeUndefined();
|
||||||
|
expect(idx.getOffset({})).toBeUndefined();
|
||||||
|
expect(idx.getOffset([])).toBeUndefined();
|
||||||
|
expect(idx.getOffset(new Float32Array())).toBeUndefined();
|
||||||
|
expect(idx.getOffset("__proto__")).toBeUndefined();
|
||||||
|
|
||||||
|
expect(idx.getLabel(0)).toBe(-10);
|
||||||
|
expect(idx.getLabel(4)).toBe(10);
|
||||||
|
expect(idx.getLabel(10)).toBeUndefined();
|
||||||
|
expect(idx.getLabel(-1)).toBeUndefined();
|
||||||
|
expect(idx.getLabel("sort")).toBeUndefined();
|
||||||
|
expect(idx.getLabel("length")).toBeUndefined();
|
||||||
|
expect(idx.getLabel(true)).toBeUndefined();
|
||||||
|
expect(idx.getLabel(0.001)).toBeUndefined();
|
||||||
|
expect(idx.getLabel({})).toBeUndefined();
|
||||||
|
expect(idx.getLabel([])).toBeUndefined();
|
||||||
|
expect(idx.getLabel(new Float32Array())).toBeUndefined();
|
||||||
|
expect(idx.getLabel("__proto__")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Empty dataframe rejects bogus labels", () => {
|
||||||
|
const df = Dataframe.Dataframe.empty();
|
||||||
|
|
||||||
|
expect(df.hasCol("sort")).toBeFalsy();
|
||||||
|
expect(df.hasCol(0)).toBeFalsy();
|
||||||
|
expect(df.hasCol(true)).toBeFalsy();
|
||||||
|
expect(df.hasCol(false)).toBeFalsy();
|
||||||
|
expect(df.hasCol([])).toBeFalsy();
|
||||||
|
expect(df.hasCol({})).toBeFalsy();
|
||||||
|
expect(df.hasCol(null)).toBeFalsy();
|
||||||
|
expect(df.hasCol(undefined)).toBeFalsy();
|
||||||
|
|
||||||
|
expect(df.col("sort")).toBeUndefined();
|
||||||
|
expect(df.col(0)).toBeUndefined();
|
||||||
|
expect(df.col(true)).toBeUndefined();
|
||||||
|
expect(df.col(false)).toBeUndefined();
|
||||||
|
expect(df.col([])).toBeUndefined();
|
||||||
|
expect(df.col({})).toBeUndefined();
|
||||||
|
expect(df.col(null)).toBeUndefined();
|
||||||
|
expect(df.col(undefined)).toBeUndefined();
|
||||||
|
|
||||||
|
expect(df.icol("sort")).toBeUndefined();
|
||||||
|
expect(df.icol(0)).toBeUndefined();
|
||||||
|
expect(df.icol(true)).toBeUndefined();
|
||||||
|
expect(df.icol(false)).toBeUndefined();
|
||||||
|
expect(df.icol([])).toBeUndefined();
|
||||||
|
expect(df.icol({})).toBeUndefined();
|
||||||
|
expect(df.icol(null)).toBeUndefined();
|
||||||
|
expect(df.icol(undefined)).toBeUndefined();
|
||||||
|
|
||||||
|
expect(df.ihas("sort", "length")).toBeFalsy();
|
||||||
|
expect(df.ihas("0", "0")).toBeFalsy();
|
||||||
|
expect(df.ihas("", "")).toBeFalsy();
|
||||||
|
expect(df.ihas(null, null)).toBeFalsy();
|
||||||
|
expect(df.ihas(undefined, undefined)).toBeFalsy();
|
||||||
|
expect(df.ihas(true, true)).toBeFalsy();
|
||||||
|
expect(df.ihas([], [])).toBeFalsy();
|
||||||
|
expect(df.ihas({}, {})).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Dataframe rejects bogus labels", () => {
|
||||||
|
const df = new Dataframe.Dataframe(
|
||||||
|
[2, 2],
|
||||||
|
[
|
||||||
|
[true, false],
|
||||||
|
[1, 0],
|
||||||
|
],
|
||||||
|
null,
|
||||||
|
new Dataframe.KeyIndex(["A", "B"])
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(df.hasCol("sort")).toBeFalsy();
|
||||||
|
expect(df.hasCol("__proto__")).toBeFalsy();
|
||||||
|
expect(df.hasCol(0)).toBeFalsy();
|
||||||
|
expect(df.hasCol(true)).toBeFalsy();
|
||||||
|
expect(df.hasCol(false)).toBeFalsy();
|
||||||
|
expect(df.hasCol([])).toBeFalsy();
|
||||||
|
expect(df.hasCol({})).toBeFalsy();
|
||||||
|
expect(df.hasCol(null)).toBeFalsy();
|
||||||
|
expect(df.hasCol(undefined)).toBeFalsy();
|
||||||
|
|
||||||
|
expect(df.col("sort")).toBeUndefined();
|
||||||
|
expect(df.col("__proto__")).toBeUndefined();
|
||||||
|
expect(df.col(0)).toBeUndefined();
|
||||||
|
expect(df.col(true)).toBeUndefined();
|
||||||
|
expect(df.col(false)).toBeUndefined();
|
||||||
|
expect(df.col([])).toBeUndefined();
|
||||||
|
expect(df.col({})).toBeUndefined();
|
||||||
|
expect(df.col(null)).toBeUndefined();
|
||||||
|
expect(df.col(undefined)).toBeUndefined();
|
||||||
|
|
||||||
|
expect(df.icol("sort")).toBeUndefined();
|
||||||
|
expect(df.icol("__proto__")).toBeUndefined();
|
||||||
|
expect(df.icol(-1)).toBeUndefined();
|
||||||
|
expect(df.icol(true)).toBeUndefined();
|
||||||
|
expect(df.icol(false)).toBeUndefined();
|
||||||
|
expect(df.icol([])).toBeUndefined();
|
||||||
|
expect(df.icol({})).toBeUndefined();
|
||||||
|
expect(df.icol(null)).toBeUndefined();
|
||||||
|
expect(df.icol(undefined)).toBeUndefined();
|
||||||
|
|
||||||
|
expect(df.ihas("sort", "length")).toBeFalsy();
|
||||||
|
expect(df.ihas("__proto__", "__proto__")).toBeFalsy();
|
||||||
|
|
||||||
|
expect(df.ihas(-1, 0)).toBeFalsy();
|
||||||
|
expect(df.ihas("0", 0)).toBeFalsy();
|
||||||
|
expect(df.ihas("", 0)).toBeFalsy();
|
||||||
|
expect(df.ihas(null, 0)).toBeFalsy();
|
||||||
|
expect(df.ihas(undefined, 0)).toBeFalsy();
|
||||||
|
expect(df.ihas([], 0)).toBeFalsy();
|
||||||
|
expect(df.ihas({}, 0)).toBeFalsy();
|
||||||
|
|
||||||
|
expect(df.ihas(0, -1)).toBeFalsy();
|
||||||
|
expect(df.ihas(0, "0")).toBeFalsy();
|
||||||
|
expect(df.ihas(0, "")).toBeFalsy();
|
||||||
|
expect(df.ihas(0, null)).toBeFalsy();
|
||||||
|
expect(df.ihas(0, undefined)).toBeFalsy();
|
||||||
|
expect(df.ihas(0, [])).toBeFalsy();
|
||||||
|
expect(df.ihas(0, {})).toBeFalsy();
|
||||||
|
|
||||||
|
expect(df.has("sort", "length")).toBeFalsy();
|
||||||
|
expect(df.has("length", "sort")).toBeFalsy();
|
||||||
|
expect(df.has("__proto__", "__proto__")).toBeFalsy();
|
||||||
|
|
||||||
|
expect(df.has(-1, "A")).toBeFalsy();
|
||||||
|
expect(df.has("0", "A")).toBeFalsy();
|
||||||
|
expect(df.has("", "A")).toBeFalsy();
|
||||||
|
expect(df.has(null, "A")).toBeFalsy();
|
||||||
|
expect(df.has(undefined, "A")).toBeFalsy();
|
||||||
|
expect(df.has([], "A")).toBeFalsy();
|
||||||
|
expect(df.has({}, "A")).toBeFalsy();
|
||||||
|
|
||||||
|
expect(df.has(0, -1)).toBeFalsy();
|
||||||
|
expect(df.has(0, "0")).toBeFalsy();
|
||||||
|
expect(df.has(0, "")).toBeFalsy();
|
||||||
|
expect(df.has(0, null)).toBeFalsy();
|
||||||
|
expect(df.has(0, undefined)).toBeFalsy();
|
||||||
|
expect(df.has(0, [])).toBeFalsy();
|
||||||
|
expect(df.has(0, {})).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import PromiseLimit from "../../src/util/promiseLimit";
|
import PromiseLimit from "../../src/util/promiseLimit";
|
||||||
import { range } from "../../src/util/range";
|
import { range } from "../../src/util/range";
|
||||||
|
|
||||||
const delay = (t) => new Promise((resolve, reject) => setTimeout(resolve, t));
|
const delay = (t) => new Promise((resolve) => setTimeout(resolve, t));
|
||||||
|
|
||||||
describe("PromiseLimit", () => {
|
describe("PromiseLimit", () => {
|
||||||
test("simple evaluation, concurrency 1", async () => {
|
test("simple evaluation, concurrency 1", async () => {
|
||||||
@@ -28,13 +28,14 @@ describe("PromiseLimit", () => {
|
|||||||
|
|
||||||
test("eval in order of insertion", async () => {
|
test("eval in order of insertion", async () => {
|
||||||
const plimit = new PromiseLimit(100);
|
const plimit = new PromiseLimit(100);
|
||||||
let counter = 0;
|
|
||||||
const result = await Promise.all([
|
const result = await Promise.all([
|
||||||
plimit.add(() => Promise.resolve((counter += 1))),
|
plimit.add(() => Promise.resolve(1)),
|
||||||
plimit.add(() => Promise.resolve((counter += 1))),
|
plimit.add(() => Promise.resolve(2)),
|
||||||
plimit.add(() => Promise.resolve((counter += 1))),
|
plimit.add(() => Promise.resolve(3)),
|
||||||
plimit.add(() => Promise.resolve((counter += 1))),
|
plimit.add(() => Promise.resolve(4)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(result).toEqual([1, 2, 3, 4]);
|
expect(result).toEqual([1, 2, 3, 4]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -43,16 +44,15 @@ describe("PromiseLimit", () => {
|
|||||||
let running = 0;
|
let running = 0;
|
||||||
let maxRunning = 0;
|
let maxRunning = 0;
|
||||||
|
|
||||||
const cbfn = async (i) => {
|
const callback = async () => {
|
||||||
running = running + 1;
|
running += 1;
|
||||||
maxRunning = running > maxRunning ? running : maxRunning;
|
maxRunning = running > maxRunning ? running : maxRunning;
|
||||||
await delay(100);
|
await delay(100);
|
||||||
running = running - 1;
|
running -= 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await Promise.all(
|
await Promise.all(range(10).map((i) => plimit.add(() => callback(i))));
|
||||||
range(10).map((i) => plimit.add(() => cbfn(i)))
|
|
||||||
);
|
|
||||||
expect(maxRunning).toEqual(2);
|
expect(maxRunning).toEqual(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,6 +60,7 @@ describe("PromiseLimit", () => {
|
|||||||
const plimit = new PromiseLimit(2);
|
const plimit = new PromiseLimit(2);
|
||||||
const result = await Promise.all([
|
const result = await Promise.all([
|
||||||
plimit.add(() => Promise.resolve("OK")),
|
plimit.add(() => Promise.resolve("OK")),
|
||||||
|
// eslint-disable-next-line prefer-promise-reject-errors -- unit test
|
||||||
plimit.add(() => Promise.reject("not OK")).catch((e) => e),
|
plimit.add(() => Promise.reject("not OK")).catch((e) => e),
|
||||||
plimit.add(() => Promise.resolve("OK")),
|
plimit.add(() => Promise.resolve("OK")),
|
||||||
plimit
|
plimit
|
||||||
@@ -70,4 +71,26 @@ describe("PromiseLimit", () => {
|
|||||||
]);
|
]);
|
||||||
expect(result).toEqual(["OK", "not OK", "OK", "not OK"]);
|
expect(result).toEqual(["OK", "not OK", "OK", "not OK"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("priority queue", async () => {
|
||||||
|
const plimit = new PromiseLimit(1);
|
||||||
|
|
||||||
|
let finishOrder = 0;
|
||||||
|
const callback = () => async () => {
|
||||||
|
await delay(100);
|
||||||
|
const result = finishOrder;
|
||||||
|
finishOrder += 1;
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await Promise.all([
|
||||||
|
plimit.add(callback()),
|
||||||
|
plimit.priorityAdd(4, callback()),
|
||||||
|
plimit.priorityAdd(0, callback()),
|
||||||
|
plimit.priorityAdd(1, callback()),
|
||||||
|
plimit.priorityAdd(-1, callback()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toEqual([0, 4, 2, 3, 1]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ test controls helpers
|
|||||||
import { subsetAndResetGeneLists } from "../../../src/util/stateManager/controlsHelpers";
|
import { subsetAndResetGeneLists } from "../../../src/util/stateManager/controlsHelpers";
|
||||||
import * as globals from "../../../src/globals";
|
import * as globals from "../../../src/globals";
|
||||||
|
|
||||||
|
|
||||||
describe("controls helpers", () => {
|
describe("controls helpers", () => {
|
||||||
test("subsetAndResetGeneLists", () => {
|
test("subsetAndResetGeneLists", () => {
|
||||||
const geneList = [];
|
const geneList = [];
|
||||||
@@ -28,7 +27,7 @@ describe("controls helpers", () => {
|
|||||||
);
|
);
|
||||||
const expectedNewUserDefinedGenes = [
|
const expectedNewUserDefinedGenes = [
|
||||||
...geneList.slice(0, 20),
|
...geneList.slice(0, 20),
|
||||||
...geneList.slice(21)
|
...geneList.slice(21),
|
||||||
].slice(0, globals.maxGenes);
|
].slice(0, globals.maxGenes);
|
||||||
expect(globals.maxUserDefinedGenes).toBeLessThan(globals.maxGenes);
|
expect(globals.maxUserDefinedGenes).toBeLessThan(globals.maxGenes);
|
||||||
expect(geneList.length).toBeGreaterThan(globals.maxGenes);
|
expect(geneList.length).toBeGreaterThan(globals.maxGenes);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint no-bitwise: "off" */
|
|
||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import { flatbuffers } from "flatbuffers";
|
import { flatbuffers } from "flatbuffers";
|
||||||
import { NetEncoding } from "../../../src/util/stateManager/matrix_generated";
|
import { NetEncoding } from "../../../src/util/stateManager/matrix_generated";
|
||||||
@@ -79,6 +78,7 @@ const anAnnotationsObsJSONResponse = {
|
|||||||
`obs${idx}`,
|
`obs${idx}`,
|
||||||
2 * idx,
|
2 * idx,
|
||||||
idx + 0.0133,
|
idx + 0.0133,
|
||||||
|
// eslint-disable-next-line no-bitwise -- idx & 1 to check for odd numbers
|
||||||
!!(idx & 1),
|
!!(idx & 1),
|
||||||
field4Categories[idx % field4Categories.length],
|
field4Categories[idx % field4Categories.length],
|
||||||
])
|
])
|
||||||
@@ -93,6 +93,7 @@ const anAnnotationsVarJSONResponse = {
|
|||||||
idx,
|
idx,
|
||||||
10 * idx,
|
10 * idx,
|
||||||
idx + 2.90143,
|
idx + 2.90143,
|
||||||
|
// eslint-disable-next-line no-bitwise -- idx & 1 to check for odd numbers
|
||||||
!!(idx & 1),
|
!!(idx & 1),
|
||||||
fieldDCategories[idx % fieldDCategories.length],
|
fieldDCategories[idx % fieldDCategories.length],
|
||||||
`var${idx}`,
|
`var${idx}`,
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
import * as Universe from "../../../src/util/stateManager/universe";
|
|
||||||
import { matrixFBSToDataframe } from "../../../src/util/stateManager/matrix";
|
|
||||||
import * as Dataframe from "../../../src/util/dataframe";
|
|
||||||
import * as REST from "./sampleResponses";
|
|
||||||
|
|
||||||
describe("createUniverseFromResponse", () => {
|
|
||||||
/*
|
|
||||||
test createUniverseFromResponse - this function converts
|
|
||||||
a set of REST 0.2 responses into a "new" Universe.
|
|
||||||
|
|
||||||
createUniverseFromResponse(
|
|
||||||
configResponse,
|
|
||||||
schemaResponse,
|
|
||||||
annotationsObsResponse,
|
|
||||||
annotationsVarResponse,
|
|
||||||
layoutObsResponse
|
|
||||||
) --> Universe
|
|
||||||
|
|
||||||
where:
|
|
||||||
configResponse: GET /.../config
|
|
||||||
schemaResponse: GET /.../schema
|
|
||||||
annotationsObsResponse: GET /.../annotations/obs
|
|
||||||
annotationsVarResponse: GET /.../annotations/var
|
|
||||||
layoutObsResponse: GET /.../layout/obs
|
|
||||||
|
|
||||||
See spec in docs/REST_API.md.
|
|
||||||
*/
|
|
||||||
|
|
||||||
test("create from test data", () => {
|
|
||||||
/*
|
|
||||||
create a universe from sample data nad validate its shape & contents
|
|
||||||
*/
|
|
||||||
const { nObs, nVar } = REST.schema.schema.dataframe;
|
|
||||||
let universe = Universe.createUniverseFromResponse(
|
|
||||||
REST.config,
|
|
||||||
REST.schema
|
|
||||||
);
|
|
||||||
expect(universe).toBeDefined();
|
|
||||||
expect(universe).toMatchObject(
|
|
||||||
expect.objectContaining({
|
|
||||||
nObs,
|
|
||||||
nVar,
|
|
||||||
schema: REST.schema.schema,
|
|
||||||
obsAnnotations: expect.any(Dataframe.Dataframe),
|
|
||||||
varAnnotations: expect.any(Dataframe.Dataframe),
|
|
||||||
obsLayout: expect.any(Dataframe.Dataframe),
|
|
||||||
varData: expect.any(Dataframe.Dataframe),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
universe = {
|
|
||||||
...universe,
|
|
||||||
...Universe.addObsAnnotations(
|
|
||||||
universe,
|
|
||||||
matrixFBSToDataframe(REST.annotationsObs)
|
|
||||||
),
|
|
||||||
...Universe.addVarAnnotations(
|
|
||||||
universe,
|
|
||||||
matrixFBSToDataframe(REST.annotationsVar)
|
|
||||||
),
|
|
||||||
...Universe.addObsLayout(universe, matrixFBSToDataframe(REST.layoutObs)),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(universe).toMatchObject(
|
|
||||||
expect.objectContaining({
|
|
||||||
nObs,
|
|
||||||
nVar,
|
|
||||||
schema: REST.schema.schema,
|
|
||||||
obsAnnotations: expect.any(Dataframe.Dataframe),
|
|
||||||
varAnnotations: expect.any(Dataframe.Dataframe),
|
|
||||||
obsLayout: expect.any(Dataframe.Dataframe),
|
|
||||||
varData: expect.any(Dataframe.Dataframe),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(universe.obsAnnotations.dims).toEqual([
|
|
||||||
nObs,
|
|
||||||
REST.schema.schema.annotations.obs.columns.length,
|
|
||||||
]);
|
|
||||||
expect(universe.obsLayout.dims).toEqual([nObs, 2]);
|
|
||||||
expect(universe.obsLayout.colIndex.labels()).toEqual(
|
|
||||||
universe.schema.layout.obs[0].dims
|
|
||||||
);
|
|
||||||
expect(universe.varAnnotations.dims).toEqual([
|
|
||||||
nVar,
|
|
||||||
REST.schema.schema.annotations.var.columns.length,
|
|
||||||
]);
|
|
||||||
expect(universe.varData.isEmpty()).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
import _ from "lodash";
|
|
||||||
import * as Universe from "../../../src/util/stateManager/universe";
|
|
||||||
import { matrixFBSToDataframe } from "../../../src/util/stateManager/matrix";
|
|
||||||
import * as World from "../../../src/util/stateManager/world";
|
|
||||||
import * as Dataframe from "../../../src/util/dataframe";
|
|
||||||
import Crossfilter from "../../../src/util/typedCrossfilter";
|
|
||||||
import { DimTypes } from "../../../src/util/typedCrossfilter/crossfilter";
|
|
||||||
import * as REST from "./sampleResponses";
|
|
||||||
import {
|
|
||||||
obsAnnoDimensionName,
|
|
||||||
layoutDimensionName,
|
|
||||||
} from "../../../src/util/nameCreators";
|
|
||||||
|
|
||||||
/*
|
|
||||||
Helper - creates universe, world, corssfilter and dimensionMap from
|
|
||||||
the default REST test response.
|
|
||||||
*/
|
|
||||||
const defaultBigBang = () => {
|
|
||||||
/* create unverse, world, crossfilter and dimensionMap */
|
|
||||||
/* create universe */
|
|
||||||
let universe = Universe.createUniverseFromResponse(
|
|
||||||
_.cloneDeep(REST.config),
|
|
||||||
_.cloneDeep(REST.schema)
|
|
||||||
);
|
|
||||||
|
|
||||||
universe = {
|
|
||||||
...universe,
|
|
||||||
...Universe.addObsAnnotations(
|
|
||||||
universe,
|
|
||||||
matrixFBSToDataframe(REST.annotationsObs)
|
|
||||||
),
|
|
||||||
...Universe.addVarAnnotations(
|
|
||||||
universe,
|
|
||||||
matrixFBSToDataframe(REST.annotationsVar)
|
|
||||||
),
|
|
||||||
...Universe.addObsLayout(universe, matrixFBSToDataframe(REST.layoutObs)),
|
|
||||||
};
|
|
||||||
|
|
||||||
/* create world */
|
|
||||||
const world = World.createWorldFromEntireUniverse(universe);
|
|
||||||
/* create crossfilter */
|
|
||||||
const crossfilter = World.createObsDimensions(
|
|
||||||
new Crossfilter(world.obsAnnotations),
|
|
||||||
world,
|
|
||||||
REST.schema.schema.layout.obs[0].dims
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
universe,
|
|
||||||
world,
|
|
||||||
crossfilter,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("createWorldFromEntireUniverse", () => {
|
|
||||||
test("create from REST sample", () => {
|
|
||||||
const universe = Universe.createUniverseFromResponse(
|
|
||||||
_.cloneDeep(REST.config),
|
|
||||||
_.cloneDeep(REST.schema),
|
|
||||||
matrixFBSToDataframe(_.cloneDeep(REST.annotationsObs)),
|
|
||||||
matrixFBSToDataframe(_.cloneDeep(REST.annotationsVar)),
|
|
||||||
matrixFBSToDataframe(_.cloneDeep(REST.layoutObs))
|
|
||||||
);
|
|
||||||
expect(universe).toBeDefined();
|
|
||||||
|
|
||||||
const world = World.createWorldFromEntireUniverse(universe);
|
|
||||||
expect(world).toBeDefined();
|
|
||||||
|
|
||||||
expect(world).toMatchObject(
|
|
||||||
expect.objectContaining({
|
|
||||||
nObs: universe.nObs,
|
|
||||||
nVar: universe.nVar,
|
|
||||||
schema: universe.schema,
|
|
||||||
obsAnnotations: expect.any(Dataframe.Dataframe),
|
|
||||||
varAnnotations: expect.any(Dataframe.Dataframe),
|
|
||||||
obsLayout: expect.any(Dataframe.Dataframe),
|
|
||||||
varData: expect.any(Dataframe.Dataframe),
|
|
||||||
clipQuantiles: { min: 0, max: 1 },
|
|
||||||
unclipped: {
|
|
||||||
obsAnnotations: expect.any(Dataframe.Dataframe),
|
|
||||||
varData: expect.any(Dataframe.Dataframe),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createWorldFromCurrentSelection", () => {
|
|
||||||
test("create from REST sample", () => {
|
|
||||||
const {
|
|
||||||
universe,
|
|
||||||
world: originalWorld,
|
|
||||||
crossfilter: originalCrossfilter,
|
|
||||||
} = defaultBigBang();
|
|
||||||
|
|
||||||
/* mock a selection */
|
|
||||||
const crossfilter = originalCrossfilter
|
|
||||||
.select(obsAnnoDimensionName("field1"), { mode: "range", lo: 0, hi: 5 })
|
|
||||||
.select(obsAnnoDimensionName("field3"), {
|
|
||||||
mode: "exact",
|
|
||||||
values: [false],
|
|
||||||
});
|
|
||||||
|
|
||||||
/* create the world from the selection */
|
|
||||||
const world = World.createWorldBySelection(
|
|
||||||
universe,
|
|
||||||
originalWorld,
|
|
||||||
crossfilter
|
|
||||||
);
|
|
||||||
expect(world).toBeDefined();
|
|
||||||
expect(world.nObs).toEqual(crossfilter.countSelected());
|
|
||||||
|
|
||||||
/*
|
|
||||||
calculate expected values and match against result
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* matchFilter must match the dimension filters above */
|
|
||||||
const matchFilter = (df, row) => {
|
|
||||||
const field1 = df.at(row, "field1");
|
|
||||||
const field3 = df.at(row, "field3");
|
|
||||||
return field1 >= 0 && field1 < 5 && !field3;
|
|
||||||
};
|
|
||||||
const matchingIndices = _()
|
|
||||||
.range(universe.nObs)
|
|
||||||
.filter((idx) => matchFilter(universe.obsAnnotations, idx))
|
|
||||||
.value();
|
|
||||||
|
|
||||||
expect(world).toMatchObject(
|
|
||||||
expect.objectContaining({
|
|
||||||
nObs: matchingIndices.length,
|
|
||||||
nVar: universe.nVar,
|
|
||||||
schema: universe.schema,
|
|
||||||
clipQuantiles: { min: 0, max: 1 },
|
|
||||||
obsAnnotations: expect.any(Dataframe.Dataframe),
|
|
||||||
varAnnotations: expect.any(Dataframe.Dataframe),
|
|
||||||
obsLayout: expect.any(Dataframe.Dataframe),
|
|
||||||
varData: expect.any(Dataframe.Dataframe),
|
|
||||||
unclipped: {
|
|
||||||
obsAnnotations: expect.any(Dataframe.Dataframe),
|
|
||||||
varData: expect.any(Dataframe.Dataframe),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(world.obsAnnotations.rowIndex.labels()).toEqual(
|
|
||||||
new Int32Array(matchingIndices)
|
|
||||||
);
|
|
||||||
expect(world.obsAnnotations.colIndex.labels()).toEqual(
|
|
||||||
universe.obsAnnotations.colIndex.labels()
|
|
||||||
);
|
|
||||||
expect(world.obsLayout.rowIndex.labels()).toEqual(
|
|
||||||
new Int32Array(matchingIndices)
|
|
||||||
);
|
|
||||||
expect(world.obsLayout.colIndex.labels()).toEqual(
|
|
||||||
world.schema.layout.obs[0].dims
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createObsDimensionMap", () => {
|
|
||||||
test("when universe eq world", () => {
|
|
||||||
/*
|
|
||||||
check for:
|
|
||||||
- creates a dimension for all obsAnnotations, PLUS X/Y layout
|
|
||||||
- check that dimension typing is sane
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { crossfilter } = defaultBigBang();
|
|
||||||
const annotationNames = _.map(
|
|
||||||
REST.schema.schema.annotations.obs.columns,
|
|
||||||
(c) => c.name
|
|
||||||
);
|
|
||||||
const obsIndexColName = REST.schema.schema.annotations.obs.index;
|
|
||||||
const schemaByObsName = _.keyBy(
|
|
||||||
REST.schema.schema.annotations.obs.columns,
|
|
||||||
"name"
|
|
||||||
);
|
|
||||||
expect(crossfilter).toBeDefined();
|
|
||||||
annotationNames.forEach((name) => {
|
|
||||||
const dim = crossfilter.dimensions[obsAnnoDimensionName(name)];
|
|
||||||
if (name === obsIndexColName) {
|
|
||||||
expect(dim).toBeUndefined();
|
|
||||||
} else {
|
|
||||||
const { type } = schemaByObsName[name];
|
|
||||||
if (type === "string" || type === "boolean" || type === "categorical") {
|
|
||||||
expect(dim.dim).toBeInstanceOf(DimTypes.enum);
|
|
||||||
} else {
|
|
||||||
expect(dim.dim).toBeInstanceOf(DimTypes.scalar);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
crossfilter.dimensions[layoutDimensionName("XY")].dim
|
|
||||||
).toBeInstanceOf(DimTypes.spatial);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("worldEqUniverse", () => {
|
|
||||||
const { universe, world } = defaultBigBang();
|
|
||||||
const result = World.worldEqUniverse(world, universe);
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
@@ -253,6 +253,11 @@ describe("ImmutableTypedCrossfilter", () => {
|
|||||||
p.select("quantity", { mode: "exact", values: v }).countSelected()
|
p.select("quantity", { mode: "exact", values: v }).countSelected()
|
||||||
).toEqual(_.filter(someData, (d) => v.includes(d.quantity)).length)
|
).toEqual(_.filter(someData, (d) => v.includes(d.quantity)).length)
|
||||||
);
|
);
|
||||||
|
test("single value exact", () => {
|
||||||
|
expect(
|
||||||
|
p.select("quantity", { mode: "exact", values: 2 }).countSelected()
|
||||||
|
).toEqual(_.filter(someData, (d) => d.quantity === 2).length);
|
||||||
|
});
|
||||||
test.each([
|
test.each([
|
||||||
[0, 1],
|
[0, 1],
|
||||||
[1, 2],
|
[1, 2],
|
||||||
@@ -295,6 +300,11 @@ describe("ImmutableTypedCrossfilter", () => {
|
|||||||
p.select("type", { mode: "exact", values: v }).countSelected()
|
p.select("type", { mode: "exact", values: v }).countSelected()
|
||||||
).toEqual(_.filter(someData, (d) => v.includes(d.type)).length)
|
).toEqual(_.filter(someData, (d) => v.includes(d.type)).length)
|
||||||
);
|
);
|
||||||
|
test("single value exact", () => {
|
||||||
|
expect(
|
||||||
|
p.select("type", { mode: "exact", values: "tab" }).countSelected()
|
||||||
|
).toEqual(_.filter(someData, (d) => d.type === "tab").length);
|
||||||
|
});
|
||||||
test("range", () => {
|
test("range", () => {
|
||||||
expect(() => p.select("type", { mode: "range", lo: 0, hi: 9 })).toThrow(
|
expect(() => p.select("type", { mode: "range", lo: 0, hi: 9 })).toThrow(
|
||||||
Error
|
Error
|
||||||
|
|||||||
@@ -2,9 +2,6 @@ import {
|
|||||||
sortArray,
|
sortArray,
|
||||||
sortIndex,
|
sortIndex,
|
||||||
lowerBound,
|
lowerBound,
|
||||||
upperBound,
|
|
||||||
lowerBoundIndirect,
|
|
||||||
upperBoundIndirect,
|
|
||||||
} from "../../../src/util/typedCrossfilter/sort";
|
} from "../../../src/util/typedCrossfilter/sort";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -68,7 +65,7 @@ describe("sortArray", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("non-finite numbers", () => {
|
describe("non-finite numbers", () => {
|
||||||
test("inifinity", () => {
|
test("infinity", () => {
|
||||||
expect(sortArray(new Float32Array([pInf, nInf, 0, 1, 2]))).toMatchObject(
|
expect(sortArray(new Float32Array([pInf, nInf, 0, 1, 2]))).toMatchObject(
|
||||||
new Float32Array([nInf, 0, 1, 2, pInf])
|
new Float32Array([nInf, 0, 1, 2, pInf])
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,23 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
root: true,
|
root: true,
|
||||||
parser: "babel-eslint",
|
parser: "babel-eslint",
|
||||||
extends: ["airbnb", "plugin:prettier/recommended", "prettier/react"],
|
extends: [
|
||||||
|
"airbnb",
|
||||||
|
"plugin:eslint-comments/recommended",
|
||||||
|
"plugin:prettier/recommended",
|
||||||
|
"prettier/react",
|
||||||
|
],
|
||||||
env: { browser: true, commonjs: true, es6: true },
|
env: { browser: true, commonjs: true, es6: true },
|
||||||
globals: { expect: true },
|
globals: {
|
||||||
|
expect: true,
|
||||||
|
jest: true,
|
||||||
|
jestPuppeteer: true,
|
||||||
|
it: true,
|
||||||
|
page: true,
|
||||||
|
browser: true,
|
||||||
|
context: true,
|
||||||
|
beforeEach: true,
|
||||||
|
},
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
ecmaVersion: 2017,
|
ecmaVersion: 2017,
|
||||||
sourceType: "module",
|
sourceType: "module",
|
||||||
@@ -13,6 +27,7 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
|
"eslint-comments/require-description": ["error"],
|
||||||
"no-magic-numbers": "off",
|
"no-magic-numbers": "off",
|
||||||
"no-nested-ternary": "off",
|
"no-nested-ternary": "off",
|
||||||
"func-style": "off",
|
"func-style": "off",
|
||||||
@@ -30,6 +45,13 @@ module.exports = {
|
|||||||
"space-before-function-paren": "off",
|
"space-before-function-paren": "off",
|
||||||
"function-paren-newline": "off",
|
"function-paren-newline": "off",
|
||||||
"prefer-destructuring": ["error", { object: true, array: false }],
|
"prefer-destructuring": ["error", { object: true, array: false }],
|
||||||
|
"import/prefer-default-export": "off",
|
||||||
|
"no-restricted-syntax": [
|
||||||
|
"error",
|
||||||
|
"ForInStatement",
|
||||||
|
"LabeledStatement",
|
||||||
|
"WithStatement",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
overrides: [
|
overrides: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
"./src/**/*.js": "eslint --fix",
|
"*.js": "eslint --fix",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* `client/jest-puppeteer.config.js` is for configuring Puppeteer's launch config options
|
||||||
|
* `client/__tests__/e2e/puppeteer.setup.js` is for configuring `jest`, `browser`,
|
||||||
|
* and `page` objects
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ENV_DEFAULT = require("../environment.default.json");
|
||||||
|
|
||||||
|
const jestEnv = process.env.JEST_ENV || ENV_DEFAULT.JEST_ENV;
|
||||||
|
const isHeadful =
|
||||||
|
process.env.HEADFUL === "true" || process.env.HEADLESS === "false";
|
||||||
|
|
||||||
|
const DEFAULT_LAUNCH_CONFIG = {
|
||||||
|
headless: !isHeadful,
|
||||||
|
args: ["--ignore-certificate-errors", "--ignore-ssl-errors"],
|
||||||
|
ignoreHTTPSErrors: true,
|
||||||
|
defaultViewport: {
|
||||||
|
width: 1280,
|
||||||
|
height: 960,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const LAUNCH_CONFIG_BY_ENV = {
|
||||||
|
[ENV_DEFAULT.DEBUG]: {
|
||||||
|
...DEFAULT_LAUNCH_CONFIG,
|
||||||
|
headless: false,
|
||||||
|
slowMo: 100,
|
||||||
|
devtools: true,
|
||||||
|
defaultViewport: {
|
||||||
|
width: DEFAULT_LAUNCH_CONFIG.defaultViewport.width,
|
||||||
|
height: DEFAULT_LAUNCH_CONFIG.defaultViewport.height + 560,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[ENV_DEFAULT.DEV]: {
|
||||||
|
...DEFAULT_LAUNCH_CONFIG,
|
||||||
|
headless: false,
|
||||||
|
slowMo: 5,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const launchConfig = LAUNCH_CONFIG_BY_ENV[jestEnv] || DEFAULT_LAUNCH_CONFIG;
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
browserContext: "incognito",
|
||||||
|
launch: launchConfig,
|
||||||
|
};
|
||||||
Generated
+5908
-4532
File diff suppressed because it is too large
Load Diff
+58
-49
@@ -5,12 +5,16 @@
|
|||||||
"description": "cellxgene is a web application for the interactive exploration of single cell sequence data.",
|
"description": "cellxgene is a web application for the interactive exploration of single cell sequence data.",
|
||||||
"repository": "https://github.com/chanzuckerberg/cellxgene",
|
"repository": "https://github.com/chanzuckerberg/cellxgene",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf build",
|
|
||||||
"build": "npm run clean && webpack --config",
|
"build": "npm run clean && webpack --config",
|
||||||
"prod": "npm run build -- configuration/webpack/webpack.config.prod.js",
|
"clean": "rimraf build",
|
||||||
"dev": "npm run build -- configuration/webpack/webpack.config.dev.js",
|
"dev": "npm run build -- configuration/webpack/webpack.config.dev.js",
|
||||||
"fmt": "eslint --fix src",
|
"e2e": "jest --config __tests__/e2e/e2eJestConfig.json e2e/e2e.test.js",
|
||||||
"lint": "eslint src"
|
"e2e-annotations": "jest --config __tests__/e2e/e2eJestConfig.json e2e/e2eAnnotations.test.js",
|
||||||
|
"e2e-prod": "CXG_URL_BASE='https://cellxgene.cziscience.com/d/pbmc3k.cxg/' jest --config __tests__/e2e/e2eJestConfig.json e2e/e2e.test.js",
|
||||||
|
"fmt": "eslint --fix src __tests__",
|
||||||
|
"lint": "eslint --fix src __tests__",
|
||||||
|
"prod": "npm run build -- configuration/webpack/webpack.config.prod.js",
|
||||||
|
"test": "jest --testPathIgnorePatterns e2e"
|
||||||
},
|
},
|
||||||
"engineStrict": true,
|
"engineStrict": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -26,9 +30,9 @@
|
|||||||
"eslint-scope": "3.7.1"
|
"eslint-scope": "3.7.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@blueprintjs/core": "^3.24.0",
|
"@blueprintjs/core": "^3.30.0",
|
||||||
"@blueprintjs/icons": "^3.14.0",
|
"@blueprintjs/icons": "^3.19.0",
|
||||||
"@blueprintjs/select": "^3.12.0",
|
"@blueprintjs/select": "^3.13.5",
|
||||||
"d3": "^4.10.0",
|
"d3": "^4.10.0",
|
||||||
"d3-scale-chromatic": "^1.5.0",
|
"d3-scale-chromatic": "^1.5.0",
|
||||||
"flatbuffers": "^1.11.0",
|
"flatbuffers": "^1.11.0",
|
||||||
@@ -37,78 +41,83 @@
|
|||||||
"gl-matrix": "^3.3.0",
|
"gl-matrix": "^3.3.0",
|
||||||
"gl-vec3": "^1.1.3",
|
"gl-vec3": "^1.1.3",
|
||||||
"is-number": "^7.0.0",
|
"is-number": "^7.0.0",
|
||||||
"lodash": "^4.17.15",
|
"lodash": "^4.17.19",
|
||||||
"memoize-one": "^5.1.1",
|
"memoize-one": "^5.1.1",
|
||||||
"react": "^16.13.1",
|
"react": "^16.13.1",
|
||||||
|
"react-async": "^10.0.1",
|
||||||
"react-dom": "^16.13.1",
|
"react-dom": "^16.13.1",
|
||||||
"react-flip-toolkit": "7.0.6",
|
"react-flip-toolkit": "^7.0.12",
|
||||||
"react-helmet": "^5.2.1",
|
"react-helmet": "^5.2.1",
|
||||||
"react-icons": "^3.9.0",
|
"react-icons": "^3.10.0",
|
||||||
"react-redux": "^7.2.0",
|
"react-redux": "^7.2.0",
|
||||||
"redux": "^4.0.5",
|
"redux": "^4.0.5",
|
||||||
"redux-thunk": "^2.3.0",
|
"redux-thunk": "^2.3.0",
|
||||||
"regl": "^1.4.0"
|
"regl": "^1.6.1",
|
||||||
|
"tinyqueue": "^2.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.9.0",
|
"@babel/core": "^7.10.5",
|
||||||
"@babel/plugin-proposal-class-properties": "^7.8.3",
|
"@babel/plugin-proposal-class-properties": "^7.10.4",
|
||||||
"@babel/plugin-proposal-decorators": "^7.8.3",
|
"@babel/plugin-proposal-decorators": "^7.10.5",
|
||||||
"@babel/plugin-proposal-export-namespace-from": "^7.8.3",
|
"@babel/plugin-proposal-export-namespace-from": "^7.10.4",
|
||||||
"@babel/plugin-proposal-function-bind": "^7.8.3",
|
"@babel/plugin-proposal-function-bind": "^7.10.5",
|
||||||
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.8.3",
|
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.10.4",
|
||||||
"@babel/plugin-proposal-optional-chaining": "^7.9.0",
|
"@babel/plugin-proposal-optional-chaining": "^7.10.4",
|
||||||
"@babel/plugin-transform-react-constant-elements": "^7.9.0",
|
"@babel/plugin-transform-react-constant-elements": "^7.10.4",
|
||||||
"@babel/plugin-transform-runtime": "^7.9.0",
|
"@babel/plugin-transform-runtime": "^7.10.5",
|
||||||
"@babel/preset-env": "^7.9.0",
|
"@babel/preset-env": "^7.10.4",
|
||||||
"@babel/preset-react": "^7.9.4",
|
"@babel/preset-react": "^7.10.4",
|
||||||
"@babel/register": "^7.9.0",
|
"@babel/register": "^7.10.5",
|
||||||
"@babel/runtime": "^7.9.2",
|
"@babel/runtime": "^7.10.5",
|
||||||
"@sentry/webpack-plugin": "^1.11.1",
|
"@sentry/webpack-plugin": "^1.12.0",
|
||||||
"babel-eslint": "^10.1.0",
|
"babel-eslint": "^10.1.0",
|
||||||
"babel-jest": "^25.2.6",
|
"babel-jest": "^26.1.0",
|
||||||
"babel-loader": "^8.1.0",
|
"babel-loader": "^8.1.0",
|
||||||
"babel-preset-modern-browsers": "^14.2.1",
|
"babel-preset-modern-browsers": "^14.2.1",
|
||||||
"chalk": "^4.0.0",
|
"chalk": "^4.1.0",
|
||||||
"cheerio": "^1.0.0-rc.3",
|
"cheerio": "^1.0.0-rc.3",
|
||||||
"clean-css": "^4.2.3",
|
"clean-css": "^4.2.3",
|
||||||
"clean-webpack-plugin": "^3.0.0",
|
"clean-webpack-plugin": "^3.0.0",
|
||||||
"codecov": "^3.6.5",
|
"codecov": "^3.7.0",
|
||||||
"connect-history-api-fallback": "^1.6.0",
|
"connect-history-api-fallback": "^1.6.0",
|
||||||
"copy-webpack-plugin": "^5.1.1",
|
"css-loader": "^3.6.0",
|
||||||
"css-loader": "^3.4.2",
|
"eslint": "^7.4.0",
|
||||||
"eslint": "^6.8.0",
|
"eslint-config-airbnb": "^18.2.0",
|
||||||
"eslint-config-airbnb": "^18.0.1",
|
"eslint-config-prettier": "^6.11.0",
|
||||||
"eslint-config-prettier": "^6.10.1",
|
|
||||||
"eslint-loader": "^3.0.4",
|
"eslint-loader": "^3.0.4",
|
||||||
|
"eslint-plugin-eslint-comments": "^3.2.0",
|
||||||
"eslint-plugin-filenames": "^1.3.2",
|
"eslint-plugin-filenames": "^1.3.2",
|
||||||
"eslint-plugin-import": "^2.20.2",
|
"eslint-plugin-import": "^2.22.0",
|
||||||
"eslint-plugin-jest": "^23.8.2",
|
"eslint-plugin-jest": "^23.18.0",
|
||||||
"eslint-plugin-jsx-a11y": "^6.2.3",
|
"eslint-plugin-jsx-a11y": "^6.3.1",
|
||||||
"eslint-plugin-prettier": "^3.1.3",
|
"eslint-plugin-prettier": "^3.1.4",
|
||||||
"eslint-plugin-react": "^7.19.0",
|
"eslint-plugin-react": "^7.20.3",
|
||||||
"eslint-plugin-react-hooks": "^2.5.1",
|
"eslint-plugin-react-hooks": "^4.0.8",
|
||||||
|
"expect-puppeteer": "^4.4.0",
|
||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
"favicons-webpack-plugin": "^3.0.1",
|
"favicons-webpack-plugin": "^3.0.1",
|
||||||
"file-loader": "^6.0.0",
|
"file-loader": "^6.0.0",
|
||||||
"html-webpack-inline-source-plugin": "^1.0.0-beta.2",
|
"html-webpack-plugin": "^4.3.0",
|
||||||
"html-webpack-plugin": "^4.0.0",
|
|
||||||
"husky": "^4.2.5",
|
"husky": "^4.2.5",
|
||||||
"jest": "^25.2.7",
|
"jest": "^26.1.0",
|
||||||
|
"jest-circus": "^26.1.0",
|
||||||
|
"jest-environment-puppeteer": "^4.4.0",
|
||||||
|
"jest-fetch-mock": "^3.0.3",
|
||||||
"jest-puppeteer": "^4.4.0",
|
"jest-puppeteer": "^4.4.0",
|
||||||
"json-loader": "^0.5.7",
|
"json-loader": "^0.5.7",
|
||||||
"lint-staged": "^10.2.4",
|
"lint-staged": "^10.2.11",
|
||||||
"mini-css-extract-plugin": "^0.9.0",
|
"mini-css-extract-plugin": "^0.9.0",
|
||||||
"optimize-css-assets-webpack-plugin": "^5.0.3",
|
"optimize-css-assets-webpack-plugin": "^5.0.3",
|
||||||
"prettier": "^2.0.5",
|
"prettier": "^2.0.5",
|
||||||
"puppeteer": "^2.1.1",
|
"puppeteer": "^3.3.0",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"serve-favicon": "^2.5.0",
|
"serve-favicon": "^2.5.0",
|
||||||
"style-loader": "^1.1.3",
|
"style-loader": "^1.2.1",
|
||||||
"sw-precache-webpack-plugin": "^1.0.0",
|
"sw-precache-webpack-plugin": "^1.0.0",
|
||||||
"terser-webpack-plugin": "^2.3.6",
|
"terser-webpack-plugin": "^3.0.7",
|
||||||
"url-loader": "^4.0.0",
|
"url-loader": "^4.1.0",
|
||||||
"webpack": "^4.42.1",
|
"webpack": "^4.43.0",
|
||||||
"webpack-cli": "^3.3.11",
|
"webpack-cli": "^3.3.12",
|
||||||
"webpack-dev-middleware": "^3.7.2"
|
"webpack-dev-middleware": "^3.7.2"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
/*
|
||||||
|
Action creators for user annotation
|
||||||
|
*/
|
||||||
|
import _ from "lodash";
|
||||||
|
import * as globals from "../globals";
|
||||||
|
import { MatrixFBS, AnnotationsHelpers } from "../util/stateManager";
|
||||||
|
|
||||||
|
const { isUserAnnotation } = AnnotationsHelpers;
|
||||||
|
|
||||||
|
export const annotationCreateCategoryAction = (
|
||||||
|
newCategoryName,
|
||||||
|
categoryToDuplicate
|
||||||
|
) => async (dispatch, getState) => {
|
||||||
|
/*
|
||||||
|
Add a new user-created category to the obs annotations.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
newCategoryName - string name for the category.
|
||||||
|
categoryToDuplicate - obs category to use for initial values, or null.
|
||||||
|
*/
|
||||||
|
const {
|
||||||
|
annoMatrix: prevAnnoMatrix,
|
||||||
|
obsCrossfilter: prevObsCrossfilter,
|
||||||
|
} = getState();
|
||||||
|
if (!prevAnnoMatrix || !prevObsCrossfilter) return;
|
||||||
|
const { schema } = prevAnnoMatrix;
|
||||||
|
|
||||||
|
/* name must be a string, non-zero length */
|
||||||
|
if (typeof newCategoryName !== "string" || newCategoryName.length === 0)
|
||||||
|
throw new Error("user annotations require string name");
|
||||||
|
|
||||||
|
/* ensure the name isn't already in use! */
|
||||||
|
if (schema.annotations.obsByName[newCategoryName])
|
||||||
|
throw new Error("name collision on annotation category create");
|
||||||
|
|
||||||
|
let initialValue;
|
||||||
|
let categories;
|
||||||
|
if (categoryToDuplicate) {
|
||||||
|
/* if we are duplicating a category, retrieve it */
|
||||||
|
const catDupSchema = schema.annotations.obsByName[categoryToDuplicate];
|
||||||
|
const catDupType = catDupSchema?.type;
|
||||||
|
if (catDupType !== "string" && catDupType !== "categorical")
|
||||||
|
throw new Error("categoryToDuplicate does not exist or has invalid type");
|
||||||
|
|
||||||
|
const catToDupDf = await prevAnnoMatrix
|
||||||
|
.base()
|
||||||
|
.fetch("obs", categoryToDuplicate);
|
||||||
|
const col = catToDupDf.col(categoryToDuplicate);
|
||||||
|
initialValue = col.asArray();
|
||||||
|
({ categories } = col.summarize());
|
||||||
|
// all user-created annotations must have the unassigned category
|
||||||
|
if (!categories.includes(globals.unassignedCategoryLabel)) {
|
||||||
|
categories.push(globals.unassignedCategoryLabel);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
/* else assign to the standard default value */
|
||||||
|
initialValue = globals.unassignedCategoryLabel;
|
||||||
|
categories = [globals.unassignedCategoryLabel];
|
||||||
|
}
|
||||||
|
|
||||||
|
const obsCrossfilter = prevObsCrossfilter.addObsColumn(
|
||||||
|
{
|
||||||
|
name: newCategoryName,
|
||||||
|
type: "categorical",
|
||||||
|
categories,
|
||||||
|
writable: true,
|
||||||
|
},
|
||||||
|
Array,
|
||||||
|
initialValue
|
||||||
|
);
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "annotation: create category",
|
||||||
|
data: newCategoryName,
|
||||||
|
categoryToDuplicate,
|
||||||
|
annoMatrix: obsCrossfilter.annoMatrix,
|
||||||
|
obsCrossfilter,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const annotationRenameCategoryAction = (
|
||||||
|
oldCategoryName,
|
||||||
|
newCategoryName
|
||||||
|
) => (dispatch, getState) => {
|
||||||
|
/*
|
||||||
|
Rename a user-created annotation category
|
||||||
|
*/
|
||||||
|
const {
|
||||||
|
annoMatrix: prevAnnoMatrix,
|
||||||
|
obsCrossfilter: prevObsCrossfilter,
|
||||||
|
} = getState();
|
||||||
|
if (!prevAnnoMatrix || !prevObsCrossfilter) return;
|
||||||
|
if (!isUserAnnotation(prevAnnoMatrix, oldCategoryName))
|
||||||
|
throw new Error("not a user annotation");
|
||||||
|
|
||||||
|
/* name must be a string, non-zero length */
|
||||||
|
if (typeof newCategoryName !== "string" || newCategoryName.length === 0)
|
||||||
|
throw new Error("user annotations require string name");
|
||||||
|
|
||||||
|
if (oldCategoryName === newCategoryName) return;
|
||||||
|
|
||||||
|
const obsCrossfilter = prevObsCrossfilter.renameObsColumn(
|
||||||
|
oldCategoryName,
|
||||||
|
newCategoryName
|
||||||
|
);
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "annotation: category edited",
|
||||||
|
annoMatrix: obsCrossfilter.annoMatrix,
|
||||||
|
obsCrossfilter,
|
||||||
|
metadataField: oldCategoryName,
|
||||||
|
newCategoryText: newCategoryName,
|
||||||
|
data: newCategoryName,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const annotationDeleteCategoryAction = (categoryName) => (
|
||||||
|
dispatch,
|
||||||
|
getState
|
||||||
|
) => {
|
||||||
|
/*
|
||||||
|
Delete a user-created category
|
||||||
|
*/
|
||||||
|
const {
|
||||||
|
annoMatrix: prevAnnoMatrix,
|
||||||
|
obsCrossfilter: prevObsCrossfilter,
|
||||||
|
} = getState();
|
||||||
|
if (!prevAnnoMatrix || !prevObsCrossfilter) return;
|
||||||
|
if (!isUserAnnotation(prevAnnoMatrix, categoryName))
|
||||||
|
throw new Error("not a user annotation");
|
||||||
|
|
||||||
|
const obsCrossfilter = prevObsCrossfilter.dropObsColumn(categoryName);
|
||||||
|
dispatch({
|
||||||
|
type: "annotation: delete category",
|
||||||
|
annoMatrix: obsCrossfilter.annoMatrix,
|
||||||
|
obsCrossfilter,
|
||||||
|
metadataField: categoryName,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const annotationCreateLabelInCategory = (
|
||||||
|
categoryName,
|
||||||
|
labelName,
|
||||||
|
assignSelected
|
||||||
|
) => async (dispatch, getState) => {
|
||||||
|
/*
|
||||||
|
Add a new label to a user-defined category. If assignSelected is true, assign
|
||||||
|
the label to all currently selected cells.
|
||||||
|
*/
|
||||||
|
const {
|
||||||
|
annoMatrix: prevAnnoMatrix,
|
||||||
|
obsCrossfilter: prevObsCrossfilter,
|
||||||
|
} = getState();
|
||||||
|
if (!prevAnnoMatrix || !prevObsCrossfilter) return;
|
||||||
|
if (!isUserAnnotation(prevAnnoMatrix, categoryName))
|
||||||
|
throw new Error("not a user annotation");
|
||||||
|
|
||||||
|
let obsCrossfilter = prevObsCrossfilter.addObsAnnoCategory(
|
||||||
|
categoryName,
|
||||||
|
labelName
|
||||||
|
);
|
||||||
|
if (assignSelected) {
|
||||||
|
obsCrossfilter = await obsCrossfilter.setObsColumnValues(
|
||||||
|
categoryName,
|
||||||
|
prevObsCrossfilter.allSelectedLabels(),
|
||||||
|
labelName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "annotation: add new label to category",
|
||||||
|
annoMatrix: obsCrossfilter.annoMatrix,
|
||||||
|
obsCrossfilter,
|
||||||
|
metadataField: categoryName,
|
||||||
|
newLabelText: labelName,
|
||||||
|
assignSelectedCells: assignSelected,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const annotationDeleteLabelFromCategory = (
|
||||||
|
categoryName,
|
||||||
|
labelName
|
||||||
|
) => async (dispatch, getState) => {
|
||||||
|
/*
|
||||||
|
delete a label from a user-defined category
|
||||||
|
*/
|
||||||
|
const {
|
||||||
|
annoMatrix: prevAnnoMatrix,
|
||||||
|
obsCrossfilter: prevObsCrossfilter,
|
||||||
|
} = getState();
|
||||||
|
if (!prevAnnoMatrix || !prevObsCrossfilter) return;
|
||||||
|
if (!isUserAnnotation(prevAnnoMatrix, categoryName))
|
||||||
|
throw new Error("not a user annotation");
|
||||||
|
|
||||||
|
const obsCrossfilter = await prevObsCrossfilter.removeObsAnnoCategory(
|
||||||
|
categoryName,
|
||||||
|
labelName,
|
||||||
|
globals.unassignedCategoryLabel
|
||||||
|
);
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "annotation: delete label",
|
||||||
|
metadataField: categoryName,
|
||||||
|
label: labelName,
|
||||||
|
annoMatrix: obsCrossfilter.annoMatrix,
|
||||||
|
obsCrossfilter,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const annotationRenameLabelInCategory = (
|
||||||
|
categoryName,
|
||||||
|
oldLabelName,
|
||||||
|
newLabelName
|
||||||
|
) => async (dispatch, getState) => {
|
||||||
|
/*
|
||||||
|
label name change
|
||||||
|
*/
|
||||||
|
const {
|
||||||
|
annoMatrix: prevAnnoMatrix,
|
||||||
|
obsCrossfilter: prevObsCrossfilter,
|
||||||
|
} = getState();
|
||||||
|
if (!prevAnnoMatrix || !prevObsCrossfilter) return;
|
||||||
|
if (!isUserAnnotation(prevAnnoMatrix, categoryName))
|
||||||
|
throw new Error("not a user annotation");
|
||||||
|
|
||||||
|
let obsCrossfilter = await prevObsCrossfilter.resetObsColumnValues(
|
||||||
|
categoryName,
|
||||||
|
oldLabelName,
|
||||||
|
newLabelName
|
||||||
|
);
|
||||||
|
obsCrossfilter = await obsCrossfilter.removeObsAnnoCategory(
|
||||||
|
categoryName,
|
||||||
|
oldLabelName,
|
||||||
|
globals.unassignedCategoryLabel
|
||||||
|
);
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "annotation: label edited",
|
||||||
|
editedLabel: newLabelName,
|
||||||
|
metadataField: categoryName,
|
||||||
|
label: oldLabelName,
|
||||||
|
annoMatrix: obsCrossfilter.annoMatrix,
|
||||||
|
obsCrossfilter,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const annotationLabelCurrentSelection = (
|
||||||
|
categoryName,
|
||||||
|
labelName
|
||||||
|
) => async (dispatch, getState) => {
|
||||||
|
/*
|
||||||
|
set the label on all currently selected
|
||||||
|
*/
|
||||||
|
const {
|
||||||
|
annoMatrix: prevAnnoMatrix,
|
||||||
|
obsCrossfilter: prevObsCrossfilter,
|
||||||
|
} = getState();
|
||||||
|
if (!prevAnnoMatrix || !prevObsCrossfilter) return;
|
||||||
|
if (!isUserAnnotation(prevAnnoMatrix, categoryName))
|
||||||
|
throw new Error("not a user annotation");
|
||||||
|
|
||||||
|
const obsCrossfilter = await prevObsCrossfilter.setObsColumnValues(
|
||||||
|
categoryName,
|
||||||
|
prevObsCrossfilter.allSelectedLabels(),
|
||||||
|
labelName
|
||||||
|
);
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "annotation: label current cell selection",
|
||||||
|
metadataField: categoryName,
|
||||||
|
label: labelName,
|
||||||
|
obsCrossfilter,
|
||||||
|
annoMatrix: obsCrossfilter.annoMatrix,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function writableAnnotations(annoMatrix) {
|
||||||
|
return annoMatrix.schema.annotations.obs.columns
|
||||||
|
.filter((s) => s.writable)
|
||||||
|
.map((s) => s.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const needToSaveObsAnnotations = (annoMatrix, lastSavedAnnoMatrix) => {
|
||||||
|
/*
|
||||||
|
Return true if there are LIKELY user-defined annotation modifications between the two
|
||||||
|
annoMatrices. Technically not an action creator, but intimately intertwined
|
||||||
|
with the save process.
|
||||||
|
|
||||||
|
Two conditions will trigger a need to save:
|
||||||
|
* the collection of user-defined columns have changed
|
||||||
|
* the contents of the user-defined columns have change
|
||||||
|
*/
|
||||||
|
|
||||||
|
annoMatrix = annoMatrix.base();
|
||||||
|
|
||||||
|
// if the annoMatrix hasn't changed, we are guaranteed no changes to the matrix schema or contents.
|
||||||
|
if (annoMatrix === lastSavedAnnoMatrix) return false;
|
||||||
|
|
||||||
|
// if the schema has changed, we need to save
|
||||||
|
const currentWritable = writableAnnotations(annoMatrix);
|
||||||
|
if (_.difference(currentWritable, writableAnnotations(lastSavedAnnoMatrix))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// no schema changes; check for change in contents
|
||||||
|
return currentWritable.some(
|
||||||
|
(col) => annoMatrix.col(col) !== lastSavedAnnoMatrix.col(col)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveObsAnnotationsAction = () => async (dispatch, getState) => {
|
||||||
|
/*
|
||||||
|
Save the user-created obs annotations IF any have changed.
|
||||||
|
*/
|
||||||
|
const state = getState();
|
||||||
|
const { annotations, autosave } = state;
|
||||||
|
const { dataCollectionNameIsReadOnly, dataCollectionName } = annotations;
|
||||||
|
const { lastSavedAnnoMatrix, saveInProgress } = autosave;
|
||||||
|
|
||||||
|
const annoMatrix = state.annoMatrix.base();
|
||||||
|
|
||||||
|
if (saveInProgress || annoMatrix === lastSavedAnnoMatrix) return;
|
||||||
|
if (!needToSaveObsAnnotations(annoMatrix, lastSavedAnnoMatrix)) {
|
||||||
|
dispatch({
|
||||||
|
type: "writable obs annotations - save complete",
|
||||||
|
lastSavedAnnoMatrix: annoMatrix,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Else, we really do need to save
|
||||||
|
*/
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "writable obs annotations - save started",
|
||||||
|
});
|
||||||
|
|
||||||
|
const df = await annoMatrix.fetch("obs", writableAnnotations(annoMatrix));
|
||||||
|
const matrix = MatrixFBS.encodeMatrixFBS(df);
|
||||||
|
try {
|
||||||
|
const queryString =
|
||||||
|
!dataCollectionNameIsReadOnly && !!dataCollectionName
|
||||||
|
? `?annotation-collection-name=${encodeURIComponent(
|
||||||
|
dataCollectionName
|
||||||
|
)}`
|
||||||
|
: "";
|
||||||
|
const res = await fetch(
|
||||||
|
`${globals.API.prefix}${globals.API.version}annotations/obs${queryString}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
body: matrix,
|
||||||
|
headers: new Headers({
|
||||||
|
"Content-Type": "application/octet-stream",
|
||||||
|
}),
|
||||||
|
credentials: "include",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (res.ok) {
|
||||||
|
dispatch({
|
||||||
|
type: "writable obs annotations - save complete",
|
||||||
|
lastSavedAnnoMatrix: annoMatrix,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
dispatch({
|
||||||
|
type: "writable obs annotations - save error",
|
||||||
|
message: `HTTP error ${res.status} - ${res.statusText}`,
|
||||||
|
res,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
dispatch({
|
||||||
|
type: "writable obs annotations - save error",
|
||||||
|
message: error.toString(),
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
+72
-318
@@ -1,93 +1,17 @@
|
|||||||
import * as globals from "../globals";
|
import * as globals from "../globals";
|
||||||
import { Universe, MatrixFBS } from "../util/stateManager";
|
import { AnnoMatrixLoader, AnnoMatrixObsCrossfilter } from "../annoMatrix";
|
||||||
import * as Dataframe from "../util/dataframe";
|
|
||||||
import {
|
import {
|
||||||
catchErrorsWrap,
|
catchErrorsWrap,
|
||||||
doJsonRequest,
|
doJsonRequest,
|
||||||
doBinaryRequest,
|
|
||||||
dispatchNetworkErrorMessageToUser,
|
dispatchNetworkErrorMessageToUser,
|
||||||
} from "../util/actionHelpers";
|
} from "../util/actionHelpers";
|
||||||
import PromiseLimit from "../util/promiseLimit";
|
import {
|
||||||
import { requestReembed, reembedResetWorldToUniverse } from "./reembed";
|
requestReembed /* , reembedResetWorldToUniverse -- disabled temporarily, TODO issue #1606 */,
|
||||||
|
} from "./reembed";
|
||||||
import { loadUserColorConfig } from "../util/stateManager/colorHelpers";
|
import { loadUserColorConfig } from "../util/stateManager/colorHelpers";
|
||||||
|
import * as selnActions from "./selection";
|
||||||
/*
|
import * as annoActions from "./annotation";
|
||||||
return promise to fetch the OBS annotations we need to load. Omit anything
|
import * as viewActions from "./viewStack";
|
||||||
we don't need.
|
|
||||||
*/
|
|
||||||
async function obsAnnotationFetchAndLoad(dispatch, schema) {
|
|
||||||
const obsAnnotations = schema?.schema?.annotations?.obs ?? {};
|
|
||||||
const index = obsAnnotations.index ?? false;
|
|
||||||
const columns = (obsAnnotations.columns ?? []).filter(
|
|
||||||
(col) => col.name !== index
|
|
||||||
);
|
|
||||||
|
|
||||||
const plimit = new PromiseLimit(5);
|
|
||||||
return Promise.all(
|
|
||||||
columns.map((col) =>
|
|
||||||
plimit.add(() =>
|
|
||||||
fetchBinary(
|
|
||||||
`annotations/obs?annotation-name=${encodeURIComponent(col.name)}`
|
|
||||||
)
|
|
||||||
.then((buffer) => MatrixFBS.matrixFBSToDataframe(buffer))
|
|
||||||
.then((df) =>
|
|
||||||
dispatch({
|
|
||||||
type: "universe: column load success",
|
|
||||||
dim: "obsAnnotations",
|
|
||||||
dataframe: df,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
return promise fetching VAR annotations we need to load. Only index is currently used.
|
|
||||||
*/
|
|
||||||
async function varAnnotationFetchAndLoad(dispatch, schema) {
|
|
||||||
const varAnnotations = schema?.schema?.annotations?.var ?? {};
|
|
||||||
const index = varAnnotations.index ?? false;
|
|
||||||
const names = index ? [index] : [];
|
|
||||||
return Promise.all(
|
|
||||||
names.map((name) =>
|
|
||||||
fetchBinary(`annotations/var?annotation-name=${encodeURIComponent(name)}`)
|
|
||||||
.then((buffer) => MatrixFBS.matrixFBSToDataframe(buffer))
|
|
||||||
.then((df) =>
|
|
||||||
dispatch({
|
|
||||||
type: "universe: column load success",
|
|
||||||
dim: "varAnnotations",
|
|
||||||
dataframe: df,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
return promise fetching layout we need
|
|
||||||
*/
|
|
||||||
function layoutFetchAndLoad(dispatch, schema) {
|
|
||||||
const embeddings = schema?.schema?.layout?.obs ?? [];
|
|
||||||
const embNames = embeddings.map((e) => e.name);
|
|
||||||
|
|
||||||
const plimit = new PromiseLimit(5);
|
|
||||||
return Promise.all(
|
|
||||||
embNames.map((e) =>
|
|
||||||
plimit.add(() =>
|
|
||||||
fetchBinary(
|
|
||||||
`layout/obs?layout-name=${encodeURIComponent(e)}`
|
|
||||||
).then((buffer) => MatrixFBS.matrixFBSToDataframe(buffer))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).then((dfs) =>
|
|
||||||
dispatch({
|
|
||||||
type: "universe: column load success",
|
|
||||||
dim: "obsLayout",
|
|
||||||
dataframe: Dataframe.Dataframe.empty().withColsFromAll(dfs),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
return promise fetching user-configured colors
|
return promise fetching user-configured colors
|
||||||
@@ -101,178 +25,62 @@ async function userColorsFetchAndLoad(dispatch) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function schemaFetch() {
|
||||||
|
return fetchJson("schema");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function configFetch(dispatch) {
|
||||||
|
return fetchJson("config").then((response) => {
|
||||||
|
const config = { ...globals.configDefaults, ...response.config };
|
||||||
|
dispatch({
|
||||||
|
type: "configuration load complete",
|
||||||
|
config,
|
||||||
|
});
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Bootstrap application with the initial data loading.
|
Application bootstrap
|
||||||
* /config - application configuration
|
|
||||||
* /schema - schema of dataframe
|
|
||||||
* /annotations - all metadata annotation
|
|
||||||
* /layout - all default layout
|
|
||||||
*/
|
*/
|
||||||
const doInitialDataLoad = () =>
|
const doInitialDataLoad = () =>
|
||||||
catchErrorsWrap(async (dispatch) => {
|
catchErrorsWrap(async (dispatch) => {
|
||||||
dispatch({ type: "initial data load start" });
|
dispatch({ type: "initial data load start" });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
/*
|
const [, schema] = await Promise.all([
|
||||||
Step 1 - config & schema, all JSON
|
configFetch(dispatch),
|
||||||
*/
|
schemaFetch(dispatch),
|
||||||
const requestJson = ["config", "schema"].map(fetchJson);
|
|
||||||
const [responseConfig, schema] = await Promise.all(requestJson);
|
|
||||||
/* set config defaults */
|
|
||||||
const config = { ...globals.configDefaults, ...responseConfig.config };
|
|
||||||
const universe = Universe.createUniverseFromResponse(config, schema);
|
|
||||||
dispatch({
|
|
||||||
type: "universe exists, but loading is still in progress",
|
|
||||||
universe,
|
|
||||||
});
|
|
||||||
dispatch({
|
|
||||||
type: "configuration load complete",
|
|
||||||
config,
|
|
||||||
});
|
|
||||||
|
|
||||||
/*
|
|
||||||
Step 2 - load the minimum stuff required to display.
|
|
||||||
*/
|
|
||||||
await Promise.all([
|
|
||||||
userColorsFetchAndLoad(dispatch),
|
userColorsFetchAndLoad(dispatch),
|
||||||
layoutFetchAndLoad(dispatch, schema),
|
|
||||||
varAnnotationFetchAndLoad(dispatch, schema),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/*
|
const baseDataUrl = `${globals.API.prefix}${globals.API.version}`;
|
||||||
Step 3 - load everything else
|
const annoMatrix = new AnnoMatrixLoader(baseDataUrl, schema.schema);
|
||||||
*/
|
const obsCrossfilter = new AnnoMatrixObsCrossfilter(annoMatrix);
|
||||||
await obsAnnotationFetchAndLoad(dispatch, schema);
|
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "initial data load complete (universe exists)",
|
type: "annoMatrix: init complete",
|
||||||
universe,
|
annoMatrix,
|
||||||
|
obsCrossfilter,
|
||||||
});
|
});
|
||||||
|
dispatch({ type: "initial data load complete" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
dispatch({ type: "initial data load error", error });
|
dispatch({ type: "initial data load error", error });
|
||||||
}
|
}
|
||||||
}, true);
|
}, true);
|
||||||
|
|
||||||
/*
|
|
||||||
Set the view (world) to current selection. Placeholder for an async action
|
|
||||||
which also does re-layout.
|
|
||||||
*/
|
|
||||||
const setWorldToSelection = () => (dispatch, getState) => {
|
|
||||||
const { universe, world, crossfilter } = getState();
|
|
||||||
dispatch({
|
|
||||||
type: "set World to current selection",
|
|
||||||
universe,
|
|
||||||
world,
|
|
||||||
crossfilter,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/* double URI encode - needed for query-param filters */
|
|
||||||
function dubEncURIComponent(s) {
|
|
||||||
return encodeURIComponent(encodeURIComponent(s));
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
Fetch expression vectors for each gene in genes. This is NOT an action
|
|
||||||
function, but rather a helper to be called from an action helper that
|
|
||||||
needs expression data.
|
|
||||||
|
|
||||||
Transparently utilizes cached data if it is already present.
|
|
||||||
*/
|
|
||||||
async function _doRequestExpressionData(dispatch, getState, genes) {
|
|
||||||
const state = getState();
|
|
||||||
const { universe } = state;
|
|
||||||
const varIndexName = universe.schema.annotations.var.index;
|
|
||||||
|
|
||||||
/* helper for this function only */
|
|
||||||
const fetchData = async (geneNames) => {
|
|
||||||
const query = geneNames
|
|
||||||
.map(
|
|
||||||
(g) =>
|
|
||||||
`var:${dubEncURIComponent(varIndexName)}=${dubEncURIComponent(g)}`
|
|
||||||
)
|
|
||||||
.join("&");
|
|
||||||
// TODO: why convert to an Object and not a Dataframe?
|
|
||||||
return fetchBinary(`data/var?${query}`).then((buffer) =>
|
|
||||||
Universe.convertDataFBStoObject(universe, buffer)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* preload data already in cache */
|
|
||||||
let expressionData = genes.reduce((acc, g) => {
|
|
||||||
const data = universe.varData.col(g);
|
|
||||||
if (data) {
|
|
||||||
acc[g] = data.asArray();
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, {}); // --> { gene: data }
|
|
||||||
|
|
||||||
/* make a list of genes for which we do not have data */
|
|
||||||
const genesToFetch = genes.filter((g) => expressionData[g] === undefined);
|
|
||||||
|
|
||||||
dispatch({ type: "expression load start" });
|
|
||||||
|
|
||||||
/* Fetch data for any genes not in cache */
|
|
||||||
if (genesToFetch.length) {
|
|
||||||
try {
|
|
||||||
const newExpressionData = await fetchData(genesToFetch);
|
|
||||||
expressionData = {
|
|
||||||
...expressionData,
|
|
||||||
...newExpressionData,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
dispatch({ type: "expression load error", error });
|
|
||||||
throw error; // rethrow
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch({ type: "expression load success", expressionData });
|
|
||||||
return expressionData;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestSingleGeneExpressionCountsForColoringPOST(gene) {
|
function requestSingleGeneExpressionCountsForColoringPOST(gene) {
|
||||||
return async (dispatch, getState) => {
|
return {
|
||||||
dispatch({ type: "get single gene expression for coloring started" });
|
|
||||||
try {
|
|
||||||
await _doRequestExpressionData(dispatch, getState, [gene]);
|
|
||||||
const { world } = getState();
|
|
||||||
dispatch({
|
|
||||||
type: "color by expression",
|
type: "color by expression",
|
||||||
gene,
|
gene,
|
||||||
data: {
|
|
||||||
[gene]: world.varData.col(gene).asArray(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
dispatch({
|
|
||||||
type: "get single gene expression for coloring error",
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestUserDefinedGene = (gene) => async (dispatch, getState) => {
|
const requestUserDefinedGene = (gene) => ({
|
||||||
dispatch({ type: "request user defined gene started" });
|
|
||||||
try {
|
|
||||||
await await _doRequestExpressionData(dispatch, getState, [gene]);
|
|
||||||
const { world } = getState();
|
|
||||||
|
|
||||||
/* then send the success case action through */
|
|
||||||
return dispatch({
|
|
||||||
type: "request user defined gene success",
|
type: "request user defined gene success",
|
||||||
data: {
|
data: {
|
||||||
genes: [gene],
|
genes: [gene],
|
||||||
expression: world.varData.col(gene).asArray(),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
|
||||||
return dispatch({
|
|
||||||
type: "request user defined gene error",
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const dispatchDiffExpErrors = (dispatch, response) => {
|
const dispatchDiffExpErrors = (dispatch, response) => {
|
||||||
switch (response.status) {
|
switch (response.status) {
|
||||||
@@ -308,9 +116,8 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async (
|
|||||||
1. get the most differentially expressed genes
|
1. get the most differentially expressed genes
|
||||||
2. get expression data for each
|
2. get expression data for each
|
||||||
*/
|
*/
|
||||||
const state = getState();
|
const { annoMatrix } = getState();
|
||||||
const { universe } = state;
|
const varIndexName = annoMatrix.schema.annotations.var.index;
|
||||||
const varIndexName = universe.schema.annotations.var.index;
|
|
||||||
|
|
||||||
// Legal values are null, Array or TypedArray. Null is initial state.
|
// Legal values are null, Array or TypedArray. Null is initial state.
|
||||||
if (!set1) set1 = [];
|
if (!set1) set1 = [];
|
||||||
@@ -345,22 +152,12 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async (
|
|||||||
return dispatchDiffExpErrors(dispatch, res);
|
return dispatchDiffExpErrors(dispatch, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const response = await res.json();
|
||||||
// result is [ [varIdx, ...], ... ]
|
const varIndex = await annoMatrix.fetch("var", varIndexName);
|
||||||
const topNGenes = data.map((r) =>
|
const data = response.map((v) => [
|
||||||
universe.varAnnotations.at(r[0], varIndexName)
|
varIndex.at(v[0], varIndexName),
|
||||||
);
|
...v.slice(1),
|
||||||
|
]);
|
||||||
/*
|
|
||||||
Kick off secondary action to fetch all of the expression data for the
|
|
||||||
topN expressed genes.
|
|
||||||
*/
|
|
||||||
const plimit = new PromiseLimit(5);
|
|
||||||
await Promise.all(
|
|
||||||
topNGenes.map((gene) =>
|
|
||||||
plimit.add(() => _doRequestExpressionData(dispatch, getState, [gene]))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
/* then send the success case action through */
|
/* then send the success case action through */
|
||||||
return dispatch({
|
return dispatch({
|
||||||
@@ -375,87 +172,44 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetWorldToUniverse = () => (dispatch, getState) => {
|
|
||||||
const { universe } = getState();
|
|
||||||
reembedResetWorldToUniverse(dispatch, getState);
|
|
||||||
dispatch({
|
|
||||||
type: "reset World to eq Universe",
|
|
||||||
universe,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveObsAnnotations = () => async (dispatch, getState) => {
|
|
||||||
const { universe, annotations } = getState();
|
|
||||||
const { obsAnnotations, schema } = universe;
|
|
||||||
const { dataCollectionNameIsReadOnly, dataCollectionName } = annotations;
|
|
||||||
|
|
||||||
dispatch({
|
|
||||||
type: "writable obs annotations - save started",
|
|
||||||
});
|
|
||||||
|
|
||||||
const writableAnnotations = schema.annotations.obs.columns
|
|
||||||
.filter((s) => s.writable)
|
|
||||||
.map((s) => s.name);
|
|
||||||
const df = obsAnnotations.subset(null, writableAnnotations);
|
|
||||||
const matrix = MatrixFBS.encodeMatrixFBS(df);
|
|
||||||
try {
|
|
||||||
const queryString =
|
|
||||||
!dataCollectionNameIsReadOnly && !!dataCollectionName
|
|
||||||
? `?annotation-collection-name=${encodeURIComponent(
|
|
||||||
dataCollectionName
|
|
||||||
)}`
|
|
||||||
: "";
|
|
||||||
const res = await fetch(
|
|
||||||
`${globals.API.prefix}${globals.API.version}annotations/obs${queryString}`,
|
|
||||||
{
|
|
||||||
method: "PUT",
|
|
||||||
body: matrix,
|
|
||||||
headers: new Headers({
|
|
||||||
"Content-Type": "application/octet-stream",
|
|
||||||
}),
|
|
||||||
credentials: "include",
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (res.ok) {
|
|
||||||
dispatch({
|
|
||||||
type: "writable obs annotations - save complete",
|
|
||||||
obsAnnotations,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
dispatch({
|
|
||||||
type: "writable obs annotations - save error",
|
|
||||||
message: `HTTP error ${res.status} - ${res.statusText}`,
|
|
||||||
res,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
dispatch({
|
|
||||||
type: "writable obs annotations - save error",
|
|
||||||
message: error.toString(),
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function fetchJson(pathAndQuery) {
|
function fetchJson(pathAndQuery) {
|
||||||
return doJsonRequest(
|
return doJsonRequest(
|
||||||
`${globals.API.prefix}${globals.API.version}${pathAndQuery}`
|
`${globals.API.prefix}${globals.API.version}${pathAndQuery}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchBinary(pathAndQuery) {
|
|
||||||
return doBinaryRequest(
|
|
||||||
`${globals.API.prefix}${globals.API.version}${pathAndQuery}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
doInitialDataLoad,
|
doInitialDataLoad,
|
||||||
requestDifferentialExpression,
|
requestDifferentialExpression,
|
||||||
requestSingleGeneExpressionCountsForColoringPOST,
|
requestSingleGeneExpressionCountsForColoringPOST,
|
||||||
requestUserDefinedGene,
|
requestUserDefinedGene,
|
||||||
requestReembed,
|
requestReembed,
|
||||||
resetWorldToUniverse,
|
selectContinuousMetadataAction: selnActions.selectContinuousMetadataAction,
|
||||||
saveObsAnnotations,
|
selectCategoricalMetadataAction: selnActions.selectCategoricalMetadataAction,
|
||||||
setWorldToSelection,
|
selectCategoricalAllMetadataAction:
|
||||||
|
selnActions.selectCategoricalAllMetadataAction,
|
||||||
|
graphBrushStartAction: selnActions.graphBrushStartAction,
|
||||||
|
graphBrushChangeAction: selnActions.graphBrushChangeAction,
|
||||||
|
graphBrushDeselectAction: selnActions.graphBrushDeselectAction,
|
||||||
|
graphBrushCancelAction: selnActions.graphBrushCancelAction,
|
||||||
|
graphBrushEndAction: selnActions.graphBrushEndAction,
|
||||||
|
graphLassoStartAction: selnActions.graphLassoStartAction,
|
||||||
|
graphLassoEndAction: selnActions.graphLassoEndAction,
|
||||||
|
graphLassoCancelAction: selnActions.graphLassoCancelAction,
|
||||||
|
graphLassoDeselectAction: selnActions.graphLassoDeselectAction,
|
||||||
|
clipAction: viewActions.clipAction,
|
||||||
|
subsetAction: viewActions.subsetAction,
|
||||||
|
resetSubsetAction: viewActions.resetSubsetAction,
|
||||||
|
annotationCreateCategoryAction: annoActions.annotationCreateCategoryAction,
|
||||||
|
annotationRenameCategoryAction: annoActions.annotationRenameCategoryAction,
|
||||||
|
annotationDeleteCategoryAction: annoActions.annotationDeleteCategoryAction,
|
||||||
|
annotationCreateLabelInCategory: annoActions.annotationCreateLabelInCategory,
|
||||||
|
annotationDeleteLabelFromCategory:
|
||||||
|
annoActions.annotationDeleteLabelFromCategory,
|
||||||
|
annotationRenameLabelInCategory: annoActions.annotationRenameLabelInCategory,
|
||||||
|
annotationLabelCurrentSelection: annoActions.annotationLabelCurrentSelection,
|
||||||
|
saveObsAnnotationsAction: annoActions.saveObsAnnotationsAction,
|
||||||
|
needToSaveObsAnnotations: annoActions.needToSaveObsAnnotations,
|
||||||
|
layoutChoiceAction: selnActions.layoutChoiceAction,
|
||||||
|
setCellSetFromSelection: selnActions.setCellSetFromSelection,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ export function requestReembed() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* disabled until reimplementation occurs
|
||||||
export function reembedResetWorldToUniverse(dispatch, getState) {
|
export function reembedResetWorldToUniverse(dispatch, getState) {
|
||||||
const { reembedController } = getState();
|
const { reembedController } = getState();
|
||||||
if (reembedController.pendingFetch) reembedController.pendingFetch.abort();
|
if (reembedController.pendingFetch) reembedController.pendingFetch.abort();
|
||||||
@@ -111,3 +112,4 @@ export function reembedResetWorldToUniverse(dispatch, getState) {
|
|||||||
type: "reembed: clear all reembeddings",
|
type: "reembed: clear all reembeddings",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
/*
|
||||||
|
Action creators for selection
|
||||||
|
*/
|
||||||
|
export const selectContinuousMetadataAction = (
|
||||||
|
type,
|
||||||
|
query,
|
||||||
|
range,
|
||||||
|
oldProps = {}
|
||||||
|
) => async (dispatch, getState) => {
|
||||||
|
const { obsCrossfilter: prevObsCrossfilter } = getState();
|
||||||
|
|
||||||
|
const selection = range
|
||||||
|
? {
|
||||||
|
mode: "range",
|
||||||
|
lo: range[0],
|
||||||
|
hi: range[1],
|
||||||
|
inclusive: true, // [lo, hi] incluisve selection
|
||||||
|
}
|
||||||
|
: { mode: "all" };
|
||||||
|
|
||||||
|
const obsCrossfilter = await prevObsCrossfilter.select(...query, selection);
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type,
|
||||||
|
obsCrossfilter,
|
||||||
|
range,
|
||||||
|
...oldProps,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const selectCategoricalMetadataAction = (
|
||||||
|
type, // action type
|
||||||
|
metadataField, // annotation category name
|
||||||
|
labels,
|
||||||
|
label, // the label being selected/deselected
|
||||||
|
isSelected, // bool
|
||||||
|
oldProps = {}
|
||||||
|
) => async (dispatch, getState) => {
|
||||||
|
const {
|
||||||
|
obsCrossfilter: prevObsCrossfilter,
|
||||||
|
categoricalSelection,
|
||||||
|
} = getState();
|
||||||
|
|
||||||
|
const labelSelectionState = new Map(categoricalSelection[metadataField]);
|
||||||
|
labels.forEach(
|
||||||
|
(l) => labelSelectionState.has(l) || labelSelectionState.set(l, true)
|
||||||
|
);
|
||||||
|
labelSelectionState.set(label, isSelected);
|
||||||
|
|
||||||
|
const values = Array.from(labelSelectionState.keys()).filter((k) =>
|
||||||
|
labelSelectionState.get(k)
|
||||||
|
);
|
||||||
|
const selection = {
|
||||||
|
mode: "exact",
|
||||||
|
values,
|
||||||
|
};
|
||||||
|
const obsCrossfilter = await prevObsCrossfilter.select(
|
||||||
|
"obs",
|
||||||
|
metadataField,
|
||||||
|
selection
|
||||||
|
);
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type,
|
||||||
|
obsCrossfilter,
|
||||||
|
metadataField,
|
||||||
|
labelSelectionState,
|
||||||
|
...oldProps,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const selectCategoricalAllMetadataAction = (
|
||||||
|
type, // action type
|
||||||
|
metadataField, // annotation category name
|
||||||
|
labels,
|
||||||
|
isSelected, // bool, select all or none
|
||||||
|
oldProps = {}
|
||||||
|
) => async (dispatch, getState) => {
|
||||||
|
const {
|
||||||
|
obsCrossfilter: prevObsCrossfilter,
|
||||||
|
categoricalSelection,
|
||||||
|
} = getState();
|
||||||
|
|
||||||
|
const labelSelectionState = new Map(categoricalSelection[metadataField]);
|
||||||
|
labels.forEach((label) => labelSelectionState.set(label, isSelected));
|
||||||
|
|
||||||
|
const selection = { mode: isSelected ? "all" : "none" };
|
||||||
|
const obsCrossfilter = await prevObsCrossfilter.select(
|
||||||
|
"obs",
|
||||||
|
metadataField,
|
||||||
|
selection
|
||||||
|
);
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type,
|
||||||
|
obsCrossfilter,
|
||||||
|
metadataField,
|
||||||
|
labelSelectionState,
|
||||||
|
...oldProps,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
** Graph selection-related actions
|
||||||
|
**/
|
||||||
|
|
||||||
|
export const graphBrushStartAction = () =>
|
||||||
|
/* no change to crossfilter until a change fires */
|
||||||
|
({ type: "graph brush start" });
|
||||||
|
|
||||||
|
const _graphBrushWithinRectAction = (type, embName, brushCoords) => async (
|
||||||
|
dispatch,
|
||||||
|
getState
|
||||||
|
) => {
|
||||||
|
const { obsCrossfilter: prevObsCrossfilter } = getState();
|
||||||
|
|
||||||
|
const selection = { mode: "within-rect", ...brushCoords };
|
||||||
|
const obsCrossfilter = await prevObsCrossfilter.select(
|
||||||
|
"emb",
|
||||||
|
embName,
|
||||||
|
selection
|
||||||
|
);
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type,
|
||||||
|
obsCrossfilter,
|
||||||
|
brushCoords,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const _graphAllAction = (type, embName) => async (dispatch, getState) => {
|
||||||
|
const { obsCrossfilter: prevObsCrossfilter } = getState();
|
||||||
|
|
||||||
|
const obsCrossfilter = await prevObsCrossfilter.select("emb", embName, {
|
||||||
|
mode: "all",
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type,
|
||||||
|
obsCrossfilter,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const graphBrushChangeAction = (embName, brushCoords) =>
|
||||||
|
_graphBrushWithinRectAction("graph brush change", embName, brushCoords);
|
||||||
|
|
||||||
|
export const graphBrushEndAction = (embName, brushCoords) =>
|
||||||
|
_graphBrushWithinRectAction("graph brush end", embName, brushCoords);
|
||||||
|
|
||||||
|
export const graphBrushCancelAction = (embName) =>
|
||||||
|
_graphAllAction("graph brush cancel", embName);
|
||||||
|
export const graphBrushDeselectAction = (embName) =>
|
||||||
|
_graphAllAction("graph brush deselect", embName);
|
||||||
|
|
||||||
|
export const graphLassoStartAction = () =>
|
||||||
|
/* no change to crossfilter until a change fires */
|
||||||
|
({ type: "graph lasso start" });
|
||||||
|
|
||||||
|
export const graphLassoCancelAction = (embName) =>
|
||||||
|
_graphAllAction("graph lasso cancel", embName);
|
||||||
|
|
||||||
|
export const graphLassoDeselectAction = (embName) =>
|
||||||
|
_graphAllAction("graph lasso cancel", embName);
|
||||||
|
|
||||||
|
export const graphLassoEndAction = (embName, polygon) => async (
|
||||||
|
dispatch,
|
||||||
|
getState
|
||||||
|
) => {
|
||||||
|
const { obsCrossfilter: prevObsCrossfilter } = getState();
|
||||||
|
|
||||||
|
const selection = {
|
||||||
|
mode: "within-polygon",
|
||||||
|
polygon,
|
||||||
|
};
|
||||||
|
const obsCrossfilter = await prevObsCrossfilter.select(
|
||||||
|
"emb",
|
||||||
|
embName,
|
||||||
|
selection
|
||||||
|
);
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "graph lasso end",
|
||||||
|
obsCrossfilter,
|
||||||
|
polygon,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const layoutChoiceAction = (newLayoutChoice) => async (
|
||||||
|
dispatch,
|
||||||
|
getState
|
||||||
|
) => {
|
||||||
|
/*
|
||||||
|
On layout choice, make sure we have selected all on the previous layout, AND the new
|
||||||
|
layout.
|
||||||
|
*/
|
||||||
|
const { obsCrossfilter: prevObsCrossfilter, layoutChoice } = getState();
|
||||||
|
|
||||||
|
let obsCrossfilter = await prevObsCrossfilter.select(
|
||||||
|
"emb",
|
||||||
|
layoutChoice.current,
|
||||||
|
{ mode: "all" }
|
||||||
|
);
|
||||||
|
obsCrossfilter = await obsCrossfilter.select("emb", newLayoutChoice, {
|
||||||
|
mode: "all",
|
||||||
|
});
|
||||||
|
dispatch({
|
||||||
|
type: "set layout choice",
|
||||||
|
layoutChoice: newLayoutChoice,
|
||||||
|
obsCrossfilter,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
Differential expression set selection
|
||||||
|
*/
|
||||||
|
export const setCellSetFromSelection = (cellSetId) => (dispatch, getState) => {
|
||||||
|
const { obsCrossfilter } = getState();
|
||||||
|
const selected = obsCrossfilter.allSelectedLabels();
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: `store current cell selection as differential set ${cellSetId}`,
|
||||||
|
data: selected.length > 0 ? selected : null,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/*
|
||||||
|
The following actions manage the view stack for annoMatrix.
|
||||||
|
|
||||||
|
Conventions used and assumed elsewhere in the code base:
|
||||||
|
* there will be zero or one clip view, and it will be the TOP view always.
|
||||||
|
* there will be zero or more subset views
|
||||||
|
|
||||||
|
In other words, in our current use, we do not stack multiple clip views but we do
|
||||||
|
stack multiple subsets.
|
||||||
|
|
||||||
|
If these conventions change, code elsewhere (eg. menubar/clip.js) will need to
|
||||||
|
change as well.
|
||||||
|
*/
|
||||||
|
import { AnnoMatrixObsCrossfilter, clip, isubsetMask } from "../annoMatrix";
|
||||||
|
|
||||||
|
export const clipAction = (min, max) => (dispatch, getState) => {
|
||||||
|
/*
|
||||||
|
apply a clip to the current annoMatrix. By convention, the clip
|
||||||
|
view is ALWAYS the top view.
|
||||||
|
*/
|
||||||
|
const { annoMatrix: prevAnnoMatrix } = getState();
|
||||||
|
const annoMatrix = prevAnnoMatrix.isClipped
|
||||||
|
? clip(prevAnnoMatrix.viewOf, min, max)
|
||||||
|
: clip(prevAnnoMatrix, min, max);
|
||||||
|
const obsCrossfilter = new AnnoMatrixObsCrossfilter(annoMatrix);
|
||||||
|
dispatch({
|
||||||
|
type: "set clip quantiles",
|
||||||
|
clipQuantiles: { min, max },
|
||||||
|
annoMatrix,
|
||||||
|
obsCrossfilter,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const subsetAction = () => (dispatch, getState) => {
|
||||||
|
/*
|
||||||
|
Subset the annoMatrix to the current crossfilter selection by pushing a
|
||||||
|
subset view.
|
||||||
|
|
||||||
|
By convention, a clip view is ALWAYS the top view, so if present, pop
|
||||||
|
off and re-apply
|
||||||
|
*/
|
||||||
|
const {
|
||||||
|
annoMatrix: prevAnnoMatrix,
|
||||||
|
obsCrossfilter: prevObsCrossfilter,
|
||||||
|
} = getState();
|
||||||
|
|
||||||
|
let annoMatrix;
|
||||||
|
if (prevAnnoMatrix.isClipped) {
|
||||||
|
// if there is a clip view, pop it and reapply after we subset
|
||||||
|
const { clipRange } = prevAnnoMatrix;
|
||||||
|
annoMatrix = isubsetMask(
|
||||||
|
prevAnnoMatrix.viewOf,
|
||||||
|
prevObsCrossfilter.allSelectedMask()
|
||||||
|
);
|
||||||
|
annoMatrix = clip(annoMatrix, ...clipRange);
|
||||||
|
} else {
|
||||||
|
// else just push a subset view.
|
||||||
|
annoMatrix = isubsetMask(
|
||||||
|
prevAnnoMatrix,
|
||||||
|
prevObsCrossfilter.allSelectedMask()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const obsCrossfilter = new AnnoMatrixObsCrossfilter(annoMatrix);
|
||||||
|
dispatch({
|
||||||
|
type: "subset to selection",
|
||||||
|
annoMatrix,
|
||||||
|
obsCrossfilter,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resetSubsetAction = () => (dispatch, getState) => {
|
||||||
|
/*
|
||||||
|
Reset the annoMatrix to all data. Because we may have multiple views
|
||||||
|
stacked, we pop them all. By convention, any clip transformation will
|
||||||
|
be the top of the stack, and must be preserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { annoMatrix: prevAnnoMatrix } = getState();
|
||||||
|
|
||||||
|
const clipRange = prevAnnoMatrix.isClipped ? prevAnnoMatrix.clipRange : null;
|
||||||
|
|
||||||
|
/* pop all views */
|
||||||
|
let annoMatrix = prevAnnoMatrix;
|
||||||
|
while (annoMatrix.isView) {
|
||||||
|
annoMatrix = annoMatrix.viewOf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* re-apply the clip, if any */
|
||||||
|
if (clipRange !== null) {
|
||||||
|
annoMatrix = clip(annoMatrix, ...clipRange);
|
||||||
|
}
|
||||||
|
|
||||||
|
const obsCrossfilter = new AnnoMatrixObsCrossfilter(annoMatrix);
|
||||||
|
dispatch({
|
||||||
|
type: "reset subset",
|
||||||
|
annoMatrix,
|
||||||
|
obsCrossfilter,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,642 @@
|
|||||||
|
import {
|
||||||
|
Dataframe,
|
||||||
|
IdentityInt32Index,
|
||||||
|
dataframeMemo,
|
||||||
|
} from "../util/dataframe";
|
||||||
|
import {
|
||||||
|
_getColumnDimensionNames,
|
||||||
|
_getColumnSchema,
|
||||||
|
_schemaColumns,
|
||||||
|
_getWritableColumns,
|
||||||
|
} from "./schema";
|
||||||
|
import { indexEntireSchema } from "../util/stateManager/schemaHelpers";
|
||||||
|
import { _whereCacheGet, _whereCacheMerge } from "./whereCache";
|
||||||
|
import _shallowClone from "./clone";
|
||||||
|
|
||||||
|
const _dataframeCache = dataframeMemo(128);
|
||||||
|
|
||||||
|
export default class AnnoMatrix {
|
||||||
|
/*
|
||||||
|
Abstract base class for all AnnoMatrix objects. This class provides a proxy
|
||||||
|
to the annotated matrix data authoritatively served by the server/back-end.
|
||||||
|
|
||||||
|
AnnoMatrix instances are immutable, meaning that their schema and dimensionality
|
||||||
|
will not change, and simple object equality can be used to detect structural
|
||||||
|
changes. The actual data is cached, and not guaranteed to be present -- any
|
||||||
|
request to access data must be resolved by a fetch() call, which is async, and
|
||||||
|
may involve a server round-trip.
|
||||||
|
|
||||||
|
Guarantees made by the immutabilty, ie, any of these can be detected by
|
||||||
|
simple annoMatrix compare:
|
||||||
|
* schema is the same, including all fields and columns
|
||||||
|
* dimensionality is the same (nObs, nVar)
|
||||||
|
* data mapping/transformation, such as clipping, are the same
|
||||||
|
|
||||||
|
AnnoMatrixes also "stack" like filters, allowing for the construction of
|
||||||
|
views which transform the data in some manner.
|
||||||
|
|
||||||
|
The bootstrap class is AnnoMatrixLoader, which is the caching server proxy, and
|
||||||
|
is bootstrapped with a API URL:
|
||||||
|
new AnnoMatirx(url, schema) -> annoMatrix
|
||||||
|
|
||||||
|
There are various "views", such as AnnoMatrixRowSubsetView, which provide
|
||||||
|
the same interface but with a transformed view of the server data. Utilities in
|
||||||
|
viewCreators.js can be used to create these views:
|
||||||
|
clip(annoMatrix, min, max) -> annoMatrix
|
||||||
|
subset(annoMatrix, rowLabels) -> annoMatrix
|
||||||
|
etc.
|
||||||
|
*/
|
||||||
|
static fields() {
|
||||||
|
/*
|
||||||
|
return the fields present in the AnnoMatrix instance.
|
||||||
|
*/
|
||||||
|
return ["obs", "var", "emb", "X"];
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(schema, nObs, nVar, rowIndex = null) {
|
||||||
|
/*
|
||||||
|
Private constructor - this is an abstract base class. Do not use.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
Public instance fields:
|
||||||
|
* schema - the matrix schema. IMPORTANT: always the entire schema, for the
|
||||||
|
base (unfiltered, unclipped, unsubset) annotated matrix, as the server
|
||||||
|
presents it.
|
||||||
|
* nObs, nVar - size of each dimension. These will accurately reflect the
|
||||||
|
size of the current annoMatrix view. For example, if you subset the view,
|
||||||
|
the nObs will be smaller.
|
||||||
|
* rowIndex - a rowIndex shared by all data on this view (ie, the list of cells).
|
||||||
|
The row index labels are as defined by the base dataset from the server.
|
||||||
|
* isView - true if this is a view, false if not.
|
||||||
|
* viewOf - pointer to parent annomatrix if a view, undefined/null if not a view.
|
||||||
|
*/
|
||||||
|
this.schema = indexEntireSchema(schema);
|
||||||
|
this.nObs = nObs;
|
||||||
|
this.nVar = nVar;
|
||||||
|
this.rowIndex = rowIndex || new IdentityInt32Index(nObs);
|
||||||
|
this.isView = false;
|
||||||
|
this.viewOf = undefined;
|
||||||
|
|
||||||
|
/*
|
||||||
|
Private instance variables.
|
||||||
|
|
||||||
|
These are caches - lazily loaded. The only guarantee is that if they
|
||||||
|
are loaded, they will conform to the schema & dimensionality constraints.
|
||||||
|
|
||||||
|
Do NOT use directly - instead, use the fetch() and preload() API.
|
||||||
|
*/
|
||||||
|
this._cache = {
|
||||||
|
obs: Dataframe.empty(this.rowIndex),
|
||||||
|
var: Dataframe.empty(this.rowIndex),
|
||||||
|
emb: Dataframe.empty(this.rowIndex),
|
||||||
|
X: Dataframe.empty(this.rowIndex),
|
||||||
|
};
|
||||||
|
this._pendingLoad = {
|
||||||
|
obs: {},
|
||||||
|
var: {},
|
||||||
|
emb: {},
|
||||||
|
X: {},
|
||||||
|
};
|
||||||
|
this._whereCache = {};
|
||||||
|
this._gcInfo = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
** Schema helper/accessors
|
||||||
|
**/
|
||||||
|
getMatrixColumns(field) {
|
||||||
|
/*
|
||||||
|
Return array of column names in the field. ONLY supported on the
|
||||||
|
obs, var and emb fields. X currently unimplemented and will throw.
|
||||||
|
|
||||||
|
For exmaple:
|
||||||
|
|
||||||
|
annoMatrix.getMatrixColumns("obs") -> ["louvain", "n_genes"]
|
||||||
|
*/
|
||||||
|
return _schemaColumns(this.schema, field);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line class-methods-use-this -- need to be able to call this on instances
|
||||||
|
getMatrixFields() {
|
||||||
|
/*
|
||||||
|
Return array of fields in this annoMatrix. Currently hard-wired to
|
||||||
|
return: ["X", "obs", "var", "emb"].
|
||||||
|
|
||||||
|
These are the fields from data may be requested.
|
||||||
|
*/
|
||||||
|
return AnnoMatrix.fields();
|
||||||
|
}
|
||||||
|
|
||||||
|
getColumnSchema(field, col) {
|
||||||
|
/*
|
||||||
|
Return the schema for the field & column ,eg,
|
||||||
|
|
||||||
|
anonMatrix.getColumnSchema("obs", "n_genes") -> { type: "int32", name: "n_genes" }
|
||||||
|
|
||||||
|
This is identical to the information in the annoMatrix.schema
|
||||||
|
instance variable.
|
||||||
|
*/
|
||||||
|
return _getColumnSchema(this.schema, field, col);
|
||||||
|
}
|
||||||
|
|
||||||
|
getColumnDimensions(field, col) {
|
||||||
|
/*
|
||||||
|
Return the dimensions on this field / column. For most fields, which are 1D,
|
||||||
|
this just return the column name. Multi-dimensional columns, such as embeddings,
|
||||||
|
will return >1 name.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
getColumnDimensions("obs", "louvain") -> ["louvain"]
|
||||||
|
getColumnDimensions("emb", "umap") -> ["umap_0", "umap_1"]
|
||||||
|
|
||||||
|
*/
|
||||||
|
return _getColumnDimensionNames(this.schema, field, col);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
** General utility methods
|
||||||
|
**/
|
||||||
|
base() {
|
||||||
|
/*
|
||||||
|
return the base of view, or `this` if not a view.
|
||||||
|
*/
|
||||||
|
let annoMatrix = this;
|
||||||
|
while (annoMatrix.isView) annoMatrix = annoMatrix.viewOf;
|
||||||
|
return annoMatrix;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
** Load / read interfaces
|
||||||
|
**/
|
||||||
|
fetch(field, q) {
|
||||||
|
/*
|
||||||
|
Return the given query on a single matrix field as a single dataframe.
|
||||||
|
Currently supports ONLY full column query.
|
||||||
|
|
||||||
|
Returns a Promise for the query result, which will resolve to a dataframe.
|
||||||
|
|
||||||
|
Field must be one of the matrix fields: 'obs', 'var', 'X', 'emb'. Value
|
||||||
|
represents the underlying object upon which the query is occuring.
|
||||||
|
|
||||||
|
Query is one of:
|
||||||
|
* a string, representing a single column name from the field, eg,
|
||||||
|
"n_genes"
|
||||||
|
* an object, containing an "value" query (see below).
|
||||||
|
* an array, containing one or more of the above.
|
||||||
|
|
||||||
|
Columns may have more than one dimension, and all will be fetched
|
||||||
|
and returned together. This is most commonly seen in an embedding,
|
||||||
|
which usually has two dimensions.
|
||||||
|
|
||||||
|
A value query allows for fetching based upon the value in another
|
||||||
|
field/column, similar to a join. Currently only supported on the var
|
||||||
|
dimension, allowing query of X columns by var value (eg, gene name)
|
||||||
|
|
||||||
|
The query filter is a single value filter:
|
||||||
|
{ "field name": [
|
||||||
|
{name: "column name", values: [ list of values ]}
|
||||||
|
]}
|
||||||
|
One and only one value filter is allowed in a value query.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
1. Fetch the "n_genes" column the "obs":
|
||||||
|
|
||||||
|
const df = await fetch("obs", "n_genes")
|
||||||
|
console.log("Largest number of genes is: ", df.summarize().max);
|
||||||
|
|
||||||
|
2. Fetch two separate columns from obs. Returns a single dataframe containing
|
||||||
|
the columns:
|
||||||
|
|
||||||
|
const df = await fetch("obs", ["n_genes", "louvain"])
|
||||||
|
console.log("Cell 0 has category: ", df.at(0, "louvain"));
|
||||||
|
|
||||||
|
3. Fetch an entire X (expression counts) column that has a var annotation
|
||||||
|
value "TYMP" in the var index.
|
||||||
|
|
||||||
|
fetch("X", {
|
||||||
|
where: {field: "var", column: this.schema.annotations.var.index, value: "TYMP"}
|
||||||
|
})
|
||||||
|
|
||||||
|
In AnnData & Pandas DataFrame API, this is equivalent to:
|
||||||
|
adata.X[:, adata.var.index.get_loc("SUMO3")]
|
||||||
|
|
||||||
|
The value query is a recodification and subset of the server REST API
|
||||||
|
value filter JSON. Range queries and multiple filters are not currently
|
||||||
|
supported.
|
||||||
|
|
||||||
|
*/
|
||||||
|
return this._fetch(field, q);
|
||||||
|
}
|
||||||
|
|
||||||
|
prefetch(field, q) {
|
||||||
|
/*
|
||||||
|
Start a data fetch & cache fill. Identical to fetch() except it does
|
||||||
|
not return a value.
|
||||||
|
|
||||||
|
Primary use is to being a cache load as early as is possible, reducing
|
||||||
|
overall component rendering latency.
|
||||||
|
*/
|
||||||
|
this._fetch(field, q);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
** Save / mutate interfaces - manipulation of "writable" OBS annotations.
|
||||||
|
**
|
||||||
|
** These are all present to support client-side creation of OBS annotations, aka
|
||||||
|
** "user annotations".
|
||||||
|
**
|
||||||
|
** They implement common manipulations to the AnnoMatrix, maintaining the
|
||||||
|
** norma guarantees around correctness of public API, eg,
|
||||||
|
** - schema will be correct, including the "writable" attribute
|
||||||
|
** - fetch() will return the latest data, even from views
|
||||||
|
** - immutability guranteeds
|
||||||
|
**
|
||||||
|
** As most of these interfaces mutate the annoMatrix, they return a new
|
||||||
|
** annoMatrix
|
||||||
|
**
|
||||||
|
** The actual implementation is in the sub-classes, which MUST override these.
|
||||||
|
**/
|
||||||
|
|
||||||
|
// eslint-disable-next-line class-methods-use-this, no-unused-vars -- make sure subclass implements
|
||||||
|
addObsAnnoCategory(col, category) {
|
||||||
|
/*
|
||||||
|
Add a new category value (aka "label") to a writable obs column, and return the new AnnoMatrix.
|
||||||
|
Typical use is to add a new user-created label to a user-created obs categorical
|
||||||
|
annotation.
|
||||||
|
|
||||||
|
Will throw column does not exist or is not writable.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
addObsAnnoCategory("my cell type", "left toenail") -> AnnoMatrix
|
||||||
|
|
||||||
|
*/
|
||||||
|
_subclassResponsibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line class-methods-use-this, no-unused-vars -- make sure subclass implements
|
||||||
|
async removeObsAnnoCategory(col, category, unassignedCategory) {
|
||||||
|
/*
|
||||||
|
Remove a category value from an obs column, reassign any obs having that value
|
||||||
|
to the 'unassignedCategory' value, and return a promise for a new AnnoMatrix.
|
||||||
|
Typical use is to remove a user-created label from a user-created obs categorical
|
||||||
|
annotation.
|
||||||
|
|
||||||
|
Will throw column does not exist or is not writable.
|
||||||
|
|
||||||
|
An `unassignedCategory` value must be provided, for assignment to any obs/cells
|
||||||
|
that had the now-delete category label as their value.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
await removeObsAnnoCategory("my-tissue-type", "right earlobe", "unassigned") -> AnnoMatrix
|
||||||
|
|
||||||
|
NOTE: method is async as it may need to fetch data to provide the reassignment.
|
||||||
|
*/
|
||||||
|
_subclassResponsibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line class-methods-use-this, no-unused-vars -- make sure subclass implements
|
||||||
|
dropObsColumn(col) {
|
||||||
|
/*
|
||||||
|
Drop an entire writable column, eg a user-created obs annotation. Typical use
|
||||||
|
is to provide the "Delete Category" implementation. Returns the new AnnoMatrix.
|
||||||
|
Will throw if not a writable annotation.
|
||||||
|
|
||||||
|
Will throw column does not exist or is not writable.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
dropObsColumn("old annotations") -> AnnoMatrix
|
||||||
|
*/
|
||||||
|
_subclassResponsibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line class-methods-use-this, no-unused-vars -- make sure subclass implements
|
||||||
|
addObsColumn(colSchema, Ctor, value) {
|
||||||
|
/*
|
||||||
|
Add a new writable OBS annotation column, with the caller-specified schema, initial value
|
||||||
|
type and value.
|
||||||
|
|
||||||
|
Value may be any one of:
|
||||||
|
* an array of values
|
||||||
|
* a primitive type, including null or undefined.
|
||||||
|
If an array, length must be the same as 'this.nObs', and constructor must equal 'Ctor'.
|
||||||
|
If a primitive, 'Ctor' will be used to create the initial value, which will be filled
|
||||||
|
with 'value'.
|
||||||
|
|
||||||
|
Throws if the name specified in 'colSchema' duplicates an existing obs column.
|
||||||
|
|
||||||
|
Returns a new AnnoMatrix.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
addObsColumn(
|
||||||
|
{ name: "foo", type: "categorical", categories: "unassigned" },
|
||||||
|
Array,
|
||||||
|
"unassigned"
|
||||||
|
) -> AnnoMatrix
|
||||||
|
|
||||||
|
*/
|
||||||
|
_subclassResponsibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line class-methods-use-this, no-unused-vars -- make sure subclass implements
|
||||||
|
renameObsColumn(oldCol, newCol) {
|
||||||
|
/*
|
||||||
|
Rename the obs column 'oldCol' to have name 'newCol' and returns new AnnoMatrix.
|
||||||
|
|
||||||
|
Will throw column does not exist or is not writable, or if 'newCol' is not unique.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
renameObsColumn('cell type', 'old cell type') -> AnnoMatrix.
|
||||||
|
|
||||||
|
*/
|
||||||
|
_subclassResponsibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line class-methods-use-this, no-unused-vars -- make sure subclass implements
|
||||||
|
async setObsColumnValues(col, obsLabels, value) {
|
||||||
|
/*
|
||||||
|
Set all obs with label in array 'obsLabels' to have 'value'. Typical use would be
|
||||||
|
to set a group of cells to have a label on a user-created categorical anntoation
|
||||||
|
(eg set all selected cells to have a label).
|
||||||
|
|
||||||
|
NOTE: async method, as it may need to fetch.
|
||||||
|
|
||||||
|
Will throw column does not exist or is not writable.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
await setObsColmnValues("flavor", [383, 400], "tasty") -> AnnoMtarix
|
||||||
|
|
||||||
|
*/
|
||||||
|
_subclassResponsibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line class-methods-use-this, no-unused-vars -- make sure subclass implements
|
||||||
|
async resetObsColumnValues(col, oldValue, newValue) {
|
||||||
|
/*
|
||||||
|
Set by value - all elements in the column with value 'oldValue' are set to 'newValue'.
|
||||||
|
Async method - returns a promise for a new AnnoMatrix.
|
||||||
|
|
||||||
|
Typical use would be to set all labels of one value to another.
|
||||||
|
|
||||||
|
Will throw column does not exist or is not writable.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
await resetObsColumnValues("my notes", "good", "not-good") -> AnnoMatrix
|
||||||
|
|
||||||
|
*/
|
||||||
|
_subclassResponsibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
** Private interfaces below.
|
||||||
|
**/
|
||||||
|
_resolveCachedQueries(field, queries) {
|
||||||
|
return queries
|
||||||
|
.map((query) =>
|
||||||
|
_whereCacheGet(this._whereCache, this.schema, field, query).filter(
|
||||||
|
(cacheKey) =>
|
||||||
|
cacheKey !== undefined && this._cache[field].hasCol(cacheKey)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
async _fetch(field, q) {
|
||||||
|
if (!AnnoMatrix.fields().includes(field)) return undefined;
|
||||||
|
const queries = Array.isArray(q) ? q : [q];
|
||||||
|
|
||||||
|
/* find cached columns we need, and GC the rest */
|
||||||
|
const cachedColumns = this._resolveCachedQueries(field, queries);
|
||||||
|
this._gcFetchCleanup(field, cachedColumns);
|
||||||
|
|
||||||
|
/* find any query not already cached */
|
||||||
|
const uncachedQueries = queries.filter((query) =>
|
||||||
|
_whereCacheGet(this._whereCache, this.schema, field, query).some(
|
||||||
|
(cacheKey) =>
|
||||||
|
cacheKey === undefined || !this._cache[field].hasCol(cacheKey)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
/* load uncached queries */
|
||||||
|
if (uncachedQueries.length > 0) {
|
||||||
|
await Promise.all(
|
||||||
|
uncachedQueries.map((query) =>
|
||||||
|
this._getPendingLoad(field, query, async (_field, _query) => {
|
||||||
|
/* fetch, then index. _doLoad is subclass interface */
|
||||||
|
const [whereCacheUpdate, df] = await this._doLoad(_field, _query);
|
||||||
|
this._cache[_field] = this._cache[_field].withColsFrom(df);
|
||||||
|
this._whereCache = _whereCacheMerge(
|
||||||
|
this._whereCache,
|
||||||
|
whereCacheUpdate
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* everything we need is in the cache, so just cherry-pick requested columns */
|
||||||
|
const requestedCacheKeys = this._resolveCachedQueries(field, queries);
|
||||||
|
const response = _dataframeCache(
|
||||||
|
this._cache[field].subset(null, requestedCacheKeys)
|
||||||
|
);
|
||||||
|
this._gcUpdateStats(field, response);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _getPendingLoad(field, query, fetchFn) {
|
||||||
|
/*
|
||||||
|
Given a query on a field, ensure that we only have a single outstanding
|
||||||
|
fetch at any given time. If multiple requests occur while a fetch is
|
||||||
|
outstanding, just wait for the original.
|
||||||
|
|
||||||
|
This is implemented by returning a promise that will await the singular
|
||||||
|
fetch promise.
|
||||||
|
*/
|
||||||
|
const key = _queryCacheKey(field, query);
|
||||||
|
if (!this._pendingLoad[field][key]) {
|
||||||
|
this._pendingLoad[field][key] = fetchFn(field, query);
|
||||||
|
try {
|
||||||
|
await this._pendingLoad[field][key];
|
||||||
|
} finally {
|
||||||
|
delete this._pendingLoad[field][key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this._pendingLoad[field][key];
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line class-methods-use-this -- make sure subclass implements
|
||||||
|
async _doLoad() {
|
||||||
|
_subclassResponsibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
** Garbage collection of annomatrix cache to manage memory use.
|
||||||
|
**/
|
||||||
|
|
||||||
|
/*
|
||||||
|
These callbacks implement a GC policy for the cache. Background:
|
||||||
|
|
||||||
|
* For the Loader (base) annomatrix, re-filling the cache is expensive as
|
||||||
|
it requires an HTTP fetch.
|
||||||
|
* user-defined / writable columns must not be GC'ed as they may be
|
||||||
|
still pending a save/commit.
|
||||||
|
* For views, cost is less and (roughly) proportional with nObs
|
||||||
|
* obs, var and emb do not grow without bounds, and are needed constantly
|
||||||
|
for rendering.
|
||||||
|
a) There is no upside to GC'ing these in the base (loader)
|
||||||
|
b) The undo/redo cache can hold a large number in views, which is worht GC'ing
|
||||||
|
* X is often much larger than memory, and the UI allows add/del from
|
||||||
|
this. Most of the GC potential is here in both the base and views.
|
||||||
|
|
||||||
|
Current policy:
|
||||||
|
* if in active use ("hot") do not GC obs, var or emb.
|
||||||
|
* never, ever GC writable obs columns
|
||||||
|
* For base/loader set a numeric limit on maximum X column count
|
||||||
|
* For views, apply a fixed limit to the number of columns cached in any field.
|
||||||
|
Limit will be lower if not hot.
|
||||||
|
|
||||||
|
To be effective, the GC callback needs to be invoked from the undo/redo code,
|
||||||
|
as much of the cache is pinned by that data structure.
|
||||||
|
*/
|
||||||
|
_gcField(field, isHot, pinnedColumns) {
|
||||||
|
const maxColumns = isHot ? 256 : 10; // maybe to aggessive?
|
||||||
|
|
||||||
|
const cache = this._cache[field];
|
||||||
|
if (cache.colIndex.size() < maxColumns) return; // trivial rejection
|
||||||
|
|
||||||
|
const candidates = cache.colIndex
|
||||||
|
.labels()
|
||||||
|
.filter((col) => !pinnedColumns.includes(col));
|
||||||
|
|
||||||
|
const excessCount = candidates.length + pinnedColumns.length - maxColumns;
|
||||||
|
if (excessCount > 0) {
|
||||||
|
const { _gcInfo } = this;
|
||||||
|
candidates.sort((a, b) => {
|
||||||
|
let atime = _gcInfo.get(_columnCacheKey(field, a));
|
||||||
|
if (atime === undefined) atime = 0;
|
||||||
|
|
||||||
|
let btime = _gcInfo.get(_columnCacheKey(field, b));
|
||||||
|
if (btime === undefined) btime = 0;
|
||||||
|
|
||||||
|
return atime - btime;
|
||||||
|
});
|
||||||
|
|
||||||
|
const toDrop = candidates.slice(0, excessCount);
|
||||||
|
// helpful debugging - please leave in place.
|
||||||
|
// console.log(
|
||||||
|
// `GC: dropping from ${field} hot:${isHot}, columns [${toDrop.join(
|
||||||
|
// ", "
|
||||||
|
// )}]`
|
||||||
|
// );
|
||||||
|
this._cache[field] = toDrop.reduce(
|
||||||
|
(df, col) => df.dropCol(col),
|
||||||
|
this._cache[field]
|
||||||
|
);
|
||||||
|
toDrop.forEach((col) => _gcInfo.delete(_columnCacheKey(field, col)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_gcFetchCleanup(field, pinnedColumns) {
|
||||||
|
/*
|
||||||
|
Called during data load/fetch. By definition, this is 'hot', so we
|
||||||
|
only want to gc X.
|
||||||
|
*/
|
||||||
|
if (field === "X") {
|
||||||
|
this._gcField(
|
||||||
|
field,
|
||||||
|
true,
|
||||||
|
pinnedColumns.concat(_getWritableColumns(this.schema, field))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_gc(hints) {
|
||||||
|
/*
|
||||||
|
Called from middleware, or elsewhere. isHot is true if we are in the active store,
|
||||||
|
or false if we are in some other context (eg, history state).
|
||||||
|
*/
|
||||||
|
const { isHot } = hints;
|
||||||
|
const candidateFields = isHot ? ["X"] : ["X", "emb", "var", "obs"];
|
||||||
|
candidateFields.forEach((field) =>
|
||||||
|
this._gcField(field, isHot, _getWritableColumns(this.schema, field))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_gcUpdateStats(field, dataframe) {
|
||||||
|
/*
|
||||||
|
called each time a query is performed, allowing the gc to update any bookkeeping
|
||||||
|
information. Currently, this is just a simple last-fetched timestamp, stored
|
||||||
|
in a Map.
|
||||||
|
|
||||||
|
Map objects preserve order of insertion. This is leveraged as a cheap way to
|
||||||
|
do LRU, by removing and re-inserting keys. IMPORTANT: the cleanup code assumes
|
||||||
|
the map insertion order is least-recently-used first.
|
||||||
|
*/
|
||||||
|
const cols = dataframe.colIndex.labels();
|
||||||
|
const { _gcInfo } = this;
|
||||||
|
const now = Date.now();
|
||||||
|
cols.forEach((c) => {
|
||||||
|
// gcInfo.delete(c);
|
||||||
|
_gcInfo.set(_columnCacheKey(field, c), now);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Cloning sublcass protocol - we rely in cloning to preserve immutable
|
||||||
|
symantics while not causing races or other side effects in internal
|
||||||
|
cache management.
|
||||||
|
|
||||||
|
Subclasses must override _cloneDeeper() if they have state which requires
|
||||||
|
something other than a shallow copy. Overrides MUST call super()._cloneDeepr(),
|
||||||
|
and return its result (after any required modification). _cloneDeeper()
|
||||||
|
will be called on the OLD object, with the NEW object as an argument.
|
||||||
|
|
||||||
|
Do not override _clone();
|
||||||
|
**/
|
||||||
|
_cloneDeeper(clone) {
|
||||||
|
clone._cache = _shallowClone(this._cache);
|
||||||
|
clone._gcInfo = new Map();
|
||||||
|
clone._pendingLoad = {
|
||||||
|
obs: {},
|
||||||
|
var: {},
|
||||||
|
emb: {},
|
||||||
|
X: {},
|
||||||
|
};
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
_clone() {
|
||||||
|
const clone = _shallowClone(this);
|
||||||
|
this._cloneDeeper(clone);
|
||||||
|
Object.seal(clone);
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
private utility functions below
|
||||||
|
*/
|
||||||
|
|
||||||
|
function _queryCacheKey(field, query) {
|
||||||
|
if (typeof query === "object") {
|
||||||
|
const { field: queryField, column: queryColumn, value: queryValue } = query;
|
||||||
|
return `${field}/${queryField}/${queryColumn}/${queryValue}`;
|
||||||
|
}
|
||||||
|
return `${field}/${query}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _columnCacheKey(field, column) {
|
||||||
|
return `${field}/${column}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _subclassResponsibility() {
|
||||||
|
/* protect against bugs in subclass */
|
||||||
|
throw new Error("subclass failed to implement required method");
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/*
|
||||||
|
Shallow clone an object, correctly handling prototype
|
||||||
|
*/
|
||||||
|
export default function _shallowClone(orig) {
|
||||||
|
return Object.assign(Object.create(Object.getPrototypeOf(orig)), orig);
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
/*
|
||||||
|
Row crossfilter proxy for an AnnoMatrix. This wraps Crossfilter,
|
||||||
|
providing a number of services, and ensuring that the crossfilter and
|
||||||
|
AnnoMatrix stay in sync:
|
||||||
|
- on-demand index creation as data is loaded
|
||||||
|
- transparently mapping between queries and crossfilter index names.
|
||||||
|
- for mutation of the matrix by user annotations, maintain synchronization
|
||||||
|
between Crossfilter and AnnoMatrix.
|
||||||
|
*/
|
||||||
|
import Crossfilter from "../util/typedCrossfilter";
|
||||||
|
import { _getColumnSchema } from "./schema";
|
||||||
|
|
||||||
|
function _dimensionNameFromDf(field, df) {
|
||||||
|
const colNames = df.colIndex.labels();
|
||||||
|
return _dimensionName(field, colNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _dimensionName(field, colNames) {
|
||||||
|
if (!Array.isArray(colNames)) return `${field}/${colNames}`;
|
||||||
|
return `${field}/${colNames.join(":")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class AnnoMatrixObsCrossfilter {
|
||||||
|
constructor(annoMatrix, _obsCrossfilter = null) {
|
||||||
|
this.annoMatrix = annoMatrix;
|
||||||
|
this.obsCrossfilter =
|
||||||
|
_obsCrossfilter || new Crossfilter(annoMatrix._cache.obs);
|
||||||
|
this.obsCrossfilter = this.obsCrossfilter.setData(annoMatrix._cache.obs);
|
||||||
|
}
|
||||||
|
|
||||||
|
size() {
|
||||||
|
return this.obsCrossfilter.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Managing the associated annoMatrix. These wrappers are necessary to
|
||||||
|
make coordinated changes to BOTH the crossfilter and annoMatrix, and
|
||||||
|
ensure that all state stays synchronized.
|
||||||
|
|
||||||
|
See API documentation in annoMatrix.js.
|
||||||
|
**/
|
||||||
|
addObsColumn(colSchema, Ctor, value) {
|
||||||
|
const annoMatrix = this.annoMatrix.addObsColumn(colSchema, Ctor, value);
|
||||||
|
const obsCrossfilter = this.obsCrossfilter.setData(annoMatrix._cache.obs);
|
||||||
|
return new AnnoMatrixObsCrossfilter(annoMatrix, obsCrossfilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
dropObsColumn(col) {
|
||||||
|
const annoMatrix = this.annoMatrix.dropObsColumn(col);
|
||||||
|
let { obsCrossfilter } = this;
|
||||||
|
const dimName = _dimensionName("obs", col);
|
||||||
|
if (obsCrossfilter.hasDimension(dimName)) {
|
||||||
|
obsCrossfilter = obsCrossfilter.delDimension(dimName);
|
||||||
|
}
|
||||||
|
return new AnnoMatrixObsCrossfilter(annoMatrix, obsCrossfilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
renameObsColumn(oldCol, newCol) {
|
||||||
|
const annoMatrix = this.annoMatrix.renameObsColumn(oldCol, newCol);
|
||||||
|
const oldDimName = _dimensionName("obs", oldCol);
|
||||||
|
const newDimName = _dimensionName("obs", newCol);
|
||||||
|
let { obsCrossfilter } = this;
|
||||||
|
if (obsCrossfilter.hasDimension(oldDimName)) {
|
||||||
|
obsCrossfilter = obsCrossfilter.renameDimension(oldDimName, newDimName);
|
||||||
|
}
|
||||||
|
return new AnnoMatrixObsCrossfilter(annoMatrix, obsCrossfilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
addObsAnnoCategory(col, category) {
|
||||||
|
const annoMatrix = this.annoMatrix.addObsAnnoCategory(col, category);
|
||||||
|
const dimName = _dimensionName("obs", col);
|
||||||
|
let { obsCrossfilter } = this;
|
||||||
|
if (obsCrossfilter.hasDimension(dimName)) {
|
||||||
|
obsCrossfilter = obsCrossfilter.delDimension(dimName);
|
||||||
|
}
|
||||||
|
return new AnnoMatrixObsCrossfilter(annoMatrix, obsCrossfilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeObsAnnoCategory(col, category, unassignedCategory) {
|
||||||
|
const annoMatrix = await this.annoMatrix.removeObsAnnoCategory(
|
||||||
|
col,
|
||||||
|
category,
|
||||||
|
unassignedCategory
|
||||||
|
);
|
||||||
|
const dimName = _dimensionName("obs", col);
|
||||||
|
let { obsCrossfilter } = this;
|
||||||
|
if (obsCrossfilter.hasDimension(dimName)) {
|
||||||
|
obsCrossfilter = obsCrossfilter.delDimension(dimName);
|
||||||
|
}
|
||||||
|
return new AnnoMatrixObsCrossfilter(annoMatrix, obsCrossfilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setObsColumnValues(col, rowLabels, value) {
|
||||||
|
const annoMatrix = await this.annoMatrix.setObsColumnValues(
|
||||||
|
col,
|
||||||
|
rowLabels,
|
||||||
|
value
|
||||||
|
);
|
||||||
|
const dimName = _dimensionName("obs", col);
|
||||||
|
let { obsCrossfilter } = this;
|
||||||
|
if (obsCrossfilter.hasDimension(dimName)) {
|
||||||
|
obsCrossfilter = obsCrossfilter.delDimension(dimName);
|
||||||
|
}
|
||||||
|
return new AnnoMatrixObsCrossfilter(annoMatrix, obsCrossfilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetObsColumnValues(col, oldValue, newValue) {
|
||||||
|
const annoMatrix = await this.annoMatrix.resetObsColumnValues(
|
||||||
|
col,
|
||||||
|
oldValue,
|
||||||
|
newValue
|
||||||
|
);
|
||||||
|
const dimName = _dimensionName("obs", col);
|
||||||
|
let { obsCrossfilter } = this;
|
||||||
|
if (obsCrossfilter.hasDimension(dimName)) {
|
||||||
|
obsCrossfilter = obsCrossfilter.delDimension(dimName);
|
||||||
|
}
|
||||||
|
return new AnnoMatrixObsCrossfilter(annoMatrix, obsCrossfilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Selection state - API is identical to ImmutableTypedCrossfilter, as these
|
||||||
|
are just wrappers to lazy create indices.
|
||||||
|
**/
|
||||||
|
|
||||||
|
async select(field, query, spec) {
|
||||||
|
const { annoMatrix } = this;
|
||||||
|
let { obsCrossfilter } = this;
|
||||||
|
|
||||||
|
if (!annoMatrix?._cache?.[field]) {
|
||||||
|
throw new Error("Unknown field name");
|
||||||
|
}
|
||||||
|
if (field === "var") {
|
||||||
|
throw new Error("unable to obsSelect upon the var dimension");
|
||||||
|
}
|
||||||
|
|
||||||
|
// grab the data, so we can grab the index.
|
||||||
|
const df = await annoMatrix.fetch(field, query);
|
||||||
|
|
||||||
|
const dimName = _dimensionNameFromDf(field, df);
|
||||||
|
if (!obsCrossfilter.hasDimension(dimName)) {
|
||||||
|
// lazy index generation - add dimension when first used
|
||||||
|
obsCrossfilter = this._addObsCrossfilterDimension(
|
||||||
|
annoMatrix,
|
||||||
|
obsCrossfilter,
|
||||||
|
field,
|
||||||
|
df
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// select
|
||||||
|
obsCrossfilter = obsCrossfilter.select(dimName, spec);
|
||||||
|
return new AnnoMatrixObsCrossfilter(annoMatrix, obsCrossfilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectAll() {
|
||||||
|
/*
|
||||||
|
Select all on any dimension in this field.
|
||||||
|
*/
|
||||||
|
const { annoMatrix } = this;
|
||||||
|
const currentDims = this.obsCrossfilter.dimensionNames();
|
||||||
|
const obsCrossfilter = currentDims.reduce((xfltr, dim) => {
|
||||||
|
return xfltr.select(dim, { mode: "all" });
|
||||||
|
}, this.obsCrossfilter);
|
||||||
|
return new AnnoMatrixObsCrossfilter(annoMatrix, obsCrossfilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
countSelected() {
|
||||||
|
/* if no data yet indexed in the crossfilter, just say everything is selected */
|
||||||
|
if (this.obsCrossfilter.size() === 0) return this.annoMatrix.nObs;
|
||||||
|
return this.obsCrossfilter.countSelected();
|
||||||
|
}
|
||||||
|
|
||||||
|
allSelectedMask() {
|
||||||
|
/* if no data yet indexed in the crossfilter, just say everything is selected */
|
||||||
|
if (
|
||||||
|
this.obsCrossfilter.size() === 0 ||
|
||||||
|
this.obsCrossfilter.dimensionNames().length === 0
|
||||||
|
) {
|
||||||
|
/* fake the mask */
|
||||||
|
return new Uint8Array(this.annoMatrix.nObs).fill(1);
|
||||||
|
}
|
||||||
|
return this.obsCrossfilter.allSelectedMask();
|
||||||
|
}
|
||||||
|
|
||||||
|
allSelectedLabels() {
|
||||||
|
/* if no data yet indexed in the crossfilter, just say everything is selected */
|
||||||
|
if (
|
||||||
|
this.obsCrossfilter.size() === 0 ||
|
||||||
|
this.obsCrossfilter.dimensionNames().length === 0
|
||||||
|
) {
|
||||||
|
return this.annoMatrix.rowIndex.labels();
|
||||||
|
}
|
||||||
|
|
||||||
|
const mask = this.obsCrossfilter.allSelectedMask();
|
||||||
|
const index = this.annoMatrix.rowIndex.isubsetMask(mask);
|
||||||
|
return index.labels();
|
||||||
|
}
|
||||||
|
|
||||||
|
fillByIsSelected(array, selectedValue, deselectedValue) {
|
||||||
|
/* if no data yet indexed in the crossfilter, just say everything is selected */
|
||||||
|
if (
|
||||||
|
this.obsCrossfilter.size() === 0 ||
|
||||||
|
this.obsCrossfilter.dimensionNames().length === 0
|
||||||
|
) {
|
||||||
|
return array.fill(selectedValue);
|
||||||
|
}
|
||||||
|
return this.obsCrossfilter.fillByIsSelected(
|
||||||
|
array,
|
||||||
|
selectedValue,
|
||||||
|
deselectedValue
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
** Private below
|
||||||
|
**/
|
||||||
|
|
||||||
|
_addObsCrossfilterDimension(annoMatrix, obsCrossfilter, field, df) {
|
||||||
|
if (field === "var") return obsCrossfilter;
|
||||||
|
const dimName = _dimensionNameFromDf(field, df);
|
||||||
|
const dimParams = this._getObsDimensionParams(field, df);
|
||||||
|
obsCrossfilter = obsCrossfilter.setData(annoMatrix._cache.obs);
|
||||||
|
obsCrossfilter = obsCrossfilter.addDimension(dimName, ...dimParams);
|
||||||
|
return obsCrossfilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
_getColumnBaseType(field, col) {
|
||||||
|
/* Look up the primitive type for this field/col */
|
||||||
|
const colSchema = _getColumnSchema(this.annoMatrix.schema, field, col);
|
||||||
|
return colSchema.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
_getObsDimensionParams(field, df) {
|
||||||
|
/* return the crossfilter dimensiontype type and params for this field/dataframe */
|
||||||
|
|
||||||
|
if (field === "emb") {
|
||||||
|
/* assumed to be 2D */
|
||||||
|
return ["spatial", df.icol(0).asArray(), df.icol(1).asArray()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* assumed to be 1D */
|
||||||
|
const col = df.icol(0);
|
||||||
|
const colName = df.colIndex.getLabel(0);
|
||||||
|
const type = this._getColumnBaseType(field, colName);
|
||||||
|
if (type === "string" || type === "categorical" || type === "boolean") {
|
||||||
|
return ["enum", col.asArray()];
|
||||||
|
}
|
||||||
|
if (type === "int32") {
|
||||||
|
return ["scalar", col.asArray(), Int32Array];
|
||||||
|
}
|
||||||
|
if (type === "float32") {
|
||||||
|
return ["scalar", col.asArray(), Float32Array];
|
||||||
|
}
|
||||||
|
// Currently not supporting boolean and categorical types.
|
||||||
|
console.error(
|
||||||
|
`Warning - unknown metadata schema (${type}) for field ${field} ${colName}.`
|
||||||
|
);
|
||||||
|
// skip it - we don't know what to do with this type
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
export { doBinaryRequest } from "../util/actionHelpers";
|
||||||
|
|
||||||
|
/* double URI encode - needed for query-param filters */
|
||||||
|
export function _dubEncURIComp(s) {
|
||||||
|
return encodeURIComponent(encodeURIComponent(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* currently unused, consider deleting */
|
||||||
|
export function _fetchResult(promise) {
|
||||||
|
let _status = "pending";
|
||||||
|
const res = promise.then(
|
||||||
|
(r) => {
|
||||||
|
_status = "success";
|
||||||
|
return r;
|
||||||
|
},
|
||||||
|
(e) => {
|
||||||
|
_status = "error";
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status = () => {
|
||||||
|
return _status;
|
||||||
|
};
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/*
|
||||||
|
AnnoMatrix -- Annotated Matrix exported interface
|
||||||
|
|
||||||
|
Public API is defined in:
|
||||||
|
|
||||||
|
annoMatrix.js
|
||||||
|
viewCreators.js
|
||||||
|
crossfilter.js
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { default as AnnoMatrixLoader } from "./loader";
|
||||||
|
export * from "./viewCreators";
|
||||||
|
export { default as AnnoMatrixObsCrossfilter } from "./crossfilter";
|
||||||
|
export { default as gcMiddleware } from "./middleware";
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import { doBinaryRequest, _dubEncURIComp } from "./fetchHelpers";
|
||||||
|
import { matrixFBSToDataframe } from "../util/stateManager/matrix";
|
||||||
|
import { _getColumnSchema, _normalizeCategoricalSchema } from "./schema";
|
||||||
|
import {
|
||||||
|
addObsAnnoColumn,
|
||||||
|
removeObsAnnoColumn,
|
||||||
|
addObsAnnoCategory,
|
||||||
|
removeObsAnnoCategory,
|
||||||
|
} from "../util/stateManager/schemaHelpers";
|
||||||
|
import { isArrayOrTypedArray } from "../util/typeHelpers";
|
||||||
|
import { _whereCacheCreate } from "./whereCache";
|
||||||
|
import AnnoMatrix from "./annoMatrix";
|
||||||
|
import PromiseLimit from "../util/promiseLimit";
|
||||||
|
|
||||||
|
const promiseThrottle = new PromiseLimit(5);
|
||||||
|
|
||||||
|
export default class AnnoMatrixLoader extends AnnoMatrix {
|
||||||
|
/*
|
||||||
|
AnnoMatrix implementation which proxies to HTTP server using the CXG REST API.
|
||||||
|
Used as the base (non-view) instance.
|
||||||
|
|
||||||
|
Public API is same as AnnoMatrix class (refer there for API description),
|
||||||
|
with the addition of the constructor which bootstraps:
|
||||||
|
|
||||||
|
new AnnoMatrixLoader(serverBaseURL, schema) -> instance
|
||||||
|
|
||||||
|
*/
|
||||||
|
constructor(baseURL, schema) {
|
||||||
|
const { nObs, nVar } = schema.dataframe;
|
||||||
|
super(schema, nObs, nVar);
|
||||||
|
|
||||||
|
if (baseURL[baseURL.length - 1] !== "/") {
|
||||||
|
// must have trailing slash
|
||||||
|
baseURL += "/";
|
||||||
|
}
|
||||||
|
this.baseURL = baseURL;
|
||||||
|
Object.seal(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
** Public. API described in base class.
|
||||||
|
**/
|
||||||
|
addObsAnnoCategory(col, category) {
|
||||||
|
/*
|
||||||
|
Add a new category (aka label) to the schema for an obs column.
|
||||||
|
*/
|
||||||
|
const colSchema = _getColumnSchema(this.schema, "obs", col);
|
||||||
|
_writableCategoryTypeCheck(colSchema); // throws on error
|
||||||
|
|
||||||
|
const o = this._clone();
|
||||||
|
o.schema = addObsAnnoCategory(this.schema, col, category);
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeObsAnnoCategory(col, category, unassignedCategory) {
|
||||||
|
/*
|
||||||
|
Remove a single "category" (aka "label") from the data & schema of an obs column.
|
||||||
|
*/
|
||||||
|
const colSchema = _getColumnSchema(this.schema, "obs", col);
|
||||||
|
_writableCategoryTypeCheck(colSchema); // throws on error
|
||||||
|
|
||||||
|
const o = await this.resetObsColumnValues(
|
||||||
|
col,
|
||||||
|
category,
|
||||||
|
unassignedCategory
|
||||||
|
);
|
||||||
|
o.schema = removeObsAnnoCategory(o.schema, col, category);
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
dropObsColumn(col) {
|
||||||
|
/*
|
||||||
|
drop column from field
|
||||||
|
*/
|
||||||
|
const colSchema = _getColumnSchema(this.schema, "obs", col);
|
||||||
|
_writableCheck(colSchema); // throws on error
|
||||||
|
|
||||||
|
const o = this._clone();
|
||||||
|
o._cache.obs = this._cache.obs.dropCol(col);
|
||||||
|
o.schema = removeObsAnnoColumn(this.schema, col);
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
addObsColumn(colSchema, Ctor, value) {
|
||||||
|
/*
|
||||||
|
add a column to field, initializing with value. Value may
|
||||||
|
be one of:
|
||||||
|
* an array of values
|
||||||
|
* a primitive type, including null or undefined.
|
||||||
|
If an array, it must be of same size as nObs and same type as Ctor
|
||||||
|
*/
|
||||||
|
colSchema.writable = true;
|
||||||
|
const col = colSchema.name;
|
||||||
|
if (
|
||||||
|
_getColumnSchema(this.schema, "obs", col) ||
|
||||||
|
this._cache.obs.hasCol(col)
|
||||||
|
) {
|
||||||
|
throw new Error("column already exists");
|
||||||
|
}
|
||||||
|
|
||||||
|
const o = this._clone();
|
||||||
|
let data;
|
||||||
|
if (isArrayOrTypedArray(value)) {
|
||||||
|
if (value.constructor !== Ctor)
|
||||||
|
throw new Error("Mismatched value array type");
|
||||||
|
if (value.length !== this.nObs)
|
||||||
|
throw new Error("Value array has incorrect length");
|
||||||
|
data = value.slice();
|
||||||
|
} else {
|
||||||
|
data = new Ctor(this.nObs).fill(value);
|
||||||
|
}
|
||||||
|
o._cache.obs = this._cache.obs.withCol(col, data);
|
||||||
|
o.schema = addObsAnnoColumn(this.schema, col, {
|
||||||
|
...colSchema,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
renameObsColumn(oldCol, newCol) {
|
||||||
|
/*
|
||||||
|
Rename the obs oldColName to newColName. oldCol must be writable.
|
||||||
|
*/
|
||||||
|
const oldColSchema = _getColumnSchema(this.schema, "obs", oldCol);
|
||||||
|
_writableCheck(oldColSchema); // throws on error
|
||||||
|
|
||||||
|
const value = this._cache.obs.hasCol(oldCol)
|
||||||
|
? this._cache.obs.col(oldCol).asArray()
|
||||||
|
: undefined;
|
||||||
|
return this.dropObsColumn(oldCol).addObsColumn(
|
||||||
|
{
|
||||||
|
...oldColSchema,
|
||||||
|
name: newCol,
|
||||||
|
},
|
||||||
|
value.constructor,
|
||||||
|
value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setObsColumnValues(col, rowLabels, value) {
|
||||||
|
/*
|
||||||
|
Set all rows identified by rowLabels to value.
|
||||||
|
*/
|
||||||
|
const colSchema = _getColumnSchema(this.schema, "obs", col);
|
||||||
|
_writableCategoryTypeCheck(colSchema); // throws on error
|
||||||
|
|
||||||
|
// ensure that we have the data in cache before we manipulate it
|
||||||
|
await this.fetch("obs", col);
|
||||||
|
if (!this._cache.obs.hasCol(col))
|
||||||
|
throw new Error("Internal error - user annotation data missing");
|
||||||
|
|
||||||
|
const rowIndices = this.rowIndex.getOffsets(rowLabels);
|
||||||
|
const data = this._cache.obs.col(col).asArray().slice();
|
||||||
|
for (let i = 0, len = rowIndices.length; i < len; i += 1) {
|
||||||
|
const idx = rowIndices[i];
|
||||||
|
if (idx === undefined) throw new Error("Unknown row label");
|
||||||
|
data[idx] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const o = this._clone();
|
||||||
|
o._cache.obs = this._cache.obs.replaceColData(col, data);
|
||||||
|
const { categories } = colSchema;
|
||||||
|
if (!categories?.includes(value)) {
|
||||||
|
o.schema = addObsAnnoCategory(this.schema, col, value);
|
||||||
|
}
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetObsColumnValues(col, oldValue, newValue) {
|
||||||
|
/*
|
||||||
|
Set all rows with value 'oldValue' to 'newValue'.
|
||||||
|
*/
|
||||||
|
const colSchema = _getColumnSchema(this.schema, "obs", col);
|
||||||
|
_writableCategoryTypeCheck(colSchema); // throws on error
|
||||||
|
|
||||||
|
if (!colSchema.categories.includes(oldValue)) {
|
||||||
|
throw new Error("unknown category");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure that we have the data in cache before we manipulate it
|
||||||
|
await this.fetch("obs", col);
|
||||||
|
if (!this._cache.obs.hasCol(col))
|
||||||
|
throw new Error("Internal error - user annotation data missing");
|
||||||
|
|
||||||
|
const data = this._cache.obs.col(col).asArray().slice();
|
||||||
|
for (let i = 0, l = data.length; i < l; i += 1) {
|
||||||
|
if (data[i] === oldValue) data[i] = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const o = this._clone();
|
||||||
|
o._cache.obs = this._cache.obs.replaceColData(col, data);
|
||||||
|
const { categories } = colSchema;
|
||||||
|
if (!categories?.includes(newValue)) {
|
||||||
|
o.schema = addObsAnnoCategory(this.schema, col, newValue);
|
||||||
|
}
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
** Private below
|
||||||
|
**/
|
||||||
|
async _doLoad(field, query) {
|
||||||
|
/*
|
||||||
|
_doLoad - evaluates the query against the field. Returns:
|
||||||
|
* whereCache update: column query map mapping the query to the column labels
|
||||||
|
* Dataframe containing the new colums (one per dimension)
|
||||||
|
*/
|
||||||
|
let urlQuery;
|
||||||
|
let urlBase;
|
||||||
|
let priority = 10; // default fetch priority
|
||||||
|
|
||||||
|
switch (field) {
|
||||||
|
case "obs":
|
||||||
|
case "var": {
|
||||||
|
urlBase = `${this.baseURL}annotations/${field}`;
|
||||||
|
urlQuery = _encodeQuery("annotation-name", query);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "X": {
|
||||||
|
urlBase = `${this.baseURL}data/var`;
|
||||||
|
urlQuery = _encodeQuery(undefined, query);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "emb": {
|
||||||
|
urlBase = `${this.baseURL}layout/obs`;
|
||||||
|
urlQuery = _encodeQuery("layout-name", query);
|
||||||
|
priority = 0; // high prio load for embeddings
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error("Unknown field name");
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${urlBase}?${urlQuery}`;
|
||||||
|
const buffer = await promiseThrottle.priorityAdd(
|
||||||
|
priority,
|
||||||
|
doBinaryRequest,
|
||||||
|
url
|
||||||
|
);
|
||||||
|
const result = matrixFBSToDataframe(buffer);
|
||||||
|
if (!result || result.isEmpty()) throw Error("Unknown field/col");
|
||||||
|
|
||||||
|
const whereCacheUpdate = _whereCacheCreate(
|
||||||
|
field,
|
||||||
|
query,
|
||||||
|
result.colIndex.labels()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (field === "obs") {
|
||||||
|
/* cough, cough - see comment on method */
|
||||||
|
_normalizeCategoricalSchema(
|
||||||
|
this.schema.annotations.obsByName[query],
|
||||||
|
result.col(query)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [whereCacheUpdate, result];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Utility functions below
|
||||||
|
*/
|
||||||
|
|
||||||
|
function _encodeQuery(colKey, q) {
|
||||||
|
if (typeof q === "object") {
|
||||||
|
const { field: queryField, column: queryColumn, value: queryValue } = q;
|
||||||
|
return `${_dubEncURIComp(queryField)}:${_dubEncURIComp(
|
||||||
|
queryColumn
|
||||||
|
)}=${_dubEncURIComp(queryValue)}`;
|
||||||
|
}
|
||||||
|
if (!colKey) throw new Error("Unsupported query by name");
|
||||||
|
return `${colKey}=${encodeURIComponent(q)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _writableCheck(colSchema) {
|
||||||
|
if (!colSchema?.writable) {
|
||||||
|
throw new Error("Unknown or readonly obs column");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _writableCategoryTypeCheck(colSchema) {
|
||||||
|
_writableCheck(colSchema);
|
||||||
|
if (colSchema.type !== "categorical") {
|
||||||
|
throw new Error("column must be categorical");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/*
|
||||||
|
Garbage collection / cache management support
|
||||||
|
|
||||||
|
Middleware that knows how to pull annoMatrix from the undoable state,
|
||||||
|
and pass it along to the AnnoMatrix class for possible cache GC.
|
||||||
|
|
||||||
|
Private interface.
|
||||||
|
|
||||||
|
Future work item: this middleware knows internal details of both the
|
||||||
|
Undoable metareducer and the AnnoMatrix private API. It would be helpful
|
||||||
|
to make the Undoable interface better factored.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const annoMatrixGC = (store) => (next) => (action) => {
|
||||||
|
if (_itIsTimeForGC()) {
|
||||||
|
_doGC(store);
|
||||||
|
}
|
||||||
|
return next(action);
|
||||||
|
};
|
||||||
|
|
||||||
|
let lastGCTime = 0;
|
||||||
|
const InterGCDelayMS = 30 * 1000; // 30 seconds
|
||||||
|
function _itIsTimeForGC() {
|
||||||
|
/*
|
||||||
|
we don't want to run GC on every dispatch, so throttle it a bit.
|
||||||
|
|
||||||
|
Runs every InterGCDelay period
|
||||||
|
*/
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastGCTime > InterGCDelayMS) {
|
||||||
|
lastGCTime = now;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _doGC(store) {
|
||||||
|
const state = store.getState();
|
||||||
|
|
||||||
|
// these should probably be a function imported from undoable.js, etc, as
|
||||||
|
// they have overly intimiate knowledge of our reducers.
|
||||||
|
const undoablePast = state["@@undoable/past"];
|
||||||
|
const undoableFuture = state["@@undoable/future"];
|
||||||
|
const undoableStack = undoablePast
|
||||||
|
.concat(undoableFuture)
|
||||||
|
.flatMap((snapshot) =>
|
||||||
|
snapshot.filter((v) => v[0] === "annoMatrix").map((v) => v[1])
|
||||||
|
);
|
||||||
|
const currentAnnoMatrix = state.annoMatrix;
|
||||||
|
|
||||||
|
/*
|
||||||
|
We want to identify those matrixes currently "hot", ie, linked from the current annoMatrix,
|
||||||
|
as our current gc algo is more aggressive with those not hot.
|
||||||
|
*/
|
||||||
|
const allAnnoMatrices = new Map(
|
||||||
|
undoableStack.map((m) => [m, { isHot: false }])
|
||||||
|
);
|
||||||
|
let am = currentAnnoMatrix;
|
||||||
|
while (am) {
|
||||||
|
allAnnoMatrices.set(am, { isHot: true });
|
||||||
|
am = am.viewOf;
|
||||||
|
}
|
||||||
|
allAnnoMatrices.forEach((hints, annoMatrix) => annoMatrix._gc(hints));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default annoMatrixGC;
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/*
|
||||||
|
Private helper functions related to schema
|
||||||
|
*/
|
||||||
|
import catLabelSort from "../util/catLabelSort";
|
||||||
|
import { unassignedCategoryLabel } from "../globals";
|
||||||
|
|
||||||
|
export function _getColumnSchema(schema, field, col) {
|
||||||
|
/* look up the column definition */
|
||||||
|
switch (field) {
|
||||||
|
case "obs":
|
||||||
|
if (typeof col === "object")
|
||||||
|
throw new Error("unable to get column schema by query");
|
||||||
|
return schema.annotations.obsByName[col];
|
||||||
|
case "var":
|
||||||
|
if (typeof col === "object")
|
||||||
|
throw new Error("unable to get column schema by query");
|
||||||
|
return schema.annotations.varByName[col];
|
||||||
|
case "emb":
|
||||||
|
if (typeof col === "object")
|
||||||
|
throw new Error("unable to get column schema by query");
|
||||||
|
return schema.layout.obsByName[col];
|
||||||
|
case "X":
|
||||||
|
return schema.dataframe;
|
||||||
|
default:
|
||||||
|
throw new Error(`unknown field name: ${field}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function _getColumnDimensionNames(schema, field, col) {
|
||||||
|
/*
|
||||||
|
field/col may be an alias for multiple columns. Currently used to map ND
|
||||||
|
values to 1D dataframe columns for embeddings/layout. Signfied by the presence
|
||||||
|
of the "dims" value in the schema.
|
||||||
|
*/
|
||||||
|
const colSchema = _getColumnSchema(schema, field, col);
|
||||||
|
if (!colSchema) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return colSchema.dims || [col];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function _schemaColumns(schema, field) {
|
||||||
|
switch (field) {
|
||||||
|
case "obs":
|
||||||
|
return Object.keys(schema.annotations.obsByName);
|
||||||
|
case "var":
|
||||||
|
return Object.keys(schema.annotations.varByName);
|
||||||
|
case "emb":
|
||||||
|
return Object.keys(schema.layout.obsByName);
|
||||||
|
default:
|
||||||
|
throw new Error(`unknown field name: ${field}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function _getWritableColumns(schema, field) {
|
||||||
|
if (field !== "obs") return [];
|
||||||
|
return schema.annotations.obs.columns
|
||||||
|
.filter((v) => v.writable)
|
||||||
|
.map((v) => v.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function _isContinuousType(schema) {
|
||||||
|
const { type } = schema;
|
||||||
|
return !(type === "string" || type === "boolean" || type === "categorical");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function _normalizeCategoricalSchema(colSchema, col) {
|
||||||
|
const { type, writable } = colSchema;
|
||||||
|
if (type === "string" || type === "boolean" || type === "categorical") {
|
||||||
|
const categorySet = new Set(
|
||||||
|
col.summarize().categories.concat(colSchema.categories ?? [])
|
||||||
|
);
|
||||||
|
if (writable && !categorySet.has(unassignedCategoryLabel)) {
|
||||||
|
categorySet.add(unassignedCategoryLabel);
|
||||||
|
}
|
||||||
|
colSchema.categories = Array.from(categorySet);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (colSchema.categories) {
|
||||||
|
colSchema.categories = catLabelSort(writable, colSchema.categories);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/*
|
||||||
|
View creators. These are helper functions which create new views from existing
|
||||||
|
instances of AnnoMatrix, implementing common UI functions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { AnnoMatrixRowSubsetView, AnnoMatrixClipView } from "./views";
|
||||||
|
|
||||||
|
export function isubsetMask(annoMatrix, obsMask) {
|
||||||
|
/*
|
||||||
|
Subset annomatrix to contain the rows which have truish value in the mask.
|
||||||
|
Maks length must equal annoMatrix.nObs (row count).
|
||||||
|
*/
|
||||||
|
return isubset(annoMatrix, _maskToList(obsMask));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isubset(annoMatrix, obsOffsets) {
|
||||||
|
/*
|
||||||
|
Subset annomatrix to contain the positions contained in the obsOffsets array
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
isubset(annoMatrix, [0, 1]) -> annoMatrix with only the first two rows
|
||||||
|
*/
|
||||||
|
const obsIndex = annoMatrix.rowIndex.isubset(obsOffsets);
|
||||||
|
return new AnnoMatrixRowSubsetView(annoMatrix, obsIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subset(annoMatrix, obsLabels) {
|
||||||
|
/*
|
||||||
|
subset based on labels
|
||||||
|
*/
|
||||||
|
const obsIndex = annoMatrix.rowIndex.subset(obsLabels);
|
||||||
|
return new AnnoMatrixRowSubsetView(annoMatrix, obsIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clip(annoMatrix, qmin, qmax) {
|
||||||
|
/*
|
||||||
|
Create a view that clips all continuous data to the [min, max] range.
|
||||||
|
The matrix shape does not change, but the continuous values outside the
|
||||||
|
specified range will become a NaN.
|
||||||
|
*/
|
||||||
|
return new AnnoMatrixClipView(annoMatrix, qmin, qmax);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Private utility functions below
|
||||||
|
*/
|
||||||
|
|
||||||
|
function _maskToList(mask) {
|
||||||
|
/* convert masks to lists - method wastes space, but is fast */
|
||||||
|
if (!mask) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const list = new Int32Array(mask.length);
|
||||||
|
let elems = 0;
|
||||||
|
for (let i = 0, l = mask.length; i < l; i += 1) {
|
||||||
|
if (mask[i]) {
|
||||||
|
list[elems] = i;
|
||||||
|
elems += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Int32Array(list.buffer, 0, elems);
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
/* eslint-disable max-classes-per-file -- Classes are interrelated*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
Views on the annomatrix. all API here is defined in viewCreators.js and annoMatrix.js.
|
||||||
|
*/
|
||||||
|
import clip from "../util/clip";
|
||||||
|
import AnnoMatrix from "./annoMatrix";
|
||||||
|
import { _whereCacheCreate } from "./whereCache";
|
||||||
|
import { _isContinuousType, _getColumnSchema } from "./schema";
|
||||||
|
|
||||||
|
class AnnoMatrixView extends AnnoMatrix {
|
||||||
|
constructor(viewOf, rowIndex = null) {
|
||||||
|
const nObs = rowIndex ? rowIndex.size() : viewOf.nObs;
|
||||||
|
super(viewOf.schema, nObs, viewOf.nVar, rowIndex || viewOf.rowIndex);
|
||||||
|
this.viewOf = viewOf;
|
||||||
|
this.isView = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
addObsAnnoCategory(col, category) {
|
||||||
|
const o = this._clone();
|
||||||
|
o.viewOf = this.viewOf.addObsAnnoCategory(col, category);
|
||||||
|
o.schema = o.viewOf.schema;
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeObsAnnoCategory(col, category, unassignedCategory) {
|
||||||
|
const o = this._clone();
|
||||||
|
o.viewOf = await this.viewOf.removeObsAnnoCategory(
|
||||||
|
col,
|
||||||
|
category,
|
||||||
|
unassignedCategory
|
||||||
|
);
|
||||||
|
o.schema = o.viewOf.schema;
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
dropObsColumn(col) {
|
||||||
|
const o = this._clone();
|
||||||
|
o.viewOf = this.viewOf.dropObsColumn(col);
|
||||||
|
o._cache.obs = this._cache.obs.dropCol(col);
|
||||||
|
o.schema = o.viewOf.schema;
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
addObsColumn(colSchema, Ctor, value) {
|
||||||
|
const o = this._clone();
|
||||||
|
o.viewOf = this.viewOf.addObsColumn(colSchema, Ctor, value);
|
||||||
|
o.schema = o.viewOf.schema;
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
renameObsColumn(oldCol, newCol) {
|
||||||
|
const o = this._clone();
|
||||||
|
o.viewOf = this.viewOf.renameObsColumn(oldCol, newCol);
|
||||||
|
o.schema = o.viewOf.schema;
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setObsColumnValues(col, rowLabels, value) {
|
||||||
|
const o = this._clone();
|
||||||
|
o.viewOf = await this.viewOf.setObsColumnValues(col, rowLabels, value);
|
||||||
|
o._cache.obs = this._cache.obs.dropCol(col);
|
||||||
|
o.schema = o.viewOf.schema;
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetObsColumnValues(col, oldValue, newValue) {
|
||||||
|
const o = this._clone();
|
||||||
|
o.viewOf = await this.viewOf.resetObsColumnValues(col, oldValue, newValue);
|
||||||
|
o._cache.obs = this._cache.obs.dropCol(col);
|
||||||
|
o.schema = o.viewOf.schema;
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AnnoMatrixMapView extends AnnoMatrixView {
|
||||||
|
/*
|
||||||
|
A view which knows how to transform its data.
|
||||||
|
*/
|
||||||
|
constructor(viewOf, mapFn) {
|
||||||
|
super(viewOf);
|
||||||
|
this.mapFn = mapFn;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _doLoad(field, query) {
|
||||||
|
const df = await this.viewOf._fetch(field, query);
|
||||||
|
const dfMapped = df.mapColumns((colData, colIdx) => {
|
||||||
|
const colLabel = df.colIndex.getLabel(colIdx);
|
||||||
|
const colSchema = _getColumnSchema(this.schema, field, colLabel);
|
||||||
|
return this.mapFn(field, colLabel, colSchema, colData, df);
|
||||||
|
});
|
||||||
|
const whereCacheUpdate = _whereCacheCreate(
|
||||||
|
field,
|
||||||
|
query,
|
||||||
|
dfMapped.colIndex.labels()
|
||||||
|
);
|
||||||
|
return [whereCacheUpdate, dfMapped];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AnnoMatrixClipView extends AnnoMatrixMapView {
|
||||||
|
/*
|
||||||
|
A view which is a clipped transformation of its parent
|
||||||
|
*/
|
||||||
|
constructor(viewOf, qmin, qmax) {
|
||||||
|
super(viewOf, (field, colLabel, colSchema, colData, df) =>
|
||||||
|
_clipAnnoMatrix(field, colLabel, colSchema, colData, df, qmin, qmax)
|
||||||
|
);
|
||||||
|
this.isClipped = true;
|
||||||
|
this.clipRange = [qmin, qmax];
|
||||||
|
Object.seal(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AnnoMatrixRowSubsetView extends AnnoMatrixView {
|
||||||
|
/*
|
||||||
|
A view which is a subset of total rows.
|
||||||
|
*/
|
||||||
|
constructor(viewOf, rowIndex) {
|
||||||
|
super(viewOf, rowIndex);
|
||||||
|
Object.seal(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _doLoad(field, query) {
|
||||||
|
const df = await this.viewOf._fetch(field, query);
|
||||||
|
|
||||||
|
// don't try to row-subset the var dimension.
|
||||||
|
if (field === "var") {
|
||||||
|
return [null, df];
|
||||||
|
}
|
||||||
|
|
||||||
|
const dfSubset = df.subset(null, null, this.rowIndex);
|
||||||
|
const whereCacheUpdate = _whereCacheCreate(
|
||||||
|
field,
|
||||||
|
query,
|
||||||
|
dfSubset.colIndex.labels()
|
||||||
|
);
|
||||||
|
return [whereCacheUpdate, dfSubset];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Utility functions below
|
||||||
|
*/
|
||||||
|
|
||||||
|
function _clipAnnoMatrix(field, colLabel, colSchema, colData, df, qmin, qmax) {
|
||||||
|
/* only clip obs and var scalar columns */
|
||||||
|
if (field !== "obs" && field !== "X") return colData;
|
||||||
|
if (!_isContinuousType(colSchema)) return colData;
|
||||||
|
if (qmin < 0) qmin = 0;
|
||||||
|
if (qmax > 1) qmax = 1;
|
||||||
|
if (qmin === 0 && qmax === 1) return colData;
|
||||||
|
|
||||||
|
const quantiles = df.col(colLabel).summarize().percentiles;
|
||||||
|
const lower = quantiles[100 * qmin];
|
||||||
|
const upper = quantiles[100 * qmax];
|
||||||
|
const clippedData = clip(colData.slice(), lower, upper, Number.NaN);
|
||||||
|
return clippedData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* eslint-enable max-classes-per-file -- enable*/
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/*
|
||||||
|
Private support functions.
|
||||||
|
|
||||||
|
Support for a "where" query, eg,
|
||||||
|
|
||||||
|
{ where: { field: "var", column: "gene", value: "FOXP2" }}
|
||||||
|
|
||||||
|
These evaluate to a given column label.
|
||||||
|
|
||||||
|
The "where cache" is a map that saves evaluated queries and points
|
||||||
|
to the column label they resolve to.
|
||||||
|
|
||||||
|
Data structure, using X as the example field being queried, and var as
|
||||||
|
the index.
|
||||||
|
|
||||||
|
{
|
||||||
|
X: {
|
||||||
|
var: Map(
|
||||||
|
column_label_in_var => Map(value_in_var_column => [column_label_in_X, ...])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
import { _getColumnDimensionNames } from "./schema";
|
||||||
|
|
||||||
|
export function _whereCacheGet(whereCache, schema, field, query) {
|
||||||
|
/*
|
||||||
|
query will either be an where query (object) or a column name (string).
|
||||||
|
|
||||||
|
Return array of column labels or undefined.
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (typeof query === "object") {
|
||||||
|
const { field: queryField, column: queryColumn, value: queryValue } = query;
|
||||||
|
|
||||||
|
const columnMap = whereCache?.[field]?.[queryField];
|
||||||
|
if (columnMap === undefined) return [undefined];
|
||||||
|
|
||||||
|
const valueMap = columnMap.get(queryColumn);
|
||||||
|
if (valueMap === undefined) return [undefined];
|
||||||
|
|
||||||
|
const columnLabels = valueMap.get(queryValue);
|
||||||
|
return columnLabels === undefined ? [undefined] : columnLabels;
|
||||||
|
}
|
||||||
|
|
||||||
|
const colDims = _getColumnDimensionNames(schema, field, query);
|
||||||
|
return colDims === undefined ? [undefined] : colDims;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function _whereCacheCreate(field, query, columnLabels) {
|
||||||
|
/*
|
||||||
|
Create a new whereCache
|
||||||
|
*/
|
||||||
|
if (typeof query !== "object") return null;
|
||||||
|
|
||||||
|
const { field: queryField, column: queryColumn, value: queryValue } = query;
|
||||||
|
const whereCache = {
|
||||||
|
[field]: {
|
||||||
|
[queryField]: new Map([
|
||||||
|
[queryColumn, new Map([[queryValue, columnLabels]])],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return whereCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
function __whereCacheMerge(dst, src) {
|
||||||
|
/*
|
||||||
|
merge src into dst (modifies dst)
|
||||||
|
*/
|
||||||
|
if (!dst) dst = {};
|
||||||
|
if (!src || typeof src !== "object") return dst;
|
||||||
|
Object.entries(src).forEach(([field, query]) => {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(dst, field)) dst[field] = {};
|
||||||
|
Object.entries(query).forEach(([queryField, columnMap]) => {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(dst[field], queryField))
|
||||||
|
dst[field][queryField] = new Map();
|
||||||
|
columnMap.forEach((valueMap, queryColumn) => {
|
||||||
|
if (!dst[field][queryField].has(queryColumn))
|
||||||
|
dst[field][queryField].set(queryColumn, new Map());
|
||||||
|
valueMap.forEach((columnLabels, queryValue) => {
|
||||||
|
dst[field][queryField].get(queryColumn).set(queryValue, columnLabels);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return dst;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function _whereCacheMerge(...caches) {
|
||||||
|
return caches.reduce((dst, src) => __whereCacheMerge(dst, src), {});
|
||||||
|
}
|
||||||
@@ -64,10 +64,10 @@ class App extends React.Component {
|
|||||||
left: window.innerWidth / 2 - 50,
|
left: window.innerWidth / 2 - 50,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
error loading
|
error loading cellxgene
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{loading ? null : (
|
{loading || error ? null : (
|
||||||
<Layout>
|
<Layout>
|
||||||
<LeftSideBar />
|
<LeftSideBar />
|
||||||
{(viewportRef) => (
|
{(viewportRef) => (
|
||||||
|
|||||||
@@ -11,13 +11,8 @@ import {
|
|||||||
} from "@blueprintjs/core";
|
} from "@blueprintjs/core";
|
||||||
|
|
||||||
@connect((state) => ({
|
@connect((state) => ({
|
||||||
universe: state.universe,
|
|
||||||
idhash: state.config?.parameters?.["annotations-user-data-idhash"] ?? null,
|
idhash: state.config?.parameters?.["annotations-user-data-idhash"] ?? null,
|
||||||
annotations: state.annotations,
|
annotations: state.annotations,
|
||||||
obsAnnotations: state.universe.obsAnnotations,
|
|
||||||
saveInProgress: state.autosave?.saveInProgress ?? false,
|
|
||||||
lastSavedObsAnnotations: state.autosave?.lastSavedObsAnnotations,
|
|
||||||
error: state.autosave?.error,
|
|
||||||
writableCategoriesEnabled: state.config?.parameters?.annotations ?? false,
|
writableCategoriesEnabled: state.config?.parameters?.annotations ?? false,
|
||||||
}))
|
}))
|
||||||
class FilenameDialog extends React.Component {
|
class FilenameDialog extends React.Component {
|
||||||
@@ -113,7 +108,7 @@ class FilenameDialog extends React.Component {
|
|||||||
this.handleCreateFilename();
|
this.handleCreateFilename();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className={Classes.DIALOG_BODY}>
|
<div className={Classes.DIALOG_BODY} data-testid="annotation-dialog">
|
||||||
<div style={{ marginBottom: 20 }}>
|
<div style={{ marginBottom: 20 }}>
|
||||||
<p>Name your annotations collection:</p>
|
<p>Name your annotations collection:</p>
|
||||||
<InputGroup
|
<InputGroup
|
||||||
@@ -124,6 +119,7 @@ class FilenameDialog extends React.Component {
|
|||||||
this.setState({ filenameText: e.target.value })
|
this.setState({ filenameText: e.target.value })
|
||||||
}
|
}
|
||||||
leftIcon="tag"
|
leftIcon="tag"
|
||||||
|
data-testid="new-annotation-name"
|
||||||
/>
|
/>
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
@@ -159,6 +155,7 @@ class FilenameDialog extends React.Component {
|
|||||||
onClick={this.handleCreateFilename}
|
onClick={this.handleCreateFilename}
|
||||||
intent="primary"
|
intent="primary"
|
||||||
type="submit"
|
type="submit"
|
||||||
|
data-testid="submit-annotation"
|
||||||
>
|
>
|
||||||
Create annotations collection
|
Create annotations collection
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -4,14 +4,12 @@ import actions from "../../actions";
|
|||||||
import FilenameDialog from "./filenameDialog";
|
import FilenameDialog from "./filenameDialog";
|
||||||
|
|
||||||
@connect((state) => ({
|
@connect((state) => ({
|
||||||
universe: state.universe,
|
|
||||||
annotations: state.annotations,
|
annotations: state.annotations,
|
||||||
obsAnnotations: state.universe.obsAnnotations,
|
|
||||||
saveInProgress: state.autosave?.saveInProgress ?? false,
|
saveInProgress: state.autosave?.saveInProgress ?? false,
|
||||||
lastSavedObsAnnotations: state.autosave?.lastSavedObsAnnotations,
|
|
||||||
error: state.autosave?.error,
|
error: state.autosave?.error,
|
||||||
writableCategoriesEnabled: state.config?.parameters?.annotations ?? false,
|
writableCategoriesEnabled: state.config?.parameters?.annotations ?? false,
|
||||||
initialDataLoadComplete: state.autosave?.initialDataLoadComplete,
|
annoMatrix: state.annoMatrix,
|
||||||
|
lastSavedAnnoMatrix: state.autosave?.lastSavedAnnoMatrix,
|
||||||
}))
|
}))
|
||||||
class Autosave extends React.Component {
|
class Autosave extends React.Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@@ -42,16 +40,14 @@ class Autosave extends React.Component {
|
|||||||
tick = () => {
|
tick = () => {
|
||||||
const { dispatch, saveInProgress } = this.props;
|
const { dispatch, saveInProgress } = this.props;
|
||||||
if (this.needToSave() && !saveInProgress) {
|
if (this.needToSave() && !saveInProgress) {
|
||||||
dispatch(actions.saveObsAnnotations());
|
dispatch(actions.saveObsAnnotationsAction());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
needToSave = () => {
|
needToSave = () => {
|
||||||
/* return true if we need to save, false if we don't */
|
/* return true if we need to save, false if we don't */
|
||||||
const { obsAnnotations, lastSavedObsAnnotations } = this.props;
|
const { annoMatrix, lastSavedAnnoMatrix } = this.props;
|
||||||
return (
|
return actions.needToSaveObsAnnotations(annoMatrix, lastSavedAnnoMatrix);
|
||||||
lastSavedObsAnnotations && obsAnnotations !== lastSavedObsAnnotations
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
statusMessage() {
|
statusMessage() {
|
||||||
@@ -66,9 +62,13 @@ class Autosave extends React.Component {
|
|||||||
const {
|
const {
|
||||||
writableCategoriesEnabled,
|
writableCategoriesEnabled,
|
||||||
saveInProgress,
|
saveInProgress,
|
||||||
initialDataLoadComplete,
|
lastSavedAnnoMatrix,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
return writableCategoriesEnabled ? (
|
const initialDataLoadComplete = lastSavedAnnoMatrix;
|
||||||
|
|
||||||
|
if (!writableCategoriesEnabled) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
id="autosave"
|
id="autosave"
|
||||||
data-testclass={
|
data-testclass={
|
||||||
@@ -89,7 +89,7 @@ class Autosave extends React.Component {
|
|||||||
{this.statusMessage()}
|
{this.statusMessage()}
|
||||||
<FilenameDialog />
|
<FilenameDialog />
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { connect } from "react-redux";
|
|
||||||
import { Button, Tooltip, Dialog, Classes, Colors } from "@blueprintjs/core";
|
import { Button, Tooltip, Dialog, Classes, Colors } from "@blueprintjs/core";
|
||||||
|
|
||||||
@connect((state) => ({
|
|
||||||
colorAccessor: state.colors.colorAccessor,
|
|
||||||
categoricalSelection: state.categoricalSelection,
|
|
||||||
annotations: state.annotations,
|
|
||||||
universe: state.universe,
|
|
||||||
}))
|
|
||||||
class AnnoDialog extends React.PureComponent {
|
class AnnoDialog extends React.PureComponent {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
@@ -72,7 +65,7 @@ class AnnoDialog extends React.PureComponent {
|
|||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
{...primaryButtonProps} // eslint-disable-line react/jsx-props-no-spreading
|
{...primaryButtonProps} // eslint-disable-line react/jsx-props-no-spreading -- Spreading props allows for modularity
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!text || validationError}
|
disabled={!text || validationError}
|
||||||
intent="primary"
|
intent="primary"
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { connect } from "react-redux";
|
|
||||||
import { Button, MenuItem } from "@blueprintjs/core";
|
import { Button, MenuItem } from "@blueprintjs/core";
|
||||||
import { Select } from "@blueprintjs/select";
|
import { Select } from "@blueprintjs/select";
|
||||||
|
|
||||||
@connect((state) => ({
|
|
||||||
colorAccessor: state.colors.colorAccessor,
|
|
||||||
categoricalSelection: state.categoricalSelection,
|
|
||||||
annotations: state.annotations,
|
|
||||||
universe: state.universe,
|
|
||||||
}))
|
|
||||||
class DuplicateCategorySelect extends React.PureComponent {
|
class DuplicateCategorySelect extends React.PureComponent {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
@@ -34,7 +27,14 @@ class DuplicateCategorySelect extends React.PureComponent {
|
|||||||
}
|
}
|
||||||
filterable={false}
|
filterable={false}
|
||||||
itemRenderer={(d, { handleClick }) => {
|
itemRenderer={(d, { handleClick }) => {
|
||||||
return <MenuItem onClick={handleClick} key={d} text={d} />;
|
return (
|
||||||
|
<MenuItem
|
||||||
|
data-testclass="duplicate-category-dropdown-option"
|
||||||
|
onClick={handleClick}
|
||||||
|
key={d}
|
||||||
|
text={d}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
noResults={<MenuItem disabled text="No results." />}
|
noResults={<MenuItem disabled text="No results." />}
|
||||||
onItemSelect={(d) => {
|
onItemSelect={(d) => {
|
||||||
@@ -43,6 +43,7 @@ class DuplicateCategorySelect extends React.PureComponent {
|
|||||||
>
|
>
|
||||||
{/* children become the popover target; render value here */}
|
{/* children become the popover target; render value here */}
|
||||||
<Button
|
<Button
|
||||||
|
data-testid="duplicate-category-dropdown"
|
||||||
text={categoryToDuplicate || "None (all cells 'unassigned')"}
|
text={categoryToDuplicate || "None (all cells 'unassigned')"}
|
||||||
rightIcon="double-caret-vertical"
|
rightIcon="double-caret-vertical"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,14 +3,13 @@ import { connect } from "react-redux";
|
|||||||
import AnnoDialog from "../annoDialog";
|
import AnnoDialog from "../annoDialog";
|
||||||
import LabelInput from "../labelInput";
|
import LabelInput from "../labelInput";
|
||||||
import { labelPrompt, isLabelErroneous } from "../labelUtil";
|
import { labelPrompt, isLabelErroneous } from "../labelUtil";
|
||||||
|
import actions from "../../../actions";
|
||||||
|
|
||||||
@connect((state) => ({
|
@connect((state) => ({
|
||||||
colorAccessor: state.colors.colorAccessor,
|
|
||||||
categoricalSelection: state.categoricalSelection,
|
|
||||||
annotations: state.annotations,
|
annotations: state.annotations,
|
||||||
universe: state.universe,
|
schema: state.annoMatrix?.schema,
|
||||||
ontology: state.ontology,
|
ontology: state.ontology,
|
||||||
crossfilter: state.crossfilter,
|
obsCrossfilter: state.obsCrossfilter,
|
||||||
}))
|
}))
|
||||||
class Category extends React.PureComponent {
|
class Category extends React.PureComponent {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@@ -36,12 +35,13 @@ class Category extends React.PureComponent {
|
|||||||
const { newLabelText } = this.state;
|
const { newLabelText } = this.state;
|
||||||
|
|
||||||
this.disableAddNewLabelMode();
|
this.disableAddNewLabelMode();
|
||||||
dispatch({
|
dispatch(
|
||||||
type: "annotation: add new label to category",
|
actions.annotationCreateLabelInCategory(
|
||||||
metadataField,
|
metadataField,
|
||||||
newLabelText,
|
newLabelText,
|
||||||
assignSelectedCells: false,
|
false
|
||||||
});
|
)
|
||||||
|
);
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -50,18 +50,15 @@ class Category extends React.PureComponent {
|
|||||||
const { newLabelText } = this.state;
|
const { newLabelText } = this.state;
|
||||||
|
|
||||||
this.disableAddNewLabelMode();
|
this.disableAddNewLabelMode();
|
||||||
dispatch({
|
dispatch(
|
||||||
type: "annotation: add new label to category",
|
actions.annotationCreateLabelInCategory(metadataField, newLabelText, true)
|
||||||
metadataField,
|
);
|
||||||
newLabelText,
|
|
||||||
assignSelectedCells: true,
|
|
||||||
});
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
};
|
};
|
||||||
|
|
||||||
labelNameError = (name) => {
|
labelNameError = (name) => {
|
||||||
const { metadataField, ontology, universe } = this.props;
|
const { metadataField, ontology, schema } = this.props;
|
||||||
return isLabelErroneous(name, metadataField, ontology, universe.schema);
|
return isLabelErroneous(name, metadataField, ontology, schema);
|
||||||
};
|
};
|
||||||
|
|
||||||
instruction = (label) => {
|
instruction = (label) => {
|
||||||
@@ -74,7 +71,7 @@ class Category extends React.PureComponent {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { newLabelText } = this.state;
|
const { newLabelText } = this.state;
|
||||||
const { metadataField, annotations, ontology, crossfilter } = this.props;
|
const { metadataField, annotations, ontology, obsCrossfilter } = this.props;
|
||||||
const ontologyEnabled = ontology?.enabled ?? false;
|
const ontologyEnabled = ontology?.enabled ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -92,7 +89,7 @@ class Category extends React.PureComponent {
|
|||||||
instruction={this.instruction(newLabelText)}
|
instruction={this.instruction(newLabelText)}
|
||||||
cancelTooltipContent="Close this dialog without adding a label."
|
cancelTooltipContent="Close this dialog without adding a label."
|
||||||
primaryButtonText="Add label"
|
primaryButtonText="Add label"
|
||||||
secondaryButtonText={`Add label & assign ${crossfilter.countSelected()} selected cells`}
|
secondaryButtonText={`Add label & assign ${obsCrossfilter.countSelected()} selected cells`}
|
||||||
handleSecondaryButtonSubmit={this.addLabelAndAssignCells}
|
handleSecondaryButtonSubmit={this.addLabelAndAssignCells}
|
||||||
text={newLabelText}
|
text={newLabelText}
|
||||||
validationError={this.labelNameError(newLabelText)}
|
validationError={this.labelNameError(newLabelText)}
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import _ from "lodash";
|
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import AnnoDialog from "../annoDialog";
|
import AnnoDialog from "../annoDialog";
|
||||||
import LabelInput from "../labelInput";
|
import LabelInput from "../labelInput";
|
||||||
import { labelPrompt } from "../labelUtil";
|
import { labelPrompt } from "../labelUtil";
|
||||||
|
|
||||||
import { AnnotationsHelpers } from "../../../util/stateManager";
|
import { AnnotationsHelpers } from "../../../util/stateManager";
|
||||||
|
import actions from "../../../actions";
|
||||||
|
|
||||||
@connect((state) => ({
|
@connect((state) => ({
|
||||||
categoricalSelection: state.categoricalSelection,
|
|
||||||
annotations: state.annotations,
|
annotations: state.annotations,
|
||||||
universe: state.universe,
|
schema: state.annoMatrix?.schema,
|
||||||
ontology: state.ontology,
|
ontology: state.ontology,
|
||||||
}))
|
}))
|
||||||
class AnnoDialogEditCategoryName extends React.PureComponent {
|
class AnnoDialogEditCategoryName extends React.PureComponent {
|
||||||
@@ -36,10 +35,15 @@ class AnnoDialogEditCategoryName extends React.PureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
handleEditCategory = (e) => {
|
handleEditCategory = (e) => {
|
||||||
const { dispatch, metadataField, categoricalSelection } = this.props;
|
const { dispatch, metadataField } = this.props;
|
||||||
const { newCategoryText } = this.state;
|
const { newCategoryText } = this.state;
|
||||||
|
|
||||||
const allCategoryNames = _.keys(categoricalSelection);
|
/*
|
||||||
|
test for uniqueness against *all* annotation names, not just the subset
|
||||||
|
we render as categorical.
|
||||||
|
*/
|
||||||
|
const { schema } = this.props;
|
||||||
|
const allCategoryNames = schema.annotations.obs.columns.map((c) => c.name);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(allCategoryNames.indexOf(newCategoryText) > -1 &&
|
(allCategoryNames.indexOf(newCategoryText) > -1 &&
|
||||||
@@ -50,17 +54,16 @@ class AnnoDialogEditCategoryName extends React.PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.disableEditCategoryMode();
|
this.disableEditCategoryMode();
|
||||||
dispatch({
|
|
||||||
type: "annotation: category edited",
|
if (metadataField !== newCategoryText)
|
||||||
metadataField,
|
dispatch(
|
||||||
newCategoryText,
|
actions.annotationRenameCategoryAction(metadataField, newCategoryText)
|
||||||
data: newCategoryText,
|
);
|
||||||
});
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
};
|
};
|
||||||
|
|
||||||
editedCategoryNameError = (name) => {
|
editedCategoryNameError = (name) => {
|
||||||
const { metadataField, categoricalSelection } = this.props;
|
const { metadataField } = this.props;
|
||||||
|
|
||||||
/* check for syntax errors in category name */
|
/* check for syntax errors in category name */
|
||||||
const error = AnnotationsHelpers.annotationNameIsErroneous(name);
|
const error = AnnotationsHelpers.annotationNameIsErroneous(name);
|
||||||
@@ -69,7 +72,14 @@ class AnnoDialogEditCategoryName extends React.PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* check for duplicative categories */
|
/* check for duplicative categories */
|
||||||
const allCategoryNames = _.keys(categoricalSelection);
|
|
||||||
|
/*
|
||||||
|
test for uniqueness against *all* annotation names, not just the subset
|
||||||
|
we render as categorical.
|
||||||
|
*/
|
||||||
|
const { schema } = this.props;
|
||||||
|
const allCategoryNames = schema.annotations.obs.columns.map((c) => c.name);
|
||||||
|
|
||||||
const categoryNameAlreadyExists = allCategoryNames.indexOf(name) > -1;
|
const categoryNameAlreadyExists = allCategoryNames.indexOf(name) > -1;
|
||||||
const sameName = name === metadataField;
|
const sameName = name === metadataField;
|
||||||
if (categoryNameAlreadyExists && !sameName) {
|
if (categoryNameAlreadyExists && !sameName) {
|
||||||
@@ -88,6 +98,11 @@ class AnnoDialogEditCategoryName extends React.PureComponent {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
allCategoryNames() {
|
||||||
|
const { schema } = this.props;
|
||||||
|
return schema.annotations.obs.columns.map((c) => c.name);
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { newCategoryText } = this.state;
|
const { newCategoryText } = this.state;
|
||||||
const { metadataField, annotations, ontology } = this.props;
|
const { metadataField, annotations, ontology } = this.props;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "@blueprintjs/core";
|
} from "@blueprintjs/core";
|
||||||
|
|
||||||
import * as globals from "../../../globals";
|
import * as globals from "../../../globals";
|
||||||
|
import actions from "../../../actions";
|
||||||
|
|
||||||
@connect((state) => ({
|
@connect((state) => ({
|
||||||
annotations: state.annotations,
|
annotations: state.annotations,
|
||||||
@@ -41,10 +42,7 @@ class AnnoMenuCategory extends React.PureComponent {
|
|||||||
|
|
||||||
handleDeleteCategory = () => {
|
handleDeleteCategory = () => {
|
||||||
const { dispatch, metadataField } = this.props;
|
const { dispatch, metadataField } = this.props;
|
||||||
dispatch({
|
dispatch(actions.annotationDeleteCategoryAction(metadataField));
|
||||||
type: "annotation: delete category",
|
|
||||||
metadataField,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import _ from "lodash";
|
|
||||||
import { connect } from "react-redux";
|
|
||||||
import { Flipper, Flipped } from "react-flip-toolkit";
|
|
||||||
|
|
||||||
import * as globals from "../../../globals";
|
|
||||||
import Value from "../value";
|
|
||||||
|
|
||||||
@connect((state) => ({
|
|
||||||
categoricalSelection: state.categoricalSelection,
|
|
||||||
}))
|
|
||||||
class Category extends React.Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
this.state = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
renderCategoryItems(optTuples) {
|
|
||||||
const { metadataField, isUserAnno } = this.props;
|
|
||||||
|
|
||||||
return _.map(optTuples, (tuple, i) => {
|
|
||||||
return (
|
|
||||||
<Flipped key={tuple[1]} flipId={tuple[1]}>
|
|
||||||
{(flippedProps) => (
|
|
||||||
<Value
|
|
||||||
isUserAnno={isUserAnno}
|
|
||||||
optTuples={optTuples}
|
|
||||||
key={tuple[1]}
|
|
||||||
metadataField={metadataField}
|
|
||||||
categoryIndex={tuple[1]}
|
|
||||||
i={i}
|
|
||||||
flippedProps={flippedProps}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Flipped>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
metadataField,
|
|
||||||
categoricalSelection,
|
|
||||||
children,
|
|
||||||
isExpanded,
|
|
||||||
} = this.props;
|
|
||||||
const { isTruncated } = categoricalSelection[metadataField];
|
|
||||||
const cat = categoricalSelection[metadataField];
|
|
||||||
const optTuples = [...cat.categoryValueIndices];
|
|
||||||
const optTuplesAsKey = _.map(optTuples, (t) => t[0]).join(""); // animation
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
maxWidth: globals.maxControlsWidth,
|
|
||||||
}}
|
|
||||||
data-testclass="category"
|
|
||||||
data-testid={`category-${metadataField}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "baseline",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
<div style={{ marginLeft: 26 }}>
|
|
||||||
<Flipper spring="veryGentle" flipKey={optTuplesAsKey}>
|
|
||||||
{isExpanded ? this.renderCategoryItems(optTuples) : null}
|
|
||||||
</Flipper>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{isExpanded && isTruncated ? (
|
|
||||||
<p style={{ paddingLeft: 15 }}>... truncated list ...</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Category;
|
|
||||||
@@ -1,70 +1,78 @@
|
|||||||
import React from "react";
|
import React, { useRef, useEffect } from "react";
|
||||||
import _ from "lodash";
|
import { connect, shallowEqual } from "react-redux";
|
||||||
import { connect } from "react-redux";
|
|
||||||
import { FaChevronRight, FaChevronDown } from "react-icons/fa";
|
import { FaChevronRight, FaChevronDown } from "react-icons/fa";
|
||||||
import { AnchorButton, Button, Tooltip } from "@blueprintjs/core";
|
import { AnchorButton, Button, Tooltip } from "@blueprintjs/core";
|
||||||
import CategoryFlipperLayout from "./categoryFlipperLayout";
|
import { Flipper, Flipped } from "react-flip-toolkit";
|
||||||
|
import Async from "react-async";
|
||||||
|
import memoize from "memoize-one";
|
||||||
|
|
||||||
|
import Value from "../value";
|
||||||
import AnnoMenu from "./annoMenuCategory";
|
import AnnoMenu from "./annoMenuCategory";
|
||||||
import AnnoDialogEditCategoryName from "./annoDialogEditCategoryName";
|
import AnnoDialogEditCategoryName from "./annoDialogEditCategoryName";
|
||||||
import AnnoDialogAddLabel from "./annoDialogAddLabel";
|
import AnnoDialogAddLabel from "./annoDialogAddLabel";
|
||||||
import Truncate from "../../util/truncate";
|
import Truncate from "../../util/truncate";
|
||||||
|
import { CategoryCrossfilterContext } from "../categoryContext";
|
||||||
|
|
||||||
import * as globals from "../../../globals";
|
import * as globals from "../../../globals";
|
||||||
|
import { createCategorySummaryFromDfCol } from "../../../util/stateManager/controlsHelpers";
|
||||||
|
import {
|
||||||
|
createColorTable,
|
||||||
|
createColorQuery,
|
||||||
|
} from "../../../util/stateManager/colorHelpers";
|
||||||
|
import actions from "../../../actions";
|
||||||
|
|
||||||
const LABEL_WIDTH = globals.leftSidebarWidth - 100;
|
const LABEL_WIDTH = globals.leftSidebarWidth - 100;
|
||||||
const ANNO_BUTTON_WIDTH = 50;
|
const ANNO_BUTTON_WIDTH = 50;
|
||||||
const LABEL_WIDTH_ANNO = LABEL_WIDTH - ANNO_BUTTON_WIDTH;
|
const LABEL_WIDTH_ANNO = LABEL_WIDTH - ANNO_BUTTON_WIDTH;
|
||||||
|
|
||||||
@connect((state, ownProps) => {
|
@connect((state, ownProps) => {
|
||||||
|
const schema = state.annoMatrix?.schema;
|
||||||
const { metadataField } = ownProps;
|
const { metadataField } = ownProps;
|
||||||
|
const isUserAnno = schema?.annotations?.obsByName[metadataField]?.writable;
|
||||||
|
const categoricalSelection = state.categoricalSelection?.[metadataField];
|
||||||
return {
|
return {
|
||||||
isColorAccessor: state.colors.colorAccessor === metadataField,
|
colors: state.colors,
|
||||||
categoricalSelection: state.categoricalSelection,
|
categoricalSelection,
|
||||||
annotations: state.annotations,
|
annotations: state.annotations,
|
||||||
universe: state.universe,
|
annoMatrix: state.annoMatrix,
|
||||||
schema: state.world?.schema,
|
schema,
|
||||||
|
crossfilter: state.obsCrossfilter,
|
||||||
|
isUserAnno,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
class Category extends React.Component {
|
class Category extends React.PureComponent {
|
||||||
constructor(props) {
|
static getSelectionState(
|
||||||
super(props);
|
categoricalSelection,
|
||||||
this.state = {
|
metadataField,
|
||||||
isChecked: true,
|
categorySummary
|
||||||
};
|
) {
|
||||||
|
// total number of categories in this dimension
|
||||||
|
const totalCatCount = categorySummary.numCategoryValues;
|
||||||
|
// number of selected options in this category
|
||||||
|
const selectedCatCount = categorySummary.categoryValues.reduce(
|
||||||
|
(res, label) => (categoricalSelection.get(label) ?? true ? res + 1 : res),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
return selectedCatCount === totalCatCount
|
||||||
|
? "all"
|
||||||
|
: selectedCatCount === 0
|
||||||
|
? "none"
|
||||||
|
: "some";
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
static watchAsync(props, prevProps) {
|
||||||
|
return !shallowEqual(props.watchProps, prevProps.watchProps);
|
||||||
|
}
|
||||||
|
|
||||||
|
createCategorySummaryFromDfCol = memoize(createCategorySummaryFromDfCol);
|
||||||
|
|
||||||
|
getSelectionState(categorySummary) {
|
||||||
const { categoricalSelection, metadataField } = this.props;
|
const { categoricalSelection, metadataField } = this.props;
|
||||||
const cat = categoricalSelection?.[metadataField];
|
return Category.getSelectionState(
|
||||||
if (
|
categoricalSelection,
|
||||||
categoricalSelection !== prevProps.categoricalSelection &&
|
metadataField,
|
||||||
!!cat &&
|
categorySummary
|
||||||
!!this.checkbox
|
);
|
||||||
) {
|
|
||||||
const categoryCount = {
|
|
||||||
// total number of categories in this dimension
|
|
||||||
totalCatCount: cat.numCategoryValues,
|
|
||||||
// number of selected options in this category
|
|
||||||
selectedCatCount: _.reduce(
|
|
||||||
cat.categoryValueSelected,
|
|
||||||
(res, cond) => (cond ? res + 1 : res),
|
|
||||||
0
|
|
||||||
),
|
|
||||||
};
|
|
||||||
if (categoryCount.selectedCatCount === categoryCount.totalCatCount) {
|
|
||||||
/* everything is on, so not indeterminate */
|
|
||||||
this.checkbox.indeterminate = false;
|
|
||||||
this.setState({ isChecked: true }); // eslint-disable-line react/no-did-update-set-state
|
|
||||||
} else if (categoryCount.selectedCatCount === 0) {
|
|
||||||
/* nothing is on, so no */
|
|
||||||
this.checkbox.indeterminate = false;
|
|
||||||
this.setState({ isChecked: false }); // eslint-disable-line react/no-did-update-set-state
|
|
||||||
} else if (categoryCount.selectedCatCount < categoryCount.totalCatCount) {
|
|
||||||
/* to be explicit... */
|
|
||||||
this.checkbox.indeterminate = true;
|
|
||||||
this.setState({ isChecked: false }); // eslint-disable-line react/no-did-update-set-state
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleColorChange = () => {
|
handleColorChange = () => {
|
||||||
@@ -85,42 +93,194 @@ class Category extends React.Component {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
toggleNone() {
|
handleCategoryKeyPress = (e) => {
|
||||||
const { dispatch, metadataField } = this.props;
|
if (e.key === "Enter") {
|
||||||
dispatch({
|
this.handleCategoryClick();
|
||||||
type: "categorical metadata filter none of these",
|
|
||||||
metadataField,
|
|
||||||
});
|
|
||||||
this.setState({ isChecked: false });
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
toggleAll() {
|
handleToggleAllClick = (categorySummary) => {
|
||||||
const { dispatch, metadataField } = this.props;
|
const isChecked = this.getSelectionState(categorySummary);
|
||||||
dispatch({
|
if (isChecked === "all") {
|
||||||
type: "categorical metadata filter all of these",
|
this.toggleNone(categorySummary);
|
||||||
metadataField,
|
|
||||||
});
|
|
||||||
this.setState({ isChecked: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
handleToggleAllClick() {
|
|
||||||
const { isChecked } = this.state;
|
|
||||||
// || this.checkbox.indeterminate === false
|
|
||||||
if (isChecked) {
|
|
||||||
this.toggleNone();
|
|
||||||
} else {
|
} else {
|
||||||
this.toggleAll();
|
this.toggleAll(categorySummary);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchAsyncProps = async (props) => {
|
||||||
|
const { annoMatrix, metadataField, colors } = props.watchProps;
|
||||||
|
const { crossfilter } = this.props;
|
||||||
|
|
||||||
|
const [categoryData, categorySummary, colorData] = await this.fetchData(
|
||||||
|
annoMatrix,
|
||||||
|
metadataField,
|
||||||
|
colors
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
categoryData,
|
||||||
|
categorySummary,
|
||||||
|
colorData,
|
||||||
|
crossfilter,
|
||||||
|
...this.updateColorTable(colorData),
|
||||||
|
handleCategoryToggleAllClick: () =>
|
||||||
|
this.handleToggleAllClick(categorySummary),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
async fetchData(annoMatrix, metadataField, colors) {
|
||||||
|
/*
|
||||||
|
fetch our data and the color-by data if appropriate, and then build a summary
|
||||||
|
of our category and a color table for the color-by annotation.
|
||||||
|
*/
|
||||||
|
const { schema } = annoMatrix;
|
||||||
|
const { colorAccessor, colorMode } = colors;
|
||||||
|
let colorDataPromise = Promise.resolve(null);
|
||||||
|
if (colorAccessor) {
|
||||||
|
const query = createColorQuery(colorMode, colorAccessor, schema);
|
||||||
|
if (query) colorDataPromise = annoMatrix.fetch(...query);
|
||||||
|
}
|
||||||
|
const [categoryData, colorData] = await Promise.all([
|
||||||
|
annoMatrix.fetch("obs", metadataField),
|
||||||
|
colorDataPromise,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// our data
|
||||||
|
const column = categoryData.icol(0);
|
||||||
|
const colSchema = schema.annotations.obsByName[metadataField];
|
||||||
|
const categorySummary = this.createCategorySummaryFromDfCol(
|
||||||
|
column,
|
||||||
|
colSchema
|
||||||
|
);
|
||||||
|
return [categoryData, categorySummary, colorData];
|
||||||
}
|
}
|
||||||
|
|
||||||
renderIsStillLoading() {
|
updateColorTable(colorData) {
|
||||||
/*
|
// color table, which may be null
|
||||||
We are still loading this category, so render a "busy" signal.
|
const { schema, colors, metadataField } = this.props;
|
||||||
*/
|
const { colorAccessor, userColors, colorMode } = colors;
|
||||||
const { metadataField } = this.props;
|
return {
|
||||||
|
isColorAccessor: colorAccessor === metadataField,
|
||||||
|
colorAccessor,
|
||||||
|
colorMode,
|
||||||
|
colorTable: createColorTable(
|
||||||
|
colorMode,
|
||||||
|
colorAccessor,
|
||||||
|
colorData,
|
||||||
|
schema,
|
||||||
|
userColors
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleNone(categorySummary) {
|
||||||
|
const { dispatch, metadataField } = this.props;
|
||||||
|
dispatch(
|
||||||
|
actions.selectCategoricalAllMetadataAction(
|
||||||
|
"categorical metadata filter none of these",
|
||||||
|
metadataField,
|
||||||
|
categorySummary.allCategoryValues,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleAll(categorySummary) {
|
||||||
|
const { dispatch, metadataField } = this.props;
|
||||||
|
dispatch(
|
||||||
|
actions.selectCategoricalAllMetadataAction(
|
||||||
|
"categorical metadata filter all of these",
|
||||||
|
metadataField,
|
||||||
|
categorySummary.allCategoryValues,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
metadataField,
|
||||||
|
isExpanded,
|
||||||
|
categoricalSelection,
|
||||||
|
crossfilter,
|
||||||
|
colors,
|
||||||
|
annoMatrix,
|
||||||
|
isUserAnno,
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
const checkboxID = `category-select-${metadataField}`;
|
const checkboxID = `category-select-${metadataField}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CategoryCrossfilterContext.Provider value={crossfilter}>
|
||||||
|
<Async
|
||||||
|
watchFn={Category.watchAsync}
|
||||||
|
promiseFn={this.fetchAsyncProps}
|
||||||
|
watchProps={{
|
||||||
|
metadataField,
|
||||||
|
annoMatrix,
|
||||||
|
categoricalSelection,
|
||||||
|
colors,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Async.Pending initial>
|
||||||
|
<StillLoading
|
||||||
|
metadataField={metadataField}
|
||||||
|
checkboxID={checkboxID}
|
||||||
|
/>
|
||||||
|
</Async.Pending>
|
||||||
|
<Async.Rejected>
|
||||||
|
{(error) => (
|
||||||
|
<ErrorLoading metadataField={metadataField} error={error} />
|
||||||
|
)}
|
||||||
|
</Async.Rejected>
|
||||||
|
<Async.Fulfilled persist>
|
||||||
|
{(asyncProps) => {
|
||||||
|
const {
|
||||||
|
colorAccessor,
|
||||||
|
colorTable,
|
||||||
|
colorData,
|
||||||
|
categoryData,
|
||||||
|
categorySummary,
|
||||||
|
isColorAccessor,
|
||||||
|
handleCategoryToggleAllClick,
|
||||||
|
} = asyncProps;
|
||||||
|
const isTruncated = !!categorySummary?.isTruncated;
|
||||||
|
const selectionState = this.getSelectionState(categorySummary);
|
||||||
|
return (
|
||||||
|
<CategoryRender
|
||||||
|
metadataField={metadataField}
|
||||||
|
checkboxID={checkboxID}
|
||||||
|
isUserAnno={isUserAnno}
|
||||||
|
isTruncated={isTruncated}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
isColorAccessor={isColorAccessor}
|
||||||
|
selectionState={selectionState}
|
||||||
|
categoryData={categoryData}
|
||||||
|
categorySummary={categorySummary}
|
||||||
|
colorAccessor={colorAccessor}
|
||||||
|
colorData={colorData}
|
||||||
|
colorTable={colorTable}
|
||||||
|
onColorChangeClick={this.handleColorChange}
|
||||||
|
onCategoryToggleAllClick={handleCategoryToggleAllClick}
|
||||||
|
onCategoryMenuClick={this.handleCategoryClick}
|
||||||
|
onCategoryMenuKeyPress={this.handleCategoryKeyPress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Async.Fulfilled>
|
||||||
|
</Async>
|
||||||
|
</CategoryCrossfilterContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Category;
|
||||||
|
|
||||||
|
const StillLoading = ({ metadataField, checkboxID }) => {
|
||||||
|
/*
|
||||||
|
We are still loading this category, so render a "busy" signal.
|
||||||
|
*/
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -163,59 +323,51 @@ class Category extends React.Component {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
render() {
|
const ErrorLoading = ({ metadataField, error }) => {
|
||||||
const { isChecked } = this.state;
|
console.error(error); // log error to console as it is unexpected.
|
||||||
const {
|
|
||||||
metadataField,
|
|
||||||
categoricalSelection,
|
|
||||||
isColorAccessor,
|
|
||||||
isExpanded,
|
|
||||||
schema,
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
const isStillLoading = !(categoricalSelection?.[metadataField] ?? false);
|
|
||||||
if (isStillLoading) {
|
|
||||||
return this.renderIsStillLoading();
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkboxID = `category-select-${metadataField}`;
|
|
||||||
|
|
||||||
const isUserAnno =
|
|
||||||
schema?.annotations?.obsByName[metadataField]?.writable ?? false;
|
|
||||||
const isTruncated = _.get(
|
|
||||||
categoricalSelection,
|
|
||||||
[metadataField, "isTruncated"],
|
|
||||||
false
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
!isUserAnno &&
|
|
||||||
schema?.annotations?.obsByName[metadataField]?.categories?.length === 1
|
|
||||||
) {
|
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBottom: 10, marginTop: 4 }}>
|
<div style={{ marginBottom: 10, marginTop: 4 }}>
|
||||||
<Truncate>
|
<span
|
||||||
<span style={{ maxWidth: 150, fontWeight: 700 }}>
|
style={{
|
||||||
{metadataField}
|
cursor: "pointer",
|
||||||
|
display: "inline-block",
|
||||||
|
width: LABEL_WIDTH,
|
||||||
|
fontStyle: "italic",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{`Failure loading ${metadataField}`}
|
||||||
</span>
|
</span>
|
||||||
</Truncate>
|
|
||||||
<Truncate>
|
|
||||||
<span style={{ maxWidth: 150 }}>
|
|
||||||
{`: ${schema.annotations.obsByName[metadataField].categories[0]}`}
|
|
||||||
</span>
|
|
||||||
</Truncate>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const CategoryHeader = React.memo(
|
||||||
|
({
|
||||||
|
metadataField,
|
||||||
|
checkboxID,
|
||||||
|
isUserAnno,
|
||||||
|
isTruncated,
|
||||||
|
isColorAccessor,
|
||||||
|
isExpanded,
|
||||||
|
selectionState,
|
||||||
|
onColorChangeClick,
|
||||||
|
onCategoryMenuClick,
|
||||||
|
onCategoryMenuKeyPress,
|
||||||
|
onCategoryToggleAllClick,
|
||||||
|
}) => {
|
||||||
|
/*
|
||||||
|
Render category name and controls (eg, color-by button).
|
||||||
|
*/
|
||||||
|
const checkboxRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkboxRef.current.indeterminate = selectionState === "some";
|
||||||
|
}, [checkboxRef.current, selectionState]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CategoryFlipperLayout
|
<>
|
||||||
metadataField={metadataField}
|
|
||||||
isExpanded={isExpanded}
|
|
||||||
isUserAnno={isUserAnno}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -228,12 +380,9 @@ class Category extends React.Component {
|
|||||||
id={checkboxID}
|
id={checkboxID}
|
||||||
data-testclass="category-select"
|
data-testclass="category-select"
|
||||||
data-testid={`${metadataField}:category-select`}
|
data-testid={`${metadataField}:category-select`}
|
||||||
onChange={this.handleToggleAllClick.bind(this)}
|
onChange={onCategoryToggleAllClick}
|
||||||
ref={(el) => {
|
ref={checkboxRef}
|
||||||
this.checkbox = el;
|
checked={selectionState === "all"}
|
||||||
return el;
|
|
||||||
}}
|
|
||||||
checked={isChecked}
|
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
/>
|
/>
|
||||||
<span className="bp3-control-indicator" />
|
<span className="bp3-control-indicator" />
|
||||||
@@ -241,16 +390,13 @@ class Category extends React.Component {
|
|||||||
<span
|
<span
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
tabIndex="0"
|
tabIndex="0"
|
||||||
|
data-testclass="category-expand"
|
||||||
data-testid={`${metadataField}:category-expand`}
|
data-testid={`${metadataField}:category-expand`}
|
||||||
onKeyPress={(e) => {
|
onKeyPress={onCategoryMenuKeyPress}
|
||||||
if (e.key === "Enter") {
|
|
||||||
this.handleCategoryClick();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={{
|
style={{
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
onClick={this.handleCategoryClick}
|
onClick={onCategoryMenuClick}
|
||||||
>
|
>
|
||||||
<Truncate>
|
<Truncate>
|
||||||
<span
|
<span
|
||||||
@@ -299,7 +445,7 @@ class Category extends React.Component {
|
|||||||
<AnchorButton
|
<AnchorButton
|
||||||
data-testclass="colorby"
|
data-testclass="colorby"
|
||||||
data-testid={`colorby-${metadataField}`}
|
data-testid={`colorby-${metadataField}`}
|
||||||
onClick={this.handleColorChange}
|
onClick={onColorChangeClick}
|
||||||
active={isColorAccessor}
|
active={isColorAccessor}
|
||||||
intent={isColorAccessor ? "primary" : "none"}
|
intent={isColorAccessor ? "primary" : "none"}
|
||||||
disabled={isTruncated}
|
disabled={isTruncated}
|
||||||
@@ -307,9 +453,168 @@ class Category extends React.Component {
|
|||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</CategoryFlipperLayout>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
);
|
||||||
|
|
||||||
export default Category;
|
const CategoryRender = React.memo(
|
||||||
|
({
|
||||||
|
metadataField,
|
||||||
|
checkboxID,
|
||||||
|
isUserAnno,
|
||||||
|
isTruncated,
|
||||||
|
isColorAccessor,
|
||||||
|
isExpanded,
|
||||||
|
selectionState,
|
||||||
|
categoryData,
|
||||||
|
categorySummary,
|
||||||
|
colorAccessor,
|
||||||
|
colorData,
|
||||||
|
colorTable,
|
||||||
|
onColorChangeClick,
|
||||||
|
onCategoryMenuClick,
|
||||||
|
onCategoryMenuKeyPress,
|
||||||
|
onCategoryToggleAllClick,
|
||||||
|
}) => {
|
||||||
|
/*
|
||||||
|
Render the core of the category, including checkboxes, controls, etc.
|
||||||
|
*/
|
||||||
|
const { numCategoryValues } = categorySummary;
|
||||||
|
const isSingularValue = !isUserAnno && numCategoryValues === 1;
|
||||||
|
|
||||||
|
if (isSingularValue) {
|
||||||
|
/*
|
||||||
|
Entire category has a single value, special case.
|
||||||
|
*/
|
||||||
|
const theOneValue = categorySummary.categoryValues[0];
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 10, marginTop: 4 }}>
|
||||||
|
<Truncate>
|
||||||
|
<span style={{ maxWidth: 150, fontWeight: 700 }}>
|
||||||
|
{metadataField}
|
||||||
|
</span>
|
||||||
|
</Truncate>
|
||||||
|
<Truncate>
|
||||||
|
<span style={{ maxWidth: 150 }}>{`: ${theOneValue}`}</span>
|
||||||
|
</Truncate>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Otherwise, our normal multi-layout layout
|
||||||
|
*/
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: globals.maxControlsWidth,
|
||||||
|
}}
|
||||||
|
data-testclass="category"
|
||||||
|
data-testid={`category-${metadataField}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "baseline",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CategoryHeader
|
||||||
|
metadataField={metadataField}
|
||||||
|
checkboxID={checkboxID}
|
||||||
|
isUserAnno={isUserAnno}
|
||||||
|
isTruncated={isTruncated}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
isColorAccessor={isColorAccessor}
|
||||||
|
selectionState={selectionState}
|
||||||
|
onColorChangeClick={onColorChangeClick}
|
||||||
|
onCategoryToggleAllClick={onCategoryToggleAllClick}
|
||||||
|
onCategoryMenuClick={onCategoryMenuClick}
|
||||||
|
onCategoryMenuKeyPress={onCategoryMenuKeyPress}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginLeft: 26 }}>
|
||||||
|
{
|
||||||
|
/* values*/
|
||||||
|
isExpanded ? (
|
||||||
|
<CategoryValueList
|
||||||
|
isUserAnno={isUserAnno}
|
||||||
|
metadataField={metadataField}
|
||||||
|
categoryData={categoryData}
|
||||||
|
categorySummary={categorySummary}
|
||||||
|
colorAccessor={colorAccessor}
|
||||||
|
colorData={colorData}
|
||||||
|
colorTable={colorTable}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{isExpanded && isTruncated ? (
|
||||||
|
<p style={{ paddingLeft: 15 }}>... truncated list ...</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const CategoryValueList = React.memo(
|
||||||
|
({
|
||||||
|
isUserAnno,
|
||||||
|
metadataField,
|
||||||
|
categoryData,
|
||||||
|
categorySummary,
|
||||||
|
colorAccessor,
|
||||||
|
colorData,
|
||||||
|
colorTable,
|
||||||
|
}) => {
|
||||||
|
const tuples = [...categorySummary.categoryValueIndices];
|
||||||
|
|
||||||
|
/*
|
||||||
|
Render the value list. If this is a user annotation, we use a flipper
|
||||||
|
animation, if read-only, we don't bother and save a few bits of perf.
|
||||||
|
*/
|
||||||
|
if (!isUserAnno) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{tuples.map(([value, index]) => (
|
||||||
|
<Value
|
||||||
|
key={value}
|
||||||
|
isUserAnno={isUserAnno}
|
||||||
|
metadataField={metadataField}
|
||||||
|
categoryIndex={index}
|
||||||
|
categoryData={categoryData}
|
||||||
|
categorySummary={categorySummary}
|
||||||
|
colorAccessor={colorAccessor}
|
||||||
|
colorData={colorData}
|
||||||
|
colorTable={colorTable}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* User annotation */
|
||||||
|
const flipKey = tuples.map((t) => t[0]).join("");
|
||||||
|
return (
|
||||||
|
<Flipper flipKey={flipKey}>
|
||||||
|
{tuples.map(([value, index]) => (
|
||||||
|
<Flipped key={value} flipId={value}>
|
||||||
|
<Value
|
||||||
|
isUserAnno={isUserAnno}
|
||||||
|
metadataField={metadataField}
|
||||||
|
categoryIndex={index}
|
||||||
|
categoryData={categoryData}
|
||||||
|
categorySummary={categorySummary}
|
||||||
|
colorAccessor={colorAccessor}
|
||||||
|
colorData={colorData}
|
||||||
|
colorTable={colorTable}
|
||||||
|
/>
|
||||||
|
</Flipped>
|
||||||
|
))}
|
||||||
|
</Flipper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
/*
|
||||||
|
CategoryCrossfilterContext is used to pass a snapshot of the crossfilter
|
||||||
|
matching the current category summary.
|
||||||
|
*/
|
||||||
|
export const CategoryCrossfilterContext = React.createContext(null);
|
||||||
@@ -9,11 +9,11 @@ import AnnoDialog from "./annoDialog";
|
|||||||
import AnnoSelect from "./annoSelect";
|
import AnnoSelect from "./annoSelect";
|
||||||
import LabelInput from "./labelInput";
|
import LabelInput from "./labelInput";
|
||||||
import { labelPrompt } from "./labelUtil";
|
import { labelPrompt } from "./labelUtil";
|
||||||
|
import actions from "../../actions";
|
||||||
|
|
||||||
@connect((state) => ({
|
@connect((state) => ({
|
||||||
writableCategoriesEnabled: state.config?.parameters?.annotations ?? false,
|
writableCategoriesEnabled: state.config?.parameters?.annotations ?? false,
|
||||||
schema: state.world?.schema,
|
schema: state.annoMatrix?.schema,
|
||||||
config: state.config,
|
|
||||||
ontology: state.ontology,
|
ontology: state.ontology,
|
||||||
}))
|
}))
|
||||||
class Categories extends React.Component {
|
class Categories extends React.Component {
|
||||||
@@ -30,11 +30,12 @@ class Categories extends React.Component {
|
|||||||
handleCreateUserAnno = (e) => {
|
handleCreateUserAnno = (e) => {
|
||||||
const { dispatch } = this.props;
|
const { dispatch } = this.props;
|
||||||
const { newCategoryText, categoryToDuplicate } = this.state;
|
const { newCategoryText, categoryToDuplicate } = this.state;
|
||||||
dispatch({
|
dispatch(
|
||||||
type: "annotation: create category",
|
actions.annotationCreateCategoryAction(
|
||||||
data: newCategoryText,
|
newCategoryText,
|
||||||
categoryToDuplicate,
|
categoryToDuplicate
|
||||||
});
|
)
|
||||||
|
);
|
||||||
this.setState({
|
this.setState({
|
||||||
createAnnoModeActive: false,
|
createAnnoModeActive: false,
|
||||||
categoryToDuplicate: null,
|
categoryToDuplicate: null,
|
||||||
@@ -126,12 +127,11 @@ class Categories extends React.Component {
|
|||||||
newCategoryText,
|
newCategoryText,
|
||||||
expandedCats,
|
expandedCats,
|
||||||
} = this.state;
|
} = this.state;
|
||||||
const { writableCategoriesEnabled, schema, config, ontology } = this.props;
|
const { writableCategoriesEnabled, schema, ontology } = this.props;
|
||||||
const ontologyEnabled = ontology?.enabled ?? false;
|
const ontologyEnabled = ontology?.enabled ?? false;
|
||||||
/* all names, sorted in display order. Will be rendered in this order */
|
/* all names, sorted in display order. Will be rendered in this order */
|
||||||
const allCategoryNames = ControlsHelpers.selectableCategoryNames(
|
const allCategoryNames = ControlsHelpers.selectableCategoryNames(
|
||||||
schema,
|
schema
|
||||||
ControlsHelpers.maxCategoryItems(config)
|
|
||||||
).sort();
|
).sort();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export default class LabelInput extends React.PureComponent {
|
|||||||
return (
|
return (
|
||||||
<InputGroup
|
<InputGroup
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
{...props.inputProps} // eslint-disable-line react/jsx-props-no-spreading
|
{...props.inputProps} // eslint-disable-line react/jsx-props-no-spreading --- Allows for modularity
|
||||||
value={label}
|
value={label}
|
||||||
onChange={this.handleChange}
|
onChange={this.handleChange}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import * as d3 from "d3";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -10,7 +11,6 @@ import {
|
|||||||
Icon,
|
Icon,
|
||||||
PopoverInteractionKind,
|
PopoverInteractionKind,
|
||||||
} from "@blueprintjs/core";
|
} from "@blueprintjs/core";
|
||||||
import Occupancy from "./occupancy";
|
|
||||||
import * as globals from "../../../globals";
|
import * as globals from "../../../globals";
|
||||||
import styles from "../categorical.css";
|
import styles from "../categorical.css";
|
||||||
import AnnoDialog from "../annoDialog";
|
import AnnoDialog from "../annoDialog";
|
||||||
@@ -19,110 +19,106 @@ import Truncate from "../../util/truncate";
|
|||||||
|
|
||||||
import { AnnotationsHelpers } from "../../../util/stateManager";
|
import { AnnotationsHelpers } from "../../../util/stateManager";
|
||||||
import { labelPrompt, isLabelErroneous } from "../labelUtil";
|
import { labelPrompt, isLabelErroneous } from "../labelUtil";
|
||||||
|
import actions from "../../../actions";
|
||||||
|
import MiniHistogram from "../../miniHistogram";
|
||||||
|
import MiniStackedBar from "../../miniStackedBar";
|
||||||
|
import { CategoryCrossfilterContext } from "../categoryContext";
|
||||||
|
|
||||||
|
const VALUE_HEIGHT = 11;
|
||||||
|
const CHART_WIDTH = 100;
|
||||||
|
|
||||||
/* this is defined outside of the class so we can use it in connect() */
|
/* this is defined outside of the class so we can use it in connect() */
|
||||||
function _currentLabel(ownProps, categoricalSelection) {
|
function _currentLabelAsString(ownProps) {
|
||||||
const { metadataField, categoryIndex } = ownProps;
|
const { label } = ownProps;
|
||||||
return String(
|
// when called as a function, the String() constructor performs type conversion,
|
||||||
categoricalSelection[metadataField].categoryValues[categoryIndex]
|
// and returns a primitive string.
|
||||||
).valueOf();
|
return String(label);
|
||||||
}
|
}
|
||||||
|
|
||||||
@connect((state, ownProps) => {
|
@connect((state, ownProps) => {
|
||||||
const { pointDilation, categoricalSelection } = state;
|
const { pointDilation, categoricalSelection } = state;
|
||||||
const { metadataField } = ownProps;
|
const { metadataField, categorySummary, categoryIndex } = ownProps;
|
||||||
const isDilated =
|
const isDilated =
|
||||||
pointDilation.metadataField === metadataField &&
|
pointDilation.metadataField === metadataField &&
|
||||||
pointDilation.categoryField ===
|
pointDilation.categoryField === _currentLabelAsString(ownProps);
|
||||||
_currentLabel(ownProps, categoricalSelection);
|
|
||||||
|
const category = categoricalSelection[metadataField];
|
||||||
|
const label = categorySummary.categoryValues[categoryIndex];
|
||||||
|
const isSelected = category.get(label) ?? true;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
categoricalSelection,
|
|
||||||
annotations: state.annotations,
|
annotations: state.annotations,
|
||||||
colorScale: state.colors.scale,
|
schema: state.annoMatrix?.schema,
|
||||||
colorAccessor: state.colors.colorAccessor,
|
|
||||||
schema: state.world?.schema,
|
|
||||||
world: state.world,
|
|
||||||
crossfilter: state.crossfilter,
|
|
||||||
ontology: state.ontology,
|
ontology: state.ontology,
|
||||||
isDilated,
|
isDilated,
|
||||||
|
isSelected,
|
||||||
|
label,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
class CategoryValue extends React.Component {
|
class CategoryValue extends React.Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
editedLabelText: this.currentLabel(),
|
editedLabelText: this.currentLabelAsString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps) {
|
||||||
const { categoricalSelection, metadataField, categoryIndex } = this.props;
|
const { metadataField, categoryIndex, categorySummary } = this.props;
|
||||||
if (
|
if (
|
||||||
prevProps.categoricalSelection !== categoricalSelection ||
|
|
||||||
prevProps.metadataField !== metadataField ||
|
prevProps.metadataField !== metadataField ||
|
||||||
prevProps.categoryIndex !== categoryIndex
|
prevProps.categoryIndex !== categoryIndex ||
|
||||||
|
prevProps.categorySummary !== categorySummary
|
||||||
) {
|
) {
|
||||||
// adequately checked to prevent looping
|
// eslint-disable-next-line react/no-did-update-set-state --- adequately checked to prevent looping
|
||||||
// eslint-disable-next-line react/no-did-update-set-state
|
|
||||||
this.setState({
|
this.setState({
|
||||||
editedLabelText: this.currentLabel(),
|
editedLabelText: this.currentLabelAsString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If coloring by and this isn't the colorAccessor and it isn't being edited
|
||||||
|
get shouldRenderStackedBarOrHistogram() {
|
||||||
|
const { colorAccessor, isColorBy, annotations } = this.props;
|
||||||
|
|
||||||
|
return colorAccessor && !isColorBy && !annotations.isEditingLabelName;
|
||||||
|
}
|
||||||
|
|
||||||
handleDeleteValue = () => {
|
handleDeleteValue = () => {
|
||||||
const { dispatch, metadataField } = this.props;
|
const { dispatch, metadataField, label } = this.props;
|
||||||
const label = this.getLabel();
|
dispatch(actions.annotationDeleteLabelFromCategory(metadataField, label));
|
||||||
|
|
||||||
dispatch({
|
|
||||||
type: "annotation: delete label",
|
|
||||||
metadataField,
|
|
||||||
label,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
handleAddCurrentSelectionToThisLabel = () => {
|
handleAddCurrentSelectionToThisLabel = () => {
|
||||||
const { dispatch, metadataField, categoryIndex } = this.props;
|
const { dispatch, metadataField, label } = this.props;
|
||||||
const label = this.getLabel();
|
dispatch(actions.annotationLabelCurrentSelection(metadataField, label));
|
||||||
dispatch({
|
|
||||||
type: "annotation: label current cell selection",
|
|
||||||
metadataField,
|
|
||||||
categoryIndex,
|
|
||||||
label,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
handleEditValue = (e) => {
|
handleEditValue = (e) => {
|
||||||
const { dispatch, metadataField, categoryIndex } = this.props;
|
const { dispatch, metadataField, label } = this.props;
|
||||||
const { editedLabelText } = this.state;
|
const { editedLabelText } = this.state;
|
||||||
const label = this.getLabel();
|
|
||||||
this.cancelEditMode();
|
this.cancelEditMode();
|
||||||
dispatch({
|
dispatch(
|
||||||
type: "annotation: label edited",
|
actions.annotationRenameLabelInCategory(
|
||||||
editedLabel: editedLabelText,
|
|
||||||
metadataField,
|
metadataField,
|
||||||
categoryIndex,
|
|
||||||
label,
|
label,
|
||||||
});
|
editedLabelText
|
||||||
|
)
|
||||||
|
);
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
};
|
};
|
||||||
|
|
||||||
handleCreateArbitraryLabel = (txt) => {
|
handleCreateArbitraryLabel = (txt) => {
|
||||||
const { dispatch, metadataField, categoryIndex } = this.props;
|
const { dispatch, metadataField, label } = this.props;
|
||||||
const label = this.getLabel();
|
|
||||||
this.cancelEditMode();
|
this.cancelEditMode();
|
||||||
dispatch({
|
dispatch(
|
||||||
type: "annotation: label edited",
|
actions.annotationRenameLabelInCategory(metadataField, label, txt)
|
||||||
metadataField,
|
);
|
||||||
editedLabel: txt,
|
|
||||||
categoryIndex,
|
|
||||||
label,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
labelNameError = (name) => {
|
labelNameError = (name) => {
|
||||||
const { metadataField, ontology, schema } = this.props;
|
const { metadataField, ontology, schema } = this.props;
|
||||||
if (name === this.currentLabel()) return false;
|
if (name === this.currentLabelAsString()) return false;
|
||||||
return isLabelErroneous(name, metadataField, ontology, schema);
|
return isLabelErroneous(name, metadataField, ontology, schema);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -131,33 +127,45 @@ class CategoryValue extends React.Component {
|
|||||||
};
|
};
|
||||||
|
|
||||||
activateEditLabelMode = () => {
|
activateEditLabelMode = () => {
|
||||||
const { dispatch, metadataField, categoryIndex } = this.props;
|
const { dispatch, metadataField, categoryIndex, label } = this.props;
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "annotation: activate edit label mode",
|
type: "annotation: activate edit label mode",
|
||||||
metadataField,
|
metadataField,
|
||||||
categoryIndex,
|
categoryIndex,
|
||||||
|
label,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
cancelEditMode = () => {
|
cancelEditMode = () => {
|
||||||
const { dispatch, metadataField, categoryIndex } = this.props;
|
const { dispatch, metadataField, categoryIndex, label } = this.props;
|
||||||
this.setState({
|
this.setState({
|
||||||
editedLabelText: this.currentLabel(),
|
editedLabelText: this.currentLabelAsString(),
|
||||||
});
|
});
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "annotation: cancel edit label mode",
|
type: "annotation: cancel edit label mode",
|
||||||
metadataField,
|
metadataField,
|
||||||
categoryIndex,
|
categoryIndex,
|
||||||
|
label,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
toggleOff = () => {
|
toggleOff = () => {
|
||||||
const { dispatch, metadataField, categoryIndex } = this.props;
|
const {
|
||||||
dispatch({
|
dispatch,
|
||||||
type: "categorical metadata filter deselect",
|
|
||||||
metadataField,
|
metadataField,
|
||||||
categoryIndex,
|
categoryIndex,
|
||||||
});
|
categorySummary,
|
||||||
|
} = this.props;
|
||||||
|
const label = categorySummary.categoryValues[categoryIndex];
|
||||||
|
dispatch(
|
||||||
|
actions.selectCategoricalMetadataAction(
|
||||||
|
"categorical metadata filter deselect",
|
||||||
|
metadataField,
|
||||||
|
categorySummary.allCategoryValues,
|
||||||
|
label,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
shouldComponentUpdate = (nextProps, nextState) => {
|
shouldComponentUpdate = (nextProps, nextState) => {
|
||||||
@@ -171,60 +179,74 @@ class CategoryValue extends React.Component {
|
|||||||
If and only if true, update the component
|
If and only if true, update the component
|
||||||
*/
|
*/
|
||||||
const { props, state } = this;
|
const { props, state } = this;
|
||||||
const { metadataField, categoryIndex, categoricalSelection } = props;
|
const { categoryIndex, categorySummary, isSelected } = props;
|
||||||
const { categoricalSelection: newCategoricalSelection } = nextProps;
|
const {
|
||||||
|
categoryIndex: newCategoryIndex,
|
||||||
|
categorySummary: newCategorySummary,
|
||||||
|
isSelected: newIsSelected,
|
||||||
|
} = nextProps;
|
||||||
|
|
||||||
const valueSelectionChange =
|
const label = categorySummary.categoryValues[categoryIndex];
|
||||||
categoricalSelection[metadataField].categoryValueSelected[
|
const newLabel = newCategorySummary.categoryValues[newCategoryIndex];
|
||||||
categoryIndex
|
const labelChanged = label !== newLabel;
|
||||||
] !==
|
const valueSelectionChange = isSelected !== newIsSelected;
|
||||||
newCategoricalSelection[metadataField].categoryValueSelected[
|
|
||||||
categoryIndex
|
|
||||||
];
|
|
||||||
|
|
||||||
const worldChange = props.world !== nextProps.world;
|
|
||||||
const colorAccessorChange = props.colorAccessor !== nextProps.colorAccessor;
|
const colorAccessorChange = props.colorAccessor !== nextProps.colorAccessor;
|
||||||
const annotationsChange = props.annotations !== nextProps.annotations;
|
const annotationsChange = props.annotations !== nextProps.annotations;
|
||||||
const crossfilterChange =
|
|
||||||
props.isUserAnno && props.crossfilter !== nextProps.crossfilter;
|
|
||||||
const editingLabel = state.editedLabelText !== nextState.editedLabelText;
|
const editingLabel = state.editedLabelText !== nextState.editedLabelText;
|
||||||
const dilationChange = props.isDilated !== nextProps.isDilated;
|
const dilationChange = props.isDilated !== nextProps.isDilated;
|
||||||
|
|
||||||
|
const count = categorySummary.categoryValueCounts[categoryIndex];
|
||||||
|
const newCount = newCategorySummary.categoryValueCounts[newCategoryIndex];
|
||||||
|
const countChanged = count !== newCount;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
labelChanged ||
|
||||||
valueSelectionChange ||
|
valueSelectionChange ||
|
||||||
worldChange ||
|
|
||||||
colorAccessorChange ||
|
colorAccessorChange ||
|
||||||
annotationsChange ||
|
annotationsChange ||
|
||||||
crossfilterChange ||
|
|
||||||
editingLabel ||
|
editingLabel ||
|
||||||
dilationChange
|
dilationChange ||
|
||||||
|
countChanged
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
toggleOn = () => {
|
toggleOn = () => {
|
||||||
const { dispatch, metadataField, categoryIndex } = this.props;
|
const {
|
||||||
dispatch({
|
dispatch,
|
||||||
type: "categorical metadata filter select",
|
|
||||||
metadataField,
|
metadataField,
|
||||||
categoryIndex,
|
categoryIndex,
|
||||||
});
|
categorySummary,
|
||||||
|
} = this.props;
|
||||||
|
const label = categorySummary.categoryValues[categoryIndex];
|
||||||
|
dispatch(
|
||||||
|
actions.selectCategoricalMetadataAction(
|
||||||
|
"categorical metadata filter select",
|
||||||
|
metadataField,
|
||||||
|
categorySummary.allCategoryValues,
|
||||||
|
label,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
handleMouseEnter = () => {
|
handleMouseEnter = () => {
|
||||||
const { dispatch, metadataField, categoryIndex } = this.props;
|
const { dispatch, metadataField, categoryIndex, label } = this.props;
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "category value mouse hover start",
|
type: "category value mouse hover start",
|
||||||
metadataField,
|
metadataField,
|
||||||
categoryIndex,
|
categoryIndex,
|
||||||
|
label,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
handleMouseExit = () => {
|
handleMouseExit = () => {
|
||||||
const { dispatch, metadataField, categoryIndex } = this.props;
|
const { dispatch, metadataField, categoryIndex, label } = this.props;
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "category value mouse hover end",
|
type: "category value mouse hover end",
|
||||||
metadataField,
|
metadataField,
|
||||||
categoryIndex,
|
categoryIndex,
|
||||||
|
label,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -237,26 +259,103 @@ class CategoryValue extends React.Component {
|
|||||||
this.setState({ editedLabelText: e.target });
|
this.setState({ editedLabelText: e.target });
|
||||||
};
|
};
|
||||||
|
|
||||||
getLabel = () => {
|
createHistogramBins = (
|
||||||
const { metadataField, categoryIndex, categoricalSelection } = this.props;
|
metadataField,
|
||||||
const category = categoricalSelection[metadataField];
|
categoryData,
|
||||||
const label = category.categoryValues[categoryIndex];
|
colorAccessor,
|
||||||
|
colorData,
|
||||||
|
categoryValue,
|
||||||
|
width,
|
||||||
|
height
|
||||||
|
) => {
|
||||||
|
/*
|
||||||
|
Knowing that colorScale is based off continuous data,
|
||||||
|
createHistogramBins fetches the continuous data in relation to the cells relevant to the category value.
|
||||||
|
It then separates that data into 50 bins for drawing the mini-histogram
|
||||||
|
*/
|
||||||
|
const groupBy = categoryData.col(metadataField);
|
||||||
|
const col = colorData.icol(0);
|
||||||
|
const range = col.summarize();
|
||||||
|
|
||||||
return label;
|
const histogramMap = col.histogram(
|
||||||
|
50,
|
||||||
|
[range.min, range.max],
|
||||||
|
groupBy
|
||||||
|
); /* Because the signature changes we really need different names for histogram to differentiate signatures */
|
||||||
|
|
||||||
|
const bins = histogramMap.has(categoryValue)
|
||||||
|
? histogramMap.get(categoryValue)
|
||||||
|
: new Array(50).fill(0);
|
||||||
|
|
||||||
|
const xScale = d3.scaleLinear().domain([0, bins.length]).range([0, width]);
|
||||||
|
|
||||||
|
const largestBin = Math.max(...bins);
|
||||||
|
|
||||||
|
const yScale = d3.scaleLinear().domain([0, largestBin]).range([0, height]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
xScale,
|
||||||
|
yScale,
|
||||||
|
bins,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
currentLabel() {
|
createStackedGraphBins = (
|
||||||
const { categoricalSelection } = this.props;
|
metadataField,
|
||||||
return _currentLabel(this.props, categoricalSelection);
|
categoryData,
|
||||||
|
colorAccessor,
|
||||||
|
colorData,
|
||||||
|
categoryValue,
|
||||||
|
colorTable,
|
||||||
|
schema,
|
||||||
|
width
|
||||||
|
) => {
|
||||||
|
/*
|
||||||
|
Knowing that the color scale is based off of categorical data,
|
||||||
|
createOccupancyStack obtains a map showing the number if cells per colored value
|
||||||
|
Using the colorScale a stack of colored bars is drawn representing the map
|
||||||
|
*/
|
||||||
|
const groupBy = categoryData.col(metadataField);
|
||||||
|
const occupancyMap = colorData
|
||||||
|
.col(colorAccessor)
|
||||||
|
.histogramCategorical(groupBy);
|
||||||
|
|
||||||
|
const occupancy = occupancyMap.get(categoryValue);
|
||||||
|
|
||||||
|
if (occupancy && occupancy.size > 0) {
|
||||||
|
// not all categories have occupancy, so occupancy may be undefined.
|
||||||
|
const scale = d3
|
||||||
|
.scaleLinear()
|
||||||
|
/* get all the keys d[1] as an array, then find the sum */
|
||||||
|
.domain([0, d3.sum(Array.from(occupancy.values()))])
|
||||||
|
.range([0, width]);
|
||||||
|
const categories =
|
||||||
|
schema.annotations.obsByName[colorAccessor]?.categories;
|
||||||
|
|
||||||
|
const dfColumn = colorData.col(colorAccessor);
|
||||||
|
const categoryValues = dfColumn.summarizeCategorical().categories;
|
||||||
|
|
||||||
|
return {
|
||||||
|
domainValues: categoryValues,
|
||||||
|
scale,
|
||||||
|
domain: categories,
|
||||||
|
occupancy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
currentLabelAsString() {
|
||||||
|
return _currentLabelAsString(this.props);
|
||||||
}
|
}
|
||||||
|
|
||||||
isAddCurrentSelectionDisabled(category, value) {
|
isAddCurrentSelectionDisabled(crossfilter, category, value) {
|
||||||
/*
|
/*
|
||||||
disable "add current selection to label", if one of the following is true:
|
disable "add current selection to label", if one of the following is true:
|
||||||
1. no cells are selected
|
1. no cells are selected
|
||||||
2. all currently selected cells already have this label, on this category
|
2. all currently selected cells already have this label, on this category
|
||||||
*/
|
*/
|
||||||
const { crossfilter, world } = this.props;
|
const { categoryData } = this.props;
|
||||||
|
|
||||||
// 1. no cells selected?
|
// 1. no cells selected?
|
||||||
if (crossfilter.countSelected() === 0) {
|
if (crossfilter.countSelected() === 0) {
|
||||||
@@ -265,12 +364,7 @@ class CategoryValue extends React.Component {
|
|||||||
// 2. all selected cells already have the label
|
// 2. all selected cells already have the label
|
||||||
const mask = crossfilter.allSelectedMask();
|
const mask = crossfilter.allSelectedMask();
|
||||||
if (
|
if (
|
||||||
AnnotationsHelpers.allHaveLabelByMask(
|
AnnotationsHelpers.allHaveLabelByMask(categoryData, category, value, mask)
|
||||||
world.obsAnnotations,
|
|
||||||
category,
|
|
||||||
value,
|
|
||||||
mask
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -278,43 +372,132 @@ class CategoryValue extends React.Component {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderMiniStackedBar = () => {
|
||||||
|
const {
|
||||||
|
colorAccessor,
|
||||||
|
metadataField,
|
||||||
|
categoryData,
|
||||||
|
colorData,
|
||||||
|
colorTable,
|
||||||
|
schema,
|
||||||
|
label,
|
||||||
|
} = this.props;
|
||||||
|
const isColorBy = metadataField === colorAccessor;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!this.shouldRenderStackedBarOrHistogram ||
|
||||||
|
!AnnotationsHelpers.isCategoricalAnnotation(schema, colorAccessor) ||
|
||||||
|
isColorBy
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { domainValues, scale, domain, occupancy } =
|
||||||
|
this.createStackedGraphBins(
|
||||||
|
metadataField,
|
||||||
|
categoryData,
|
||||||
|
colorAccessor,
|
||||||
|
colorData,
|
||||||
|
label,
|
||||||
|
colorTable,
|
||||||
|
schema,
|
||||||
|
CHART_WIDTH
|
||||||
|
) ?? {};
|
||||||
|
|
||||||
|
if (!domainValues || !scale || !domain || !occupancy) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MiniStackedBar
|
||||||
|
/* eslint-disable react/jsx-props-no-spreading -- Disable unneeded on next release of eslint-config-airbnb */
|
||||||
|
{...{
|
||||||
|
colorTable,
|
||||||
|
domainValues,
|
||||||
|
scale,
|
||||||
|
domain,
|
||||||
|
occupancy,
|
||||||
|
}}
|
||||||
|
/* eslint-enable react/jsx-props-no-spreading -- enable */
|
||||||
|
height={VALUE_HEIGHT}
|
||||||
|
width={CHART_WIDTH}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
renderMiniHistogram = () => {
|
||||||
|
const {
|
||||||
|
colorAccessor,
|
||||||
|
metadataField,
|
||||||
|
colorData,
|
||||||
|
categoryData,
|
||||||
|
colorTable,
|
||||||
|
schema,
|
||||||
|
label,
|
||||||
|
} = this.props;
|
||||||
|
const colorScale = colorTable?.scale;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!this.shouldRenderStackedBarOrHistogram ||
|
||||||
|
!AnnotationsHelpers.isContinuousAnnotation(schema, colorAccessor)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { xScale, yScale, bins } =
|
||||||
|
this.createHistogramBins(
|
||||||
|
metadataField,
|
||||||
|
categoryData,
|
||||||
|
colorAccessor,
|
||||||
|
colorData,
|
||||||
|
label,
|
||||||
|
CHART_WIDTH,
|
||||||
|
VALUE_HEIGHT
|
||||||
|
) ?? {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MiniHistogram
|
||||||
|
/* eslint-disable react/jsx-props-no-spreading -- Disable unneeded on next release of eslint-config-airbnb */
|
||||||
|
{...{
|
||||||
|
colorScale,
|
||||||
|
xScale,
|
||||||
|
yScale,
|
||||||
|
bins,
|
||||||
|
}}
|
||||||
|
/* eslint-enable react/jsx-props-no-spreading -- enable */
|
||||||
|
obsOrVarContinuousFieldDisplayName={colorAccessor}
|
||||||
|
domainLabel={label}
|
||||||
|
height={VALUE_HEIGHT}
|
||||||
|
width={CHART_WIDTH}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
categoricalSelection,
|
|
||||||
metadataField,
|
metadataField,
|
||||||
categoryIndex,
|
categoryIndex,
|
||||||
colorAccessor,
|
colorAccessor,
|
||||||
colorScale,
|
colorTable,
|
||||||
i,
|
|
||||||
schema,
|
|
||||||
isUserAnno,
|
isUserAnno,
|
||||||
annotations,
|
annotations,
|
||||||
ontology,
|
ontology,
|
||||||
// flippedProps is potentially brittle, their docs want {...flippedProps} on our div,
|
|
||||||
// our lint doesn't like jsx spread, we are version pinned to prevent api change on their part
|
|
||||||
flippedProps,
|
|
||||||
isDilated,
|
isDilated,
|
||||||
world,
|
isSelected,
|
||||||
|
categorySummary,
|
||||||
|
label,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
const colorScale = colorTable?.scale;
|
||||||
const ontologyEnabled = ontology?.enabled ?? false;
|
const ontologyEnabled = ontology?.enabled ?? false;
|
||||||
|
|
||||||
const { editedLabelText } = this.state;
|
const { editedLabelText } = this.state;
|
||||||
|
|
||||||
if (!categoricalSelection) return null;
|
const count = categorySummary.categoryValueCounts[categoryIndex];
|
||||||
|
const displayString = this.currentLabelAsString();
|
||||||
const category = categoricalSelection[metadataField];
|
|
||||||
const selected = category.categoryValueSelected[categoryIndex];
|
|
||||||
const count = category.categoryValueCounts[categoryIndex];
|
|
||||||
const value = category.categoryValues[categoryIndex];
|
|
||||||
const displayString = this.currentLabel();
|
|
||||||
|
|
||||||
/* this is the color scale, so add swatches below */
|
/* this is the color scale, so add swatches below */
|
||||||
const isColorBy = metadataField === colorAccessor;
|
const isColorBy = metadataField === colorAccessor;
|
||||||
let categories = null;
|
const { categoryValueIndices } = categorySummary;
|
||||||
|
|
||||||
if (isColorBy && schema) {
|
|
||||||
categories = schema.annotations.obsByName[colorAccessor]?.categories;
|
|
||||||
}
|
|
||||||
|
|
||||||
const editModeActive =
|
const editModeActive =
|
||||||
isUserAnno &&
|
isUserAnno &&
|
||||||
@@ -322,13 +505,14 @@ class CategoryValue extends React.Component {
|
|||||||
annotations.isEditingLabelName &&
|
annotations.isEditingLabelName &&
|
||||||
annotations.labelEditable.label === categoryIndex;
|
annotations.labelEditable.label === categoryIndex;
|
||||||
|
|
||||||
const valueToggleLabel = `value-toggle-checkbox-${displayString}`;
|
const valueToggleLabel = `value-toggle-checkbox-${metadataField}-${displayString}`;
|
||||||
|
|
||||||
const LEFT_MARGIN = 33;
|
const LEFT_MARGIN = 60;
|
||||||
const CHECKBOX = 26;
|
const CHECKBOX = 26;
|
||||||
const CELL_NUMBER = 61;
|
const CELL_NUMBER = 50;
|
||||||
const ANNO_MENU = 26;
|
const ANNO_MENU = 26;
|
||||||
const LABEL_MARGIN = 24;
|
const LABEL_MARGIN = 16;
|
||||||
|
const CHART_MARGIN = 24;
|
||||||
|
|
||||||
const otherElementsWidth =
|
const otherElementsWidth =
|
||||||
LEFT_MARGIN +
|
LEFT_MARGIN +
|
||||||
@@ -337,19 +521,16 @@ class CategoryValue extends React.Component {
|
|||||||
LABEL_MARGIN +
|
LABEL_MARGIN +
|
||||||
(isUserAnno ? ANNO_MENU : 0);
|
(isUserAnno ? ANNO_MENU : 0);
|
||||||
|
|
||||||
const OCCUPANCY_WIDTH = 100;
|
|
||||||
|
|
||||||
const labelWidth =
|
const labelWidth =
|
||||||
colorAccessor && !isColorBy
|
colorAccessor && !isColorBy
|
||||||
? globals.leftSidebarWidth - otherElementsWidth - OCCUPANCY_WIDTH
|
? globals.leftSidebarWidth -
|
||||||
|
otherElementsWidth -
|
||||||
|
CHART_WIDTH -
|
||||||
|
CHART_MARGIN
|
||||||
: globals.leftSidebarWidth - otherElementsWidth;
|
: globals.leftSidebarWidth - otherElementsWidth;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={i}
|
|
||||||
data-flip-config={flippedProps["data-flip-config"]}
|
|
||||||
data-flip-id={flippedProps["data-flip-id"]}
|
|
||||||
data-portal-key={flippedProps["data-portal-key"]}
|
|
||||||
className={
|
className={
|
||||||
/* This code is to change the styles on centroid label hover is causing over-rendering */
|
/* This code is to change the styles on centroid label hover is causing over-rendering */
|
||||||
`${styles.value}${isDilated ? ` ${styles.hover}` : ""}`
|
`${styles.value}${isDilated ? ` ${styles.hover}` : ""}`
|
||||||
@@ -384,10 +565,10 @@ class CategoryValue extends React.Component {
|
|||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
id={valueToggleLabel}
|
id={valueToggleLabel}
|
||||||
onChange={selected ? this.toggleOff : this.toggleOn}
|
onChange={isSelected ? this.toggleOff : this.toggleOn}
|
||||||
data-testclass="categorical-value-select"
|
data-testclass="categorical-value-select"
|
||||||
data-testid={`categorical-value-select-${metadataField}-${displayString}`}
|
data-testid={`categorical-value-select-${metadataField}-${displayString}`}
|
||||||
checked={selected}
|
checked={isSelected}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
@@ -460,16 +641,8 @@ class CategoryValue extends React.Component {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<span style={{ flexShrink: 0 }}>
|
<span style={{ flexShrink: 0 }}>
|
||||||
{colorAccessor && !isColorBy && !annotations.isEditingLabelName ? (
|
{this.renderMiniStackedBar()}
|
||||||
<Occupancy
|
{this.renderMiniHistogram()}
|
||||||
categoryValue={value}
|
|
||||||
colorAccessor={colorAccessor}
|
|
||||||
metadataField={metadataField}
|
|
||||||
world={world}
|
|
||||||
colorScale={colorScale}
|
|
||||||
colorByIsCategorical={!!categoricalSelection[colorAccessor]}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -492,14 +665,14 @@ class CategoryValue extends React.Component {
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<svg
|
<svg
|
||||||
display={isColorBy && categories ? "auto" : "none"}
|
display={isColorBy && categoryValueIndices ? "auto" : "none"}
|
||||||
style={{
|
style={{
|
||||||
marginLeft: 5,
|
marginLeft: 5,
|
||||||
width: 11,
|
width: VALUE_HEIGHT,
|
||||||
height: 11,
|
height: VALUE_HEIGHT,
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
isColorBy && categories
|
isColorBy && categoryValueIndices
|
||||||
? colorScale(categories.indexOf(value))
|
? colorScale(categoryValueIndices.get(label))
|
||||||
: "inherit",
|
: "inherit",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -514,6 +687,8 @@ class CategoryValue extends React.Component {
|
|||||||
position={Position.RIGHT_TOP}
|
position={Position.RIGHT_TOP}
|
||||||
content={
|
content={
|
||||||
<Menu>
|
<Menu>
|
||||||
|
<CategoryCrossfilterContext.Consumer>
|
||||||
|
{(crossfilter) => (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon="plus"
|
icon="plus"
|
||||||
data-testclass="handleAddCurrentSelectionToThisLabel"
|
data-testclass="handleAddCurrentSelectionToThisLabel"
|
||||||
@@ -536,10 +711,13 @@ class CategoryValue extends React.Component {
|
|||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
disabled={this.isAddCurrentSelectionDisabled(
|
disabled={this.isAddCurrentSelectionDisabled(
|
||||||
|
crossfilter,
|
||||||
metadataField,
|
metadataField,
|
||||||
value
|
label
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</CategoryCrossfilterContext.Consumer>
|
||||||
{displayString !== globals.unassignedCategoryLabel ? (
|
{displayString !== globals.unassignedCategoryLabel ? (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon="edit"
|
icon="edit"
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import {
|
|||||||
Classes,
|
Classes,
|
||||||
} from "@blueprintjs/core";
|
} from "@blueprintjs/core";
|
||||||
|
|
||||||
@connect()
|
@connect((state) => ({
|
||||||
|
schema: state.annoMatrix?.schema,
|
||||||
|
}))
|
||||||
class Occupancy extends React.PureComponent {
|
class Occupancy extends React.PureComponent {
|
||||||
_WIDTH = 100;
|
_WIDTH = 100;
|
||||||
|
|
||||||
@@ -21,16 +23,17 @@ class Occupancy extends React.PureComponent {
|
|||||||
createHistogram fetches the continous data in relation to the cells releveant to the catagory value.
|
createHistogram fetches the continous data in relation to the cells releveant to the catagory value.
|
||||||
It then seperates that data into 50 bins for drawing the mini-histogram
|
It then seperates that data into 50 bins for drawing the mini-histogram
|
||||||
*/
|
*/
|
||||||
const { world, metadataField, colorAccessor, categoryValue } = this.props;
|
const {
|
||||||
|
metadataField,
|
||||||
|
categoryData,
|
||||||
|
colorData,
|
||||||
|
categoryValue,
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
if (!this.canvas) return;
|
if (!this.canvas) return;
|
||||||
|
|
||||||
const groupBy = world.obsAnnotations.col(metadataField);
|
const groupBy = categoryData.col(metadataField);
|
||||||
|
const col = colorData.icol(0);
|
||||||
const col =
|
|
||||||
world.obsAnnotations.col(colorAccessor) ||
|
|
||||||
world.varData.col(colorAccessor);
|
|
||||||
|
|
||||||
const range = col.summarize();
|
const range = col.summarize();
|
||||||
|
|
||||||
const histogramMap = col.histogram(
|
const histogramMap = col.histogram(
|
||||||
@@ -39,7 +42,6 @@ class Occupancy extends React.PureComponent {
|
|||||||
groupBy
|
groupBy
|
||||||
); /* Because the signature changes we really need different names for histogram to differentiate signatures */
|
); /* Because the signature changes we really need different names for histogram to differentiate signatures */
|
||||||
|
|
||||||
// const categoryValue = category.categoryValues[categoryIndex];
|
|
||||||
const bins = histogramMap.has(categoryValue)
|
const bins = histogramMap.has(categoryValue)
|
||||||
? histogramMap.get(categoryValue)
|
? histogramMap.get(categoryValue)
|
||||||
: new Array(50).fill(0);
|
: new Array(50).fill(0);
|
||||||
@@ -79,20 +81,22 @@ class Occupancy extends React.PureComponent {
|
|||||||
Using the colorScale a stack of colored bars is drawn representing the map
|
Using the colorScale a stack of colored bars is drawn representing the map
|
||||||
*/
|
*/
|
||||||
const {
|
const {
|
||||||
world,
|
|
||||||
metadataField,
|
metadataField,
|
||||||
|
categoryData,
|
||||||
colorAccessor,
|
colorAccessor,
|
||||||
categoryValue,
|
categoryValue,
|
||||||
colorScale,
|
colorTable,
|
||||||
|
schema,
|
||||||
|
colorData,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const { schema } = world;
|
const { scale: colorScale } = colorTable;
|
||||||
|
|
||||||
const ctx = this.canvas?.getContext("2d");
|
const ctx = this.canvas?.getContext("2d");
|
||||||
|
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
const groupBy = world.obsAnnotations.col(metadataField);
|
const groupBy = categoryData.col(metadataField);
|
||||||
const occupancyMap = world.obsAnnotations
|
const occupancyMap = colorData
|
||||||
.col(colorAccessor)
|
.col(colorAccessor)
|
||||||
.histogramCategorical(groupBy);
|
.histogramCategorical(groupBy);
|
||||||
|
|
||||||
@@ -109,7 +113,7 @@ class Occupancy extends React.PureComponent {
|
|||||||
schema.annotations.obsByName[colorAccessor]?.categories;
|
schema.annotations.obsByName[colorAccessor]?.categories;
|
||||||
|
|
||||||
let currentOffset = 0;
|
let currentOffset = 0;
|
||||||
const dfColumn = world.obsAnnotations.col(colorAccessor);
|
const dfColumn = colorData.col(colorAccessor);
|
||||||
const categoryValues = dfColumn.summarizeCategorical().categories;
|
const categoryValues = dfColumn.summarizeCategorical().categories;
|
||||||
|
|
||||||
let o;
|
let o;
|
||||||
|
|||||||
@@ -1,97 +1,28 @@
|
|||||||
// jshint esversion: 6
|
|
||||||
/* rc slider https://www.npmjs.com/package/rc-slider */
|
/* rc slider https://www.npmjs.com/package/rc-slider */
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { Button } from "@blueprintjs/core";
|
|
||||||
|
|
||||||
import * as globals from "../../globals";
|
|
||||||
import HistogramBrush from "../brushableHistogram";
|
import HistogramBrush from "../brushableHistogram";
|
||||||
|
|
||||||
@connect((state) => ({
|
@connect((state) => ({
|
||||||
obsAnnotations: state.world?.obsAnnotations,
|
schema: state.annoMatrix?.schema,
|
||||||
colorAccessor: state.colors.colorAccessor,
|
|
||||||
colorScale: state.colors.scale,
|
|
||||||
schema: state.world?.schema,
|
|
||||||
}))
|
}))
|
||||||
class Continuous extends React.PureComponent {
|
class Continuous extends React.PureComponent {
|
||||||
static renderIsStillLoading(zebra, key) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={key}
|
|
||||||
style={{
|
|
||||||
padding: globals.leftSidebarSectionPadding,
|
|
||||||
backgroundColor: zebra % 2 === 0 ? globals.lightestGrey : "white",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
justifyItems: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ minWidth: 30 }} />
|
|
||||||
<div style={{ display: "flex", alignSelf: "center" }}>
|
|
||||||
<span style={{ fontStyle: "italic" }}>{key}</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "flex-end",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button minimal loading intent="primary" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { obsAnnotations, schema } = this.props;
|
/* initial value for iterator to simulate index, ranges is an object */
|
||||||
|
const { schema } = this.props;
|
||||||
|
if (!schema) return null;
|
||||||
const obsIndex = schema.annotations.obs.index;
|
const obsIndex = schema.annotations.obs.index;
|
||||||
const allContinuousNames = schema.annotations.obs.columns
|
const allContinuousNames = schema.annotations.obs.columns
|
||||||
.filter((col) => col.type === "int32" || col.type === "float32")
|
.filter((col) => col.type === "int32" || col.type === "float32")
|
||||||
.filter((col) => col.name !== obsIndex)
|
.filter((col) => col.name !== obsIndex)
|
||||||
.map((col) => col.name);
|
.map((col) => col.name);
|
||||||
|
|
||||||
/* initial value for iterator to simulate index, ranges is an object */
|
|
||||||
let zebra = 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{allContinuousNames.map((key) => {
|
{allContinuousNames.map((key, zebra) => (
|
||||||
if (!obsAnnotations.hasCol(key)) {
|
<HistogramBrush key={key} field={key} isObs zebra={zebra % 2 === 0} />
|
||||||
// still loading!
|
))}
|
||||||
zebra += 1;
|
|
||||||
return Continuous.renderIsStillLoading(zebra, key);
|
|
||||||
}
|
|
||||||
|
|
||||||
// data loaded and available
|
|
||||||
const summary = obsAnnotations.col(key).summarize();
|
|
||||||
const nonFiniteExtent =
|
|
||||||
summary.min === undefined ||
|
|
||||||
summary.max === undefined ||
|
|
||||||
Number.isNaN(summary.min) ||
|
|
||||||
Number.isNaN(summary.max);
|
|
||||||
if (!summary.categorical && !nonFiniteExtent) {
|
|
||||||
zebra += 1;
|
|
||||||
return (
|
|
||||||
<HistogramBrush
|
|
||||||
key={key}
|
|
||||||
field={key}
|
|
||||||
isObs
|
|
||||||
zebra={zebra % 2 === 0}
|
|
||||||
ranges={summary}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { connect } from "react-redux";
|
|||||||
import * as d3 from "d3";
|
import * as d3 from "d3";
|
||||||
import { interpolateCool } from "d3-scale-chromatic";
|
import { interpolateCool } from "d3-scale-chromatic";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createColorTable,
|
||||||
|
createColorQuery,
|
||||||
|
} from "../../util/stateManager/colorHelpers";
|
||||||
|
|
||||||
// create continuous color legend
|
// create continuous color legend
|
||||||
// http://bl.ocks.org/syntagmatic/e8ccca52559796be775553b467593a9f
|
// http://bl.ocks.org/syntagmatic/e8ccca52559796be775553b467593a9f
|
||||||
const continuous = (selectorId, colorscale, colorAccessor) => {
|
const continuous = (selectorId, colorscale, colorAccessor) => {
|
||||||
@@ -101,23 +106,54 @@ const continuous = (selectorId, colorscale, colorAccessor) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
@connect((state) => ({
|
@connect((state) => ({
|
||||||
colorAccessor: state.colors.colorAccessor,
|
annoMatrix: state.annoMatrix,
|
||||||
colorScale: state.colors.scale,
|
colors: state.colors,
|
||||||
}))
|
}))
|
||||||
class ContinuousLegend extends React.Component {
|
class ContinuousLegend extends React.Component {
|
||||||
componentDidUpdate(prevProps) {
|
constructor(props) {
|
||||||
const { colorAccessor, colorScale } = this.props;
|
super(props);
|
||||||
if (
|
this.ref = null;
|
||||||
prevProps.colorAccessor !== colorAccessor ||
|
this.state = {
|
||||||
prevProps.colorScale !== colorScale
|
colorAccessor: null,
|
||||||
) {
|
colorScale: null,
|
||||||
/* always remove it, if it's not continuous we don't put it back. */
|
};
|
||||||
d3.select("#continuous_legend").selectAll("*").remove();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (colorAccessor && colorScale && colorScale.range) {
|
componentDidMount() {
|
||||||
|
this.updateState(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
this.updateState(prevProps);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateState(prevProps) {
|
||||||
|
const { annoMatrix, colors } = this.props;
|
||||||
|
if (!colors || !annoMatrix) return;
|
||||||
|
|
||||||
|
if (colors !== prevProps?.colors || annoMatrix !== prevProps?.annoMatrix) {
|
||||||
|
const { schema } = annoMatrix;
|
||||||
|
const { colorMode, colorAccessor, userColors } = colors;
|
||||||
|
const colorQuery = createColorQuery(colorMode, colorAccessor, schema);
|
||||||
|
const colorDf = colorQuery ? await annoMatrix.fetch(...colorQuery) : null;
|
||||||
|
const colorTable = createColorTable(
|
||||||
|
colorMode,
|
||||||
|
colorAccessor,
|
||||||
|
colorDf,
|
||||||
|
schema,
|
||||||
|
userColors
|
||||||
|
);
|
||||||
|
|
||||||
|
const colorScale = colorTable.scale;
|
||||||
|
const range = colorScale?.range;
|
||||||
|
const [domainMin, domainMax] = colorScale?.domain?.() ?? [0, 0];
|
||||||
|
|
||||||
|
/* always remove it, if it's not continuous we don't put it back. */
|
||||||
|
d3.select("#continuous_legend").selectAll("*").remove();
|
||||||
|
|
||||||
|
if (colorAccessor && colorScale && range && domainMin < domainMax) {
|
||||||
/* fragile! continuous range is 0 to 1, not [#fa4b2c, ...], make this a flag? */
|
/* fragile! continuous range is 0 to 1, not [#fa4b2c, ...], make this a flag? */
|
||||||
if (colorScale.range()[0][0] !== "#") {
|
if (range()[0][0] !== "#") {
|
||||||
continuous(
|
continuous(
|
||||||
"#continuous_legend",
|
"#continuous_legend",
|
||||||
d3.scaleSequential(interpolateCool).domain(colorScale.domain()),
|
d3.scaleSequential(interpolateCool).domain(colorScale.domain()),
|
||||||
@@ -125,10 +161,23 @@ class ContinuousLegend extends React.Component {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
colorAccessor,
|
||||||
|
colorScale: colorTable.scale,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { colorAccessor } = this.props;
|
const { colorAccessor, colorScale } = this.state;
|
||||||
|
if (
|
||||||
|
colorScale?.domain &&
|
||||||
|
colorScale.domain()[1] === colorScale.domain()[0]
|
||||||
|
) {
|
||||||
|
/* it's a single value, not a distribution, min max are the same */
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id="continuous_legend"
|
id="continuous_legend"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Position, Toaster, Intent } from "@blueprintjs/core";
|
|||||||
const ToastTopCenter = Toaster.create({
|
const ToastTopCenter = Toaster.create({
|
||||||
className: "recipe-toaster",
|
className: "recipe-toaster",
|
||||||
position: Position.TOP,
|
position: Position.TOP,
|
||||||
|
maxToasts: 4,
|
||||||
});
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -23,12 +24,15 @@ export const keepAroundErrorToast = (message) =>
|
|||||||
/*
|
/*
|
||||||
a hard network error
|
a hard network error
|
||||||
*/
|
*/
|
||||||
export const postNetworkErrorToast = (message) =>
|
export const postNetworkErrorToast = (message, key = undefined) =>
|
||||||
ToastTopCenter.show({
|
ToastTopCenter.show(
|
||||||
|
{
|
||||||
message,
|
message,
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
intent: Intent.DANGER,
|
intent: Intent.DANGER,
|
||||||
});
|
},
|
||||||
|
key
|
||||||
|
);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Async message to user
|
Async message to user
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// jshint esversion: 6
|
|
||||||
/* rc slider https://www.npmjs.com/package/rc-slider */
|
/* rc slider https://www.npmjs.com/package/rc-slider */
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
@@ -34,9 +33,6 @@ const renderGene = (fuzzySortResult, { handleClick, modifiers }) => {
|
|||||||
active={modifiers.active}
|
active={modifiers.active}
|
||||||
disabled={modifiers.disabled}
|
disabled={modifiers.disabled}
|
||||||
data-testid={`suggest-menu-item-${geneName}`}
|
data-testid={`suggest-menu-item-${geneName}`}
|
||||||
// Use of annotations in this way is incorrect and dataset specific.
|
|
||||||
// See https://github.com/chanzuckerberg/cellxgene/issues/483
|
|
||||||
// label={gene.n_counts}
|
|
||||||
key={geneName}
|
key={geneName}
|
||||||
onClick={(g) =>
|
onClick={(g) =>
|
||||||
/* this fires when user clicks a menu item */
|
/* this fires when user clicks a menu item */
|
||||||
@@ -56,11 +52,9 @@ const filterGenes = (query, genes) =>
|
|||||||
|
|
||||||
@connect((state) => {
|
@connect((state) => {
|
||||||
return {
|
return {
|
||||||
obsAnnotations: state.world?.obsAnnotations,
|
annoMatrix: state.annoMatrix,
|
||||||
userDefinedGenes: state.controls.userDefinedGenes,
|
userDefinedGenes: state.controls.userDefinedGenes,
|
||||||
userDefinedGenesLoading: state.controls.userDefinedGenesLoading,
|
userDefinedGenesLoading: state.controls.userDefinedGenesLoading,
|
||||||
world: state.world,
|
|
||||||
colorAccessor: state.colors.colorAccessor,
|
|
||||||
differential: state.differential,
|
differential: state.differential,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@@ -71,9 +65,19 @@ class AddGenes extends React.Component {
|
|||||||
bulkAdd: "",
|
bulkAdd: "",
|
||||||
tab: "autosuggest",
|
tab: "autosuggest",
|
||||||
activeItem: null,
|
activeItem: null,
|
||||||
|
geneNames: [],
|
||||||
|
status: "pending",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.updateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
this.updateState(prevProps);
|
||||||
|
}
|
||||||
|
|
||||||
_genesToUpper = (listGenes) => {
|
_genesToUpper = (listGenes) => {
|
||||||
// Has to be a Map to preserve index
|
// Has to be a Map to preserve index
|
||||||
const upperGenes = new Map();
|
const upperGenes = new Map();
|
||||||
@@ -84,13 +88,12 @@ class AddGenes extends React.Component {
|
|||||||
return upperGenes;
|
return upperGenes;
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line react/sort-comp
|
// eslint-disable-next-line react/sort-comp -- memo requires a defined _genesToUpper
|
||||||
_memoGenesToUpper = memoize(this._genesToUpper, (arr) => arr);
|
_memoGenesToUpper = memoize(this._genesToUpper, (arr) => arr);
|
||||||
|
|
||||||
handleBulkAddClick = () => {
|
handleBulkAddClick = () => {
|
||||||
const { world, dispatch, userDefinedGenes } = this.props;
|
const { dispatch, userDefinedGenes } = this.props;
|
||||||
const varIndexName = world.schema.annotations.var.index;
|
const { bulkAdd, geneNames } = this.state;
|
||||||
const { bulkAdd } = this.state;
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
test:
|
test:
|
||||||
@@ -98,18 +101,14 @@ class AddGenes extends React.Component {
|
|||||||
*/
|
*/
|
||||||
if (bulkAdd !== "") {
|
if (bulkAdd !== "") {
|
||||||
const genes = _.pull(_.uniq(bulkAdd.split(/[ ,]+/)), "");
|
const genes = _.pull(_.uniq(bulkAdd.split(/[ ,]+/)), "");
|
||||||
console.log("geneExpression genes", genes);
|
|
||||||
if (genes.length === 0) {
|
if (genes.length === 0) {
|
||||||
return keepAroundErrorToast("Must enter a gene name.");
|
return keepAroundErrorToast("Must enter a gene name.");
|
||||||
}
|
}
|
||||||
const worldGenes =
|
|
||||||
world.varAnnotations?.col(varIndexName)?.asArray() || [];
|
|
||||||
|
|
||||||
// These gene lists are unique enough where memoization is useless
|
// These gene lists are unique enough where memoization is useless
|
||||||
const upperGenes = this._genesToUpper(genes);
|
const upperGenes = this._genesToUpper(genes);
|
||||||
const upperUserDefinedGenes = this._genesToUpper(userDefinedGenes);
|
const upperUserDefinedGenes = this._genesToUpper(userDefinedGenes);
|
||||||
|
|
||||||
const upperWorldGenes = this._memoGenesToUpper(worldGenes);
|
const upperGeneNames = this._memoGenesToUpper(geneNames);
|
||||||
|
|
||||||
dispatch({ type: "bulk user defined gene start" });
|
dispatch({ type: "bulk user defined gene start" });
|
||||||
|
|
||||||
@@ -119,7 +118,7 @@ class AddGenes extends React.Component {
|
|||||||
return keepAroundErrorToast("That gene already exists");
|
return keepAroundErrorToast("That gene already exists");
|
||||||
}
|
}
|
||||||
|
|
||||||
const indexOfGene = upperWorldGenes.get(upperGene);
|
const indexOfGene = upperGeneNames.get(upperGene);
|
||||||
|
|
||||||
if (indexOfGene === undefined) {
|
if (indexOfGene === undefined) {
|
||||||
return keepAroundErrorToast(
|
return keepAroundErrorToast(
|
||||||
@@ -129,7 +128,7 @@ class AddGenes extends React.Component {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return dispatch(
|
return dispatch(
|
||||||
actions.requestUserDefinedGene(worldGenes[indexOfGene])
|
actions.requestUserDefinedGene(geneNames[indexOfGene])
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
).then(
|
).then(
|
||||||
@@ -142,6 +141,27 @@ class AddGenes extends React.Component {
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async updateState(prevProps) {
|
||||||
|
const { annoMatrix } = this.props;
|
||||||
|
if (!annoMatrix) return;
|
||||||
|
if (annoMatrix !== prevProps?.annoMatrix) {
|
||||||
|
const { schema } = annoMatrix;
|
||||||
|
const varIndex = schema.annotations.var.index;
|
||||||
|
|
||||||
|
this.setState({ status: "pending" });
|
||||||
|
try {
|
||||||
|
const df = await annoMatrix.fetch("var", varIndex);
|
||||||
|
this.setState({
|
||||||
|
status: "success",
|
||||||
|
geneNames: df.col(varIndex).asArray(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.setState({ status: "error" });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
placeholderGeneNames() {
|
placeholderGeneNames() {
|
||||||
/*
|
/*
|
||||||
return a string containing gene name suggestions for use as a user hint.
|
return a string containing gene name suggestions for use as a user hint.
|
||||||
@@ -151,10 +171,7 @@ class AddGenes extends React.Component {
|
|||||||
|
|
||||||
NOTE: the random selection means it will re-render constantly.
|
NOTE: the random selection means it will re-render constantly.
|
||||||
*/
|
*/
|
||||||
const { world } = this.props;
|
const { geneNames } = this.state;
|
||||||
const { varAnnotations } = world;
|
|
||||||
const varIndexName = world.schema.annotations.var.index;
|
|
||||||
const geneNames = varAnnotations.col(varIndexName).asArray();
|
|
||||||
if (geneNames.length > 0) {
|
if (geneNames.length > 0) {
|
||||||
const placeholder = [];
|
const placeholder = [];
|
||||||
let len = geneNames.length;
|
let len = geneNames.length;
|
||||||
@@ -175,8 +192,8 @@ class AddGenes extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleClick(g) {
|
handleClick(g) {
|
||||||
const { world, dispatch, userDefinedGenes } = this.props;
|
const { dispatch, userDefinedGenes } = this.props;
|
||||||
const varIndexName = world.schema.annotations.var.index;
|
const { geneNames } = this.state;
|
||||||
if (!g) return;
|
if (!g) return;
|
||||||
const gene = g.target;
|
const gene = g.target;
|
||||||
if (userDefinedGenes.indexOf(gene) !== -1) {
|
if (userDefinedGenes.indexOf(gene) !== -1) {
|
||||||
@@ -185,27 +202,21 @@ class AddGenes extends React.Component {
|
|||||||
postUserErrorToast(
|
postUserErrorToast(
|
||||||
`That's too many genes, you can have at most ${globals.maxUserDefinedGenes} user defined genes`
|
`That's too many genes, you can have at most ${globals.maxUserDefinedGenes} user defined genes`
|
||||||
);
|
);
|
||||||
} else if (
|
} else if (geneNames.indexOf(gene) === undefined) {
|
||||||
world.varAnnotations.col(varIndexName).indexOf(gene) === undefined
|
|
||||||
) {
|
|
||||||
postUserErrorToast("That doesn't appear to be a valid gene name.");
|
postUserErrorToast("That doesn't appear to be a valid gene name.");
|
||||||
} else {
|
} else {
|
||||||
dispatch({ type: "single user defined gene start" });
|
dispatch({ type: "single user defined gene start" });
|
||||||
dispatch(actions.requestUserDefinedGene(gene)).then(
|
dispatch(actions.requestUserDefinedGene(gene));
|
||||||
() => dispatch({ type: "single user defined gene complete" }),
|
dispatch({ type: "single user defined gene complete" });
|
||||||
() => dispatch({ type: "single user defined gene error" })
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { world, userDefinedGenesLoading } = this.props;
|
const { userDefinedGenesLoading } = this.props;
|
||||||
const varIndexName = world?.schema?.annotations?.var?.index;
|
const { tab, bulkAdd, activeItem, status, geneNames } = this.state;
|
||||||
const varIndex = world?.varAnnotations?.col(varIndexName)?.asArray();
|
|
||||||
const { tab, bulkAdd, activeItem } = this.state;
|
|
||||||
|
|
||||||
// may still be loading!
|
// may still be loading!
|
||||||
if (!varIndex) return null;
|
if (status !== "success") return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -263,7 +274,7 @@ class AddGenes extends React.Component {
|
|||||||
itemListPredicate={filterGenes}
|
itemListPredicate={filterGenes}
|
||||||
onActiveItemChange={(item) => this.setState({ activeItem: item })}
|
onActiveItemChange={(item) => this.setState({ activeItem: item })}
|
||||||
itemRenderer={renderGene}
|
itemRenderer={renderGene}
|
||||||
items={varIndex || ["No genes"]}
|
items={geneNames || ["No genes"]}
|
||||||
popoverProps={{ minimal: true }}
|
popoverProps={{ minimal: true }}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// jshint esversion: 6
|
|
||||||
/* rc slider https://www.npmjs.com/package/rc-slider */
|
/* rc slider https://www.npmjs.com/package/rc-slider */
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
@@ -13,11 +12,7 @@ import testGeneSets from "./test_data";
|
|||||||
|
|
||||||
@connect((state) => {
|
@connect((state) => {
|
||||||
return {
|
return {
|
||||||
obsAnnotations: state.world?.obsAnnotations,
|
|
||||||
userDefinedGenes: state.controls.userDefinedGenes,
|
userDefinedGenes: state.controls.userDefinedGenes,
|
||||||
userDefinedGenesLoading: state.controls.userDefinedGenesLoading,
|
|
||||||
world: state.world,
|
|
||||||
colorAccessor: state.colors.colorAccessor,
|
|
||||||
differential: state.differential,
|
differential: state.differential,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@@ -35,13 +30,7 @@ class GeneExpression extends React.Component {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { world, userDefinedGenes, differential } = this.props;
|
const { userDefinedGenes, differential } = this.props;
|
||||||
const varIndexName = world?.schema?.annotations?.var?.index;
|
|
||||||
const varIndex = world?.varAnnotations?.col(varIndexName)?.asArray();
|
|
||||||
|
|
||||||
// may still be loading!
|
|
||||||
if (!varIndex) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -50,19 +39,13 @@ class GeneExpression extends React.Component {
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<AddGenes />
|
<AddGenes />
|
||||||
{world && userDefinedGenes.length > 0
|
{userDefinedGenes.length > 0
|
||||||
? _.map(userDefinedGenes, (geneName, index) => {
|
? _.map(userDefinedGenes, (geneName, index) => {
|
||||||
const values = world.varData.col(geneName);
|
|
||||||
if (!values) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const summary = values.summarize();
|
|
||||||
return (
|
return (
|
||||||
<HistogramBrush
|
<HistogramBrush
|
||||||
key={geneName}
|
key={geneName}
|
||||||
field={geneName}
|
field={geneName}
|
||||||
zebra={index % 2 === 0}
|
zebra={index % 2 === 0}
|
||||||
ranges={summary}
|
|
||||||
isUserDefined
|
isUserDefined
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -72,18 +55,11 @@ class GeneExpression extends React.Component {
|
|||||||
<div>
|
<div>
|
||||||
{differential.diffExp
|
{differential.diffExp
|
||||||
? _.map(differential.diffExp, (value, index) => {
|
? _.map(differential.diffExp, (value, index) => {
|
||||||
const name = world.varAnnotations.at(value[0], varIndexName);
|
|
||||||
const values = world.varData.col(name);
|
|
||||||
if (!values) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const summary = values.summarize();
|
|
||||||
return (
|
return (
|
||||||
<HistogramBrush
|
<HistogramBrush
|
||||||
key={name}
|
key={value[0]}
|
||||||
field={name}
|
field={value[0]}
|
||||||
zebra={index % 2 === 0}
|
zebra={index % 2 === 0}
|
||||||
ranges={summary}
|
|
||||||
isDiffExp
|
isDiffExp
|
||||||
logFoldChange={value[1]}
|
logFoldChange={value[1]}
|
||||||
pval={value[2]}
|
pval={value[2]}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { glPointFlags, glPointSize } from "../../util/glHelpers";
|
import { glPointFlags, glPointSize } from "../../util/glHelpers";
|
||||||
|
|
||||||
export default function (regl) {
|
export default function drawPointsRegl(regl) {
|
||||||
return regl({
|
return regl({
|
||||||
vert: `
|
vert: `
|
||||||
precision mediump float;
|
precision mediump float;
|
||||||
|
|||||||
@@ -1,18 +1,26 @@
|
|||||||
// jshint esversion: 6
|
// jshint esversion: 6
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import * as d3 from "d3";
|
import * as d3 from "d3";
|
||||||
import { connect } from "react-redux";
|
import { connect, shallowEqual } from "react-redux";
|
||||||
import { mat3, vec2 } from "gl-matrix";
|
import { mat3, vec2 } from "gl-matrix";
|
||||||
import _regl from "regl";
|
import _regl from "regl";
|
||||||
import memoize from "memoize-one";
|
import memoize from "memoize-one";
|
||||||
|
import Async from "react-async";
|
||||||
|
import { Button } from "@blueprintjs/core";
|
||||||
|
|
||||||
import setupSVGandBrushElements from "./setupSVGandBrush";
|
import setupSVGandBrushElements from "./setupSVGandBrush";
|
||||||
import _camera from "../../util/camera";
|
import _camera from "../../util/camera";
|
||||||
import _drawPoints from "./drawPointsRegl";
|
import _drawPoints from "./drawPointsRegl";
|
||||||
import { isTypedArray } from "../../util/typeHelpers";
|
import {
|
||||||
|
createColorTable,
|
||||||
|
createColorQuery,
|
||||||
|
} from "../../util/stateManager/colorHelpers";
|
||||||
|
import * as globals from "../../globals";
|
||||||
|
|
||||||
import GraphOverlayLayer from "./overlays/graphOverlayLayer";
|
import GraphOverlayLayer from "./overlays/graphOverlayLayer";
|
||||||
import CentroidLabels from "./overlays/centroidLabels";
|
import CentroidLabels from "./overlays/centroidLabels";
|
||||||
|
import actions from "../../actions";
|
||||||
|
import renderThrottle from "../../util/renderThrottle";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Simple 2D transforms control all point painting. There are three:
|
Simple 2D transforms control all point painting. There are three:
|
||||||
@@ -21,7 +29,6 @@ Simple 2D transforms control all point painting. There are three:
|
|||||||
* camera - apply a 2D camera transformation (pan, zoom)
|
* camera - apply a 2D camera transformation (pan, zoom)
|
||||||
* projection - apply any transformation required for screen size and layout
|
* projection - apply any transformation required for screen size and layout
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function createProjectionTF(viewportWidth, viewportHeight) {
|
function createProjectionTF(viewportWidth, viewportHeight) {
|
||||||
/*
|
/*
|
||||||
the projection transform accounts for the screen size & other layout
|
the projection transform accounts for the screen size & other layout
|
||||||
@@ -53,38 +60,49 @@ function createModelTF() {
|
|||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderThrottle(callback) {
|
const flagSelected = 1;
|
||||||
/*
|
const flagNaN = 2;
|
||||||
This wraps a call to requestAnimationFrame(), enforcing a single
|
const flagHighlight = 4;
|
||||||
render callback at any given time (ie, you can call this any number
|
|
||||||
of times, and it will coallesce multiple inter-frame calls into a
|
|
||||||
single render).
|
|
||||||
*/
|
|
||||||
let rafCurrentlyInProgress = null;
|
|
||||||
return function f() {
|
|
||||||
if (rafCurrentlyInProgress) return;
|
|
||||||
const context = this;
|
|
||||||
rafCurrentlyInProgress = window.requestAnimationFrame(() => {
|
|
||||||
callback.apply(context);
|
|
||||||
rafCurrentlyInProgress = null;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@connect((state) => ({
|
@connect((state) => ({
|
||||||
universe: state.universe,
|
annoMatrix: state.annoMatrix,
|
||||||
world: state.world,
|
crossfilter: state.obsCrossfilter,
|
||||||
crossfilter: state.crossfilter,
|
|
||||||
colorRGB: state.colors.rgb,
|
|
||||||
selectionTool: state.graphSelection.tool,
|
selectionTool: state.graphSelection.tool,
|
||||||
currentSelection: state.graphSelection.selection,
|
currentSelection: state.graphSelection.selection,
|
||||||
layoutChoice: state.layoutChoice,
|
layoutChoice: state.layoutChoice,
|
||||||
centroidLabels: state.centroidLabels,
|
|
||||||
graphInteractionMode: state.controls.graphInteractionMode,
|
graphInteractionMode: state.controls.graphInteractionMode,
|
||||||
colorAccessor: state.colors.colorAccessor,
|
colors: state.colors,
|
||||||
pointDilation: state.pointDilation,
|
pointDilation: state.pointDilation,
|
||||||
}))
|
}))
|
||||||
class Graph extends React.Component {
|
class Graph extends React.Component {
|
||||||
|
static createReglState(canvas) {
|
||||||
|
/*
|
||||||
|
Must be created for each canvas
|
||||||
|
*/
|
||||||
|
// setup canvas, webgl draw function and camera
|
||||||
|
const camera = _camera(canvas);
|
||||||
|
const regl = _regl(canvas);
|
||||||
|
const drawPoints = _drawPoints(regl);
|
||||||
|
|
||||||
|
// preallocate webgl buffers
|
||||||
|
const pointBuffer = regl.buffer();
|
||||||
|
const colorBuffer = regl.buffer();
|
||||||
|
const flagBuffer = regl.buffer();
|
||||||
|
|
||||||
|
return {
|
||||||
|
camera,
|
||||||
|
regl,
|
||||||
|
drawPoints,
|
||||||
|
pointBuffer,
|
||||||
|
colorBuffer,
|
||||||
|
flagBuffer,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static watchAsync(props, prevProps) {
|
||||||
|
return !shallowEqual(props.watchProps, prevProps.watchProps);
|
||||||
|
}
|
||||||
|
|
||||||
computePointPositions = memoize((X, Y, modelTF) => {
|
computePointPositions = memoize((X, Y, modelTF) => {
|
||||||
/*
|
/*
|
||||||
compute the model coordinate for each point
|
compute the model coordinate for each point
|
||||||
@@ -111,18 +129,44 @@ class Graph extends React.Component {
|
|||||||
});
|
});
|
||||||
|
|
||||||
computeSelectedFlags = memoize(
|
computeSelectedFlags = memoize(
|
||||||
(crossfilter, flagSelected, flagUnselected) => {
|
(crossfilter, _flagSelected, _flagUnselected) => {
|
||||||
const x = crossfilter.fillByIsSelected(
|
const x = crossfilter.fillByIsSelected(
|
||||||
new Float32Array(crossfilter.size()),
|
new Float32Array(crossfilter.size()),
|
||||||
flagSelected,
|
_flagSelected,
|
||||||
flagUnselected
|
_flagUnselected
|
||||||
);
|
);
|
||||||
return x;
|
return x;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
computeHighlightFlags = memoize(
|
||||||
|
(nObs, pointDilationData, pointDilationLabel) => {
|
||||||
|
const flags = new Float32Array(nObs);
|
||||||
|
if (pointDilationData) {
|
||||||
|
for (let i = 0, len = flags.length; i < len; i += 1) {
|
||||||
|
if (pointDilationData[i] === pointDilationLabel) {
|
||||||
|
flags[i] = flagHighlight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return flags;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
computeColorByFlags = memoize((nObs, colorByData) => {
|
||||||
|
const flags = new Float32Array(nObs);
|
||||||
|
if (colorByData) {
|
||||||
|
for (let i = 0, len = flags.length; i < len; i += 1) {
|
||||||
|
if (!Number.isFinite(colorByData[i])) {
|
||||||
|
flags[i] = flagNaN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return flags;
|
||||||
|
});
|
||||||
|
|
||||||
computePointFlags = memoize(
|
computePointFlags = memoize(
|
||||||
(world, crossfilter, colorAccessor, pointDilation) => {
|
(crossfilter, colorByData, pointDilationData, pointDilationLabel) => {
|
||||||
/*
|
/*
|
||||||
We communicate with the shader using three flags:
|
We communicate with the shader using three flags:
|
||||||
- isNaN -- the value is a NaN. Only makes sense when we have a colorAccessor
|
- isNaN -- the value is a NaN. Only makes sense when we have a colorAccessor
|
||||||
@@ -136,38 +180,25 @@ class Graph extends React.Component {
|
|||||||
continuous metadata, as they rely on different tests, and some of the flags
|
continuous metadata, as they rely on different tests, and some of the flags
|
||||||
(eg, isNaN) are meaningless in the face of categorical metadata.
|
(eg, isNaN) are meaningless in the face of categorical metadata.
|
||||||
*/
|
*/
|
||||||
|
const nObs = crossfilter.size();
|
||||||
|
const flags = new Float32Array(nObs);
|
||||||
|
|
||||||
const flagSelected = 1;
|
const selectedFlags = this.computeSelectedFlags(
|
||||||
const flagNaN = 2;
|
|
||||||
const flagHighlight = 4;
|
|
||||||
|
|
||||||
const flags = this.computeSelectedFlags(
|
|
||||||
crossfilter,
|
crossfilter,
|
||||||
flagSelected,
|
flagSelected,
|
||||||
0
|
0
|
||||||
).slice();
|
);
|
||||||
|
const highlightFlags = this.computeHighlightFlags(
|
||||||
|
nObs,
|
||||||
|
pointDilationData,
|
||||||
|
pointDilationLabel
|
||||||
|
);
|
||||||
|
const colorByFlags = this.computeColorByFlags(nObs, colorByData);
|
||||||
|
|
||||||
const { metadataField, categoryField } = pointDilation;
|
for (let i = 0; i < nObs; i += 1) {
|
||||||
const highlightData = metadataField
|
flags[i] = selectedFlags[i] + highlightFlags[i] + colorByFlags[i];
|
||||||
? world.obsAnnotations.col(metadataField)?.asArray()
|
}
|
||||||
: null;
|
|
||||||
const colorByColumn = colorAccessor
|
|
||||||
? world.obsAnnotations.col(colorAccessor)?.asArray() ||
|
|
||||||
world.varData.col(colorAccessor)?.asArray()
|
|
||||||
: null;
|
|
||||||
const colorByData =
|
|
||||||
colorByColumn && isTypedArray(colorByColumn) ? colorByColumn : null;
|
|
||||||
|
|
||||||
if (colorByData || highlightData) {
|
|
||||||
for (let i = 0, len = flags.length; i < len; i += 1) {
|
|
||||||
if (highlightData) {
|
|
||||||
flags[i] += highlightData[i] === categoryField ? flagHighlight : 0;
|
|
||||||
}
|
|
||||||
if (colorByData) {
|
|
||||||
flags[i] += Number.isFinite(colorByData[i]) ? 0 : flagNaN;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return flags;
|
return flags;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -175,167 +206,68 @@ class Graph extends React.Component {
|
|||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
const viewport = this.getViewportDimensions();
|
const viewport = this.getViewportDimensions();
|
||||||
this.count = 0;
|
this.reglCanvas = null;
|
||||||
this.renderCache = {
|
this.cachedAsyncProps = null;
|
||||||
X: null,
|
const modelTF = createModelTF();
|
||||||
Y: null,
|
|
||||||
positions: null,
|
|
||||||
colors: null,
|
|
||||||
sizes: null,
|
|
||||||
flags: null,
|
|
||||||
};
|
|
||||||
this.state = {
|
this.state = {
|
||||||
toolSVG: null,
|
toolSVG: null,
|
||||||
tool: null,
|
tool: null,
|
||||||
container: null,
|
container: null,
|
||||||
cameraRender: 0,
|
|
||||||
viewport,
|
viewport,
|
||||||
|
|
||||||
|
// projection
|
||||||
|
camera: null,
|
||||||
|
modelTF,
|
||||||
|
modelInvTF: mat3.invert([], modelTF),
|
||||||
|
projectionTF: createProjectionTF(viewport.width, viewport.height),
|
||||||
|
|
||||||
|
// regl state
|
||||||
|
regl: null,
|
||||||
|
drawPoints: null,
|
||||||
|
pointBuffer: null,
|
||||||
|
colorBuffer: null,
|
||||||
|
flagBuffer: null,
|
||||||
|
|
||||||
|
// component rendering derived state - these must stay synchronized
|
||||||
|
// with the reducer state they were generated from.
|
||||||
|
layoutState: {
|
||||||
|
layoutDf: null,
|
||||||
|
layoutChoice: null,
|
||||||
|
},
|
||||||
|
colorState: {
|
||||||
|
colors: null,
|
||||||
|
colorDf: null,
|
||||||
|
colorTable: null,
|
||||||
|
},
|
||||||
|
pointDilationState: {
|
||||||
|
pointDilation: null,
|
||||||
|
pointDilationDf: null,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
window.addEventListener("resize", this.handleResize);
|
window.addEventListener("resize", this.handleResize);
|
||||||
|
|
||||||
// setup canvas, webgl draw function and camera
|
|
||||||
const camera = _camera(this.reglCanvas);
|
|
||||||
const regl = _regl(this.reglCanvas);
|
|
||||||
const drawPoints = _drawPoints(regl);
|
|
||||||
|
|
||||||
// preallocate webgl buffers
|
|
||||||
const pointBuffer = regl.buffer();
|
|
||||||
const colorBuffer = regl.buffer();
|
|
||||||
const flagBuffer = regl.buffer();
|
|
||||||
|
|
||||||
// create all default rendering transformations
|
|
||||||
const modelTF = createModelTF();
|
|
||||||
const projectionTF = createProjectionTF(
|
|
||||||
this.reglCanvas.width,
|
|
||||||
this.reglCanvas.height
|
|
||||||
);
|
|
||||||
|
|
||||||
// initial draw to canvas
|
|
||||||
this.renderPoints(
|
|
||||||
regl,
|
|
||||||
drawPoints,
|
|
||||||
colorBuffer,
|
|
||||||
pointBuffer,
|
|
||||||
flagBuffer,
|
|
||||||
camera,
|
|
||||||
projectionTF
|
|
||||||
);
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
regl,
|
|
||||||
drawPoints,
|
|
||||||
pointBuffer,
|
|
||||||
colorBuffer,
|
|
||||||
flagBuffer,
|
|
||||||
camera,
|
|
||||||
modelTF,
|
|
||||||
modelInvTF: mat3.invert([], modelTF),
|
|
||||||
projectionTF,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps, prevState) {
|
componentDidUpdate(prevProps, prevState) {
|
||||||
const { renderCache } = this;
|
|
||||||
const {
|
const {
|
||||||
world,
|
|
||||||
crossfilter,
|
|
||||||
colorRGB,
|
|
||||||
selectionTool,
|
selectionTool,
|
||||||
currentSelection,
|
currentSelection,
|
||||||
layoutChoice,
|
|
||||||
graphInteractionMode,
|
graphInteractionMode,
|
||||||
pointDilation,
|
|
||||||
colorAccessor,
|
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const { regl, toolSVG, camera, modelTF, viewport } = this.state;
|
const { toolSVG, viewport } = this.state;
|
||||||
let { projectionTF } = this.state;
|
|
||||||
const hasResized =
|
const hasResized =
|
||||||
prevState.viewport.height !== this.reglCanvas.height ||
|
prevState.viewport.height !== viewport.height ||
|
||||||
prevState.viewport.width !== this.reglCanvas.width;
|
prevState.viewport.width !== viewport.width;
|
||||||
let stateChanges = {};
|
let stateChanges = {};
|
||||||
let needsRepaint = hasResized;
|
|
||||||
|
|
||||||
if (regl && world && crossfilter) {
|
if (
|
||||||
/* update the regl and point rendering state */
|
(viewport.height && viewport.width && !toolSVG) || // first time init
|
||||||
const { obsLayout, nObs } = world;
|
hasResized || // window size has changed we want to recreate all SVGs
|
||||||
const { drawPoints, pointBuffer, colorBuffer, flagBuffer } = this.state;
|
selectionTool !== prevProps.selectionTool || // change of selection tool
|
||||||
|
prevProps.graphInteractionMode !== graphInteractionMode // lasso/zoom mode is switched
|
||||||
if (hasResized) {
|
|
||||||
projectionTF = createProjectionTF(
|
|
||||||
this.reglCanvas.width,
|
|
||||||
this.reglCanvas.height
|
|
||||||
);
|
|
||||||
stateChanges = {
|
|
||||||
...stateChanges,
|
|
||||||
projectionTF,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/* coordinates for each point */
|
|
||||||
const X = obsLayout.col(layoutChoice.currentDimNames[0]).asArray();
|
|
||||||
const Y = obsLayout.col(layoutChoice.currentDimNames[1]).asArray();
|
|
||||||
const newPositions = this.computePointPositions(X, Y, modelTF);
|
|
||||||
if (renderCache.positions !== newPositions) {
|
|
||||||
/* update our cache & GL if the buffer changes */
|
|
||||||
renderCache.positions = newPositions;
|
|
||||||
pointBuffer({ data: newPositions, dimension: 2 });
|
|
||||||
needsRepaint = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* colors for each point */
|
|
||||||
const newColors = this.computePointColors(colorRGB);
|
|
||||||
if (renderCache.colors !== newColors) {
|
|
||||||
/* update our cache & GL if the buffer changes */
|
|
||||||
renderCache.colors = newColors;
|
|
||||||
colorBuffer({ data: newColors, dimension: 3 });
|
|
||||||
needsRepaint = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* flags for each point */
|
|
||||||
const newFlags = this.computePointFlags(
|
|
||||||
world,
|
|
||||||
crossfilter,
|
|
||||||
colorAccessor,
|
|
||||||
pointDilation
|
|
||||||
);
|
|
||||||
if (renderCache.flags !== newFlags) {
|
|
||||||
renderCache.flags = newFlags;
|
|
||||||
needsRepaint = true;
|
|
||||||
flagBuffer({ data: newFlags, dimension: 1 });
|
|
||||||
}
|
|
||||||
|
|
||||||
this.count = nObs;
|
|
||||||
|
|
||||||
if (needsRepaint) {
|
|
||||||
this.renderPoints(
|
|
||||||
regl,
|
|
||||||
drawPoints,
|
|
||||||
colorBuffer,
|
|
||||||
pointBuffer,
|
|
||||||
flagBuffer,
|
|
||||||
camera,
|
|
||||||
projectionTF
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasResized) {
|
|
||||||
// If the window size has changed we want to recreate all SVGs
|
|
||||||
stateChanges = {
|
|
||||||
...stateChanges,
|
|
||||||
...this.createToolSVG(),
|
|
||||||
};
|
|
||||||
} else if (
|
|
||||||
(viewport.height && viewport.width && !toolSVG) ||
|
|
||||||
selectionTool !== prevProps.selectionTool
|
|
||||||
) {
|
) {
|
||||||
// first time or change of selection tool
|
|
||||||
stateChanges = { ...stateChanges, ...this.createToolSVG() };
|
|
||||||
} else if (prevProps.graphInteractionMode !== graphInteractionMode) {
|
|
||||||
// If lasso/zoom is switched
|
|
||||||
stateChanges = {
|
stateChanges = {
|
||||||
...stateChanges,
|
...stateChanges,
|
||||||
...this.createToolSVG(),
|
...this.createToolSVG(),
|
||||||
@@ -358,8 +290,7 @@ class Graph extends React.Component {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (Object.keys(stateChanges).length > 0) {
|
if (Object.keys(stateChanges).length > 0) {
|
||||||
// Preventing update loop via stateChanges and diff checks
|
// eslint-disable-next-line react/no-did-update-set-state --- Preventing update loop via stateChanges and diff checks
|
||||||
// eslint-disable-next-line react/no-did-update-set-state
|
|
||||||
this.setState(stateChanges);
|
this.setState(stateChanges);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -368,12 +299,21 @@ class Graph extends React.Component {
|
|||||||
window.removeEventListener("resize", this.handleResize);
|
window.removeEventListener("resize", this.handleResize);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setReglCanvas = (canvas) => {
|
||||||
|
this.reglCanvas = canvas;
|
||||||
|
this.setState({
|
||||||
|
...Graph.createReglState(canvas),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
handleResize = () => {
|
handleResize = () => {
|
||||||
const { state } = this.state;
|
const { state } = this.state;
|
||||||
const viewport = this.getViewportDimensions();
|
const viewport = this.getViewportDimensions();
|
||||||
|
const projectionTF = createProjectionTF(viewport.width, viewport.height);
|
||||||
this.setState({
|
this.setState({
|
||||||
...state,
|
...state,
|
||||||
viewport,
|
viewport,
|
||||||
|
projectionTF,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -401,11 +341,13 @@ class Graph extends React.Component {
|
|||||||
Called from componentDidUpdate. Create the tool SVG, and return any
|
Called from componentDidUpdate. Create the tool SVG, and return any
|
||||||
state changes that should be passed to setState().
|
state changes that should be passed to setState().
|
||||||
*/
|
*/
|
||||||
const { viewport, selectionTool, graphInteractionMode } = this.props;
|
const { selectionTool, graphInteractionMode } = this.props;
|
||||||
|
const { viewport } = this.state;
|
||||||
|
|
||||||
/* clear out whatever was on the div, even if nothing, but usually the brushes etc */
|
/* clear out whatever was on the div, even if nothing, but usually the brushes etc */
|
||||||
|
const lasso = d3.select("#lasso-layer");
|
||||||
d3.select("#lasso-layer").selectAll(".lasso-group").remove();
|
if (lasso.empty()) return {}; // still initializing
|
||||||
|
lasso.selectAll(".lasso-group").remove();
|
||||||
|
|
||||||
// Don't render or recreate toolSVG if currently in zoom mode
|
// Don't render or recreate toolSVG if currently in zoom mode
|
||||||
if (graphInteractionMode !== "select") {
|
if (graphInteractionMode !== "select") {
|
||||||
@@ -441,6 +383,88 @@ class Graph extends React.Component {
|
|||||||
return { toolSVG: newToolSVG, tool, container };
|
return { toolSVG: newToolSVG, tool, container };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
fetchAsyncProps = async (props) => {
|
||||||
|
const {
|
||||||
|
annoMatrix,
|
||||||
|
colors: colorsProp,
|
||||||
|
layoutChoice,
|
||||||
|
crossfilter,
|
||||||
|
pointDilation,
|
||||||
|
viewport,
|
||||||
|
} = props.watchProps;
|
||||||
|
const { modelTF } = this.state;
|
||||||
|
|
||||||
|
const [layoutDf, colorDf, pointDilationDf] = await this.fetchData(
|
||||||
|
annoMatrix,
|
||||||
|
layoutChoice,
|
||||||
|
colorsProp,
|
||||||
|
pointDilation
|
||||||
|
);
|
||||||
|
const { currentDimNames } = layoutChoice;
|
||||||
|
const X = layoutDf.col(currentDimNames[0]).asArray();
|
||||||
|
const Y = layoutDf.col(currentDimNames[1]).asArray();
|
||||||
|
const positions = this.computePointPositions(X, Y, modelTF);
|
||||||
|
|
||||||
|
const colorTable = this.updateColorTable(colorsProp, colorDf);
|
||||||
|
const colors = this.computePointColors(colorTable.rgb);
|
||||||
|
|
||||||
|
const { colorAccessor } = colorsProp;
|
||||||
|
const colorByData = colorDf?.col(colorAccessor)?.asArray();
|
||||||
|
const {
|
||||||
|
metadataField: pointDilationCategory,
|
||||||
|
categoryField: pointDilationLabel,
|
||||||
|
} = pointDilation;
|
||||||
|
const pointDilationData = pointDilationDf
|
||||||
|
?.col(pointDilationCategory)
|
||||||
|
?.asArray();
|
||||||
|
const flags = this.computePointFlags(
|
||||||
|
crossfilter,
|
||||||
|
colorByData,
|
||||||
|
pointDilationData,
|
||||||
|
pointDilationLabel
|
||||||
|
);
|
||||||
|
|
||||||
|
const { width, height } = viewport;
|
||||||
|
return {
|
||||||
|
positions,
|
||||||
|
colors,
|
||||||
|
flags,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
async fetchData(annoMatrix, layoutChoice, colors, pointDilation) {
|
||||||
|
/*
|
||||||
|
fetch all data needed. Includes:
|
||||||
|
- the color by dataframe
|
||||||
|
- the layout dataframe
|
||||||
|
- the point dilation dataframe
|
||||||
|
*/
|
||||||
|
const { metadataField: pointDilationAccessor } = pointDilation;
|
||||||
|
|
||||||
|
const promises = [];
|
||||||
|
// layout
|
||||||
|
promises.push(annoMatrix.fetch("emb", layoutChoice.current));
|
||||||
|
|
||||||
|
// color
|
||||||
|
const query = this.createColorByQuery(colors);
|
||||||
|
if (query) {
|
||||||
|
promises.push(annoMatrix.fetch(...query));
|
||||||
|
} else {
|
||||||
|
promises.push(Promise.resolve(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
// point highlighting
|
||||||
|
if (pointDilationAccessor) {
|
||||||
|
promises.push(annoMatrix.fetch("obs", pointDilationAccessor));
|
||||||
|
} else {
|
||||||
|
promises.push(Promise.resolve(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.all(promises);
|
||||||
|
}
|
||||||
|
|
||||||
brushToolUpdate(tool, container) {
|
brushToolUpdate(tool, container) {
|
||||||
/*
|
/*
|
||||||
this is called from componentDidUpdate(), so be very careful using
|
this is called from componentDidUpdate(), so be very careful using
|
||||||
@@ -572,17 +596,22 @@ class Graph extends React.Component {
|
|||||||
// ignore programatically generated events
|
// ignore programatically generated events
|
||||||
if (d3.event.sourceEvent === null || !d3.event.selection) return;
|
if (d3.event.sourceEvent === null || !d3.event.selection) return;
|
||||||
|
|
||||||
const { dispatch } = this.props;
|
const { dispatch, layoutChoice } = this.props;
|
||||||
const s = d3.event.selection;
|
const s = d3.event.selection;
|
||||||
const brushCoords = {
|
const northwest = this.mapScreenToPoint(s[0]);
|
||||||
northwest: this.mapScreenToPoint([s[0][0], s[0][1]]),
|
const southeast = this.mapScreenToPoint(s[1]);
|
||||||
southeast: this.mapScreenToPoint([s[1][0], s[1][1]]),
|
const [minX, maxY] = northwest;
|
||||||
};
|
const [maxX, minY] = southeast;
|
||||||
|
dispatch(
|
||||||
dispatch({
|
actions.graphBrushChangeAction(layoutChoice.current, {
|
||||||
type: "graph brush change",
|
minX,
|
||||||
brushCoords,
|
minY,
|
||||||
});
|
maxX,
|
||||||
|
maxY,
|
||||||
|
northwest,
|
||||||
|
southeast,
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleBrushStartAction() {
|
handleBrushStartAction() {
|
||||||
@@ -590,7 +619,7 @@ class Graph extends React.Component {
|
|||||||
if (!d3.event.sourceEvent) return;
|
if (!d3.event.sourceEvent) return;
|
||||||
|
|
||||||
const { dispatch } = this.props;
|
const { dispatch } = this.props;
|
||||||
dispatch({ type: "graph brush start" });
|
dispatch(actions.graphBrushStartAction());
|
||||||
}
|
}
|
||||||
|
|
||||||
handleBrushEndAction() {
|
handleBrushEndAction() {
|
||||||
@@ -601,65 +630,67 @@ class Graph extends React.Component {
|
|||||||
coordinates will be included if selection made, null
|
coordinates will be included if selection made, null
|
||||||
if selection cleared.
|
if selection cleared.
|
||||||
*/
|
*/
|
||||||
const { dispatch } = this.props;
|
const { dispatch, layoutChoice } = this.props;
|
||||||
const s = d3.event.selection;
|
const s = d3.event.selection;
|
||||||
if (s) {
|
if (s) {
|
||||||
const brushCoords = {
|
const northwest = this.mapScreenToPoint(s[0]);
|
||||||
northwest: this.mapScreenToPoint(s[0]),
|
const southeast = this.mapScreenToPoint(s[1]);
|
||||||
southeast: this.mapScreenToPoint(s[1]),
|
const [minX, maxY] = northwest;
|
||||||
};
|
const [maxX, minY] = southeast;
|
||||||
dispatch({
|
dispatch(
|
||||||
type: "graph brush end",
|
actions.graphBrushEndAction(layoutChoice.current, {
|
||||||
brushCoords,
|
minX,
|
||||||
});
|
minY,
|
||||||
|
maxX,
|
||||||
|
maxY,
|
||||||
|
northwest,
|
||||||
|
southeast,
|
||||||
|
})
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
dispatch({
|
dispatch(actions.graphBrushDeselectAction(layoutChoice.current));
|
||||||
type: "graph brush deselect",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleBrushDeselectAction() {
|
handleBrushDeselectAction() {
|
||||||
const { dispatch } = this.props;
|
const { dispatch, layoutChoice } = this.props;
|
||||||
dispatch({
|
dispatch(actions.graphBrushDeselectAction(layoutChoice.current));
|
||||||
type: "graph brush deselect",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleLassoStart() {
|
handleLassoStart() {
|
||||||
const { dispatch } = this.props;
|
const { dispatch, layoutChoice } = this.props;
|
||||||
dispatch({
|
dispatch(actions.graphLassoStartAction(layoutChoice.current));
|
||||||
type: "graph lasso start",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// when a lasso is completed, filter to the points within the lasso polygon
|
// when a lasso is completed, filter to the points within the lasso polygon
|
||||||
handleLassoEnd(polygon) {
|
handleLassoEnd(polygon) {
|
||||||
const minimumPolygonArea = 10;
|
const minimumPolygonArea = 10;
|
||||||
const { dispatch } = this.props;
|
const { dispatch, layoutChoice } = this.props;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
polygon.length < 3 ||
|
polygon.length < 3 ||
|
||||||
Math.abs(d3.polygonArea(polygon)) < minimumPolygonArea
|
Math.abs(d3.polygonArea(polygon)) < minimumPolygonArea
|
||||||
) {
|
) {
|
||||||
// if less than three points, or super small area, treat as a clear selection.
|
// if less than three points, or super small area, treat as a clear selection.
|
||||||
dispatch({ type: "graph lasso deselect" });
|
dispatch(actions.graphLassoDeselectAction(layoutChoice.current));
|
||||||
} else {
|
} else {
|
||||||
dispatch({
|
dispatch(
|
||||||
type: "graph lasso end",
|
actions.graphLassoEndAction(
|
||||||
polygon: polygon.map((xy) => this.mapScreenToPoint(xy)), // transform the polygon
|
layoutChoice.current,
|
||||||
});
|
polygon.map((xy) => this.mapScreenToPoint(xy))
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleLassoCancel() {
|
handleLassoCancel() {
|
||||||
const { dispatch } = this.props;
|
const { dispatch, layoutChoice } = this.props;
|
||||||
dispatch({ type: "graph lasso cancel" });
|
dispatch(actions.graphLassoCancelAction(layoutChoice.current));
|
||||||
}
|
}
|
||||||
|
|
||||||
handleLassoDeselectAction() {
|
handleLassoDeselectAction() {
|
||||||
const { dispatch } = this.props;
|
const { dispatch, layoutChoice } = this.props;
|
||||||
dispatch({ type: "graph lasso deselect" });
|
dispatch(actions.graphLassoDeselectAction(layoutChoice.current));
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDeselectAction() {
|
handleDeselectAction() {
|
||||||
@@ -676,38 +707,6 @@ class Graph extends React.Component {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
renderPoints(
|
|
||||||
regl,
|
|
||||||
drawPoints,
|
|
||||||
colorBuffer,
|
|
||||||
pointBuffer,
|
|
||||||
flagBuffer,
|
|
||||||
camera,
|
|
||||||
projectionTF
|
|
||||||
) {
|
|
||||||
const { universe } = this.props;
|
|
||||||
if (!this.reglCanvas || !universe) return;
|
|
||||||
const cameraTF = camera.view();
|
|
||||||
const projView = mat3.multiply(mat3.create(), projectionTF, cameraTF);
|
|
||||||
const { width, height } = this.reglCanvas;
|
|
||||||
regl.poll();
|
|
||||||
regl.clear({
|
|
||||||
depth: 1,
|
|
||||||
color: [1, 1, 1, 1],
|
|
||||||
});
|
|
||||||
drawPoints({
|
|
||||||
distance: camera.distance(),
|
|
||||||
color: colorBuffer,
|
|
||||||
position: pointBuffer,
|
|
||||||
flag: flagBuffer,
|
|
||||||
count: this.count,
|
|
||||||
projView,
|
|
||||||
nPoints: universe.nObs,
|
|
||||||
minViewportDimension: Math.min(width, height),
|
|
||||||
});
|
|
||||||
regl._gl.flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
renderCanvas = renderThrottle(() => {
|
renderCanvas = renderThrottle(() => {
|
||||||
const {
|
const {
|
||||||
regl,
|
regl,
|
||||||
@@ -729,9 +728,92 @@ class Graph extends React.Component {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
updateReglAndRender(asyncProps) {
|
||||||
|
const { positions, colors, flags } = asyncProps;
|
||||||
|
this.cachedAsyncProps = asyncProps;
|
||||||
|
const { pointBuffer, colorBuffer, flagBuffer } = this.state;
|
||||||
|
pointBuffer({ data: positions, dimension: 2 });
|
||||||
|
colorBuffer({ data: colors, dimension: 3 });
|
||||||
|
flagBuffer({ data: flags, dimension: 1 });
|
||||||
|
this.renderCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateColorTable(colors, colorDf) {
|
||||||
|
const { annoMatrix } = this.props;
|
||||||
|
const { schema } = annoMatrix;
|
||||||
|
|
||||||
|
/* update color table state */
|
||||||
|
if (!colors || !colorDf) {
|
||||||
|
return createColorTable(
|
||||||
|
null, // default mode
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
schema,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { colorAccessor, userColors, colorMode } = colors;
|
||||||
|
return createColorTable(
|
||||||
|
colorMode,
|
||||||
|
colorAccessor,
|
||||||
|
colorDf,
|
||||||
|
schema,
|
||||||
|
userColors
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
createColorByQuery(colors) {
|
||||||
|
const { annoMatrix } = this.props;
|
||||||
|
const { schema } = annoMatrix;
|
||||||
|
const { colorMode, colorAccessor } = colors;
|
||||||
|
return createColorQuery(colorMode, colorAccessor, schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderPoints(
|
||||||
|
regl,
|
||||||
|
drawPoints,
|
||||||
|
colorBuffer,
|
||||||
|
pointBuffer,
|
||||||
|
flagBuffer,
|
||||||
|
camera,
|
||||||
|
projectionTF
|
||||||
|
) {
|
||||||
|
const { annoMatrix } = this.props;
|
||||||
|
if (!this.reglCanvas || !annoMatrix) return;
|
||||||
|
|
||||||
|
const { schema } = annoMatrix;
|
||||||
|
const cameraTF = camera.view();
|
||||||
|
const projView = mat3.multiply(mat3.create(), projectionTF, cameraTF);
|
||||||
|
const { width, height } = this.reglCanvas;
|
||||||
|
regl.poll();
|
||||||
|
regl.clear({
|
||||||
|
depth: 1,
|
||||||
|
color: [1, 1, 1, 1],
|
||||||
|
});
|
||||||
|
drawPoints({
|
||||||
|
distance: camera.distance(),
|
||||||
|
color: colorBuffer,
|
||||||
|
position: pointBuffer,
|
||||||
|
flag: flagBuffer,
|
||||||
|
count: annoMatrix.nObs,
|
||||||
|
projView,
|
||||||
|
nPoints: schema.dataframe.nObs,
|
||||||
|
minViewportDimension: Math.min(width, height),
|
||||||
|
});
|
||||||
|
regl._gl.flush();
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { graphInteractionMode } = this.props;
|
const {
|
||||||
const { modelTF, projectionTF, camera, viewport } = this.state;
|
graphInteractionMode,
|
||||||
|
annoMatrix,
|
||||||
|
colors,
|
||||||
|
layoutChoice,
|
||||||
|
pointDilation,
|
||||||
|
crossfilter,
|
||||||
|
} = this.props;
|
||||||
|
const { modelTF, projectionTF, camera, viewport, regl } = this.state;
|
||||||
const cameraTF = camera?.view()?.slice();
|
const cameraTF = camera?.view()?.slice();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -782,18 +864,99 @@ class Graph extends React.Component {
|
|||||||
}}
|
}}
|
||||||
className="graph-canvas"
|
className="graph-canvas"
|
||||||
data-testid="layout-graph"
|
data-testid="layout-graph"
|
||||||
ref={(canvas) => {
|
ref={this.setReglCanvas}
|
||||||
this.reglCanvas = canvas;
|
|
||||||
}}
|
|
||||||
onMouseDown={this.handleCanvasEvent}
|
onMouseDown={this.handleCanvasEvent}
|
||||||
onMouseUp={this.handleCanvasEvent}
|
onMouseUp={this.handleCanvasEvent}
|
||||||
onMouseMove={this.handleCanvasEvent}
|
onMouseMove={this.handleCanvasEvent}
|
||||||
onDoubleClick={this.handleCanvasEvent}
|
onDoubleClick={this.handleCanvasEvent}
|
||||||
onWheel={this.handleCanvasEvent}
|
onWheel={this.handleCanvasEvent}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Async
|
||||||
|
watchFn={Graph.watchAsync}
|
||||||
|
promiseFn={this.fetchAsyncProps}
|
||||||
|
watchProps={{
|
||||||
|
annoMatrix,
|
||||||
|
colors,
|
||||||
|
layoutChoice,
|
||||||
|
pointDilation,
|
||||||
|
crossfilter,
|
||||||
|
viewport,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Async.Pending initial>
|
||||||
|
<StillLoading
|
||||||
|
displayName={layoutChoice.current}
|
||||||
|
width={viewport.width}
|
||||||
|
height={viewport.height}
|
||||||
|
/>
|
||||||
|
</Async.Pending>
|
||||||
|
<Async.Rejected>
|
||||||
|
{(error) => (
|
||||||
|
<ErrorLoading
|
||||||
|
displayName={layoutChoice.current}
|
||||||
|
error={error}
|
||||||
|
width={viewport.width}
|
||||||
|
height={viewport.height}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Async.Rejected>
|
||||||
|
<Async.Fulfilled>
|
||||||
|
{(asyncProps) => {
|
||||||
|
if (regl && !shallowEqual(asyncProps, this.cachedAsyncProps)) {
|
||||||
|
this.updateReglAndRender(asyncProps);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
|
</Async.Fulfilled>
|
||||||
|
</Async>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ErrorLoading = ({ displayName, error, width, height }) => {
|
||||||
|
console.log(error); // log to console as this is an unepected error
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
fontWeight: 500,
|
||||||
|
top: height / 2,
|
||||||
|
left: globals.leftSidebarWidth + width / 2 - 50,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{`Failure loading ${displayName}`}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const StillLoading = ({ displayName, width, height }) => {
|
||||||
|
/*
|
||||||
|
Render a busy/loading indicator
|
||||||
|
*/
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
fontWeight: 500,
|
||||||
|
top: height / 2,
|
||||||
|
width,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
justifyItems: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button minimal loading intent="primary" />
|
||||||
|
<span style={{ fontStyle: "italic" }}>Loading {displayName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default Graph;
|
export default Graph;
|
||||||
|
|||||||
@@ -1,63 +1,136 @@
|
|||||||
import React, { PureComponent } from "react";
|
import React, { PureComponent } from "react";
|
||||||
import { connect } from "react-redux";
|
import { connect, shallowEqual } from "react-redux";
|
||||||
|
import Async from "react-async";
|
||||||
|
|
||||||
import { categoryLabelDisplayStringLongLength } from "../../../globals";
|
import { categoryLabelDisplayStringLongLength } from "../../../globals";
|
||||||
|
import calcCentroid from "../../../util/centroid";
|
||||||
|
import { createColorQuery } from "../../../util/stateManager/colorHelpers";
|
||||||
|
|
||||||
export default
|
export default
|
||||||
@connect((state) => ({
|
@connect((state) => ({
|
||||||
colorAccessor: state.colors.colorAccessor,
|
annoMatrix: state.annoMatrix,
|
||||||
|
colors: state.colors,
|
||||||
|
layoutChoice: state.layoutChoice,
|
||||||
dilatedValue: state.pointDilation.categoryField,
|
dilatedValue: state.pointDilation.categoryField,
|
||||||
labels: state.centroidLabels.labels,
|
|
||||||
categoricalSelection: state.categoricalSelection,
|
categoricalSelection: state.categoricalSelection,
|
||||||
|
showLabels: state.centroidLabels?.showLabels,
|
||||||
}))
|
}))
|
||||||
class CentroidLabels extends PureComponent {
|
class CentroidLabels extends PureComponent {
|
||||||
// Check to see if centroids have either just been displayed or removed from the overlay
|
static watchAsync(props, prevProps) {
|
||||||
|
return !shallowEqual(props.watchProps, prevProps.watchProps);
|
||||||
componentDidUpdate(prevProps) {
|
|
||||||
const { labels, overlayToggled } = this.props;
|
|
||||||
const prevSize = prevProps.labels.size;
|
|
||||||
const { size } = labels;
|
|
||||||
|
|
||||||
const displayChangeOff = prevSize > 0 && size === undefined;
|
|
||||||
const displayChangeOn = prevSize === undefined && size > 0;
|
|
||||||
|
|
||||||
if (displayChangeOn || displayChangeOff) {
|
|
||||||
// Notify overlay layer of display change
|
|
||||||
overlayToggled("centroidLabels", displayChangeOn);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fetchAsyncProps = async (props) => {
|
||||||
|
const {
|
||||||
|
annoMatrix,
|
||||||
|
colors,
|
||||||
|
layoutChoice,
|
||||||
|
categoricalSelection,
|
||||||
|
showLabels,
|
||||||
|
} = props.watchProps;
|
||||||
|
const { schema } = annoMatrix;
|
||||||
|
const { colorAccessor } = colors;
|
||||||
|
|
||||||
|
const [layoutDf, colorDf] = await this.fetchData();
|
||||||
|
let labels;
|
||||||
|
if (colorDf) {
|
||||||
|
labels = calcCentroid(
|
||||||
|
schema,
|
||||||
|
colorAccessor,
|
||||||
|
colorDf,
|
||||||
|
layoutChoice,
|
||||||
|
layoutDf
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
labels = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { overlaySetShowing } = this.props;
|
||||||
|
overlaySetShowing("centroidLabels", showLabels && labels.size > 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels,
|
||||||
|
colorAccessor,
|
||||||
|
category: categoricalSelection[colorAccessor],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
handleMouseEnter = (e, colorAccessor, label) => {
|
||||||
|
const { dispatch } = this.props;
|
||||||
|
dispatch({
|
||||||
|
type: "category value mouse hover start",
|
||||||
|
metadataField: colorAccessor,
|
||||||
|
categoryField: label,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
handleMouseOut = (e, colorAccessor, label) => {
|
||||||
|
const { dispatch } = this.props;
|
||||||
|
dispatch({
|
||||||
|
type: "category value mouse hover end",
|
||||||
|
metadataField: colorAccessor,
|
||||||
|
categoryField: label,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
colorByQuery() {
|
||||||
|
const { annoMatrix, colors } = this.props;
|
||||||
|
const { schema } = annoMatrix;
|
||||||
|
const { colorMode, colorAccessor } = colors;
|
||||||
|
return createColorQuery(colorMode, colorAccessor, schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchData() {
|
||||||
|
const { annoMatrix, layoutChoice } = this.props;
|
||||||
|
// fetch all data we need: layout, category
|
||||||
|
const promises = [];
|
||||||
|
// layout
|
||||||
|
promises.push(annoMatrix.fetch("emb", layoutChoice.current));
|
||||||
|
// category to label - we ONLY label on obs, never on X, etc.
|
||||||
|
const query = this.colorByQuery();
|
||||||
|
if (query && query[0] === "obs") {
|
||||||
|
promises.push(annoMatrix.fetch(...query));
|
||||||
|
} else {
|
||||||
|
promises.push(Promise.resolve(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.all(promises);
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
labels,
|
|
||||||
inverseTransform,
|
inverseTransform,
|
||||||
dilatedValue,
|
dilatedValue,
|
||||||
dispatch,
|
|
||||||
colorAccessor,
|
|
||||||
categoricalSelection,
|
categoricalSelection,
|
||||||
|
showLabels,
|
||||||
|
colors,
|
||||||
|
annoMatrix,
|
||||||
|
layoutChoice,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
if (!colorAccessor || labels.size === undefined || labels.size === 0)
|
return (
|
||||||
return null;
|
<Async
|
||||||
|
watchFn={CentroidLabels.watchAsync}
|
||||||
const {
|
promiseFn={this.fetchAsyncProps}
|
||||||
categoryValueIndices,
|
watchProps={{
|
||||||
categoryValueSelected,
|
annoMatrix,
|
||||||
} = categoricalSelection?.[colorAccessor];
|
colors,
|
||||||
|
layoutChoice,
|
||||||
|
categoricalSelection,
|
||||||
|
dilatedValue,
|
||||||
|
showLabels,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Async.Fulfilled>
|
||||||
|
{(asyncProps) => {
|
||||||
|
if (!showLabels) return null;
|
||||||
|
|
||||||
const labelSVGS = [];
|
const labelSVGS = [];
|
||||||
let fontSize = "15px";
|
|
||||||
let fontWeight = null;
|
|
||||||
const deselectOpacity = 0.375;
|
const deselectOpacity = 0.375;
|
||||||
labels.forEach((coords, label) => {
|
const { category, colorAccessor, labels } = asyncProps;
|
||||||
fontSize = "15px";
|
|
||||||
fontWeight = null;
|
|
||||||
if (label === dilatedValue) {
|
|
||||||
fontSize = "18px";
|
|
||||||
fontWeight = "800";
|
|
||||||
}
|
|
||||||
|
|
||||||
const selected = categoryValueSelected[categoryValueIndices.get(label)];
|
labels.forEach((coords, label) => {
|
||||||
|
const selected = category.get(label) ?? true;
|
||||||
|
|
||||||
// Mirror LSB middle truncation
|
// Mirror LSB middle truncation
|
||||||
let displayLabel = label;
|
let displayLabel = label;
|
||||||
@@ -69,50 +142,76 @@ class CentroidLabels extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
labelSVGS.push(
|
labelSVGS.push(
|
||||||
|
// eslint-disable-next-line jsx-a11y/mouse-events-have-key-events -- the mouse actions for centroid labels do not have a screen reader alternative
|
||||||
|
<Label
|
||||||
|
key={label} // eslint-disable-line react/no-array-index-key --- label is not an index, eslint is confused
|
||||||
|
label={label}
|
||||||
|
dilatedValue={dilatedValue}
|
||||||
|
coords={coords}
|
||||||
|
inverseTransform={inverseTransform}
|
||||||
|
opactity={selected ? 1 : deselectOpacity}
|
||||||
|
colorAccessor={colorAccessor}
|
||||||
|
displayLabel={displayLabel}
|
||||||
|
onMouseEnter={this.handleMouseEnter}
|
||||||
|
onMouseOut={this.handleMouseOut}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return <>{labelSVGS}</>;
|
||||||
|
}}
|
||||||
|
</Async.Fulfilled>
|
||||||
|
</Async>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const Label = ({
|
||||||
|
label,
|
||||||
|
dilatedValue,
|
||||||
|
coords,
|
||||||
|
inverseTransform,
|
||||||
|
opacity,
|
||||||
|
colorAccessor,
|
||||||
|
displayLabel,
|
||||||
|
onMouseEnter,
|
||||||
|
onMouseOut,
|
||||||
|
}) => {
|
||||||
|
/*
|
||||||
|
Render a label at a given coordinate.
|
||||||
|
*/
|
||||||
|
let fontSize = "15px";
|
||||||
|
let fontWeight = null;
|
||||||
|
if (label === dilatedValue) {
|
||||||
|
fontSize = "18px";
|
||||||
|
fontWeight = "800";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
<g
|
<g
|
||||||
// label is unique so disabling eslint rule
|
|
||||||
// eslint-disable-next-line react/no-array-index-key
|
|
||||||
key={label}
|
key={label}
|
||||||
className="centroid-label"
|
className="centroid-label"
|
||||||
transform={`translate(${coords[0]}, ${coords[1]})`}
|
transform={`translate(${coords[0]}, ${coords[1]})`}
|
||||||
data-testclass="centroid-label"
|
data-testclass="centroid-label"
|
||||||
data-testid={`${label}-centroid-label`}
|
data-testid={`${label}-centroid-label`}
|
||||||
>
|
>
|
||||||
{/* The mouse actions for centroid labels do not have a screen reader alternative */}
|
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events --- the mouse actions for centroid labels do not have a screen reader alternative*/}
|
||||||
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */}
|
|
||||||
<text
|
<text
|
||||||
transform={inverseTransform}
|
transform={inverseTransform}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
data-label={label}
|
|
||||||
style={{
|
style={{
|
||||||
fontSize,
|
fontSize,
|
||||||
fontWeight,
|
fontWeight,
|
||||||
fill: "black",
|
fill: "black",
|
||||||
userSelect: "none",
|
userSelect: "none",
|
||||||
opacity: selected ? 1 : deselectOpacity,
|
opacity: { opacity },
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) =>
|
onMouseEnter={(e) => onMouseEnter(e, colorAccessor, label)}
|
||||||
dispatch({
|
onMouseOut={(e) => onMouseOut(e, colorAccessor, label)}
|
||||||
type: "category value mouse hover start",
|
|
||||||
metadataField: colorAccessor,
|
|
||||||
categoryField: e.target.getAttribute("data-label"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onMouseOut={(e) =>
|
|
||||||
dispatch({
|
|
||||||
type: "category value mouse hover end",
|
|
||||||
metadataField: colorAccessor,
|
|
||||||
categoryField: e.target.getAttribute("data-label"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
pointerEvents="visiblePainted"
|
pointerEvents="visiblePainted"
|
||||||
>
|
>
|
||||||
{displayLabel}
|
{displayLabel}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
|
||||||
return <>{labelSVGS}</>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export default class GraphOverlayLayer extends PureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// This is passed to all children, should be called when an overlay's display state is toggled along with the overlay name and its new display state in boolean form
|
// This is passed to all children, should be called when an overlay's display state is toggled along with the overlay name and its new display state in boolean form
|
||||||
overlayToggled = (overlay, displaying) => {
|
overlaySetShowing = (overlay, displaying) => {
|
||||||
this.setState((state) => {
|
this.setState((state) => {
|
||||||
return { ...state, display: { ...state.display, [overlay]: displaying } };
|
return { ...state, display: { ...state.display, [overlay]: displaying } };
|
||||||
});
|
});
|
||||||
@@ -67,7 +67,7 @@ export default class GraphOverlayLayer extends PureComponent {
|
|||||||
const newChildren = React.Children.map(children, (child) =>
|
const newChildren = React.Children.map(children, (child) =>
|
||||||
cloneElement(child, {
|
cloneElement(child, {
|
||||||
inverseTransform,
|
inverseTransform,
|
||||||
overlayToggled: this.overlayToggled,
|
overlaySetShowing: this.overlaySetShowing,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
// https://bl.ocks.org/pbeshai/8008075f9ce771ee8be39e8c38907570
|
// https://bl.ocks.org/pbeshai/8008075f9ce771ee8be39e8c38907570
|
||||||
|
|
||||||
import * as d3 from "d3";
|
import * as d3 from "d3";
|
||||||
|
import { Colors } from "@blueprintjs/core";
|
||||||
|
|
||||||
const Lasso = () => {
|
const Lasso = () => {
|
||||||
const dispatch = d3.dispatch("start", "end", "cancel");
|
const dispatch = d3.dispatch("start", "end", "cancel");
|
||||||
|
|
||||||
|
const lasso = (svg) => {
|
||||||
|
let lassoPolygon;
|
||||||
|
let lassoPath;
|
||||||
|
let closePath;
|
||||||
|
|
||||||
const polygonToPath = (polygon) =>
|
const polygonToPath = (polygon) =>
|
||||||
`M${polygon.map((d) => d.join(",")).join("L")}`;
|
`M${polygon.map((d) => d.join(",")).join("L")}`;
|
||||||
|
|
||||||
@@ -13,11 +19,7 @@ const Lasso = () => {
|
|||||||
|
|
||||||
// distance last point has to be to first point before it auto closes when mouse is released
|
// distance last point has to be to first point before it auto closes when mouse is released
|
||||||
const closeDistance = 75;
|
const closeDistance = 75;
|
||||||
|
const lassoPathColor = Colors.BLUE5;
|
||||||
const lasso = (svg) => {
|
|
||||||
let lassoPolygon;
|
|
||||||
let lassoPath;
|
|
||||||
let closePath;
|
|
||||||
|
|
||||||
const handleDragStart = () => {
|
const handleDragStart = () => {
|
||||||
lassoPolygon = [d3.mouse(svg.node())]; // current x y of mouse within element
|
lassoPolygon = [d3.mouse(svg.node())]; // current x y of mouse within element
|
||||||
@@ -29,18 +31,14 @@ const Lasso = () => {
|
|||||||
lassoPath = g
|
lassoPath = g
|
||||||
.append("path")
|
.append("path")
|
||||||
.attr("data-testid", "lasso-element")
|
.attr("data-testid", "lasso-element")
|
||||||
.attr("fill", "#0bb")
|
|
||||||
.attr("fill-opacity", 0.1)
|
.attr("fill-opacity", 0.1)
|
||||||
.attr("stroke", "#0bb")
|
|
||||||
.attr("stroke-dasharray", "3, 3");
|
.attr("stroke-dasharray", "3, 3");
|
||||||
|
|
||||||
closePath = g
|
closePath = g
|
||||||
.append("line")
|
.append("line")
|
||||||
.attr("x2", lassoPolygon[0][0])
|
.attr("x2", lassoPolygon[0][0])
|
||||||
.attr("y2", lassoPolygon[0][1])
|
.attr("y2", lassoPolygon[0][1])
|
||||||
.attr("stroke", "#0bb")
|
.attr("stroke-dasharray", "3, 3");
|
||||||
.attr("stroke-dasharray", "3, 3")
|
|
||||||
.attr("opacity", 0);
|
|
||||||
|
|
||||||
dispatch.call("start", lasso, lassoPolygon);
|
dispatch.call("start", lasso, lassoPolygon);
|
||||||
};
|
};
|
||||||
@@ -55,9 +53,17 @@ const Lasso = () => {
|
|||||||
distance(lassoPolygon[0], lassoPolygon[lassoPolygon.length - 1]) <
|
distance(lassoPolygon[0], lassoPolygon[lassoPolygon.length - 1]) <
|
||||||
closeDistance
|
closeDistance
|
||||||
) {
|
) {
|
||||||
closePath.attr("x1", point[0]).attr("y1", point[1]).attr("opacity", 1);
|
const closePathColor = Colors.GREEN5;
|
||||||
|
closePath
|
||||||
|
.attr("x1", point[0])
|
||||||
|
.attr("y1", point[1])
|
||||||
|
.attr("opacity", 1)
|
||||||
|
.attr("stroke", closePathColor)
|
||||||
|
.attr("fill", closePathColor);
|
||||||
|
lassoPath.attr("stroke", closePathColor).attr("fill", closePathColor);
|
||||||
} else {
|
} else {
|
||||||
closePath.attr("opacity", 0);
|
closePath.attr("opacity", 0);
|
||||||
|
lassoPath.attr("stroke", lassoPathColor).attr("fill", lassoPathColor);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -121,9 +127,9 @@ const Lasso = () => {
|
|||||||
lassoPath = g
|
lassoPath = g
|
||||||
.append("path")
|
.append("path")
|
||||||
.attr("data-testid", "lasso-element")
|
.attr("data-testid", "lasso-element")
|
||||||
.attr("fill", "#0bb")
|
.attr("fill", lassoPathColor)
|
||||||
.attr("fill-opacity", 0.1)
|
.attr("fill-opacity", 0.1)
|
||||||
.attr("stroke", "#0bb")
|
.attr("stroke", lassoPathColor)
|
||||||
.attr("stroke-dasharray", "3, 3");
|
.attr("stroke-dasharray", "3, 3");
|
||||||
|
|
||||||
lassoPath.attr("d", `${polygonToPath(lassoPolygon)}Z`);
|
lassoPath.attr("d", `${polygonToPath(lassoPolygon)}Z`);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export default (
|
|||||||
viewport
|
viewport
|
||||||
) => {
|
) => {
|
||||||
const svg = d3.select("#graph-wrapper").select("#lasso-layer");
|
const svg = d3.select("#graph-wrapper").select("#lasso-layer");
|
||||||
|
if (svg.empty()) return {};
|
||||||
|
|
||||||
if (selectionToolType === "brush") {
|
if (selectionToolType === "brush") {
|
||||||
const brush = d3
|
const brush = d3
|
||||||
|
|||||||
@@ -2,31 +2,19 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { AnchorButton, Tooltip } from "@blueprintjs/core";
|
import { AnchorButton, Tooltip } from "@blueprintjs/core";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { World } from "../../util/stateManager";
|
|
||||||
import { tooltipHoverOpenDelay } from "../../globals";
|
import { tooltipHoverOpenDelay } from "../../globals";
|
||||||
|
import actions from "../../actions";
|
||||||
|
|
||||||
@connect()
|
@connect((state) => ({
|
||||||
|
differential: state.differential,
|
||||||
|
}))
|
||||||
class CellSetButton extends React.PureComponent {
|
class CellSetButton extends React.PureComponent {
|
||||||
set() {
|
set() {
|
||||||
const {
|
const { differential, dispatch, eitherCellSetOneOrTwo } = this.props;
|
||||||
differential,
|
|
||||||
crossfilter,
|
|
||||||
dispatch,
|
|
||||||
eitherCellSetOneOrTwo,
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
// Reducer and components assume that value will be null if
|
|
||||||
// no selection made. World..getSelectedByIndex() returns a
|
|
||||||
// zero length TypedArray when nothing is selected.
|
|
||||||
let set = World.getSelectedByIndex(crossfilter);
|
|
||||||
if (set.length === 0) set = null;
|
|
||||||
|
|
||||||
if (!differential.diffExp) {
|
if (!differential.diffExp) {
|
||||||
/* diffexp needs to be cleared before we store a new set */
|
// disallow this action if the user has active differential expression results
|
||||||
dispatch({
|
dispatch(actions.setCellSetFromSelection(eitherCellSetOneOrTwo));
|
||||||
type: `store current cell selection as differential set ${eitherCellSetOneOrTwo}`,
|
|
||||||
data: set,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// jshint esversion: 6
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
Position,
|
Position,
|
||||||
@@ -11,7 +10,7 @@ import {
|
|||||||
import { tooltipHoverOpenDelay } from "../../globals";
|
import { tooltipHoverOpenDelay } from "../../globals";
|
||||||
import styles from "./menubar.css";
|
import styles from "./menubar.css";
|
||||||
|
|
||||||
function Clip(props) {
|
const Clip = React.memo((props) => {
|
||||||
const {
|
const {
|
||||||
pendingClipPercentiles,
|
pendingClipPercentiles,
|
||||||
clipPercentileMin,
|
clipPercentileMin,
|
||||||
@@ -129,6 +128,6 @@ function Clip(props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
export default Clip;
|
export default Clip;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// jshint esversion: 6
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { Button, ButtonGroup, AnchorButton, Tooltip } from "@blueprintjs/core";
|
import { Button, ButtonGroup, AnchorButton, Tooltip } from "@blueprintjs/core";
|
||||||
@@ -8,15 +7,13 @@ import actions from "../../actions";
|
|||||||
import CellSetButton from "./cellSetButtons";
|
import CellSetButton from "./cellSetButtons";
|
||||||
|
|
||||||
@connect((state) => ({
|
@connect((state) => ({
|
||||||
config: state.config,
|
|
||||||
crossfilter: state.crossfilter,
|
|
||||||
differential: state.differential,
|
differential: state.differential,
|
||||||
celllist1: state.differential?.celllist1,
|
celllist1: state.differential?.celllist1,
|
||||||
celllist2: state.differential?.celllist2,
|
celllist2: state.differential?.celllist2,
|
||||||
diffexpMayBeSlow: state.config?.parameters?.["diffexp-may-be-slow"] ?? false,
|
diffexpMayBeSlow: state.config?.parameters?.["diffexp-may-be-slow"] ?? false,
|
||||||
diffexpCellcountMax: state.config?.limits?.["diffexp_cellcount_max"],
|
diffexpCellcountMax: state.config?.limits?.["diffexp_cellcount_max"],
|
||||||
}))
|
}))
|
||||||
class DiffexpButtons extends React.Component {
|
class DiffexpButtons extends React.PureComponent {
|
||||||
computeDiffExp = () => {
|
computeDiffExp = () => {
|
||||||
const { dispatch, differential } = this.props;
|
const { dispatch, differential } = this.props;
|
||||||
if (differential.celllist1 && differential.celllist2) {
|
if (differential.celllist1 && differential.celllist2) {
|
||||||
@@ -66,14 +63,8 @@ class DiffexpButtons extends React.Component {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ButtonGroup className={styles.menubarButton}>
|
<ButtonGroup className={styles.menubarButton}>
|
||||||
<CellSetButton
|
<CellSetButton eitherCellSetOneOrTwo={1} />
|
||||||
{...this.props} // eslint-disable-line react/jsx-props-no-spreading
|
<CellSetButton eitherCellSetOneOrTwo={2} />
|
||||||
eitherCellSetOneOrTwo={1}
|
|
||||||
/>
|
|
||||||
<CellSetButton
|
|
||||||
{...this.props} // eslint-disable-line react/jsx-props-no-spreading
|
|
||||||
eitherCellSetOneOrTwo={2}
|
|
||||||
/>
|
|
||||||
{!differential.diffExp ? (
|
{!differential.diffExp ? (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={warnMaxSizeExceeded ? tipMessageWarn : tipMessage}
|
content={warnMaxSizeExceeded ? tipMessageWarn : tipMessage}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
AnchorButton,
|
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
Popover,
|
Popover,
|
||||||
Button,
|
Button,
|
||||||
@@ -12,26 +11,25 @@ import {
|
|||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import * as globals from "../../globals";
|
import * as globals from "../../globals";
|
||||||
import styles from "./menubar.css";
|
import styles from "./menubar.css";
|
||||||
import { World } from "../../util/stateManager";
|
|
||||||
import actions from "../../actions";
|
import actions from "../../actions";
|
||||||
|
|
||||||
@connect((state) => ({
|
@connect((state) => ({
|
||||||
universe: state.universe,
|
|
||||||
world: state.world,
|
|
||||||
layoutChoice: state.layoutChoice,
|
layoutChoice: state.layoutChoice,
|
||||||
reembedController: state.reembedController,
|
// disabled temporarily. TODO - issue #1606
|
||||||
enableReembedding: state.config?.parameters?.["enable-reembedding"] ?? false,
|
// reembedController: state.reembedController,
|
||||||
|
// enableReembedding: state.config?.parameters?.["enable-reembedding"] ?? false,
|
||||||
|
enableReembedding: false,
|
||||||
}))
|
}))
|
||||||
class Embedding extends React.PureComponent {
|
class Embedding extends React.PureComponent {
|
||||||
handleLayoutChoiceChange = (e) => {
|
handleLayoutChoiceChange = (e) => {
|
||||||
const { dispatch } = this.props;
|
const { dispatch } = this.props;
|
||||||
dispatch({
|
dispatch(actions.layoutChoiceAction(e.currentTarget.value));
|
||||||
type: "set layout choice",
|
|
||||||
layoutChoice: e.currentTarget.value,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line class-methods-use-this -- temporary disable
|
||||||
renderReembedding() {
|
renderReembedding() {
|
||||||
|
return null;
|
||||||
|
/* disabled pending rewrite. TODO - issue #1606
|
||||||
const {
|
const {
|
||||||
enableReembedding,
|
enableReembedding,
|
||||||
world,
|
world,
|
||||||
@@ -63,6 +61,7 @@ class Embedding extends React.PureComponent {
|
|||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// jshint esversion: 6
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { ButtonGroup, AnchorButton, Tooltip } from "@blueprintjs/core";
|
import { ButtonGroup, AnchorButton, Tooltip } from "@blueprintjs/core";
|
||||||
@@ -13,14 +12,23 @@ import Subset from "./subset";
|
|||||||
import UndoRedoReset from "./undoRedo";
|
import UndoRedoReset from "./undoRedo";
|
||||||
import DiffexpButtons from "./diffexpButtons";
|
import DiffexpButtons from "./diffexpButtons";
|
||||||
|
|
||||||
@connect((state) => ({
|
@connect((state) => {
|
||||||
universe: state.universe,
|
const { annoMatrix } = state;
|
||||||
world: state.world,
|
const crossfilter = state.obsCrossfilter;
|
||||||
crossfilter: state.crossfilter,
|
const selectedCount = crossfilter.countSelected();
|
||||||
|
|
||||||
|
const subsetPossible =
|
||||||
|
selectedCount !== 0 && selectedCount !== crossfilter.size(); // ie, not all are selected
|
||||||
|
const subsetResetPossible =
|
||||||
|
annoMatrix.nObs !== annoMatrix.schema.dataframe.nObs;
|
||||||
|
|
||||||
|
return {
|
||||||
|
subsetPossible,
|
||||||
|
subsetResetPossible,
|
||||||
differential: state.differential,
|
differential: state.differential,
|
||||||
graphInteractionMode: state.controls.graphInteractionMode,
|
graphInteractionMode: state.controls.graphInteractionMode,
|
||||||
clipPercentileMin: Math.round(100 * (state.world?.clipQuantiles?.min ?? 0)),
|
clipPercentileMin: Math.round(100 * (annoMatrix?.clipRange?.[0] ?? 0)),
|
||||||
clipPercentileMax: Math.round(100 * (state.world?.clipQuantiles?.max ?? 1)),
|
clipPercentileMax: Math.round(100 * (annoMatrix?.clipRange?.[1] ?? 1)),
|
||||||
userDefinedGenes: state.controls.userDefinedGenes,
|
userDefinedGenes: state.controls.userDefinedGenes,
|
||||||
diffexpGenes: state.controls.diffexpGenes,
|
diffexpGenes: state.controls.diffexpGenes,
|
||||||
colorAccessor: state.colors.colorAccessor,
|
colorAccessor: state.colors.colorAccessor,
|
||||||
@@ -33,13 +41,15 @@ import DiffexpButtons from "./diffexpButtons";
|
|||||||
redoDisabled: state["@@undoable/future"].length === 0,
|
redoDisabled: state["@@undoable/future"].length === 0,
|
||||||
aboutLink: state.config?.links?.["about-dataset"],
|
aboutLink: state.config?.links?.["about-dataset"],
|
||||||
disableDiffexp: state.config?.parameters?.["disable-diffexp"] ?? false,
|
disableDiffexp: state.config?.parameters?.["disable-diffexp"] ?? false,
|
||||||
diffexpMayBeSlow: state.config?.parameters?.["diffexp-may-be-slow"] ?? false,
|
diffexpMayBeSlow:
|
||||||
|
state.config?.parameters?.["diffexp-may-be-slow"] ?? false,
|
||||||
showCentroidLabels: state.centroidLabels.showLabels,
|
showCentroidLabels: state.centroidLabels.showLabels,
|
||||||
tosURL: state.config?.parameters?.["about_legal_tos"],
|
tosURL: state.config?.parameters?.["about_legal_tos"],
|
||||||
privacyURL: state.config?.parameters?.["about_legal_privacy"],
|
privacyURL: state.config?.parameters?.["about_legal_privacy"],
|
||||||
categoricalSelection: state.categoricalSelection,
|
categoricalSelection: state.categoricalSelection,
|
||||||
}))
|
};
|
||||||
class MenuBar extends React.Component {
|
})
|
||||||
|
class MenuBar extends React.PureComponent {
|
||||||
static isValidDigitKeyEvent(e) {
|
static isValidDigitKeyEvent(e) {
|
||||||
/*
|
/*
|
||||||
Return true if this event is necessary to enter a percent number input.
|
Return true if this event is necessary to enter a percent number input.
|
||||||
@@ -78,10 +88,10 @@ class MenuBar extends React.Component {
|
|||||||
const { pendingClipPercentiles } = this.state;
|
const { pendingClipPercentiles } = this.state;
|
||||||
const clipPercentileMin = pendingClipPercentiles?.clipPercentileMin;
|
const clipPercentileMin = pendingClipPercentiles?.clipPercentileMin;
|
||||||
const clipPercentileMax = pendingClipPercentiles?.clipPercentileMax;
|
const clipPercentileMax = pendingClipPercentiles?.clipPercentileMax;
|
||||||
|
const {
|
||||||
const { world } = this.props;
|
clipPercentileMin: currentClipMin,
|
||||||
const currentClipMin = 100 * world?.clipQuantiles?.min;
|
clipPercentileMax: currentClipMax,
|
||||||
const currentClipMax = 100 * world?.clipQuantiles?.max;
|
} = this.props;
|
||||||
|
|
||||||
// if you change this test, be careful with logic around
|
// if you change this test, be careful with logic around
|
||||||
// comparisons between undefined / NaN handling.
|
// comparisons between undefined / NaN handling.
|
||||||
@@ -150,10 +160,7 @@ class MenuBar extends React.Component {
|
|||||||
const { clipPercentileMin, clipPercentileMax } = pendingClipPercentiles;
|
const { clipPercentileMin, clipPercentileMax } = pendingClipPercentiles;
|
||||||
const min = clipPercentileMin / 100;
|
const min = clipPercentileMin / 100;
|
||||||
const max = clipPercentileMax / 100;
|
const max = clipPercentileMax / 100;
|
||||||
dispatch({
|
dispatch(actions.clipAction(min, max));
|
||||||
type: "set clip quantiles",
|
|
||||||
clipQuantiles: { min, max },
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
handleClipOpening = () => {
|
handleClipOpening = () => {
|
||||||
@@ -176,17 +183,14 @@ class MenuBar extends React.Component {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
subsetPossible = () => {
|
handleSubset = () => {
|
||||||
const { crossfilter } = this.props;
|
const { dispatch } = this.props;
|
||||||
return (
|
dispatch(actions.subsetAction());
|
||||||
crossfilter.countSelected() !== 0 &&
|
|
||||||
crossfilter.countSelected() !== crossfilter.size()
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
subsetResetPossible = () => {
|
handleSubsetReset = () => {
|
||||||
const { world, universe } = this.props;
|
const { dispatch } = this.props;
|
||||||
return world.nObs !== universe.nObs;
|
dispatch(actions.resetSubsetAction());
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
@@ -206,6 +210,8 @@ class MenuBar extends React.Component {
|
|||||||
tosURL,
|
tosURL,
|
||||||
categoricalSelection,
|
categoricalSelection,
|
||||||
colorAccessor,
|
colorAccessor,
|
||||||
|
subsetPossible,
|
||||||
|
subsetResetPossible,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const { pendingClipPercentiles } = this.state;
|
const { pendingClipPercentiles } = this.state;
|
||||||
|
|
||||||
@@ -314,16 +320,10 @@ class MenuBar extends React.Component {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
<Subset
|
<Subset
|
||||||
subsetPossible={this.subsetPossible()}
|
subsetPossible={subsetPossible}
|
||||||
subsetResetPossible={this.subsetResetPossible()}
|
subsetResetPossible={subsetResetPossible}
|
||||||
handleSubset={() => {
|
handleSubset={this.handleSubset}
|
||||||
dispatch(actions.setWorldToSelection());
|
handleSubsetReset={this.handleSubsetReset}
|
||||||
dispatch({ type: "increment graph render counter" });
|
|
||||||
}}
|
|
||||||
handleSubsetReset={() => {
|
|
||||||
dispatch(actions.resetWorldToUniverse());
|
|
||||||
dispatch({ type: "increment graph render counter" });
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{disableDiffexp ? null : <DiffexpButtons />}
|
{disableDiffexp ? null : <DiffexpButtons />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import React from "react";
|
|||||||
import { Button, Popover, Menu, MenuItem, Position } from "@blueprintjs/core";
|
import { Button, Popover, Menu, MenuItem, Position } from "@blueprintjs/core";
|
||||||
import styles from "./menubar.css";
|
import styles from "./menubar.css";
|
||||||
|
|
||||||
function InformationMenu(props) {
|
const InformationMenu = React.memo((props) => {
|
||||||
const { libraryVersions, aboutLink, tosURL, privacyURL } = props;
|
const { libraryVersions, aboutLink, tosURL, privacyURL } = props;
|
||||||
return (
|
return (
|
||||||
<div className={`bp3-button-group ${styles.menubarButton}`}>
|
<div className={`bp3-button-group ${styles.menubarButton}`}>
|
||||||
@@ -41,11 +41,11 @@ function InformationMenu(props) {
|
|||||||
/>
|
/>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
target="_blank"
|
target="_blank"
|
||||||
text={`cellxgene v${
|
text={
|
||||||
libraryVersions && libraryVersions.cellxgene
|
libraryVersions && libraryVersions.cellxgene
|
||||||
? libraryVersions.cellxgene
|
? libraryVersions.cellxgene
|
||||||
: null
|
: null
|
||||||
}`}
|
}
|
||||||
/>
|
/>
|
||||||
<MenuItem text="MIT License" />
|
<MenuItem text="MIT License" />
|
||||||
{tosURL ? (
|
{tosURL ? (
|
||||||
@@ -72,6 +72,6 @@ function InformationMenu(props) {
|
|||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
export default InformationMenu;
|
export default InformationMenu;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { AnchorButton, ButtonGroup, Tooltip } from "@blueprintjs/core";
|
|||||||
import styles from "./menubar.css";
|
import styles from "./menubar.css";
|
||||||
import * as globals from "../../globals";
|
import * as globals from "../../globals";
|
||||||
|
|
||||||
function Subset(props) {
|
const Subset = React.memo((props) => {
|
||||||
const {
|
const {
|
||||||
subsetPossible,
|
subsetPossible,
|
||||||
subsetResetPossible,
|
subsetResetPossible,
|
||||||
@@ -41,6 +41,6 @@ function Subset(props) {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
export default Subset;
|
export default Subset;
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
// jshint esversion: 6
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { AnchorButton, Tooltip } from "@blueprintjs/core";
|
import { AnchorButton, Tooltip } from "@blueprintjs/core";
|
||||||
import { tooltipHoverOpenDelay } from "../../globals";
|
import { tooltipHoverOpenDelay } from "../../globals";
|
||||||
import styles from "./menubar.css";
|
import styles from "./menubar.css";
|
||||||
|
|
||||||
function InformationMenu(props) {
|
const UndoRedo = React.memo((props) => {
|
||||||
const { undoDisabled, redoDisabled, dispatch } = props;
|
const { undoDisabled, redoDisabled, dispatch } = props;
|
||||||
return (
|
return (
|
||||||
<div className={`bp3-button-group ${styles.menubarButton}`}>
|
<div className={`bp3-button-group ${styles.menubarButton}`}>
|
||||||
@@ -46,6 +45,6 @@ function InformationMenu(props) {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
export default InformationMenu;
|
export default UndoRedo;
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverInteractionKind,
|
||||||
|
Position,
|
||||||
|
Classes,
|
||||||
|
} from "@blueprintjs/core";
|
||||||
|
|
||||||
|
export default class MiniHistogram extends React.PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.canvasRef = React.createRef();
|
||||||
|
}
|
||||||
|
|
||||||
|
drawHistogram = () => {
|
||||||
|
const { xScale, yScale, bins, width, height } = this.props;
|
||||||
|
|
||||||
|
if (!bins) return;
|
||||||
|
|
||||||
|
const ctx = this.canvasRef.current.getContext("2d");
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
|
ctx.fillStyle = "#000";
|
||||||
|
|
||||||
|
let x;
|
||||||
|
let y;
|
||||||
|
|
||||||
|
const rectWidth = width / bins.length;
|
||||||
|
|
||||||
|
for (let i = 0, { length } = bins; i < length; i += 1) {
|
||||||
|
x = xScale(i);
|
||||||
|
y = yScale(bins[i]);
|
||||||
|
ctx.fillRect(x, height - y, rectWidth, y);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidMount = () => {
|
||||||
|
this.drawHistogram();
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidUpdate = (prevProps) => {
|
||||||
|
const { obsOrVarContinuousFieldDisplayName } = this.props;
|
||||||
|
if (
|
||||||
|
prevProps.obsOrVarContinuousFieldDisplayName !==
|
||||||
|
obsOrVarContinuousFieldDisplayName
|
||||||
|
)
|
||||||
|
this.drawHistogram();
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
domainLabel,
|
||||||
|
obsOrVarContinuousFieldDisplayName,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
interactionKind={PopoverInteractionKind.HOVER_TARGET_ONLY}
|
||||||
|
hoverOpenDelay={1500}
|
||||||
|
hoverCloseDelay={200}
|
||||||
|
position={Position.LEFT}
|
||||||
|
modifiers={{
|
||||||
|
preventOverflow: { enabled: false },
|
||||||
|
hide: { enabled: false },
|
||||||
|
}}
|
||||||
|
lazy
|
||||||
|
usePortal
|
||||||
|
popoverClassName={Classes.POPOVER_CONTENT_SIZING}
|
||||||
|
>
|
||||||
|
<canvas
|
||||||
|
className="bp3-popover-targer"
|
||||||
|
style={{
|
||||||
|
marginRight: 5,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
borderBottom: "solid rgb(230, 230, 230) 0.25px",
|
||||||
|
}}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
ref={this.canvasRef}
|
||||||
|
/>
|
||||||
|
<div key="text" style={{ fontSize: "14px" }}>
|
||||||
|
<p style={{ margin: "0" }}>
|
||||||
|
This histograms shows the distribution of{" "}
|
||||||
|
<strong>{obsOrVarContinuousFieldDisplayName}</strong> within{" "}
|
||||||
|
<strong>{domainLabel}</strong>.
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
The x axis is the same for each histogram, while the y axis is
|
||||||
|
scaled to the largest bin within this histogram instead of the
|
||||||
|
largest bin within the whole category.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// jshint esversion: 6
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export default class MiniStackedBar extends React.PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.canvasRef = React.createRef();
|
||||||
|
}
|
||||||
|
|
||||||
|
drawStacks = () => {
|
||||||
|
const {
|
||||||
|
domainValues,
|
||||||
|
scale,
|
||||||
|
domain,
|
||||||
|
colorTable,
|
||||||
|
occupancy,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
if (!colorTable || !domainValues) return;
|
||||||
|
|
||||||
|
const { scale: colorScale } = colorTable;
|
||||||
|
|
||||||
|
const ctx = this.canvasRef?.current.getContext("2d");
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
let currentOffset = 0;
|
||||||
|
|
||||||
|
let occupancyValue;
|
||||||
|
let scaledValue;
|
||||||
|
let value;
|
||||||
|
|
||||||
|
for (let i = 0, { length } = domainValues; i < length; i += 1) {
|
||||||
|
value = domainValues[i];
|
||||||
|
occupancyValue = occupancy.get(value);
|
||||||
|
scaledValue = scale(occupancyValue);
|
||||||
|
ctx.fillStyle = occupancyValue
|
||||||
|
? colorScale(domain.indexOf(value))
|
||||||
|
: "rgb(255,255,255)";
|
||||||
|
ctx.fillRect(currentOffset, 0, occupancyValue ? scaledValue : 0, height);
|
||||||
|
currentOffset += occupancyValue ? scaledValue : 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidUpdate = (prevProps) => {
|
||||||
|
const { occupancy } = this.props;
|
||||||
|
if (occupancy !== prevProps.occupancy) this.drawStacks();
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidMount = () => {
|
||||||
|
this.drawStacks();
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { width, height } = this.props;
|
||||||
|
const { canvas } = this;
|
||||||
|
if (canvas) canvas.getContext("2d").clearRect(0, 0, width, height);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
className="bp3-popover-targer"
|
||||||
|
style={{
|
||||||
|
marginRight: 5,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
}}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
ref={this.canvasRef}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { glPointFlags, glPointSize } from "../../util/glHelpers";
|
import { glPointFlags, glPointSize } from "../../util/glHelpers";
|
||||||
|
|
||||||
export default function (regl) {
|
export default function drawPointsRegl(regl) {
|
||||||
return regl({
|
return regl({
|
||||||
vert: `
|
vert: `
|
||||||
precision mediump float;
|
precision mediump float;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user