Compare commits

..
2 Commits
Author SHA1 Message Date
Charlotte Weaver f810d858f3 bump version 2018-11-30 15:58:46 -08:00
Charlotte Weaver 702b5d5070 Update scikit learn (#487)
They finally fixed their cloud pickle issue
2018-11-30 15:55:07 -08:00
327 changed files with 12921 additions and 35621 deletions
+5 -5
View File
@@ -1,15 +1,15 @@
[bumpversion]
current_version = 0.14.1
current_version = 0.2.4
[bumpversion:file:setup.py]
search = version="{current_version}"
replace = version="{new_version}"
[bumpversion:file:server/cli/cli.py]
search = version="{current_version}"
replace = version="{new_version}"
[bumpversion:file:client/package.json]
search = "version": "{current_version}"
replace = "version": "{new_version}"
[bumpversion:file:server/__init__.py]
search = __version__ = "{current_version}"
replace = __version__ = "{new_version}"
-5
View File
@@ -1,5 +0,0 @@
bin
client
dist
docs
server
-8
View File
@@ -20,7 +20,6 @@ cellxgene/
server/app/web/static/css/
server/app/web/static/img/
server/app/web/static/media/
server/app/web/static/fonts/
server/app/web/static/js/
server/app/web/templates/index\.html
@@ -28,9 +27,6 @@ server/app/web/templates/index\.html
.ipynb_checkpoints
*.ipynb
# cefpython
error.log
# misc
.DS_Store
npm-debug.log
@@ -39,7 +35,3 @@ npm-debug.log
__pycache__
*.DS_Store*
data
# Jekyll
docs/_site/
docs/Gemfile.lock
+14 -22
View File
@@ -1,28 +1,20 @@
language: python
dist: xenial
sudo: required
python:
- "3.6"
node_js:
- 10
- "8"
cache:
- pip
- npm
pip: true
install:
- set -eo pipefail
- pip install flake8
- make pydist install-dist dev-env
jobs:
include:
- name: "Branch Tests 3.7"
python: "3.7"
script: make build-client lint unit-test
- name: "Branch Tests 3.6"
python: "3.6"
script: make build-client lint unit-test
- name: "Docker Build"
install: skip
python: "3.6"
script: docker build .
- name: "Smoke Tests"
python: "3.6"
script: make smoke-test
- ./bin/build-client
- pip install -e .
- pip install -r server/requirements-dev.txt
script:
- set -eo pipefail
- flake8 server/app/
- flake8 server/cli/
- npm run --prefix client/ build
- npm run --prefix client/ test
- pytest -s server/test
-3
View File
@@ -1,3 +0,0 @@
We warmly welcome contributions from the community!
Whether you want to contribute ideas, requests, documentation, or code, you can get started by visiting our [contribution guide](https://chanzuckerberg.github.io/cellxgene/posts/contribute).
-10
View File
@@ -1,10 +0,0 @@
FROM ubuntu:bionic
ENV LC_ALL=C.UTF-8
ENV LANG=C.UTF-8
RUN apt-get update && \
apt-get install -y build-essential libxml2-dev python3-dev python3-pip zlib1g-dev python3-requests && \
pip3 install cellxgene
ENTRYPOINT ["cellxgene"]
+1 -2
View File
@@ -1,5 +1,4 @@
recursive-include server/app/web/templates *
recursive-include server/app/web/static *
include server/requirements.txt
include server/requirements-prepare.txt
include server/requirements.txt
-211
View File
@@ -1,211 +0,0 @@
BUILDDIR := build
CLIENTBUILD := $(BUILDDIR)/client
SERVERBUILD := $(BUILDDIR)/server
CLEANFILES := $(BUILDDIR)/ client/build build dist cellxgene.egg-info
PART ?= patch
# CLEANING
.PHONY: clean
clean: clean-lite clean-server clean-client
# cleaning the client's node_modules is the longest one, so we avoid that if possible
.PHONY: clean-lite
clean-lite:
rm -rf $(CLEANFILES)
clean-%:
cd $(*) && $(MAKE) clean
# BUILDING PACKAGE
.PHONY: build
build: clean build-cli
@echo "done"
.PHONY: build-client
build-client:
cd client && $(MAKE) ci build
.PHONY: build-cli
build-cli: build-client
git ls-files server/ | cpio -pdm $(BUILDDIR)
cp -r client/build/ $(CLIENTBUILD)
mkdir -p $(SERVERBUILD)/app/web/static/img
mkdir -p $(SERVERBUILD)/app/web/templates/
cp $(CLIENTBUILD)/index.html $(SERVERBUILD)/app/web/templates/
cp -r $(CLIENTBUILD)/static $(SERVERBUILD)/app/web/
cp $(CLIENTBUILD)/favicon.png $(SERVERBUILD)/app/web/static/img
cp $(CLIENTBUILD)/service-worker.js $(SERVERBUILD)/app/web/static/js/
cp MANIFEST.in README.md setup.cfg setup.py $(BUILDDIR)
# If you are actively developing in the server folder use this, dirties the source tree
.PHONY: build-for-server-dev
build-for-server-dev: clean-server build-client
mkdir -p server/app/web/static/img
mkdir -p server/app/web/static/js
mkdir -p server/app/web/templates/
cp client/build/index.html server/app/web/templates/
cp -r client/build/static server/app/web/
cp client/build/favicon.png server/app/web/static/img
cp client/build/service-worker.js server/app/web/static/js/
# TESTING
.PHONY: test
test: unit-test smoke-test
.PHONY: unit-test
unit-test: unit-test-server unit-test-client
unit-test-%:
cd $(*) && $(MAKE) unit-test
.PHONY: smoke-test
smoke-test:
cd client && $(MAKE) smoke-test
# FORMATTING CODE
.PHOHY: fmt
fmt: fmt-client fmt-py
fmt-client:
cd client && $(MAKE) fmt
fmt-py:
black .
.PHONY: lint
lint:
flake8 server
# CREATING DISTRIBUTION RELEASE
.PHONY: pydist
pydist: build
cd $(BUILDDIR); python setup.py sdist -d ../dist
@echo "done"
# RELEASE HELPERS
# create new version to commit to master
.PHONY: release-stage-1
release-stage-1: dev-env bump clean-lite gen-package-lock
@echo "Version bumped part:$(PART) and client built. Ready to commit and push"
# build dist and release to dev pypi
.PHONY: release-stage-2
release-stage-2: dev-env pydist twine
@echo "Dist built and uploaded to test.pypi.org"
@echo "Test the install:"
@echo " make install-release-test"
@echo "Then upload to Pypi prod:"
@echo " make twine-prod"
.PHONY: release-stage-final
release-stage-final: twine-prod
@echo "Release uploaded to pypi.org"
# DANGER: releases directly to prod
# use this if you accidently burned a test release version number,
.PHONY: release-directly-to-prod
release-directly-to-prod: dev-env pydist twine-prod
@echo "Dist built and uploaded to pypi.org"
@echo "Test the install:"
@echo " make install-release"
.PHONY: dev-env
dev-env:
cd client && $(MAKE) ci
pip install -r server/requirements-dev.txt
.PHONY: gui-env
gui-env: dev-env
pip install -r server/requirements-gui.txt
# give PART=[major, minor, part] as param to make bump
.PHONY: bump
bump:
bumpversion --config-file .bumpversion.cfg $(PART)
.PHONY: twine
twine:
twine upload --repository-url https://test.pypi.org/legacy/ dist/*
.PHONY: twine-prod
twine-prod:
twine upload dist/*
# quicker than re-building client
.PHONY: gen-package-lock
gen-package-lock:
cd client && $(MAKE) install
# INSTALL
# setup.py sucks when you have your library in a separate folder, adding these in to help setup envs
# install from build directory
.PHONY: install
install: uninstall
cd $(BUILDDIR); pip install -e .
# install from source tree for development
.PHONY: install-dev
install-dev: uninstall
pip install -e .
# install from test.pypi to test your release
.PHONY: install-release-test
install-release-test: uninstall
pip install --no-cache-dir --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple cellxgene
@echo "Installed cellxgene from test.pypi.org, now run and smoke test"
# install from pypi to test your release
.PHONY: install-release
install-release: uninstall
pip install --no-cache-dir cellxgene
@echo "Installed cellxgene from pypi.org"
# install from dist
.PHONY: install-dist
install-dist: uninstall
pip install dist/cellxgene*.tar.gz
.PHONY: uninstall
uninstall:
pip uninstall -y cellxgene || :
# GUI
.PHONY: build-assets
build-assets:
pyside2-rcc server/gui/cellxgene.qrc -o server/gui/cellxgene_rc.py
.PHONY: gui-spec-osx
gui-spec-osx: clean-lite gui-env
pip install -e .[gui]
pyi-makespec -D -w --additional-hooks-dir server/gui/ -n cellxgene --add-binary='/System/Library/Frameworks/Tk.framework/Tk':'tk' --add-binary='/System/Library/Frameworks/Tcl.framework/Tcl':'tcl' --add-data server/app/web/templates/:server/app/web/templates/ --add-data server/app/web/static/:server/app/web/static/ --icon server/gui/images/cxg_icons.icns server/gui/main.py
mv cellxgene.spec cellxgene-osx.spec
.PHONY: gui-spec-windows
gui-spec-windows: clean-lite dev-env
pip install -e .[gui]
pyi-makespec -D -w --additional-hooks-dir server/gui/ -n cellxgene --add-data server/app/web/templates;server/app/web/templates --add-data server/app/web/static;server/app/web/static --icon server/gui/images/icon.ico server/gui/main.py
mv cellxgene.spec cellxgene-windows.spec
.PHONY: gui-build-osx
gui-build-osx: clean-lite
pyinstaller --clean cellxgene-osx.spec
.PHONY: gui-build-windows
gui-build-windows: clean-lite
pyinstaller --clean cellxgene-windows.spec
+193 -51
View File
@@ -1,86 +1,228 @@
<img src="./docs/cellxgene-logo.png" width="300">
# cellxgene
_an interactive explorer for single-cell transcriptomics data_
> 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)
`cellxgene` 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, and to demonstrate general, scalable, and reusable patterns for scientific data visualization.
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.
<img src="https://raw.githubusercontent.com/chanzuckerberg/cellxgene/master/docs/cellxgene-demo-1.gif" width="200" height="200" hspace="30"><img src="https://raw.githubusercontent.com/chanzuckerberg/cellxgene/master/docs/cellxgene-demo-2.gif" width="200" height="200" hspace="30"><img src="https://raw.githubusercontent.com/chanzuckerberg/cellxgene/master/docs/cellxgene-demo-3.gif" width="200" height="200" hspace="30">
Whether you need to visualize one thousand cells or one million, cellxgene helps you gain insight into your single-cell data.
## getting started
<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">
You'll need **python 3.6** and **Google Chrome**. The web UI is tested on OSX and Windows using Chrome, and the python CLI is tested on OSX and Ubuntu (via WSL/Windows). It should work on other platforms, but if you run into trouble let us know (see [help](#help-and-contact) below).
# Getting started
### The comprehensive guide to cellxgene
[The cellxgene documentation is your one-stop-shop for information about cellxgene](https://chanzuckerberg.github.io/cellxgene/)! You may be particularly interested in:
* Seeing [what cellxgene can do](https://chanzuckerberg.github.io/cellxgene/posts/gallery)
* Learning more about cellxgene [installation](https://chanzuckerberg.github.io/cellxgene/posts/install) and [usage](https://chanzuckerberg.github.io/cellxgene/posts/launch)
* [Preparing your own data](https://chanzuckerberg.github.io/cellxgene/posts/prepare) for use in cellxgene
* Checking out [our roadmap](https://chanzuckerberg.github.io/cellxgene/posts/roadmap) for future development
* [Contributing](https://chanzuckerberg.github.io/cellxgene/posts/contribute) to cellxgene
To install run
### Quick start
To install cellxgene you need Python 3.6+. We recommend [installing cellxgene into a conda or virtual environment.](https://chanzuckerberg.github.io/cellxgene/posts/install)
Install the package.
``` bash
```
pip install cellxgene
```
Launch cellxgene with an example [anndata](https://anndata.readthedocs.io/en/latest/) file
To start exploring a dataset call
``` bash
cellxgene launch https://cellxgene-example-data.czi.technology/pbmc3k.h5ad
```
cellxgene launch dataset.h5ad --open
```
To explore more datasets already formatted for cellxgene, check out the [Demo data](https://chanzuckerberg.github.io/cellxgene/posts/demo-data) or
see [Preparing your data](https://chanzuckerberg.github.io/cellxgene/posts/prepare) to learn more about formatting your own
data for cellxgene.
If you want an example dataset download [this file](https://github.com/chanzuckerberg/cellxgene/raw/master/example-dataset/pbmc3k.h5ad) and then call
### Finding help
```
cellxgene launch pbmc3k.h5ad --open
```
You should see your web browser open with the following
We'd love to hear from you!
For questions, suggestions, or accolades, [join the `#cellxgene-users` channel on the CZI Science Slack](https://join-cellxgene-users.herokuapp.com/) and say "hi!".
<img width="450" src="https://raw.githubusercontent.com/chanzuckerberg/cellxgene/master/docs/cellxgene-opening-screenshot.png" pad="50px">
For any errors, [report bugs on Github](https://github.com/chanzuckerberg/cellxgene/issues).
**Note**: automatic opening of the browser with the `--open` flag only works on OS X, on other platforms you'll need to directly point to the provided link in your browser.
# Developing with cellxgene
There are several options available, such as:
### Contributing
We warmly welcome contributions from the community! Please see our [contributing guide](https://chanzuckerberg.github.io/cellxgene/posts/contribute) and don't hesitate to open an issue or send a pull request to improve cellxgene.
- `--layout` to specify the layout as `tsne` or `umap`
- `--title` to show a title on the explorer
- `--open` to automatically open the web browser after launching (OS X only)
This project adheres to the Contributor Covenant [code of conduct](https://github.com/chanzuckerberg/.github/blob/master/CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to opensource@chanzuckerberg.com.
To see all options call
### Reuse
```
cellxgene launch --help
```
This project was started with the sole goal of empowering the scientific community to explore and understand their data. As such, we encourage other scientific tool builders in academia or industry to adopt the patterns, tools, and code from this project, and reach out to us with ideas or questions. All code is freely available for reuse under the [MIT license](https://opensource.org/licenses/MIT).
There is an additional subcommand called `cellxgene prepare` that takes an existing dataset in one of several formats and applies minimal preprocessing and reformatting so that `launch` can use it (see [the next section](##data-formatting) for more info on `prepare`).
### Security
## data formatting
If you believe you have found a security issue, we would appreciate notification. Please send email to <security@chanzuckerberg.com>.
### assumptions
# About
### Core team
The `launch` command assumes that the data is stored in the `.h5ad` format from the [`anndata`](https://anndata.readthedocs.io/en/latest/index.html) library. It also assumes that certain computations have already been performed. Briefly, the `.h5ad` format wraps a two-dimensional `ndarray` and stores additional metadata as "annotations" for either observations (referred to as `obs` and `obsm`) or variables (`var` and `varm`). `cellxgene launch` makes the following assumptions about your data (we recommend loading and inspecting your data using `scanpy` to validate these assumptions)
The current core team:
- an `obs` field has a unique identifier for every cell (you can specify which field to use with the `--obs-names` option, by default it will use the value of `data.obs_names`)
- a `var` field has a unique identifier for every gene (you can specify which field to use with the `--var-names` option, by default it will use the value of `data.var_names`)
- an `obsm` field contains the two-dimensional coordinates for the layout that you want to render (e.g. `X_tsne` for the `tsne` layout or `X_umap` for the `umap` layout)
- any additional `obs` fields will be rendered as per-cell continuous or categorical metadata by the app (e.g. `louvain` cluster assignments)
- Colin Megill, frontend & product design
- Bruce Martin, software engineer
- Sidney Bell, computational biologist
- Lia Prins, designer
- Severiano Badajoz, software engineer
### prepare
We would also like to gratefully acknowledge contributions from past core team members:
The `prepare` command is included to help you format your data. It uses `scanpy` under the hood. This is especially useful if you are starting with raw unanalyzed data and are unfamiliar with `scanpy`.
- Charlotte Weaver, software engineer
To prepare from an existing `.h5ad` file use
### Inspiration
```
cellxgene prepare dataset.h5ad --output=dataset-processed.h5ad
```
This will load the input data, perform PCA and nearest neighbor calculations, compute `umap` and `tsne` layouts and `louvain` cluster assignments, and save the results in a new file called `dataset-processed.h5ad` that can be loaded using `cellxgene launch`. Data can be loaded from several formats, including `.h5ad` `.loom` and a `10-Genomics-formatted` `mtx` directory. Several options are available, including running one of the preprocessing `recipes` included with `scanpy`, which include steps like cell filtering and gene selection.
Depending on the options chosen, `prepare` can take a long time to run (a few minutes for datasets with 10-100k cells, up to an hour or more for datasets with >100k cells). If you want `prepare` to run faster we recommend using the `sparse` option and only computing the layout for `umap`, using a call like this
```
cellxgene prepare dataset.h5ad --output=dataset-processed.h5ad --layout=umap --sparse
```
To see all options call
```
cellxgene prepare --help
```
**Note**: `cellxgene prepare` will only perform `louvain` clustering if you have the `python-igraph` and `louvain` packages installed. To make sure they are installed alongside `cellxgene` use
```
pip install cellxgene[louvain]
```
## conda and virtual environments
If you use conda and want to create a conda environment for `cellxgene` you can use the following commands
```
conda create --yes -n cellxgene python=3.6
conda activate cellxgene
pip install cellxgene
```
Or you can create a virtual environment by using
```
ENV_NAME=cellxgene
python3 -m venv ${ENV_NAME}
source ${ENV_NAME}/bin/activate
pip install cellxgene
```
## FAQ
> Someone sent me a directory of `10X-Genomics` data with a `mtx` file and I've never used `scanpy`, can I use `cellxgene`?
Yep! This should only take a couple steps. We'll assume your data is in a folder called `data/` and you've successfully installed `cellxgene` with the `louvain` packages as described above. Just run
```
cellxgene prepare data/ --output=data-processed.h5ad --layout=umap
```
Depending on the size of the dataset, this may take some time. Once it's done, call
```
cellxgene launch data-processed.h5ad --layout=umap --open
```
And your web browser should open with an interactive view of your data.
> In my `prepare` command I received the following error `Warning: louvain module is not installed, no clusters will be calculated. To fix this please install cellxgene with the optional feature louvain enabled`
Louvain clustering requires additional dependencies that are somewhat complex, so we don't include them by default. For now, you need to specify that you want these packages by using
```
pip install cellxgene[louvain]
```
> I ran `prepare` and I'm getting results that look unexpected
You might want to try running one of the preprocessing recipes included with `scanpy` (read more about them [here](https://scanpy.readthedocs.io/en/latest/api/index.html#recipes)). You can specify this with the `--recipe` option, such as
```
cellxgene prepare data/ --output=data-processed.h5ad --recipe=zheng17
```
It should be easy to run `prepare` then call `cellxgene launch` a few times with different settings to explore different behaviors. We may explore adding other preprocessing options in the future.
> I have extra metadata that I want to add to my dataset
Currently this is not supported directly, but you should be able to do this manually using `scanpy`. For example, this [notebook](https://github.com/falexwolf/fun-analyses/blob/master/tabula_muris/tabula_muris.ipynb) shows adding the contents of a `csv` file with metadata to an `anndata` object. For now, you could do this manually on your data in the same way and then save out the result before loading into `cellxgene`.
> I tried to `pip install cellxgene` and got a weird error I don't understand
This may happen, especially as we work out bugs in our installation process! Please create a new [Github issue](https://github.com/chanzuckerberg/cellxgene/issues), explain what you did, and include all the error messages you saw. It'd also be super helpful if you call `pip freeze` and include the full output alongside your issue.
> How are you computing and sorting differential expression results?
Currently we use a [Welch's *t*-test](https://en.wikipedia.org/wiki/Welch%27s_t-test) implementation including the same variance overestimation correction as used in `scanpy`. We sort the `tscore` to identify the top N genes, and then filter to remove any that fall below a cutoff log fold change value, which can help remove spurious test results. The default threshold is `0.01` and can be changed using the option `--diffexp-lfc-cutoff`. We can explore adding support for other test types in the future.
> I'm following the developer instructions and get an error about "missing files and directories” when trying to build the client
This is likely because you do not have node and npm installed, we recommend using [nvm](https://github.com/creationix/nvm) if you're new to using these tools.
## developer guide
This project has made a few key design choices
- The front-end is built with [`regl`](https://github.com/regl-project/regl) (a webgl library), [`react`](https://reactjs.org/), [`redux`](https://redux.js.org/), [`d3`](https://github.com/d3/d3), and [`blueprint`](https://blueprintjs.com/docs/#core) to handle rendering large numbers of cells with lots of complex interactivity
- The app is designed with a client-server model that can support a range of existing analysis packages for backend computational tasks (currently built for [scanpy](https://github.com/theislab/scanpy))
- The client uses fast cross-filtering to handle selections and comparisons across subsets of data
Depending on your background and interests, you might want to contribute to the frontend, or backend, or both!
If you are interested in working on `cellxgene` development, we recommend cloning the project from Gitub. First you'll need the following installed on your machine
- python 3.6
- node and npm (we recommend using [nvm](https://github.com/creationix/nvm) if this is your first time with node)
Then clone the project
```
git clone https://github.com/chanzuckerberg/cellxgene.git
```
Build the client web assets by calling this from inside the `cellxgene` folder
```
./bin/build-client
```
Install all requirements (we recommend doing this inside a virtual environment)
```
pip install -e .
```
You can start the app while developing either by calling `cellxgene` or by calling `python -m server`. We recommend using the `--debug` flag to see more output, which you can include when reporting bugs.
If you have any questions about developing or contributing, come hang out with us by joining the [CZI Science Slack](https://cziscience.slack.com/messages/CCTA8DF1T) and posting in the `#cellxgene-dev` channel.
## development roadmap
`cellxgene` is still very much in development, and we've love to include the community as we plan new features to work on. We are thinking about working on the following features over the next 3-12 months. If you are interested in updates, want to give feedback, want to contribute, or have ideas about other features we should work on, please [contact us](#help-and-contact)
- **Visualizaling spatial metadata** Image-based transcriptomics methods also generate large cell by gene matrices, alongside rich metadata about spatial location; we would like to render this information in `cellxgene`
- **Visualizing trajectories** Trajectory analyses infer progression along some ordering or pseudotime; we would like `cellxgene ` to render the results of these analyses when they have been performed
- **Deploy to web** Many projects release public data browser websites alongside their publicatons; we would like to make it easy for anyone to deploy `cellxgene` to a custom URL with their own dataset that they own and operate
- **HCA Integration** The [Human Cell Atlas](https://humancellatlas.org) is generating a large corpus of single-cell expression data and will make it available through the Data Coordination Platform; we would like `cellxgene` to be one of several different portals for browsing these data
## contributing
We warmly welcome contributions from the community! Please submit any bug reports and feature requests through [Github issues](https://github.com/chanzuckerberg/cellxgene/issues). Please submit any direct contributions by forking the repository, creating a branch, and submitting a Pull Request. It'd be great for PRs to include test cases and documentation updates where relevant, though we know the core test suite is itself still a work in progress. And all code contributions and dependencies must be compatible with the project's open-source license (MIT). If you have any questions about this stuff, just ask!
## inspiration and collaboration
We've been heavily inspired by several other related single-cell visualization projects, including the [UCSC Cell Browswer](http://cells.ucsc.edu/), [Cytoscape](http://www.cytoscape.org/), [Xena](https://xena.ucsc.edu/), [ASAP](https://asap.epfl.ch/), [Gene Pattern](http://genepattern-notebook.org/), and many others. We hope to explore collaborations where useful as this community works together on improving interactive visualization for single-cell data.
We were inspired by Mike Bostock and the [crossfilter](https://github.com/crossfilter) team for the design of our filtering implementation.
We have been working closely with the [scanpy](https://github.com/theislab/scanpy) team to integrate with their awesome analysis tools. Special thanks to Alex Wolf, Fabian Theis, and the rest of the team for their help during development and for providing an example dataset.
We have been working closely with the [`scanpy`](https://github.com/theislab/scanpy) team to integrate with their awesome analysis tools. Special thanks to Alex Wolf, Fabian Theis, and the rest of the team for their help during development and for providing an example dataset.
We are eager to explore integrations with other computational backends such as [Seurat](https://github.com/satijalab/seurat) or [Bioconductor](https://github.com/Bioconductor)
We are eager to explore integrations with other computational backends such as [`Seurat`](https://github.com/satijalab/seurat) or [`Bioconductor`](https://github.com/Bioconductor)
## help and contact
Have questions, suggestions, or comments? You can come hang out with us by joining the [CZI Science Slack](https://cziscience.slack.com/messages/CCTA8DF1T) and posting in the `#cellxgene-users` channel. As mentioned above, please submit any feature requests or bugs as [Github issues](https://github.com/chanzuckerberg/cellxgene/issues). We'd love to hear from you!
## reuse
This project was started with the sole goal of empowering the scientific community to explore and understand their data. As such, we encourage other scientific tool builders in academia or industry to adopt the patterns, tools, and code from this project, and reach out to us with ideas or questions. All code is freely available for reuse under the [MIT license](https://opensource.org/licenses/MIT).
-21
View File
@@ -1,21 +0,0 @@
{
"name": "cellxgene",
"description": "An interactive explorer for single-cell transcriptomics data",
"repository": "https://github.com/chanzuckerberg/cellxgene/",
"logo": "https://cellxgene-example-data.czi.technology/favicon.png",
"keywords": [
"scientific",
"visualization",
"scrna-seq",
"transcriptomics",
"dataviz"
],
"stack": "container",
"env": {
"DATASET": {
"description": "Link to dataset",
"value": "https://cellxgene-example-data.czi.technology/pbmc3k.h5ad",
"required": "true"
}
}
}
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )"
CELLXGENE_DIR=$(dirname $DIR)
cd $CELLXGENE_DIR
npm install --prefix client/ client
npm run --prefix client build
rm -rf server/app/web/static
mkdir -p server/app/web/static/img
cp client/build/index.html server/app/web/templates/
cp -r client/build/static server/app/web/
cp client/build/favicon.png server/app/web/static/img
cp client/build/service-worker.js server/app/web/static/js/
Executable
+18
View File
@@ -0,0 +1,18 @@
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )"
CELLXGENE_DIR=$(dirname $DIR)
echo "Uninstalling cellxgene"
yes | pip uninstall cellxgene
echo "removing node_modules"
rm -rf $CELLXGENE_DIR/client/node_modules
echo "removing client_build"
rm -rf $CELLXGENE_DIR/client/build
echo "removing dist"
rm -rf $CELLXGENE_DIR/dist
echo "removing egg-info"
rm -rf $CELLXGENE_DIR/cellxgene.egg-info
echo "removing static files"
rm -f $CELLXGENE_DIR/server/app/web/templates/index.html
rm -rf $CELLXGENE_DIR/server/app/web/static
echo "cellxgene cleanup complete"
-41
View File
@@ -1,41 +0,0 @@
# -*- mode: python ; coding: utf-8 -*-
block_cipher = None
a = Analysis(['server/gui/main.py'],
pathex=['/Users/charlotteweaver/Documents/Git/cellxgene'],
binaries=[('/System/Library/Frameworks/Tk.framework/Tk', 'tk'), ('/System/Library/Frameworks/Tcl.framework/Tcl', 'tcl')],
datas=[('server/app/web/templates/', 'server/app/web/templates/'), ('server/app/web/static/', 'server/app/web/static/')],
hiddenimports=['sklearn', 'sklearn.utils._cython_blas', 'sklearn.neighbors.typedefs', 'sklearn.neighbors.quad_tree', 'sklearn.tree', 'sklearn.tree._utils'],
hookspath=['server/gui/'],
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False)
pyz = PYZ(a.pure, a.zipped_data,
cipher=block_cipher)
exe = EXE(pyz,
a.scripts,
[],
exclude_binaries=True,
name='cellxgene',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False , icon='server/gui/images/cxg_icons.icns')
coll = COLLECT(exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='cellxgene')
app = BUNDLE(coll,
name='cellxgene.app',
icon='server/gui/images/cxg_icons.icns',
bundle_identifier=None)
-36
View File
@@ -1,36 +0,0 @@
# -*- mode: python -*-
block_cipher = None
a = Analysis(['server\\gui\\main.py'],
pathex=['C:\\Users\\Charlotte\\Documents\\git\\cellxgene'],
binaries=[],
datas=[('server/app/web/templates/', 'server/app/web/templates'), ('server/app/web/static/', 'server/app/web/static')],
hiddenimports=[],
hookspath=['server/gui/'],
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False)
pyz = PYZ(a.pure, a.zipped_data,
cipher=block_cipher)
exe = EXE(pyz,
a.scripts,
[],
exclude_binaries=True,
name='cellxgene',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False , icon='server\\gui\\images\\icon.ico')
coll = COLLECT(exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
name='cellxgene')
-20
View File
@@ -1,20 +0,0 @@
.PHONY: clean
clean:
rm -rf node_modules
.PHONY: ci
ci:
npm ci client
.PHONY: install
install:
npm install client
.PHONY: build
build:
npm run build
# pass remaining commands through to npm run
%:
npm run $(*)
-6
View File
@@ -1,6 +0,0 @@
export const jest_env = process.env.JEST_ENV || "dev";
export const appPort = process.env.JEST_CXG_PORT || 3000;
export const appUrlBase = `http://localhost:${appPort}`;
export const DEV = jest_env === "dev";
export const DEBUG = jest_env === "debug";
export const DATASET = "pbmc3k";
-128
View File
@@ -1,128 +0,0 @@
export const datasets = {
pbmc3k: {
title: "pbmc3k",
dataframe: {
nObs: "2638",
nVar: "1838",
type: "float32"
},
categorical: {
louvain: {
"B cells": "342",
"CD14+ Monocytes": "480",
"CD4 T cells": "1144",
"CD8 T cells": "316",
"Dendritic cells": "37",
"FCGR3A+ Monocytes": "150",
Megakaryocytes: "15",
"NK cells": "154"
}
},
continuous: {
n_genes: "int32",
percent_mito: "float32",
n_counts: "float32"
},
cellsets: {
lasso: [
{
"coordinates-as-percent": { x1: 0.1, y1: 0.25, x2: 0.7, y2: 0.75 },
count: "1181"
}
],
categorical: [
{
metadata: "louvain",
values: ["B cells", "Megakaryocytes"],
count: "357"
}
],
continuous: [
{
metadata: "n_genes",
"coordinates-as-percent": { x1: 0.25, y1: 0.5, x2: 0.55, y2: 0.5 },
count: "1552"
}
]
},
diffexp: {
cellset1: [
{ kind: "categorical", metadata: "louvain", values: ["B cells"] }
],
cellset2: [
{
kind: "categorical",
metadata: "louvain",
values: ["CD4 T cells", "NK cells"]
}
],
"gene-results": [
"HLA-DRB1",
"HLA-DPB1",
"CD79A",
"HLA-DPA1",
"HLA-DQA1",
"CD79B",
"HLA-DQB1",
"MS4A1",
"IL32",
"CD37"
]
},
genes: {
bulkadd: ["S100A8", "FCGR3A", "LGALS2", "GSTP1"],
search: "ACD"
},
subset: {
cellset1: [
{
kind: "categorical",
metadata: "louvain",
values: ["B cells", "Megakaryocytes"]
}
],
count: "357",
categorical: {
louvain: {
"B cells": "342",
"CD14+ Monocytes": "0",
"CD4 T cells": "0",
"CD8 T cells": "0",
"Dendritic cells": "0",
"FCGR3A+ Monocytes": "0",
Megakaryocytes: "15",
"NK cells": "0"
}
},
lasso: {
"coordinates-as-percent": { x1: 0.25, y1: 0.05, x2: 0.75, y2: 0.55 },
count: "329"
}
},
scatter: {
genes: { x: "S100A8", y: "FCGR3A" }
},
pan: {
"coordinates-as-percent": { x1: 0.75, y1: 0.75, x2: 0.35, y2: 0.35 }
},
features: {
panzoom: {
lasso: {
"coordinates-as-percent": { x1: 0.3, y1: 0.3, x2: 0.5, y2: 0.5 },
count: "24"
}
}
},
clip: {
min: "30",
max: "70",
metadata: "n_genes",
gene: "S100A8",
"coordinates-as-percent": { x1: 0.25, y1: 0.5, x2: 0.55, y2: 0.5 },
count: "392",
"gene-cell-count": "421"
}
}
};
-343
View File
@@ -1,343 +0,0 @@
/*
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
*/
import puppeteer from "puppeteer";
import { appUrlBase, DEBUG, DEV, DATASET } from "./config";
import { puppeteerUtils, cellxgeneActions } from "./puppeteerUtils";
import { datasets } from "./data";
let browser, page, utils, cxgActions, spy;
const browserViewport = { width: 1280, height: 960 };
let data = datasets[DATASET];
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", async msg => {
// If there is a console.error but an error is not thrown, this will ensure the test fails
if (msg.type() === "error") {
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}`);
});
utils = puppeteerUtils(page);
cxgActions = cellxgeneActions(page);
});
beforeEach(async () => {
await page.goto(appUrlBase);
});
afterAll(() => {
if (!DEBUG) {
browser.close();
}
});
describe("did launch", () => {
test("page launched", async () => {
let el = await utils.getOneElementInnerHTML("[data-testid='header']");
expect(el).toBe(data.title);
});
});
describe("metadata loads", () => {
test("categories and values from dataset appear", async () => {
for (const label in data.categorical) {
await utils.waitByID(`category-${label}`);
const categoryName = await utils.getOneElementInnerText(
`[data-testid="category-${label}"]`
);
expect(categoryName).toMatch(label);
await utils.clickOn(`category-expand-${label}`);
const categories = await cxgActions.getAllCategoriesAndCounts(label);
expect(Object.keys(categories)).toMatchObject(
Object.keys(data.categorical[label])
);
expect(Object.values(categories)).toMatchObject(
Object.values(data.categorical[label])
);
}
});
test("continuous data appears", async () => {
for (const label in data.continuous) {
await utils.waitByID(`histogram-${label}`);
}
});
});
describe("cell selection", () => {
test("selects all cells cellset 1", async () => {
const cellCount = await cxgActions.cellSet(1);
expect(cellCount).toBe(data.dataframe.nObs);
});
test("selects all cells cellset 2", async () => {
const cellCount = await cxgActions.cellSet(2);
expect(cellCount).toBe(data.dataframe.nObs);
});
test("selects cells via lasso", async () => {
for (const cellset of data.cellsets.lasso) {
const cellset1 = await cxgActions.calcDragCoordinates(
"layout-graph",
cellset["coordinates-as-percent"]
);
await cxgActions.drag("layout-graph", cellset1.start, cellset1.end, true);
const cellCount = await cxgActions.cellSet(1);
expect(cellCount).toBe(cellset.count);
}
});
test("selects cells via categorical", async () => {
for (const cellset of data.cellsets.categorical) {
await utils.clickOn(`category-expand-${cellset.metadata}`);
await utils.clickOn(`category-select-${cellset.metadata}`);
for (const val of cellset.values) {
await utils.clickOn(
`categorical-value-select-${cellset.metadata}-${val}`
);
}
const cellCount = await cxgActions.cellSet(1);
expect(cellCount).toBe(cellset.count);
}
});
test("selects cells via continuous", async () => {
for (const cellset of data.cellsets.continuous) {
const histBrushableAreaId = `histogram-${cellset.metadata}-plot-brushable-area`;
const coords = await cxgActions.calcDragCoordinates(
histBrushableAreaId,
cellset["coordinates-as-percent"]
);
await cxgActions.drag(histBrushableAreaId, coords.start, coords.end);
const cellCount = await cxgActions.cellSet(1);
expect(cellCount).toBe(cellset.count);
}
});
});
describe("gene entry", () => {
test("search for single gene", async () => {
// blueprint's typeahead is treating typing weird, clicking & waiting first solves this
await utils.typeInto("gene-search", data.genes.search);
await page.keyboard.press("Enter");
await page.waitForSelector(
`[data-testid='histogram-${data.genes.search}']`
);
});
test("bulk add genes", async () => {
await cxgActions.reset();
const testGenes = data.genes.bulkadd;
await utils.clickOn("section-bulk-add");
await utils.typeInto("input-bulk-add", testGenes.join(","));
await page.keyboard.press("Enter");
const allHistograms = await cxgActions.getAllHistograms(
"histogram-user-gene",
testGenes
);
expect(allHistograms).toEqual(expect.arrayContaining(testGenes));
expect(allHistograms.length).toEqual(testGenes.length);
});
});
describe("diffexp", () => {
test("selects cells, saves them and performs diffexp", async () => {
for (const select of data.diffexp.cellset1) {
if (select.kind === "categorical") {
await cxgActions.selectCategory(select.metadata, select.values, true);
}
}
await cxgActions.cellSet(1);
for (const select of data.diffexp.cellset2) {
if (select.kind === "categorical") {
await cxgActions.selectCategory(select.metadata, select.values, true);
}
}
await cxgActions.cellSet(2);
await utils.clickOn("diffexp-button");
const allHistograms = await cxgActions.getAllHistograms(
"histogram-diffexp",
data.diffexp["gene-results"]
);
expect(allHistograms).toEqual(
expect.arrayContaining(data.diffexp["gene-results"])
);
expect(allHistograms.length).toEqual(data.diffexp["gene-results"].length);
});
});
describe("subset/reset", () => {
test("subset - cell count matches", async () => {
for (const select of data.subset.cellset1) {
if (select.kind === "categorical") {
await cxgActions.selectCategory(select.metadata, select.values, true);
}
}
await utils.clickOn("subset-button");
for (const label in data.subset.categorical) {
const categories = await cxgActions.getAllCategoriesAndCounts(label);
expect(Object.keys(categories)).toMatchObject(
Object.keys(data.subset.categorical[label])
);
expect(Object.values(categories)).toMatchObject(
Object.values(data.subset.categorical[label])
);
}
});
test("reset after subset", async () => {
for (const select of data.subset.cellset1) {
if (select.kind === "categorical") {
await cxgActions.selectCategory(select.metadata, select.values, true);
}
}
await utils.clickOn("subset-button");
for (const label in data.subset.categorical) {
const categories = await cxgActions.getAllCategoriesAndCounts(label);
expect(Object.keys(categories)).toMatchObject(
Object.keys(data.subset.categorical[label])
);
expect(Object.values(categories)).toMatchObject(
Object.values(data.subset.categorical[label])
);
}
await cxgActions.reset();
for (const label in data.categorical) {
await utils.waitByID(`category-${label}`);
const categoryName = await utils.getOneElementInnerText(
`[data-testid="category-${label}"]`
);
expect(categoryName).toMatch(label);
const categories = await cxgActions.getAllCategoriesAndCounts(label);
expect(Object.keys(categories)).toMatchObject(
Object.keys(data.categorical[label])
);
expect(Object.values(categories)).toMatchObject(
Object.values(data.categorical[label])
);
}
});
test("lasso after subset", async () => {
for (const select of data.subset.cellset1) {
if (select.kind === "categorical") {
await cxgActions.selectCategory(select.metadata, select.values, true);
}
}
await utils.clickOn("subset-button");
const lassoSelection = await cxgActions.calcDragCoordinates(
"layout-graph",
data.subset.lasso["coordinates-as-percent"]
);
await cxgActions.drag(
"layout-graph",
lassoSelection.start,
lassoSelection.end,
true
);
const cellCount = await cxgActions.cellSet(1);
expect(cellCount).toBe(data.subset.lasso.count);
});
});
describe("scatter plot", () => {
test("scatter plot appears", async () => {
await cxgActions.reset();
const testGenes = data.scatter.genes;
await utils.clickOn("section-bulk-add");
await utils.typeInto("input-bulk-add", Object.values(testGenes).join(","));
await page.keyboard.press("Enter");
await utils.clickOn(`plot-x-${data.scatter.genes.x}`);
await utils.clickOn(`plot-y-${data.scatter.genes.y}`);
await utils.waitByID("scatterplot");
});
});
describe("clipping", () => {
test("clip continuous", async () => {
await cxgActions.clip(data.clip.min, data.clip.max);
const histBrushableAreaId = `histogram-${data.clip.metadata}-plot-brushable-area`;
const coords = await cxgActions.calcDragCoordinates(
histBrushableAreaId,
data.clip["coordinates-as-percent"]
);
await cxgActions.drag(histBrushableAreaId, coords.start, coords.end);
const cellCount = await cxgActions.cellSet(1);
expect(cellCount).toBe(data.clip.count);
});
test("clip gene", async () => {
await utils.typeInto("gene-search", data.clip.gene);
await page.keyboard.press("Enter");
await page.waitForSelector(`[data-testid='histogram-${data.clip.gene}']`);
await cxgActions.clip(data.clip.min, data.clip.max);
const histBrushableAreaId = `histogram-${data.clip.gene}-plot-brushable-area`;
const coords = await cxgActions.calcDragCoordinates(
histBrushableAreaId,
data.clip["coordinates-as-percent"]
);
await cxgActions.drag(histBrushableAreaId, coords.start, coords.end);
const cellCount = await cxgActions.cellSet(1);
expect(cellCount).toBe(data.clip["gene-cell-count"]);
});
});
// interact with UI elements just that they do not break
describe("ui elements don't error", () => {
test("color by", async () => {
for (const label in data.categorical) {
await utils.clickOn(`colorby-${label}`);
}
for (const label in data.continuous) {
await utils.clickOn(`colorby-${label}`);
}
});
test("color by for gene", async () => {
await utils.typeInto("gene-search", data.genes.search);
await page.keyboard.press("Enter");
await page.waitForSelector(
`[data-testid='histogram-${data.genes.search}']`
);
await utils.clickOn(`colorby-${data.genes.search}`);
});
test("pan and zoom", async () => {
await utils.clickOn("mode-pan-zoom");
const panCoords = await cxgActions.calcDragCoordinates(
"layout-graph",
data.pan["coordinates-as-percent"]
);
await cxgActions.drag(
"layout-graph",
panCoords.start,
panCoords.end,
false
);
await page.evaluate(`window.scrollBy(0, 1000);`);
});
});
-10
View File
@@ -1,10 +0,0 @@
{
"preset": "jest-puppeteer",
"testMatch": [
"**/__tests__/**/?(*.)(spec|test).js?(x)"
],
"testURL": "http://localhost/",
"setupFiles": [
"../setupMissingGlobals.js"
]
}
-118
View File
@@ -1,118 +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, page, utils, cxgActions, spy;
const browserViewport = { width: 1280, height: 960 };
let 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);
});
});
-211
View File
@@ -1,211 +0,0 @@
export const puppeteerUtils = puppeteerPage => ({
async waitByID(testid, props = {}) {
return await puppeteerPage.waitForSelector(
`[data-testid='${testid}']`,
props
);
},
async waitByClass(testclass, props = {}) {
return await puppeteerPage.waitForSelector(
`[data-testclass='${testclass}']`,
props
);
},
async waitForAllByIds(testids, props = {}) {
await Promise.all(
testids.map(testid =>
puppeteerPage.waitForSelector(`[data-testid='${testid}']`)
)
);
},
async getAllByClass(testclass, props = {}) {
const elements = await puppeteerPage.$$eval(
`[data-testclass=${testclass}]`,
els => {
return els.map(el => {
return el.dataset.testid;
});
}
);
return elements;
},
async typeInto(testid, text) {
// only works for text without special characters
await this.waitByID(testid);
const selector = `[data-testid='${testid}']`;
// type ahead can be annoying if you don't pause before you type
await puppeteerPage.click(selector);
await puppeteerPage.waitFor(200);
await puppeteerPage.type(selector, text);
},
async clearInputAndTypeInto(testid, text) {
await this.waitByID(testid);
const selector = `[data-testid='${testid}']`;
// only works for text without special characters
// type ahead can be annoying if you don't pause before you type
await puppeteerPage.click(selector);
await puppeteerPage.waitFor(200);
// select all
await puppeteerPage.click(selector, { clickCount: 3 });
await puppeteerPage.keyboard.type("Backspace");
await puppeteerPage.type(selector, text);
},
async clickOn(testid) {
await this.waitByID(testid);
await puppeteerPage.click(`[data-testid='${testid}']`);
await puppeteerPage.waitFor(50);
},
async getOneElementInnerHTML(selector) {
await puppeteerPage.waitForSelector(selector);
let text = await puppeteerPage.$eval(selector, el => el.innerHTML);
return text;
},
async getOneElementInnerText(selector) {
await puppeteerPage.waitForSelector(selector);
let text = await puppeteerPage.$eval(selector, el => el.innerText);
return text;
}
});
export const cellxgeneActions = puppeteerPage => ({
async drag(testid, start, end, lasso = false) {
const layout = await puppeteerUtils(puppeteerPage).waitByID(testid);
const elBox = await layout.boxModel();
const x1 = elBox.content[0].x + start.x;
const x2 = elBox.content[0].x + end.x;
const y1 = elBox.content[0].y + start.y;
const y2 = elBox.content[0].y + end.y;
await puppeteerPage.mouse.move(x1, y1);
await puppeteerPage.mouse.down();
if (lasso) {
await puppeteerPage.mouse.move(x2, y1);
await puppeteerPage.mouse.move(x2, y2);
await puppeteerPage.mouse.move(x1, y2);
await puppeteerPage.mouse.move(x1, y1);
} else {
await puppeteerPage.mouse.move(x2, y2);
}
await puppeteerPage.mouse.up();
},
async getAllHistograms(testclass, testids) {
const histTestIds = testids.map(tid => `histogram-${tid}`);
// these load asynchronously, so we need to wait for each histogram individually
await puppeteerUtils(puppeteerPage).waitForAllByIds(histTestIds);
const allHistograms = await puppeteerUtils(puppeteerPage).getAllByClass(
testclass
);
return allHistograms.map(hist =>
hist.substr("histogram_".length, hist.length)
);
},
async getAllCategoriesAndCounts(category) {
await puppeteerUtils(puppeteerPage).waitByClass("categorical-row");
const categories = await puppeteerPage.$$eval(
`[data-testid="category-${category}"] [data-testclass='categorical-row']`,
els => {
let result = {};
els.forEach(el => {
const cat = el.querySelector("[data-testclass='categorical-value']")
.innerText;
const count = el.querySelector(
"[data-testclass='categorical-value-count']"
).innerText;
result[cat] = count;
});
return result;
}
);
return categories;
},
async cellSet(num) {
await puppeteerUtils(puppeteerPage).clickOn(`cellset-button-${num}`);
return await puppeteerUtils(puppeteerPage).getOneElementInnerText(
`[data-testid='cellset-count-${num}']`
);
},
async resetCategory(category) {
const checkboxId = `category-select-${category}`;
await puppeteerUtils(puppeteerPage).waitByID(checkboxId);
const checkedPseudoclass = await puppeteerPage.$eval(
`[data-testid='${checkboxId}']`,
el => {
return el.matches(":checked");
}
);
if (!checkedPseudoclass) {
await puppeteerUtils(puppeteerPage).clickOn(checkboxId);
}
try {
const categoryRow = await puppeteerUtils(puppeteerPage).waitByID(
`category-expand-${category}`
);
const isExpanded = await categoryRow.$(
"[data-testclass='category-expand-is-expanded']"
);
if (isExpanded) {
await puppeteerUtils(puppeteerPage).clickOn(
`category-expand-${category}`
);
}
} catch {}
},
async calcDragCoordinates(testid, coordinateAsPercent) {
const el = await puppeteerUtils(puppeteerPage).waitByID(testid);
const size = await el.boxModel();
const coords = {
start: {
x: Math.floor(size.width * coordinateAsPercent.x1),
y: Math.floor(size.height * coordinateAsPercent.y1)
},
end: {
x: Math.floor(size.width * coordinateAsPercent.x2),
y: Math.floor(size.height * coordinateAsPercent.y2)
}
};
return coords;
},
async selectCategory(category, values, reset = true) {
if (reset) await this.resetCategory(category);
await puppeteerUtils(puppeteerPage).clickOn(`category-expand-${category}`);
await puppeteerUtils(puppeteerPage).clickOn(`category-select-${category}`);
for (const val of values) {
await puppeteerUtils(puppeteerPage).clickOn(
`categorical-value-select-${category}-${val}`
);
}
},
async reset() {
await puppeteerUtils(puppeteerPage).clickOn("reset");
// loading state never actually happens, reset is too fast
await page.waitFor(200);
},
async clip(min = 0, max = 100) {
await puppeteerUtils(puppeteerPage).clickOn("visualization-settings");
await puppeteerUtils(puppeteerPage).clearInputAndTypeInto(
"clip-min-input",
min
);
await puppeteerUtils(puppeteerPage).clearInputAndTypeInto(
"clip-max-input",
max
);
await puppeteerUtils(puppeteerPage).clickOn("clip-commit");
}
});
-48
View File
@@ -1,48 +0,0 @@
import cascadeReducers from "../../src/reducers/cascade";
describe("create", () => {
test("from Array", () => {
expect(cascadeReducers([["foo", () => 0]])).toBeInstanceOf(Function);
});
test("from Map", () => {
expect(cascadeReducers(new Map([["foo", () => 0]]))).toBeInstanceOf(
Function
);
});
});
describe("cascade", () => {
test("expected arguments provided & cascade ordering", () => {
const topLevelState = {};
const topLevelAction = { type: "test" };
const reducer = cascadeReducers([
[
"foo",
(currentState, action, nextSharedState, prevSharedState) => {
expect(currentState).toBeUndefined();
expect(action).toEqual(topLevelAction);
expect(nextSharedState).toStrictEqual({});
expect(prevSharedState).toBe(topLevelState);
return 0;
}
],
[
"bar",
(currentState, action, nextSharedState, prevSharedState) => {
expect(currentState).toBeUndefined();
expect(action).toEqual(topLevelAction);
expect(nextSharedState).toStrictEqual({ foo: 0 });
expect(prevSharedState).toBe(topLevelState);
return 99;
}
]
]);
const nextState = reducer(topLevelState, topLevelAction);
expect(nextState).toStrictEqual({ foo: 0, bar: 99 });
expect(topLevelState).toStrictEqual({});
expect(topLevelAction).toStrictEqual({ type: "test" });
});
});
@@ -1,87 +0,0 @@
import undoable from "../../src/reducers/undoable";
describe("create", () => {
test("no keys", () => {
expect(() => undoable(() => {})).toThrow();
expect(() => undoable(() => {}, null)).toThrow();
expect(() => undoable(() => {}, [])).toThrow();
expect(() => undoable(() => {}, [], {})).toThrow();
});
test("simple", () => {
expect(undoable(() => {}, ["foo"])).toBeInstanceOf(Function);
expect(undoable(() => {}, ["foo"], {})).toBeInstanceOf(Function);
});
test("handles undefined initial state", () => {
expect(
undoable(() => {}, ["a"])(undefined, { type: "test" })
).toMatchObject({});
});
});
describe("undo", () => {
test("expected state modifications", () => {
const initialState = { a: 0, b: 1000 };
const reducer = state => {
return { a: state.a + 1, b: state.b + 1 };
};
const undoableReducer = undoable(reducer, ["a"]);
const s1 = undoableReducer(initialState, { type: "test" });
expect(s1).toMatchObject({ a: 1, b: 1001 });
// test that only specified keys are undone
const s2 = undoableReducer(s1, { type: "@@undoable/undo" });
expect(s2).toMatchObject({ a: 0, b: 1001 });
// test backstop when no more history
const s3 = undoableReducer(s2, { type: "@@undoable/undo" });
expect(s3).toMatchObject({ a: 0, b: 1001 });
});
});
describe("redo", () => {
const initialState = { a: 0, b: 1000 };
const reducer = state => {
return { a: state.a + 1, b: state.b + 1 };
};
let UR;
beforeEach(() => {
UR = undoable(reducer, ["a"]);
});
test("expected state modifications", () => {
const s1 = UR(initialState, { type: "test" });
expect(s1).toMatchObject({ a: 1, b: 1001 });
// verify undo->redo reverts state.
const s2 = UR(UR(s1, { type: "@@undoable/undo" }), {
type: "@@undoable/redo"
});
expect(s2).toMatchObject({ a: 1, b: 1001 });
// verify backstop when no redo future
const s3 = UR(s2, { type: "@@undoable/redo" });
expect(s3).toMatchObject({ a: 1, b: 1001 });
});
test("history cleared", () => {
// verify future cleared upon a normal state transition
const s1 = UR(initialState, { type: "test" });
expect(s1).toMatchObject({ a: 1, b: 1001 });
const s2 = UR(s1, { type: "@@undoable/undo" });
expect(s2).toMatchObject({ a: 0, b: 1001 });
const s3 = UR(s2, { type: "test" });
expect(s3).toMatchObject({ a: 1, b: 1002 });
const s4 = UR(s3, { type: "@@undoable/redo" });
expect(s4).toMatchObject({ a: 1, b: 1002 });
});
});
/*
TODO:
- historyLimit is enforced
- action filters
*/
-9
View File
@@ -1,9 +0,0 @@
/*
Define globals which are present in the client, but not in node (and therefore not in
the jest test environment).
*/
import { TextDecoder, TextEncoder } from "util";
global.TextDecoder = TextDecoder;
global.TextEncoder = TextEncoder;
@@ -1,731 +0,0 @@
import * as Dataframe from "../../../src/util/dataframe";
describe("dataframe constructor", () => {
test("empty dataframe", () => {
const df = new Dataframe.Dataframe([0, 0], []);
expect(df).toBeDefined();
expect(df.dims).toEqual([0, 0]);
expect(df).toHaveLength(0);
expect(df.icol(0)).not.toBeDefined();
});
test("create with default indices", () => {
const df = new Dataframe.Dataframe(
[3, 2],
[new Int32Array(3).fill(0), new Int32Array(3).fill(1)]
);
expect(df).toBeDefined();
expect(df.dims).toEqual([3, 2]);
expect(df.rowIndex).toBeInstanceOf(Dataframe.IdentityInt32Index);
expect(df.colIndex).toBeInstanceOf(Dataframe.IdentityInt32Index);
expect(df.at(0, 0)).toEqual(0);
expect(df.at(2, 1)).toEqual(1);
expect(df.iat(0, 0)).toEqual(0);
expect(df.iat(2, 1)).toEqual(1);
});
test("create with labelled indices", () => {
const df = new Dataframe.Dataframe(
[3, 2],
[new Int32Array([0, 1, 2]), new Int32Array([3, 4, 5])],
new Dataframe.DenseInt32Index([2, 1, 0]),
new Dataframe.KeyIndex(["A", "B"])
);
expect(df).toBeDefined();
expect(df.dims).toEqual([3, 2]);
expect(df.rowIndex).toBeInstanceOf(Dataframe.DenseInt32Index);
expect(df.colIndex).toBeInstanceOf(Dataframe.KeyIndex);
expect(df.rowIndex.keys()).toEqual(new Int32Array([2, 1, 0]));
expect(df.colIndex.keys()).toEqual(["A", "B"]);
expect(df.at(0, "A")).toEqual(2);
expect(df.at(2, "B")).toEqual(3);
expect(df.iat(0, 0)).toEqual(0);
expect(df.iat(2, 1)).toEqual(5);
});
});
describe("simple data access", () => {
const df = new Dataframe.Dataframe(
[4, 2],
[
new Float64Array([0.0, Number.NaN, Number.POSITIVE_INFINITY, 3.14159]),
["red", "blue", "green", "nan"]
],
new Dataframe.DenseInt32Index([3, 2, 1, 0]),
new Dataframe.KeyIndex(["numbers", "colors"])
);
test("iat", () => {
expect(df).toBeDefined();
// present
expect(df.iat(0, 0)).toEqual(0.0);
expect(df.iat(0, 1)).toEqual("red");
expect(df.iat(1, 0)).toEqual(Number.NaN);
expect(df.iat(1, 1)).toEqual("blue");
expect(df.iat(2, 0)).toEqual(Number.POSITIVE_INFINITY);
expect(df.iat(2, 1)).toEqual("green");
expect(df.iat(3, 0)).toEqual(3.14159);
expect(df.iat(3, 1)).toEqual("nan");
// labels out of range have no defined behavior
});
test("at", () => {
expect(df).toBeDefined();
// present
expect(df.at(3, "numbers")).toEqual(0.0);
expect(df.at(3, "colors")).toEqual("red");
expect(df.at(2, "numbers")).toEqual(Number.NaN);
expect(df.at(2, "colors")).toEqual("blue");
expect(df.at(1, "numbers")).toEqual(Number.POSITIVE_INFINITY);
expect(df.at(1, "colors")).toEqual("green");
expect(df.at(0, "numbers")).toEqual(3.14159);
expect(df.at(0, "colors")).toEqual("nan");
// labels out of range have no defined behavior
});
test("ihas", () => {
expect(df).toBeDefined();
// present
expect(df.ihas(0, 0)).toBeTruthy();
expect(df.ihas(1, 1)).toBeTruthy();
expect(df.ihas(3, 1)).toBeTruthy();
// not present
expect(df.ihas(-1, -1)).toBeFalsy();
expect(df.ihas(0, 99)).toBeFalsy();
expect(df.ihas(99, 0)).toBeFalsy();
expect(df.ihas(99, 99)).toBeFalsy();
expect(df.ihas(-1, 0)).toBeFalsy();
expect(df.ihas(0, -1)).toBeFalsy();
});
test("has", () => {
expect(df).toBeDefined();
// present
expect(df.has(3, "numbers")).toBeTruthy();
expect(df.has(0, "numbers")).toBeTruthy();
expect(df.has(3, "colors")).toBeTruthy();
expect(df.has(0, "colors")).toBeTruthy();
// not present
expect(df.has(3, "foo")).toBeFalsy();
expect(df.has(-1, "numbers")).toBeFalsy();
expect(df.has(-1, -1)).toBeFalsy();
expect(df.has(null, null)).toBeFalsy();
expect(df.has(0, "foo")).toBeFalsy();
expect(df.has(99, "numbers")).toBeFalsy();
expect(df.has(99, "foo")).toBeFalsy();
});
});
describe("dataframe subsetting", () => {
describe("subset", () => {
const sourceDf = new Dataframe.Dataframe(
[3, 4],
[
new Int32Array([0, 1, 2]),
["A", "B", "C"],
new Float32Array([4.4, 5.5, 6.6]),
["red", "green", "blue"]
],
null,
new Dataframe.KeyIndex(["int32", "string", "float32", "colors"])
);
test("all rows, one column", () => {
const dfA = sourceDf.subset(null, ["colors"]);
expect(dfA).toBeDefined();
expect(dfA.dims).toEqual([3, 1]);
expect(dfA.iat(0, 0)).toEqual("red");
expect(dfA.at(2, "colors")).toEqual("blue");
expect(dfA.col("colors").asArray()).toEqual(["red", "green", "blue"]);
expect(dfA.icol(0).asArray()).toEqual(["red", "green", "blue"]);
expect(dfA.col("colors").asArray()).toEqual(
sourceDf.col("colors").asArray()
);
expect(dfA.rowIndex.keys()).toEqual(sourceDf.rowIndex.keys());
expect(dfA.colIndex.keys()).toEqual(["colors"]);
});
test("all rows, two columns", () => {
const dfB = sourceDf.subset(null, ["colors", "float32"]);
expect(dfB).toBeDefined();
expect(dfB.dims).toEqual([3, 2]);
expect(dfB.iat(0, 0)).toBeCloseTo(4.4);
expect(dfB.iat(0, 1)).toEqual("red");
expect(dfB.at(2, "colors")).toEqual("blue");
expect(dfB.at(2, "float32")).toBeCloseTo(6.6);
expect(dfB.col("colors").asArray()).toEqual(["red", "green", "blue"]);
expect(dfB.col("float32").asArray()).toEqual(
new Float32Array([4.4, 5.5, 6.6])
);
expect(dfB.icol(0).asArray()).toEqual(dfB.col("float32").asArray());
expect(dfB.icol(1).asArray()).toEqual(dfB.col("colors").asArray());
expect(dfB.col("colors").asArray()).toEqual(
sourceDf.col("colors").asArray()
);
expect(dfB.col("float32").asArray()).toEqual(
sourceDf.col("float32").asArray()
);
expect(dfB.rowIndex.keys()).toEqual(sourceDf.rowIndex.keys());
expect(dfB.colIndex.keys()).toEqual(["float32", "colors"]);
});
test("one row, all columns", () => {
const dfC = sourceDf.subset([1], null);
expect(dfC).toBeDefined();
expect(dfC.dims).toEqual([1, 4]);
expect(dfC.iat(0, 0)).toEqual(1);
expect(dfC.iat(0, 1)).toEqual("B");
expect(dfC.iat(0, 2)).toBeCloseTo(5.5);
expect(dfC.iat(0, 3)).toEqual("green");
expect(dfC.rowIndex.keys()).toEqual(new Int32Array([1]));
expect(dfC.colIndex.keys()).toEqual(sourceDf.colIndex.keys());
});
test("two rows, all columns", () => {
const dfD = sourceDf.subset([0, 2], null);
expect(dfD).toBeDefined();
expect(dfD.dims).toEqual([2, 4]);
expect(dfD.icol(0).asArray()).toEqual(new Int32Array([0, 2]));
expect(dfD.icol(1).asArray()).toEqual(["A", "C"]);
expect(dfD.icol(2).asArray()).toEqual(new Float32Array([4.4, 6.6]));
expect(dfD.icol(3).asArray()).toEqual(["red", "blue"]);
expect(dfD.rowIndex.keys()).toEqual(new Int32Array([0, 2]));
expect(dfD.colIndex.keys()).toEqual(sourceDf.colIndex.keys());
});
test("all rows, all columns", () => {
const dfE = sourceDf.subset(null, null);
expect(dfE).toBeDefined();
expect(dfE.dims).toEqual([3, 4]);
expect(dfE.icol(0).asArray()).toEqual(sourceDf.icol(0).asArray());
expect(dfE.icol(1).asArray()).toEqual(sourceDf.icol(1).asArray());
expect(dfE.icol(2).asArray()).toEqual(sourceDf.icol(2).asArray());
expect(dfE.icol(3).asArray()).toEqual(sourceDf.icol(3).asArray());
expect(dfE.rowIndex.keys()).toEqual(sourceDf.rowIndex.keys());
expect(dfE.colIndex.keys()).toEqual(sourceDf.colIndex.keys());
});
test("two rows, two colums", () => {
const dfF = sourceDf.subset([0, 2], ["int32", "float32"]);
expect(dfF).toBeDefined();
expect(dfF.dims).toEqual([2, 2]);
expect(dfF.icol(0).asArray()).toEqual(new Int32Array([0, 2]));
expect(dfF.icol(1).asArray()).toEqual(new Float32Array([4.4, 6.6]));
expect(dfF.rowIndex.keys()).toEqual(new Int32Array([0, 2]));
expect(dfF.colIndex.keys()).toEqual(["int32", "float32"]);
});
test("withRowIndex", () => {
const df = sourceDf.subset(
null,
["int32", "float32"],
new Dataframe.DenseInt32Index([3, 2, 1])
);
expect(df.colIndex).toBeInstanceOf(Dataframe.KeyIndex);
expect(df.rowIndex).toBeInstanceOf(Dataframe.DenseInt32Index);
expect(df.at(3, "int32")).toEqual(df.iat(0, 0));
});
test("withRowIndex error checks", () => {
expect(() =>
sourceDf.subset(null, ["red"], new Dataframe.IdentityInt32Index(1))
).toThrow(RangeError);
expect(() =>
sourceDf.subset(null, ["red"], new Dataframe.DenseInt32Index([0, 1]))
).toThrow(RangeError);
expect(() =>
sourceDf.subset(null, ["red"], new Dataframe.KeyIndex([0, 1, 2, 3]))
).toThrow(RangeError);
});
});
test("isubsetMask", () => {
const sourceDf = new Dataframe.Dataframe(
[3, 4],
[
new Int32Array([0, 1, 2]),
["A", "B", "C"],
new Float32Array([4.4, 5.5, 6.6]),
["red", "green", "blue"]
],
new Dataframe.DenseInt32Index([2, 4, 6]),
new Dataframe.KeyIndex(["int32", "string", "float32", "colors"])
);
const dfA = sourceDf.isubsetMask(
new Uint8Array([0, 1, 1]),
new Uint8Array([1, 0, 0, 1])
);
expect(dfA.dims).toEqual([2, 2]);
expect(dfA.icol(0).asArray()).toEqual(new Int32Array([1, 2]));
expect(dfA.icol(1).asArray()).toEqual(["green", "blue"]);
expect(dfA.rowIndex.keys()).toEqual(new Int32Array([4, 6]));
expect(dfA.colIndex.keys()).toEqual(["int32", "colors"]);
});
});
describe("dataframe factories", () => {
test("create", () => {
const df = Dataframe.Dataframe.create(
[3, 3],
[
new Array(3).fill(0),
new Int16Array(3).fill(99),
new Float64Array(3).fill(1.1)
]
);
expect(df).toBeDefined();
expect(df.dims).toEqual([3, 3]);
expect(df).toHaveLength(3);
expect(df.iat(0, 0)).toEqual(0);
expect(df.iat(1, 1)).toEqual(99);
expect(df.iat(2, 2)).toBeCloseTo(1.1);
expect(df.iat(0, 0)).toEqual(df.at(0, 0));
expect(df.iat(1, 1)).toEqual(df.at(1, 1));
expect(df.iat(2, 2)).toEqual(df.at(2, 2));
});
test("clone", () => {
const dfA = new Dataframe.Dataframe(
[3, 2],
[new Int32Array([0, 1, 2]), new Int32Array([3, 4, 5])],
new Dataframe.DenseInt32Index([2, 1, 0]),
new Dataframe.KeyIndex(["A", "B"])
);
const dfB = dfA.clone();
expect(dfB).not.toBe(dfA);
expect(dfB.dims).toEqual(dfA.dims);
expect(dfB).toHaveLength(dfA.length);
expect(dfB.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
expect(dfB.colIndex.keys()).toEqual(dfA.colIndex.keys());
for (let i = 0, l = dfB.dims[1]; i < l; i += 1) {
expect(dfB.icol(i).asArray()).toEqual(dfA.icol(i).asArray());
}
});
describe("withCol", () => {
test("KeyIndex", () => {
const df = new Dataframe.Dataframe(
[2, 2],
[["red", "blue"], [true, false]],
null,
new Dataframe.KeyIndex(["colors", "bools"])
);
const dfA = df.withCol("numbers", [1, 0]);
expect(dfA).toBeDefined();
expect(dfA.dims).toEqual([2, 3]);
expect(dfA.icol(0).asArray()).toEqual(["red", "blue"]);
expect(dfA.icol(1).asArray()).toEqual([true, false]);
expect(dfA.icol(2).asArray()).toEqual([1, 0]);
expect(dfA.col("numbers").asArray()).toEqual([1, 0]);
expect(dfA.colIndex.keys()).toEqual(["colors", "bools", "numbers"]);
expect(df.colIndex.keys()).toEqual(["colors", "bools"]);
expect(df.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
});
test("DenseInt32Index", () => {
const df = new Dataframe.Dataframe(
[2, 2],
[["red", "blue"], [true, false]],
null,
new Dataframe.DenseInt32Index([74, 75])
);
const dfA = df.withCol(72, [1, 0]);
expect(dfA).toBeDefined();
expect(dfA.dims).toEqual([2, 3]);
expect(dfA.icol(0).asArray()).toEqual(["red", "blue"]);
expect(dfA.icol(1).asArray()).toEqual([true, false]);
expect(dfA.icol(2).asArray()).toEqual([1, 0]);
expect(dfA.col(74).asArray()).toEqual(["red", "blue"]);
expect(dfA.col(75).asArray()).toEqual([true, false]);
expect(dfA.col(72).asArray()).toEqual([1, 0]);
expect(dfA.colIndex.keys()).toEqual(new Int32Array([74, 75, 72]));
expect(df.colIndex.keys()).toEqual(new Int32Array([74, 75]));
expect(df.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
});
test("DenseInt32Index promote", () => {
const df = new Dataframe.Dataframe(
[2, 2],
[["red", "blue"], [true, false]],
null,
new Dataframe.DenseInt32Index([74, 75])
);
const dfA = df.withCol(999, [1, 0]);
expect(dfA).toBeDefined();
expect(dfA.dims).toEqual([2, 3]);
expect(dfA.icol(0).asArray()).toEqual(["red", "blue"]);
expect(dfA.icol(1).asArray()).toEqual([true, false]);
expect(dfA.icol(2).asArray()).toEqual([1, 0]);
expect(dfA.col(74).asArray()).toEqual(["red", "blue"]);
expect(dfA.col(75).asArray()).toEqual([true, false]);
expect(dfA.col(999).asArray()).toEqual([1, 0]);
expect(dfA.colIndex.keys()).toEqual(new Int32Array([74, 75, 999]));
expect(df.colIndex.keys()).toEqual(new Int32Array([74, 75]));
expect(df.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
});
test("IdentityInt32Index with last", () => {
const df = new Dataframe.Dataframe(
[2, 2],
[["red", "blue"], [true, false]],
null,
null
);
const dfA = df.withCol(2, [1, 0]);
expect(dfA).toBeDefined();
expect(dfA.dims).toEqual([2, 3]);
expect(dfA.icol(0).asArray()).toEqual(["red", "blue"]);
expect(dfA.icol(1).asArray()).toEqual([true, false]);
expect(dfA.icol(2).asArray()).toEqual([1, 0]);
expect(dfA.col(0).asArray()).toEqual(["red", "blue"]);
expect(dfA.col(1).asArray()).toEqual([true, false]);
expect(dfA.col(2).asArray()).toEqual([1, 0]);
expect(dfA.colIndex.keys()).toEqual(new Int32Array([0, 1, 2]));
expect(df.colIndex.keys()).toEqual(new Int32Array([0, 1]));
expect(df.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
});
test("IdentityInt32Index promote", () => {
const df = new Dataframe.Dataframe(
[2, 2],
[["red", "blue"], [true, false]],
null,
null
);
const dfA = df.withCol(99, [1, 0]);
expect(dfA).toBeDefined();
expect(dfA.dims).toEqual([2, 3]);
expect(dfA.icol(0).asArray()).toEqual(["red", "blue"]);
expect(dfA.icol(1).asArray()).toEqual([true, false]);
expect(dfA.icol(2).asArray()).toEqual([1, 0]);
expect(dfA.col(0).asArray()).toEqual(["red", "blue"]);
expect(dfA.col(1).asArray()).toEqual([true, false]);
expect(dfA.col(99).asArray()).toEqual([1, 0]);
expect(dfA.colIndex.keys()).toEqual(new Int32Array([0, 1, 99]));
expect(df.colIndex.keys()).toEqual(new Int32Array([0, 1]));
expect(df.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
});
describe("handle column dimensions correctly", () => {
/*
there are two conditions:
- empty dataframe - will accept an add of any dimensionality
- non-empty dataframe - added column must match row-count dimension
*/
test("empty.withCol", () => {
const edf = Dataframe.Dataframe.empty();
const df = edf.withCol("foo", [1, 2, 3]);
expect(edf).toBeDefined();
expect(df).toBeDefined();
expect(edf).not.toEqual(df);
expect(df.dims).toEqual([3, 1]);
expect(df.icol(0).asArray()).toEqual([1, 2, 3]);
});
test("withCol dimension check", () => {
const dfA = new Dataframe.Dataframe([1, 1], [["a"]]);
expect(() => {
dfA.withCol(1, []);
}).toThrow(RangeError);
});
});
});
describe("withColsFrom", () => {
test("error conditions", () => {
/*
make sure we catch common errors:
- duplicate column names
- dimensionality difference
*/
const dfA = new Dataframe.Dataframe(
[2, 3],
[["red", "blue"], [true, false], [1, 0]],
null,
new Dataframe.KeyIndex(["colors", "bools", "numbers"])
);
/* different dimensionality should throw error */
const dfB = new Dataframe.Dataframe(
[3, 1],
[["red", "blue", "green"]],
null,
new Dataframe.KeyIndex(["colorsA"])
);
expect(() => dfA.withColsFrom(dfB)).toThrow(RangeError);
/* duplicate labels should throw an error */
expect(() => dfA.withColsFrom(dfA)).toThrow(Error);
});
test("simple", () => {
/* simple test that it works as expected in common case */
const dfA = new Dataframe.Dataframe(
[2, 1],
[["red", "blue"]],
null,
new Dataframe.KeyIndex(["colors"])
);
const dfB = new Dataframe.Dataframe(
[2, 1],
[[true, false]],
null,
new Dataframe.KeyIndex(["bools"])
);
const dfC = dfA.withColsFrom(dfB);
expect(dfC).toBeDefined();
expect(dfC.dims).toEqual([2, 2]);
expect(dfC.colIndex.keys()).toEqual(["colors", "bools"]);
expect(dfC.rowIndex).toEqual(dfA.rowIndex);
expect(dfC.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
expect(dfC.col("colors").asArray()).toEqual(["red", "blue"]);
expect(dfC.col("bools").asArray()).toEqual([true, false]);
});
});
describe("dropCol", () => {
test("KeyIndex", () => {
const df = new Dataframe.Dataframe(
[2, 3],
[["red", "blue"], [true, false], [1, 0]],
null,
new Dataframe.KeyIndex(["colors", "bools", "numbers"])
);
const dfA = df.dropCol("colors");
expect(dfA).toBeDefined();
expect(dfA.dims).toEqual([2, 2]);
expect(dfA.icol(0).asArray()).toEqual([true, false]);
expect(dfA.icol(1).asArray()).toEqual([1, 0]);
expect(dfA.col("numbers").asArray()).toEqual([1, 0]);
expect(dfA.colIndex.keys()).toEqual(["bools", "numbers"]);
expect(df.colIndex.keys()).toEqual(["colors", "bools", "numbers"]);
expect(df.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
});
test("IdentityInt32Index drop first", () => {
const df = new Dataframe.Dataframe(
[2, 3],
[["red", "blue"], [true, false], [1, 0]],
null,
null
);
const dfA = df.dropCol(0);
expect(dfA).toBeDefined();
expect(dfA.dims).toEqual([2, 2]);
expect(dfA.icol(0).asArray()).toEqual([true, false]);
expect(dfA.icol(1).asArray()).toEqual([1, 0]);
expect(df.col(1).asArray()).toEqual(dfA.col(1).asArray());
expect(df.col(2).asArray()).toEqual(dfA.col(2).asArray());
expect(dfA.colIndex.keys()).toEqual(new Int32Array([1, 2]));
expect(df.colIndex.keys()).toEqual(new Int32Array([0, 1, 2]));
expect(df.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
});
test("IdentityInt32Index drop last", () => {
const df = new Dataframe.Dataframe(
[2, 3],
[["red", "blue"], [true, false], [1, 0]],
null,
null
);
const dfA = df.dropCol(2);
expect(dfA).toBeDefined();
expect(dfA.dims).toEqual([2, 2]);
expect(dfA.icol(0).asArray()).toEqual(["red", "blue"]);
expect(dfA.icol(1).asArray()).toEqual([true, false]);
expect(df.col(0).asArray()).toEqual(dfA.col(0).asArray());
expect(df.col(1).asArray()).toEqual(dfA.col(1).asArray());
expect(dfA.colIndex.keys()).toEqual(new Int32Array([0, 1]));
expect(df.colIndex.keys()).toEqual(new Int32Array([0, 1, 2]));
expect(df.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
});
test("DenseInt32Index", () => {
const df = new Dataframe.Dataframe(
[2, 3],
[["red", "blue"], [true, false], [1, 0]],
null,
new Dataframe.DenseInt32Index([102, 101, 100])
);
const dfA = df.dropCol(101);
expect(dfA).toBeDefined();
expect(dfA.dims).toEqual([2, 2]);
expect(dfA.icol(0).asArray()).toEqual(["red", "blue"]);
expect(dfA.icol(1).asArray()).toEqual([1, 0]);
expect(dfA.col(100).asArray()).toEqual([1, 0]);
expect(dfA.col(102).asArray()).toEqual(["red", "blue"]);
expect(dfA.colIndex.keys()).toEqual(new Int32Array([102, 100]));
expect(df.colIndex.keys()).toEqual(new Int32Array([102, 101, 100]));
expect(df.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
});
});
describe("mapColumns", () => {
test("identity", () => {
const dfA = Dataframe.Dataframe.create(
[3, 3],
[
new Array(3).fill(0),
new Int16Array(3).fill(99),
new Float64Array(3).fill(1.1)
]
);
const dfB = dfA.mapColumns((col, idx) => {
expect(dfA.icol(idx).asArray()).toBe(col);
return col;
});
expect(dfA).not.toBe(dfB);
expect(dfA.dims).toEqual(dfB.dims);
for (let c = 0; c < dfA.dims[1]; c += 1) {
expect(dfA.icol(c).asArray()).toBe(dfB.icol(c).asArray());
}
});
test("transform", () => {
const dfA = Dataframe.Dataframe.create(
[3, 3],
[new Array(3).fill(0), new Array(3).fill(0), new Array(3).fill(0)]
);
const dfB = dfA.mapColumns(() => {
return new Array(3).fill(1);
});
expect(dfA).not.toBe(dfB);
expect(dfB.iat(0, 0)).toEqual(1);
expect(dfB.iat(0, 1)).toEqual(1);
expect(dfB.iat(0, 2)).toEqual(1);
});
test("columns", () => {
const df = Dataframe.Dataframe.create(
[3, 3],
[new Array(3).fill(0), new Array(3).fill(0), new Array(3).fill(0)]
);
expect(df).toBeDefined();
expect(df.columns()).toHaveLength(3);
expect(df.columns()[0]).toEqual(df.icol(0));
expect(df.columns()[2]).toEqual(df.icol(2));
});
test("renameCol", () => {
const dfA = new Dataframe.Dataframe(
[2, 2],
[[true, false], [1, 0]],
null,
new Dataframe.KeyIndex(["A", "B"])
);
const dfB = dfA.renameCol("B", "C");
expect(dfA.colIndex.keys()).toEqual(["A", "B"]);
expect(dfB.colIndex.keys()).toEqual(["A", "C"]);
expect(dfA.dims).toMatchObject(dfB.dims);
expect(dfA.columns()).toMatchObject(dfB.columns());
});
});
});
describe("dataframe col", () => {
let df = null;
beforeEach(() => {
df = new Dataframe.Dataframe(
[2, 2],
[[true, false], [1, 0]],
null,
new Dataframe.KeyIndex(["A", "B"])
);
});
test("col", () => {
expect(df).toBeDefined();
expect(df.col("A")).toBe(df.icol(0));
expect(df.col("B")).toBe(df.icol(1));
expect(df.col("undefined")).toBeUndefined();
expect(df.icol("undefined")).toBeUndefined();
const colA = df.col("A");
expect(colA).toBeInstanceOf(Function);
expect(colA.asArray).toBeInstanceOf(Function);
expect(colA.has).toBeInstanceOf(Function);
expect(colA.ihas).toBeInstanceOf(Function);
expect(colA.indexOf).toBeInstanceOf(Function);
expect(colA.iget).toBeInstanceOf(Function);
});
test("col.asArray", () => {
expect(df).toBeDefined();
expect(df.col("A").asArray()).toEqual([true, false]);
expect(df.icol(0).asArray()).toEqual([true, false]);
expect(df.col("B").asArray()).toEqual([1, 0]);
expect(df.icol(1).asArray()).toEqual([1, 0]);
});
test("col.has", () => {
expect(df).toBeDefined();
expect(df.col("A").has(-1)).toBe(false);
expect(df.col("A").has(0)).toBe(true);
expect(df.col("A").has(1)).toBe(true);
expect(df.col("A").has(2)).toBe(false);
expect(df.col("B").has(-1)).toBe(false);
expect(df.col("B").has(0)).toBe(true);
expect(df.col("B").has(1)).toBe(true);
expect(df.col("B").has(2)).toBe(false);
});
test("col.ihas", () => {
expect(df).toBeDefined();
expect(df.col("A").ihas(-1)).toBe(false);
expect(df.col("A").ihas(0)).toBe(true);
expect(df.col("A").ihas(1)).toBe(true);
expect(df.col("A").ihas(2)).toBe(false);
expect(df.col("B").ihas(-1)).toBe(false);
expect(df.col("B").ihas(0)).toBe(true);
expect(df.col("B").ihas(1)).toBe(true);
expect(df.col("B").ihas(2)).toBe(false);
});
test("col.iget", () => {
expect(df).toBeDefined();
expect(df.col("A").iget(0)).toEqual(df.iat(0, 0));
expect(df.col("B").iget(1)).toEqual(df.iat(1, 1));
});
test("col.indexOf", () => {
expect(df).toBeDefined();
expect(df.col("A").indexOf(true)).toEqual(0);
expect(df.col("A").indexOf(false)).toEqual(1);
expect(df.col("A").indexOf(99)).toBeUndefined();
expect(df.col("A").indexOf(undefined)).toBeUndefined();
expect(df.col("A").indexOf(1)).toBeUndefined();
expect(df.col("B").indexOf(1)).toEqual(0);
expect(df.col("B").indexOf(0)).toEqual(1);
expect(df.col("B").indexOf(99)).toBeUndefined();
expect(df.col("B").indexOf(undefined)).toBeUndefined();
expect(df.col("B").indexOf(true)).toBeUndefined();
});
});
@@ -1,93 +0,0 @@
import * as Dataframe from "../../../src/util/dataframe";
describe("Dataframe column histogram", () => {
test("categorical by categorical", () => {
const df = new Dataframe.Dataframe(
[3, 3],
[["n1", "n2", "n3"], ["c1", "c2", "c3"], new Int32Array([0, 1, 2])],
null,
new Dataframe.KeyIndex(["name", "cat", "value"])
);
const h1 = df.col("cat").histogram(df.col("name"));
expect(h1).toMatchObject(
new Map([
["n1", new Map([["c1", 1]])],
["n2", new Map([["c2", 1]])],
["n3", new Map([["c3", 1]])]
])
);
// memoized?
expect(df.col("cat").histogram(df.col("name"))).toMatchObject(h1);
});
test("continuous by categorical", () => {
const df = new Dataframe.Dataframe(
[3, 3],
[["n1", "n2", "n3"], ["c1", "c2", "c3"], new Int32Array([0, 1, 2])],
null,
new Dataframe.KeyIndex(["name", "cat", "value"])
);
const h1 = df.col("value").histogram(3, [0, 2], df.col("name"));
expect(h1).toMatchObject(
new Map([["n1", [1, 0, 0]], ["n2", [0, 1, 0]], ["n3", [0, 0, 1]]])
);
// memoized?
expect(df.col("value").histogram(3, [0, 2], df.col("name"))).toMatchObject(
h1
);
});
test("categorical", () => {
const df = new Dataframe.Dataframe(
[3, 3],
[["n1", "n2", "n3"], ["c1", "c2", "c3"], new Int32Array([0, 1, 2])],
null,
new Dataframe.KeyIndex(["name", "cat", "value"])
);
const h1 = df.col("cat").histogram();
expect(h1).toMatchObject(new Map([["c1", 1], ["c2", 1], ["c3", 1]]));
// memoized?
expect(df.col("value").histogram(3, [0, 2])).toMatchObject(h1);
});
test("continuous", () => {
const df = new Dataframe.Dataframe(
[3, 3],
[["n1", "n2", "n3"], ["c1", "c2", "c3"], new Int32Array([0, 1, 2])],
null,
new Dataframe.KeyIndex(["name", "cat", "value"])
);
const h1 = df.col("value").histogram(3, [0, 2]);
expect(h1).toMatchObject([1, 1, 1]);
// memoized?
expect(df.col("value").histogram(3, [0, 2])).toMatchObject(h1);
});
test("continuous thesholds correct", () => {
const vals = [0, 1, 9, 10, 11, 20, 99, 100];
const df = new Dataframe.Dataframe(
[8, 2],
[new Int32Array(vals), new Float32Array(vals)]
);
expect(df.col(0).histogram(5, [0, 100])).toEqual([5, 1, 0, 0, 2]);
expect(df.col(1).histogram(5, [0, 100])).toEqual([5, 1, 0, 0, 2]);
expect(df.col(0).histogram(2, [0, 10])).toEqual([2, 2]);
expect(df.col(0).histogram(10, [0, 100])).toEqual([
3,
2,
1,
0,
0,
0,
0,
0,
0,
2
]);
});
});
@@ -1,253 +0,0 @@
import * as Dataframe from "../../../src/util/dataframe";
function float32Conversion(f) {
return new Float32Array([f])[0];
}
describe("Dataframe column summary", () => {
test("empty column test", () => {
const df = Dataframe.Dataframe.create([0, 1], [[]]);
const summary = df.icol(0).summarize();
expect(summary).toEqual(
expect.objectContaining({
categorical: true,
categories: [],
categoryCounts: new Map(),
numCategories: 0
})
);
});
test("simple test", () => {
const df = new Dataframe.Dataframe(
[1, 6],
[
["n1"],
["hi"],
[true],
new Float32Array([39.3]),
new Int32Array([99]),
[1]
],
null,
new Dataframe.KeyIndex([
"name",
"nameString",
"nameBoolean",
"nameFloat32",
"nameInt32",
"nameCategorical"
])
);
expect(df.icol(0).summarize()).toEqual(
expect.objectContaining({
categorical: true,
categories: ["n1"],
categoryCounts: new Map([["n1", 1]]),
numCategories: 1
})
);
expect(df.icol(1).summarize()).toEqual(
expect.objectContaining({
categorical: true,
categories: ["hi"],
categoryCounts: new Map([["hi", 1]]),
numCategories: 1
})
);
expect(df.icol(2).summarize()).toEqual(
expect.objectContaining({
categorical: true,
categories: [true],
categoryCounts: new Map([[true, 1]]),
numCategories: 1
})
);
expect(df.icol(3).summarize()).toEqual(
expect.objectContaining({
categorical: false,
min: float32Conversion(39.3),
max: float32Conversion(39.3),
nan: 0,
ninf: 0,
pinf: 0
})
);
expect(df.icol(4).summarize()).toEqual(
expect.objectContaining({
categorical: false,
min: 99,
max: 99,
nan: 0,
ninf: 0,
pinf: 0
})
);
expect(df.icol(5).summarize()).toEqual(
expect.objectContaining({
categorical: true,
categories: [1],
categoryCounts: new Map([[1, 1]]),
numCategories: 1
})
);
});
test("multi test", () => {
const df = new Dataframe.Dataframe(
[3, 6],
[
["n0", "n1", "n2"],
["hi", "hi", "bye"],
[false, true, true],
new Float32Array([39.3, 39.3, 0]),
new Int32Array([99, 99, 99]),
[1, false, "0"]
],
null,
new Dataframe.KeyIndex([
"name",
"nameString",
"nameBoolean",
"nameFloat32",
"nameInt32",
"nameCategorical"
])
);
expect(df.icol(0).summarize()).toEqual(
expect.objectContaining({
categorical: true,
categories: expect.arrayContaining(["n0", "n1", "n2"]),
categoryCounts: new Map([["n0", 1], ["n1", 1], ["n2", 1]]),
numCategories: 3
})
);
expect(df.icol(1).summarize()).toEqual(
expect.objectContaining({
categorical: true,
categories: expect.arrayContaining(["hi", "bye"]),
categoryCounts: new Map([["hi", 2], ["bye", 1]]),
numCategories: 2
})
);
expect(df.icol(2).summarize()).toEqual(
expect.objectContaining({
categorical: true,
categories: expect.arrayContaining([true, false]),
categoryCounts: new Map([[true, 2], [false, 1]]),
numCategories: 2
})
);
expect(df.icol(3).summarize()).toEqual(
expect.objectContaining({
categorical: false,
min: 0,
max: float32Conversion(39.3),
nan: 0,
ninf: 0,
pinf: 0
})
);
expect(df.icol(4).summarize()).toEqual(
expect.objectContaining({
categorical: false,
min: 99,
max: 99,
nan: 0,
ninf: 0,
pinf: 0
})
);
expect(df.icol(5).summarize()).toEqual(
expect.objectContaining({
categorical: true,
categories: expect.arrayContaining([1, false, "0"]),
categoryCounts: new Map([[1, 1], [false, 1], ["0", 1]]),
numCategories: 3
})
);
});
test("non-finite numbers", () => {
const df = new Dataframe.Dataframe(
[4, 6],
[
["n0", "n1", "n2", "n2"],
["hi", "hi", "bye", "bye"],
[false, true, true, true],
new Float32Array([
39.3,
Number.NEGATIVE_INFINITY,
Number.NaN,
Number.POSITIVE_INFINITY
]),
new Int32Array([99, 99, 99, 99]),
[1, false, "0", "0"]
],
null,
new Dataframe.KeyIndex([
"name",
"nameString",
"nameBoolean",
"nameFloat32",
"nameInt32",
"nameCategorical"
])
);
expect(df.icol(0).summarize()).toEqual(
expect.objectContaining({
categorical: true,
categories: expect.arrayContaining(["n0", "n1", "n2"]),
categoryCounts: new Map([["n0", 1], ["n1", 1], ["n2", 2]]),
numCategories: 3
})
);
expect(df.icol(1).summarize()).toEqual(
expect.objectContaining({
categorical: true,
categories: expect.arrayContaining(["hi", "bye"]),
categoryCounts: new Map([["hi", 2], ["bye", 1]]),
numCategories: 2
})
);
expect(df.icol(2).summarize()).toEqual(
expect.objectContaining({
categorical: true,
categories: expect.arrayContaining([true, false]),
categoryCounts: new Map([[true, 2], [false, 1]]),
numCategories: 2
})
);
expect(df.icol(3).summarize()).toEqual(
expect.objectContaining({
categorical: false,
min: float32Conversion(39.3),
max: float32Conversion(39.3),
nan: 1,
ninf: 1,
pinf: 1
})
);
expect(df.icol(4).summarize()).toEqual(
expect.objectContaining({
categorical: false,
min: 99,
max: 99,
nan: 0,
ninf: 0,
pinf: 0
})
);
expect(df.icol(5).summarize()).toEqual(
expect.objectContaining({
categorical: true,
categories: expect.arrayContaining([1, false, "0"]),
categoryCounts: new Map([[1, 1], [false, 1], ["0", 1]]),
numCategories: 3
})
);
});
});
-29
View File
@@ -1,29 +0,0 @@
import quantile from "../../src/util/quantile";
describe("quantile", () => {
test("single q", () => {
const arr = new Float32Array([9, 3, 5, 6, 0]);
expect(quantile([1.0], arr)).toMatchObject([9]);
expect(quantile([0.9], arr)).toMatchObject([9]);
expect(quantile([0.8], arr)).toMatchObject([9]);
expect(quantile([0.7], arr)).toMatchObject([6]);
expect(quantile([0.6], arr)).toMatchObject([6]);
expect(quantile([0.5], arr)).toMatchObject([5]);
expect(quantile([0.4], arr)).toMatchObject([5]);
expect(quantile([0.3], arr)).toMatchObject([3]);
expect(quantile([0.2], arr)).toMatchObject([3]);
expect(quantile([0.1], arr)).toMatchObject([0]);
expect(quantile([0], arr)).toMatchObject([0]);
});
test("multi q", () => {
const arr = new Float32Array([9, 3, 5, 6, 0]);
expect(quantile([0, 0.25, 0.5, 0.75, 1.0], arr)).toMatchObject([
0,
3,
5,
6,
9
]);
});
});
-50
View File
@@ -1,50 +0,0 @@
import { range, rangeFill, linspace } from "../../src/util/range";
describe("range", () => {
test("no defaults", () => {
expect(range(0, 3, 1)).toMatchObject([0, 1, 2]);
});
test("range(stop)", () => {
expect(range(3)).toMatchObject([0, 1, 2]);
expect(range(0)).toMatchObject([]);
expect(range(1)).toMatchObject([0]);
});
test("range(start,stop)", () => {
expect(range(0, 0)).toMatchObject([]);
expect(range(0, 2)).toMatchObject([0, 1]);
expect(range(4, 8)).toMatchObject([4, 5, 6, 7]);
});
test("range(start, stop, step", () => {
expect(range(4, 0, -1)).toMatchObject([4, 3, 2, 1]);
expect(range(0, 4, 2)).toMatchObject([0, 2]);
});
});
describe("rangefill", () => {
test("rangeFill(arr)", () => {
expect(rangeFill(new Int32Array(3))).toMatchObject(
new Int32Array([0, 1, 2])
);
});
test("rangeFill(arr, start)", () => {
expect(rangeFill(new Int32Array(2), 1)).toMatchObject(
new Int32Array([1, 2])
);
});
test("rangeFill(arr, start, step)", () => {
expect(rangeFill(new Int32Array(3), 2, -1)).toMatchObject(
new Int32Array([2, 1, 0])
);
});
});
describe("linspace", () => {
test("linspace(arr, start, step)", () => {
expect(linspace(0.0, 2.0, 5)).toMatchObject(
[0.0, 0.5, 1.0, 1.5, 2.0]
);
});
});
@@ -1,34 +0,0 @@
/*
test FBS encode/decode API
*/
import { Dataframe, KeyIndex } from "../../../src/util/dataframe";
import {
decodeMatrixFBS,
encodeMatrixFBS
} from "../../../src/util/stateManager/matrix";
describe("encode/decode", () => {
test("round trip", () => {
const columns = [
["red", "green", "blue"],
new Int32Array(3).fill(0),
new Uint32Array(3).fill(1),
new Float32Array(3).fill(2)
];
const dfNoColIdx = new Dataframe([3, 4], columns);
const dfA = decodeMatrixFBS(encodeMatrixFBS(dfNoColIdx));
expect([dfA.nRows, dfA.nCols]).toEqual(dfNoColIdx.dims);
expect(dfA.colIdx).toBeNull();
expect(dfA.rowIdx).toBeNull();
expect(dfA.columns).toEqual(columns);
const colIndex = new KeyIndex(["a", "b", "c", "d"]);
const dfWithColIdx = new Dataframe([3, 4], columns, null, colIndex);
const dfB = decodeMatrixFBS(encodeMatrixFBS(dfWithColIdx));
expect([dfB.nRows, dfB.nCols]).toEqual(dfWithColIdx.dims);
expect(dfB.colIdx).toEqual(colIndex.keys());
expect(dfB.rowIdx).toBeNull();
expect(dfB.columns).toEqual(columns);
});
});
@@ -0,0 +1,249 @@
import _ from "lodash";
import * as kvCache from "../../../src/util/stateManager/keyvalcache";
/*
This is PRIVATE to keyvalcache and must be kept in sync with
any changs ot that module. Need to Know - to enable error handling test
*/
const cachePrivateKey = "__kvcachekey__";
/*
helper function - promisify setTimeout()
*/
function timeout(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
describe("kvcache API", () => {
/*
test the happy path create/set/get API
*/
test("simple create", () => {
/* with defaults */
const kvc = kvCache.create();
expect(kvc).toBeDefined();
expect(kvc).toEqual(expect.objectContaining({}));
expect(kvCache.get(kvc, "test")).toBeUndefined();
/* with params */
const kvc1 = kvCache.create(/* lowWatermark */ 99, /* minTTL */ 0);
expect(kvc1).toBeDefined();
expect(kvc1).toEqual(expect.objectContaining({}));
});
test("set/get", () => {
/*
- check basic get/set functionality
- check set does not mutate source cache
*/
const keyName = "foo";
const kvc1 = kvCache.create();
expect(kvc1).toBeDefined();
expect(kvCache.get(kvc1, keyName)).toBeUndefined();
const val2 = [2];
const kvc2 = kvCache.set(kvc1, keyName, val2);
expect(kvc2).toBeDefined();
expect(kvc2).not.toBe(kvc1);
expect(kvCache.get(kvc1, keyName)).toBeUndefined();
expect(kvCache.get(kvc2, keyName)).toBe(val2);
const val3 = [3];
const kvc3 = kvCache.set(kvc2, keyName, val3);
expect(kvc3).toBeDefined();
expect(kvc3).not.toBe(kvc1);
expect(kvc3).not.toBe(kvc2);
expect(kvCache.get(kvc1, keyName)).toBeUndefined();
expect(kvCache.get(kvc2, keyName)).toBe(val2);
expect(kvCache.get(kvc3, keyName)).toBe(val3);
});
});
describe("common error handling", () => {
/*
Test common error handlers
*/
test("set() protection from namespace pollution", () => {
/*
Test that set() will not allow use of the private cache key
*/
const kvc = kvCache.create();
expect(() => {
kvCache.set(kvc, cachePrivateKey, {});
}).toThrow();
});
test("create() does not accept bogus config", () => {
expect(() => {
kvCache.create([], {});
}).toThrow();
expect(() => {
kvCache.create(-99, 0);
}).toThrow();
expect(() => {
kvCache.create(100, -1);
}).toThrow();
expect(() => {
kvCache.create(1000, "foobar");
}).toThrow();
expect(() => {
kvCache.create(null, 8);
}).toThrow();
});
});
describe("map", () => {
/*
Test kvCache.map() - create new cache that is a transformation of an
existing cache
*/
test("map of empty cache", () => {
const kvc = kvCache.create();
const callback = jest.fn();
const kvcMapped = kvCache.map(kvc, callback);
expect(callback).not.toHaveBeenCalled();
expect(kvcMapped).toBeDefined();
expect(kvcMapped).not.toBe(kvc); // immutable operation
expect(kvcMapped).toEqual(kvc);
});
test("map of non-empty cache", () => {
const key = "aKey";
const val = [0, 1, 2];
let kvc = kvCache.create();
kvc = kvCache.set(kvc, key, val);
const mockCB = jest.fn().mockImplementation(v => [...v]);
const kvcMapped = kvCache.map(kvc, mockCB);
expect(kvcMapped).toBeDefined();
expect(kvcMapped).not.toBe(kvc); // immutable operation
expect(_.isEqual(kvc, kvcMapped)).toBe(true);
expect(mockCB).toHaveBeenCalledTimes(1);
expect(mockCB).toHaveBeenLastCalledWith(val, key);
});
});
describe("flush", () => {
/*
test various cache flush behavior
*/
test("flush - lowWatermark, disable minTTL", () => {
/*
verify lowWatermark functions correctly
*/
// set lowWatermark to 2, set three times - only the final two
// should remain.
let kvc = kvCache.create(2, 0);
["a", "b", "c"].forEach(k => {
kvc = kvCache.set(kvc, k, []);
});
expect(kvc).toEqual(
expect.objectContaining({
b: expect.arrayContaining([]),
c: expect.arrayContaining([])
})
);
expect(kvc).toEqual(
expect.not.objectContaining({
a: expect.arrayContaining([])
})
);
});
test("flush - minTTL, disable lowWatermark", async () => {
/*
verify minTTL functions correctly
*/
// set minTTL to 1 ms
let kvc = kvCache.create(0, 10);
kvc = kvCache.set(kvc, "a", []);
await timeout(20);
["b", "c"].forEach(k => {
kvc = kvCache.set(kvc, k, []);
});
expect(kvc).toEqual(
expect.objectContaining({
b: expect.arrayContaining([]),
c: expect.arrayContaining([])
})
);
expect(kvc).toEqual(
expect.not.objectContaining({
a: expect.arrayContaining([])
})
);
});
test("flush - minTTL and lowWatermark", async () => {
/*
verify minTTL functions correctly
*/
// set lowwatermark to 3, minTTL to 1 ms
let kvc = kvCache.create(3, 10);
kvc = kvCache.set(kvc, "a", []);
// delay
await timeout(20);
["b", "c"].forEach(k => {
kvc = kvCache.set(kvc, k, []);
});
expect(kvc).toEqual(
expect.objectContaining({
a: expect.arrayContaining([]),
b: expect.arrayContaining([]),
c: expect.arrayContaining([])
})
);
kvc = kvCache.set(kvc, "d", []);
expect(kvc).toEqual(
expect.objectContaining({
b: expect.arrayContaining([]),
c: expect.arrayContaining([]),
d: expect.arrayContaining([])
})
);
expect(kvc).toEqual(
expect.not.objectContaining({
a: expect.arrayContaining([])
})
);
});
test("manual flush", async () => {
let kvc = kvCache.create(1, 10);
["a", "b", "c", "d"].forEach(k => {
kvc = kvCache.set(kvc, k, []);
});
// Before TTL has expired, should have all values in cache.
expect(kvc).toEqual(
expect.objectContaining({
a: expect.arrayContaining([]),
b: expect.arrayContaining([]),
c: expect.arrayContaining([])
})
);
// let TTL expire
await timeout(10);
// manually flush
const postFlushKvc = kvCache.flush(kvc);
expect(postFlushKvc).toBeDefined();
expect(postFlushKvc).not.toBe(kvc);
expect(postFlushKvc).toEqual(
expect.objectContaining({
d: expect.arrayContaining([])
})
);
});
});
@@ -1,7 +1,5 @@
/* eslint no-bitwise: "off" */
import _ from "lodash";
import { flatbuffers } from "flatbuffers";
import { NetEncoding } from "../../../src/util/stateManager/matrix_generated";
/*
test data mocking REST 0.2 API responses. Used in several tests.
@@ -34,43 +32,33 @@ const aSchemaResponse = {
type: "float32"
},
annotations: {
obs: {
index: "name",
columns: [
{ name: "name", type: "string" },
{ name: "field1", type: "int32" },
{ name: "field2", type: "float32" },
{ name: "field3", type: "boolean" },
{
name: "field4",
type: "categorical",
categories: field4Categories
}
]
},
var: {
index: "name",
columns: [
{ name: "name", type: "string" },
{ name: "fieldA", type: "int32" },
{ name: "fieldB", type: "float32" },
{ name: "fieldC", type: "boolean" },
{
name: "fieldD",
type: "categorical",
categories: fieldDCategories
}
]
}
},
layout: {
obs: [{ name: "umap", type: "float32", dims: ["umap_0", "umap_1"] }],
var: []
obs: [
{ name: "name", type: "string" },
{ name: "field1", type: "int32" },
{ name: "field2", type: "float32" },
{ name: "field3", type: "boolean" },
{
name: "field4",
type: "categorical",
categories: field4Categories
}
],
var: [
{ name: "name", type: "string" },
{ name: "fieldA", type: "int32" },
{ name: "fieldB", type: "float32" },
{ name: "fieldC", type: "boolean" },
{
name: "fieldD",
type: "categorical",
categories: fieldDCategories
}
]
}
}
};
const anAnnotationsObsJSONResponse = {
const anAnnotationsObsResponse = {
names: ["name", "field1", "field2", "field3", "field4"],
data: _()
.range(nObs)
@@ -85,7 +73,7 @@ const anAnnotationsObsJSONResponse = {
.value()
};
const anAnnotationsVarJSONResponse = {
const anAnnotationsVarResponse = {
names: ["fieldA", "fieldB", "fieldC", "fieldD", "name"],
data: _()
.range(nVar)
@@ -100,88 +88,15 @@ const anAnnotationsVarJSONResponse = {
.value()
};
function encodeTypedArray(builder, uType, uData) {
const uTypeName = NetEncoding.TypedArray[uType];
const ArrayType = NetEncoding[uTypeName];
const dv = ArrayType.createDataVector(builder, uData);
builder.startObject(1);
builder.addFieldOffset(0, dv, 0);
return builder.endObject();
}
function encodeMatrix(columns, colIndex = undefined) {
/*
IMPORTANT: this is not a general purpose encoder. in particular,
it doesn't correctly handle all column index types, nor does it
handle all column typedarray types.
encodeMatrixFBS in matrix.py is more general. This is used only
as a testing santity check (alt implementation).
*/
const utf8Encoder = new TextEncoder("utf-8");
const builder = new flatbuffers.Builder(1024);
const cols = _.map(columns, carr => {
let uType;
let tarr;
if (_.every(carr, _.isNumber)) {
uType = NetEncoding.TypedArray.Float32Array;
tarr = encodeTypedArray(builder, uType, new Float32Array(carr));
} else {
uType = NetEncoding.TypedArray.JSONEncodedArray;
const json = JSON.stringify(carr);
const jsonUTF8 = utf8Encoder.encode(json);
tarr = encodeTypedArray(builder, uType, jsonUTF8);
}
NetEncoding.Column.startColumn(builder);
NetEncoding.Column.addUType(builder, uType);
NetEncoding.Column.addU(builder, tarr);
return NetEncoding.Column.endColumn(builder);
});
const encColumns = NetEncoding.Matrix.createColumnsVector(builder, cols);
let encColIndex;
if (colIndex) {
encColIndex = encodeTypedArray(
builder,
NetEncoding.TypedArray.JSONEncodedArray,
utf8Encoder.encode(JSON.stringify(colIndex))
);
const aLayoutResponse = {
layout: {
ndims: 2,
coordinates: _()
.range(nObs)
.map(idx => [idx, Math.random(), Math.random()])
.value()
}
NetEncoding.Matrix.startMatrix(builder);
NetEncoding.Matrix.addNRows(builder, columns[0].length);
NetEncoding.Matrix.addNCols(builder, columns.length);
NetEncoding.Matrix.addColumns(builder, encColumns);
if (colIndex) {
NetEncoding.Matrix.addColIndexType(
builder,
NetEncoding.TypedArray.JSONEncodedArray
);
NetEncoding.Matrix.addColIndex(builder, encColIndex);
}
const root = NetEncoding.Matrix.endMatrix(builder);
builder.finish(root);
return builder.asUint8Array();
}
const anAnnotationsObsFBSResponse = (() => {
const columns = _.zip(...anAnnotationsObsJSONResponse.data).slice(1);
return encodeMatrix(columns, anAnnotationsObsJSONResponse.names);
})();
const anAnnotationsVarFBSResponse = (() => {
const columns = _.zip(...anAnnotationsVarJSONResponse.data).slice(1);
return encodeMatrix(columns, anAnnotationsVarJSONResponse.names);
})();
const aLayoutFBSResponse = (() => {
const coords = [
new Float32Array(nObs).fill(Math.random()),
new Float32Array(nObs).fill(Math.random())
];
return encodeMatrix(coords, ["umap_0", "umap_1"]);
})();
};
const aDataObsResponse = {
var: [2, 4, 29],
@@ -192,10 +107,10 @@ const aDataObsResponse = {
};
export {
aLayoutFBSResponse as layoutObs,
aLayoutResponse as layoutObs,
aDataObsResponse as dataObs,
anAnnotationsVarFBSResponse as annotationsVar,
anAnnotationsObsFBSResponse as annotationsObs,
anAnnotationsVarResponse as annotationsVar,
anAnnotationsObsResponse as annotationsObs,
aSchemaResponse as schema,
aConfigResponse as config
};
@@ -1,13 +1,13 @@
import _ from "lodash";
import * as Universe from "../../../src/util/stateManager/universe";
import * as Dataframe from "../../../src/util/dataframe";
import * as REST from "./sampleResponses";
describe("createUniverseFromResponse", () => {
describe("createUniverseFromRestV02Response", () => {
/*
test createUniverseFromResponse - this function converts
test createUniverseFromRestV02Response - this function converts
a set of REST 0.2 responses into a "new" Universe.
createUniverseFromResponse(
createUniverseFromRestV02Response(
configResponse,
schemaResponse,
annotationsObsResponse,
@@ -30,7 +30,8 @@ describe("createUniverseFromResponse", () => {
create a universe from sample data nad validate its shape & contents
*/
const { nObs, nVar } = REST.schema.schema.dataframe;
const universe = Universe.createUniverseFromResponse(
const universe = Universe.createUniverseFromRestV02Response(
REST.config,
REST.schema,
REST.annotationsObs,
@@ -41,28 +42,80 @@ describe("createUniverseFromResponse", () => {
expect(universe).toBeDefined();
expect(universe).toMatchObject(
expect.objectContaining({
api: "0.2",
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)
obsAnnotations: expect.any(Array),
varAnnotations: expect.any(Array),
obsNameToIndexMap: expect.any(Object),
varNameToIndexMap: expect.any(Object),
obsLayout: expect.objectContaining({
X: expect.any(Float32Array),
Y: expect.any(Float32Array)
}),
varDataCache: expect.any(Object)
})
);
expect(universe.obsAnnotations.dims).toEqual([
nObs,
REST.schema.schema.annotations.obs.columns.length
]);
expect(universe.obsLayout.dims).toEqual([nObs, 2]);
expect(universe.obsLayout.colIndex.keys()).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();
expect(universe.obsAnnotations).toHaveLength(nObs);
expect(_.keys(universe.obsNameToIndexMap)).toHaveLength(nObs);
expect(universe.obsLayout.X).toHaveLength(nObs);
expect(universe.obsLayout.Y).toHaveLength(nObs);
expect(universe.varAnnotations).toHaveLength(nVar);
expect(_.keys(universe.varNameToIndexMap)).toHaveLength(nVar);
});
});
describe("convertExpressionRESTv02ToObject", () => {
/*
test convertExpressionRESTv02ToObject
convertExpressionRESTv02ToObject(
universe,
response) --> { geneName: Float32Array, geneName: Float32Array, ... }
reponse is a /data/obs response:
{
var: [ varIndices fetched ],
obs: [
[ obsIndex, evalue, ... ],
...
]
}
*/
test("create from response data", () => {
const universe = Universe.createUniverseFromRestV02Response(
REST.config,
REST.schema,
REST.annotationsObs,
REST.annotationsVar,
REST.layoutObs
);
const expression = Universe.convertExpressionRESTv02ToObject(
universe,
REST.dataObs
);
/* Check that the expected keys are present */
const expectedGeneNames = _.map(
REST.dataObs.var,
v => REST.annotationsVar.data[v][5]
);
expect(Object.keys(expression)).toEqual(
expect.arrayContaining(expectedGeneNames)
);
const expectedExpressionValues = _.map(
_.unzip(REST.dataObs.obs),
a => new Float32Array(a)
);
_.forEach(REST.dataObs.var, (varIdx, idx) => {
const varName = universe.varAnnotations[varIdx].name;
expect(varName).toBeDefined();
expect(varIdx).toBe(universe.varNameToIndexMap[varName]);
expect(expression[varName]).toEqual(expectedExpressionValues[idx + 1]);
});
});
});
+152 -87
View File
@@ -1,14 +1,13 @@
import _ from "lodash";
import * as Universe from "../../../src/util/stateManager/universe";
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";
import * as kvCache from "../../../src/util/stateManager/keyvalcache";
/*
Helper - creates universe, world, corssfilter and dimensionMap from
@@ -17,37 +16,36 @@ the default REST test response.
const defaultBigBang = () => {
/* create unverse, world, crossfilter and dimensionMap */
/* create universe */
const universe = Universe.createUniverseFromResponse(
_.cloneDeep(REST.config),
_.cloneDeep(REST.schema),
_.cloneDeep(REST.annotationsObs),
_.cloneDeep(REST.annotationsVar),
_.cloneDeep(REST.layoutObs)
const universe = Universe.createUniverseFromRestV02Response(
REST.config,
REST.schema,
REST.annotationsObs,
REST.annotationsVar,
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
);
const crossfilter = Crossfilter(world.obsAnnotations);
/* create dimension map */
const dimensionMap = World.createObsDimensionMap(crossfilter, world);
return {
universe,
world,
crossfilter
crossfilter,
dimensionMap
};
};
describe("createWorldFromEntireUniverse", () => {
test("create from REST sample", () => {
const universe = Universe.createUniverseFromResponse(
_.cloneDeep(REST.config),
_.cloneDeep(REST.schema),
_.cloneDeep(REST.annotationsObs),
_.cloneDeep(REST.annotationsVar),
_.cloneDeep(REST.layoutObs)
const universe = Universe.createUniverseFromRestV02Response(
REST.config,
REST.schema,
REST.annotationsObs,
REST.annotationsVar,
REST.layoutObs
);
expect(universe).toBeDefined();
@@ -56,18 +54,31 @@ describe("createWorldFromEntireUniverse", () => {
expect(world).toMatchObject(
expect.objectContaining({
api: "0.2",
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)
}
obsAnnotations: universe.obsAnnotations,
varAnnotations: universe.varAnnotations,
obsLayout: universe.obsLayout,
summary: expect.objectContaining({
obs: _(REST.schema.schema.annotations.obs)
.filter(v => v.name !== "name")
.keyBy("name")
.mapValues(() => expect.any(Object))
.value(),
var: _(REST.schema.schema.annotations.var)
.filter(v => v.name !== "name")
.keyBy("name")
.mapValues(() => expect.any(Object))
.value()
}),
varDataCache: expect.any(Object),
obsIndex: null, // null indicating full universe
obsBackIndex: null
})
);
});
@@ -78,70 +89,73 @@ describe("createWorldFromCurrentSelection", () => {
const {
universe,
world: originalWorld,
crossfilter: originalCrossfilter
crossfilter,
dimensionMap
} = defaultBigBang();
/* mock a selection */
const crossfilter = originalCrossfilter
.select(obsAnnoDimensionName("field1"), { mode: "range", lo: 0, hi: 5 })
.select(obsAnnoDimensionName("field3"), {
mode: "exact",
values: [false]
});
dimensionMap[obsAnnoDimensionName("field1")].filterRange([0, 5]);
dimensionMap[obsAnnoDimensionName("field3")].filterExact(false);
/* create the world from the selection */
const world = World.createWorldBySelection(
const world = World.createWorldFromCurrentSelection(
universe,
originalWorld,
crossfilter
);
expect(world).toBeDefined();
expect(world.nObs).toEqual(crossfilter.countSelected());
expect(world.nObs).toEqual(crossfilter.countFiltered());
/*
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 = _()
const matchFilter = val => val.field1 >= 0 && val.field1 < 5 && !val.field3;
const universeIndices = _()
.range(universe.nObs)
.filter(idx => matchFilter(universe.obsAnnotations, idx))
.filter(idx => matchFilter(universe.obsAnnotations[idx]))
.value();
const expected = {
nObs: universeIndices.length,
obsAnnotations: _.map(universeIndices, i => universe.obsAnnotations[i]),
obsLayout: {
X: new Float32Array(
_.map(universeIndices, i => universe.obsLayout.X[i])
),
Y: new Float32Array(
_.map(universeIndices, i => universe.obsLayout.Y[i])
)
},
obsBackIndex: _.transform(
universeIndices,
(result, univIdx, worldIdx) => {
result[univIdx] = worldIdx;
},
new Uint32Array(universe.nObs).fill(-1)
),
obsIndex: new Uint32Array(universeIndices)
};
expect(world).toMatchObject(
expect.objectContaining({
nObs: matchingIndices.length,
api: "0.2",
nObs: expected.nObs,
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)
}
obsAnnotations: expected.obsAnnotations,
varAnnotations: universe.varAnnotations,
obsLayout: expected.obsLayout,
summary: {
obs: expect.any(Object) /* we could do better! */,
var: expect.any(Object) /* we could do better! */
},
varDataCache: expect.any(Object),
obsIndex: expected.obsIndex,
obsBackIndex: expected.obsBackIndex
})
);
expect(world.obsAnnotations.rowIndex.keys()).toEqual(
new Int32Array(matchingIndices)
);
expect(world.obsAnnotations.colIndex.keys()).toEqual(
universe.obsAnnotations.colIndex.keys()
);
expect(world.obsLayout.rowIndex.keys()).toEqual(
new Int32Array(matchingIndices)
);
expect(world.obsLayout.colIndex.keys()).toEqual(
world.schema.layout.obs[0].dims
);
});
});
@@ -153,36 +167,87 @@ describe("createObsDimensionMap", () => {
- 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) {
const { dimensionMap } = defaultBigBang();
const schemaByObsName = _.keyBy(REST.schema.schema.annotations.obs, "name");
expect(dimensionMap).toBeDefined();
REST.annotationsObs.names.forEach(name => {
const dim = dimensionMap[obsAnnoDimensionName(name)];
if (name === "name") {
expect(dim).toBeUndefined();
} else {
const { type } = schemaByObsName[name];
if (type === "string" || type === "boolean" || type === "categorical") {
expect(dim.dim).toBeInstanceOf(DimTypes.enum);
expect(dim).toBeInstanceOf(Crossfilter.EnumDimension);
} else {
expect(dim.dim).toBeInstanceOf(DimTypes.scalar);
expect(dim).toBeInstanceOf(Crossfilter.ScalarDimension);
}
}
});
expect(
crossfilter.dimensions[layoutDimensionName("XY")].dim
).toBeInstanceOf(DimTypes.spatial);
expect(dimensionMap[layoutDimensionName("X")]).toBeInstanceOf(
Crossfilter.ScalarDimension
);
expect(dimensionMap[layoutDimensionName("Y")]).toBeInstanceOf(
Crossfilter.ScalarDimension
);
});
});
describe("subsetVarData", () => {
test("when world eq universe", () => {
const { universe, world } = defaultBigBang();
/* create a mock varData array for subsetting */
const sourceVarData = new Float32Array(universe.nObs);
/* expect literally the same object back */
const result = World.subsetVarData(world, universe, sourceVarData);
expect(result).toBe(sourceVarData);
});
test("when world neq universe", () => {
const { universe, world, crossfilter, dimensionMap } = defaultBigBang();
/* create a mock varData array for subsetting */
const sourceVarData = Float32Array.from(_.range(universe.nObs));
/* mock a selection */
dimensionMap[obsAnnoDimensionName("field1")].filterRange([0, 5]);
dimensionMap[obsAnnoDimensionName("field3")].filterExact(false);
/* create the world from the selection */
const newWorld = World.createWorldFromCurrentSelection(
universe,
world,
crossfilter
);
expect(newWorld.obsIndex).toMatchObject(new Uint32Array([0, 2]));
/* expect a subset */
const result = World.subsetVarData(newWorld, universe, sourceVarData);
expect(result).not.toBe(sourceVarData);
expect(result).toHaveLength(newWorld.nObs);
/* check that we have expected source var content */
expect(result).toMatchObject(new Float32Array([0, 2]));
});
});
describe("createVarDimension", () => {
/* create default universe */
const { world, crossfilter } = defaultBigBang();
/* create a mock var data cache */
const varDataCache = kvCache.set(
kvCache.create(),
"GENE",
Float32Array.from(_.range(world.nObs))
);
const result = World.createVarDimension(
world,
varDataCache,
crossfilter,
"GENE"
);
expect(result).toBeInstanceOf(Crossfilter.ScalarDimension);
});
describe("worldEqUniverse", () => {
const { universe, world } = defaultBigBang();
const result = World.worldEqUniverse(world, universe);
@@ -118,16 +118,16 @@ describe("selectionCount", () => {
const dim2 = ba.allocDimension();
expect(dim2).toBeDefined();
expect(ba.selectionCount()).toEqual(0);
expect(ba.selectionCount).toEqual(0);
ba.selectAll(dim1);
expect(ba.selectionCount()).toEqual(0);
expect(ba.selectionCount).toEqual(0);
ba.selectAll(dim2);
expect(ba.selectionCount()).toEqual(defaultTestLength);
expect(ba.selectionCount).toEqual(defaultTestLength);
for (let i = 0; i < defaultTestLength; i += 1) {
ba.deselectOne(dim1, i);
expect(ba.selectionCount()).toEqual(defaultTestLength - i - 1);
expect(ba.selectionCount()).toEqual(ba.countAllOnes());
expect(ba.selectionCount).toEqual(defaultTestLength - i - 1);
expect(ba.selectionCount).toEqual(ba.countAllOnes());
}
ba.freeDimension(dim1);
@@ -1,441 +0,0 @@
import _ from "lodash";
import Crossfilter from "../../../src/util/typedCrossfilter";
const someData = [
{
date: "2011-11-14T16:17:54Z",
quantity: 2,
total: 190,
tip: 100,
type: "tab",
productIDs: ["001"],
coords: [0, 0],
nonFinite: 0.0
},
{
date: "2011-11-14T16:20:19Z",
quantity: 2,
total: 190,
tip: 100,
type: "tab",
productIDs: ["001", "005"],
coords: [0.4, 0.4],
nonFinite: Number.NaN
},
{
date: "2011-11-14T16:28:54Z",
quantity: 1,
total: 300,
tip: 200,
type: "visa",
productIDs: ["004", "005"],
coords: [0.3, 0.1],
nonFinite: Number.POSITIVE_INFINITY
},
{
date: "2011-11-14T16:30:43Z",
quantity: 2,
total: 90,
tip: 0,
type: "tab",
productIDs: ["001", "002"],
coords: [0.392, 0.1],
nonFinite: Number.NEGATIVE_INFINITY
},
{
date: "2011-11-14T16:48:46Z",
quantity: 2,
total: 90,
tip: 0,
type: "tab",
productIDs: ["005"],
coords: [0.7, 0.0482],
nonFinite: 1.0
},
{
date: "2011-11-14T16:53:41Z",
quantity: 2,
total: 90,
tip: 0,
type: "tab",
productIDs: ["001", "004", "005"],
coords: [0.9999, 1.0],
nonFinite: Number.NaN
},
{
date: "2011-11-14T16:54:06Z",
quantity: 1,
total: 100,
tip: 0,
type: "cash",
productIDs: ["001", "002", "003", "004", "005"],
coords: [0.384, 0.6938],
nonFinite: 99.0
},
{
date: "2011-11-14T16:58:03Z",
quantity: 2,
total: 90,
tip: 0,
type: "tab",
productIDs: ["001"],
coords: [0.4822, 0.482],
nonFinite: Number.NaN
},
{
date: "2011-11-14T17:07:21Z",
quantity: 2,
total: 90,
tip: 0,
type: "tab",
productIDs: ["004", "005"],
coords: [0.2234, 0],
nonFinite: Number.NaN
},
{
date: "2011-11-14T17:22:59Z",
quantity: 2,
total: 90,
tip: 0,
type: "tab",
productIDs: ["001", "002", "004", "005"],
coords: [0.382, 0.38485],
nonFinite: -1
},
{
date: "2011-11-14T17:25:45Z",
quantity: 2,
total: 200,
tip: 0,
type: "cash",
productIDs: ["002"],
coords: [0.998, 0.8472],
nonFinite: 0.0
},
{
date: "2011-11-14T17:29:52Z",
quantity: 1,
total: 200,
tip: 100,
type: "visa",
productIDs: ["004"],
coords: [0.8273, 0.3384],
nonFinite: 0.0
}
];
let payments = null;
beforeEach(() => {
payments = new Crossfilter(someData);
});
describe("ImmutableTypedCrossfilter", () => {
test("create crossfilter", () => {
expect(payments).toBeDefined();
expect(payments.size()).toEqual(someData.length);
expect(payments.all()).toEqual(someData);
const p = payments
.addDimension("quantity", "scalar", (i, d) => d[i].quantity, Int32Array)
.select("quantity", { mode: "all" });
expect(p).toBeDefined();
expect(p.all()).toEqual(someData);
expect(p.size()).toEqual(someData.length);
expect(p.isElementSelected(0)).toBeTruthy();
expect(p.countSelected()).toEqual(someData.length);
expect(p.allSelected()).toEqual(someData);
});
test("immutability", () => {
/*
the following should return a new crossfilter:
- addDimension()
- delDimension()
- select
*/
const p2 = payments.addDimension(
"quantity",
"scalar",
(i, data) => data[i].quantity,
Int32Array
);
expect(payments).not.toBe(p2);
const p3 = p2.select("quantity", { mode: "all" });
expect(p3).not.toBe(p2);
const p4 = p3.delDimension("quantity");
expect(p4).not.toBe(p3);
const p5 = p2.renameDimension("quantity", "Quantity");
expect(p5).not.toBe(p2);
});
test("select all and none", () => {
let p = payments
.addDimension("quantity", "scalar", (i, d) => d[i].quantity, Int32Array)
.addDimension("tip", "scalar", (i, d) => d[i].tip, Float32Array)
.addDimension("total", "scalar", (i, d) => d[i].total, Float32Array)
.addDimension("type", "enum", (i, d) => d[i].type);
expect(p).toBeDefined();
/* expect all records to be selected - default init state */
expect(p.allSelected()).toEqual(someData);
expect(p.countSelected()).toEqual(someData.length);
expect(p.allSelectedMask()).toEqual(
new Uint8Array(someData.length).fill(1)
);
expect(p.fillByIsSelected(new Uint8Array(someData.length), 99, 0)).toEqual(
new Uint8Array(someData.length).fill(99)
);
for (let i = 0; i < someData.length; i += 1) {
expect(p.isElementSelected(i)).toBeTruthy();
}
/* expect a selectAll on one dimension to change nothing */
p = p.select("tip", { mode: "all" });
expect(p.allSelected()).toEqual(someData);
/* ditto */
p = p.select("quantity", { mode: "all" });
expect(p.allSelected()).toEqual(someData);
/* select none on one dimension */
p = p.select("type", { mode: "none" });
expect(p.allSelected()).toEqual([]);
expect(p.countSelected()).toEqual(0);
expect(p.allSelectedMask()).toEqual(
new Uint8Array(someData.length).fill(0)
);
expect(p.fillByIsSelected(new Uint8Array(someData.length), 99, 0)).toEqual(
new Uint8Array(someData.length).fill(0)
);
for (let i = 0; i < someData.length; i += 1) {
expect(p.isElementSelected(i)).toBeFalsy();
}
p = p.select("quantity", { mode: "none" });
expect(p.allSelected()).toEqual([]);
// invert the first none; should have no effect because type is
// still not filtered.
p = p.select("quantity", { mode: "all" });
expect(p.allSelected()).toEqual([]);
/* select all of type; should select all records */
p = p.select("type", { mode: "all" });
expect(p.allSelected()).toEqual(someData);
});
describe("scalar dimension", () => {
let p;
beforeEach(() => {
p = payments
.addDimension("quantity", "scalar", (i, d) => d[i].quantity, Int32Array)
.addDimension("tip", "scalar", (i, d) => d[i].tip, Float32Array)
.select("tip", { mode: "all" });
});
/*
select modes: all, none, exact, range
*/
test("all", () => {
expect(p.select("quantity", { mode: "all" }).countSelected()).toEqual(
someData.length
);
});
test("none", () => {
expect(p.select("quantity", { mode: "none" }).countSelected()).toEqual(0);
});
test.each([[[]], [[2]], [[2, 1]], [[9, 82]], [[0, 1]]])("exact: %p", v =>
expect(
p.select("quantity", { mode: "exact", values: v }).countSelected()
).toEqual(_.filter(someData, d => v.includes(d.quantity)).length)
);
test.each([[0, 1], [1, 2], [0, 99], [99, 100000]])("range %p", (lo, hi) =>
expect(
p.select("quantity", { mode: "range", lo, hi }).countSelected()
).toEqual(
_.filter(someData, d => d.quantity >= lo && d.quantity < hi).length
)
);
test("bad mode", () => {
expect(() => p.select("type", { mode: "bad mode" })).toThrow(Error);
});
});
describe("enum dimension", () => {
let p;
beforeEach(() => {
p = payments.addDimension("type", "enum", (i, d) => d[i].type);
});
test("all", () => {
expect(p.select("type", { mode: "all" }).countSelected()).toEqual(
someData.length
);
});
test("none", () => {
expect(p.select("type", { mode: "none" }).countSelected()).toEqual(0);
});
test.each([
[[]],
[["tab"]],
[["visa"]],
[["visa", "tab"]],
[["cash", "tab", "visa"]]
])("exact: %p", v =>
expect(
p.select("type", { mode: "exact", values: v }).countSelected()
).toEqual(_.filter(someData, d => v.includes(d.type)).length)
);
test("range", () => {
expect(() => p.select("type", { mode: "range", lo: 0, hi: 9 })).toThrow(
Error
);
});
test("bad mode", () => {
expect(() => p.select("type", { mode: "bad mode" })).toThrow(Error);
});
});
describe("spatial dimension", () => {
let p;
beforeEach(() => {
const X = someData.map(r => r.coords[0]);
const Y = someData.map(r => r.coords[1]);
p = payments.addDimension("coords", "spatial", X, Y);
});
test("all", () => {
expect(p.select("coords", { mode: "all" }).countSelected()).toEqual(
someData.length
);
});
test("none", () => {
expect(p.select("coords", { mode: "none" }).countSelected()).toEqual(0);
});
test.each([[0, 0, 1, 1], [0, 0, 0.5, 0.5], [0.5, 0.5, 1, 1]])(
"within-rect %d %d %d %d",
(minX, minY, maxX, maxY) => {
expect(
p
.select("coords", { mode: "within-rect", minX, minY, maxX, maxY })
.allSelected()
).toEqual(
_.filter(someData, d => {
const [x, y] = d.coords;
return minX <= x && x < maxX && minY <= y && y < maxY;
})
);
}
);
test.each([
[
[[0, 0], [0, 1], [1, 1], [1, 0]],
[true, true, true, true, true, false, true, true, true, true, true, true]
],
[
[[0, 0], [0, 0.5], [0.5, 0.5], [0.5, 0]],
[true, true, true, true, false, false, false, true, true, true, false, false]
]
])("within-polygon %p", (polygon, expected) => {
expect(p.select("coords", { mode: "within-polygon", polygon }).allSelected())
.toEqual(_.zip(someData, expected).filter(x => x[1]).map(x => x[0]));
});
});
describe("non-finite scalars", () => {
let p;
beforeEach(() => {
p = payments
.addDimension("quantity", "scalar", (i, d) => d[i].quantity, Int32Array)
.addDimension(
"nonFinite",
"scalar",
(i, d) => d[i].nonFinite,
Float32Array
)
.select("quantity", { mode: "all" });
});
test("all or none", () => {
expect(p.select("nonFinite", { mode: "all" }).countSelected()).toEqual(
someData.length
);
expect(p.select("nonFinite", { mode: "none" }).countSelected()).toEqual(
0
);
});
test("exact", () => {
expect(
p.select("nonFinite", { mode: "exact", values: [0] }).countSelected()
).toEqual(3);
expect(
p.select("nonFinite", { mode: "exact", values: [1] }).countSelected()
).toEqual(1);
expect(
p
.select("nonFinite", {
mode: "exact",
values: [Number.POSITIVE_INFINITY]
})
.countSelected()
).toEqual(1);
expect(
p
.select("nonFinite", {
mode: "exact",
values: [Number.NEGATIVE_INFINITY]
})
.countSelected()
).toEqual(1);
expect(
p
.select("nonFinite", { mode: "exact", values: [Number.NaN] })
.countSelected()
).toEqual(4);
expect(
p
.select("nonFinite", {
mode: "exact",
values: [Number.POSITIVE_INFINITY, 0, 1, 99]
})
.countSelected()
).toEqual(6);
});
test("range", () => {
expect(
p
.select("nonFinite", {
mode: "range",
lo: 0,
hi: Number.POSITIVE_INFINITY
})
.countSelected()
).toEqual(5);
expect(
p
.select("nonFinite", {
mode: "range",
lo: 0,
hi: Number.NaN
})
.countSelected()
).toEqual(6);
expect(
p
.select("nonFinite", {
mode: "range",
lo: Number.NEGATIVE_INFINITY,
hi: Number.POSITIVE_INFINITY
})
.countSelected()
).toEqual(7);
});
});
});
@@ -1,22 +1,4 @@
import {
sortArray,
sortIndex,
lowerBound,
upperBound,
lowerBoundIndirect,
upperBoundIndirect
} from "../../../src/util/typedCrossfilter/sort";
/*
Sort tests should keep in mind that there are separate code
paths for:
- small vs. large arrays (insertionsort only)
- float-only typed arrays vs. other array types (non-finite handling)
- indexed vs. direct sort
*/
const pInf = Number.POSITIVE_INFINITY;
const nInf = Number.NEGATIVE_INFINITY;
import { sort, sortIndex } from "../../../src/util/typedCrossfilter/sort";
function fillRange(arr, start = 0) {
const larr = arr;
@@ -33,225 +15,42 @@ function fillRand(arr) {
return arr;
}
describe("sortArray", () => {
describe("JS vals", () => {
[
[true, false],
["a", "b", "0", "1"],
[0, "a", true, null, undefined, 3.1415],
fillRand(new Array(1000)),
["a", NaN, null, pInf]
].map((val, idx) =>
test(`JS vals ${idx}`, () => {
expect(sortArray(val)).toMatchObject(val.sort());
})
);
});
describe("sort", () => {
[Array, Float32Array, Uint32Array, Int32Array, Float64Array].map(Type =>
test(Type.name, () => {
expect(sort(Type.from([6, 5, 4, 3, 2, 1, 0]))).toMatchObject(
Type.from([0, 1, 2, 3, 4, 5, 6])
);
expect(sort(Type.from([6, 5, 4, 3, 2, 1]))).toMatchObject(
Type.from([1, 2, 3, 4, 5, 6])
);
describe("finite numbers", () => {
[Array, Float32Array, Uint32Array, Int32Array, Float64Array].map(Type =>
test(Type.name, () => {
expect(sortArray(Type.from([6, 5, 4, 3, 2, 1, 0]))).toMatchObject(
Type.from([0, 1, 2, 3, 4, 5, 6])
);
expect(sortArray(Type.from([6, 5, 4, 3, 2, 1]))).toMatchObject(
Type.from([1, 2, 3, 4, 5, 6])
);
const source = fillRand(new Type(1000));
expect(sortArray(Type.from(source))).toMatchObject(
Type.from(source).sort()
);
})
);
});
describe("non-finite numbers", () => {
test("inifinity", () => {
expect(sortArray(new Float32Array([pInf, nInf, 0, 1, 2]))).toMatchObject(
new Float32Array([nInf, 0, 1, 2, pInf])
);
expect(
sortArray(new Float32Array([pInf, nInf, pInf, nInf]))
).toMatchObject(new Float32Array([nInf, nInf, pInf, pInf]));
expect(
sortArray(new Float32Array([pInf, nInf, pInf, nInf, pInf]))
).toMatchObject(new Float32Array([nInf, nInf, pInf, pInf, pInf]));
expect(
sortArray(
new Float32Array(100).fill(Infinity, 0, 50).fill(-Infinity, 50, 100)
)
).toMatchObject(
new Float32Array(100).fill(-Infinity, 0, 50).fill(Infinity, 50, 100)
);
});
test("NaN", () => {
expect(sortArray(new Float64Array([NaN, 2, 1, 0]))).toMatchObject(
new Float64Array([0, 1, 2, NaN])
);
expect(sortArray(new Float32Array([NaN, 2, 1, 0]))).toMatchObject(
new Float32Array([0, 1, 2, NaN])
);
expect(sortArray(new Float32Array([NaN, 2, NaN, 1, 0]))).toMatchObject(
new Float32Array([0, 1, 2, NaN, NaN])
);
expect(sortArray(new Float32Array([NaN, 2, 1, NaN, 0]))).toMatchObject(
new Float32Array([0, 1, 2, NaN, NaN])
);
expect(
sortArray(fillRange(new Float32Array(100)).fill(NaN, 0, 10))
).toMatchObject(fillRange(new Float32Array(100), 10).fill(NaN, 90, 100));
});
test("mixed numbers", () => {
expect(
sortArray(new Float32Array([NaN, pInf, nInf, NaN, NaN]))
).toMatchObject(new Float32Array([nInf, pInf, NaN, NaN, NaN]));
expect(
sortArray(new Float32Array([NaN, pInf, nInf, NaN, 1, NaN, 2]))
).toMatchObject(new Float32Array([nInf, 1, 2, pInf, NaN, NaN, NaN]));
expect(
sortArray(new Float32Array([NaN, pInf, nInf, 0, 1, NaN, 2]))
).toMatchObject(new Float32Array([nInf, 0, 1, 2, pInf, NaN, NaN]));
expect(
sortArray(
fillRange(new Float32Array(100))
.fill(NaN, 0, 10)
.fill(Infinity, 10, 20)
)
).toMatchObject(
fillRange(new Float32Array(100), 20)
.fill(Infinity, 80, 90)
.fill(NaN, 90, 100)
);
});
});
const source = fillRand(new Type(1000));
expect(sort(Type.from(source))).toMatchObject(Type.from(source).sort());
})
);
});
describe("sortIndex", () => {
describe("finite numbers", () => {
[Array, Float32Array, Uint32Array, Int32Array, Float64Array].map(Type =>
test(Type.name, () => {
const source1 = Type.from([6, 5, 4, 3, 2, 1, 0]);
const index1 = fillRange(new Uint32Array(source1.length));
expect(sortIndex(index1, source1)).toMatchObject(
index1.sort((a, b) => source1[a] - source1[b])
);
const source2 = Type.from([6, 5, 4, 3, 2, 1]);
const index2 = fillRange(new Uint32Array(source2.length));
expect(sortIndex(index2, source2)).toMatchObject(
index2.sort((a, b) => source1[a] - source1[b])
);
const source3 = fillRand(new Type(1000));
const index3 = fillRange(new Uint32Array(source3.length));
expect(sortIndex(index3, source3)).toMatchObject(
index3.sort((a, b) => source1[a] - source1[b])
);
})
);
});
describe("non-finite numbers", () => {
test("mixed numbers", () => {
const source1 = new Float32Array([NaN, pInf, nInf, NaN, 1, NaN, 2]);
[Array, Float32Array, Uint32Array, Int32Array, Float64Array].map(Type =>
test(Type.name, () => {
const source1 = Type.from([6, 5, 4, 3, 2, 1, 0]);
const index1 = fillRange(new Uint32Array(source1.length));
expect(sortIndex(index1, source1)).toMatchObject(
new Uint32Array([2, 4, 6, 1, 0, 3, 5])
index1.sort((a, b) => source1[a] - source1[b])
);
const source2 = new Float32Array([NaN, pInf, nInf, 0, 1, NaN, 2]);
const source2 = Type.from([6, 5, 4, 3, 2, 1]);
const index2 = fillRange(new Uint32Array(source2.length));
expect(sortIndex(index2, source2)).toMatchObject(
new Uint32Array([2, 3, 4, 6, 1, 0, 5])
index2.sort((a, b) => source1[a] - source1[b])
);
});
});
});
describe("lowerBound", () => {
test("non-float path", () => {
expect(lowerBound([], 0, 0, 0)).toEqual(0);
expect(lowerBound([0, 1, 2, 3], -1, 0, 4)).toEqual(0);
expect(lowerBound([0, 1, 2, 3], 0, 0, 4)).toEqual(0);
expect(lowerBound([0, 1, 2, 3], 1, 0, 4)).toEqual(1);
expect(lowerBound([0, 1, 2, 3], 3, 0, 4)).toEqual(3);
expect(lowerBound([0, 1, 2, 3], 4, 0, 4)).toEqual(4);
expect(lowerBound([0, 1, 2, 3], 4, 0, 3)).toEqual(3);
expect(lowerBound([0, 1, 2, 3, 4], -1, 0, 5)).toEqual(0);
expect(lowerBound([0, 1, 2, 3, 4], 0, 0, 5)).toEqual(0);
expect(lowerBound([0, 1, 2, 3, 4], 2, 0, 5)).toEqual(2);
expect(lowerBound([0, 1, 2, 3, 4], 4, 0, 5)).toEqual(4);
expect(lowerBound([0, 1, 2, 3, 4], 5, 0, 5)).toEqual(5);
expect(lowerBound([0, 2, 4, 6, 8], 5, 0, 5)).toEqual(3);
expect(lowerBound([0, 2, 2, 2, 8], 5, 0, 5)).toEqual(4);
expect(lowerBound([0, 1, 2, 3, 4, 5, 6, 7, 8], 3, 2, 4)).toEqual(3);
expect(lowerBound([0, 1, 2, 3, 4, 5, 6, 7, 8], 99, 2, 4)).toEqual(4);
});
test("float path, finites", () => {
expect(lowerBound(new Float32Array([0, 1, 2, 3]), 1, 0, 4)).toEqual(1);
expect(lowerBound(new Float32Array([]), 0, 0, 0)).toEqual(0);
expect(lowerBound(new Float32Array([0, 1, 2, 3]), -1, 0, 4)).toEqual(0);
expect(lowerBound(new Float32Array([0, 1, 2, 3]), 0, 0, 4)).toEqual(0);
expect(lowerBound(new Float32Array([0, 1, 2, 3]), 1, 0, 4)).toEqual(1);
expect(lowerBound(new Float32Array([0, 1, 2, 3]), 3, 0, 4)).toEqual(3);
expect(lowerBound(new Float32Array([0, 1, 2, 3]), 4, 0, 4)).toEqual(4);
expect(lowerBound(new Float32Array([0, 1, 2, 3]), 4, 0, 3)).toEqual(3);
expect(lowerBound(new Float32Array([0, 1, 2, 3, 4]), -1, 0, 5)).toEqual(0);
expect(lowerBound(new Float32Array([0, 1, 2, 3, 4]), 0, 0, 5)).toEqual(0);
expect(lowerBound(new Float32Array([0, 1, 2, 3, 4]), 2, 0, 5)).toEqual(2);
expect(lowerBound(new Float32Array([0, 1, 2, 3, 4]), 4, 0, 5)).toEqual(4);
expect(lowerBound(new Float32Array([0, 1, 2, 3, 4]), 5, 0, 5)).toEqual(5);
expect(lowerBound(new Float32Array([0, 2, 4, 6, 8]), 5, 0, 5)).toEqual(3);
expect(lowerBound(new Float32Array([0, 2, 2, 2, 8]), 5, 0, 5)).toEqual(4);
expect(
lowerBound(new Float32Array([0, 1, 2, 3, 4, 5, 6, 7, 8]), 3, 2, 4)
).toEqual(3);
expect(
lowerBound(new Float32Array([0, 1, 2, 3, 4, 5, 6, 7, 8]), 99, 2, 4)
).toEqual(4);
});
test("float path, non-finite", () => {
expect(
lowerBound(
new Float32Array([-Infinity, 0, 1, Infinity, NaN]),
-Infinity,
0,
5
)
).toEqual(0);
expect(
lowerBound(new Float32Array([-Infinity, 0, 1, Infinity, NaN]), 0, 0, 5)
).toEqual(1);
expect(
lowerBound(new Float32Array([-Infinity, 0, 1, Infinity, NaN]), 1, 0, 5)
).toEqual(2);
expect(
lowerBound(new Float32Array([-Infinity, 0, 1, Infinity, NaN]), 2, 0, 5)
).toEqual(3);
expect(
lowerBound(
new Float32Array([-Infinity, 0, 1, Infinity, NaN]),
Infinity,
0,
5
)
).toEqual(3);
expect(
lowerBound(new Float32Array([-Infinity, 0, 1, Infinity, NaN]), NaN, 0, 5)
).toEqual(4);
});
const source3 = fillRand(new Type(1000));
const index3 = fillRange(new Uint32Array(source3.length));
expect(sortIndex(index3, source3)).toMatchObject(
index3.sort((a, b) => source1[a] - source1[b])
);
})
);
});
@@ -0,0 +1,462 @@
// jshint esversion: 6
import _ from "lodash";
import crossfilter from "../../../src/util/typedCrossfilter";
const someData = [
{
date: "2011-11-14T16:17:54Z",
quantity: 2,
total: 190,
tip: 100,
type: "tab",
productIDs: ["001"]
},
{
date: "2011-11-14T16:20:19Z",
quantity: 2,
total: 190,
tip: 100,
type: "tab",
productIDs: ["001", "005"]
},
{
date: "2011-11-14T16:28:54Z",
quantity: 1,
total: 300,
tip: 200,
type: "visa",
productIDs: ["004", "005"]
},
{
date: "2011-11-14T16:30:43Z",
quantity: 2,
total: 90,
tip: 0,
type: "tab",
productIDs: ["001", "002"]
},
{
date: "2011-11-14T16:48:46Z",
quantity: 2,
total: 90,
tip: 0,
type: "tab",
productIDs: ["005"]
},
{
date: "2011-11-14T16:53:41Z",
quantity: 2,
total: 90,
tip: 0,
type: "tab",
productIDs: ["001", "004", "005"]
},
{
date: "2011-11-14T16:54:06Z",
quantity: 1,
total: 100,
tip: 0,
type: "cash",
productIDs: ["001", "002", "003", "004", "005"]
},
{
date: "2011-11-14T16:58:03Z",
quantity: 2,
total: 90,
tip: 0,
type: "tab",
productIDs: ["001"]
},
{
date: "2011-11-14T17:07:21Z",
quantity: 2,
total: 90,
tip: 0,
type: "tab",
productIDs: ["004", "005"]
},
{
date: "2011-11-14T17:22:59Z",
quantity: 2,
total: 90,
tip: 0,
type: "tab",
productIDs: ["001", "002", "004", "005"]
},
{
date: "2011-11-14T17:25:45Z",
quantity: 2,
total: 200,
tip: 0,
type: "cash",
productIDs: ["002"]
},
{
date: "2011-11-14T17:29:52Z",
quantity: 1,
total: 200,
tip: 100,
type: "visa",
productIDs: ["004"]
}
];
function groupReduce(data, valueMap, valueReduce, valueInit) {
return _
.reduce(
data,
(acc, value) => {
const k = valueMap(value);
let r = _.find(acc, o => o.key === k);
if (!r) {
r = { key: k, value: valueInit() };
acc.push(r);
}
r.value = valueReduce(r.value, value);
return acc;
},
[]
)
.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
}
function groupCount(data, map) {
return groupReduce(data, map, (p, v) => p + 1, () => 0);
}
function groupSum(data, map) {
return groupReduce(data, map, (p, v) => (p += map(v)), () => 0);
}
var payments = null;
beforeEach(() => {
payments = crossfilter(someData);
});
describe("typedCrossfilter", () => {
test("alloc and free", () => {
expect(payments).toBeDefined();
expect(payments.size()).toEqual(someData.length);
expect(payments.all()).toEqual(someData);
const quantity = payments.dimension(r => r.quantity, Int32Array);
expect(quantity).toBeDefined();
expect(quantity.id()).toBeDefined();
quantity.dispose();
expect(payments.size()).toEqual(someData.length);
expect(payments.all()).toEqual(someData);
});
test("filterAll and filterNone", () => {
expect(payments).toBeDefined();
const quantity = payments.dimension(r => r.quantity, Int32Array);
const tip = payments.dimension(r => r.tip, Float32Array);
const total = payments.dimension(r => r.total, Float32Array);
const type = payments.dimension(r => r.type, "enum");
expect(quantity).toBeDefined();
expect(tip).toBeDefined();
expect(total).toBeDefined();
expect(type).toBeDefined();
// initially, all should be filtered
expect(payments.allFiltered()).toHaveLength(payments.size());
expect(payments.allFiltered()).toEqual(payments.all());
expect(payments.countFiltered()).toEqual(someData.length);
// filterAll
tip.filterAll(); // should change nothing
expect(payments.allFiltered()).toEqual(payments.all());
expect(payments.countFiltered()).toEqual(someData.length);
// ditto
total.filterAll();
expect(payments.allFiltered()).toEqual(payments.all());
expect(payments.countFiltered()).toEqual(someData.length);
// filterNone
type.filterNone();
expect(payments.allFiltered()).toEqual([]);
expect(payments.countFiltered()).toEqual(0);
quantity.filterNone();
expect(payments.allFiltered()).toEqual([]);
expect(payments.countFiltered()).toEqual(0);
// invert the first none; should have no effect because type is
// still not filtered
quantity.filterAll();
expect(payments.allFiltered()).toEqual([]);
expect(payments.countFiltered()).toEqual(0);
// filter all of type; should select all
type.filterAll();
expect(payments.allFiltered()).toEqual(payments.all());
expect(payments.countFiltered()).toEqual(payments.size());
});
test("filterExact", () => {
expect(payments).toBeDefined();
const quantity = payments.dimension(r => r.quantity, Int32Array);
const tip = payments.dimension(r => r.tip, Float32Array);
const total = payments.dimension(r => r.total, Float32Array);
const type = payments.dimension(r => r.type, "enum");
quantity.filterExact(1);
expect(payments.countFiltered()).toEqual(
_.countBy(someData, "quantity")[1]
);
expect(payments.allFiltered()).toEqual(_.filter(someData, { quantity: 1 }));
tip.filterExact(0);
expect(payments.allFiltered()).toEqual(
_.filter(someData, { tip: 0, quantity: 1 })
);
type.filterExact("cash");
expect(payments.allFiltered()).toEqual(
_.filter(someData, { tip: 0, quantity: 1, type: "cash" })
);
});
test("filterRange", () => {
expect(payments).toBeDefined();
const quantity = payments.dimension(r => r.quantity, Int32Array);
const tip = payments.dimension(r => r.tip, Float32Array);
const total = payments.dimension(r => r.total, Float32Array);
const type = payments.dimension(r => r.type, "enum");
tip.filterRange([0, 91]);
expect(payments.allFiltered()).toEqual(
_(someData)
.filter(r => r.tip >= 0 && r.tip < 91)
.value()
);
tip.filterRange([0, 90]);
expect(payments.allFiltered()).toEqual(
_(someData)
.filter(r => r.tip >= 0 && r.tip < 90)
.value()
);
tip.filterRange([1, 90]);
expect(payments.allFiltered()).toEqual(
_(someData)
.filter(r => r.tip >= 1 && r.tip < 91)
.value()
);
});
test("filterEnum", () => {
expect(payments).toBeDefined();
const quantity = payments.dimension(r => r.quantity, Int32Array);
const tip = payments.dimension(r => r.tip, Float32Array);
const total = payments.dimension(r => r.total, Float32Array);
const type = payments.dimension(r => r.type, "enum");
type.filterEnum(["tab", "cash"]);
expect(payments.allFiltered()).toEqual(
_(someData)
.filter(r => r.type === "cash" || r.type === "tab")
.value()
);
tip.filterEnum([0, 100]);
expect(payments.allFiltered()).toEqual(
_(someData)
.filter(r => r.type === "cash" || r.type === "tab")
.filter(r => r.tip === 0 || r.tip === 100)
.value()
);
});
test("more than 32 dimensions", () => {
expect(payments).toBeDefined();
const quantity = payments.dimension(r => r.quantity, Int32Array);
const tip = payments.dimension(r => r.tip, Float32Array);
const total = payments.dimension(r => r.total, Float32Array);
const type = payments.dimension(r => r.type, "enum");
// Create a bunch of fake dimensions to ensure we can handle > 32
let dimMap = {};
for (let i = 0; i < 65; i++) {
dimMap[i] = payments.dimension(r => Math.random(), Float32Array);
expect(dimMap[i]).toBeDefined();
expect(dimMap[i].id()).toBeDefined();
}
// everything should start as selected/filtered
expect(payments.countFiltered()).toEqual(someData.length);
dimMap[0].filterAll();
dimMap[64].filterAll();
expect(payments.countFiltered()).toEqual(someData.length);
dimMap[33].filterNone();
expect(payments.allFiltered()).toEqual([]);
dimMap[33].filterAll();
expect(payments.allFiltered()).toEqual(someData);
});
test("group, default mapping, default reducer, no filter", () => {
expect(payments).toBeDefined();
var quantity = payments.dimension(r => r.quantity, Int32Array);
var tip = payments.dimension(r => r.tip, Int32Array);
var type = payments.dimension(r => r.type, "enum");
var total = payments.dimension(r => r.total, Int32Array);
_.each(
{
tip: tip.group(r => r),
type: type.group(),
total: total.group(),
quantity: quantity.group()
},
(grp, k) => {
const whatWeExpect = groupCount(someData, v => v[k]);
expect(grp.all()).toEqual(whatWeExpect);
expect(grp.size()).toEqual(whatWeExpect.length);
expect(grp.dispose()).toEqual(grp);
}
);
});
test("group, custom map, default reducer, no filters", () => {
expect(payments).toBeDefined();
// custom mapping in groups only works for scalar types. Enums do not
// currently implement it.
const tip = payments.dimension(r => r.tip, Int32Array);
const totalX10 = payments.dimension(r => r.total * 10, Int32Array);
const type = payments.dimension(r => r.type, "enum");
const paymentsByTip_A = tip.group();
const paymentsByTip_B = tip.group(r => 10 * r);
const paymentsByType = type.group(); // identity only
const paymentsByTotalX10_A = totalX10.group();
const paymentsByTotalX10_B = totalX10.group(r => r / 10);
expect(paymentsByTip_A.all()).toEqual(groupCount(someData, v => v.tip));
expect(paymentsByTip_B.all()).toEqual(
groupCount(someData, v => 10 * v.tip)
);
expect(paymentsByType.all()).toEqual(groupCount(someData, v => v.type));
expect(paymentsByTotalX10_A.all()).toEqual(
groupCount(someData, v => 10 * v.total)
);
expect(paymentsByTotalX10_B.all()).toEqual(
groupCount(someData, v => (10 * v.total) / 10)
);
for (let i of [
paymentsByTip_A,
paymentsByTip_B,
paymentsByType,
paymentsByTotalX10_A,
paymentsByTotalX10_B,
tip,
totalX10,
type
]) {
expect(i.dispose()).toEqual(i);
}
});
test("group, default map, custom reducer, no filters", () => {
expect(payments).toBeDefined();
const total = payments.dimension(r => r.total, Float32Array);
const type = payments.dimension(r => r.type, "enum");
const paymentsByTotal = total.group();
const paymentsByType = type.group();
// reduceCount
expect(paymentsByTotal.reduceCount()).toEqual(paymentsByTotal);
expect(paymentsByTotal.all()).toEqual(groupCount(someData, v => v.total));
// reduceSum
expect(paymentsByTotal.reduceSum(v => v.total)).toEqual(paymentsByTotal);
expect(paymentsByTotal.all()).toEqual(groupSum(someData, v => v.total));
// use custom reducers (my reducers) - count by three, init 1
expect(
paymentsByTotal.reduce((p, v) => (p += 3), (p, v) => (p -= 3), () => 1)
).toEqual(paymentsByTotal);
expect(paymentsByTotal.all()).toEqual(
groupReduce(someData, v => v.total, (p, v) => p + 3, () => 1)
);
for (let i of [paymentsByTotal, paymentsByType, type]) {
expect(i.dispose()).toEqual(i);
}
});
test("group, default map, default reducer, filters", () => {
// From the docs:
// Note: a grouping intersects the crossfilter's current filters, except for the
// associated dimension's filter. Thus, group methods consider only records that
// satisfy every filter except this dimension's filter. So, if the crossfilter of
// payments is filtered by type and total, then group by total only observes the
// filter by type.
expect(payments).toBeDefined();
const tip = payments.dimension(r => r.tip, Int32Array);
const total = payments.dimension(r => r.total, Int32Array);
const type = payments.dimension(r => r.type, "enum");
const paymentsByTip = tip.group();
const paymentsByTotal = total.group();
const paymentsByType = type.group();
// 1. confirm that changing the filter on a dimension does NOT change that
// dimensions groups.
{
tip.filterAll(), total.filterAll(), type.filterAll();
let before = _.cloneDeep(paymentsByTip.all());
tip.filterExact(0);
expect(paymentsByTip.all()).toEqual(before);
}
// 2. confirm that changing a filter on a different dimension DOES change
// all other groups.
{
tip.filterAll(), total.filterAll(), type.filterAll();
const before = _.cloneDeep([paymentsByTotal.all(), paymentsByType.all()]);
tip.filterExact(0);
const after = [paymentsByTotal.all(), paymentsByType.all()];
expect(after).not.toEqual(before);
expect(after).toEqual([
groupReduce(
someData,
v => v.total,
(p, v) => (v.tip !== 0 ? p : p + 1),
() => 0
),
groupReduce(
someData,
v => v.type,
(p, v) => (v.tip !== 0 ? p : p + 1),
() => 0
)
]);
}
for (let i of [
paymentsByTip,
paymentsByTotal,
paymentsByType,
tip,
total,
type
]) {
expect(i.dispose()).toEqual(i);
}
});
});
@@ -1,8 +1,11 @@
import {
fillRange,
sliceByIndex,
makeSortIndex
makeSortIndex,
lowerBound,
lowerBoundIndirect,
upperBoundIndirect
} from "../../../src/util/typedCrossfilter/util";
import { rangeFill as fillRange } from "../../../src/util/range";
describe("fillRange", () => {
test("Array", () => {
+2 -4
View File
@@ -7,10 +7,8 @@ module.exports = {
],
plugins: [
"@babel/plugin-proposal-function-bind",
"@babel/plugin-proposal-class-properties",
["@babel/plugin-proposal-decorators", { legacy: true }],
["@babel/plugin-proposal-class-properties", { loose: true }],
"@babel/plugin-proposal-export-namespace-from",
"@babel/plugin-proposal-optional-chaining",
"@babel/plugin-proposal-nullish-coalescing-operator"
"@babel/plugin-proposal-export-namespace-from"
]
};
+2 -4
View File
@@ -6,12 +6,10 @@ module.exports = {
],
plugins: [
"@babel/plugin-proposal-function-bind",
"@babel/plugin-proposal-class-properties",
["@babel/plugin-proposal-decorators", { legacy: true }],
["@babel/plugin-proposal-class-properties", { loose: true }],
"@babel/plugin-proposal-export-namespace-from",
"@babel/plugin-transform-react-constant-elements",
"@babel/plugin-transform-runtime",
"@babel/plugin-proposal-optional-chaining",
"@babel/plugin-proposal-nullish-coalescing-operator"
"@babel/plugin-transform-runtime"
]
};
@@ -4,7 +4,6 @@ const webpack = require("webpack");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const src = path.resolve("src");
const fonts = path.resolve("src/fonts");
const nodeModules = path.resolve("node_modules");
const babelOptions = require("../babel/babel.dev");
@@ -38,9 +37,8 @@ module.exports = {
{
loader: "css-loader",
options: {
modules: {
localIdentName: "[name]__[local]___[hash:base64:5]"
}
modules: true,
localIdentName: "[name]__[local]___[hash:base64:5]"
}
}
]
@@ -61,16 +59,10 @@ module.exports = {
{ test: /\.json$/, include: [src, nodeModules], loader: "json-loader" },
{
test: /\.(jpg|png|gif|eot|svg|ttf|woff|woff2)(\?.*)?$/,
include: nodeModules,
include: [src, nodeModules],
loader: "file-loader",
query: { name: "static/media/[name].[ext]" }
},
{
test: /\.(woff|woff2|eot|ttf|otf)$/,
include: fonts,
loader: "file-loader",
query: { name: "static/fonts/[name].[ext]" }
},
{
test: /\.(mp4|webm)(\?.*)?$/,
include: [src, nodeModules],
@@ -85,9 +77,6 @@ module.exports = {
template: path.resolve("index.html"),
favicon: path.resolve("favicon.png")
}),
new webpack.NoEmitOnErrorsPlugin(),
new webpack.DefinePlugin({
__REACT_DEVTOOLS_GLOBAL_HOOK__: "({ isDisabled: true })"
})
new webpack.NoEmitOnErrorsPlugin()
]
};
@@ -6,12 +6,11 @@ const SWPrecacheWebpackPlugin = require("sw-precache-webpack-plugin");
const HtmlWebpackInlineSourcePlugin = require("html-webpack-inline-source-plugin");
const src = path.resolve("src");
const fonts = path.resolve("src/fonts");
const nodeModules = path.resolve("node_modules");
const babelOptions = require("../babel/babel.prod");
const publicPath = "";
const publicPath = "/";
module.exports = {
mode: "production",
@@ -41,10 +40,9 @@ module.exports = {
{
loader: "css-loader",
options: {
modules: {
localIdentName: "[name]__[local]___[hash:base64:5]"
},
importLoaders: 1
modules: true,
importLoaders: 1,
localIdentName: "[name]__[local]___[hash:base64:5]"
}
}
]
@@ -70,15 +68,12 @@ module.exports = {
},
{
test: /\.(jpg|png|gif|eot|svg|ttf|woff|woff2)(\?.*)?$/,
include: nodeModules,
include: [src, nodeModules],
loader: "file-loader",
query: { name: "static/media/[name].[ext]" }
},
{
test: /\.(woff|woff2|eot|ttf|otf)$/,
include: fonts,
loader: "file-loader",
query: { name: "static/fonts/[name].[ext]" }
options: {
name: "[name].[hash:8].[ext]",
outputPath: "static/media/"
}
},
{
test: /\.(mp4|webm)(\?.*)?$/,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 312 B

After

Width:  |  Height:  |  Size: 4.4 KiB

+2 -1
View File
@@ -3,7 +3,8 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>cell&times;gene</title>
<title>cellxgene</title>
<link href="https://fonts.googleapis.com/css?family=Roboto+Condensed:400,400i,700" rel="stylesheet">
<style>
html, body, p, h1, h2, h3, h4, h5, h6, span, button, input, label, text, div {
font-family: 'Roboto Condensed','Helvetica Neue','Helvetica','Arial',sans-serif;
+24 -30
View File
@@ -1,41 +1,35 @@
<!DOCTYPE html>
<html lang="en">
<head>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>cell&times;gene</title>
<title>cellxgene</title>
<link href="https://fonts.googleapis.com/css?family=Roboto+Condensed:400,400i,700" rel="stylesheet">
<style>
html, body, p, h1, h2, h3, h4, h5, h6, span, button, input, label, text, div {
font-family: 'Roboto Condensed', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif;
font-size: 14px;
}
html, body, p, h1, h2, h3, h4, h5, h6, span, button, input, label, text, div {
font-family: 'Roboto Condensed','Helvetica Neue','Helvetica','Arial',sans-serif;
font-size: 14px;
}
body {
margin: 0;
padding: 0;
}
body {
margin: 0;
padding: 0;
}
* {
box-sizing: border-box;
}
* {
box-sizing: border-box;
}
</style>
</head>
<body>
</head>
<body>
<script type="text/javascript">
window.CELLXGENE = {};
window.CELLXGENE.API = {
prefix: window.location.href + "api/",
version: "v0.2/"
};
window.CELLXGENE = {};
window.CELLXGENE.API = {
prefix: "{{ prefix | safe }}",
version: "v0.2/"
};
</script>
<noscript>If you're seeing this message, that means <strong>JavaScript has been disabled on your browser</strong>,
please <strong>enable JS</strong> to make this app work.
</noscript>
<noscript>If you're seeing this message, that means <strong>JavaScript has been disabled on your browser</strong>, please <strong>enable JS</strong> to make this app work.</noscript>
<div id="root"></div>
{% for script in SCRIPTS %}
<script type="text/javascript" src="{{script | safe}}"></script>
{% endfor %}
</body>
<div id="root"></div>
</body>
</html>
+5924 -7752
View File
File diff suppressed because it is too large Load Diff
+76 -91
View File
@@ -1,24 +1,16 @@
{
"name": "cellxgene",
"version": "0.14.1",
"version": "0.2.4",
"license": "MIT",
"description": "cellxgene is a web application for the interactive exploration of single cell sequence data.",
"repository": "https://github.com/chanzuckerberg/cellxgene",
"scripts": {
"backend-dev": "python3.6 -m venv cellxgene && source cellxgene/bin/activate && yes | pip uninstall cellxgene || true && pip install -e .. && cellxgene launch ",
"backend-dev-anno-ontology": "python3.6 -m venv cellxgene && source cellxgene/bin/activate && yes | pip uninstall cellxgene || true && pip install -e .. && cellxgene launch --experimental-annotations --experimental-annotations-ontology",
"backend-dev-anno": "python3.6 -m venv cellxgene && source cellxgene/bin/activate && yes | pip uninstall cellxgene || true && pip install -e .. && cellxgene launch --experimental-annotations",
"build": "npm run clean && webpack --config configuration/webpack/webpack.config.prod.js",
"clean": "rimraf build",
"dev": "npm run clean && webpack --config configuration/webpack/webpack.config.dev.js",
"e2e": "node node_modules/jest/bin/jest.js --verbose false --config __tests__/e2e/e2eJestConfig.json e2e/e2e.test.js",
"fmt": "eslint --fix src",
"lint": "eslint src",
"smoke-test": "start-server-and-test start-server-for-test :5000 e2e",
"clean": "rimraf build",
"start": "node server/development.js",
"start-server-for-test": "cellxgene launch -p 5000 ../example-dataset/pbmc3k.h5ad",
"test": "node node_modules/jest/bin/jest.js",
"unit-test": "node node_modules/jest/bin/jest.js --testPathIgnorePatterns e2e"
"lint": "eslint src",
"test": "jest"
},
"engineStrict": true,
"engines": {
@@ -27,97 +19,97 @@
"eslintConfig": {
"extends": "./configuration/eslint/eslint.js"
},
"eslintIgnore": [
"src/util/stateManager/matrix_generated.js"
],
"nyc": {
"sourceMap": false,
"instrument": false
},
"resolutions": {
"eslint-scope": "3.7.1"
},
"dependencies": {
"@blueprintjs/core": "^3.22.3",
"@blueprintjs/icons": "^3.13.0",
"@blueprintjs/select": "^3.11.2",
"client": "0.0.1",
"@blueprintjs/core": "^3.8.0",
"@blueprintjs/icons": "^3.3.0",
"@blueprintjs/select": "^3.2.1",
"canvas-fit": "^1.5.0",
"d3": "^4.10.0",
"d3-scale-chromatic": "^1.5.0",
"flatbuffers": "^1.11.0",
"fuzzysort": "^1.1.4",
"d3-scale-chromatic": "^1.3.0",
"font-color-contrast": "^1.0.3",
"gl-mat4": "^1.1.4",
"gl-matrix": "^3.1.0",
"gl-vec3": "^1.1.3",
"is-number": "^7.0.0",
"lodash": "^4.17.15",
"memoize-one": "^5.1.1",
"react": "^16.12.0",
"gl-matrix": "^2.7.1",
"key-pressed": "0.0.1",
"lodash": "^4.17.4",
"memoize-one": "^4.0.0",
"mouse-position": "^2.0.1",
"mouse-pressed": "^1.0.0",
"normalize.css": "^8.0.0",
"orbit-camera": "^1.0.0",
"query-string": "^6.1.0",
"react": "^16.6.0",
"react-autocomplete": "^1.7.2",
"react-dom": "^16.12.0",
"react-flip-toolkit": "7.0.6",
"react-helmet": "^5.2.1",
"react-icons": "^3.8.0",
"react-redux": "^7.1.3",
"redux": "^4.0.5",
"react-dom": "^16.6.0",
"react-helmet": "^5.2.0",
"react-icons": "^3.2.2",
"react-redux": "^5.1.0",
"redux": "^4.0.1",
"redux-devtools-extension": "^2.13.5",
"redux-thunk": "^2.2.0",
"regl": "^1.3.13"
"regl": "^1.3.9",
"scroll-speed": "^1.0.0",
"urijs": "^1.19.0"
},
"devDependencies": {
"@babel/core": "^7.7.7",
"@babel/plugin-proposal-class-properties": "^7.7.4",
"@babel/plugin-proposal-decorators": "^7.7.4",
"@babel/plugin-proposal-export-namespace-from": "^7.7.4",
"@babel/plugin-proposal-function-bind": "^7.7.4",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.7.4",
"@babel/plugin-proposal-optional-chaining": "^7.7.5",
"@babel/plugin-transform-react-constant-elements": "^7.7.4",
"@babel/plugin-transform-runtime": "^7.7.6",
"@babel/preset-env": "^7.7.7",
"@babel/preset-react": "^7.7.4",
"@babel/register": "^7.7.7",
"@babel/runtime": "^7.7.7",
"babel-eslint": "^10.0.3",
"babel-jest": "^24.9.0",
"babel-loader": "^8.0.6",
"babel-preset-modern-browsers": "^14.1.1",
"chalk": "^3.0.0",
"connect-history-api-fallback": "^1.6.0",
"copy-webpack-plugin": "^5.1.1",
"css-loader": "^3.4.1",
"eslint": "^6.8.0",
"eslint-config-airbnb": "^18.0.1",
"eslint-config-prettier": "^6.9.0",
"eslint-loader": "^3.0.3",
"@babel/core": "^7.1.5",
"@babel/plugin-proposal-class-properties": "^7.0.0",
"@babel/plugin-proposal-decorators": "^7.0.0",
"@babel/plugin-proposal-export-namespace-from": "^7.0.0",
"@babel/plugin-proposal-function-bind": "^7.0.0",
"@babel/plugin-transform-react-constant-elements": "^7.0.0",
"@babel/plugin-transform-runtime": "^7.1.0",
"@babel/preset-env": "^7.1.5",
"@babel/preset-react": "^7.0.0",
"@babel/register": "^7.0.0",
"@babel/runtime": "^7.1.5",
"babel-core": "^7.0.0-bridge.0",
"babel-eslint": "^10.0.1",
"babel-jest": "^23.6.0",
"babel-loader": "^8.0.0",
"babel-plugin-istanbul": "^5.1.0",
"babel-preset-modern-browsers": "^12.0.0",
"chalk": "^2.4.1",
"connect-history-api-fallback": "^1.3.0",
"copy-webpack-plugin": "^4.6.0",
"css-loader": "^1.0.1",
"eslint": "^5.8.0",
"eslint-config-airbnb": "^17.1.0",
"eslint-config-prettier": "^3.1.0",
"eslint-loader": "^2.1.1",
"eslint-plugin-filenames": "^1.3.2",
"eslint-plugin-import": "^2.19.1",
"eslint-plugin-jest": "^23.3.0",
"eslint-plugin-jsx-a11y": "^6.2.3",
"eslint-plugin-react": "^7.17.0",
"eslint-plugin-react-hooks": "^2.3.0",
"express": "^4.17.1",
"file-loader": "^5.0.2",
"eslint-plugin-import": "^2.14.0",
"eslint-plugin-jest": "^21.27.2",
"eslint-plugin-jsx-a11y": "^6.1.1",
"eslint-plugin-react": "^7.11.1",
"express": "^4.14.0",
"file-loader": "^2.0.0",
"html-webpack-inline-source-plugin": "0.0.10",
"html-webpack-plugin": "^3.2.0",
"jest": "^24.9.0",
"jest-puppeteer": "^4.4.0",
"jest": "^23.5.0",
"json-loader": "^0.5.4",
"mini-css-extract-plugin": "^0.9.0",
"puppeteer": "^2.0.0",
"rimraf": "^3.0.0",
"mini-css-extract-plugin": "^0.4.1",
"nyc": "^13.0.1",
"rimraf": "^2.5.4",
"serve-favicon": "^2.3.0",
"start-server-and-test": "^1.10.6",
"style-loader": "^1.1.2",
"style-loader": "^0.23.1",
"sw-precache-webpack-plugin": "^0.11.5",
"url-loader": "^3.0.0",
"webpack": "^4.41.5",
"webpack-cli": "^3.3.10",
"webpack-dev-middleware": "^3.7.2"
"url-loader": "^1.1.0",
"webpack": "^4.25.1",
"webpack-cli": "^3.1.0",
"webpack-dev-middleware": "^3.1.3"
},
"jest": {
"testMatch": [
"**/__tests__/**/?(*.)(spec|test).js?(x)"
],
"testURL": "http://localhost/",
"setupFiles": [
"./__tests__/setupMissingGlobals.js"
]
"testURL": "http://localhost/"
},
"babel": {
"env": {
@@ -128,23 +120,16 @@
],
"plugins": [
"@babel/plugin-proposal-function-bind",
"@babel/plugin-proposal-class-properties",
[
"@babel/plugin-proposal-decorators",
{
"legacy": true
}
],
[
"@babel/plugin-proposal-class-properties",
{
"loose": true
}
],
"@babel/plugin-proposal-export-namespace-from",
"@babel/plugin-transform-react-constant-elements",
"@babel/plugin-transform-runtime",
"@babel/plugin-proposal-optional-chaining",
"@babel/plugin-proposal-nullish-coalescing-operator"
"@babel/plugin-transform-runtime"
]
}
}
+71 -162
View File
@@ -1,11 +1,11 @@
// jshint esversion: 6
import _ from "lodash";
import * as globals from "../globals";
import { Universe, MatrixFBS } from "../util/stateManager";
import { Universe, kvCache } from "../util/stateManager";
import {
catchErrorsWrap,
doJsonRequest,
doBinaryRequest,
rangeEncodeIndices,
dispatchNetworkErrorMessageToUser
} from "../util/actionHelpers";
@@ -13,41 +13,29 @@ import {
Bootstrap application with the initial data loading.
* /config - application configuration
* /schema - schema of dataframe
* /annotations - all metadata annotation
* /layout - all default layout
* /annotations/obs - all metadata annotation
*/
const doInitialDataLoad = () =>
catchErrorsWrap(async dispatch => {
dispatch({ type: "initial data load start" });
try {
/*
Step 1 - config & schema, all JSON
*/
const requestJson = ["config", "schema"]
const requests = _([
"config",
"schema",
"annotations/obs",
"annotations/var",
"layout/obs"
])
.map(r => `${globals.API.prefix}${globals.API.version}${r}`)
.map(url => doJsonRequest(url));
const stepOneResults = await Promise.all(requestJson);
.map(url => doJsonRequest(url))
.value();
const results = await Promise.all(requests);
/* set config defaults */
const config = { ...globals.configDefaults, ...stepOneResults[0].config };
const schema = stepOneResults[1];
/*
Step 2 - dataframes, all binary. NOTE: uses results of step 1.
*/
/* only load names for var annotations, if possible*/
const varIndexName = schema?.schema?.annotations?.var?.index;
const varAnnotationsQuery = varIndexName
? `?annotation-name=${encodeURIComponent(varIndexName)}`
: "";
const varAnnotationsURL = `annotations/var${varAnnotationsQuery}`;
const requestBinary = ["annotations/obs", varAnnotationsURL, "layout/obs"]
.map(r => `${globals.API.prefix}${globals.API.version}${r}`)
.map(url => doBinaryRequest(url));
const stepTwoResults = await Promise.all(requestBinary);
const [obsAnno, varAnno, obsLayout] = [...stepTwoResults];
const universe = Universe.createUniverseFromResponse(
const config = { ...globals.configDefaults, ...results[0].config };
const [, schema, obsAnno, varAnno, obsLayout] = [...results];
const universe = Universe.createUniverseFromRestV02Response(
config,
schema,
obsAnno,
@@ -73,7 +61,7 @@ Set the view (world) to current selection. Placeholder for an async action
which also does re-layout.
*/
const regraph = () => (dispatch, getState) => {
const { universe, world, crossfilter } = getState();
const { universe, world, crossfilter } = getState().controls;
dispatch({
type: "set World to current selection",
universe,
@@ -84,7 +72,9 @@ const regraph = () => (dispatch, getState) => {
// Throws
const dispatchExpressionErrors = (dispatch, res) => {
const msg = `Unexpected HTTP response while fetching expression data ${res.status}, ${res.statusText}`;
const msg = `Unexpected HTTP response while fetching expression data ${
res.status
}, ${res.statusText}`;
dispatchNetworkErrorMessageToUser(msg);
throw new Error(msg);
};
@@ -98,49 +88,14 @@ 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 res = await fetch(
`${globals.API.prefix}${globals.API.version}data/var`,
{
method: "PUT",
body: JSON.stringify({
filter: {
var: {
annotation_value: [{ name: varIndexName, values: geneNames }]
}
}
}),
headers: new Headers({
accept: "application/octet-stream",
"Content-Type": "application/json"
}),
credentials: "include"
}
);
if (
!res.ok ||
res.headers.get("Content-Type") !== "application/octet-stream"
) {
// WILL throw
return dispatchExpressionErrors(dispatch, res);
}
const data = await res.arrayBuffer();
return Universe.convertDataFBStoObject(universe, data);
};
const { universe } = state.controls;
/* preload data already in cache */
let expressionData = _.transform(
genes,
(expData, g) => {
const data = universe.varData.col(g);
const data = kvCache.get(universe.varDataCache, g);
if (data) {
expData[g] = data.asArray();
expData[g] = data;
}
},
{}
@@ -153,10 +108,35 @@ async function _doRequestExpressionData(dispatch, getState, genes) {
/* Fetch data for any genes not in cache */
if (genesToFetch.length) {
try {
const newExpressionData = await fetchData(genesToFetch);
// XXX: TODO - this could be using /data/var rather than /data/obs,
// as that would simplify the transformation in convertExpressionRESTv02ToObject
const res = await fetch(
`${globals.API.prefix}${globals.API.version}data/obs`,
{
method: "PUT",
body: JSON.stringify({
filter: {
var: {
annotation_value: [{ name: "name", values: genesToFetch }]
}
}
}),
headers: new Headers({
accept: "application/json",
"Content-Type": "application/json"
})
}
);
if (!res.ok || res.headers.get("Content-Type") !== "application/json") {
// WILL throw
return dispatchExpressionErrors(dispatch, res);
}
const data = await res.json();
expressionData = {
...expressionData,
...newExpressionData
...Universe.convertExpressionRESTv02ToObject(universe, data)
};
} catch (error) {
dispatch({ type: "expression load error", error });
@@ -173,12 +153,12 @@ function requestSingleGeneExpressionCountsForColoringPOST(gene) {
dispatch({ type: "get single gene expression for coloring started" });
try {
await _doRequestExpressionData(dispatch, getState, [gene]);
const { world } = getState();
const { world } = getState().controls;
dispatch({
type: "color by expression",
gene,
data: {
[gene]: world.varData.col(gene).asArray()
[gene]: kvCache.get(world.varDataCache, gene)
}
});
} catch (error) {
@@ -194,14 +174,14 @@ const requestUserDefinedGene = gene => async (dispatch, getState) => {
dispatch({ type: "request user defined gene started" });
try {
await await _doRequestExpressionData(dispatch, getState, [gene]);
const { world } = getState();
const { world } = getState().controls;
/* then send the success case action through */
return dispatch({
type: "request user defined gene success",
data: {
genes: [gene],
expression: world.varData.col(gene).asArray()
expression: kvCache.get(world.varDataCache, gene)
}
});
} catch (error) {
@@ -225,7 +205,9 @@ const dispatchDiffExpErrors = (dispatch, response) => {
);
break;
default: {
const msg = `Unexpected differential expression HTTP response ${response.status}, ${response.statusText}`;
const msg = `Unexpected differential expression HTTP response ${
response.status
}, ${response.statusText}`;
dispatchNetworkErrorMessageToUser(msg);
dispatch({
type: "request differential expression error",
@@ -247,20 +229,13 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async (
2. get expression data for each
*/
const state = getState();
const { universe } = state;
const varIndexName = universe.schema.annotations.var.index;
// Legal values are null, Array or TypedArray. Null is initial state.
if (!set1) set1 = [];
if (!set2) set2 = [];
// These lines ensure that we convert any TypedArray to an Array.
// This is necessary because JSON.stringify() does some very strange
// things with TypedArrays (they are marshalled to JSON objects, rather
// than being marshalled as a JSON array).
set1 = Array.isArray(set1) ? set1 : Array.from(set1);
set2 = Array.isArray(set2) ? set2 : Array.from(set2);
const { universe } = state.controls;
const set1ByIndex = rangeEncodeIndices(
_.map(set1, s => universe.obsNameToIndexMap[s])
);
const set2ByIndex = rangeEncodeIndices(
_.map(set2, s => universe.obsNameToIndexMap[s])
);
const res = await fetch(
`${globals.API.prefix}${globals.API.version}diffexp/obs`,
{
@@ -272,10 +247,9 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async (
body: JSON.stringify({
mode: "topN",
count: num_genes,
set1: { filter: { obs: { index: set1 } } },
set2: { filter: { obs: { index: set2 } } }
}),
credentials: "include"
set1: { filter: { obs: { index: set1ByIndex } } },
set2: { filter: { obs: { index: set2ByIndex } } }
})
}
);
@@ -285,9 +259,7 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async (
const data = await res.json();
// result is [ [varIdx, ...], ... ]
const topNGenes = _.map(data, r =>
universe.varAnnotations.at(r[0], varIndexName)
);
const topNGenes = _.map(data, r => universe.varAnnotations[r[0]].name);
/*
Kick off secondary action to fetch all of the expression data for the
@@ -309,11 +281,8 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async (
};
const resetInterface = () => (dispatch, getState) => {
const { universe } = getState();
const { universe } = getState().controls;
dispatch({
type: "user reset start"
});
dispatch({
type: "clear all user defined genes"
});
@@ -323,9 +292,6 @@ const resetInterface = () => (dispatch, getState) => {
dispatch({
type: "reset colorscale"
});
dispatch({
type: "reset centroid labels"
});
dispatch({
type: "clear scatterplot"
});
@@ -336,62 +302,6 @@ const resetInterface = () => (dispatch, getState) => {
dispatch({
type: "increment graph render counter"
});
dispatch({
type: "user reset end"
});
};
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
});
}
};
export default {
@@ -400,6 +310,5 @@ export default {
requestSingleGeneExpressionCountsForColoringPOST,
requestDifferentialExpression,
requestUserDefinedGene,
doInitialDataLoad,
saveObsAnnotations
doInitialDataLoad
};
+11 -22
View File
@@ -4,13 +4,9 @@ import Helmet from "react-helmet";
import { connect } from "react-redux";
import Container from "./framework/container";
import LeftSideBar from "./leftSidebar";
import RightSideBar from "./rightSidebar";
import LeftSideBar from "./leftsidebar";
import Legend from "./continuousLegend";
import Graph from "./graph/graph";
import MenuBar from "./menubar";
import Autosave from "./autosave";
import actions from "../actions";
@connect(state => ({
@@ -75,25 +71,18 @@ class App extends React.Component {
loading cellxgene
</div>
) : null}
{error ? (
<div
style={{
position: "fixed",
fontWeight: 500,
top: window.innerHeight / 2,
left: window.innerWidth / 2 - 50
}}
>
error loading
</div>
) : null}
<div>
{loading ? null : <LeftSideBar />}
{loading ? null : <RightSideBar />}
{loading ? null : <MenuBar />}
{loading ? null : <Graph key={graphRenderCounter} />}
{loading ? null : <Autosave />}
<Legend />
<div
style={{
padding: 15,
width: 1440 - 410 /* but responsive */,
marginLeft: 350 /* but responsive */
}}
>
{loading ? null : <Graph key={graphRenderCounter} />}
<Legend />
</div>
</div>
</Container>
);
@@ -1,171 +0,0 @@
import React from "react";
import { connect } from "react-redux";
import {
Button,
Tooltip,
InputGroup,
Dialog,
Classes,
Colors
} from "@blueprintjs/core";
@connect(state => ({
universe: state.universe,
idhash: state.config?.parameters?.["annotations-user-data-idhash"] ?? null,
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
}))
class FilenameDialog extends React.Component {
constructor(props) {
super(props);
this.state = {
filenameText: ""
};
}
dismissFilenameDialog = () => {};
handleCreateFilename = () => {
const { dispatch } = this.props;
const { filenameText } = this.state;
dispatch({
type: "set annotations collection name",
data: filenameText
});
};
filenameError = () => {
const legalNames = /^\w+$/;
const { filenameText } = this.state;
let err = false;
if (filenameText === "") {
err = "empty_string";
} else if (!legalNames.test(filenameText)) {
/*
IMPORTANT: this test must ultimately match the test applied by the
backend, which is designed to ensure a safe file name can be created
from the data collection name. If you change this, you will also need
to change the validation code in the backend, or it will have no effect.
*/
err = "characters";
}
return err;
};
filenameErrorMessage = () => {
const err = this.filenameError();
let markup = null;
if (err === "empty_string") {
markup = (
<span
style={{
fontStyle: "italic",
fontSize: 12,
marginTop: 5,
color: Colors.ORANGE3
}}
>
{"Name cannot be blank"}
</span>
);
} else if (err === "characters") {
markup = (
<span
style={{
fontStyle: "italic",
fontSize: 12,
marginTop: 5,
color: Colors.ORANGE3
}}
>
{"Only alphanumeric and underscore allowed"}
</span>
);
}
return markup;
};
render() {
const { writableCategoriesEnabled, annotations, idhash } = this.props;
const { filenameText } = this.state;
return writableCategoriesEnabled &&
!annotations.dataCollectionNameIsReadOnly &&
!annotations.dataCollectionName ? (
<Dialog
icon="tag"
title="Annotations Collection"
isOpen={!annotations.dataCollectionName}
onClose={this.dismissFilenameDialog}
>
<form
onSubmit={e => {
e.preventDefault();
this.handleCreateFilename();
}}
>
<div className={Classes.DIALOG_BODY}>
<div style={{ marginBottom: 20 }}>
<p>Name your annotations collection:</p>
<InputGroup
autoFocus
value={filenameText}
intent={this.filenameError(filenameText) ? "warning" : "none"}
onChange={e => this.setState({ filenameText: e.target.value })}
leftIcon="tag"
/>
<p
style={{
marginTop: 7,
visibility: this.filenameError(filenameText)
? "visible"
: "hidden",
color: Colors.ORANGE3
}}
>
{this.filenameErrorMessage(filenameText)}
</p>
</div>
<div>
<p>
Your annotations are stored in this file:
<code className="bp3-code">
{filenameText}-{idhash}.csv
</code>
</p>
<p style={{ fontStyle: "italic" }}>
(We added a unique ID to your filename)
</p>
</div>
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Tooltip content="Cancel naming collection">
<Button onClick={this.dismissFilenameDialog}>Cancel</Button>
</Tooltip>
<Button
disabled={!filenameText || this.filenameError(filenameText)}
onClick={this.handleCreateFilename}
intent="primary"
type="submit"
>
Create annotations collection
</Button>
</div>
</div>
</form>
</Dialog>
) : null;
}
}
export default FilenameDialog;
-84
View File
@@ -1,84 +0,0 @@
import React from "react";
import { connect } from "react-redux";
import * as globals from "../../globals";
import actions from "../../actions";
import FilenameDialog from "./filenameDialog";
@connect(state => ({
universe: state.universe,
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
}))
class Autosave extends React.Component {
constructor(props) {
super(props);
this.state = {
timer: null
};
}
componentDidMount() {
const { writableCategoriesEnabled } = this.props;
let { timer } = this.state;
if (timer) clearInterval(timer);
if (writableCategoriesEnabled) {
timer = setInterval(this.tick, 2500);
} else {
timer = null;
}
this.setState({ timer });
}
componentWillUnmount() {
const { timer } = this.state;
if (timer) this.clearInterval(timer);
}
tick = () => {
const { dispatch, saveInProgress } = this.props;
if (this.needToSave() && !saveInProgress) {
dispatch(actions.saveObsAnnotations());
}
};
needToSave = () => {
/* return true if we need to save, false if we don't */
const { obsAnnotations, lastSavedObsAnnotations } = this.props;
return (
lastSavedObsAnnotations && obsAnnotations !== lastSavedObsAnnotations
);
};
statusMessage() {
const { error } = this.props;
if (error) {
return `Autosave error: ${error}`;
}
return this.needToSave() ? "Unsaved" : "All saved";
}
render() {
const { writableCategoriesEnabled } = this.props;
return writableCategoriesEnabled ? (
<div
id="autosave"
style={{
position: "fixed",
display: "inherit",
right: globals.leftSidebarWidth + 5,
bottom: 5
}}
>
{this.statusMessage()}
<FilenameDialog />
</div>
) : null;
}
}
export default Autosave;
+148 -352
View File
@@ -2,80 +2,71 @@
https://bl.ocks.org/mbostock/4341954
https://bl.ocks.org/mbostock/34f08d5e11952a80609169b7917d4172
https://bl.ocks.org/SpaceActuary/2f004899ea1b2bd78d6f1dbb2febf771
https://bl.ocks.org/mbostock/3019563
*/
// jshint esversion: 6
import React from "react";
import _ from "lodash";
import { Button, ButtonGroup, Tooltip } from "@blueprintjs/core";
import { connect } from "react-redux";
import * as d3 from "d3";
import memoize from "memoize-one";
import { kvCache } from "../../util/stateManager";
import * as globals from "../../globals";
import actions from "../../actions";
import { linspace } from "../../util/range";
import { makeContinuousDimensionName } from "../../util/nameCreators";
@connect((state, ownProps) => {
const { isObs, isUserDefined, isDiffExp, field } = ownProps;
const myName = makeContinuousDimensionName(
{ isObs, isUserDefined, isDiffExp },
field
);
return {
world: state.world,
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
scatterplotYYaccessor: state.controls.scatterplotYYaccessor,
continuousSelectionRange: state.continuousSelection[myName],
colorAccessor: state.colors.colorAccessor
};
})
class HistogramBrush extends React.PureComponent {
static getColumn(world, field, clipped = true) {
/*
Return the underlying Dataframe column for our field. By default,
returns the clipped column. If clipped===false, will return the
unclipped column.
*/
const obsAnnotations = clipped
? world.obsAnnotations
: world.unclipped.obsAnnotations;
const varData = clipped ? world.varData : world.unclipped.varData;
if (obsAnnotations.hasCol(field)) {
return obsAnnotations.col(field);
}
return varData.col(field);
}
calcHistogramCache = memoize(col => {
/*
recalculate expensive stuff, notably bins, summaries, etc.
*/
@connect(state => ({
world: state.controls.world,
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
scatterplotYYaccessor: state.controls.scatterplotYYaccessor,
crossfilter: state.controls.crossfilter,
differential: state.differential,
initializeRanges: _.get(state.controls.world, "summary.obs"),
colorAccessor: state.controls.colorAccessor,
colorScale: state.controls.colorScale,
obsAnnotations: _.get(state.controls.world, "obsAnnotations", null)
}))
class HistogramBrush extends React.Component {
calcHistogramCache = memoize((obsAnnotations, field, ranges) => {
const { world } = this.props;
const histogramCache = {};
const values = col.asArray();
const summary = col.summarize();
const { min: domainMin, max: domainMax } = summary;
const numBins = 40;
histogramCache.x = d3
.scaleLinear()
.domain([domainMin, domainMax])
.range([this.marginLeft, this.marginLeft + this.width]);
const [xStart, xStop] = histogramCache.x.domain();
const histThresholds = linspace(xStart, xStop, numBins + 1);
histogramCache.bins = d3
.histogram()
.domain(histogramCache.x.domain())
.thresholds(histThresholds)(values);
const yMax = histogramCache.bins
.map(b => b.length)
.reduce((a, b) => Math.max(a, b));
histogramCache.y = d3
.scaleLinear()
.domain([0, yMax])
.range([this.marginTop + this.height, this.marginTop]);
.range([this.height - this.marginBottom, 0]);
if (obsAnnotations[0][field] !== undefined) {
// recalculate expensive stuff
const allValuesForContinuousFieldAsArray = _.map(obsAnnotations, field);
histogramCache.x = d3
.scaleLinear()
.domain([ranges.min, ranges.max])
.range([0, this.width]);
histogramCache.bins = d3
.histogram()
.domain(histogramCache.x.domain())
.thresholds(40)(allValuesForContinuousFieldAsArray);
histogramCache.numValues = allValuesForContinuousFieldAsArray.length;
} else if (kvCache.get(world.varDataCache, field)) {
/* it's not in observations, so it's a gene, but let's check to make sure */
const varValues = kvCache.get(world.varDataCache, field);
histogramCache.x = d3
.scaleLinear()
.domain(
d3.extent(varValues)
) /* replace this if we have ranges for genes back from server like we do for annotations on cells */
.range([0, this.width]);
histogramCache.bins = d3
.histogram()
.domain(histogramCache.x.domain())
.thresholds(40)(varValues);
histogramCache.numValues = varValues.length;
}
return histogramCache;
});
@@ -83,95 +74,34 @@ class HistogramBrush extends React.PureComponent {
constructor(props) {
super(props);
this.marginLeft = 3; // Space for 0 tick label on X axis
this.marginRight = 40; // space for Y axis & labels
this.marginBottom = 25; // space for X axis & labels
this.marginTop = 3;
this.width = 340 - this.marginLeft - this.marginRight;
this.height = 135 - this.marginTop - this.marginBottom;
this.width = 340;
this.height = 100;
this.marginBottom = 20;
}
componentDidMount() {
const { field } = this.props;
const { x, y, bins, svgRef } = this._histogram;
const { x, y, bins, numValues, svgRef } = this._histogram;
this.renderAxesBrushBins(x, y, bins, svgRef, field);
this.renderAxesBrushBins(x, y, bins, numValues, svgRef, field);
}
componentDidUpdate(prevProps) {
const { field, world } = this.props;
const { x, y, bins, svgRef } = this._histogram;
let { brushXselection, brushX } = this.state;
let forceBrushUpdate = false;
const { field, obsAnnotations } = this.props;
const { x, y, bins, numValues, svgRef } = this._histogram;
/*
Update our axis if the underlying dataframe column has changed
*/
const dfColumn = HistogramBrush.getColumn(world, field);
const oldDfColumn = HistogramBrush.getColumn(
prevProps.world,
prevProps.field
);
if (dfColumn !== oldDfColumn) {
({ brushXselection, brushX } = this.renderAxesBrushBins(
x,
y,
bins,
svgRef,
field
));
forceBrushUpdate = true;
}
/*
if the selection has changed, ensure that the brush correctly reflects
the underlying selection.
*/
const { continuousSelectionRange: range } = this.props;
if (forceBrushUpdate || range !== prevProps.continuousSelectionRange) {
if (brushXselection) {
const selection = d3.brushSelection(brushXselection.node());
if (!range && selection) {
/* no active selection - clear brush */
brushXselection.call(brushX.move, null);
} else if (range && !selection) {
/* there is an active selection, but no brush - set the brush */
const x0 = x(range[0]);
const x1 = x(range[1]);
brushXselection.call(brushX.move, [x0, x1]);
} else if (range && selection) {
/* there is an active selection and a brush - make sure they match */
const moveDeltaThreshold = 1;
const x0 = x(range[0]);
const x1 = x(range[1]);
const dX0 = Math.abs(x0 - selection[0]);
const dX1 = Math.abs(x1 - selection[1]);
/*
only update the brush if it is grossly incorrect,
as defined by the moveDeltaThreshold
*/
if (dX0 > moveDeltaThreshold || dX1 > moveDeltaThreshold) {
brushXselection.call(brushX.move, [x0, x1]);
}
}
}
if (obsAnnotations !== prevProps.obsAnnotations) {
this.renderAxesBrushBins(x, y, bins, numValues, svgRef, field);
}
}
onBrush(selection, x, eventType) {
const type = `continuous metadata histogram ${eventType}`;
onBrush(selection, x) {
return () => {
const { dispatch, field, isObs, isUserDefined, isDiffExp } = this.props;
// ignore programmatically generated events
if (!d3.event.sourceEvent) return;
// ignore cascading events, which are programmatically generated
if (d3.event.sourceEvent.sourceEvent) return;
if (d3.event.selection) {
dispatch({
type,
type: "continuous metadata histogram brush",
selection: field,
continuousNamespace: {
isObs,
@@ -182,7 +112,7 @@ class HistogramBrush extends React.PureComponent {
});
} else {
dispatch({
type,
type: "continuous metadata histogram brush",
selection: field,
continuousNamespace: {
isObs,
@@ -195,99 +125,41 @@ class HistogramBrush extends React.PureComponent {
};
}
onBrushEnd(selection, x) {
return () => {
const { dispatch, field, isObs, isUserDefined, isDiffExp } = this.props;
const minAllowedBrushSize = 10;
const smallAmountToAvoidInfiniteLoop = 0.1;
drawHistogram(svgRef) {
const { obsAnnotations, field, ranges } = this.props;
const histogramCache = this.calcHistogramCache(
obsAnnotations,
field,
ranges
);
// ignore programmatically generated events
if (!d3.event.sourceEvent) return;
// ignore cascading events, which are programmatically generated
if (d3.event.sourceEvent.sourceEvent) return;
const { x, y, bins, numValues } = histogramCache;
if (d3.event.selection) {
let _range;
if (
d3.event.selection[1] - d3.event.selection[0] >
minAllowedBrushSize
) {
_range = [x(d3.event.selection[0]), x(d3.event.selection[1])];
} else {
/* the user selected range is too small and will be hidden #587, so take control of it procedurally */
/* https://stackoverflow.com/questions/12354729/d3-js-limit-size-of-brush */
const procedurallyResizedBrushWidth =
d3.event.selection[0] +
minAllowedBrushSize +
smallAmountToAvoidInfiniteLoop; //
_range = [x(d3.event.selection[0]), x(procedurallyResizedBrushWidth)];
}
dispatch({
type: "continuous metadata histogram end",
selection: field,
continuousNamespace: {
isObs,
isUserDefined,
isDiffExp
},
range: _range
});
} else {
dispatch({
type: "continuous metadata histogram cancel",
selection: field,
continuousNamespace: {
isObs,
isUserDefined,
isDiffExp
}
});
}
};
this._histogram = { x, y, bins, numValues, svgRef };
}
handleSetGeneAsScatterplotX = () => {
const { dispatch, field } = this.props;
dispatch({
type: "set scatterplot x",
data: field
});
};
handleColorAction() {
const {
obsAnnotations,
dispatch,
field,
world,
initializeRanges
} = this.props;
handleSetGeneAsScatterplotY = () => {
const { dispatch, field } = this.props;
dispatch({
type: "set scatterplot y",
data: field
});
};
handleColorAction = () => {
const { dispatch, field, world, ranges } = this.props;
if (world.obsAnnotations.hasCol(field)) {
if (obsAnnotations[0][field]) {
dispatch({
type: "color by continuous metadata",
colorAccessor: field,
rangeForColorAccessor: ranges
rangeMaxForColorAccessor: initializeRanges[field].range.max
});
} else if (world.varData.hasCol(field)) {
} else if (kvCache.get(world.varDataCache, field)) {
dispatch(actions.requestSingleGeneExpressionCountsForColoringPOST(field));
}
};
}
removeHistogram = () => {
const {
dispatch,
field,
colorAccessor,
scatterplotXXaccessor,
scatterplotYYaccessor
} = this.props;
removeHistogram() {
const { dispatch, field, colorAccessor } = this.props;
dispatch({
type: "clear user defined gene",
data: field
@@ -297,158 +169,103 @@ class HistogramBrush extends React.PureComponent {
type: "reset colorscale"
});
}
if (field === scatterplotXXaccessor) {
dispatch({
type: "set scatterplot x",
data: null
});
}
if (field === scatterplotYYaccessor) {
dispatch({
type: "set scatterplot y",
data: null
});
}
};
drawHistogram(svgRef) {
const { field, world } = this.props;
const col = HistogramBrush.getColumn(world, field);
const histogramCache = this.calcHistogramCache(col);
const { x, y, bins } = histogramCache;
this._histogram = { x, y, bins, svgRef };
}
renderAxesBrushBins(x, y, bins, svgRef, field) {
const svg = d3.select(svgRef);
handleSetGeneAsScatterplotX() {
return () => {
const { dispatch, field } = this.props;
dispatch({
type: "set scatterplot x",
data: field
});
};
}
handleSetGeneAsScatterplotY() {
return () => {
const { dispatch, field } = this.props;
dispatch({
type: "set scatterplot y",
data: field
});
};
}
renderAxesBrushBins(x, y, bins, numValues, svgRef, field) {
/* Remove everything */
svg.selectAll("*").remove();
/* Set margins within the SVG */
const container = svg
.attr("width", this.width + this.marginLeft + this.marginRight)
.attr("height", this.height + this.marginTop + this.marginBottom)
.append("g")
.attr("class", "histogram-container")
.attr("transform", `translate(${this.marginLeft},${this.marginTop})`);
d3.select(svgRef)
.selectAll("*")
.remove();
/* BINS */
container
d3.select(svgRef)
.insert("g", "*")
.attr("fill", "#bbb")
.selectAll("rect")
.data(bins)
.enter()
.append("rect")
.attr("class", "bar")
.attr("x", d => x(d.x0) + 1)
.attr("y", d => y(d.length))
.attr("y", d => y(d.length / numValues))
.attr("width", d => Math.abs(x(d.x1) - x(d.x0) - 1))
.attr("height", d => y(0) - y(d.length));
.attr("height", d => y(0) - y(d.length / numValues));
// BRUSH
// Note the brushable area is bounded by the data on three sides, but goes down to cover the x-axis
const brushX = d3
.brushX()
.extent([
[x.range()[0], y.range()[1]],
[x.range()[1], this.marginTop + this.height + this.marginBottom]
])
/*
emit start so that the Undoable history can save an undo point
upon drag start, and ignore the subsequent intermediate drag events.
*/
.on("start", this.onBrush(field, x.invert, "start").bind(this))
.on("brush", this.onBrush(field, x.invert, "brush").bind(this))
.on("end", this.onBrushEnd(field, x.invert).bind(this));
const brushXselection = container
.insert("g")
/* BRUSH */
d3.select(svgRef)
.append("g")
.attr("class", "brush")
.attr("data-testid", `${svgRef.dataset.testid}-brushable-area`)
.call(brushX);
.call(
d3
.brushX()
.on("brush", this.onBrush(field, x.invert).bind(this))
.on("end", this.onBrush(field, x.invert).bind(this))
);
/* X AXIS */
container
.insert("g")
/* AXIS */
d3.select(svgRef)
.append("g")
.attr("class", "axis axis--x")
.attr("transform", `translate(0,${this.marginTop + this.height})`)
.call(
d3
.axisBottom(x)
.ticks(5)
.tickFormat(d3.format(".0s"))
);
.attr("transform", `translate(0,${this.height - this.marginBottom})`)
.call(d3.axisBottom(x).ticks(5));
/* Y AXIS */
container
.insert("g")
.attr("class", "axis axis--y")
.attr("transform", `translate(${this.marginLeft + this.width},0)`)
.call(
d3
.axisRight(y)
.ticks(3)
.tickFormat(d3.format(".0s"))
);
d3.select(svgRef)
.selectAll(".axis--x text")
.style("fill", "rgb(80,80,80)");
/* axis style */
svg.selectAll(".axis text").style("fill", "rgb(80,80,80)");
svg.selectAll(".axis path").style("stroke", "rgb(230,230,230)");
svg.selectAll(".axis line").style("stroke", "rgb(230,230,230)");
d3.select(svgRef)
.selectAll(".axis--x path")
.style("stroke", "rgb(230,230,230)");
const newState = { brushX, brushXselection };
this.setState(newState);
return newState;
d3.select(svgRef)
.selectAll(".axis--x line")
.style("stroke", "rgb(230,230,230)");
}
render() {
const {
field,
world,
colorAccessor,
isUserDefined,
isDiffExp,
logFoldChange,
pval,
pvalAdj,
scatterplotXXaccessor,
scatterplotYYaccessor,
zebra
} = this.props;
const fieldForId = field.replace(/\s/g, "_");
const {
min: unclippedRangeMin,
max: unclippedRangeMax
} = HistogramBrush.getColumn(world, field, false).summarize();
const unclippedRangeMinColor =
world.clipQuantiles.min === 0 ? "#bbb" : globals.blue;
const unclippedRangeMaxColor =
world.clipQuantiles.max === 1 ? "#bbb" : globals.blue;
return (
<div
id={`histogram_${fieldForId}`}
data-testid={`histogram-${field}`}
data-testclass={
isDiffExp
? "histogram-diffexp"
: isUserDefined
? "histogram-user-gene"
: "histogram-continuous-metadata"
}
id={`histogram_${field}`}
style={{
padding: globals.leftSidebarSectionPadding,
backgroundColor: zebra ? globals.lightestGrey : "white"
}}
>
<div
style={{
display: "flex",
justifyContent: "flex-end",
paddingBottom: "8px"
}}
>
{isDiffExp || isUserDefined ? (
<div style={{ display: "flex", justifyContent: "flex-end" }}>
{isDiffExp ? (
<span>
<span
style={{ marginRight: 7 }}
@@ -456,16 +273,14 @@ class HistogramBrush extends React.PureComponent {
/>
<ButtonGroup style={{ marginRight: 7 }}>
<Button
data-testid={`plot-x-${field}`}
onClick={this.handleSetGeneAsScatterplotX}
onClick={this.handleSetGeneAsScatterplotX(field).bind(this)}
active={scatterplotXXaccessor === field}
intent={scatterplotXXaccessor === field ? "primary" : "none"}
>
plot x
</Button>
<Button
data-testid={`plot-y-${field}`}
onClick={this.handleSetGeneAsScatterplotY}
onClick={this.handleSetGeneAsScatterplotY(field).bind(this)}
active={scatterplotYYaccessor === field}
intent={scatterplotYYaccessor === field ? "primary" : "none"}
>
@@ -477,7 +292,7 @@ class HistogramBrush extends React.PureComponent {
{isUserDefined ? (
<Button
minimal
onClick={this.removeHistogram}
onClick={this.removeHistogram.bind(this)}
style={{
color: globals.blue,
cursor: "pointer",
@@ -487,17 +302,11 @@ class HistogramBrush extends React.PureComponent {
remove
</Button>
) : null}
<Tooltip
content="Use as color scale"
position="bottom"
hoverOpenDelay={globals.tooltipHoverOpenDelay}
>
<Tooltip content="Use as color scale" position="bottom">
<Button
onClick={this.handleColorAction}
onClick={this.handleColorAction.bind(this)}
active={colorAccessor === field}
intent={colorAccessor === field ? "primary" : "none"}
data-testclass="colorby"
data-testid={`colorby-${field}`}
icon="tint"
/>
</Tooltip>
@@ -505,9 +314,7 @@ class HistogramBrush extends React.PureComponent {
<svg
width={this.width}
height={this.height}
id={`histogram_${fieldForId}_svg`}
data-testclass="histogram-plot"
data-testid={`histogram-${field}-plot`}
id={`histogram_${field}_svg`}
ref={svgRef => {
this.drawHistogram(svgRef);
}}
@@ -515,21 +322,10 @@ class HistogramBrush extends React.PureComponent {
<div
style={{
display: "flex",
justifyContent: "space-between"
justifyContent: "center"
}}
>
<span style={{ color: unclippedRangeMinColor }}>
min {unclippedRangeMin.toPrecision(4)}
</span>
<span
data-testclass="brushable-histogram-field-name"
style={{ fontStyle: "italic" }}
>
{field}
</span>
<span style={{ color: unclippedRangeMaxColor }}>
max {unclippedRangeMax.toPrecision(4)}
</span>
<span style={{ fontStyle: "italic" }}>{field}</span>
</div>
{isDiffExp ? (
@@ -1,93 +0,0 @@
import React from "react";
import { connect } from "react-redux";
import { Button, Tooltip, Dialog, Classes, Colors } from "@blueprintjs/core";
@connect(state => ({
colorAccessor: state.colors.colorAccessor,
categoricalSelection: state.categoricalSelection,
annotations: state.annotations,
universe: state.universe,
ontology: state.ontology,
ontologyLoading: state.ontology?.loading
}))
class AnnoDialog extends React.Component {
constructor(props) {
super(props);
this.state = {};
}
render() {
const {
isActive,
text,
title,
instruction,
cancelTooltipContent,
errorMessage,
validationError,
annoSelect,
ontologySelect,
annoInput,
handleCancel,
handleSubmit,
primaryButtonText,
secondaryButtonText,
handleSecondaryButtonSubmit
} = this.props;
return (
<Dialog icon="tag" title={title} isOpen={isActive} onClose={handleCancel}>
<form
onSubmit={e => {
e.preventDefault();
}}
>
<div className={Classes.DIALOG_BODY}>
<div style={{ marginBottom: 20 }}>
<p>{instruction}</p>
{annoInput || null}
<p
style={{
marginTop: 7,
visibility: validationError ? "visible" : "hidden",
color: Colors.ORANGE3
}}
>
{errorMessage}
</p>
</div>
{annoSelect || null}
{ontologySelect || null}
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Tooltip content={cancelTooltipContent}>
<Button onClick={handleCancel}>Cancel</Button>
</Tooltip>
{handleSecondaryButtonSubmit && secondaryButtonText ? (
<Button
onClick={handleSecondaryButtonSubmit}
disabled={!text || validationError}
intent="none"
type="submit"
>
{secondaryButtonText}
</Button>
) : null}
<Button
onClick={handleSubmit}
disabled={!text || validationError}
intent="primary"
type="submit"
>
{primaryButtonText}
</Button>
</div>
</div>
</form>
</Dialog>
);
}
}
export default AnnoDialog;
@@ -1,133 +0,0 @@
import React from "react";
import { connect } from "react-redux";
import AnnoDialog from "./annoDialog";
import AnnoInputs from "./annoInputs";
import { labelErrorMessage, isLabelErroneous } from "./labelUtil";
@connect(state => ({
colorAccessor: state.colors.colorAccessor,
categoricalSelection: state.categoricalSelection,
annotations: state.annotations,
universe: state.universe,
ontology: state.ontology,
ontologyLoading: state.ontology?.loading,
ontologyEnabled: state.ontology?.enabled
}))
class Category extends React.Component {
constructor(props) {
super(props);
this.state = {
newLabelText: ""
};
}
disableAddNewLabelMode = () => {
const { dispatch } = this.props;
dispatch({
type: "annotation: disable add new label mode"
});
this.setState({
newLabelText: ""
});
};
handleAddNewLabelToCategory = () => {
const { dispatch, metadataField } = this.props;
const { newLabelText } = this.state;
dispatch({
type: "annotation: add new label to category",
metadataField,
newLabelText,
assignSelectedCells: false
});
this.setState({ newLabelText: "" });
};
addLabelAndAssignCells = () => {
const { dispatch, metadataField } = this.props;
const { newLabelText } = this.state;
dispatch({
type: "annotation: add new label to category",
metadataField,
newLabelText,
assignSelectedCells: true
});
this.setState({ newLabelText: "" });
};
handleCreateArbitraryLabel = newLabelTextNotInOntology => {
const { dispatch, metadataField } = this.props;
dispatch({
type: "annotation: add new label to category",
metadataField,
newLabelText: newLabelTextNotInOntology,
assignSelectedCells: false
});
this.setState({ newLabelText: "" });
};
labelNameError = name => {
const { metadataField, ontology, universe } = this.props;
return isLabelErroneous(name, metadataField, ontology, universe.schema);
};
labelNameErrorMessage = name => {
const { metadataField, ontology, universe } = this.props;
return labelErrorMessage(name, metadataField, ontology, universe.schema);
};
/* leaky to have both of these in multiple components */
handleChoice = e => {
this.setState({ newLabelText: e.target });
};
handleTextChange = text => {
this.setState({ newLabelText: text });
};
render() {
const { newLabelText } = this.state;
const { metadataField, annotations, ontologyEnabled } = this.props;
return (
<>
<AnnoDialog
isActive={
annotations.isAddingNewLabel &&
annotations.categoryAddingNewLabel === metadataField
}
title="Add new label to category"
instruction="New, unique label name:"
cancelTooltipContent="Close this dialog without adding a label."
primaryButtonText="Add label"
secondaryButtonText="Add label and assign currently selected cells"
handleSecondaryButtonSubmit={this.addLabelAndAssignCells}
text={newLabelText}
validationError={this.labelNameError(newLabelText)}
errorMessage={this.labelNameErrorMessage(newLabelText)}
handleSubmit={this.handleAddNewLabelToCategory}
handleCancel={this.disableAddNewLabelMode}
annoInput={
<AnnoInputs
useSuggest={ontologyEnabled}
text={newLabelText}
handleCreateArbitraryLabel={this.handleCreateArbitraryLabel}
handleItemChange={this.handleSuggestActiveItemChange}
handleChoice={this.handleChoice}
handleTextChange={this.handleTextChange}
isTextInvalid={this.labelNameError}
isTextInvalidErrorMessage={this.labelNameErrorMessage}
/>
}
/>
</>
);
}
}
export default Category;
@@ -1,131 +0,0 @@
import React from "react";
import { connect } from "react-redux";
import AnnoDialog from "./annoDialog";
import OntologySelect from "./ontologySelect";
import { labelErrorMessage, isLabelErroneous } from "./labelUtil";
@connect(state => ({
colorAccessor: state.colors.colorAccessor,
categoricalSelection: state.categoricalSelection,
annotations: state.annotations,
universe: state.universe,
ontology: state.ontology,
ontologyLoading: state.ontology?.loading,
ontologyEnabled: state.ontology?.enabled
}))
class Category extends React.Component {
constructor(props) {
super(props);
this.state = {
newLabelText: ""
};
}
disableAddNewLabelFromOntologyMode = () => {
const { dispatch } = this.props;
dispatch({
type: "annotation: disable add new ontology label mode"
});
this.setState({
newLabelText: ""
});
};
handleAddNewLabelToCategory = () => {
const { dispatch, metadataField } = this.props;
const { newLabelText } = this.state;
dispatch({
type: "annotation: add new label to category",
metadataField,
newLabelText,
assignSelectedCells: false
});
this.setState({ newLabelText: "" });
};
addLabelAndAssignCells = () => {
const { dispatch, metadataField } = this.props;
const { newLabelText } = this.state;
dispatch({
type: "annotation: add new label to category",
metadataField,
newLabelText,
assignSelectedCells: true
});
this.setState({ newLabelText: "" });
};
handleCreateArbitraryLabel = newLabelTextNotInOntology => {
const { dispatch, metadataField } = this.props;
dispatch({
type: "annotation: add new label to category",
metadataField,
newLabelText: newLabelTextNotInOntology,
assignSelectedCells: false
});
this.setState({ newLabelText: "" });
};
labelNameError = name => {
const { metadataField, ontology, universe } = this.props;
return isLabelErroneous(name, metadataField, ontology, universe.schema);
};
labelNameErrorMessage = name => {
const { metadataField, ontology, universe } = this.props;
return labelErrorMessage(name, metadataField, ontology, universe.schema);
};
/* leaky to have both of these in multiple components */
handleChoice = e => {
this.setState({ newLabelText: e.target });
};
handleTextChange = text => {
this.setState({ newLabelText: text });
};
render() {
const { newLabelText } = this.state;
const { metadataField, annotations, ontology } = this.props;
return (
<>
<AnnoDialog
isActive={
annotations.isAddingNewLabelFromOntology &&
annotations.categoryAddingNewLabelFromOntology === metadataField
}
title="Add new label to category from existing ontology terms"
instruction="Choose an ontology term to use as a label name:"
cancelTooltipContent="Close this dialog without adding a label."
primaryButtonText="Add label"
secondaryButtonText="Add label and assign currently selected cells"
handleSecondaryButtonSubmit={this.addLabelAndAssignCells}
text={newLabelText}
validationError={this.labelNameError(newLabelText)}
errorMessage={this.labelNameErrorMessage(newLabelText)}
handleSubmit={this.handleAddNewLabelToCategory}
handleCancel={this.disableAddNewLabelFromOntologyMode}
ontologySelect={
<OntologySelect
handleChooseOntologyTermFromDropdown={term => {
this.handleChoice(term);
}}
categoryToDuplicate={newLabelText}
ontology={ontology}
/>
}
annoInput={null}
/>
</>
);
}
}
export default Category;
@@ -1,146 +0,0 @@
import React from "react";
import _ from "lodash";
import { connect } from "react-redux";
import { Colors } from "@blueprintjs/core";
import AnnoDialog from "./annoDialog";
import AnnoInputs from "./annoInputs";
import { AnnotationsHelpers } from "../../util/stateManager";
@connect(state => ({
categoricalSelection: state.categoricalSelection,
annotations: state.annotations,
universe: state.universe,
ontology: state.ontology,
ontologyLoading: state.ontology?.loading,
ontologyEnabled: state.ontology?.enabled
}))
class AnnoDialogEditCategoryName extends React.Component {
constructor(props) {
super(props);
this.state = {
newCategoryText: props.metadataField
};
}
handleCategoryEditTextChange = txt => {
this.setState({
newCategoryText: txt
});
};
disableEditCategoryMode = () => {
const { dispatch } = this.props;
dispatch({
type: "annotation: disable category edit mode"
});
};
handleEditCategory = () => {
const { dispatch, metadataField, categoricalSelection } = this.props;
const { newCategoryText } = this.state;
const allCategoryNames = _.keys(categoricalSelection);
if (
(allCategoryNames.indexOf(newCategoryText) > -1 &&
newCategoryText !== metadataField) ||
newCategoryText === ""
) {
return;
}
dispatch({
type: "annotation: category edited",
metadataField,
newCategoryText,
data: newCategoryText
});
};
categoryNameErrorMessage = () => {
const err = this.editedCategoryNameError();
if (err === false) return null;
const errorMessageMap = {
/* map error code to human readable error message */
"empty-string": "Blank names not allowed",
duplicate: "Category name must be unique",
"trim-spaces": "Leading and trailing spaces not allowed",
"illegal-characters":
"Only alphanumeric and special characters (-_.) allowed",
"multi-space-run": "Multiple consecutive spaces not allowed"
};
const errorMessage = errorMessageMap[err] ?? "error";
return (
<span
style={{
display: "block",
fontStyle: "italic",
fontSize: 12,
marginTop: 5,
color: Colors.ORANGE3
}}
>
{errorMessage}
</span>
);
};
editedCategoryNameError = () => {
const { metadataField, categoricalSelection } = this.props;
const { newCategoryText } = this.state;
/* check for syntax errors in category name */
const error = AnnotationsHelpers.annotationNameIsErroneous(newCategoryText);
if (error) {
return error;
}
/* check for duplicative categories */
const allCategoryNames = _.keys(categoricalSelection);
const categoryNameAlreadyExists =
allCategoryNames.indexOf(newCategoryText) > -1;
const sameName = newCategoryText === metadataField;
if (categoryNameAlreadyExists && !sameName) {
return "duplicate";
}
/* otherwise, no error */
return false;
};
render() {
const { newCategoryText } = this.state;
const { metadataField, annotations } = this.props;
return (
<>
<AnnoDialog
isActive={
annotations.isEditingCategoryName &&
annotations.categoryBeingEdited === metadataField
}
title="Edit category name"
instruction="New, unique category name:"
cancelTooltipContent="Close this dialog without editing this category."
primaryButtonText="Edit category name"
text={newCategoryText}
validationError={this.editedCategoryNameError(newCategoryText)}
errorMessage={this.categoryNameErrorMessage(newCategoryText)}
handleSubmit={this.handleEditCategory}
handleCancel={this.disableEditCategoryMode}
annoInput={
<AnnoInputs
useSuggest={false}
text={newCategoryText}
handleTextChange={this.handleCategoryEditTextChange}
/>
}
/>
</>
);
}
}
export default AnnoDialogEditCategoryName;
@@ -1,43 +0,0 @@
import React from "react";
import { connect } from "react-redux";
import { InputGroup } from "@blueprintjs/core";
const VanillaInput = props => {
const { text, handleTextChange } = props;
return (
<InputGroup
autoFocus
value={text}
intent="none"
onChange={e => handleTextChange?.(e.target.value)}
leftIcon="tag"
/>
);
};
@connect(state => ({
colorAccessor: state.colors.colorAccessor,
categoricalSelection: state.categoricalSelection,
annotations: state.annotations,
universe: state.universe,
world: state.world,
ontology: state.ontology?.terms,
ontologyLoading: state.ontology?.loading
}))
class AnnoInputs extends React.Component {
constructor(props) {
super(props);
this.state = {};
}
render() {
const { handleTextChange, text } = this.props;
return (
<div>
<VanillaInput text={text} handleTextChange={handleTextChange} />
</div>
);
}
}
export default AnnoInputs;
@@ -1,125 +0,0 @@
import React from "react";
import { connect } from "react-redux";
import {
Button,
Menu,
MenuItem,
Popover,
Position,
PopoverInteractionKind
} from "@blueprintjs/core";
@connect(state => ({
annotations: state.annotations,
ontologyEnabled: state.ontology?.enabled
}))
class AnnoMenuCategory extends React.Component {
constructor(props) {
super(props);
this.state = {};
}
activateAddNewLabelMode = () => {
const { dispatch, metadataField } = this.props;
dispatch({
type: "annotation: activate add new label mode",
data: metadataField
});
};
activateAddNewOntologyLabelMode = () => {
const { dispatch, metadataField } = this.props;
dispatch({
type: "annotation: activate add new ontology label mode",
data: metadataField
});
};
activateEditCategoryMode = () => {
const { dispatch, metadataField } = this.props;
dispatch({
type: "annotation: activate category edit mode",
data: metadataField
});
};
handleDeleteCategory = () => {
const { dispatch, metadataField } = this.props;
dispatch({
type: "annotation: delete category",
metadataField
});
};
render() {
const {
metadataField,
annotations,
isUserAnno,
ontologyEnabled,
createText,
createFromOntologyText,
editText,
deleteText
} = this.props;
return (
<>
{isUserAnno ? (
<Popover
interactionKind={PopoverInteractionKind.HOVER}
boundary="window"
position={Position.RIGHT_TOP}
content={
<Menu>
<MenuItem
icon="tag"
data-testclass="handleAddNewLabelToCategory"
data-testid={`handleAddNewLabelToCategory-${metadataField}`}
onClick={this.activateAddNewLabelMode}
text={createText}
/>
{ontologyEnabled ? (
<MenuItem
icon="book"
data-testclass="activateAddNewOntologyLabelMode"
data-testid={`activateAddNewOntologyLabelMode-${metadataField}`}
onClick={this.activateAddNewOntologyLabelMode}
text={createFromOntologyText}
/>
) : null}
<MenuItem
icon="edit"
disabled={annotations.isEditingCategoryName}
data-testclass="activateEditCategoryMode"
data-testid={`activateEditCategoryMode-${metadataField}`}
onClick={this.activateEditCategoryMode}
text={editText}
/>
<MenuItem
icon="delete"
intent="danger"
data-testclass="handleDeleteCategory"
data-testid={`handleDeleteCategory-${metadataField}`}
onClick={this.handleDeleteCategory}
text={deleteText}
/>
</Menu>
}
>
<Button
style={{ marginLeft: 0 }}
data-testclass="seeActions"
data-testid={`seeActions-${metadataField}`}
icon="more"
minimal
/>
</Popover>
) : null}
</>
);
}
}
export default AnnoMenuCategory;
@@ -1,57 +0,0 @@
import React from "react";
import { connect } from "react-redux";
import { Button, MenuItem } from "@blueprintjs/core";
import { Select } from "@blueprintjs/select";
@connect(state => ({
colorAccessor: state.colors.colorAccessor,
categoricalSelection: state.categoricalSelection,
annotations: state.annotations,
universe: state.universe,
ontology: state.ontology,
ontologyLoading: state.ontology?.loading
}))
class DuplicateCategorySelect extends React.Component {
constructor(props) {
super(props);
this.state = {};
}
render() {
const {
allCategoryNames,
categoryToDuplicate,
handleModalDuplicateCategorySelection
} = this.props;
return (
<div>
<p>
Optionally duplicate all labels & cell assignments from existing
category into new category:
</p>
<Select
items={
allCategoryNames ||
[] /* this is a placeholder, could be a subcomponent to avoid this */
}
filterable={false}
itemRenderer={(d, { handleClick }) => {
return <MenuItem onClick={handleClick} key={d} text={d} />;
}}
noResults={<MenuItem disabled text="No results." />}
onItemSelect={d => {
handleModalDuplicateCategorySelection(d);
}}
>
{/* children become the popover target; render value here */}
<Button
text={categoryToDuplicate || "None (all cells 'unassigned')"}
rightIcon="double-caret-vertical"
/>
</Select>
</div>
);
}
}
export default DuplicateCategorySelect;
@@ -1,4 +0,0 @@
:local(.hover),
:local(.value):hover {
background: rgba(167, 182, 194, 0.3);
}
+14 -181
View File
@@ -1,134 +1,17 @@
// jshint esversion: 6
import React from "react";
import { Button } from "@blueprintjs/core";
import _ from "lodash";
import { connect } from "react-redux";
import * as globals from "../../globals";
import Category from "./category";
import { AnnotationsHelpers } from "../../util/stateManager";
import AnnoDialog from "./annoDialog";
import AnnoInputs from "./annoInputs";
import AnnoSelect from "./annoSelect";
@connect(state => ({
categoricalSelection: state.categoricalSelection,
writableCategoriesEnabled: state.config?.parameters?.["annotations"] ?? false,
schema: state.world?.schema
categoricalSelectionState: state.controls.categoricalSelectionState
}))
class Categories extends React.Component {
constructor(props) {
super(props);
this.state = {
createAnnoModeActive: false,
newCategoryText: "",
categoryToDuplicate: null
};
}
handleCreateUserAnno = () => {
const { dispatch } = this.props;
const { newCategoryText, categoryToDuplicate } = this.state;
dispatch({
type: "annotation: create category",
data: newCategoryText,
categoryToDuplicate
});
this.setState({
createAnnoModeActive: false,
categoryToDuplicate: null,
newCategoryText: ""
});
};
handleEnableAnnoMode = () => {
this.setState({ createAnnoModeActive: true });
};
handleDisableAnnoMode = () => {
this.setState({
createAnnoModeActive: false,
categoryToDuplicate: null,
newCategoryText: ""
});
};
handleModalDuplicateCategorySelection = d => {
this.setState({ categoryToDuplicate: d });
};
categoryNameError = name => {
/*
return false if this is a LEGAL/acceptable category name or NULL/empty string,
or return an error type.
*/
/* allow empty string */
if (name === "") return false;
/*
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);
/* check category name syntax */
const error = AnnotationsHelpers.annotationNameIsErroneous(name);
if (error) {
return error;
}
/* disallow duplicates */
if (allCategoryNames.indexOf(name) !== -1) {
return "duplicate";
}
/* otherwise, no error */
return false;
};
categoryNameErrorMessage = name => {
const err = this.categoryNameError(name);
if (err === false) return null;
const errorMessageMap = {
/* map error code to human readable error message */
"empty-string": "Blank names not allowed",
duplicate: "Name must be unique",
"trim-spaces": "Leading and trailing spaces not allowed",
"illegal-characters":
"Only alphanumeric and special characters (-_.) allowed",
"multi-space-run": "Multiple consecutive spaces not allowed"
};
const errorMessage = errorMessageMap[err] ?? "error";
return <span>{errorMessage}</span>;
};
handleNewCategoryText = txt => {
this.setState({ newCategoryText: txt });
};
handleChoice = e => {
/* Blueprint Suggest format */
this.setState({ newCategoryText: e.target });
};
handleSuggestActiveItemChange = () => {};
render() {
const {
createAnnoModeActive,
categoryToDuplicate,
newCategoryText
} = this.state;
const {
categoricalSelection,
writableCategoriesEnabled,
schema
} = this.props;
if (!categoricalSelection) return null;
/* all names, sorted in display order. Will be rendered in this order */
const allCategoryNames = Object.keys(categoricalSelection).sort();
const { categoricalSelectionState } = this.props;
if (!categoricalSelectionState) return null;
return (
<div
@@ -136,66 +19,16 @@ class Categories extends React.Component {
padding: globals.leftSidebarSectionPadding
}}
>
<AnnoDialog
isActive={createAnnoModeActive}
title="Create new category"
instruction="New, unique category name:"
cancelTooltipContent="Close this dialog without creating a category."
primaryButtonText="Create new category"
text={newCategoryText}
validationError={this.categoryNameError(newCategoryText)}
errorMessage={this.categoryNameErrorMessage(newCategoryText)}
handleSubmit={this.handleCreateUserAnno}
handleCancel={this.handleDisableAnnoMode}
annoInput={
<AnnoInputs
text={newCategoryText}
handleItemChange={this.handleSuggestActiveItemChange}
handleChoice={this.handleChoice}
handleTextChange={this.handleNewCategoryText}
/>
}
annoSelect={
<AnnoSelect
handleModalDuplicateCategorySelection={
this.handleModalDuplicateCategorySelection
}
categoryToDuplicate={categoryToDuplicate}
allCategoryNames={allCategoryNames}
/>
}
/>
{/* READ ONLY CATEGORICAL FIELDS */}
{/* this is duplicative but flat, could be abstracted */}
{allCategoryNames.map(catName =>
!schema.annotations.obsByName[catName].writable ? (
<Category
key={catName}
metadataField={catName}
createAnnoModeActive={createAnnoModeActive}
isUserAnno={false}
/>
) : null
)}
{/* WRITEABLE FIELDS */}
{allCategoryNames.map(catName =>
schema.annotations.obsByName[catName].writable ? (
<Category
key={catName}
metadataField={catName}
createAnnoModeActive={createAnnoModeActive}
isUserAnno
/>
) : null
)}
{writableCategoriesEnabled ? (
<div>
<Button onClick={this.handleEnableAnnoMode} intent="primary">
Create new category
</Button>
</div>
) : null}
<p
style={Object.assign({}, globals.leftSidebarSectionHeading, {
marginTop: 4
})}
>
Categorical Metadata
</p>
{_.map(categoricalSelectionState, (catState, catName) => (
<Category key={catName} metadataField={catName} />
))}
</div>
);
}
+99 -115
View File
@@ -2,23 +2,15 @@ import React from "react";
import _ from "lodash";
import { connect } from "react-redux";
import { FaChevronRight, FaChevronDown } from "react-icons/fa";
import { Button, Tooltip, Icon } from "@blueprintjs/core";
import CategoryFlipperLayout from "./categoryFlipperLayout";
import AnnoMenu from "./annoMenuCategory";
import AnnoDialogEditCategoryName from "./annoDialogEditCategoryName";
import AnnoDialogAddLabel from "./annoDialogAddLabel";
import AnnoDialogAddLabelFromOntology from "./annoDialogAddLabelFromOntology";
import { Button, Tooltip } from "@blueprintjs/core";
import * as globals from "../../globals";
import Value from "./value";
import alphabeticallySortedValues from "./util";
@connect(state => ({
colorAccessor: state.colors.colorAccessor,
categoricalSelection: state.categoricalSelection,
annotations: state.annotations,
universe: state.universe,
ontology: state.ontology,
ontologyLoading: state.ontology?.loading,
ontologyEnabled: state.ontology?.enabled
colorAccessor: state.controls.colorAccessor,
categoricalSelectionState: state.controls.categoricalSelectionState
}))
class Category extends React.Component {
constructor(props) {
@@ -29,43 +21,40 @@ class Category extends React.Component {
};
}
componentDidUpdate(prevProps) {
const { categoricalSelection, metadataField } = this.props;
if (categoricalSelection !== prevProps.categoricalSelection) {
const cat = categoricalSelection[metadataField];
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 });
}
componentDidUpdate() {
const { categoricalSelectionState, metadataField } = this.props;
const cat = categoricalSelectionState[metadataField];
const categoryCount = {
// total number of options in this category
totalOptionCount: cat.numOptions,
// number of selected options in this category
selectedOptionCount: _.reduce(
cat.optionSelected,
(res, cond) => (cond ? res + 1 : res),
0
)
};
if (categoryCount.selectedOptionCount === categoryCount.totalOptionCount) {
/* everything is on, so not indeterminate */
this.checkbox.indeterminate = false;
} else if (categoryCount.selectedOptionCount === 0) {
/* nothing is on, so no */
this.checkbox.indeterminate = false;
} else if (
categoryCount.selectedOptionCount < categoryCount.totalOptionCount
) {
/* to be explicit... */
this.checkbox.indeterminate = true;
}
}
handleColorChange = () => {
handleColorChange() {
const { dispatch, metadataField } = this.props;
dispatch({
type: "color by categorical metadata",
colorAccessor: metadataField
});
};
}
toggleAll() {
const { dispatch, metadataField } = this.props;
@@ -95,103 +84,98 @@ class Category extends React.Component {
}
}
renderCategoryItems() {
const { categoricalSelectionState, metadataField } = this.props;
const cat = categoricalSelectionState[metadataField];
const optTuples = alphabeticallySortedValues([...cat.optionIndex]);
return _.map(optTuples, (tuple, i) => (
<Value
key={tuple[1]}
metadataField={metadataField}
optionIndex={tuple[1]}
i={i}
/>
));
}
render() {
const { isExpanded, isChecked } = this.state;
const {
metadataField,
colorAccessor,
isUserAnno,
annotations
categoricalSelectionState
} = this.props;
const { isTruncated } = categoricalSelectionState[metadataField];
return (
<CategoryFlipperLayout
metadataField={metadataField}
isExpanded={isExpanded}
isUserAnno={isUserAnno}
<div
style={{
maxWidth: globals.maxControlsWidth
}}
>
<div
style={{
display: "flex",
justifyContent: "flex-start",
alignItems: "flex-start"
justifyContent: "space-between",
alignItems: "baseline"
}}
>
<label className="bp3-control bp3-checkbox">
<input
data-testclass="category-select"
data-testid={`category-select-${metadataField}`}
onChange={this.handleToggleAllClick.bind(this)}
ref={el => {
this.checkbox = el;
return el;
}}
checked={isChecked}
type="checkbox"
/>
<span className="bp3-control-indicator" />
</label>
<span
data-testid={`category-expand-${metadataField}`}
<div
style={{
cursor: "pointer",
display: "inline-block"
}}
onClick={() => {
const editingCategory =
annotations.isEditingCategoryName &&
annotations.categoryBeingEdited === metadataField;
if (!editingCategory) {
this.setState({ isExpanded: !isExpanded });
}
display: "flex",
justifyContent: "flex-start",
alignItems: "baseline"
}}
>
{isUserAnno ? (
<Icon style={{ marginRight: 5 }} icon="tag" iconSize={16} />
) : null}
{metadataField}
{isExpanded ? (
<FaChevronDown
data-testclass="category-expand-is-expanded"
style={{ fontSize: 10, marginLeft: 5 }}
<label className="bp3-control bp3-checkbox">
<input
onChange={this.handleToggleAllClick.bind(this)}
ref={el => {
this.checkbox = el;
return el;
}}
checked={isChecked}
type="checkbox"
/>
) : (
<FaChevronRight
data-testclass="category-expand-is-not-expanded"
style={{ fontSize: 10, marginLeft: 5 }}
/>
)}
</span>
</div>
{<AnnoDialogEditCategoryName metadataField={metadataField} />}
{<AnnoDialogAddLabel metadataField={metadataField} />}
{<AnnoDialogAddLabelFromOntology metadataField={metadataField} />}
<div>
<AnnoMenu
metadataField={metadataField}
isUserAnno={isUserAnno}
createText="Add a new label to this category"
createFromOntologyText="Add a new label to this category using existing ontology terms"
editText="Edit this category's name"
deleteText="Delete this category, all associated labels, and remove all cell assignments"
/>
<span className="bp3-control-indicator" />
{""}
</label>
<Tooltip
content="Use as color scale"
position="bottom"
hoverOpenDelay={globals.tooltipHoverOpenDelay}
>
<span
style={{
cursor: "pointer",
display: "inline-block"
}}
onClick={() => {
this.setState({ isExpanded: !isExpanded });
}}
>
{metadataField}
{isExpanded ? (
<FaChevronDown style={{ fontSize: 10, marginLeft: 5 }} />
) : (
<FaChevronRight style={{ fontSize: 10, marginLeft: 5 }} />
)}
</span>
</div>
<Tooltip content="Use as color scale" position="bottom">
<Button
data-testclass="colorby"
data-testid={`colorby-${metadataField}`}
onClick={this.handleColorChange}
onClick={this.handleColorChange.bind(this)}
active={colorAccessor === metadataField}
intent={colorAccessor === metadataField ? "primary" : "none"}
icon="tint"
icon={"tint"}
/>
</Tooltip>
</div>
</CategoryFlipperLayout>
<div style={{ marginLeft: 26 }}>
{isExpanded ? this.renderCategoryItems() : null}
</div>
<div>
{isExpanded && isTruncated ? (
<p style={{ paddingLeft: 15 }}>... truncated list ...</p>
) : null}
</div>
</div>
);
}
}
@@ -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,57 +0,0 @@
import React from "react";
import { AnnotationsHelpers } from "../../util/stateManager";
export function isLabelErroneous(label, metadataField, ontology, schema) {
/*
return false if this is a LEGAL/acceptable category name or NULL/empty string,
or return an error type.
*/
/* allow empty string */
if (label === "") return false;
/* check for label syntax errors, but allow terms in ontology */
const termInOntology = ontology?.termSet.has(label) ?? false;
const error = AnnotationsHelpers.annotationNameIsErroneous(label);
if (error && !termInOntology) return error;
/* disallow duplicates */
const { obsByName } = schema.annotations;
if (obsByName[metadataField].categories.indexOf(label) !== -1)
return "duplicate";
/* otherwise, no error */
return false;
}
export function labelErrorMessage(label, metadataField, ontology, schema) {
const err = isLabelErroneous(label, metadataField, ontology, schema);
if (err === "duplicate") {
/* duplicate error is special cased because it has special formatting */
return (
<span>
<span style={{ fontStyle: "italic" }}>{label}</span> already
exists already exists within{" "}
<span style={{ fontStyle: "italic" }}>{metadataField}</span>{" "}
</span>
);
}
if (err) {
/* all other errors - map code to human error message */
const errorMessageMap = {
"empty-string": "Blank names not allowed",
duplicate: "Name must be unique",
"trim-spaces": "Leading and trailing spaces not allowed",
"illegal-characters":
"Only alphanumeric and special characters (-_.) allowed",
"multi-space-run": "Multiple consecutive spaces not allowed"
};
const errorMessage = errorMessageMap[err] ?? "error";
return <span>{errorMessage}</span>;
}
/* no error, no message generated */
return null;
}
@@ -1,203 +0,0 @@
// jshint esversion: 6
import React from "react";
import { connect } from "react-redux";
import * as d3 from "d3";
import {
Popover,
PopoverInteractionKind,
Position,
Classes
} from "@blueprintjs/core";
@connect()
class Occupancy extends React.PureComponent {
_WIDTH = 100;
_HEIGHT = 11;
createHistogram = () => {
/*
Knowing that colorScale is based off continous data,
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
*/
const {
world,
metadataField,
colorAccessor,
category,
categoryIndex
} = this.props;
if (!this.canvas) return;
const groupBy = world.obsAnnotations.col(metadataField);
const col =
world.obsAnnotations.col(colorAccessor) ||
world.varData.col(colorAccessor);
const range = col.summarize();
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 categoryValue = category.categoryValues[categoryIndex];
const bins = histogramMap.has(categoryValue)
? histogramMap.get(categoryValue)
: new Array(50).fill(0);
const xScale = d3
.scaleLinear()
.domain([0, bins.length])
.range([0, this._WIDTH]);
const largestBin = Math.max(...bins);
const yScale = d3
.scaleLinear()
.domain([0, largestBin])
.range([0, this._HEIGHT]);
const ctx = this.canvas.getContext("2d");
ctx.fillStyle = "#000";
let x;
let y;
const rectWidth = this._WIDTH / bins.length;
for (let i = 0, { length } = bins; i < length; i += 1) {
x = xScale(i);
y = yScale(bins[i]);
ctx.fillRect(x, this._HEIGHT - y, rectWidth, y);
}
};
createOccupancyStack = () => {
/*
Knowing that the color scale is based off of catagorical 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 {
world,
metadataField,
colorAccessor,
category,
categoryIndex,
schema,
colorScale
} = this.props;
const ctx = this.canvas?.getContext("2d");
if (!ctx) return;
const groupBy = world.obsAnnotations.col(metadataField);
const occupancyMap = world.obsAnnotations
.col(colorAccessor)
.histogram(groupBy);
const occupancy = occupancyMap.get(category.categoryValues[categoryIndex]);
if (occupancy && occupancy.size > 0) {
// not all categories have occupancy, so occupancy may be undefined.
const x = 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, this._WIDTH]);
const categories =
schema.annotations.obsByName[colorAccessor]?.categories;
let currentOffset = 0;
const dfColumn = world.obsAnnotations.col(colorAccessor);
const categoryValues = dfColumn.summarize().categories;
let o;
let scaledValue;
let value;
for (let i = 0, { length } = categoryValues; i < length; i += 1) {
value = categoryValues[i];
o = occupancy.get(value);
scaledValue = x(o);
ctx.fillStyle = o
? colorScale(categories.indexOf(value))
: "rgb(255,255,255)";
ctx.fillRect(currentOffset, 0, o ? scaledValue : 0, this._HEIGHT);
currentOffset += o ? scaledValue : 0;
}
}
};
render() {
const {
colorAccessor,
categoricalSelection,
category,
categoryIndex
} = this.props;
const { canvas } = this;
if (canvas)
canvas.getContext("2d").clearRect(0, 0, this._WIDTH, this._HEIGHT);
const colorByIsCatagoricalData = !!categoricalSelection[colorAccessor];
return (
<Popover
interactionKind={PopoverInteractionKind.HOVER_TARGET_ONLY}
hoverOpenDelay={1500}
hoverCloseDelay={200}
position={Position.LEFT}
modifiers={{
preventOverflow: { enabled: false },
hide: { enabled: false }
}}
lazy
usePortal
disabled={colorByIsCatagoricalData}
popoverClassName={Classes.POPOVER_CONTENT_SIZING}
>
<canvas
className="bp3-popover-targer"
style={{
marginRight: 5,
width: this._WIDTH,
height: this._HEIGHT,
borderBottom: colorByIsCatagoricalData
? ""
: "solid rgb(230, 230, 230) 0.25px"
}}
width={this._WIDTH}
height={this._HEIGHT}
ref={ref => {
this.canvas = ref;
if (colorByIsCatagoricalData) this.createOccupancyStack();
else this.createHistogram();
}}
/>
<div key="text" style={{ fontFamily: "Roboto", fontSize: "14px" }}>
<p style={{ margin: "0" }}>
This histograms shows the distribution of{" "}
<strong>{colorAccessor}</strong> within{" "}
<strong>{category.categoryValues[categoryIndex]}</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>
);
}
}
export default Occupancy;
@@ -1,57 +0,0 @@
import React from "react";
import { connect } from "react-redux";
import { Button, MenuItem } from "@blueprintjs/core";
import { Select } from "@blueprintjs/select";
import fuzzysort from "fuzzysort";
const filterOntology = (query, ontology) =>
/* fires on load, once, and then for each character typed into the input */
fuzzysort.go(query, ontology, {
limit: 100,
threshold: -10000 // don't return bad results
});
@connect()
class ChooseOntologySelect extends React.Component {
constructor(props) {
super(props);
this.state = {};
}
render() {
const {
ontology,
categoryToDuplicate,
handleChooseOntologyTermFromDropdown
} = this.props;
return (
<div>
<Select
items={
ontology?.terms ||
[] /* this is a placeholder, could be a subcomponent to avoid this */
}
filterable
itemListPredicate={filterOntology}
itemRenderer={(d, { handleClick }) => {
return (
<MenuItem onClick={handleClick} key={d.target} text={d.target} />
);
}}
noResults={<MenuItem disabled text="No results." />}
onItemSelect={d => {
handleChooseOntologyTermFromDropdown(d);
}}
>
{/* children become the popover target; render value here */}
<Button
text={categoryToDuplicate || "Choose an Ontology Term"}
rightIcon="double-caret-vertical"
/>
</Select>
</div>
);
}
}
export default ChooseOntologySelect;
+11
View File
@@ -0,0 +1,11 @@
// jshint esversion: 6
// values is [ [optVal, optIdx], ...]
// index is range array
// return sorted index
export default values =>
values.sort((a, b) => {
const textA = String(a[0]).toUpperCase();
const textB = String(b[0]).toUpperCase();
return textA < textB ? -1 : textA > textB ? 1 : 0;
});
+52 -530
View File
@@ -1,582 +1,104 @@
// jshint esversion: 6
import { connect } from "react-redux";
import React from "react";
import {
Button,
Menu,
MenuItem,
Popover,
Position,
PopoverInteractionKind,
Tooltip,
Colors
} from "@blueprintjs/core";
import Occupancy from "./occupancy";
import * as globals from "../../globals";
import styles from "./categorical.css";
import AnnoDialog from "./annoDialog";
import AnnoInputs from "./annoInputs";
import { AnnotationsHelpers } from "../../util/stateManager";
import { labelErrorMessage, isLabelErroneous } from "./labelUtil";
import _ from "lodash";
@connect(state => ({
categoricalSelection: state.categoricalSelection,
annotations: state.annotations,
colorScale: state.colors.scale,
colorAccessor: state.colors.colorAccessor,
pointDilation: state.pointDilation,
schema: state.world?.schema,
world: state.world,
crossfilter: state.crossfilter,
ontology: state.ontology,
ontologyLoading: state.ontology?.loading,
ontologyEnabled: state.ontology?.enabled
categoricalSelectionState: state.controls.categoricalSelectionState,
colorScale: state.controls.colorScale,
colorAccessor: state.controls.colorAccessor,
schema: _.get(state.controls.world, "schema", null)
}))
class CategoryValue extends React.Component {
constructor(props) {
super(props);
this.state = {
editedLabelText: String(
props.categoricalSelection[props.metadataField].categoryValues[
props.categoryIndex
]
).valueOf()
};
}
componentDidUpdate(prevProps) {
const { categoricalSelection, metadataField, categoryIndex } = this.props;
if (
prevProps.categoricalSelection !== categoricalSelection ||
prevProps.metadataField !== metadataField ||
prevProps.categoryIndex !== categoryIndex
) {
this.setState({
editedLabelText: String(
categoricalSelection[metadataField].categoryValues[categoryIndex]
).valueOf()
});
}
}
handleDeleteValue = () => {
const { dispatch, metadataField } = this.props;
const label = this.getLabel();
dispatch({
type: "annotation: delete label",
metadataField,
label
});
};
handleAddCurrentSelectionToThisLabel = () => {
const { dispatch, metadataField, categoryIndex } = this.props;
const label = this.getLabel();
dispatch({
type: "annotation: label current cell selection",
metadataField,
categoryIndex,
label
});
};
handleEditValue = () => {
const { dispatch, metadataField, categoryIndex } = this.props;
const { editedLabelText } = this.state;
const label = this.getLabel();
dispatch({
type: "annotation: label edited",
editedLabel: editedLabelText,
metadataField,
categoryIndex,
label
});
};
handleCreateArbitraryLabel = editedLabelTextNotInOntology => {
const { dispatch, metadataField, categoryIndex } = this.props;
const label = this.getLabel();
dispatch({
type: "annotation: label edited",
metadataField,
editedLabel: editedLabelTextNotInOntology,
categoryIndex,
label
});
};
labelNameError = name => {
const {
metadataField,
ontology,
schema,
categoricalSelection,
categoryIndex
} = this.props;
const category = categoricalSelection[metadataField];
const displayString = String(
category.categoryValues[categoryIndex]
).valueOf();
if (name === displayString) return false;
return isLabelErroneous(name, metadataField, ontology, schema);
};
labelNameErrorMessage = name => {
const {
metadataField,
ontology,
schema,
categoricalSelection,
categoryIndex
} = this.props;
const category = categoricalSelection[metadataField];
const displayString = String(
category.categoryValues[categoryIndex]
).valueOf();
if (name === displayString) return null;
return labelErrorMessage(name, metadataField, ontology, schema);
};
activateEditLabelMode = () => {
const { dispatch, metadataField, categoryIndex } = this.props;
dispatch({
type: "annotation: activate edit label mode",
metadataField,
categoryIndex
});
};
cancelEdit = () => {
const { dispatch, metadataField, categoryIndex } = this.props;
dispatch({
type: "annotation: cancel edit label mode",
metadataField,
categoryIndex
});
};
toggleOff = () => {
const { dispatch, metadataField, categoryIndex } = this.props;
toggleOff() {
const { dispatch, metadataField, optionIndex } = this.props;
dispatch({
type: "categorical metadata filter deselect",
metadataField,
categoryIndex
optionIndex
});
};
}
shouldComponentUpdate = (nextProps, nextState) => {
/*
Checks to see if at least one of the following changed:
* world state
* the color accessor (what is currently being colored by)
* if this catagorical value's selection status has changed
* the crossfilter (ie, global selection state)
If and only if true, update the component
*/
const { props, state } = this;
const { metadataField, categoryIndex, categoricalSelection } = props;
const { categoricalSelection: newCategoricalSelection } = nextProps;
const valueSelectionChange =
categoricalSelection[metadataField].categoryValueSelected[
categoryIndex
] !==
newCategoricalSelection[metadataField].categoryValueSelected[
categoryIndex
];
const worldChange = props.world !== nextProps.world;
const colorAccessorChange = props.colorAccessor !== nextProps.colorAccessor;
const annotationsChange = props.annotations !== nextProps.annotations;
const crossfilterChange =
props.isUserAnno && props.crossfilter !== nextProps.crossfilter;
const editingLabel = state.editedLabelText !== nextState.editedLabelText;
const dilationChange = props.pointDilation !== nextProps.pointDilation;
return (
valueSelectionChange ||
worldChange ||
colorAccessorChange ||
annotationsChange ||
crossfilterChange ||
editingLabel ||
dilationChange
);
};
toggleOn = () => {
const { dispatch, metadataField, categoryIndex } = this.props;
toggleOn() {
const { dispatch, metadataField, optionIndex } = this.props;
dispatch({
type: "categorical metadata filter select",
metadataField,
categoryIndex
optionIndex
});
};
handleMouseEnter = () => {
const { dispatch, metadataField, categoryIndex } = this.props;
dispatch({
type: "category value mouse hover start",
metadataField,
categoryIndex
});
};
handleMouseExit = () => {
const { dispatch, metadataField, categoryIndex } = this.props;
dispatch({
type: "category value mouse hover end",
metadataField,
categoryIndex
});
};
handleTextChange = text => {
this.setState({ editedLabelText: text });
};
handleChoice = e => {
/* Blueprint Suggest format */
this.setState({ editedLabelText: e.target });
};
getLabel = () => {
const { metadataField, categoryIndex, categoricalSelection } = this.props;
const category = categoricalSelection[metadataField];
const label = category.categoryValues[categoryIndex];
return label;
};
isAddCurrentSelectionDisabled(category, value) {
/*
disable "add current selection to label", if one of the following is true:
1. no cells are selected
2. all currently selected cells already have this label, on this category
*/
const { crossfilter, world } = this.props;
// 1. no cells selected?
if (crossfilter.countSelected() === 0) {
return true;
}
// 2. all selected cells already have the label
const mask = crossfilter.allSelectedMask();
if (
AnnotationsHelpers.allHaveLabelByMask(
world.obsAnnotations,
category,
value,
mask
)
) {
return true;
}
// else, don't disable
return false;
}
render() {
const {
categoricalSelection,
categoricalSelectionState,
metadataField,
categoryIndex,
optionIndex,
colorAccessor,
colorScale,
i,
schema,
isUserAnno,
annotations,
ontologyEnabled,
// 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,
pointDilation
schema
} = this.props;
const { editedLabelText } = this.state;
if (!categoricalSelectionState) return null;
if (!categoricalSelection) return null;
const category = categoricalSelection[metadataField];
const selected = category.categoryValueSelected[categoryIndex];
const count = category.categoryValueCounts[categoryIndex];
const value = category.categoryValues[categoryIndex];
const displayString = String(
category.categoryValues[categoryIndex]
).valueOf();
const category = categoricalSelectionState[metadataField];
const selected = category.optionSelected[optionIndex];
const count = category.optionCount[optionIndex];
const value = category.optionValue[optionIndex];
const displayString = String(category.optionValue[optionIndex]).valueOf();
/* this is the color scale, so add swatches below */
const isColorBy = metadataField === colorAccessor;
const c = metadataField === colorAccessor;
let categories = null;
if (isColorBy && schema) {
categories = schema.annotations.obsByName[colorAccessor]?.categories;
if (c && schema) {
categories = _.filter(schema.annotations.obs, {
name: colorAccessor
})[0].categories;
}
let truncatedString = null;
if (
colorAccessor &&
!isColorBy &&
displayString.length > globals.categoryLabelDisplayStringShortLength
) {
truncatedString = `${displayString.slice(
0,
globals.categoryLabelDisplayStringShortLength / 2
)}…${displayString.slice(
-globals.categoryLabelDisplayStringShortLength / 2
)}`;
} else if (
displayString.length > globals.categoryLabelDisplayStringLongLength
) {
truncatedString = `${displayString.slice(
0,
globals.categoryLabelDisplayStringLongLength / 2
)}…${displayString.slice(
-globals.categoryLabelDisplayStringLongLength / 2
)}`;
}
const editModeActive =
isUserAnno &&
annotations.labelEditable.category === metadataField &&
annotations.isEditingLabelName &&
annotations.labelEditable.label === categoryIndex;
return (
<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={
/* This code is to change the styles on centroid label hover is causing over-rendering */
`${styles.value}${
pointDilation.metadataField === metadataField &&
pointDilation.categoryField === displayString
? ` ${styles.hover}`
: ""
}`
}
data-testclass="categorical-row"
style={{
padding: "4px 7px",
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
marginBottom: "2px",
borderRadius: "2px"
justifyContent: "space-between"
}}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseExit}
>
<div
style={{
margin: 0,
padding: 0,
userSelect: "none",
width: globals.leftSidebarWidth - 145,
display: "flex",
justifyContent: "space-between"
userSelect: "none"
}}
>
<div style={{ display: "flex", alignItems: "baseline" }}>
<label className="bp3-control bp3-checkbox" style={{ margin: 0 }}>
<input
onChange={selected ? this.toggleOff : this.toggleOn}
data-testclass="categorical-value-select"
data-testid={`categorical-value-select-${metadataField}-${displayString}`}
checked={selected}
type="checkbox"
/>
<span
className="bp3-control-indicator"
onMouseEnter={this.handleMouseExit}
onMouseLeave={this.handleMouseEnter}
/>
</label>
<Tooltip
content={displayString}
disabled={truncatedString === null}
hoverOpenDelay={globals.tooltipHoverOpenDelayQuick}
>
<span
data-testid={`categorical-value-${metadataField}-${displayString}`}
data-testclass="categorical-value"
style={{
color:
displayString === globals.unassignedCategoryLabel
? "#ababab"
: "black",
fontStyle:
displayString === globals.unassignedCategoryLabel
? "italic"
: "normal",
display: "inline-block",
overflow: "hidden",
lineHeight: "1.1em",
height: "1.1em",
wordBreak: "break-all",
verticalAlign: "middle"
}}
>
{truncatedString || displayString}
</span>
</Tooltip>
{editModeActive ? (
<div>
<AnnoDialog
isActive={editModeActive}
title="Edit label"
instruction={`New label text must be unique within category ${metadataField}:`}
cancelTooltipContent="Close this dialog without editing label text."
primaryButtonText="Change label text"
text={editedLabelText}
categoryToDuplicate={null}
validationError={this.labelNameError(editedLabelText)}
errorMessage={this.labelNameErrorMessage(editedLabelText)}
handleSubmit={this.handleEditValue}
handleCancel={this.cancelEdit}
annoInput={
<AnnoInputs
useSuggest={ontologyEnabled}
text={editedLabelText}
handleCreateArbitraryLabel={
this.handleCreateArbitraryLabel
}
handleItemChange={this.handleSuggestActiveItemChange}
handleChoice={this.handleChoice}
handleTextChange={this.handleTextChange}
isTextInvalid={this.labelNameError}
isTextInvalidErrorMessage={this.labelNameErrorMessage}
/>
}
annoSelect={null}
/>
</div>
) : null}
</div>
<span style={{ flexShrink: 0 }}>
{colorAccessor && !isColorBy && !annotations.isEditingLabelName ? (
<Occupancy category={category} {...this.props} />
) : null}
</span>
</div>
<div>
<span>
<span
data-testclass="categorical-value-count"
data-testid={`categorical-value-count-${metadataField}-${displayString}`}
style={{
color:
displayString === globals.unassignedCategoryLabel
? "#ababab"
: "black",
fontStyle:
displayString === globals.unassignedCategoryLabel
? "italic"
: "auto"
}}
>
{count}
</span>
<svg
display={isColorBy && categories ? "auto" : "none"}
style={{
marginLeft: 5,
width: 11,
height: 11,
backgroundColor:
isColorBy && categories
? colorScale(categories.indexOf(value))
: "inherit"
}}
<label className="bp3-control bp3-checkbox">
<input
onChange={
selected ? this.toggleOff.bind(this) : this.toggleOn.bind(this)
}
checked={selected}
type="checkbox"
/>
{isUserAnno ? (
<span
onMouseEnter={this.handleMouseExit}
onMouseLeave={this.handleMouseEnter}
>
<Popover
interactionKind={PopoverInteractionKind.HOVER}
boundary="window"
position={Position.RIGHT_TOP}
content={
<Menu>
<MenuItem
icon="plus"
data-testclass="handleAddCurrentSelectionToThisLabel"
data-testid={`handleAddCurrentSelectionToThisLabel-${metadataField}`}
onClick={this.handleAddCurrentSelectionToThisLabel}
text={
<span>
Re-label currently selected cells as
<span
style={{
fontStyle:
displayString ===
globals.unassignedCategoryLabel
? "italic"
: "auto"
}}
>
{` ${displayString}`}
</span>
</span>
}
disabled={this.isAddCurrentSelectionDisabled(
metadataField,
value
)}
/>
{displayString !== globals.unassignedCategoryLabel ? (
<MenuItem
icon="edit"
text="Edit this label's name"
data-testclass="handleEditValue"
data-testid={`handleEditValue-${metadataField}`}
onClick={this.activateEditLabelMode}
disabled={annotations.isEditingLabelName}
/>
) : null}
{displayString !== globals.unassignedCategoryLabel ? (
<MenuItem
icon="delete"
intent="danger"
data-testclass="handleDeleteValue"
data-testid={`handleDeleteValue-${metadataField}`}
onClick={this.handleDeleteValue}
text={`Delete this label, and reassign all cells to type '${globals.unassignedCategoryLabel}'`}
/>
) : null}
</Menu>
}
>
<Button
style={{
marginLeft: 0,
position: "relative",
top: -1,
minHeight: 16
}}
data-testclass="seeActions"
data-testid={`seeActions-${metadataField}`}
icon="more"
small
minimal
/>
</Popover>
</span>
) : null}
</span>
<span className="bp3-control-indicator" />
{displayString}
</label>
</div>
<span>
<span>{count}</span>
<svg
style={{
marginLeft: 5,
width: 11,
height: 11,
backgroundColor:
c && categories
? colorScale(categories.indexOf(value))
: "inherit"
}}
/>
</span>
</div>
);
}
+32 -43
View File
@@ -9,10 +9,12 @@ import * as globals from "../../globals";
import HistogramBrush from "../brushableHistogram";
@connect(state => ({
obsAnnotations: state.world?.obsAnnotations,
colorAccessor: state.colors.colorAccessor,
colorScale: state.colors.scale,
schema: state.world?.schema
ranges: _.get(state.controls.world, "summary.obs", null),
metadata: _.get(state.controls.world, "obsAnnotations", null),
colorAccessor: state.controls.colorAccessor,
colorScale: state.controls.colorScale,
selectionUpdate: _.get(state.controls, "crossfilter.updateTime", null),
schema: _.get(state.controls.world, "schema", null)
}))
class Continuous extends React.Component {
constructor(props) {
@@ -25,20 +27,19 @@ class Continuous extends React.Component {
componentDidUpdate() {}
handleColorAction = key => {
handleColorAction(key) {
return () => {
const { dispatch, obsAnnotations } = this.props;
const summary = obsAnnotations.col(key).summarize();
const { dispatch, ranges } = this.props;
dispatch({
type: "color by continuous metadata",
colorAccessor: key,
rangeForColorAccessor: summary
rangeMaxForColorAccessor: ranges[key].range.max
});
};
};
}
render() {
const { obsAnnotations, schema } = this.props;
const { ranges, obsAnnotations, schema } = this.props;
if (schema && !this.continuousChecked) {
this.hasContinuous = _.some(
schema.annotations.obs,
@@ -48,50 +49,38 @@ class Continuous extends React.Component {
}
/* initial value for iterator to simulate index, ranges is an object */
let zebra = 0;
let zebra = -1;
return (
<div>
{this.hasContinuous ? (
<p
style={{
...globals.leftSidebarSectionHeading,
style={Object.assign({}, globals.leftSidebarSectionHeading, {
marginTop: 40,
paddingLeft: globals.leftSidebarSectionPadding
}}
})}
>
Continuous metadata
</p>
) : null}
{obsAnnotations
? _.map(obsAnnotations.colIndex.keys(), key => {
const isColorField =
key.includes("color") || key.includes("Color");
if (key === schema.annotations.obs.index || isColorField)
return null;
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}
handleColorAction={this.handleColorAction(key)}
/>
);
}
return null;
})
: null}
{_.map(ranges, (value, key) => {
const isColorField = key.includes("color") || key.includes("Color");
zebra += 1;
if (value.range && key !== "name" && !isColorField) {
return (
<HistogramBrush
key={key}
field={key}
isObs
zebra={zebra % 2 === 0}
fieldValues={obsAnnotations}
ranges={value.range}
handleColorAction={this.handleColorAction(key).bind(this)}
/>
);
}
return null;
})}
</div>
);
}
@@ -0,0 +1,83 @@
// jshint esversion: 6
import * as d3 from "d3";
import styles from "./parallelCoordinates.css";
import { yAxis, brushstart } from "./util";
const drawAxes = (
svg,
ctx,
dimensions,
xscale,
height,
width,
handleBrushAction,
handleColorAction
) => {
/*****************************************
******************************************
Handles a brush event, toggling the display of foreground lines.
******************************************
******************************************/
function brush() {
const actives = [];
svg
.selectAll(".parcoords_axis .parcoords_brush")
.filter(() => d3.brushSelection(this))
.each(d => {
actives.push({
dimension: d,
extent: d3.brushSelection(this)
});
});
/* fire action, with selected dimensions & their values */
handleBrushAction(actives);
}
const axes = svg
.selectAll(".parcoords_axis")
.data(dimensions)
.enter()
.append("g")
.attr("class", `${styles.axis} parcoords_axis`)
.attr("transform", (d, i) => `translate(${xscale(i)})`);
axes
.append("g")
.each(d => {
const renderAxis =
"axis" in d
? d.axis.scale(d.scale) // custom axis
: yAxis.scale(d.scale); // default axis
d3.select(this).call(renderAxis);
})
.append("text")
.on("click", d => {
handleColorAction(d.key);
})
.attr("class", styles.title)
.attr("text-anchor", "start")
.text(d => ("description" in d ? `${d.description} 🖌️` : `${d.key} 🖌️`));
// Add and store a brush for each axis.
axes
.append("g")
.attr("class", `${styles.brush} parcoords_brush`)
.each(d => {
d3.select(this).call(
(d.brush = d3
.brushY()
.extent([[-10, 0], [10, height]])
.on("start", brushstart)
.on("brush", brush)
.on("end", brush))
);
})
.selectAll("rect")
.attr("x", -8)
.attr("width", 16);
return axes;
};
export default drawAxes;
@@ -2,8 +2,7 @@
import React from "react";
import { connect } from "react-redux";
import * as d3 from "d3";
import { interpolateCool } from "d3-scale-chromatic";
import * as globals from "../../globals";
import { interpolateViridis, interpolateCool } from "d3-scale-chromatic";
// create continuous color legend
// http://bl.ocks.org/syntagmatic/e8ccca52559796be775553b467593a9f
@@ -99,8 +98,8 @@ const continuous = (selectorId, colorscale, colorAccessor) => {
};
@connect(state => ({
colorAccessor: state.colors.colorAccessor,
colorScale: state.colors.scale,
colorAccessor: state.controls.colorAccessor,
colorScale: state.controls.colorScale,
responsive: state.responsive
}))
class ContinuousLegend extends React.Component {
@@ -113,7 +112,6 @@ class ContinuousLegend extends React.Component {
const { colorAccessor, responsive, colorScale } = this.props;
if (
prevProps.colorAccessor !== colorAccessor ||
prevProps.colorScale !== colorScale ||
prevProps.responsive.height !== responsive.height ||
prevProps.responsive.width !== responsive.width
) {
@@ -143,7 +141,7 @@ class ContinuousLegend extends React.Component {
style={{
position: "fixed",
display: colorAccessor ? "inherit" : "none",
right: globals.leftSidebarWidth,
right: 0,
top: responsive.height / 2
}}
/>
-18
View File
@@ -1,18 +0,0 @@
import React from "react";
import * as globals from "../../globals";
const Logo = props => {
const { size } = props;
return (
<svg width={size} height={size} viewBox="0 0 48 48" fill="none">
<rect width="48" height="48" fill="white" />
<rect width="48" height="48" fill={globals.logoColor} />
<rect x="19" y="19" width="22" height="22" fill="white" />
<rect x="24" y="24" width="12" height="12" fill={globals.logoColor} />
<rect x="7" y="19" width="7" height="22" fill="white" />
<rect x="19" y="7" width="22" height="7" fill="white" />
</svg>
);
};
export default Logo;
@@ -13,13 +13,6 @@ A "user" error - eg, bad input
export const postUserErrorToast = message =>
ErrorToastTopCenter.show({ message, intent: Intent.WARNING });
/*
A toast the user must dismiss manually, because they need to act on its information,
ie., 8 bulk add genes out of 40 were bad. Manually see which ones and fix.
*/
export const keepAroundErrorToast = message =>
ErrorToastTopCenter.show({ message, timeout: 0, intent: Intent.WARNING });
/*
a hard network error
*/
@@ -0,0 +1,52 @@
// jshint esversion: 6
import React from "react";
import _ from "lodash";
import { Button, Tooltip } from "@blueprintjs/core";
import { connect } from "react-redux";
@connect()
class CellSetButton extends React.Component {
set() {
const {
differential,
crossfilter,
dispatch,
eitherCellSetOneOrTwo
} = this.props;
const set = _.map(crossfilter.allFiltered(), "name");
if (!differential.diffExp) {
/* diffexp needs to be cleared before we store a new set */
dispatch({
type: `store current cell selection as differential set ${eitherCellSetOneOrTwo}`,
data: set
});
}
}
render() {
const { differential, eitherCellSetOneOrTwo } = this.props;
const cellListName = `celllist${eitherCellSetOneOrTwo}`;
return (
<Tooltip
content="Save current selection for differential expression computation"
position="top"
>
<Button
style={{ marginRight: 10 }}
type="button"
onClick={this.set.bind(this)}
>
{eitherCellSetOneOrTwo}
{": "}
{differential[cellListName]
? `${differential[cellListName].length} cells`
: "0 cells"}
</Button>
</Tooltip>
);
}
}
export default CellSetButton;
@@ -0,0 +1,100 @@
// jshint esversion: 6
import React from "react";
import _ from "lodash";
import { Button, AnchorButton, Tooltip } from "@blueprintjs/core";
import { connect } from "react-redux";
import * as globals from "../../globals";
import actions from "../../actions";
import CellSetButton from "./cellSetButtons";
@connect(state => ({
differential: state.differential,
world: state.controls.world,
crossfilter: state.controls.crossfilter,
selectionUpdate: _.get(state.controls, "crossfilter.updateTime", null)
}))
class Expression extends React.Component {
constructor(props) {
super(props);
this.state = {};
}
computeDiffExp() {
const { dispatch, differential } = this.props;
if (differential.celllist1 && differential.celllist2) {
dispatch(
actions.requestDifferentialExpression(
differential.celllist1,
differential.celllist2
)
);
}
}
clearDifferentialExpression() {
const { dispatch, differential } = this.props;
dispatch({
type: "clear differential expression",
diffExp: differential.diffExp
});
dispatch({
type: "clear scatterplot"
});
}
render() {
const { differential } = this.props;
if (!differential) {
return null;
}
const haveBothCellSets =
!!differential.celllist1 && !!differential.celllist2;
return (
<div
style={{
marginRight: 10,
marginBottom: 10,
paddingLeft: globals.leftSidebarSectionPadding
}}
>
<CellSetButton {...this.props} eitherCellSetOneOrTwo={1} />
<CellSetButton {...this.props} eitherCellSetOneOrTwo={2} />
{!differential.diffExp ? (
<Tooltip
content="Add two cells selections, see the top 15 differentially expressed genes between them"
position="bottom"
>
<AnchorButton
style={{ marginTop: 10 }}
disabled={!haveBothCellSets}
intent="primary"
fill
type="button"
onClick={this.computeDiffExp.bind(this)}
>
Compute Differential Expression
</AnchorButton>
</Tooltip>
) : null}
{differential.diffExp ? (
<Tooltip
content="Remove differentially expressed gene list and clear cell selections"
position="bottom"
>
<Button
type="button"
fill
style={{ marginTop: 10 }}
intent="warning"
onClick={this.clearDifferentialExpression.bind(this)}
>
Clear Differential Expression
</Button>
</Tooltip>
) : null}
</div>
);
}
}
export default Expression;
+80 -282
View File
@@ -3,66 +3,28 @@
import React from "react";
import _ from "lodash";
import fuzzysort from "fuzzysort";
import * as d3 from "d3";
import { connect } from "react-redux";
import {
MenuItem,
Button,
FormGroup,
InputGroup,
ControlGroup
} from "@blueprintjs/core";
import { Suggest } from "@blueprintjs/select";
import { Button, Tooltip } from "@blueprintjs/core";
import HistogramBrush from "../brushableHistogram";
import * as globals from "../../globals";
import actions from "../../actions";
import {
postUserErrorToast,
keepAroundErrorToast
} from "../framework/toasters";
import { memoize } from "../../util/dataframe/util";
const renderGene = (fuzzySortResult, { handleClick, modifiers }) => {
if (!modifiers.matchesPredicate) {
return null;
}
/* the fuzzysort wraps the object with other properties, like a score */
const geneName = fuzzySortResult.target;
return (
<MenuItem
active={modifiers.active}
disabled={modifiers.disabled}
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}
onClick={g =>
/* this fires when user clicks a menu item */
handleClick(g)
}
text={geneName}
/>
);
};
const filterGenes = (query, genes) =>
/* fires on load, once, and then for each character typed into the input */
fuzzysort.go(query, genes, {
limit: 5,
threshold: -10000 // don't return bad results
});
import { postUserErrorToast } from "../framework/toasters";
import ExpressionButtons from "./expressionButtons";
@connect(state => {
const metadata = _.get(state.controls.world, "obsAnnotations", null);
const ranges = _.get(state.controls.world, "summary.obs", null);
const initializeRanges = _.get(state.controls.world, "summary.obs");
return {
obsAnnotations: state.world?.obsAnnotations,
ranges,
metadata,
initializeRanges,
userDefinedGenes: state.controls.userDefinedGenes,
userDefinedGenesLoading: state.controls.userDefinedGenesLoading,
world: state.world,
colorAccessor: state.colors.colorAccessor,
world: state.controls.world,
colorAccessor: state.controls.colorAccessor,
allGeneNames: state.controls.allGeneNames,
differential: state.differential
};
})
@@ -70,268 +32,95 @@ class GeneExpression extends React.Component {
constructor(props) {
super(props);
this.state = {
bulkAdd: "",
tab: "autosuggest",
activeItem: null
gene: ""
};
}
_genesToUpper = listGenes => {
// Has to be a Map to preserve index
const upperGenes = new Map();
for (let i = 0, { length } = listGenes; i < length; i += 1) {
upperGenes.set(listGenes[i].toUpperCase(), i);
keyPress(e) {
if (e.keyCode === 13) {
this.handleClick();
}
return upperGenes;
};
// eslint-disable-next-line react/sort-comp
_memoGenesToUpper = memoize(this._genesToUpper, arr => arr);
handleBulkAddClick = () => {
const { world, dispatch, userDefinedGenes } = this.props;
const varIndexName = world.schema.annotations.var.index;
const { bulkAdd } = this.state;
/*
test:
Apod,,, Cd74,, ,,, Foo, Bar-2,,
*/
if (bulkAdd !== "") {
const genes = _.pull(_.uniq(bulkAdd.split(/[ ,]+/)), "");
if (genes.length === 0) {
return keepAroundErrorToast("Must enter a gene name.");
}
const worldGenes = world.varAnnotations.col(varIndexName).asArray();
// These gene lists are unique enough where memoization is useless
const upperGenes = this._genesToUpper(genes);
const upperUserDefinedGenes = this._genesToUpper(userDefinedGenes);
const upperWorldGenes = this._memoGenesToUpper(worldGenes);
dispatch({ type: "bulk user defined gene start" });
Promise.all(
[...upperGenes.keys()].map(upperGene => {
if (upperUserDefinedGenes.get(upperGene) !== undefined) {
return keepAroundErrorToast("That gene already exists");
}
const indexOfGene = upperWorldGenes.get(upperGene);
if (indexOfGene === undefined) {
return keepAroundErrorToast(
`${
genes[upperGenes.get(upperGene)]
} doesn't appear to be a valid gene name.`
);
}
return dispatch(
actions.requestUserDefinedGene(worldGenes[indexOfGene])
);
})
).then(
() => dispatch({ type: "bulk user defined gene complete" }),
() => dispatch({ type: "bulk user defined gene error" })
);
}
this.setState({ bulkAdd: "" });
return undefined;
};
placeholderGeneNames() {
/*
return a string containing gene name suggestions for use as a user hint.
Eg., Apod, Cd74, ...
Will return a max of 3 genes, totalling 15 characters in length.
Randomly selects gene names.
NOTE: the random selection means it will re-render constantly.
*/
const { world } = this.props;
const { varAnnotations } = world;
const varIndexName = world.schema.annotations.var.index;
const geneNames = varAnnotations.col(varIndexName).asArray();
if (geneNames.length > 0) {
const placeholder = [];
let len = geneNames.length;
const maxGeneNameCount = 3;
const maxStrLength = 15;
len = len < maxGeneNameCount ? len : maxGeneNameCount;
for (let i = 0, strLen = 0; i < len && strLen < maxStrLength; i += 1) {
const deal = Math.floor(Math.random() * geneNames.length);
const geneName = geneNames[deal];
placeholder.push(geneName);
strLen += geneName.length + 2; // '2' is the length of a comma and space
}
placeholder.push("...");
return placeholder.join(", ");
}
// default - should never happen.
return "Apod, Cd74, ...";
}
handleClick(g) {
handleClick() {
const { world, dispatch, userDefinedGenes } = this.props;
const varIndexName = world.schema.annotations.var.index;
if (!g) return;
const gene = g.target;
const { gene } = this.state;
if (userDefinedGenes.indexOf(gene) !== -1) {
postUserErrorToast("That gene already exists");
} else if (userDefinedGenes.length > 15) {
postUserErrorToast(
"That's too many genes, you can have at most 15 user defined genes"
);
} else if (
world.varAnnotations.col(varIndexName).indexOf(gene) === undefined
) {
} else if (!_.find(world.varAnnotations, { name: gene })) {
postUserErrorToast("That doesn't appear to be a valid gene name.");
} else {
dispatch({ type: "single user defined gene start" });
dispatch(actions.requestUserDefinedGene(gene)).then(
() => dispatch({ type: "single user defined gene complete" }),
() => dispatch({ type: "single user defined gene error" })
);
dispatch(actions.requestUserDefinedGene(gene));
dispatch({
type: "user defined gene",
data: gene
});
this.setState({ gene: "" });
}
}
render() {
const {
world,
userDefinedGenes,
userDefinedGenesLoading,
differential
} = this.props;
const varIndexName = world?.schema?.annotations?.var?.index;
const { tab, bulkAdd, activeItem } = this.state;
const { world, userDefinedGenes, differential } = this.props;
const { gene } = this.state;
return (
<div>
<div>
<div
style={{
padding: globals.leftSidebarSectionPadding
}}
<div
style={{
marginTop: 30
}}
>
<p
style={Object.assign({}, globals.leftSidebarSectionHeading, {
paddingLeft: globals.leftSidebarSectionPadding,
margin: 0
})}
>
<Button
active={tab === "autosuggest"}
style={{ marginRight: 5 }}
minimal
small
data-testid="tab-autosuggest"
onClick={() => {
this.setState({ tab: "autosuggest" });
}}
>
Autosuggest genes
</Button>
<Button
active={tab === "bulkadd"}
minimal
small
data-testid="section-bulk-add"
onClick={() => {
this.setState({ tab: "bulkadd" });
}}
>
Bulk add genes
</Button>
</div>
{tab === "autosuggest" ? (
<ControlGroup
style={{
paddingLeft: globals.leftSidebarSectionPadding,
paddingBottom: globals.leftSidebarSectionPadding
}}
>
<Suggest
resetOnSelect
closeOnSelect
resetOnClose
itemDisabled={
userDefinedGenesLoading ? () => true : () => false
}
noResults={<MenuItem disabled text="No matching genes." />}
onItemSelect={g => {
/* this happens on 'enter' */
this.handleClick(g);
Selected Genes
</p>
<div
style={{ padding: globals.leftSidebarSectionPadding }}
className="bp3-control-group"
>
<div className="bp3-input-group bp3-fill">
<input
onKeyDown={this.keyPress.bind(this)}
onChange={e => {
this.setState({ gene: e.target.value });
}}
initialContent={<MenuItem disabled text="Enter a gene…" />}
inputProps={{ "data-testid": "gene-search" }}
inputValueRenderer={() => {
return "";
}}
itemListPredicate={filterGenes}
onActiveItemChange={item => this.setState({ activeItem: item })}
itemRenderer={renderGene.bind(this)}
items={
world && world.varAnnotations
? world.varAnnotations.col(varIndexName).asArray()
: ["No genes"]
}
popoverProps={{ minimal: true }}
value={gene}
type="text"
className="bp3-input"
placeholder="Enter a gene name"
style={{ paddingRight: 94 }}
/>
<Button
className="bp3-button bp3-intent-primary"
data-testid="add-gene"
loading={userDefinedGenesLoading}
onClick={() => this.handleClick(activeItem)}
>
Add gene
</Button>
</ControlGroup>
) : null}
{tab === "bulkadd" ? (
<div style={{ paddingLeft: globals.leftSidebarSectionPadding }}>
<form
onSubmit={e => {
e.preventDefault();
this.handleBulkAddClick();
}}
>
<FormGroup
helperText="Add a list of genes (comma delimited)"
labelFor="text-input-bulk-add"
>
<ControlGroup>
<InputGroup
onChange={e => {
this.setState({ bulkAdd: e.target.value });
}}
id="text-input-bulk-add"
data-testid="input-bulk-add"
placeholder={this.placeholderGeneNames()}
value={bulkAdd}
/>
<Button
intent="primary"
onClick={this.handleBulkAddClick}
loading={userDefinedGenesLoading}
>
Add genes
</Button>
</ControlGroup>
</FormGroup>
</form>
</div>
) : null}
<Tooltip
content="Add a gene to see its expression levels"
position="bottom"
>
<Button intent="primary" onClick={this.handleClick.bind(this)}>
Add
</Button>
</Tooltip>
</div>
{world && userDefinedGenes.length > 0
? _.map(userDefinedGenes, (geneName, index) => {
const values = world.varData.col(geneName);
const values = world.varDataCache[geneName];
if (!values) {
return null;
}
const summary = values.summarize();
return (
<HistogramBrush
key={geneName}
field={geneName}
zebra={index % 2 === 0}
ranges={summary}
ranges={d3.extent(values)}
isUserDefined
/>
);
@@ -339,20 +128,29 @@ class GeneExpression extends React.Component {
: null}
</div>
<div>
<p
style={Object.assign({}, globals.leftSidebarSectionHeading, {
marginTop: 40,
paddingLeft: globals.leftSidebarSectionPadding
})}
>
Differentially Expressed Genes
</p>
<ExpressionButtons />
{differential.diffExp
? _.map(differential.diffExp, (value, index) => {
const name = world.varAnnotations.at(value[0], varIndexName);
const values = world.varData.col(name);
const annotations = world.varAnnotations[value[0]];
const { name } = annotations;
const values = world.varDataCache[name];
if (!values) {
return null;
}
const summary = values.summarize();
return (
<HistogramBrush
key={name}
field={name}
zebra={index % 2 === 0}
ranges={summary}
ranges={d3.extent(values)}
isDiffExp
logFoldChange={value[1]}
pval={value[2]}
+15 -38
View File
@@ -1,67 +1,44 @@
import { glPointFlags, glPointSize } from "../../util/glHelpers";
// jshint esversion: 6
const mat4 = require("gl-mat4");
// opacity: https://github.com/spacetx/starfish/blob/master/viz/draw/regions.js
export default function(regl) {
return regl({
vert: `
precision mediump float;
attribute vec2 position;
attribute vec3 color;
attribute float flag;
attribute float size;
uniform float distance;
uniform mat3 projView;
uniform float nPoints;
uniform float minViewportDimension;
varying vec4 fragColor;
const float zBottom = 0.99;
const float zMiddle = 0.;
const float zTop = -1.;
// import getFlags()
${glPointFlags}
// get pointSize()
${glPointSize}
uniform mat4 projection, view;
varying vec3 fragColor;
void main() {
bool isNaN, isSelected, isHighlight;
getFlags(flag, isNaN, isSelected, isHighlight);
float size = pointSize(nPoints, minViewportDimension, isSelected, isHighlight);
gl_PointSize = size * pow(distance, 0.5);
float z = isNaN ? zBottom : (isHighlight ? zTop : zMiddle);
vec3 xy = projView * vec3(position, 1.);
gl_Position = vec4(xy.xy, z, 1.);
float alpha = isNaN ? 0.9 : 1.0;
fragColor = vec4(color, alpha);
gl_PointSize = 7.0 / pow(distance, 2.5) + size;
gl_Position = projection * view * vec4(position.x, -position.y, 0, 1);
fragColor = color;
}`,
frag: `
precision mediump float;
varying vec4 fragColor;
varying vec3 fragColor;
void main() {
if (length(gl_PointCoord.xy - 0.5) > 0.5) {
discard;
}
gl_FragColor = fragColor;
gl_FragColor = vec4(fragColor, 1);
}`,
attributes: {
position: regl.prop("position"),
color: regl.prop("color"),
flag: regl.prop("flag")
size: regl.prop("size")
},
uniforms: {
distance: regl.prop("distance"),
projView: regl.prop("projView"),
nPoints: regl.prop("nPoints"),
minViewportDimension: regl.prop("minViewportDimension")
view: regl.prop("view"),
projection: ({viewportWidth, viewportHeight}) => mat4.perspective([], Math.PI / 2, viewportWidth / viewportHeight, 0.01, 1000)
},
count: regl.prop("count"),
File diff suppressed because it is too large Load Diff
@@ -1,73 +0,0 @@
/* eslint-disable jsx-a11y/mouse-events-have-key-events */
import React, { PureComponent } from "react";
import { connect } from "react-redux";
export default
@connect(state => ({
colorAccessor: state.colors.colorAccessor,
dilatedValue: state.pointDilation.categoryField,
labels: state.centroidLabels.labels
}))
class CentroidLabels extends PureComponent {
render() {
const {
labels,
inverseTransform,
dilatedValue,
dispatch,
colorAccessor
} = this.props;
const labelSVGS = [];
let fontSize = "15px";
let fontWeight = null;
labels.forEach((value, key) => {
fontSize = "15px";
fontWeight = null;
if (key === dilatedValue) {
fontSize = "18px";
fontWeight = "800";
}
labelSVGS.push(
<g
// eslint-disable-next-line react/no-array-index-key
key={key}
className="centroid-label"
transform={`translate(${value[0]}, ${value[1]})`}
>
<text
transform={inverseTransform}
textAnchor="middle"
data-label={key}
style={{
fontFamily: "Roboto Condensed",
fontSize,
fontWeight,
fill: "black",
userSelect: "none"
}}
onMouseEnter={e =>
dispatch({
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"
>
{key.length > 20 ? `${key.substr(0, 20)}...` : key}
</text>
</g>
);
});
return <>{labelSVGS}</>;
}
}
@@ -1,100 +0,0 @@
import React, { PureComponent, cloneElement } from "react";
import { connect } from "react-redux";
import styles from "../graph.css";
export default
@connect(state => ({
responsive: state.responsive
}))
class GraphOverlayLayer extends PureComponent {
/*
This component takes its children (assumed in the data coordinate space ([0, 1] range, origin in bottom left corner))
and transforms itself multiple times resulting in screen space ([0, screenWidth/Height] range, origin in top left corner)
Children are assigned in the graph component
*/
matrixToTransformString = m => {
/*
Translates the gl-matrix mat3 to SVG matrix transform style
mat3 SVG Transform Function
a c e
b d f / [a, b, 0, c, d, 0, e, f, 1] => matrix(a, b, c, d, e, f) / matrix(sx, 0, 0, sy, tx, ty) / matrix(m[0] m[3] m[1] m[4] m[6] m[7])
0 0 1
*/
return `matrix(${m[0]} ${m[1]} ${m[3]} ${m[4]} ${m[6]} ${m[7]})`;
};
reverseMatrixScaleTransformString = m => {
return `matrix(${1 / m[0]} 0 0 ${1 / m[4]} 0 0)`;
};
render() {
const {
cameraTF,
modelTF,
projectionTF,
responsive,
graphPaddingRightLeft,
graphPaddingTop,
children
} = this.props;
if (!cameraTF) return null;
const inverseTransform = `${this.reverseMatrixScaleTransformString(
modelTF
)} ${this.reverseMatrixScaleTransformString(
cameraTF
)} ${this.reverseMatrixScaleTransformString(
projectionTF
)} scale(1 2) scale(1 ${1 /
-(responsive.height - graphPaddingTop)}) scale(2 1) scale(${1 /
(responsive.width - graphPaddingRightLeft)} 1)`;
const newChildren = React.Children.toArray(children);
return (
<svg
className={styles.graphSVG}
width={responsive.width - graphPaddingRightLeft}
height={responsive.height}
pointerEvents="none"
style={{ zIndex: 99 }}
>
<g
id="canvas-transformation-group-x"
transform={`scale(${responsive.width -
graphPaddingRightLeft} 1) scale(.5 1) translate(1 0)`}
>
<g
id="canvas-transformation-group-y"
transform={`scale(1 ${-(
responsive.height - graphPaddingTop
)}) translate(0 -1) scale(1 .5) translate(0 1)`}
>
<g
id="projection-transformation-group"
transform={this.matrixToTransformString(projectionTF)}
>
<g
id="camera-transformation-group"
transform={this.matrixToTransformString(cameraTF)}
>
<g
id="model-transformation-group"
transform={this.matrixToTransformString(modelTF)}
>
{newChildren.map(child =>
cloneElement(child, { inverseTransform })
)}
</g>
</g>
</g>
</g>
</g>
</svg>
);
}
}
-145
View File
@@ -1,145 +0,0 @@
// https://bl.ocks.org/pbeshai/8008075f9ce771ee8be39e8c38907570
import * as d3 from "d3";
const Lasso = () => {
const dispatch = d3.dispatch("start", "end", "cancel");
const polygonToPath = polygon =>
`M${polygon.map(d => d.join(",")).join("L")}`;
const distance = (pt1, pt2) =>
Math.sqrt((pt2[0] - pt1[0]) ** 2 + (pt2[1] - pt1[1]) ** 2);
// distance last point has to be to first point before it auto closes when mouse is released
const closeDistance = 75;
const lasso = svg => {
let lassoPolygon;
let lassoPath;
let closePath;
const handleDragStart = () => {
lassoPolygon = [d3.mouse(svg.node())]; // current x y of mouse within element
if (lassoPath) {
lassoPath.remove();
}
lassoPath = g
.append("path")
.attr("data-testid", "lasso-element")
.attr("fill", "#0bb")
.attr("fill-opacity", 0.1)
.attr("stroke", "#0bb")
.attr("stroke-dasharray", "3, 3");
closePath = g
.append("line")
.attr("x2", lassoPolygon[0][0])
.attr("y2", lassoPolygon[0][1])
.attr("stroke", "#0bb")
.attr("stroke-dasharray", "3, 3")
.attr("opacity", 0);
dispatch.call("start", lasso, lassoPolygon);
};
const handleDrag = () => {
const point = d3.mouse(svg.node());
lassoPolygon.push(point);
lassoPath.attr("d", polygonToPath(lassoPolygon));
// indicate if we are within closing distance
if (
distance(lassoPolygon[0], lassoPolygon[lassoPolygon.length - 1]) <
closeDistance
) {
closePath
.attr("x1", point[0])
.attr("y1", point[1])
.attr("opacity", 1);
} else {
closePath.attr("opacity", 0);
}
};
const handleDragEnd = () => {
// remove the close path
closePath.remove();
closePath = null;
// succesfully closed
if (
distance(lassoPolygon[0], lassoPolygon[lassoPolygon.length - 1]) <
closeDistance
) {
lassoPath.attr("d", `${polygonToPath(lassoPolygon)}Z`);
dispatch.call("end", lasso, lassoPolygon);
// otherwise cancel
} else {
lassoPath.remove();
lassoPath = null;
lassoPolygon = null;
dispatch.call("cancel");
}
};
// append a <g> with a rect
const g = svg.append("g").attr("class", "lasso-group");
const bbox = svg.node().getBoundingClientRect();
const area = g
.append("rect")
.attr("width", bbox.width)
.attr("height", bbox.height)
.attr("opacity", 0);
const drag = d3
.drag()
.on("start", handleDragStart)
.on("drag", handleDrag)
.on("end", handleDragEnd);
area.call(drag);
lasso.reset = () => {
if (lassoPath) {
lassoPath.remove();
lassoPath = null;
}
lassoPolygon = null;
if (closePath) {
closePath.remove();
closePath = null;
}
};
lasso.move = polygon => {
if (polygon !== lassoPolygon || polygon.length !== lassoPolygon.length) {
lasso.reset();
lassoPolygon = polygon;
lassoPath = g
.append("path")
.attr("data-testid", "lasso-element")
.attr("fill", "#0bb")
.attr("fill-opacity", 0.1)
.attr("stroke", "#0bb")
.attr("stroke-dasharray", "3, 3");
lassoPath.attr("d", `${polygonToPath(lassoPolygon)}Z`);
}
};
};
lasso.on = (type, callback) => {
dispatch.on(type, callback);
return lasso;
};
return lasso;
};
export default Lasso;
+23 -38
View File
@@ -1,6 +1,6 @@
// jshint esversion: 6
import * as d3 from "d3";
import Lasso from "./setupLasso";
import styles from "./graph.css";
/******************************************
*******************************************
@@ -9,47 +9,32 @@ import Lasso from "./setupLasso";
******************************************/
export default (
selectionToolType,
handleStartAction,
handleDragAction,
handleEndAction,
handleCancelAction,
handleBrushSelectAction,
handleBrushDeselectAction,
responsive,
graphPaddingRight
) => {
const svg = d3.select("#graphAttachPoint").select("#lasso-layer");
const svg = d3
.select("#graphAttachPoint")
.append("svg")
.attr("width", responsive.width - graphPaddingRight)
.attr("height", responsive.height)
.attr("class", `${styles.graphSVG}`);
if (selectionToolType === "brush") {
const brush = d3
.brush()
.extent([
[0, 0],
[responsive.width - graphPaddingRight, responsive.height]
])
.on("start", handleStartAction)
.on("brush", handleDragAction)
// FYI, brush doesn't generate cancel
.on("end", handleEndAction);
const brush = d3
.brush()
.extent([[0, 0], [responsive.width - graphPaddingRight, responsive.height]])
.on("brush", handleBrushSelectAction)
.on("end", handleBrushDeselectAction);
const brushContainer = svg
.append("g")
.attr("class", "graph_brush")
.call(brush);
const brushContainer = svg
.append("g")
.attr("class", "graph_brush")
.call(brush);
return { svg, container: brushContainer, tool: brush };
}
if (selectionToolType === "lasso") {
const lasso = Lasso()
.on("end", handleEndAction)
// FYI, Lasso doesn't generate drag
.on("start", handleStartAction)
.on("cancel", handleCancelAction);
const lassoContainer = svg.call(lasso);
return { svg, container: lassoContainer, tool: lasso };
}
throw new Error("unknown graph selection tool");
return {
svg,
brushContainer,
brush
};
};
+26
View File
@@ -0,0 +1,26 @@
// jshint esversion: 6
// createExpressionsCountsMap () {
//
// const CHANGE_ME_MAGIC_GENE_INDEX = 5;
//
// const expressionsCountsMap = {};
//
// /* currently selected gene */
// expressionsCountsMap.geneName = this.state.expressions.data.genes[3];
//
// let maxExpressionValue = 0;
//
// /* create map of expressions for every cell */
// this.state.expressions.data.cells.map((c) => {
// /* cellname = 234 */
// expressionsCountsMap[c.cellname] = c["e"][CHANGE_ME_MAGIC_GENE_INDEX];
// /* collect the maximum value as we iterate */
// if (c["e"][CHANGE_ME_MAGIC_GENE_INDEX] > maxExpressionValue) {
// maxExpressionValue = c["e"][CHANGE_ME_MAGIC_GENE_INDEX]
// }
// })
//
// expressionsCountsMap.maxValue = maxExpressionValue;
//
// return expressionsCountsMap;
// }
@@ -1,90 +0,0 @@
// jshint esversion: 6
import React from "react";
import { connect } from "react-redux";
import * as globals from "../../globals";
import Logo from "../framework/logo";
@connect(state => ({
responsive: state.responsive,
datasetTitle: state.config?.displayNames?.dataset ?? "",
aboutURL: state.config?.links?.["about-dataset"],
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
scatterplotYYaccessor: state.controls.scatterplotYYaccessor
}))
class LeftSideBar extends React.Component {
render() {
const { datasetTitle, aboutURL } = this.props;
const paddingToAvoidScrollBar = 15;
const displayTitle =
datasetTitle.length > globals.datasetTitleMaxCharacterCount
? `${datasetTitle.substring(
0,
Math.floor(globals.datasetTitleMaxCharacterCount / 2)
)}…${datasetTitle.slice(
-Math.floor(globals.datasetTitleMaxCharacterCount / 2)
)}`
: datasetTitle;
return (
<div
style={{
paddingLeft: 8,
paddingTop: 8,
width: globals.leftSidebarWidth - paddingToAvoidScrollBar,
position: "absolute",
backgroundColor: "white",
zIndex: 8888
/* x y blur spread color */
// boxShadow: "-5px -1px 4px 2px rgba(225,225,225,0.4)"
}}
>
<Logo size={30} />
<span
style={{
fontSize: 28,
position: "relative",
top: -6,
fontWeight: "bold",
marginLeft: 5,
color: globals.logoColor,
userSelect: "none"
}}
>
cell
<span
style={{
position: "relative",
top: 1,
fontWeight: 300,
fontSize: 24
}}
>
×
</span>
gene
</span>
<div
data-testid="header"
style={{
fontSize: 14,
position: "relative",
top: -6,
display: "inline-block",
width: "190px",
marginLeft: "7px",
height: "1.2em",
overflow: "hidden",
wordBreak: "break-all"
}}
title={datasetTitle}
>
{aboutURL ? <a href={aboutURL}>{displayTitle}</a> : displayTitle}
</div>
</div>
);
}
}
export default LeftSideBar;
@@ -1,13 +1,16 @@
// jshint esversion: 6
import _ from "lodash";
import React from "react";
import { connect } from "react-redux";
import Categorical from "../categorical/categorical";
import * as globals from "../../globals";
import DynamicScatterplot from "../scatterplot/scatterplot";
import TopLeftLogoAndTitle from "./topLeftLogoAndTitle";
import Categorical from "./categorical/categorical";
import Continuous from "./continuous/continuous";
import GeneExpression from "./geneExpression";
import * as globals from "../globals";
import DynamicScatterplot from "./scatterplot/scatterplot";
@connect(state => ({
responsive: state.responsive,
datasetTitle: _.get(state.config, "displayNames.dataset"),
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
scatterplotYYaccessor: state.controls.scatterplotYYaccessor
}))
@@ -15,6 +18,7 @@ class LeftSideBar extends React.Component {
render() {
const {
responsive,
datasetTitle,
scatterplotXXaccessor,
scatterplotYYaccessor
} = this.props;
@@ -23,7 +27,8 @@ class LeftSideBar extends React.Component {
this magic number should be made less fragile,
if cellxgene logo or tabs change, this must as well
*/
const logoRelatedPadding = 50;
const metadataSectionPadding = 0;
// scatterplotXXaccessor && scatterplotYYaccessor ? 450 : 0;
return (
<div
@@ -31,20 +36,34 @@ class LeftSideBar extends React.Component {
position: "fixed",
backgroundColor: "white",
/* x y blur spread color */
borderRight: `1px solid ${globals.lightGrey}`
boxShadow: "1px 0px 6px 2px rgba(153,153,153,0.4)"
}}
>
<TopLeftLogoAndTitle />
<p
style={{
position: "fixed",
top: globals.cellxgeneTitleTopPadding,
left: globals.leftSidebarWidth + globals.cellxgeneTitleLeftPadding,
margin: 0,
fontSize: globals.largestFontSize,
color: globals.darkerGrey,
width: "100%"
}}
>
cellxgene: {datasetTitle}
</p>
<div
style={{
height: responsive.height - logoRelatedPadding,
marginTop: logoRelatedPadding,
height: responsive.height - metadataSectionPadding,
width: globals.leftSidebarWidth,
overflowY: "auto",
overflowX: "hidden"
}}
>
<Categorical />
<GeneExpression />
<Continuous />
</div>
{scatterplotXXaccessor && scatterplotYYaccessor ? (
<DynamicScatterplot />
@@ -1,63 +0,0 @@
// jshint esversion: 6
import React from "react";
import { AnchorButton, Tooltip } from "@blueprintjs/core";
import { connect } from "react-redux";
import { World } from "../../util/stateManager";
import { tooltipHoverOpenDelay } from "../../globals";
@connect()
class CellSetButton extends React.PureComponent {
set() {
const {
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) {
/* diffexp needs to be cleared before we store a new set */
dispatch({
type: `store current cell selection as differential set ${eitherCellSetOneOrTwo}`,
data: set
});
}
}
render() {
const { differential, eitherCellSetOneOrTwo } = this.props;
const cellListName = `celllist${eitherCellSetOneOrTwo}`;
const cellsSelected = differential[cellListName]
? differential[cellListName].length
: 0;
return (
<Tooltip
content="Save current selection for differential expression computation"
position="bottom"
hoverOpenDelay={tooltipHoverOpenDelay}
>
<AnchorButton
type="button"
disabled={differential.diffExp}
onClick={this.set.bind(this)}
data-testid={`cellset-button-${eitherCellSetOneOrTwo}`}
>
{eitherCellSetOneOrTwo}
{": "}
<span data-testid={`cellset-count-${eitherCellSetOneOrTwo}`}>
{cellsSelected}
</span>
{" cells"}
</AnchorButton>
</Tooltip>
);
}
}
export default CellSetButton;
-138
View File
@@ -1,138 +0,0 @@
// jshint esversion: 6
import React from "react";
import {
Position,
Button,
Popover,
NumericInput,
Icon,
Tooltip
} from "@blueprintjs/core";
import { tooltipHoverOpenDelay } from "../../globals";
function Clip(props) {
const {
pendingClipPercentiles,
clipPercentileMin,
clipPercentileMax,
handleClipOpening,
handleClipClosing,
handleClipCommit,
isClipDisabled,
handleClipOnKeyPress,
handleClipPercentileMaxValueChange,
handleClipPercentileMinValueChange
} = props;
const clipMin =
pendingClipPercentiles?.clipPercentileMin ?? clipPercentileMin;
const clipMax =
pendingClipPercentiles?.clipPercentileMax ?? clipPercentileMax;
const activeClipClass =
clipPercentileMin > 0 || clipPercentileMax < 100
? " bp3-intent-warning"
: "";
return (
<div
className="bp3-button-group"
style={{
marginRight: 10
}}
>
<Popover
target={
<Tooltip
content="Clip all continuous values to a percentile range"
position="bottom"
hoverOpenDelay={tooltipHoverOpenDelay}
>
<Button
type="button"
data-testid="visualization-settings"
className={`bp3-button bp3-icon-timeline-bar-chart ${activeClipClass}`}
style={{
cursor: "pointer"
}}
/>
</Tooltip>
}
position={Position.BOTTOM_RIGHT}
onOpening={handleClipOpening}
onClosing={handleClipClosing}
content={
<div
style={{
display: "flex",
justifyContent: "flex-start",
alignItems: "flex-start",
flexDirection: "column",
padding: 10
}}
>
<div>Clip all continuous values to percentile range</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
paddingTop: 5,
paddingBottom: 5
}}
>
<NumericInput
style={{ width: 50 }}
data-testid="clip-min-input"
onValueChange={handleClipPercentileMinValueChange}
onKeyPress={handleClipOnKeyPress}
value={clipMin}
min={0}
max={100}
fill={false}
minorStepSize={null}
rightElement={
<div style={{ padding: "4px 2px" }}>
<Icon icon="percentage" intent="primary" iconSize={14} />
</div>
}
/>
<span style={{ marginRight: 5, marginLeft: 5 }}> - </span>
<NumericInput
style={{ width: 50 }}
data-testid="clip-max-input"
onValueChange={handleClipPercentileMaxValueChange}
onKeyPress={handleClipOnKeyPress}
value={clipMax}
min={0}
max={100}
fill={false}
minorStepSize={null}
rightElement={
<div style={{ padding: "4px 2px" }}>
<Icon icon="percentage" intent="primary" iconSize={14} />
</div>
}
/>
<Button
type="button"
data-testid="clip-commit"
className="bp3-button"
disabled={isClipDisabled()}
style={{
cursor: "pointer",
marginRight: 5,
marginLeft: 5
}}
onClick={handleClipCommit}
>
Clip
</Button>
</div>
</div>
}
/>
</div>
);
}
export default Clip;
-527
View File
@@ -1,527 +0,0 @@
// jshint esversion: 6
import React from "react";
import { connect } from "react-redux";
import {
Button,
ButtonGroup,
AnchorButton,
Tooltip,
Popover,
Position,
RadioGroup,
Radio,
Icon
} from "@blueprintjs/core";
import { World } from "../../util/stateManager";
import actions from "../../actions";
import CellSetButton from "./cellSetButtons";
import InformationMenu from "./infoMenu";
import UndoRedoReset from "./undoRedoReset";
import Clip from "./clip";
import * as globals from "../../globals";
@connect(state => ({
universe: state.universe,
world: state.world,
loading: state.controls.loading,
crossfilter: state.crossfilter,
differential: state.differential,
resettingInterface: state.controls.resettingInterface,
layoutChoice: state.layoutChoice,
graphInteractionMode: state.controls.graphInteractionMode,
clipPercentileMin: Math.round(100 * (state.world?.clipQuantiles?.min ?? 0)),
clipPercentileMax: Math.round(100 * (state.world?.clipQuantiles?.max ?? 1)),
userDefinedGenes: state.controls.userDefinedGenes,
diffexpGenes: state.controls.diffexpGenes,
colorAccessor: state.colors.colorAccessor,
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
scatterplotYYaccessor: state.controls.scatterplotYYaccessor,
celllist1: state.differential.celllist1,
celllist2: state.differential.celllist2,
libraryVersions: state.config?.library_versions, // eslint-disable-line camelcase
undoDisabled: state["@@undoable/past"].length === 0,
redoDisabled: state["@@undoable/future"].length === 0,
aboutLink: state.config?.links?.["about-dataset"],
disableDiffexp: state.config?.parameters?.["disable-diffexp"] ?? false,
diffexpMayBeSlow: state.config?.parameters?.["diffexp-may-be-slow"] ?? false,
showCentroidLabels: state.centroidLabels.showLabels
}))
class MenuBar extends React.Component {
static isValidDigitKeyEvent(e) {
/*
Return true if this event is necessary to enter a percent number input.
Return false if not.
Returns true for events with keys: backspace, control, alt, meta, [0-9],
or events that don't have a key.
*/
if (e.key === null) return true;
if (e.ctrlKey || e.altKey || e.metaKey) return true;
// concept borrowed from blueprint's numericInputUtils:
// keys that print a single character when pressed have a `key` name of
// length 1. every other key has a longer `key` name (e.g. "Backspace",
// "ArrowUp", "Shift"). since none of those keys can print a character
// to the field--and since they may have important native behaviors
// beyond printing a character--we don't want to disable their effects.
const isSingleCharKey = e.key.length === 1;
if (!isSingleCharKey) return true;
const key = e.key.charCodeAt(0) - 48; /* "0" */
return key >= 0 && key <= 9;
}
constructor(props) {
super(props);
this.state = {
pendingClipPercentiles: null
};
}
isClipDisabled = () => {
/*
return true if clip button should be disabled.
*/
const { pendingClipPercentiles } = this.state;
const clipPercentileMin = pendingClipPercentiles?.clipPercentileMin;
const clipPercentileMax = pendingClipPercentiles?.clipPercentileMax;
const { world } = this.props;
const currentClipMin = 100 * world?.clipQuantiles?.min;
const currentClipMax = 100 * world?.clipQuantiles?.max;
// if you change this test, be careful with logic around
// comparisons between undefined / NaN handling.
const isDisabled =
!(clipPercentileMin < clipPercentileMax) ||
(clipPercentileMin === currentClipMin &&
clipPercentileMax === currentClipMax);
return isDisabled;
};
isResetDisabled = () => {
/*
Reset should be disabled when all of the following are true:
* nothing is selected in the crossfilter
* world EQ universe
* nothing is colored by
* there are no userDefinedGenes or diffexpGenes displayed
* scatterplot is not displayed
* nothing in cellset1 or cellset2
* clip percentiles are [0,100]
*/
const {
crossfilter,
world,
universe,
userDefinedGenes,
diffexpGenes,
colorAccessor,
scatterplotXXaccessor,
scatterplotYYaccessor,
celllist1,
celllist2,
clipPercentileMin,
clipPercentileMax
} = this.props;
if (!crossfilter || !world || !universe) {
return false;
}
const nothingSelected = crossfilter.countSelected() === crossfilter.size();
const nothingColoredBy = !colorAccessor;
const noGenes = userDefinedGenes.length === 0 && diffexpGenes.length === 0;
const scatterNotDpl = !scatterplotXXaccessor || !scatterplotYYaccessor;
const nothingInCellsets = !celllist1 && !celllist2;
return (
nothingSelected &&
World.worldEqUniverse(world, universe) &&
nothingColoredBy &&
noGenes &&
scatterNotDpl &&
nothingInCellsets &&
clipPercentileMax === 100 &&
clipPercentileMin === 0
);
};
resetInterface = () => {
const { dispatch } = this.props;
dispatch({
type: "interface reset started"
});
dispatch(actions.resetInterface());
};
handleClipOnKeyPress = e => {
/*
allow only numbers, plus other critical keys which
may be required to make a number
*/
if (!MenuBar.isValidDigitKeyEvent(e)) {
e.preventDefault();
}
};
handleClipPercentileMinValueChange = v => {
/*
Ignore anything that isn't a legit number
*/
if (!Number.isFinite(v)) return;
const { pendingClipPercentiles } = this.state;
const clipPercentileMax = pendingClipPercentiles?.clipPercentileMax;
/*
clamp to [0, currentClipPercentileMax]
*/
if (v <= 0) v = 0;
if (v > 100) v = 100;
const clipPercentileMin = Math.round(v); // paranoia
this.setState({
pendingClipPercentiles: { clipPercentileMin, clipPercentileMax }
});
};
handleClipPercentileMaxValueChange = v => {
/*
Ignore anything that isn't a legit number
*/
if (!Number.isFinite(v)) return;
const { pendingClipPercentiles } = this.state;
const clipPercentileMin = pendingClipPercentiles?.clipPercentileMin;
/*
clamp to [0, 100]
*/
if (v < 0) v = 0;
if (v > 100) v = 100;
const clipPercentileMax = Math.round(v); // paranoia
this.setState({
pendingClipPercentiles: { clipPercentileMin, clipPercentileMax }
});
};
handleClipCommit = () => {
const { dispatch } = this.props;
const { pendingClipPercentiles } = this.state;
const { clipPercentileMin, clipPercentileMax } = pendingClipPercentiles;
const min = clipPercentileMin / 100;
const max = clipPercentileMax / 100;
dispatch({
type: "set clip quantiles",
clipQuantiles: { min, max }
});
};
handleClipOpening = () => {
const { clipPercentileMin, clipPercentileMax } = this.props;
this.setState({
pendingClipPercentiles: { clipPercentileMin, clipPercentileMax }
});
};
handleClipClosing = () => {
this.setState({ pendingClipPercentiles: null });
};
handleLayoutChoiceChange = e => {
const { dispatch } = this.props;
dispatch({
type: "set layout choice",
layoutChoice: e.currentTarget.value
});
};
computeDiffExp = () => {
const { dispatch, differential } = this.props;
if (differential.celllist1 && differential.celllist2) {
dispatch(
actions.requestDifferentialExpression(
differential.celllist1,
differential.celllist2
)
);
}
};
clearDifferentialExpression = () => {
const { dispatch, differential } = this.props;
dispatch({
type: "clear differential expression",
diffExp: differential.diffExp
});
dispatch({
type: "clear scatterplot"
});
};
handleCentroidChange = () => {
const { dispatch, showCentroidLabels } = this.props;
dispatch({
type: "show centroid labels for category",
showLabels: !showCentroidLabels
});
};
renderDiffExp() {
/* diffexp-related buttons may be disabled */
const { disableDiffexp, differential, diffexpMayBeSlow } = this.props;
if (disableDiffexp) return null;
const haveBothCellSets =
!!differential.celllist1 && !!differential.celllist2;
const slowMsg = diffexpMayBeSlow
? " (CAUTION: large dataset - may take longer or fail)"
: "";
const tipMessage = `See top 10 differentially expressed genes${slowMsg}`;
return (
<ButtonGroup style={{ marginRight: 10 }}>
<CellSetButton {...this.props} eitherCellSetOneOrTwo={1} />
<CellSetButton {...this.props} eitherCellSetOneOrTwo={2} />
{!differential.diffExp ? (
<Tooltip
content={tipMessage}
position="bottom"
hoverOpenDelay={globals.tooltipHoverOpenDelayQuick}
>
<AnchorButton
disabled={!haveBothCellSets}
intent="primary"
data-testid="diffexp-button"
loading={differential.loading}
icon="left-join"
fill
onClick={this.computeDiffExp}
/>
</Tooltip>
) : null}
{differential.diffExp ? (
<Tooltip
content="Remove differentially expressed gene list and clear cell selections"
position="bottom"
hoverOpenDelay={globals.tooltipHoverOpenDelayQuick}
>
<Button
type="button"
fill
intent="warning"
onClick={this.clearDifferentialExpression}
>
Clear Differential Expression
</Button>
</Tooltip>
) : null}
</ButtonGroup>
);
}
render() {
const {
dispatch,
crossfilter,
resettingInterface,
libraryVersions,
undoDisabled,
redoDisabled,
selectionTool,
clipPercentileMin,
clipPercentileMax,
layoutChoice,
graphInteractionMode,
aboutLink,
showCentroidLabels
} = this.props;
const { pendingClipPercentiles } = this.state;
// constants used to create selection tool button
let selectionTooltip;
let selectionButtonIcon;
if (selectionTool === "brush") {
selectionTooltip = "Brush selection";
selectionButtonIcon = "select";
} else {
selectionTooltip = "Lasso selection";
selectionButtonIcon = "polygon-filter";
}
return (
<div
style={{
position: "fixed",
right: globals.leftSidebarWidth + 8,
top: 8,
display: "flex"
}}
>
{this.renderDiffExp()}
<Tooltip
content="Show only metadata and cells which are currently selected"
position="bottom"
hoverOpenDelay={globals.tooltipHoverOpenDelay}
>
<AnchorButton
data-testid="subset-button"
disabled={
crossfilter &&
(crossfilter.countSelected() === 0 ||
crossfilter.countSelected() === crossfilter.size())
}
style={{
marginRight: 10
}}
onClick={() => {
dispatch(actions.regraph());
dispatch({ type: "increment graph render counter" });
}}
>
<Icon icon="double-chevron-down" />
</AnchorButton>
</Tooltip>
<ButtonGroup style={{ marginRight: "10px" }}>
<Tooltip
content={selectionTooltip}
position="bottom"
hoverOpenDelay={globals.tooltipHoverOpenDelay}
>
<AnchorButton
type="button"
data-testid="mode-lasso"
icon={selectionButtonIcon}
active={graphInteractionMode === "select"}
onClick={() => {
dispatch({
type: "change graph interaction mode",
data: "select"
});
}}
style={{
cursor: "pointer"
}}
/>
</Tooltip>
<Tooltip
content="Drag to pan, scroll to zoom"
position="bottom"
hoverOpenDelay={globals.tooltipHoverOpenDelay}
>
<AnchorButton
type="button"
data-testid="mode-pan-zoom"
icon="zoom-in"
active={graphInteractionMode === "zoom"}
onClick={() => {
dispatch({
type: "change graph interaction mode",
data: "zoom"
});
}}
style={{
cursor: "pointer"
}}
/>
</Tooltip>
</ButtonGroup>
<Tooltip
content="When a category is colored by, show labels on the graph"
position="bottom"
disabled={graphInteractionMode === "zoom"}
>
<Button
icon="property"
onClick={this.handleCentroidChange}
active={showCentroidLabels}
intent={showCentroidLabels ? "primary" : "none"}
style={{
marginRight: 10
}}
/>
</Tooltip>
<ButtonGroup
style={{
marginRight: 10
}}
>
<Popover
target={
<Tooltip
content="Select embedding for visualization"
position="bottom"
hoverOpenDelay={globals.tooltipHoverOpenDelay}
>
<Button
type="button"
data-testid="layout-choice"
icon="heatmap"
style={{
cursor: "pointer"
}}
/>
</Tooltip>
}
position={Position.BOTTOM_RIGHT}
content={
<div
style={{
display: "flex",
justifyContent: "flex-start",
alignItems: "flex-start",
flexDirection: "column",
padding: 10
}}
>
<RadioGroup
label="Embedding Choice"
onChange={this.handleLayoutChoiceChange}
selectedValue={layoutChoice.current}
>
{layoutChoice.available.map(name => (
<Radio label={name} value={name} key={name} />
))}
</RadioGroup>
</div>
}
/>
</ButtonGroup>
<Clip
pendingClipPercentiles={pendingClipPercentiles}
clipPercentileMin={clipPercentileMin}
clipPercentileMax={clipPercentileMax}
handleClipOpening={this.handleClipOpening}
handleClipClosing={this.handleClipClosing}
handleClipCommit={this.handleClipCommit}
isClipDisabled={this.isClipDisabled}
handleClipOnKeyPress={this.handleClipOnKeyPress}
handleClipPercentileMaxValueChange={
this.handleClipPercentileMaxValueChange
}
handleClipPercentileMinValueChange={
this.handleClipPercentileMinValueChange
}
/>
<UndoRedoReset
dispatch={dispatch}
isResetDisabled={this.isResetDisabled}
resetInterface={this.resetInterface}
resettingInterface={resettingInterface}
undoDisabled={undoDisabled}
redoDisabled={redoDisabled}
/>
<InformationMenu
libraryVersions={libraryVersions}
aboutLink={aboutLink}
/>
</div>
);
}
}
export default MenuBar;
-66
View File
@@ -1,66 +0,0 @@
// jshint esversion: 6
import React from "react";
import { Button, Popover, Menu, MenuItem, Position } from "@blueprintjs/core";
function InformationMenu(props) {
const { libraryVersions, aboutLink } = props;
return (
<div style={{}} className="bp3-button-group">
<Popover
content={
<Menu>
{aboutLink ? (
<MenuItem
href={aboutLink}
target="_blank"
icon="document-open"
text="About this dataset"
/>
) : (
""
)}
<MenuItem
href="https://chanzuckerberg.github.io/cellxgene/"
target="_blank"
icon="help"
text="Help"
/>
<MenuItem
href="https://join-cellxgene-users.herokuapp.com/"
target="_blank"
icon="chat"
text="Chat"
/>
<MenuItem
href="https://github.com/chanzuckerberg/cellxgene"
target="_blank"
icon="git-branch"
text="Github"
/>
<MenuItem
target="_blank"
text={`cellxgene v${
libraryVersions && libraryVersions.cellxgene
? libraryVersions.cellxgene
: null
}`}
/>
<MenuItem text="MIT License" />
</Menu>
}
position={Position.BOTTOM_RIGHT}
>
<Button
type="button"
className="bp3-button bp3-icon-info-sign"
style={{
cursor: "pointer"
}}
/>
</Popover>
</div>
);
}
export default InformationMenu;
@@ -1,72 +0,0 @@
// jshint esversion: 6
import React from "react";
import { AnchorButton, Tooltip } from "@blueprintjs/core";
import { tooltipHoverOpenDelay } from "../../globals";
function InformationMenu(props) {
const {
resettingInterface,
undoDisabled,
redoDisabled,
resetInterface,
isResetDisabled,
dispatch
} = props;
return (
<div style={{ marginRight: 10 }} className="bp3-button-group">
<Tooltip
content="Undo"
position="bottom"
hoverOpenDelay={tooltipHoverOpenDelay}
>
<AnchorButton
type="button"
className="bp3-button bp3-icon-undo"
disabled={undoDisabled}
onClick={() => {
dispatch({ type: "@@undoable/undo" });
}}
style={{
cursor: "pointer"
}}
/>
</Tooltip>
<Tooltip
content="Redo"
position="bottom"
hoverOpenDelay={tooltipHoverOpenDelay}
>
<AnchorButton
type="button"
className="bp3-button bp3-icon-redo"
disabled={redoDisabled}
onClick={() => {
dispatch({ type: "@@undoable/redo" });
}}
style={{
cursor: "pointer"
}}
/>
</Tooltip>
<Tooltip
content="Reset cellxgene, clearing all selections"
position="bottom"
hoverOpenDelay={tooltipHoverOpenDelay}
>
<AnchorButton
disabled={isResetDisabled()}
style={{ marginLeft: 10 }}
type="button"
loading={resettingInterface}
intent="none"
icon="refresh"
onClick={resetInterface}
data-testid="reset"
data-testclass={`resetting-${resettingInterface}`}
/>
</Tooltip>
</div>
);
}
export default InformationMenu;
@@ -1,43 +0,0 @@
// jshint esversion: 6
import React from "react";
import { connect } from "react-redux";
import Continuous from "../continuous/continuous";
import GeneExpression from "../geneExpression";
import * as globals from "../../globals";
@connect(state => ({
responsive: state.responsive,
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
scatterplotYYaccessor: state.controls.scatterplotYYaccessor
}))
class RightSidebar extends React.Component {
render() {
const { responsive } = this.props;
return (
<div
style={{
position: "fixed",
right: 0,
backgroundColor: "white",
/* x y blur spread color */
borderLeft: `1px solid ${globals.lightGrey}`
}}
>
<div
style={{
height: responsive.height,
width: globals.leftSidebarWidth,
overflowY: "auto",
overflowX: "hidden"
}}
>
<GeneExpression />
<Continuous />
</div>
</div>
);
}
}
export default RightSidebar;
@@ -1,64 +1,44 @@
import { glPointFlags, glPointSize } from "../../util/glHelpers";
// jshint esversion: 6
const mat4 = require("gl-mat4");
// opacity: https://github.com/spacetx/starfish/blob/master/viz/draw/regions.js
export default function(regl) {
return regl({
vert: `
precision mediump float;
attribute vec2 position;
attribute vec3 color;
attribute float flag;
uniform mat3 projection;
uniform float nPoints;
uniform float minViewportDimension;
varying vec4 fragColor;
const float zBottom = 0.99;
const float zMiddle = 0.;
const float zTop = -1.;
// import getFlags()
${glPointFlags}
// get pointSize()
${glPointSize}
attribute float size;
uniform float distance;
uniform mat4 projection, view;
varying vec3 fragColor;
void main() {
bool isNaN, isSelected, isHighlight;
getFlags(flag, isNaN, isSelected, isHighlight);
gl_PointSize = pointSize(nPoints, minViewportDimension, isSelected, isHighlight);
float z = isNaN ? zBottom : (isHighlight ? zTop : zMiddle);
vec3 xy = projection * vec3(position, 1.);
gl_Position = vec4(xy.xy, z, 1.);
float alpha = isNaN ? 0.9 : 1.0;
fragColor = vec4(color, alpha);
gl_PointSize = 7.0 / pow(distance, 2.5) + size;
gl_Position = projection * view * vec4(position.x, -position.y, 0, 1);
fragColor = color;
}`,
frag: `
precision mediump float;
varying vec4 fragColor;
varying vec3 fragColor;
void main() {
if (length(gl_PointCoord.xy - 0.5) > 0.5) {
discard;
}
gl_FragColor = fragColor;
gl_FragColor = vec4(fragColor, 1);
}`,
attributes: {
position: regl.prop("position"),
color: regl.prop("color"),
flag: regl.prop("flag")
size: regl.prop("size")
},
uniforms: {
projection: regl.prop("projection"),
nPoints: regl.prop("nPoints"),
minViewportDimension: regl.prop("minViewportDimension")
distance: regl.prop("distance"),
view: regl.prop("view"),
projection: () => mat4.perspective([], Math.PI / 2, 1, 0.01, 1000)
},
count: regl.prop("count"),
+163 -235
View File
@@ -1,46 +1,48 @@
// jshint esversion: 6
// https://bl.ocks.org/Jverma/076377dd0125b1a508621441752735fc
// https://peterbeshai.com/scatterplot-in-d3-with-voronoi-interaction.html
import React from "react";
import _ from "lodash";
import { connect } from "react-redux";
import { Button, ButtonGroup } from "@blueprintjs/core";
import _regl from "regl";
import * as d3 from "d3";
import { mat3 } from "gl-matrix";
import memoize from "memoize-one";
import { isTypedArray } from "../../util/typeHelpers";
import * as globals from "../../globals";
import _camera from "../../util/camera";
import setupScatterplot from "./setupScatterplot";
import styles from "./scatterplot.css";
import _drawPoints from "./drawPointsRegl";
import { margin, width, height } from "./util";
import finiteExtent from "../../util/finiteExtent";
function createProjectionTF(viewportWidth, viewportHeight) {
/*
the projection transform accounts for the screen size & other layout
*/
const m = mat3.create();
return mat3.projection(m, viewportWidth, viewportHeight);
}
import _drawPoints from "./drawPointsRegl";
import scaleLinear from "../../util/scaleLinear";
import { margin, width, height } from "./util";
import { kvCache } from "../../util/stateManager";
@connect(state => {
const { world, crossfilter, universe } = state;
const { scatterplotXXaccessor, scatterplotYYaccessor } = state.controls;
const expressionX = scatterplotXXaccessor
? world.varData.col(scatterplotXXaccessor)?.asArray()
: null;
const expressionY = scatterplotYYaccessor
? world.varData.col(scatterplotYYaccessor)?.asArray()
: null;
const {
world,
crossfilter,
scatterplotXXaccessor,
scatterplotYYaccessor
} = state.controls;
const expressionX =
world && scatterplotXXaccessor
? kvCache.get(world.varDataCache, scatterplotXXaccessor)
: null;
const expressionY =
world && scatterplotYYaccessor
? kvCache.get(world.varDataCache, scatterplotYYaccessor)
: null;
return {
world,
universe,
colorRGB: state.colors.rgb,
colorScale: state.colors.scale,
colorAccessor: state.colors.colorAccessor,
pointDilation: state.pointDilation,
colorRGB: state.controls.colorRGB,
colorAccessor: state.controls.colorAccessor,
colorScale: state.controls.colorScale,
// Accessors are var/gene names (strings)
scatterplotXXaccessor,
@@ -53,93 +55,20 @@ function createProjectionTF(viewportWidth, viewportHeight) {
expressionY,
crossfilter,
responsive: state.responsive
// updated whenever the crossfilter selection is updated
selectionUpdate: _.get(state.controls, "crossfilter.updateTime", null)
};
})
class Scatterplot extends React.PureComponent {
computePointPositions = memoize((X, Y, xScale, yScale) => {
const positions = new Float32Array(2 * X.length);
for (let i = 0, len = X.length; i < len; i += 1) {
positions[2 * i] = xScale(X[i]);
positions[2 * i + 1] = yScale(Y[i]);
}
return positions;
});
computePointColors = memoize(rgb => {
/*
compute webgl colors for each point
*/
const colors = new Float32Array(3 * rgb.length);
for (let i = 0, len = rgb.length; i < len; i += 1) {
colors.set(rgb[i], 3 * i);
}
return colors;
});
computeSelectedFlags = memoize(
(crossfilter, flagSelected, flagUnselected) => {
const x = crossfilter.fillByIsSelected(
new Float32Array(crossfilter.size()),
flagSelected,
flagUnselected
);
return x;
}
);
computePointFlags = memoize(
(world, crossfilter, colorAccessor, pointDilation) => {
const flagSelected = 1;
const flagNaN = 2;
const flagHighlight = 4;
const flags = this.computeSelectedFlags(
crossfilter,
flagSelected,
0
).slice();
const { metadataField, categoryField } = pointDilation;
const highlightData = metadataField
? 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;
}
);
class Scatterplot extends React.Component {
constructor(props) {
super(props);
this.count = 0;
this.axes = false;
this.renderCache = {
positions: null,
colors: null,
flags: null,
xScale: null,
yScale: null
};
this.state = {
svg: null,
minimized: null
minimized: null,
xScale: null,
yScale: null
};
}
@@ -151,40 +80,43 @@ class Scatterplot extends React.PureComponent {
if (svg && expressionX && expressionY) {
scales = Scatterplot.setupScales(expressionX, expressionY);
this.drawAxesSVG(scales.xScale, scales.yScale, svg);
this.renderCache = { ...this.renderCache, ...scales };
}
const camera = _camera(this.reglCanvas, { scale: true, rotate: false });
const regl = _regl(this.reglCanvas);
const drawPoints = _drawPoints(regl);
// Create render transform
const projectionTF = createProjectionTF(
this.reglCanvas.width,
this.reglCanvas.height
);
const drawPoints = _drawPoints(regl);
// preallocate buffers
const pointBuffer = regl.buffer();
const colorBuffer = regl.buffer();
const flagBuffer = regl.buffer();
const sizeBuffer = regl.buffer();
this.renderPoints(
regl,
drawPoints,
flagBuffer,
colorBuffer,
pointBuffer,
projectionTF
);
const reglRender = regl.frame(() => {
this.reglDraw(
regl,
drawPoints,
sizeBuffer,
colorBuffer,
pointBuffer,
camera
);
camera.tick();
});
this.reglRenderState = "rendering";
this.setState({
regl,
flagBuffer,
sizeBuffer,
pointBuffer,
colorBuffer,
svg,
drawPoints,
projectionTF
xScale: scales ? scales.xScale : null,
yScale: scales ? scales.yScale : null,
reglRender,
camera,
drawPoints
});
}
@@ -196,90 +128,110 @@ class Scatterplot extends React.PureComponent {
scatterplotYYaccessor,
expressionX,
expressionY,
colorRGB,
colorAccessor,
pointDilation
colorRGB
} = this.props;
const {
reglRender,
xScale,
yScale,
regl,
pointBuffer,
colorBuffer,
flagBuffer,
sizeBuffer,
svg,
drawPoints,
projectionTF
camera
} = this.state;
if (
scatterplotXXaccessor !== prevProps.scatterplotXXaccessor ||
scatterplotYYaccessor !== prevProps.scatterplotYYaccessor ||
world !== prevProps.world // shape or clip of world changed
world &&
svg &&
xScale &&
yScale &&
scatterplotXXaccessor &&
scatterplotYYaccessor &&
(scatterplotXXaccessor !== prevProps.scatterplotXXaccessor || // was CLU now FTH1 etc
scatterplotYYaccessor !== prevProps.scatterplotYYaccessor || // was CLU now FTH1 etc
!this.axes) // clicked off the tab and back again, rerender
) {
const scales = Scatterplot.setupScales(expressionX, expressionY);
this.drawAxesSVG(scales.xScale, scales.yScale, svg);
this.renderCache = { ...this.renderCache, ...scales };
this.drawAxesSVG(xScale, yScale, svg);
}
if (world && regl) {
const { renderCache } = this;
const { xScale, yScale } = this.renderCache;
let needsRepaint = false;
if (reglRender && this.reglRenderState === "rendering") {
reglRender.cancel();
this.reglRenderState = "paused";
}
const newPositions = this.computePointPositions(
expressionX,
expressionY,
xScale,
yScale
if (
world &&
regl &&
pointBuffer &&
colorBuffer &&
sizeBuffer &&
expressionX &&
expressionY &&
scatterplotXXaccessor &&
scatterplotYYaccessor &&
xScale &&
yScale
) {
const cellCount = expressionX.length;
const positionsBuf = new Float32Array(2 * cellCount);
const colorsBuf = new Float32Array(3 * cellCount);
const sizesBuf = new Float32Array(cellCount);
const glScaleX = scaleLinear([0, width], [-0.95, 0.95]);
const glScaleY = scaleLinear([0, height], [-1, 1]);
/*
Construct Vectors
*/
for (let i = 0; i < cellCount; i += 1) {
positionsBuf[2 * i] = glScaleX(xScale(expressionX[i]));
positionsBuf[2 * i + 1] = glScaleY(yScale(expressionY[i]));
}
for (let i = 0; i < cellCount; i += 1) {
colorsBuf.set(colorRGB[i], 3 * i);
}
crossfilter.fillByIsFiltered(sizesBuf, 4, 0.2);
pointBuffer({ data: positionsBuf, dimension: 2 });
colorBuffer({ data: colorsBuf, dimension: 3 });
sizeBuffer({ data: sizesBuf, dimension: 1 });
this.count = cellCount;
regl._refresh();
this.reglDraw(
regl,
drawPoints,
sizeBuffer,
colorBuffer,
pointBuffer,
camera
);
if (renderCache.positions !== newPositions) {
renderCache.positions = newPositions;
pointBuffer({ data: renderCache.positions, dimension: 2 });
needsRepaint = true;
}
}
/* colors for each point */
const newColors = this.computePointColors(colorRGB);
if (renderCache.colors !== newColors) {
renderCache.colors = newColors;
colorBuffer({ data: renderCache.colors, dimension: 3 });
needsRepaint = true;
}
const newFlags = this.computePointFlags(
world,
crossfilter,
colorAccessor,
pointDilation
);
if (renderCache.flags !== newFlags) {
renderCache.flags = newFlags;
flagBuffer({ data: renderCache.flags, dimension: 1 });
needsRepaint = true;
}
this.count = expressionX.length;
if (needsRepaint) {
this.renderPoints(
regl,
drawPoints,
flagBuffer,
colorBuffer,
pointBuffer,
projectionTF
);
}
if (
expressionX &&
expressionY &&
(scatterplotXXaccessor !== prevProps.scatterplotXXaccessor || // was CLU now FTH1 etc
scatterplotYYaccessor !== prevProps.scatterplotYYaccessor)
) {
const scales = Scatterplot.setupScales(expressionX, expressionY);
this.setState(scales);
}
}
static setupScales(expressionX, expressionY) {
const xScale = d3
.scaleLinear()
.domain(finiteExtent(expressionX))
.domain(d3.extent(expressionX))
.range([0, width]);
const yScale = d3
.scaleLinear()
.domain(finiteExtent(expressionY))
.domain(d3.extent(expressionY))
.range([height, 0]);
return {
@@ -288,21 +240,31 @@ class Scatterplot extends React.PureComponent {
};
}
reglDraw(regl, drawPoints, sizeBuffer, colorBuffer, pointBuffer, camera) {
regl.clear({
depth: 1,
color: [1, 1, 1, 1]
});
drawPoints({
size: sizeBuffer,
distance: camera.distance,
color: colorBuffer,
position: pointBuffer,
count: this.count,
view: camera.view()
});
}
drawAxesSVG(xScale, yScale, svg) {
const { scatterplotYYaccessor, scatterplotXXaccessor } = this.props;
svg.selectAll("*").remove();
// the axes are much cleaner and easier now. No need to rotate and orient
// the axis, just call axisBottom, axisLeft etc.
const xAxis = d3
.axisBottom()
.ticks(7)
.scale(xScale);
const xAxis = d3.axisBottom().scale(xScale);
const yAxis = d3
.axisLeft()
.ticks(7)
.scale(yScale);
const yAxis = d3.axisLeft().scale(yScale);
// adding axes is also simpler now, just translate x-axis to (0,height)
// and it's alread defined to be a bottom axis.
@@ -338,41 +300,6 @@ class Scatterplot extends React.PureComponent {
.text(scatterplotXXaccessor);
}
renderPoints(
regl,
drawPoints,
flagBuffer,
colorBuffer,
pointBuffer,
projectionTF
) {
if (!this.reglCanvas) return;
const { universe, responsive } = this.props;
// The viewport dimension is used to scale points, so we want to pass
// the dimension of the MAIN viewport, not the scatterplot viewport.
// Slightly hacky, but we want all points to scale uniformly. Perhaps
// this should move to the redux state and be shared?
const { width: cvWidth, height: cvHeight } = responsive;
regl.poll();
regl.clear({
depth: 1,
color: [1, 1, 1, 1]
});
drawPoints({
flag: flagBuffer,
color: colorBuffer,
position: pointBuffer,
projection: projectionTF,
count: this.count,
nPoints: universe.nObs,
minViewportDimension: Math.min(
cvWidth - globals.leftSidebarWidth || width,
cvHeight || height
)
});
regl._gl.flush();
}
render() {
const { dispatch } = this.props;
const { minimized } = this.state;
@@ -381,7 +308,7 @@ class Scatterplot extends React.PureComponent {
<div
style={{
position: "fixed",
bottom: minimized ? -height + -margin.top - 2 : 0,
bottom: minimized ? -height + -margin.top : 0,
borderRadius: "3px 3px 0px 0px",
left: globals.leftSidebarWidth + globals.scatterplotMarginLeft,
padding: "0px 20px 20px 0px",
@@ -410,12 +337,14 @@ class Scatterplot extends React.PureComponent {
<Button
type="button"
minimal
data-testid="clear-scatterplot"
onClick={() =>
onClick={() => {
dispatch({
type: "clear scatterplot"
})
}
});
dispatch({
type: "reset colorscale"
});
}}
>
remove
</Button>
@@ -431,9 +360,8 @@ class Scatterplot extends React.PureComponent {
<canvas
width={width}
height={height}
data-testid="scatterplot"
style={{
marginLeft: margin.left,
marginLeft: margin.left - 7,
marginTop: margin.top
}}
ref={canvas => {
@@ -14,7 +14,6 @@ const setupScatterplot = (width, height, margin) => {
.append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.attr("data-testid", "scatterplot-svg")
.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
-5
View File
@@ -1,5 +0,0 @@
# Third-party fonts used in cellxgene
| Font Family | License at time of download | URL |
|-------------|-----------------------------|-----|
| Roboto Conensed | Apache v2.0 | https://fonts.google.com/specimen/Roboto+Condensed |
Binary file not shown.
Binary file not shown.
Binary file not shown.
+145 -22
View File
@@ -1,11 +1,36 @@
import { Colors } from "@blueprintjs/core";
// jshint esversion: 6
/* these will be either (preferably) specified or inferred */
export const categories = [
"Sample.type",
"Selection",
"Location",
"Sample.name",
"Class",
"Neoplastic"
];
export const continuous = [
"Total_reads",
"Unique_reads",
"Unique_reads_percent",
"ERCC_reads",
"Non_ERCC_reads",
"ERCC_to_non_ERCC",
"Genes_detected",
"Multimapping_reads_percent",
"Splice_sites_AT.AC",
"Splice_sites_Annotated",
"Splice_sites_GC.AG",
"Splice_sites_GT.AG",
"Splice_sites_non_canonical",
"Splice_sites_total",
"Unmapped_mismatch",
"Unmapped_other",
"Unmapped_short"
];
/* if a categorical metadata field has more options than this, truncate */
export const maxCategoricalOptionsToDisplay = 100;
/* default "unassigned" value for user-created categorical metadata */
export const unassignedCategoryLabel = "unassigned";
/*
these are default values for configuration the CLI may supply.
See the REST API and CLI specs for more info.
@@ -14,19 +39,16 @@ export const configDefaults = {
features: {},
displayNames: {},
parameters: {
"max-category-items": 1000,
"disable-diffexp": false,
"diffexp-may-be-slow": false
},
links: {}
"max-category-items": 1000
}
};
/* colors */
export const blue = Colors.BLUE3;
export const linkBlue = Colors.BLUE5;
export const blue = "#4a90e2";
export const hcaBlue = "#1c7cc7";
export const lightestGrey = "rgb(249,249,249)";
export const lighterGrey = "rgb(245,245,245)";
export const lightGrey = Colors.LIGHT_GRAY1;
export const lightGrey = "rgb(211,211,211)";
export const mediumGrey = "rgb(153,153,153)";
export const darkGrey = "rgb(102,102,102)";
export const darkerGrey = "rgb(51,51,51)";
@@ -35,9 +57,7 @@ export const brightBlue = "#4a90e2";
export const brightGreen = "#A2D729";
export const darkGreen = "#448C4D";
export const nonFiniteCellColor = lightGrey;
export const defaultCellColor = "rgb(0,0,0,1)";
export const logoColor = "black"; /* logo pink: "#E9429A" */
/* typography constants */
@@ -53,8 +73,6 @@ export const maxParagraphWidth = 600;
export const cellxgeneTitleLeftPadding = 14;
export const cellxgeneTitleTopPadding = 7;
export const datasetTitleMaxCharacterCount = 25;
export const maxControlsWidth = 800;
export const graphMargin = { top: 20, right: 10, bottom: 30, left: 40 };
@@ -70,12 +88,6 @@ export const leftSidebarSectionHeading = {
letterSpacing: ".05em"
};
export const leftSidebarSectionPadding = 10;
export const categoryLabelDisplayStringLongLength = 27;
export const categoryLabelDisplayStringShortLength = 11;
/* various timing-related behaviors */
export const tooltipHoverOpenDelay = 1000; /* ms delay before a tooltip displays */
export const tooltipHoverOpenDelayQuick = 500;
let _API = {
// prefix: "http://api.clustering.czi.technology/api/",
@@ -87,3 +99,114 @@ let _API = {
if (window.CELLXGENE && window.CELLXGENE.API) _API = window.CELLXGENE.API;
export const API = _API;
export const ordinalColors = [
"#0ac115",
"#c10ab6",
"#c1710a",
"#0a5ac1",
"#c1150a",
"#0ab6c1",
"#5ac10a",
"#710ac1",
"#0ac171",
"#c10a5a",
"#b6c10a",
"#150ac1",
"#b2ffb7",
"#ffb2fa",
"#ffddb2",
"#b2d4ff",
"#ffb7b2",
"#b2faff",
"#d4ffb2",
"#ddb2ff",
"#b2ffdd",
"#ffb2d4",
"#faffb2",
"#b7b2ff",
"#27a908",
"#8b08a9",
"#a93a08",
"#0877a9",
"#a90827",
"#08a98b",
"#77a908",
"#3a08a9",
"#08a93a",
"#a90877",
"#a98b08",
"#0827a9",
"#00ff0f",
"#ff00ef",
"#ff8e00",
"#0070ff",
"#ff0f00",
"#00efff",
"#70ff00",
"#8e00ff",
"#00ff8e",
"#ff0070",
"#efff00",
"#0f00ff",
"#006606",
"#66005f",
"#663900",
"#002c66",
"#660600",
"#005f66",
"#2c6600",
"#390066",
"#006639",
"#66002c",
"#5f6600",
"#060066",
"#83ff65",
"#e165ff",
"#ff9565",
"#65cfff",
"#ff6583",
"#65ffe1",
"#cfff65",
"#9565ff",
"#65ff95",
"#ff65cf",
"#ffe165",
"#6583ff",
"#009909",
"#99008f",
"#995500",
"#004399",
"#990900",
"#008f99",
"#439900",
"#550099",
"#009955",
"#990043",
"#8f9900",
"#090099",
"#d9fecc",
"#f1ccfe",
"#fed7cc",
"#ccf3fe",
"#feccd9",
"#ccfef1",
"#f3fecc",
"#d7ccfe",
"#ccfed7",
"#feccf3",
"#fef1cc",
"#ccd9fe",
"#47ea51",
"#ea47e0",
"#eaa247",
"#478fea",
"#ea5147",
"#47e0ea",
"#8fea47",
"#a247ea",
"#47eaa2",
"#ea478f",
"#e0ea47",
"#5147ea"
];

Some files were not shown because too many files have changed in this diff Show More