Merge branch 'main' into colinmegill/geneset-prototype

This commit is contained in:
Colin Megill
2020-07-20 12:15:11 -04:00
197 changed files with 27298 additions and 11280 deletions
+4 -4
View File
@@ -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:
+1 -1
View File
@@ -2,7 +2,7 @@ name: Deploy via single cell infra repo
on: on:
push: push:
branches: master branches: main
jobs: jobs:
deploy: deploy:
+4 -5
View File
@@ -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
View File
@@ -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*
+1 -1
View File
@@ -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"
+2 -2
View File
@@ -5,13 +5,13 @@ _an interactive explorer for single-cell transcriptomics data_
[![DOI](https://zenodo.org/badge/105615409.svg)](https://zenodo.org/badge/latestdoi/105615409) [![PyPI](https://img.shields.io/pypi/v/cellxgene)](https://pypi.org/project/cellxgene/) [![PyPI - Downloads](https://img.shields.io/pypi/dm/cellxgene)](https://pypistats.org/packages/cellxgene) [![GitHub last commit](https://img.shields.io/github/last-commit/chanzuckerberg/cellxgene)](https://github.com/chanzuckerberg/cellxgene/pulse) [![DOI](https://zenodo.org/badge/105615409.svg)](https://zenodo.org/badge/latestdoi/105615409) [![PyPI](https://img.shields.io/pypi/v/cellxgene)](https://pypi.org/project/cellxgene/) [![PyPI - Downloads](https://img.shields.io/pypi/dm/cellxgene)](https://pypistats.org/packages/cellxgene) [![GitHub last commit](https://img.shields.io/github/last-commit/chanzuckerberg/cellxgene)](https://github.com/chanzuckerberg/cellxgene/pulse)
[![Push Tests](https://github.com/chanzuckerberg/cellxgene/workflows/Push%20Tests/badge.svg)](https://github.com/chanzuckerberg/cellxgene/actions?query=workflow%3A%22Push+Tests%22) [![Push Tests](https://github.com/chanzuckerberg/cellxgene/workflows/Push%20Tests/badge.svg)](https://github.com/chanzuckerberg/cellxgene/actions?query=workflow%3A%22Push+Tests%22)
[![Compatibility Tests](https://github.com/chanzuckerberg/cellxgene/workflows/Compatibility%20Tests/badge.svg)](https://github.com/chanzuckerberg/cellxgene/actions?query=workflow%3A%22Compatibility+Tests%22) [![Compatibility Tests](https://github.com/chanzuckerberg/cellxgene/workflows/Compatibility%20Tests/badge.svg)](https://github.com/chanzuckerberg/cellxgene/actions?query=workflow%3A%22Compatibility+Tests%22)
![Code Coverage](https://codecov.io/gh/chanzuckerberg/cellxgene/branch/master/graph/badge.svg) ![Code Coverage](https://codecov.io/gh/chanzuckerberg/cellxgene/branch/main/graph/badge.svg)
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
+6 -2
View File
@@ -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
View File
@@ -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>",
]
`;
+215 -124
View File
@@ -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) {
if (reset) await this.resetCategory(category);
await utils.clickOn(`${category}:category-expand`);
await utils.clickOn(`${category}:category-select`);
for (const val of values) {
await utils.clickOn(`categorical-value-select-${category}-${val}`);
} }
},
async expandCategory(category) { export async function selectCategory(category, values, reset = true) {
const expand = await utils.waitByID(`${category}:category-expand`); if (reset) await resetCategory(category);
await clickOn(`${category}:category-expand`);
await clickOn(`${category}:category-select`);
for (const value of values) {
await clickOn(`categorical-value-select-${category}-${value}`);
}
}
export async function expandCategory(category) {
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) {
for (const selection of cellSet.filter(
(sel) => sel.kind === "categorical"
)) {
await this.selectCategory(selection.metadata, selection.values, true);
} }
await this.cellSet(cellSetNum);
},
async runDiffExp(cellSet1, cellSet2) { export async function setSellSet(cellSet, cellSetNum) {
await this.setSellSet(cellSet1, 1); const selections = cellSet.filter((sel) => sel.kind === "categorical");
await this.setSellSet(cellSet2, 2);
await utils.clickOn("diffexp-button");
},
async bulkAddGenes(geneNames) { for (const selection of selections) {
await utils.clickOn("section-bulk-add"); await selectCategory(selection.metadata, selection.values, true);
await utils.typeInto("input-bulk-add", geneNames.join(",")); }
await getCellSetCount(cellSetNum);
}
export async function runDiffExp(cellSet1, cellSet2) {
await setSellSet(cellSet1, 1);
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 */
+7 -9
View File
@@ -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;
+2 -2
View File
@@ -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
View File
@@ -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 */
+194 -111
View File
@@ -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;
}
}); });
+6 -1
View File
@@ -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"
} }
-122
View File
@@ -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);
});
});
+58
View File
@@ -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);
});
+99 -37
View File
@@ -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 */
+36
View File
@@ -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;
+17
View File
@@ -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;
-58
View File
@@ -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];
}
+4
View File
@@ -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);
});
});
+24 -43
View File
@@ -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) => {
+492 -49
View File
@@ -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();
});
test("labels", () => {
expect(idx.labels()).toEqual(new Int32Array([99, 1002, 48, 0, 22])); expect(idx.labels()).toEqual(new Int32Array([99, 1002, 48, 0, 22]));
expect(idx.size()).toEqual(5); 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(1002)).toEqual(1);
expect(idx.getOffset(0)).toEqual(3); 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.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("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(idx.labels()).toEqual(["red", "green", "blue"]); expect(new Dataframe.KeyIndex().size()).toEqual(0);
expect(idx.size()).toEqual(3);
expect(idx.getOffset("blue")).toEqual(2);
expect(idx.getLabel(1)).toEqual("green");
expect(idx.subset(["green"]).labels()).toEqual(["green"]);
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"]);
expect(idx.withLabel("yo").getOffset("yo")).toEqual(3);
expect(idx.dropLabel("blue").labels()).toEqual(["red", "green"]);
}); });
}) test("labels", () => {
expect(idx.labels()).toEqual(["red", "green", "blue"]);
expect(idx.size()).toEqual(3);
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", "red"]).labels()).toEqual(["green", "red"]);
});
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.withLabels(["hey", "there"]).labels()).toEqual([
"red",
"green",
"blue",
"hey",
"there",
]);
});
test("dropLabel", () => {
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();
});
});
+35 -12
View File
@@ -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])
); );
+24 -2
View File
@@ -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",
}; };
+46
View File
@@ -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,
};
+5908 -4532
View File
File diff suppressed because it is too large Load Diff
+58 -49
View File
@@ -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": {
+378
View File
@@ -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,
});
}
};
+71 -317
View File
@@ -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,
}; };
+2
View File
@@ -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",
}); });
} }
*/
+224
View File
@@ -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,
});
};
+100
View File
@@ -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,
});
};
+642
View File
@@ -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");
}
+6
View File
@@ -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);
}
+263
View File
@@ -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;
}
}
+27
View File
@@ -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;
}
+15
View File
@@ -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";
+287
View File
@@ -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");
}
}
+66
View File
@@ -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;
+82
View File
@@ -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);
}
}
+63
View File
@@ -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);
}
+161
View File
@@ -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*/
+92
View File
@@ -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), {});
}
+2 -2
View File
@@ -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>
+12 -12
View File
@@ -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> </>
); );
} }
);
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>
);
} }
export default Category; /*
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);
+10 -10
View File
@@ -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}
/> />
+326 -148
View File
@@ -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;
+7 -76
View File
@@ -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>
); );
} }
+62 -13
View File
@@ -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"
+7 -3
View File
@@ -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
+4 -28
View File
@@ -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;
+441 -278
View File
@@ -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,
}) })
); );
+19 -13
View File
@@ -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,
});
} }
} }
+2 -3
View File
@@ -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}
+9 -10
View File
@@ -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() {
+37 -37
View File
@@ -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>
+4 -4
View File
@@ -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;
+2 -2
View File
@@ -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;
+3 -4
View File
@@ -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