Compare commits

..
3 Commits
Author SHA1 Message Date
Charlotte Weaver 86a453c965 Merge branch 'master' into csweaver/release1 2018-11-09 14:19:27 -08:00
Charlotte Weaver 34bd4e8cf0 Release Test! 2018-11-09 14:11:49 -08:00
Charlotte Weaver 33c79a8391 bump-update 2018-11-09 14:02:09 -08:00
216 changed files with 10985 additions and 24329 deletions
+2 -5
View File
@@ -1,5 +1,6 @@
[bumpversion]
current_version = 0.11.0
current_version = 0.0.2
[bumpversion:file:setup.py]
search = version="{current_version}"
@@ -13,7 +14,3 @@ replace = version="{new_version}"
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
-7
View File
@@ -27,9 +27,6 @@ server/app/web/templates/index\.html
.ipynb_checkpoints
*.ipynb
# cefpython
error.log
# misc
.DS_Store
npm-debug.log
@@ -38,7 +35,3 @@ npm-debug.log
__pycache__
*.DS_Store*
data
# Jekyll
docs/_site/
docs/Gemfile.lock
+13 -24
View File
@@ -1,31 +1,20 @@
language: python
dist: xenial
sudo: required
python:
- "3.6"
node_js:
- 8
- "8"
cache:
- pip
- npm
pip: true
install:
- set -eo pipefail
- pip install flake8
- make pydist
- make install-dist
- ./bin/build-client
- pip install -e .
- pip install -r server/requirements-dev.txt
jobs:
include:
- name: "Branch Tests 3.7"
python: "3.7"
script: ./travis-build.sh
- name: "Branch Tests 3.6"
python: "3.6"
script: ./travis-build.sh
- name: "Docker Build"
install: skip
python: "3.6"
script: docker build .
- name: "Smoke Tests"
python: "3.6"
script:
- npm run --prefix client/ smoke-test
script:
- set -eo pipefail
- flake8 server/app/
- flake8 server/cli/
- npm run --prefix client/ build
- npm run --prefix client/ test
- pytest -s server/test
-49
View File
@@ -1,49 +0,0 @@
# contributing to cellxgene
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.
All code contributions and dependencies must be compatible with the project's [open-source license (MIT)](LICENSE.txt).
This project adheres to the Contributor Covenant
[code of conduct](https://github.com/chanzuckerberg/.github/tree/master/CODE_OF_CONDUCT.md).
By participating, you are expected to uphold this code. Please report unacceptable behavior
to opensource@chanzuckerberg.com.
And finally, if you have any questions about any of this stuff, just ask! :)
## 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 `make` from inside the `cellxgene` folder
```
make
```
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://join-cellxgene-users.herokuapp.com/) and posting in the `#cellxgene-dev` channel.
-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 && \
pip3 install cellxgene
ENTRYPOINT ["cellxgene"]
-1
View File
@@ -1,4 +1,3 @@
recursive-include server/app/web/templates *
recursive-include server/app/web/static *
include server/requirements.txt
+74 -58
View File
@@ -1,95 +1,111 @@
# cellxgene
> an interactive explorer for single-cell transcriptomics data
### An interactive, performant explorer for single cell transcriptomics data.
[![DOI](https://zenodo.org/badge/105615409.svg)](https://zenodo.org/badge/latestdoi/105615409)
<img align="right" width="350" height="218" src="./example-dataset/cellxgene-demo.gif" pad="50px">
cellxgene is an open-source experiment in how to bring powerful tools from modern web development to visualize and explore large single-cell transcriptomics datasets.
Started in the context of the Human Cell Atlas Consortium, cellxgene hopes to both enable scientists to explore their data and to equip developers with scalable, reusable patterns and frameworks for visualizing large scientific datasets.
_cellxgene_ (pronounced "sell-by-jean") 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.
## Features
<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">
- **Visualization at scale:** built with [WebGL](https://www.khronos.org/webgl/), [React](https://reactjs.org/) & [Redux](https://redux.js.org/) to handle visualization of at least 1 million cells.
- Want to install and use cellxgene? Visit the [cellxgene docs](https://chanzuckerberg.github.io/cellxgene/).
- Want to see where we are going? Check out [our roadmap](ROADMAP.md).
- Want to contribute? See our [contributors guide](CONTRIBUTING.md).
- **Interactive exploration:** select, cross-filter, and compare subsets of your data with performant indexing and data handling.
## quick start
- **Flexible API:** the cellxgene client-server model is designed to support a range of existing analysis packages for backend computational tasks (eg scanpy), integrated with client-side visualization via a [REST API](https://restfulapi.net/).
To install _cellxgene_ you need Python 3.6+. We recommend [installing _cellxgene_ into a conda or virtual environment.](https://chanzuckerberg.github.io/cellxgene/faq.html#how-do-i-create-a-python-36-environment-for-cellxgene)
## Getting Started
Install the package.
``` bash
pip install cellxgene
```
**Requirements**
Download an example [anndata](https://anndata.readthedocs.io/en/latest/) file
- OS: OSX, Windows, Linux -- the developers are currently testing on OSX and Windows (via WSL using Ubuntu). It should work on other platforms but if you are using something different and need help, please let us know.
- python 3.6
- python3 tkinter
- npm
- Google Chrome
``` bash
curl -o pbmc3k.h5ad https://raw.githubusercontent.com/chanzuckerberg/cellxgene/master/example-dataset/pbmc3k.h5ad
```
**Clone project**
Launch _cellxgene_
``` bash
cellxgene launch pbmc3k.h5ad --open
```
git clone https://github.com/chanzuckerberg/cellxgene.git
To learn more about what you can do with _cellxgene_, see the [Getting Started](https://chanzuckerberg.github.io/cellxgene/getting-started.html) guide.
**Install client**
## get in touch
cd cellxgene
./bin/build-client
Have questions, suggestions, or comments? You can come hang out with us by joining the [CZI Science Slack](https://join-cellxgene-users.herokuapp.com/) and posting in the `#cellxgene-users` channel. Have feature requests or bugs? Please submit these as [Github issues](https://github.com/chanzuckerberg/cellxgene/issues). We'd love to hear from you!
**To use with virtual env for python**
(optional, but recommended)
## contributing
ENV_NAME=cellxgene
python3 -m venv ${ENV_NAME}
source ${ENV_NAME}/bin/activate
We warmly welcome contributions from the community! Please see our [contributing guide](CONTRIBUTING.md) and don't hesitate to open an issue or send a pull request to improve cellxgene.
**Install server**
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.
pip install -e .
## where we are going
**Run (with demo data)**
Our goal is to enable teams of computational and experimental
biologists to collaboratively gain insight into their single-cell RNA-seq data.
cellxgene launch --title PBMC3K example-dataset/pbmc3k.h5ad
There are 4 key features we plan to implement in the near term.
**Help**
- Click install and launch
- Manual annotation workflows
- Toggle embeddings
- Gene information
cellxgene --help
For more detail on these features and where we are going, see [our roadmap](ROADMAP.md).
_For help with the scanpy engine_
## risks of hosting cellxgene
cellxgene scanpy --help
_cellxgene_ is built on standard web technologies, but is currently designed as a single-user desktop application.
We've done this so we can prioritize the features on [our roadmap](ROADMAP.md).
## Using your own data
Some of our users have experimented with hosting _cellxgene_ either for their lab or for public use, but please note that the _cellxgene_ team does not officially support, troubleshoot, or maintain any web deployments at this time.
### Scanpy
If do you choose setup _cellxgene_ as a hosted service, you should be aware of the following risks:
To prepare your data you will need to format your data into AnnData format using scanpy and calculate PCA and nearest neighbors and save in h5ad format.
- `$ cellxgene launch` uses Flask's development server, which is not recommended for hosted deployment (see the [Flask documentation](http://flask.pocoo.org/docs/1.0/tutorial/deploy/#run-with-a-production-server))
- We have no testing or official support for deployments where multiple users are accessing the same _cellxgene_ instance.
- Your _cellxgene_ instance is likely to hang or crash if too many people access it at the same time, especially if they using functions that call the Python backend (such as differential expression, updating the layout, or coloring by gene).
- _cellxgene_ only supports one instance per dataset
1. [Load data into scanpy](https://scanpy.readthedocs.io/en/latest/api/index.html#reading)
## inspiration
- Ensure that `obs`'s index is the cell names: `print(data.obs_names)` should show your cell indices. If it shows gene names, you may need to just call `data.transpose()`.
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.
2. Calculate PCA
We were inspired by Mike Bostock and the [crossfilter](https://github.com/crossfilter) team for the design of our filtering implementation.
sc.pp.pca(data) ## sc is scanpy.api
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.
3. Calculate nearest neighbors (depending on layout algorithm)
We are eager to explore integrations with other computational backends such as [`Seurat`](https://github.com/satijalab/seurat) or [`Bioconductor`](https://github.com/Bioconductor)
```
# For umap layout algorithm, you need to use the "umap" method for neighbors
sc.pp.neighbors(data, method="umap", metric="euclidean", use_rep="X_pca")
## core team
# For tsne layout algorithm, you can use either "umap" or "gauss"; we recommend "gauss"
sc.pp.neighbors(data, method="gauss", metric="euclidean", use_rep="X_pca")
```
- Colin Megill, frontend & product design
- Charlotte Weaver, software engineer
- Bruce Martin, software engineer
- Sidney Bell, computational biologist
- Justin Kiggins, product manager
4. Save file
## reuse
```
# cellxgene requires file to be named data.h5ad
data.write("data.h5ad")
```
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).
## Contributing
We warmly welcome contributions from the community. Please submit any bug reports and feature requests through github issues. Please submit any direct contributions via a branch + pull request.
## Inspiration and collaboration
We’ve been inspired by several other related efforts in this space, 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/), & many others; we hope to explore collaborations where useful.
## Help/Contact
Have questions, suggestions, or comments? You can contact us by joining [CZI Science Slack](https://cziscience.slack.com/messages/CCTA8DF1T) and posting in the #cellxgene channel. Please submit any feature requests or bugs as an issue in github. 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 whole-heartedly encourage other scientific tool builders to adopt the patterns, tools, and code from this project, and reach out to us with ideas or questions using Github Issues or Pull Requests. All code is freely available for reuse under the [MIT license](https://opensource.org/licenses/MIT).
## Acknowledgements
cellxgene is inspired by many innovative projects. We would like to specifically thank:
- Alex Wolf for the demo dataset.
- Mike Bostock and the [crossfilter](https://github.com/crossfilter) team for API inspiration.
-46
View File
@@ -1,46 +0,0 @@
# cellxgene roadmap
cellxgene makes it easier for biologists to collaboratively explore and understand their single-cell RNA-seq data.
In the near term, we are focused on continuing to enable fast, interactive exploration of single-cell data, supporting collaborative workflows in single-cell analysis, and improving user support.
If you have questions or feedback about this roadmap, please submit an issue on GitHub.
Please note: this roadmap is subject to change.
Last updated: June 25, 2019
## Fast, interactive exploration of single-cell data
### Exposing Relationships Between Metadata and Data
Biologists need to understand how variables (stored in metadata) are associated with one another and how they relate to changes in gene expression.
Building upon visualization features that reveal categorical metadata relationships (cluster occupancy) and gene expression relationships (scatterplot), we plan to add exploratory visualization components that enable investigation of relationships between metadata and gene expression.
See [issue #616](https://github.com/chanzuckerberg/cellxgene/issues/616) for more details.
### Contextualizing Genes
While exploring a transcriptomics dataset, scientists need to understand the biological context of genes.
This context may be provided by user-defined gene metadata or publicly available gene databases.
We plan to support augmenting gene names with additional information that is useful to biologists.
See [issue #96](https://github.com/chanzuckerberg/cellxgene/issues/96) for more detail.
## Support collaborative workflows in single-cell analysis
### Manual Annotations
cellxgene offers exploratory visualizations that are critical for manual annotation workflows, especially in collaborative environments.
We plan to support manually annotating cells with labels (i.e., cell type or QC flags), and their easy export for downstream analysis.
See [issue #524](https://github.com/chanzuckerberg/cellxgene/issues/524) for more details.
### Simple Click to Launch
Many biologists prefer not to interact with the command line and need an OS-native experience when using cellxgene.
We plan to implement a point-and-click installation and launch experience so that users can easily load data into cellxgene.
See [issue #687](https://github.com/chanzuckerberg/cellxgene/issues/687) for details.
### Python API
For computational biologists, saving h5ad files then loading them into cellxgene is a point of friction.
We plan to support importing cellxgene as a Python package so that users can launch cellxgene directly from an interactive environment (such as Jupyter, IPython, or Spyder), and pass data to and from the cellxgene UI.
## Improving user support
### Improved documentation
cellxgene has some specific expectations about how data is stored.
We want to ensure that new users can get started easily and learn how to use cellxgene with their own data.
We plan to improve documentation on getting started, installation, data, and contributing.
See [issue #533](https://github.com/chanzuckerberg/cellxgene/issues/533) for more details.
+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
+16
View File
@@ -0,0 +1,16 @@
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 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')
-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";
-122
View File
@@ -1,122 +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.05, y1: 0.25, x2: 0.15, y2: 0.35 },
count: "71"
}
],
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: "1537"
}
]
},
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",
Megakaryocytes: "15"
}
},
lasso: {
"coordinates-as-percent": { x1: 0.45, y1: 0.05, x2: 0.65, y2: 0.15 },
count: "36"
}
},
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 histId = `histogram-${cellset.metadata}-plot-brush`;
const coords = await cxgActions.calcDragCoordinates(
histId,
cellset["coordinates-as-percent"]
);
await cxgActions.drag(histId, 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 histId = `histogram-${data.clip.metadata}-plot-brush`;
const coords = await cxgActions.calcDragCoordinates(
histId,
data.clip["coordinates-as-percent"]
);
await cxgActions.drag(histId, 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 histId = `histogram-${data.clip.gene}-plot-brush`;
const coords = await cxgActions.calcDragCoordinates(
histId,
data.clip["coordinates-as-percent"]
);
await cxgActions.drag(histId, 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,652 +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("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);
});
});
});
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,69 +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);
});
});
@@ -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
]);
});
});
-42
View File
@@ -1,42 +0,0 @@
import { range, rangeFill } 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])
);
});
});
@@ -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,80 +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) {
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],
@@ -184,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]);
});
});
});
+142 -77
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,7 +16,7 @@ the default REST test response.
const defaultBigBang = () => {
/* create unverse, world, crossfilter and dimensionMap */
/* create universe */
const universe = Universe.createUniverseFromResponse(
const universe = Universe.createUniverseFromRestV02Response(
REST.config,
REST.schema,
REST.annotationsObs,
@@ -27,22 +26,21 @@ const defaultBigBang = () => {
/* 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(
const universe = Universe.createUniverseFromRestV02Response(
REST.config,
REST.schema,
REST.annotationsObs,
@@ -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);
@@ -1,62 +0,0 @@
import {
countCategoryValues2D,
clearCaches
} from "../../../src/util/stateManager/worldUtil";
import * as Dataframe from "../../../src/util/dataframe";
describe("WorldUtil cache management", () => {
test("empty", () => {
const count = countCategoryValues2D(
"a",
"b",
new Dataframe.Dataframe([0, 0], [])
);
expect(count).toMatchObject(new Map());
expect(count.size).toBe(0);
});
test("simple couts", () => {
const df = new Dataframe.Dataframe(
[3, 2],
[[0, 0, 1], [false, true, false]],
null,
new Dataframe.KeyIndex(["a", "b"])
);
const count = countCategoryValues2D("a", "b", df);
expect(count).toMatchObject(
new Map([
[0, new Map([[true, 1], [false, 1]])],
[1, new Map([[false, 1]])]
])
);
});
test("memo cache clear", () => {
clearCaches();
const df1 = new Dataframe.Dataframe([0, 0], []);
const df2 = new Dataframe.Dataframe(
[3, 2],
[[0, 0, 1], [false, true, false]],
null,
new Dataframe.KeyIndex(["a", "b"])
);
const count1 = countCategoryValues2D("a", "b", df1);
const count2 = countCategoryValues2D("a", "b", df1);
const count3 = countCategoryValues2D("a", "b", df1.clone());
const count4 = countCategoryValues2D("a", "b", df2);
clearCaches();
const count10 = countCategoryValues2D("a", "b", df1);
const count11 = countCategoryValues2D("a", "b", df2);
expect(count1).toEqual(count2);
expect(count1).toEqual(count3);
expect(count1).toEqual(count10);
expect(count1).not.toBe(count3);
expect(count1).not.toBe(count10);
expect(count4).toEqual(count11);
expect(count4).not.toBe(count11);
});
});
@@ -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,434 +0,0 @@
import _ from "lodash";
import { polygonContains } from "d3";
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);
});
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]]],
[[[0, 0], [0, 0.5], [0.5, 0.5], [0.5, 0]]]
])("within-polygon %p", polygon => {
expect(
p.select("coords", { mode: "within-polygon", polygon }).allSelected()
).toEqual(_.filter(someData, d => polygonContains(polygon, d.coords)));
});
});
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,223 +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], -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]), -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"
]
};
+1 -2
View File
@@ -34,8 +34,7 @@ module.exports = {
"object-curly-newline": ["error", { consistent: true }],
"react/prop-types": [0],
"space-before-function-paren": "off",
"function-paren-newline": "off",
"prefer-destructuring": ["error", { object: true, array: false }]
"function-paren-newline": "off"
},
overrides: [
{
@@ -77,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()
]
};
@@ -10,7 +10,7 @@ const nodeModules = path.resolve("node_modules");
const babelOptions = require("../babel/babel.prod");
const publicPath = "";
const publicPath = "/";
module.exports = {
mode: "production",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 312 B

After

Width:  |  Height:  |  Size: 4.4 KiB

+1 -1
View File
@@ -3,7 +3,7 @@
<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 {
+23 -30
View File
@@ -1,42 +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>
+4666 -7615
View File
File diff suppressed because it is too large Load Diff
+60 -78
View File
@@ -1,21 +1,16 @@
{
"name": "cellxgene",
"version": "0.11.0",
"version": "0.0.2",
"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 ",
"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",
"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": {
@@ -24,103 +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.15.1",
"@blueprintjs/icons": "^3.8.0",
"@blueprintjs/select": "^3.8.0",
"@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.3.0",
"flatbuffers": "^1.10.2",
"font-color-contrast": "^1.0.3",
"fuzzysort": "^1.1.4",
"gl-mat4": "^1.1.4",
"gl-matrix": "^3.0.0",
"gl-vec3": "^1.1.3",
"is-number": "^7.0.0",
"gl-matrix": "^2.7.1",
"key-pressed": "0.0.1",
"lodash": "^4.17.14",
"memoize-one": "^5.0.4",
"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.5.0",
"react": "^16.8.6",
"query-string": "^6.1.0",
"react": "^16.6.0",
"react-autocomplete": "^1.7.2",
"react-dom": "^16.8.6",
"react-helmet": "^5.2.1",
"react-icons": "^3.7.0",
"react-redux": "^7.0.3",
"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.11",
"regl": "^1.3.9",
"scroll-speed": "^1.0.0",
"urijs": "^1.19.0"
},
"devDependencies": {
"@babel/core": "^7.4.4",
"@babel/plugin-proposal-class-properties": "^7.4.4",
"@babel/plugin-proposal-decorators": "^7.4.4",
"@babel/plugin-proposal-export-namespace-from": "^7.2.0",
"@babel/plugin-proposal-function-bind": "^7.2.0",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.4.4",
"@babel/plugin-proposal-optional-chaining": "^7.2.0",
"@babel/plugin-transform-react-constant-elements": "^7.2.0",
"@babel/plugin-transform-runtime": "^7.4.4",
"@babel/preset-env": "^7.4.4",
"@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.4.4",
"@babel/runtime": "^7.4.4",
"@babel/register": "^7.0.0",
"@babel/runtime": "^7.1.5",
"babel-core": "^7.0.0-bridge.0",
"babel-eslint": "^10.0.1",
"babel-jest": "^24.8.0",
"babel-loader": "^8.0.6",
"babel-preset-modern-browsers": "^14.0.0",
"chalk": "^2.4.2",
"connect-history-api-fallback": "^1.6.0",
"copy-webpack-plugin": "^5.0.3",
"css-loader": "^2.1.1",
"eslint": "^5.16.0",
"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": "^4.2.0",
"eslint-loader": "^2.1.2",
"eslint-config-prettier": "^3.1.0",
"eslint-loader": "^2.1.1",
"eslint-plugin-filenames": "^1.3.2",
"eslint-plugin-import": "^2.17.2",
"eslint-plugin-jest": "^22.5.1",
"eslint-plugin-jsx-a11y": "^6.2.1",
"eslint-plugin-react": "^7.13.0",
"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": "^3.0.1",
"file-loader": "^2.0.0",
"html-webpack-inline-source-plugin": "0.0.10",
"html-webpack-plugin": "^3.2.0",
"jest": "^24.8.0",
"jest-puppeteer": "^4.1.1",
"jest": "^23.5.0",
"json-loader": "^0.5.4",
"mini-css-extract-plugin": "^0.6.0",
"puppeteer": "^1.16.0",
"rimraf": "^2.6.3",
"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.9.0",
"style-loader": "^0.23.1",
"sw-precache-webpack-plugin": "^0.11.5",
"url-loader": "^1.1.0",
"webpack": "^4.31.0",
"webpack-cli": "^3.3.2",
"webpack-dev-middleware": "^3.6.2"
"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": {
@@ -131,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"
]
}
}
+69 -110
View File
@@ -1,11 +1,11 @@
// jshint esversion: 6
import _ from "lodash";
import * as globals from "../globals";
import { Universe } 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=${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,
@@ -100,52 +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"
})
}
);
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);
if (data) {
expData[g] = data.asArray();
}
},
{}
); // --> { gene: data }
let expressionData = _.transform(genes, (expData, g) => {
const data = kvCache.get(universe.varDataCache, g);
if (data) {
expData[g] = data;
}
}); // --> { gene: data }
/* make a list of genes for which we do not have data */
const genesToFetch = _.filter(genes, g => expressionData[g] === undefined);
@@ -154,10 +104,36 @@ 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",
"Accept-Encoding": "gzip, deflate, br",
"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 });
@@ -174,12 +150,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) {
@@ -195,14 +171,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) {
@@ -250,33 +226,27 @@ 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`,
{
method: "POST",
headers: new Headers({
Accept: "application/json",
"Accept-Encoding": "gzip, deflate, br",
"Content-Type": "application/json"
}),
body: JSON.stringify({
mode: "topN",
count: num_genes,
set1: { filter: { obs: { index: set1 } } },
set2: { filter: { obs: { index: set2 } } }
set1: { filter: { obs: { index: set1ByIndex } } },
set2: { filter: { obs: { index: set2ByIndex } } }
})
}
);
@@ -287,9 +257,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
@@ -311,11 +279,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"
});
@@ -332,12 +297,6 @@ const resetInterface = () => (dispatch, getState) => {
type: "reset World to eq Universe",
universe
});
dispatch({
type: "increment graph render counter"
});
dispatch({
type: "user reset end"
});
};
export default {
+15 -21
View File
@@ -4,17 +4,14 @@ import Helmet from "react-helmet";
import { connect } from "react-redux";
import Container from "./framework/container";
import LeftSideBar from "./leftSidebar";
import LeftSideBar from "./leftsidebar";
import Legend from "./continuousLegend";
import Graph from "./graph/graph";
import MenuBar from "./menubar";
import actions from "../actions";
@connect(state => ({
loading: state.controls.loading,
error: state.controls.error,
graphRenderCounter: state.controls.graphRenderCounter
error: state.controls.error
}))
class App extends React.Component {
constructor(props) {
@@ -57,7 +54,7 @@ class App extends React.Component {
}
render() {
const { loading, error, graphRenderCounter } = this.props;
const { loading } = this.props;
return (
<Container>
<Helmet title="cellxgene" />
@@ -73,23 +70,20 @@ 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 : <MenuBar />}
{loading ? null : <Graph key={graphRenderCounter} />}
<Legend />
<div
style={{
padding: 15,
width: 1440 - 410 /* but responsive */,
marginLeft: 350 /* but responsive */
}}
>
{loading ? null : <Graph />}
<Legend />
{}
</div>
</div>
</Container>
);
+117 -288
View File
@@ -5,65 +5,69 @@ https://bl.ocks.org/SpaceActuary/2f004899ea1b2bd78d6f1dbb2febf771
*/
// 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 { makeContinuousDimensionName } from "../../util/nameCreators";
@connect(state => ({
world: state.world,
world: state.controls.world,
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
scatterplotYYaccessor: state.controls.scatterplotYYaccessor,
continuousSelection: state.continuousSelection,
crossfilter: state.controls.crossfilter,
differential: state.differential,
colorAccessor: state.colors.colorAccessor
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 {
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, field) => {
/*
recalculate expensive stuff, notably bins, summaries, etc.
*/
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;
histogramCache.x = d3
.scaleLinear()
.domain([domainMin, domainMax])
.range([0, this.width - this.marginRight]);
histogramCache.bins = d3
.histogram()
.domain(histogramCache.x.domain())
.thresholds(40)(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.height - this.marginBottom, 0]);
if (obsAnnotations[0][field]) {
// 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;
});
@@ -72,98 +76,32 @@ class HistogramBrush extends React.Component {
this.width = 340;
this.height = 100;
this.marginBottom = 20; // space for X axis & labels
this.marginRight = 40; // space for Y axis & labels
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, continuousSelection } = 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.
*/
if (
forceBrushUpdate ||
continuousSelection !== prevProps.continuousSelection
) {
const { isObs, isUserDefined, isDiffExp } = this.props;
const myName = makeContinuousDimensionName(
{ isObs, isUserDefined, isDiffExp },
field
);
const range = continuousSelection[myName];
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,
@@ -174,7 +112,7 @@ class HistogramBrush extends React.Component {
});
} else {
dispatch({
type,
type: "continuous metadata histogram brush",
selection: field,
continuousNamespace: {
isObs,
@@ -187,91 +125,41 @@ class HistogramBrush extends React.Component {
};
}
onBrushEnd(selection, x) {
return () => {
const { dispatch, field, isObs, isUserDefined, isDiffExp } = this.props;
const minAllowedBrushSize = 10;
const smallAmountToAvoidInfiniteLoop = 0.1;
// 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) {
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
}
});
}
};
}
drawHistogram(svgRef) {
const { field, world } = this.props;
const col = HistogramBrush.getColumn(world, field);
const histogramCache = this.calcHistogramCache(col, field);
const { x, y, bins } = histogramCache;
this._histogram = { x, y, bins, svgRef };
const { obsAnnotations, field, ranges } = this.props;
const histogramCache = this.calcHistogramCache(
obsAnnotations,
field,
ranges
);
const { x, y, bins, numValues } = histogramCache;
this._histogram = { x, y, bins, numValues, svgRef };
}
handleColorAction() {
const { dispatch, field, world, ranges } = this.props;
const {
obsAnnotations,
dispatch,
field,
world,
initializeRanges
} = 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;
const { dispatch, field, colorAccessor } = this.props;
dispatch({
type: "clear user defined gene",
data: field
@@ -281,18 +169,6 @@ class HistogramBrush extends React.Component {
type: "reset colorscale"
});
}
if (field === scatterplotXXaccessor) {
dispatch({
type: "set scatterplot x",
data: null
});
}
if (field === scatterplotYYaccessor) {
dispatch({
type: "set scatterplot y",
data: null
});
}
}
handleSetGeneAsScatterplotX() {
@@ -315,114 +191,76 @@ class HistogramBrush extends React.Component {
};
}
renderAxesBrushBins(x, y, bins, svgRef, field) {
const svg = d3.select(svgRef);
renderAxesBrushBins(x, y, bins, numValues, svgRef, field) {
/* Remove everything */
svg.selectAll("*").remove();
d3.select(svgRef)
.selectAll("*")
.remove();
/* BINS */
svg
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 */
const brushX = d3
.brushX()
.extent([[0, 0], [this.width - this.marginRight, this.height]])
/*
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 = d3
.select(svgRef)
d3.select(svgRef)
.append("g")
.attr("class", "brush")
.attr("data-testid", `${svgRef.dataset.testid}-brush`)
.call(brushX);
.call(d3.brushX().on("end", this.onBrush(field, x.invert).bind(this)));
/* X AXIS */
svg
/* AXIS */
d3.select(svgRef)
.append("g")
.attr("class", "axis axis--x")
.attr("transform", `translate(0,${this.height - this.marginBottom})`)
.call(d3.axisBottom(x).ticks(5));
/* Y AXIS */
svg
.append("g")
.attr("class", "axis axis--y")
.attr("transform", `translate(${this.width - this.marginRight},0)`)
.call(d3.axisRight(y).ticks(3));
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,
pvalAdj,
avgDiff,
set1AvgExp,
set2AvgExp,
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 }}
@@ -430,7 +268,6 @@ class HistogramBrush extends React.Component {
/>
<ButtonGroup style={{ marginRight: 7 }}>
<Button
data-testid={`plot-x-${field}`}
onClick={this.handleSetGeneAsScatterplotX(field).bind(this)}
active={scatterplotXXaccessor === field}
intent={scatterplotXXaccessor === field ? "primary" : "none"}
@@ -438,7 +275,6 @@ class HistogramBrush extends React.Component {
plot x
</Button>
<Button
data-testid={`plot-y-${field}`}
onClick={this.handleSetGeneAsScatterplotY(field).bind(this)}
active={scatterplotYYaccessor === field}
intent={scatterplotYYaccessor === field ? "primary" : "none"}
@@ -466,8 +302,6 @@ class HistogramBrush extends React.Component {
onClick={this.handleColorAction.bind(this)}
active={colorAccessor === field}
intent={colorAccessor === field ? "primary" : "none"}
data-testclass="colorby"
data-testid={`colorby-${field}`}
icon="tint"
/>
</Tooltip>
@@ -475,9 +309,7 @@ class HistogramBrush extends React.Component {
<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);
}}
@@ -485,21 +317,10 @@ class HistogramBrush extends React.Component {
<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 ? (
@@ -511,17 +332,25 @@ class HistogramBrush extends React.Component {
}}
>
<span>
<strong>log fold change:</strong>
{` ${logFoldChange.toPrecision(4)}`}
<strong>1:</strong>
{` ${set1AvgExp.toPrecision(2)}`}
</span>
<span
style={{
marginLeft: 7,
backgroundColor: globals.lighterGrey,
padding: 2
}}
>
<strong>p-value (adj):</strong>
{pvalAdj < 0.0001 ? " < 0.0001" : ` ${pvalAdj.toFixed(4)}`}
<strong>2:</strong>
{` ${set2AvgExp.toPrecision(2)}`}
</span>
<span
style={{
marginLeft: 7
}}
>
{`Av. Diff: ${avgDiff.toFixed(2)}`}
</span>
</div>
) : null}
@@ -1,3 +0,0 @@
:local(.value):hover {
background: rgba(167, 182, 194, 0.3);
}
@@ -5,13 +5,34 @@ import { connect } from "react-redux";
import * as globals from "../../globals";
import Category from "./category";
/* Cap the max number of displayed categories */
const truncateCategories = options => {
const numOptions = _.size(options);
if (numOptions <= globals.maxCategoricalOptionsToDisplay) {
return options;
}
return _(options)
.map((v, k) => ({ name: k, val: v }))
.sortBy("val")
.slice(numOptions - globals.maxCategoricalOptionsToDisplay)
.transform((r, v) => {
r[v.name] = v.val;
}, {})
.value();
};
@connect(state => ({
categoricalSelection: state.categoricalSelection
ranges: _.get(state.controls.world, "summary.obs", null),
categorySelectionLimit: _.get(
state.config,
"parameters.max-category-items",
globals.configDefaults.parameters["max-category-items"]
)
}))
class Categories extends React.Component {
render() {
const { categoricalSelection } = this.props;
if (!categoricalSelection) return null;
const { ranges, categorySelectionLimit } = this.props;
if (!ranges) return null;
return (
<div
@@ -19,9 +40,34 @@ class Categories extends React.Component {
padding: globals.leftSidebarSectionPadding
}}
>
{_.map(categoricalSelection, (catState, catName) => (
<Category key={catName} metadataField={catName} />
))}
<p
style={Object.assign({}, globals.leftSidebarSectionHeading, {
marginTop: 4
})}
>
Categorical Metadata
</p>
{_.map(ranges, (value, key) => {
const isColorField = key.includes("color") || key.includes("Color");
const isSelectableCategory =
value.options &&
!isColorField &&
key !== "name" &&
value.numOptions < categorySelectionLimit;
if (isSelectableCategory) {
const categoryOptions = truncateCategories(value.options);
return (
<Category
key={key}
metadataField={key}
values={categoryOptions}
isTruncated={categoryOptions !== value.options}
/>
);
}
return undefined;
})}
</div>
);
}
+53 -62
View File
@@ -2,15 +2,29 @@ import React from "react";
import _ from "lodash";
import { connect } from "react-redux";
import { FaChevronRight, FaChevronDown } from "react-icons/fa";
import { Button, Tooltip } from "@blueprintjs/core";
import memoize from "memoize-one";
import { Button, Tooltip, Position } from "@blueprintjs/core";
import * as globals from "../../globals";
import Value from "./value";
import sortedCategoryValues from "./util";
import alphabeticallySortedValues from "./util";
const countCategories = (values, optsAsBools) =>
_.reduce(
values,
(r, v, k) => {
r.total += 1;
if (optsAsBools[k]) {
r.on += 1;
}
return r;
},
{ total: 0, on: 0 }
);
@connect(state => ({
colorAccessor: state.colors.colorAccessor,
categoricalSelection: state.categoricalSelection
colorAccessor: state.controls.colorAccessor,
categoricalAsBooleansMap: state.controls.categoricalAsBooleansMap
}))
class Category extends React.Component {
constructor(props) {
@@ -19,45 +33,36 @@ class Category extends React.Component {
isChecked: true,
isExpanded: false
};
this.countCategories = memoize((values, optsAsBools) =>
countCategories(values, optsAsBools)
);
}
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 { categoricalAsBooleansMap, metadataField, values } = this.props;
const categoryCount = this.countCategories(
values,
categoricalAsBooleansMap[metadataField]
);
if (categoryCount.on === categoryCount.total) {
/* everything is on, so not indeterminate */
this.checkbox.indeterminate = false;
} else if (categoryCount.on === 0) {
/* nothing is on, so no */
this.checkbox.indeterminate = false;
} else if (categoryCount.on < categoryCount.total) {
/* 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;
@@ -69,10 +74,11 @@ class Category extends React.Component {
}
toggleNone() {
const { dispatch, metadataField } = this.props;
const { dispatch, metadataField, value } = this.props;
dispatch({
type: "categorical metadata filter none of these",
metadataField
metadataField,
value
});
this.setState({ isChecked: false });
}
@@ -88,16 +94,13 @@ class Category extends React.Component {
}
renderCategoryItems() {
const { categoricalSelection, metadataField } = this.props;
const cat = categoricalSelection[metadataField];
const optTuples = sortedCategoryValues([...cat.categoryValueIndices]);
return _.map(optTuples, (tuple, i) => (
const { values, metadataField } = this.props;
return _.map(alphabeticallySortedValues(values), (v, i) => (
<Value
optTuples={optTuples}
key={tuple[1]}
key={v}
metadataField={metadataField}
categoryIndex={tuple[1]}
count={values[v]}
value={v}
i={i}
/>
));
@@ -105,15 +108,14 @@ class Category extends React.Component {
render() {
const { isExpanded, isChecked } = this.state;
const { metadataField, colorAccessor, categoricalSelection } = this.props;
const { isTruncated } = categoricalSelection[metadataField];
const { metadataField, colorAccessor, isTruncated } = this.props;
return (
<div
style={{
// display: "flex",
// alignItems: "baseline",
maxWidth: globals.maxControlsWidth
}}
data-testclass="category"
data-testid={`category-${metadataField}`}
>
<div
style={{
@@ -131,8 +133,6 @@ class Category extends React.Component {
>
<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;
@@ -146,7 +146,6 @@ class Category extends React.Component {
</label>
<span
data-testid={`category-expand-${metadataField}`}
style={{
cursor: "pointer",
display: "inline-block"
@@ -157,26 +156,18 @@ class Category extends React.Component {
>
{metadataField}
{isExpanded ? (
<FaChevronDown
data-testclass="category-expand-is-expanded"
style={{ fontSize: 10, marginLeft: 5 }}
/>
<FaChevronDown style={{ fontSize: 10, marginLeft: 5 }} />
) : (
<FaChevronRight
data-testclass="category-expand-is-not-expanded"
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>
@@ -1,194 +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.Component {
_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 bins = histogramMap.get(category.categoryValues[categoryIndex]);
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]);
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;
this.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;
+4 -31
View File
@@ -1,34 +1,7 @@
// jshint esversion: 6
// values is [ [optVal, optIdx], ...]
// index is range array
// return sorted index
import isNumber from "is-number";
const sortedCategoryValues = values => {
/* this sort could be memoized for perf */
const strings = [];
const ints = [];
values.forEach(v => {
if (isNumber(v[0])) {
ints.push(v);
} else {
strings.push(v);
}
});
strings.sort((a, b) => {
const textA = String(a[0]).toUpperCase();
const textB = String(b[0]).toUpperCase();
export default values =>
Object.keys(values).sort((a, b) => {
const textA = a.toUpperCase();
const textB = b.toUpperCase();
return textA < textB ? -1 : textA > textB ? 1 : 0;
});
ints.sort((a, b) => +a[0] - +b[0]);
return ints.concat(strings);
};
export default sortedCategoryValues;
+33 -130
View File
@@ -1,181 +1,84 @@
// jshint esversion: 6
import { connect } from "react-redux";
import React from "react";
import Occupancy from "./occupancy";
import * as globals from "../../globals";
import styles from "./categorical.css";
@connect(state => ({
categoricalSelection: state.categoricalSelection,
colorScale: state.colors.scale,
colorAccessor: state.colors.colorAccessor,
schema: state.world?.schema,
world: state.world
categoricalAsBooleansMap: state.controls.categoricalAsBooleansMap,
colorScale: state.controls.colorScale,
colorAccessor: state.controls.colorAccessor
}))
class CategoryValue extends React.Component {
toggleOff = () => {
const { dispatch, metadataField, categoryIndex } = this.props;
toggleOff() {
const { dispatch, metadataField, value } = this.props;
dispatch({
type: "categorical metadata filter deselect",
metadataField,
categoryIndex
value
});
};
}
shouldComponentUpdate = nextProps => {
/*
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
If and only if true, update the component
*/
const { props } = 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;
return valueSelectionChange || worldChange || colorAccessorChange;
};
toggleOn = () => {
const { dispatch, metadataField, categoryIndex } = this.props;
toggleOn() {
const { dispatch, metadataField, value } = this.props;
dispatch({
type: "categorical metadata filter select",
metadataField,
categoryIndex
value
});
};
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
});
};
}
render() {
const {
categoricalSelection,
categoricalAsBooleansMap,
metadataField,
categoryIndex,
count,
value,
colorAccessor,
colorScale,
i,
schema
i
} = this.props;
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();
if (!categoricalAsBooleansMap) return null;
const selected = categoricalAsBooleansMap[metadataField][value];
/* this is the color scale, so add swatches below */
const isColorBy = metadataField === colorAccessor;
let categories = null;
if (isColorBy && schema) {
categories = schema.annotations.obsByName[colorAccessor]?.categories;
}
const c = metadataField === colorAccessor;
return (
<div
key={i}
className={styles.value}
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 - 130,
display: "flex",
justifyContent: "space-between"
userSelect: "none"
}}
>
<div style={{ display: "flex" }}>
<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>
<span
data-testid={`categorical-value-${metadataField}-${displayString}`}
data-testclass="categorical-value"
style={{ wordBreak: "break-all" }}
>
{displayString}
</span>
</div>
<span style={{ flexShrink: 0 }}>
{colorAccessor && !isColorBy ? (
<Occupancy category={category} {...this.props} />
) : null}
</span>
<label className="bp3-control bp3-checkbox">
<input
onChange={
selected ? this.toggleOff.bind(this) : this.toggleOn.bind(this)
}
checked={selected}
type="checkbox"
/>
<span className="bp3-control-indicator" />
{value}
</label>
</div>
<span>
<span
data-testclass="categorical-value-count"
data-testid={`categorical-value-count-${metadataField}-${displayString}`}
>
{count}
</span>
<span>{count}</span>
<svg
style={{
marginLeft: 5,
width: 11,
height: 11,
backgroundColor:
isColorBy && categories
? colorScale(categories.indexOf(value))
: "inherit"
backgroundColor: c ? colorScale(value) : "inherit"
}}
/>
</span>
+28 -38
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) {
@@ -27,18 +29,17 @@ class Continuous extends React.Component {
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,7 +49,7 @@ class Continuous extends React.Component {
}
/* initial value for iterator to simulate index, ranges is an object */
let zebra = 0;
let zebra = -1;
return (
<div>
@@ -62,35 +63,24 @@ class Continuous extends React.Component {
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).bind(this)}
/>
);
}
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>
);
}
@@ -2,7 +2,7 @@
import React from "react";
import { connect } from "react-redux";
import * as d3 from "d3";
import { interpolateCool } from "d3-scale-chromatic";
import { interpolateViridis } from "d3-scale-chromatic";
// create continuous color legend
// http://bl.ocks.org/syntagmatic/e8ccca52559796be775553b467593a9f
@@ -98,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 {
@@ -112,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
) {
@@ -122,12 +121,12 @@ class ContinuousLegend extends React.Component {
.remove();
}
if (colorAccessor && colorScale && colorScale.range) {
if (colorAccessor && colorScale) {
/* fragile! continuous range is 0 to 1, not [#fa4b2c, ...], make this a flag? */
if (colorScale.range()[0][0] !== "#") {
continuous(
"#continuous_legend",
d3.scaleSequential(interpolateCool).domain(colorScale.domain()),
d3.scaleSequential(interpolateViridis).domain(colorScale.domain()),
colorAccessor
);
}
-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
*/
@@ -1,8 +1,8 @@
// jshint esversion: 6
import React from "react";
import { AnchorButton, Tooltip } from "@blueprintjs/core";
import _ from "lodash";
import { Button, Tooltip } from "@blueprintjs/core";
import { connect } from "react-redux";
import { World } from "../../util/stateManager";
@connect()
class CellSetButton extends React.Component {
@@ -14,11 +14,7 @@ class CellSetButton extends React.Component {
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;
const set = _.map(crossfilter.allFiltered(), "name");
if (!differential.diffExp) {
/* diffexp needs to be cleared before we store a new set */
@@ -32,27 +28,22 @@ class CellSetButton extends React.Component {
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="top"
>
<AnchorButton
<Button
style={{ marginRight: 10 }}
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>
{differential[cellListName]
? `${differential[cellListName].length} cells`
: "0 cells"}
</Button>
</Tooltip>
);
}
@@ -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;
+83 -251
View File
@@ -3,64 +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";
const renderGene = (fuzzySortResult, { handleClick, modifiers, query }) => {
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
};
})
@@ -68,236 +32,95 @@ class GeneExpression extends React.Component {
constructor(props) {
super(props);
this.state = {
bulkAdd: "",
tab: "autosuggest"
gene: ""
};
}
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(", ");
keyPress(e) {
if (e.keyCode === 13) {
this.handleClick();
}
// default - should never happen.
return "Apod, Cd74, ...";
}
handleClick(g) {
handleClick() {
const { world, dispatch, userDefinedGenes } = this.props;
const varIndexName = world.schema.annotations.var.index;
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: "" });
}
}
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(/[ ,]+/)), "");
dispatch({ type: "bulk user defined gene start" });
Promise.all(
genes.map(gene => {
if (gene.length === 0) {
return keepAroundErrorToast("Must enter a gene name.");
}
if (userDefinedGenes.indexOf(gene) !== -1) {
return keepAroundErrorToast("That gene already exists");
}
if (
world.varAnnotations.col(varIndexName).indexOf(gene) === undefined
) {
return keepAroundErrorToast(
`${gene} doesn't appear to be a valid gene name.`
);
}
return dispatch(actions.requestUserDefinedGene(gene));
})
).then(
() => dispatch({ type: "bulk user defined gene complete" }),
() => dispatch({ type: "bulk user defined gene error" })
);
}
this.setState({ bulkAdd: "" });
}
render() {
const {
world,
userDefinedGenes,
userDefinedGenesLoading,
differential
} = this.props;
const varIndexName = world?.schema?.annotations?.var?.index;
const { tab, bulkAdd } = 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 });
}}
inputProps={{ "data-testid": "gene-search" }}
inputValueRenderer={g => {
return "";
}}
itemListPredicate={filterGenes}
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}
>
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.bind(this)}
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
/>
);
@@ -305,24 +128,33 @@ 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]}
pvalAdj={value[3]}
avgDiff={value[1]}
set1AvgExp={value[4]}
set2AvgExp={value[5]}
/>
);
})
+1 -15
View File
@@ -1,6 +1,5 @@
// jshint esversion: 6
const mat4 = require("gl-mat4");
const vec3 = require("gl-vec3");
// opacity: https://github.com/spacetx/starfish/blob/master/viz/draw/regions.js
@@ -39,20 +38,7 @@ export default function(regl) {
uniforms: {
distance: regl.prop("distance"),
view: regl.prop("view"),
projection: ({ viewportWidth, viewportHeight }) => {
const aspectRatio = viewportWidth / viewportHeight;
let m = mat4.perspective(
[],
Math.PI / 2,
viewportWidth / viewportHeight,
0.01,
1000
);
if (aspectRatio < 1) {
m = mat4.scale(m, m, vec3.fromValues(1, 1, 1 / aspectRatio));
}
return m;
}
projection: () => mat4.perspective([], Math.PI / 2, 1, 0.01, 1000)
},
count: regl.prop("count"),
+247 -481
View File
@@ -1,14 +1,14 @@
// jshint esversion: 6
import React from "react";
import _ from "lodash";
import * as d3 from "d3";
import { connect } from "react-redux";
import mat4 from "gl-mat4";
import _regl from "regl";
import memoize from "memoize-one";
import { Button, AnchorButton, Tooltip } from "@blueprintjs/core";
import { worldEqUniverse } from "../../util/stateManager/world";
import * as globals from "../../globals";
import setupSVGandBrushElements from "./setupSVGandBrush";
import setupCentroidSVG from "./setupCentroidSVG";
import actions from "../../actions";
import _camera from "../../util/camera";
import _drawPoints from "./drawPointsRegl";
@@ -17,93 +17,30 @@ import scaleLinear from "../../util/scaleLinear";
/* https://bl.ocks.org/mbostock/9078690 - quadtree for onClick / hover selections */
@connect(state => ({
world: state.world,
crossfilter: state.crossfilter,
world: state.controls.world,
universe: state.controls.universe,
crossfilter: state.controls.crossfilter,
responsive: state.responsive,
colorRGB: state.colors.rgb,
selectionTool: state.graphSelection.tool,
currentSelection: state.graphSelection.selection,
layoutChoice: state.layoutChoice,
centroidLabel: state.centroidLabel,
graphInteractionMode: state.controls.graphInteractionMode,
colorAccessor: state.colors.colorAccessor
colorRGB: _.get(state.controls, "colorRGB", null),
opacityForDeselectedCells: state.controls.opacityForDeselectedCells,
selectionUpdate: _.get(state.controls, "crossfilter.updateTime", null)
}))
class Graph extends React.Component {
computePointPositions = memoize((X, Y, scaleX, scaleY) => {
/*
compute webgl coordinate buffer for each point
*/
const positions = new Float32Array(2 * X.length);
for (let i = 0, len = X.length; i < len; i += 1) {
positions[2 * i] = scaleX(X[i]);
positions[2 * i + 1] = scaleY(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;
});
computePointSizesFromCrossfilter = memoize((len, crossfilter) => {
const sizes = new Float32Array(len);
crossfilter.fillByIsSelected(sizes, 4, 0.2);
return sizes;
});
computePointSizes = memoize(
(len, crossfilter, metadataField, categoryField) => {
/*
compute webgl dot size for each point
*/
const selectionSizes = this.computePointSizesFromCrossfilter(
len,
crossfilter
);
let sizes;
if (metadataField && categoryField) {
sizes = selectionSizes.slice();
const valuesArr = crossfilter.data.col(metadataField).asArray();
for (let i = 0; i < len; i += 1) {
if (valuesArr[i] === categoryField) {
sizes[i] = 10;
}
}
} else {
sizes = selectionSizes;
}
return sizes;
}
);
constructor(props) {
super(props);
this.count = 0;
this.graphPaddingTop = 0;
this.graphPaddingRight = globals.leftSidebarWidth;
this.inverse = mat4.identity([]);
this.graphPaddingTop = 100;
this.graphPaddingBottom = 45;
this.graphPaddingRight = 10;
this.renderCache = {
X: null,
Y: null,
positions: null,
colors: null,
sizes: null
colors: null
};
this.state = {
toolSVG: null,
centroidSVG: null,
tool: null,
container: null
svg: null,
brush: null,
mode: "brush"
};
}
@@ -119,20 +56,6 @@ class Graph extends React.Component {
const colorBuffer = regl.buffer();
const sizeBuffer = regl.buffer();
// preallocate coordinate system transformation between data and gl
const fractionToUse = 0.93; // fraction of dimension to use
const shiftForMenuBar = 0.05;
const transform = {
glScaleX: scaleLinear([0, 1], [-1 * fractionToUse, 1 * fractionToUse]),
glScaleY: scaleLinear(
[0, 1],
[
(1 + shiftForMenuBar) * fractionToUse,
(-1 + shiftForMenuBar) * fractionToUse
]
)
};
/* first time, but this duplicates above function, should be possile to avoid this */
const reglRender = regl.frame(() => {
this.reglDraw(
@@ -155,94 +78,98 @@ class Graph extends React.Component {
colorBuffer,
sizeBuffer,
camera,
reglRender,
transform
reglRender
});
}
componentDidUpdate(prevProps) {
const { renderCache } = this;
const {
world,
crossfilter,
selectionUpdate,
colorRGB,
responsive,
selectionTool,
currentSelection,
layoutChoice,
graphInteractionMode,
colorAccessor,
centroidLabel
responsive
} = this.props;
const { reglRender, mode, regl, toolSVG, centroidSVG } = this.state;
let stateChanges = {};
const {
reglRender,
mode,
regl,
drawPoints,
camera,
pointBuffer,
colorBuffer,
sizeBuffer,
svg
} = this.state;
if (reglRender) {
if (
// If it IS RENDERING and it is NOT IN ZOOM mode, stop rendering.
this.reglRenderState === "rendering" &&
graphInteractionMode !== "zoom"
) {
reglRender.cancel();
this.reglRenderState = "paused";
}
if (
// If it is NOT RENDERING and it IS IN ZOOM mode, start rendering
this.reglRenderState !== "rendering" &&
graphInteractionMode === "zoom"
) {
this.restartReglLoop();
this.reglRenderState = "rendering";
}
if (reglRender && this.reglRenderState === "rendering" && mode !== "zoom") {
reglRender.cancel();
this.reglRenderState = "paused";
}
if (regl && world) {
/* update the regl state */
const { obsLayout, nObs } = world;
const {
drawPoints,
transform,
camera,
pointBuffer,
colorBuffer,
sizeBuffer
} = this.state;
const { obsLayout } = world;
const cellCount = crossfilter.size();
/* coordinates for each point */
const { glScaleX, glScaleY } = transform;
const X = obsLayout.col(layoutChoice.currentDimNames[0]).asArray();
const Y = obsLayout.col(layoutChoice.currentDimNames[1]).asArray();
const newPositions = this.computePointPositions(X, Y, glScaleX, glScaleY);
if (renderCache.positions !== newPositions) {
/* update our cache & GL if the buffer changes */
renderCache.positions = newPositions;
pointBuffer({ data: newPositions, dimension: 2 });
// X/Y positions for each point - a cached value that only
// changes if we have loaded entirely new cell data
//
if (
!this.renderCache.positions ||
selectionUpdate !== prevProps.selectionUpdate
) {
if (!this.renderCache.positions) {
this.renderCache.positions = new Float32Array(2 * cellCount);
}
const glScaleX = scaleLinear([0, 1], [-1, 1]);
const glScaleY = scaleLinear([0, 1], [1, -1]);
for (
let i = 0, { positions } = this.renderCache;
i < cellCount;
i += 1
) {
positions[2 * i] = glScaleX(obsLayout.X[i]);
positions[2 * i + 1] = glScaleY(obsLayout.Y[i]);
}
pointBuffer({
data: this.renderCache.positions,
dimension: 2
});
}
/* colors for each point */
const newColors = this.computePointColors(colorRGB);
if (renderCache.colors !== newColors) {
/* update our cache & GL if the buffer changes */
renderCache.colors = newColors;
colorBuffer({ data: newColors, dimension: 3 });
// Colors for each point - a cached value that only changes when
// the cell metadata changes (done by updateCellColors middleware).
// NOTE: this is a slightly pessimistic assumption, as the metadata
// could have changed for some other reason, but for now color is
// the only metadata that changes client-side. If this is problematic,
// we could add some sort of color-specific indicator to the app state.
if (!this.renderCache.colors || colorRGB !== prevProps.colorRGB) {
const rgb = colorRGB;
if (!this.renderCache.colors) {
this.renderCache.colors = new Float32Array(3 * rgb.length);
}
for (let i = 0, { colors } = this.renderCache; i < rgb.length; i += 1) {
colors.set(rgb[i], 3 * i);
}
colorBuffer({ data: this.renderCache.colors, dimension: 3 });
}
/* sizes for each point */
const { metadataField, categoryField } = centroidLabel;
const newSizes = this.computePointSizes(
nObs,
crossfilter,
metadataField,
categoryField
);
if (renderCache.sizes !== newSizes) {
/* update our cache & GL if the buffer changes */
renderCache.size = newSizes;
sizeBuffer({ data: newSizes, dimension: 1 });
// Sizes for each point - this is presumed to change each time the
// component receives new props. Almost always a true assumption, as
// most property upates are due to changes driving a crossfilter
// selection set change.
//
if (!this.renderCache.sizes) {
this.renderCache.sizes = new Float32Array(cellCount);
}
this.count = nObs;
crossfilter.fillByIsFiltered(this.renderCache.sizes, 4, 0.2);
sizeBuffer({ data: this.renderCache.sizes, dimension: 1 });
this.count = cellCount;
regl._refresh();
this.reglDraw(
@@ -255,181 +182,23 @@ class Graph extends React.Component {
);
}
const createToolSVG = () => {
/* clear out whatever was on the div, even if nothing, but usually the brushes etc */
d3.select("#graphAttachPoint")
.select("#tool")
.remove();
let handleStart;
let handleDrag;
let handleEnd;
let handleCancel;
if (selectionTool === "brush") {
handleStart = this.handleBrushStartAction.bind(this);
handleDrag = this.handleBrushDragAction.bind(this);
handleEnd = this.handleBrushEndAction.bind(this);
} else {
handleStart = this.handleLassoStart.bind(this);
handleEnd = this.handleLassoEnd.bind(this);
handleCancel = this.handleLassoCancel.bind(this);
}
const { svg: newToolSVG, tool, container } = setupSVGandBrushElements(
selectionTool,
handleStart,
handleDrag,
handleEnd,
handleCancel,
responsive,
this.graphPaddingRight,
graphInteractionMode
);
stateChanges = { ...stateChanges, toolSVG: newToolSVG, tool, container };
};
const createCentroidSVG = () => {
d3.select("#graphAttachPoint")
.select("#centroid-container")
.remove();
if (centroidLabel.metadataField === "" || !centroidLabel.centroidXY) {
return;
}
const centroidScreen = this.mapPointToScreen(centroidLabel.centroidXY);
const newCentroidSVG = setupCentroidSVG(
responsive,
this.graphPaddingRight,
centroidScreen,
centroidLabel.categoryField,
colorAccessor
);
stateChanges = { ...stateChanges, centroidSVG: newCentroidSVG };
};
// Centroid SVG creation is disabled for now but should go into the first and third cases if enabled
if (
prevProps.responsive.height !== responsive.height ||
prevProps.responsive.width !== responsive.width
prevProps.responsive.width !== responsive.width ||
/* first time */
(responsive.height && responsive.width && !svg)
) {
// If the window size has changed we want to recreate all SVGs
createToolSVG();
} else if (
(responsive.height && responsive.width && !toolSVG) ||
selectionTool !== prevProps.selectionTool ||
prevProps.graphInteractionMode !== graphInteractionMode
) {
// first time or change of selection tool6
createToolSVG();
} else if (
centroidLabel !== prevProps.centroidLabel ||
(responsive.height && responsive.width && !centroidSVG)
) {
// First time for centroid or label change
}
/*
if the selection tool or state has changed, ensure that the selection
tool correctly reflects the underlying selection.
*/
if (
currentSelection !== prevProps.currentSelection ||
graphInteractionMode !== prevProps.graphInteractionMode ||
stateChanges.toolSVG
) {
const { tool, container } = this.state;
this.selectionToolUpdate(
stateChanges.tool ? stateChanges.tool : tool,
stateChanges.container ? stateChanges.container : container
/* clear out whatever was on the div, even if nothing, but usually the brushes etc */
d3.select("#graphAttachPoint")
.selectAll("svg")
.remove();
const { svg: newSvg, brush } = setupSVGandBrushElements(
this.handleBrushSelectAction.bind(this),
this.handleBrushDeselectAction.bind(this),
responsive,
this.graphPaddingTop
);
}
if (Object.keys(stateChanges).length > 0) {
this.setState(stateChanges);
}
}
brushToolUpdate(tool, container) {
/*
this is called from componentDidUpdate(), so be very careful using
anything from this.state, which may be updated asynchronously.
*/
const { currentSelection } = this.props;
if (container) {
const toolCurrentSelection = d3.brushSelection(container.node());
if (currentSelection.mode === "within-rect") {
/*
if there is a selection, make sure the brush tool matches
*/
const screenCoords = [
this.mapPointToScreen(currentSelection.brushCoords.northwest),
this.mapPointToScreen(currentSelection.brushCoords.southeast)
];
if (!toolCurrentSelection) {
/* tool is not selected, so just move the brush */
container.call(tool.move, screenCoords);
} else {
/* there is an active selection and a brush - make sure they match */
/* this just sums the difference of each dimension, of each point */
let delta = 0;
for (let x = 0; x < 2; x += 1) {
for (let y = 0; y < 2; y += 1) {
delta += Math.abs(
screenCoords[x][y] - toolCurrentSelection[x][y]
);
}
}
if (delta > 0) {
container.call(tool.move, screenCoords);
}
}
} else if (toolCurrentSelection) {
/* no selection, so clear the brush tool if it is set */
container.call(tool.move, null);
}
}
}
lassoToolUpdate(tool) {
/*
this is called from componentDidUpdate(), so be very careful using
anything from this.state, which may be updated asynchronously.
*/
const { currentSelection } = this.props;
if (currentSelection.mode === "within-polygon") {
/*
if there is a current selection, make sure the lasso tool matches
*/
const polygon = currentSelection.polygon.map(p =>
this.mapPointToScreen(p)
);
tool.move(polygon);
} else {
tool.reset();
}
}
selectionToolUpdate(tool, container) {
/*
this is called from componentDidUpdate(), so be very careful using
anything from this.state, which may be updated asynchronously.
*/
const { selectionTool } = this.props;
switch (selectionTool) {
case "brush":
this.brushToolUpdate(tool, container);
break;
case "lasso":
this.lassoToolUpdate(tool, container);
break;
default:
/* punt? */
break;
this.setState({ svg: newSvg, brush });
}
}
@@ -476,174 +245,78 @@ class Graph extends React.Component {
});
}
mapScreenToPoint(pin) {
handleBrushSelectAction() {
/*
Map an XY coordinates from screen domain to cell/point range,
accounting for current pan/zoom camera.
This conditional handles procedural brush deselect. Brush emits
an event on procedural deselect because it is move: null
*/
const { responsive } = this.props;
const { regl, camera, transform } = this.state;
const { glScaleX, glScaleY } = transform;
const gl = regl._gl;
const { camera } = this.state;
const { dispatch, responsive } = this.props;
// get aspect ratio
const aspect = gl.drawingBufferWidth / gl.drawingBufferHeight;
const scale = aspect < 1 ? 1 / aspect : 1;
// compute inverse view matrix
const inverse = mat4.invert([], camera.view());
// transform screen coordinates -> cell coordinates
const x = (2 * pin[0]) / (responsive.width - this.graphPaddingRight) - 1;
const y = 2 * (1 - pin[1] / (responsive.height - this.graphPaddingTop)) - 1;
const pout = [
x * inverse[14] * aspect * scale + inverse[12],
-(y * inverse[14] * scale + inverse[13])
];
const xy = [glScaleX.invert(pout[0]), glScaleY.invert(pout[1])];
return xy;
}
mapPointToScreen(xyCell) {
/*
Map an XY coordinate from cell/point domain to screen range. Inverse
of mapScreenToPoint()
if (d3.event.sourceEvent !== null) {
/*
No idea why d3 event scope works like this
but apparently
it does
https://bl.ocks.org/EfratVil/0e542f5fc426065dd1d4b6daaa345a9f
*/
const { responsive } = this.props;
const { regl, camera, transform } = this.state;
const { glScaleX, glScaleY } = transform;
const gl = regl._gl;
// get aspect ratio
const aspect = gl.drawingBufferWidth / gl.drawingBufferHeight;
const scale = aspect < 1 ? 1 / aspect : 1;
// compute inverse view matrix
const inverse = mat4.invert([], camera.view());
// variable names are choosen to reflect inverse of those used
// in mapScreenToPoint().
const pout = [glScaleX(xyCell[0]), glScaleY(xyCell[1])];
const x = (pout[0] - inverse[12]) / aspect / scale / inverse[14];
const y = (-pout[1] - inverse[13]) / scale / inverse[14];
const pin = [
Math.round(((x + 1) * (responsive.width - this.graphPaddingRight)) / 2),
Math.round(
-((y + 1) / 2 - 1) * (responsive.height - this.graphPaddingTop)
)
];
return pin;
}
handleBrushDragAction() {
/*
const s = d3.event.selection;
/*
event describing brush position:
@-------|
| |
| |
|-------@
*/
// ignore programatically generated events
if (d3.event.sourceEvent === null || !d3.event.selection) return;
const { dispatch } = this.props;
const s = d3.event.selection;
const brushCoords = {
northwest: this.mapScreenToPoint([s[0][0], s[0][1]]),
southeast: this.mapScreenToPoint([s[1][0], s[1][1]])
};
// compute inverse view matrix
const inverse = mat4.invert([], camera.view());
dispatch({
type: "graph brush change",
brushCoords
});
}
handleBrushStartAction() {
// Ignore programatically generated events.
if (!d3.event.sourceEvent) return;
const { dispatch } = this.props;
dispatch({ type: "graph brush start" });
}
handleBrushEndAction() {
// Ignore programatically generated events.
if (!d3.event.sourceEvent) return;
/*
coordinates will be included if selection made, null
if selection cleared.
*/
const { dispatch } = this.props;
const s = d3.event.selection;
if (s) {
const brushCoords = {
northwest: this.mapScreenToPoint(s[0]),
southeast: this.mapScreenToPoint(s[1])
// transform screen coordinates -> cell coordinates
const invert = pin => {
const x = (2 * pin[0]) / (responsive.height - this.graphPaddingTop) - 1;
const y =
2 * (1 - pin[1] / (responsive.height - this.graphPaddingTop)) - 1;
const pout = [
x * inverse[14] + inverse[12],
y * inverse[14] + inverse[13]
];
return [(pout[0] + 1) / 2, (pout[1] + 1) / 2];
};
const brushCoords = {
northwest: invert([s[0][0], s[0][1]]),
southeast: invert([s[1][0], s[1][1]])
};
dispatch({
type: "graph brush end",
type: "graph brush selection change",
brushCoords
});
} else {
dispatch({
type: "graph brush deselect"
});
}
}
handleBrushDeselectAction() {
const { dispatch } = this.props;
dispatch({
type: "graph brush deselect"
});
}
const { svg, brush } = this.state;
handleLassoStart() {
const { dispatch } = this.props;
dispatch({
type: "graph lasso start"
});
}
// when a lasso is completed, filter to the points within the lasso polygon
handleLassoEnd(polygon) {
const minimumPolygonArea = 10;
const { dispatch } = this.props;
if (
polygon.length < 3 ||
Math.abs(d3.polygonArea(polygon)) < minimumPolygonArea
) {
// if less than three points, or super small area, treat as a clear selection.
dispatch({ type: "graph lasso deselect" });
} else {
if (d3.event && !d3.event.selection) {
dispatch({
type: "graph lasso end",
polygon: polygon.map(xy => this.mapScreenToPoint(xy)) // transform the polygon
type: "graph brush deselect"
});
}
}
handleLassoCancel() {
const { dispatch } = this.props;
dispatch({ type: "graph lasso cancel" });
}
handleLassoDeselectAction() {
const { dispatch } = this.props;
dispatch({ type: "graph lasso deselect" });
}
handleDeselectAction() {
const { selectionTool } = this.props;
if (selectionTool === "brush") this.handleBrushDeselectAction();
if (selectionTool === "lasso") this.handleLassoDeselectAction();
if (!d3.event) {
/*
this line clears the brush procedurally, ie., zoom button clicked,
not a click away from brush on svg
*/
svg.select(".graph_brush").call(brush.move, null);
dispatch({
type: "graph brush deselect"
});
}
}
handleOpacityRangeChange(e) {
@@ -654,25 +327,118 @@ class Graph extends React.Component {
});
}
render() {
const { responsive, graphInteractionMode } = this.props;
resetInterface() {
const { dispatch } = this.props;
dispatch(actions.resetInterface());
}
render() {
const { dispatch, responsive, crossfilter } = this.props;
const { mode } = this.state;
return (
<div id="graphWrapper">
<div
style={{
zIndex: -9999,
position: "fixed",
top: this.graphPaddingTop,
right: 0
right: 0,
top: 0
}}
>
<div id="graphAttachPoint" />
<div
style={{
padding: 10,
display: "flex",
justifyContent: "flex-end",
alignItems: "baseline"
}}
>
<Tooltip
content="Show only metadata and cells which are currently selected"
position="left"
>
<AnchorButton
type="button"
disabled={
crossfilter &&
(crossfilter.countFiltered() === 0 ||
crossfilter.countFiltered() === crossfilter.size())
}
style={{ marginRight: 10 }}
onClick={() => {
dispatch(actions.regraph());
}}
>
subset to current selection
</AnchorButton>
</Tooltip>
<Tooltip
content="Reset cellxgene, clearing all selections"
position="left"
>
<AnchorButton
disabled={
false
/* world && universe ? worldEqUniverse(world, universe) : false */
}
type="button"
intent="warning"
style={{ marginRight: 10 }}
onClick={this.resetInterface.bind(this)}
>
reset
</AnchorButton>
</Tooltip>
<div>
<div className="bp3-button-group">
<Tooltip content="Lasso cells" position="left">
<Button
className="bp3-button bp3-icon-select"
type="button"
active={mode === "brush"}
onClick={() => {
this.setState({ mode: "brush" });
}}
/>
</Tooltip>
<Tooltip content="Pan and zoom" position="left">
<Button
type="button"
className="bp3-button bp3-icon-zoom-in"
active={mode === "zoom"}
onClick={() => {
this.handleBrushDeselectAction();
this.restartReglLoop();
this.setState({ mode: "zoom" });
}}
style={{
cursor: "pointer"
}}
/>
</Tooltip>
</div>
</div>
</div>
</div>
<div
style={{
marginRight: 50,
marginTop: 50,
zIndex: -9999,
position: "fixed",
right: this.graphPaddingRight,
bottom: this.graphPaddingBottom
}}
>
<div
style={{
display: mode === "brush" ? "inherit" : "none"
}}
id="graphAttachPoint"
/>
<div style={{ padding: 0, margin: 0 }}>
<canvas
width={responsive.width - this.graphPaddingRight}
width={responsive.height - this.graphPaddingTop}
height={responsive.height - this.graphPaddingTop}
data-testid="layout-graph"
ref={canvas => {
this.reglCanvas = canvas;
}}
@@ -1,34 +0,0 @@
import * as d3 from "d3";
import styles from "./graph.css";
export default (responsive, graphPaddingRight, xy, text, colorBy) => {
const containerWidth = responsive.width - graphPaddingRight;
const svg = d3
.select("#graphAttachPoint")
.append("svg")
.attr("id", "centroid-container")
.attr("data-testid", "centroid-overlay")
.attr("width", containerWidth)
.attr("height", responsive.height)
.attr("class", `${styles.graphSVG}`)
.style("z-index", 998)
.style("pointer-events", "none");
// TODO: Create own styles, ask Colin for an explanation on the css
// For now I'm going to put centroid z-index at 998 and lasso on 999
const label = svg
.append("g")
.attr("transform", `translate(${xy[0]}, ${xy[1]})`);
label
.append("text")
.attr("text-anchor", "middle")
.text(text)
.style("font-family", "Roboto Condensed")
.style("font-size", "18px")
.style("font-weight", "700")
.style("fill", colorBy ? "black" : "rgb(32, 178, 212)");
return svg;
};
-146
View File
@@ -1,146 +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("fill", "tomato")
.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;
+24 -46
View File
@@ -1,7 +1,6 @@
// jshint esversion: 6
import * as d3 from "d3";
import styles from "./graph.css";
import Lasso from "./setupLasso";
/******************************************
*******************************************
@@ -10,57 +9,36 @@ import Lasso from "./setupLasso";
******************************************/
export default (
selectionToolType,
handleStartAction,
handleDragAction,
handleEndAction,
handleCancelAction,
handleBrushSelectAction,
handleBrushDeselectAction,
responsive,
graphPaddingRight,
graphInteractionMode
graphPaddingTop
) => {
const side = responsive.height - graphPaddingTop;
const svg = d3
.select("#graphAttachPoint")
.append("svg")
.attr("id", "tool")
.attr("data-testid", "layout-overlay")
.attr("width", responsive.width - graphPaddingRight)
.attr("height", responsive.height)
.attr("class", `${styles.graphSVG}`)
.style("z-index", 999)
.style("display", graphInteractionMode === "select" ? "inherit" : "none");
.attr("width", side)
.attr("height", side)
.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.height - graphPaddingTop, responsive.height - graphPaddingTop]
])
.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,71 +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 ?? "",
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
scatterplotYYaccessor: state.controls.scatterplotYYaccessor
}))
class LeftSideBar extends React.Component {
render() {
const { datasetTitle } = this.props;
const paddingToAvoidScrollBar = 15;
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>
<span
data-testid="header"
style={{
fontSize: 14,
position: "relative",
marginLeft: 7,
top: -8
}}
>
{datasetTitle}
</span>
</div>
);
}
}
export default LeftSideBar;
@@ -1,15 +1,16 @@
// jshint esversion: 6
import _ from "lodash";
import React from "react";
import { connect } from "react-redux";
import Categorical from "../categorical/categorical";
import Continuous from "../continuous/continuous";
import GeneExpression from "../geneExpression";
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
}))
@@ -17,6 +18,7 @@ class LeftSideBar extends React.Component {
render() {
const {
responsive,
datasetTitle,
scatterplotXXaccessor,
scatterplotYYaccessor
} = this.props;
@@ -25,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
@@ -33,14 +36,26 @@ class LeftSideBar extends React.Component {
position: "fixed",
backgroundColor: "white",
/* x y blur spread color */
boxShadow: "-3px 0px 6px 2px rgba(153,153,153,0.4)"
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"
-130
View File
@@ -1,130 +0,0 @@
// jshint esversion: 6
import React from "react";
import {
Position,
Button,
Popover,
NumericInput,
Icon
} from "@blueprintjs/core";
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={{
marginLeft: 10
}}
>
<Popover
target={
<Button
type="button"
data-testid="visualization-settings"
className={`bp3-button bp3-icon-timeline-bar-chart ${activeClipClass}`}
style={{
cursor: "pointer"
}}
/>
}
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;
-464
View File
@@ -1,464 +0,0 @@
// jshint esversion: 6
import React from "react";
import { connect } from "react-redux";
import {
Button,
AnchorButton,
Tooltip,
Popover,
Position,
RadioGroup,
Radio
} 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";
@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
}))
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"
});
};
render() {
const {
dispatch,
differential,
crossfilter,
resettingInterface,
libraryVersions,
undoDisabled,
redoDisabled,
selectionTool,
clipPercentileMin,
clipPercentileMax,
layoutChoice,
graphInteractionMode
} = this.props;
const { pendingClipPercentiles } = this.state;
const haveBothCellSets =
!!differential.celllist1 && !!differential.celllist2;
// constants used to create selection tool button
let selectionTooltip;
let selectionButtonClass;
if (selectionTool === "brush") {
selectionTooltip = "Brush selection";
selectionButtonClass = "bp3-icon-select";
} else {
selectionTooltip = "Lasso selection";
selectionButtonClass = "bp3-icon-polygon-filter";
}
return (
<div
style={{
position: "fixed",
right: 8,
top: 8
}}
>
<div className="bp3-button-group" style={{ marginRight: 10 }}>
<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
disabled={!haveBothCellSets}
intent="primary"
data-testid="diffexp-button"
loading={differential.loading}
icon="left-join"
fill
type="button"
onClick={this.computeDiffExp}
>
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
intent="warning"
onClick={this.clearDifferentialExpression}
>
Clear Differential Expression
</Button>
</Tooltip>
) : null}
</div>
<Tooltip
content="Show only metadata and cells which are currently selected"
position="left"
>
<AnchorButton
type="button"
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" });
}}
>
subset to current selection
</AnchorButton>
</Tooltip>
<div className="bp3-button-group">
<Tooltip content={selectionTooltip} position="left">
<Button
type="button"
data-testid="mode-lasso"
className={`bp3-button ${selectionButtonClass}`}
active={graphInteractionMode === "select"}
onClick={() => {
dispatch({
type: "change graph interaction mode",
data: "select"
});
}}
style={{
cursor: "pointer"
}}
/>
</Tooltip>
<Tooltip content="Pan and zoom" position="left">
<Button
type="button"
data-testid="mode-pan-zoom"
className="bp3-button bp3-icon-zoom-in"
active={graphInteractionMode === "zoom"}
onClick={() => {
dispatch({
type: "change graph interaction mode",
data: "zoom"
});
}}
style={{
cursor: "pointer"
}}
/>
</Tooltip>
</div>
<div
className="bp3-button-group"
style={{
marginLeft: 10
}}
>
<Popover
target={
<Button
type="button"
data-testid="layout-choice"
className="bp3-button bp3-icon-heatmap"
style={{
cursor: "pointer"
}}
/>
}
position={Position.BOTTOM_RIGHT}
content={
<div
style={{
display: "flex",
justifyContent: "flex-start",
alignItems: "flex-start",
flexDirection: "column",
padding: 10
}}
>
<RadioGroup
label="Layout Choice"
onChange={this.handleLayoutChoiceChange}
selectedValue={layoutChoice.current}
>
{layoutChoice.available.map(name => (
<Radio label={name} value={name} key={name} />
))}
</RadioGroup>
</div>
}
/>
</div>
<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} />
</div>
);
}
}
export default MenuBar;
-61
View File
@@ -1,61 +0,0 @@
// jshint esversion: 6
import React from "react";
import { Button, Popover, Menu, MenuItem, Position } from "@blueprintjs/core";
function InformationMenu(props) {
const { libraryVersions } = props;
return (
<div style={{ marginLeft: 10 }} className="bp3-button-group">
<Popover
content={
<Menu>
<MenuItem
href="https://chanzuckerberg.github.io/cellxgene/faq.html"
target="_blank"
icon="help"
text="FAQ"
/>
<MenuItem
href="https://join-cellxgene-users.herokuapp.com/"
target="_blank"
icon="chat"
text="Chat"
/>
<MenuItem
href="https://chanzuckerberg.github.io/cellxgene/"
target="_blank"
icon="book"
text="Docs"
/>
<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,64 +0,0 @@
// jshint esversion: 6
import React from "react";
import { AnchorButton, Tooltip } from "@blueprintjs/core";
function InformationMenu(props) {
const {
resettingInterface,
undoDisabled,
redoDisabled,
resetInterface,
isResetDisabled,
dispatch
} = props;
return (
<div style={{ marginLeft: 10 }} className="bp3-button-group">
<Tooltip content="Undo" position="left">
<AnchorButton
type="button"
className="bp3-button bp3-icon-undo"
disabled={undoDisabled}
onClick={() => {
dispatch({ type: "@@undoable/undo" });
}}
style={{
cursor: "pointer"
}}
/>
</Tooltip>
<Tooltip content="Redo" position="left">
<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="left"
>
<AnchorButton
disabled={isResetDisabled()}
style={{ marginLeft: 10 }}
type="button"
loading={resettingInterface}
intent="none"
icon="refresh"
onClick={resetInterface}
data-testid="reset"
data-testclass={`resetting-${resettingInterface}`}
>
reset
</AnchorButton>
</Tooltip>
</div>
);
}
export default InformationMenu;
@@ -38,7 +38,14 @@ export default function(regl) {
uniforms: {
distance: regl.prop("distance"),
view: regl.prop("view"),
projection: () => mat4.perspective([], Math.PI / 2, 1, 0.01, 1000)
projection: (context, props) =>
mat4.perspective(
[],
Math.PI / 2,
(context.viewportWidth * props.scale) / context.viewportHeight,
0.01,
1000
)
},
count: regl.prop("count"),
+102 -137
View File
@@ -3,6 +3,7 @@
// 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";
@@ -18,30 +19,30 @@ import _drawPoints from "./drawPointsRegl";
import scaleLinear from "../../util/scaleLinear";
import { margin, width, height } from "./util";
import finiteExtent from "../../util/finiteExtent";
import { kvCache } from "../../util/stateManager";
@connect(state => {
const { world, crossfilter } = state;
const { scatterplotXXaccessor, scatterplotYYaccessor } = state.controls;
const {
world,
crossfilter,
scatterplotXXaccessor,
scatterplotYYaccessor
} = state.controls;
const expressionX =
world &&
scatterplotXXaccessor &&
world.varData.hasCol(scatterplotXXaccessor)
? world.varData.col(scatterplotXXaccessor).asArray()
world && scatterplotXXaccessor
? kvCache.get(world.varDataCache, scatterplotXXaccessor)
: null;
const expressionY =
world &&
scatterplotYYaccessor &&
world.varData.hasCol(scatterplotYYaccessor)
? world.varData.col(scatterplotYYaccessor).asArray()
world && scatterplotYYaccessor
? kvCache.get(world.varDataCache, scatterplotYYaccessor)
: null;
return {
world,
colorRGB: state.colors.rgb,
colorScale: state.colors.scale,
colorAccessor: state.colors.colorAccessor,
colorRGB: state.controls.colorRGB,
colorAccessor: state.controls.colorAccessor,
colorScale: state.controls.colorScale,
// Accessors are var/gene names (strings)
scatterplotXXaccessor,
@@ -53,7 +54,9 @@ import finiteExtent from "../../util/finiteExtent";
expressionX,
expressionY,
crossfilter
crossfilter,
// updated whenever the crossfilter selection is updated
selectionUpdate: _.get(state.controls, "crossfilter.updateTime", null)
};
})
class Scatterplot extends React.Component {
@@ -61,16 +64,11 @@ class Scatterplot extends React.Component {
super(props);
this.count = 0;
this.axes = false;
this.renderCache = {
positions: null,
colors: null,
sizes: null,
xScale: null,
yScale: null
};
this.state = {
svg: null,
minimized: null
minimized: null,
xScale: null,
yScale: null
};
}
@@ -82,9 +80,14 @@ class Scatterplot extends React.Component {
if (svg && expressionX && expressionY) {
scales = Scatterplot.setupScales(expressionX, expressionY);
this.drawAxesSVG(scales.xScale, scales.yScale, svg);
this.renderCache = { ...this.renderCache, ...scales };
}
this.setState({
svg,
xScale: scales ? scales.xScale : null,
yScale: scales ? scales.yScale : null
});
const camera = _camera(this.reglCanvas, { scale: true, rotate: false });
const regl = _regl(this.reglCanvas);
@@ -95,33 +98,43 @@ class Scatterplot extends React.Component {
const colorBuffer = regl.buffer();
const sizeBuffer = regl.buffer();
const reglRender = regl.frame(() => {
this.reglDraw(
regl,
drawPoints,
sizeBuffer,
colorBuffer,
pointBuffer,
camera
);
regl.frame(({ viewportWidth, viewportHeight }) => {
regl.clear({
depth: 1,
color: [1, 1, 1, 1]
});
drawPoints({
distance: camera.distance,
color: colorBuffer,
position: pointBuffer,
size: sizeBuffer,
count: this.count,
view: camera.view(),
scale: viewportHeight / viewportWidth
});
camera.tick();
});
this.reglRenderState = "rendering";
this.setState({
regl,
sizeBuffer,
pointBuffer,
colorBuffer,
svg,
reglRender,
camera,
drawPoints
colorBuffer
});
}
componentDidUpdate(prevProps) {
const {
svg,
xScale,
yScale,
regl,
pointBuffer,
colorBuffer,
sizeBuffer
} = this.state;
const {
world,
crossfilter,
@@ -131,30 +144,19 @@ class Scatterplot extends React.Component {
expressionY,
colorRGB
} = this.props;
const {
reglRender,
regl,
pointBuffer,
colorBuffer,
sizeBuffer,
svg,
drawPoints,
camera
} = this.state;
if (
scatterplotXXaccessor !== prevProps.scatterplotXXaccessor || // was CLU now FTH1 etc
world &&
svg &&
xScale &&
yScale &&
scatterplotXXaccessor &&
scatterplotYYaccessor &&
(scatterplotXXaccessor !== prevProps.scatterplotXXaccessor || // was CLU now FTH1 etc
scatterplotYYaccessor !== prevProps.scatterplotYYaccessor || // was CLU now FTH1 etc
world !== prevProps.world // shape or clip of world changed
!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 };
}
if (reglRender && this.reglRenderState === "rendering") {
reglRender.cancel();
this.reglRenderState = "paused";
this.drawAxesSVG(xScale, yScale, svg);
}
if (
@@ -166,73 +168,57 @@ class Scatterplot extends React.Component {
expressionX &&
expressionY &&
scatterplotXXaccessor &&
scatterplotYYaccessor
scatterplotYYaccessor &&
xScale &&
yScale
) {
const { renderCache } = this;
const { xScale, yScale } = this.renderCache;
const cellCount = expressionX.length;
const positionsBuf = new Float32Array(2 * cellCount);
const colorsBuf = new Float32Array(3 * cellCount);
const sizesBuf = new Float32Array(cellCount);
// Points change when expressionX or expressionY change.
if (
!renderCache.positions ||
expressionX !== prevProps.expressionX ||
expressionY !== prevProps.expressionY
) {
if (!renderCache.positions) {
renderCache.positions = new Float32Array(2 * cellCount);
}
const glScaleX = scaleLinear([0, width], [-0.95, 0.95]);
const glScaleY = scaleLinear([0, height], [-1, 1]);
for (let i = 0, { positions } = renderCache; i < cellCount; i += 1) {
positions[2 * i] = glScaleX(xScale(expressionX[i]));
positions[2 * i + 1] = glScaleY(yScale(expressionY[i]));
}
pointBuffer({ data: renderCache.positions, dimension: 2 });
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]));
}
// Colors for each point - change only when props.colorsRGB change.
if (!renderCache.colors || colorRGB !== prevProps.colorRGB) {
if (!renderCache.colors) {
renderCache.colors = new Float32Array(3 * cellCount);
}
for (let i = 0, { colors } = renderCache; i < cellCount; i += 1) {
colors.set(colorRGB[i], 3 * i);
}
colorBuffer({ data: renderCache.colors, dimension: 3 });
for (let i = 0; i < cellCount; i += 1) {
colorsBuf.set(colorRGB[i], 3 * i);
}
// Sizes for each point - updates are triggered only when selected
// obs change
if (!renderCache.sizes || crossfilter !== prevProps.crossfilter) {
if (!renderCache.sizes) {
renderCache.sizes = new Float32Array(cellCount);
}
crossfilter.fillByIsSelected(renderCache.sizes, 4, 0.2);
sizeBuffer({ data: renderCache.sizes, dimension: 1 });
}
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 (
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 {
@@ -241,37 +227,15 @@ class Scatterplot extends React.Component {
};
}
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.
@@ -344,12 +308,14 @@ class Scatterplot extends React.Component {
<Button
type="button"
minimal
data-testid="clear-scatterplot"
onClick={() =>
onClick={() => {
dispatch({
type: "clear scatterplot"
})
}
});
dispatch({
type: "reset colorscale"
});
}}
>
remove
</Button>
@@ -365,7 +331,6 @@ class Scatterplot extends React.Component {
<canvas
width={width}
height={height}
data-testid="scatterplot"
style={{
marginLeft: margin.left - 7,
marginTop: margin.top
@@ -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})`);
+142 -5
View File
@@ -1,4 +1,32 @@
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;
@@ -16,8 +44,8 @@ export const configDefaults = {
};
/* 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 = "rgb(211,211,211)";
@@ -29,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 */
@@ -73,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"
];
+115
View File
@@ -0,0 +1,115 @@
// jshint esversion: 6
import _ from "lodash";
import * as d3 from "d3";
import { interpolateViridis } from "d3-scale-chromatic";
import * as globals from "../globals";
import parseRGB from "../util/parseRGB";
/*
https://medium.com/@jacobp100/you-arent-using-redux-middleware-enough-94ffe991e6
storeInstance =>
functionToCallWithAnActionThatWillSendItToTheNextMiddleware =>
actionThatDispatchWasCalledWith =>
valueToUseAsTheReturnValueOfTheDispatchCall
*/
/*
What this file does:
1. fire a filter action anywhere in the app
2. ** this middleware checks to see the state of all the currently selected filters,
including the new one
3. ** create updated selection from a copy of all the cells presently on the client
(this may be a subset of 'all')
4. ** append that new selection to the action so that it magically appears in the reducer
just because the action was fired
This is nice because we keep a lot of filtering business logic centralized
(what it means in practice to be selected)
*/
const updateCellColorsMiddleware = store => next => action => {
const s = store.getState();
/*
this is a hardcoded map of the things we need to keep an eye on and update
global cell selection in response to
*/
const filterJustChanged =
action.type === "color by expression" ||
action.type === "color by continuous metadata" ||
action.type === "color by categorical metadata";
if (!filterJustChanged || !s.controls.world.obsAnnotations) {
return next(
action
); /* if the cells haven't loaded or the action wasn't a color change, bail */
}
const { obsAnnotations } = s.controls.world;
let colorScale;
const colorsByName = new Array(obsAnnotations.length);
const colorsByRGB = new Array(obsAnnotations.length);
/*
in plain language...
(a) once the cells have loaded.
(b) each time a user changes a color control we need to update cellsMetadata colors
This is available to all the draw functions as world.colorName[index] or world.colorRGB[index]
*/
if (action.type === "color by categorical metadata") {
colorScale = d3.scaleOrdinal().range(globals.ordinalColors);
for (let i = 0; i < obsAnnotations.length; i += 1) {
const obs = obsAnnotations[i];
const c = colorScale(obs[action.colorAccessor]);
colorsByName[i] = c;
colorsByRGB[i] = parseRGB(c);
}
}
if (action.type === "color by continuous metadata") {
colorScale = d3
.scaleLinear()
.domain([0, action.rangeMaxForColorAccessor])
.range([1, 0]);
for (let i = 0; i < obsAnnotations.length; i += 1) {
const obs = obsAnnotations[i];
const c = interpolateViridis(colorScale(obs[action.colorAccessor]));
colorsByName[i] = c;
colorsByRGB[i] = parseRGB(c);
}
}
if (action.type === "color by expression") {
const { gene, data } = action;
const expression = data[gene]; // Float32Array
colorScale = d3
.scaleLinear()
.domain([_.min(expression), _.max(expression)])
.range([
1,
0
]); /* invert viridis... probably pass this scale through to others */
for (let i = 0, len = expression.length; i < len; i += 1) {
const c = interpolateViridis(colorScale(expression[i]));
colorsByName[i] = c;
colorsByRGB[i] = parseRGB(c);
}
}
/*
append the result of all the filters to the action the user just triggered
*/
const modifiedAction = Object.assign({}, action, {
colors: { name: colorsByName, rgb: colorsByRGB },
colorScale
});
return next(modifiedAction);
};
export default updateCellColorsMiddleware;
@@ -0,0 +1,87 @@
// jshint esversion: 6
// import uri from "urijs";
/*
NOTE: file currently not used, but retained as we expect to reinstate features in this
area shortly.
*/
/*
https://medium.com/@jacobp100/you-arent-using-redux-middleware-enough-94ffe991e6
storeInstance
=> functionToCallWithAnActionThatWillSendItToTheNextMiddleware
=> actionThatDispatchWasCalledWith
=> valueToUseAsTheReturnValueOfTheDispatchCall
*/
const updateURLMiddleware = (/* store */) => next => action => {
// const oldState = store.getState();
const nextAction = next(action);
if (action.type === "url changed") {
/* we don't handle pop state here - we handle it in the url reducer */
return nextAction;
}
// const state = store.getState();
/************************************************************************
*************************************************************************
1. Redux app state just changed. Clear URL, and then update it.
1a. We get the whole state tree to construct the url!
1b. But (see reducers/url.js) we try to centralize it because...
1c. ...the back button / initial load case ('url changed' return above)
means that we have to listen for 'url changed' and construct state
from the browser
*************************************************************************
************************************************************************/
// const oldURI = URI(window.location.href)
// const newURI = URI(oldURI).setQuery({})
// if (window.location.search === "") {
// newURL = uri.addQuery(category, value).toString(); /* #1 */
// } else if (uri.hasQuery(category, value) || uri.hasQuery(category, value, true)) { /* true param here means check arrays as well http://medialize.github.io/URI.js/docs.html#search-has */
// newURL = uri.removeQuery(category, value).toString(); /* #4 */
// } else {
// newURL = uri.addQuery(category, value).toString(); /* #2 & #3 are handled by URI */
// }
//
// window.history.pushState("", "", newURL)
//
// // Internal helper for working with URIs
// const oldURI = new URI(window.location.href);
// const newURI = new URI(oldURI).setQueryData({});
//
// newURI.setPath('/foo/bar');
//
// // Set the path based on state
// if (!state.isOnLandingPage && state.project.id) {
// newURI.setPath(newURI.getPath() + state.project.id + '/');
// newURI.addQueryData('baz', state.mode);
// newURI.addQueryData('bat', state.selection.activePageID);
// } else {
// newURI.setPath(newURI.getPath() + state.landingSection + '/');
// }
//
// // Avoid URL thrashing by replacing state while loading instead of pushing
// const newPath = newURI.toString();
// const oldPath = oldURI.toString();
// if (newPath !== oldPath) {
// if (
// (oldState.mode === 'asdf' &&
// state.mode === 'asdf' &&
// !oldState.isOnLandingPage) ||
// oldState.isLoadingProject !== state.isLoadingProject
// ) {
// window.history.replaceState(null, null, newPath);
// } else {
// window.history.pushState(null, null, newPath);
// }
// }
return nextAction;
};
export default updateURLMiddleware;
-44
View File
@@ -1,44 +0,0 @@
export default function cascadeReducers(arg) {
/*
Combined a set of cascading reducers into a single reducer. Cascading
reducers are reducers which may rely on state computed by another reducer.
Therefore, they:
- must be composed in a particular order (currently, this is a simple
linear list of reducers, run in list order)
- must have access to partially updated "next state" so they can further
derive state.
Parameter is one of:
- a Map object
- an array of tuples, [ [key1, reducer1], [key2, reducer2], ... ]
Ie, cascadeReducers([ ["a", reduceA], ["b", reduceB] ])
Each reducer will be called with the sigature:
(prevState, action, sharedNextState, sharedPrevState) => newState
cascadeReducers will build a composite newState object, much
like combinedReducers. Additional semantics:
- reducers guaranteed to be called in order
- each reducer will receive shared objects
*/
const reducers = arg instanceof Map ? arg : new Map(arg);
const reducerKeys = [...reducers.keys()];
return (prevState, action) => {
const nextState = {};
let stateChange = false;
for (let i = 0, l = reducerKeys.length; i < l; i += 1) {
const key = reducerKeys[i];
const reducer = reducers.get(key);
const prevStateForKey = prevState ? prevState[key] : undefined;
const nextStateForKey = reducer(
prevStateForKey,
action,
nextState,
prevState
);
nextState[key] = nextStateForKey;
stateChange = stateChange || nextStateForKey !== prevStateForKey;
}
return stateChange ? nextState : prevState;
};
}
-105
View File
@@ -1,105 +0,0 @@
import { ControlsHelpers } from "../util/stateManager";
import * as globals from "../globals";
function maxCategoryItems(state) {
return (
state.config.parameters?.["max-category-items"] ??
globals.configDefaults.parameters["max-category-items"]
);
}
const CategoricalSelection = (
state,
action,
nextSharedState,
prevSharedState
) => {
switch (action.type) {
case "initial data load complete (universe exists)":
case "set World to current selection":
case "reset World to eq Universe":
case "set clip quantiles": {
const { world } = nextSharedState;
return ControlsHelpers.createCategoricalSelection(
maxCategoryItems(prevSharedState),
world
);
}
case "categorical metadata filter select": {
/*
Set the specific category in this field to false
*/
const newCategoryValueSelected = Array.from(
state[action.metadataField].categoryValueSelected
);
newCategoryValueSelected[action.categoryIndex] = true;
const newCategoricalSelection = {
...state,
[action.metadataField]: {
...state[action.metadataField],
categoryValueSelected: newCategoryValueSelected
}
};
return newCategoricalSelection;
}
case "categorical metadata filter deselect": {
/*
Set the specific category in this field to false
*/
const newCategoryValueSelected = Array.from(
state[action.metadataField].categoryValueSelected
);
newCategoryValueSelected[action.categoryIndex] = false;
const newCategoricalSelection = {
...state,
[action.metadataField]: {
...state[action.metadataField],
categoryValueSelected: newCategoryValueSelected
}
};
return newCategoricalSelection;
}
case "categorical metadata filter none of these": {
/*
set all categories in this field to false.
*/
const newCategoricalSelection = {
...state,
[action.metadataField]: {
...state[action.metadataField],
categorySelected: false,
categoryValueSelected: Array.from(
state[action.metadataField].categoryValueSelected
).fill(false)
}
};
return newCategoricalSelection;
}
case "categorical metadata filter all of these": {
/*
set all categories in this field to true.
*/
const newCategoricalSelection = {
...state,
[action.metadataField]: {
...state[action.metadataField],
categorySelected: true,
categoryValueSelected: Array.from(
state[action.metadataField].categoryValueSelected
).fill(true)
}
};
return newCategoricalSelection;
}
default: {
return state;
}
}
};
export default CategoricalSelection;
-44
View File
@@ -1,44 +0,0 @@
import calcCentroid from "../util/centroid";
const initialState = {
metadataField: "",
categoryIndex: -1,
categoryField: "",
centroidXY: [-1, -1]
};
const CentroidLabel = (state = initialState, action, sharedNextState) => {
const { categoricalSelection, world, layoutChoice } = sharedNextState;
const { metadataField, categoryIndex } = action;
const categoryField =
categoricalSelection?.[metadataField]?.categoryValues[categoryIndex];
switch (action.type) {
case "category value mouse hover start":
return {
...state,
metadataField,
categoryIndex,
categoryField,
centroidXY: null /* calcCentroid( This function call is computationally heavy and also leading to large GC. Before reimplementation, look into optimization and memoization
world,
metadataField,
categoryField,
layoutChoice.currentDimNames
) */
};
case "category value mouse hover end":
if (
metadataField === state.metadataField &&
categoryIndex === state.categoryIndex
) {
return initialState;
}
return state;
default:
return state;
}
};
export default CentroidLabel;
-130
View File
@@ -1,130 +0,0 @@
import { ColorHelpers } from "../util/stateManager";
const ColorsReducer = (
state = {
colorMode: null,
colorAccessor: null,
rgb: null,
scale: null
},
action,
nextSharedState,
prevSharedState
) => {
switch (action.type) {
case "initial data load complete (universe exists)":
case "reset World to eq Universe": {
const { world } = nextSharedState;
const colorMode = null;
const colorAccessor = null;
const { rgb, scale } = ColorHelpers.createColors(world, colorMode);
return {
...state,
colorAccessor,
colorMode,
rgb,
scale
};
}
case "set clip quantiles":
case "set World to current selection": {
const { world: prevWorld, controls: prevControls } = prevSharedState;
const resetColorState = ColorHelpers.checkIfColorByDiffexpAndResetColors(
prevControls,
state,
prevWorld
);
if (resetColorState) {
return resetColorState;
}
const { colorMode, colorAccessor } = state;
const { world } = nextSharedState;
const { rgb, scale } = ColorHelpers.createColors(
world,
colorMode,
colorAccessor
);
return {
...state,
rgb,
scale
};
}
case "reset colorscale": {
return {
...state,
...ColorHelpers.resetColors(prevSharedState.world)
};
}
case "color by categorical metadata":
case "color by continuous metadata": {
const { world } = prevSharedState;
/* toggle between this mode and reset */
const resetCurrent =
action.type === state.colorMode &&
action.colorAccessor === state.colorAccessor;
const colorMode = !resetCurrent ? action.type : null;
const colorAccessor = !resetCurrent ? action.colorAccessor : null;
const { rgb, scale } = ColorHelpers.createColors(
world,
colorMode,
colorAccessor
);
return {
...state,
colorMode,
colorAccessor,
rgb,
scale
};
}
case "color by expression": {
const { world } = prevSharedState;
/* toggle between this mode and reset */
const resetCurrent =
action.type === state.colorMode && action.gene === state.colorAccessor;
const colorMode = !resetCurrent ? action.type : null;
const colorAccessor = !resetCurrent ? action.gene : null;
const { rgb, scale } = ColorHelpers.createColors(
world,
colorMode,
colorAccessor
);
return {
...state,
colorMode,
colorAccessor,
rgb,
scale
};
}
case "clear differential expression": {
const { world: prevWorld, controls: prevControls } = prevSharedState;
const resetColorState = ColorHelpers.checkIfColorByDiffexpAndResetColors(
prevControls,
state,
prevWorld
);
if (resetColorState) {
return resetColorState;
}
return state;
}
default: {
return state;
}
}
};
export default ColorsReducer;
@@ -1,35 +0,0 @@
import { makeContinuousDimensionName } from "../util/nameCreators";
const ContinuousSelection = (state = {}, action) => {
switch (action.type) {
case "reset World to eq Universe":
case "set clip quantiles": {
return {};
}
case "continuous metadata histogram start":
case "continuous metadata histogram brush":
case "continuous metadata histogram end": {
const name = makeContinuousDimensionName(
action.continuousNamespace,
action.selection
);
return {
...state,
[name]: action.range
};
}
case "continuous metadata histogram cancel": {
const name = makeContinuousDimensionName(
action.continuousNamespace,
action.selection
);
const { [name]: deletedField, ...newState } = state;
return newState;
}
default: {
return state;
}
}
};
export default ContinuousSelection;
+407 -51
View File
@@ -1,8 +1,31 @@
// jshint esversion: 6
import _ from "lodash";
import { World, kvCache } from "../util/stateManager";
import parseRGB from "../util/parseRGB";
import Crossfilter from "../util/typedCrossfilter";
import * as globals from "../globals";
import {
layoutDimensionName,
obsAnnoDimensionName,
userDefinedDimensionName,
diffexpDimensionName,
makeContinuousDimensionName
} from "../util/nameCreators";
import { WorldUtil } from "../util/stateManager";
function createCategoricalAsBooleansMap(world) {
const res = {};
_.each(world.summary.obs, (value, key) => {
if (value.options && key !== "name") {
const optionsAsBooleans = {};
_.each(value.options, (_value, _key) => {
optionsAsBooleans[_key] = true;
});
res[key] = optionsAsBooleans;
}
});
return res;
}
const Controls = (
state = {
@@ -10,23 +33,31 @@ const Controls = (
loading: false,
error: null,
universe: null,
// all of the data + selection state
world: null,
colorName: null,
colorRGB: null,
categoricalAsBooleansMap: null,
crossfilter: null,
dimensionMap: null,
userDefinedGenes: [],
userDefinedGenesLoading: false,
diffexpGenes: [],
resettingInterface: false,
graphInteractionMode: "select",
colorAccessor: null,
colorScale: null,
opacityForDeselectedCells: 0.2,
graphBrushSelection: null,
continuousSelection: null,
scatterplotXXaccessor: null, // just easier to read
scatterplotYYaccessor: null,
graphRenderCounter: 0 /* integer as <Component key={graphRenderCounter} - a change in key forces a remount */,
axesHaveBeenDrawn: false,
__storedStateForCelllist1__: null /* will need procedural control of brush ie., brush.extent https://bl.ocks.org/micahstubbs/3cda05ca68cba260cb81 */,
__storedStateForCelllist2__: null
},
action,
nextSharedState,
prevSharedState
action
) => {
/*
For now, log anything looking like an error to the console.
@@ -43,89 +74,288 @@ const Controls = (
case "initial data load start": {
return { ...state, loading: true };
}
case "initial data load complete (universe exists)": {
case "initial data load complete (universe exists)":
case "reset World to eq Universe": {
const { userDefinedGenes, diffexpGenes } = state;
/* first light - create world & other data-driven defaults */
WorldUtil.clearCaches();
const { universe } = action;
const world = World.createWorldFromEntireUniverse(universe);
const colorName = new Array(universe.nObs).fill(globals.defaultCellColor);
const colorRGB = _.map(colorName, c => parseRGB(c));
const categoricalAsBooleansMap = createCategoricalAsBooleansMap(world);
const crossfilter = Crossfilter(world.obsAnnotations);
const dimensionMap = World.createObsDimensionMap(crossfilter, world);
const worldVarDataCache = world.varDataCache;
// dimensionMap = {
// layout_X: dim-for-X,
// obsAnno_name: dim for an annotation,
// varData_userDefined_genename: dim for user defined expression,
// varData_diffexp_genename: dim for diff-exp added gene expression
// }
/* var dimensions */
if (userDefinedGenes.length > 0) {
/*
verbose & slightly confusing that we also access this as an object
in controls rather than an array, should be abstracted into
util ie., createDimensionsFromBothListsOfGenes(userGenes, diffExp)
*/
_.forEach(userDefinedGenes, gene => {
dimensionMap[
userDefinedDimensionName(gene)
] = World.createVarDimension(
/* "__var__" + */
world,
worldVarDataCache,
crossfilter,
gene
);
});
}
if (diffexpGenes.length > 0) {
_.forEach(diffexpGenes, gene => {
dimensionMap[diffexpDimensionName(gene)] = World.createVarDimension(
/* "__var__" + */
world,
worldVarDataCache,
crossfilter,
gene
);
});
}
return {
...state,
loading: false,
error: null,
resettingInterface: false
};
}
case "reset World to eq Universe": {
WorldUtil.clearCaches();
return {
...state,
resettingInterface: false
universe,
world,
colorName,
colorRGB,
categoricalAsBooleansMap,
crossfilter,
dimensionMap,
colorAccessor: null
};
}
case "set World to current selection": {
WorldUtil.clearCaches();
const { userDefinedGenes, diffexpGenes } = state;
/* Set viewable world to be the currently selected data */
const world = World.createWorldFromCurrentSelection(
action.universe,
action.world,
action.crossfilter
);
const colorName = new Array(world.nObs).fill(globals.defaultCellColor);
const colorRGB = _.map(colorName, c => parseRGB(c));
const categoricalAsBooleansMap = createCategoricalAsBooleansMap(world);
const crossfilter = Crossfilter(world.obsAnnotations);
const dimensionMap = World.createObsDimensionMap(crossfilter, world);
const worldVarDataCache = world.varDataCache;
/* var dimensions */
if (userDefinedGenes.length > 0) {
/*
verbose & slightly confusing that we also access this as an object
in controls rather than an array, should be abstracted into
util ie., createDimensionsFromBothListsOfGenes(userGenes, diffExp)
*/
_.forEach(userDefinedGenes, gene => {
dimensionMap[
userDefinedDimensionName(gene)
] = World.createVarDimension(
/* "__var__" + */
world,
worldVarDataCache,
crossfilter,
gene
);
});
}
if (diffexpGenes.length > 0) {
_.forEach(diffexpGenes, gene => {
dimensionMap[diffexpDimensionName(gene)] = World.createVarDimension(
/* "__var__" + */
world,
worldVarDataCache,
crossfilter,
gene
);
});
}
return {
...state,
loading: false,
error: null
error: null,
world,
colorName,
colorRGB,
categoricalAsBooleansMap,
crossfilter,
dimensionMap,
colorAccessor: null
};
}
case "request user defined gene started": {
case "expression load success": {
const { world, universe } = state;
let universeVarDataCache = universe.varDataCache;
let worldVarDataCache = world.varDataCache;
_.forEach(action.expressionData, (val, key) => {
universeVarDataCache = kvCache.set(universeVarDataCache, key, val);
if (kvCache.get(worldVarDataCache, key) === undefined) {
worldVarDataCache = kvCache.set(
worldVarDataCache,
key,
World.subsetVarData(world, universe, val)
);
}
});
return {
...state,
userDefinedGenesLoading: true
};
}
case "request user defined gene error": {
return {
...state,
userDefinedGenesLoading: false
universe: {
...universe,
varDataCache: universeVarDataCache
},
world: {
...world,
varDataCache: worldVarDataCache
}
};
}
case "request user defined gene success": {
const { userDefinedGenes } = state;
const _userDefinedGenes = _.uniq(
userDefinedGenes.concat(action.data.genes)
const { world, crossfilter, dimensionMap, userDefinedGenes } = state;
const worldVarDataCache = world.varDataCache;
const _userDefinedGenes = userDefinedGenes.slice();
const gene = action.data.genes[0];
dimensionMap[userDefinedDimensionName(gene)] = World.createVarDimension(
/* "__var__" + */
world,
worldVarDataCache,
crossfilter,
gene
);
return {
...state,
userDefinedGenes: _userDefinedGenes,
userDefinedGenesLoading: false
dimensionMap,
userDefinedGenes: _userDefinedGenes
};
}
case "request differential expression success": {
const { world } = prevSharedState;
const varIndexName = world.schema.annotations.var.index;
const { world, crossfilter, dimensionMap } = state;
const worldVarDataCache = world.varDataCache;
const _diffexpGenes = [];
action.data.forEach(d => {
_diffexpGenes.push(world.varAnnotations.at(d[0], varIndexName));
_diffexpGenes.push(world.varAnnotations[d[0]].name);
});
_.forEach(_diffexpGenes, gene => {
dimensionMap[diffexpDimensionName(gene)] = World.createVarDimension(
/* "__var__" + */
world,
worldVarDataCache,
crossfilter,
gene
);
});
return {
...state,
dimensionMap,
diffexpGenes: _diffexpGenes
};
}
case "clear differential expression": {
const { world, universe, dimensionMap } = state;
const _dimensionMap = dimensionMap;
const universeVarDataCache = universe.varDataCache;
const worldVarDataCache = world.varDataCache;
_.forEach(action.diffExp, values => {
const { name } = world.varAnnotations[values[0]];
// clean up crossfilter dimensions
const dimension = dimensionMap[diffexpDimensionName(name)];
dimension.dispose();
delete dimensionMap[diffexpDimensionName(name)];
});
return {
...state,
diffexpGenes: []
dimensionMap: _dimensionMap,
diffexpGenes: [],
universe: {
...universe,
varDataCache: universeVarDataCache
},
world: {
...world,
varDataCache: worldVarDataCache
}
};
}
case "clear user defined gene": {
const { userDefinedGenes } = state;
const newUserDefinedGenes = _.filter(
userDefinedGenes,
d => d !== action.data
);
case "user defined gene": {
/*
this could also live in expression success with a conditional,
but that handles diffexp also
*/
const newUserDefinedGenes = state.userDefinedGenes.slice();
newUserDefinedGenes.push(action.data);
return {
...state,
userDefinedGenes: newUserDefinedGenes
};
}
case "clear all user defined genes": {
case "clear user defined gene": {
const { userDefinedGenes, dimensionMap } = state;
const newUserDefinedGenes = _.filter(
userDefinedGenes,
d => d !== action.data
);
const dimension = dimensionMap[userDefinedDimensionName(action.data)];
dimension.dispose();
delete dimensionMap[userDefinedDimensionName(action.data)];
return {
...state,
dimensionMap,
userDefinedGenes: newUserDefinedGenes
};
}
case "clear all user defined genes": {
const { userDefinedGenes, dimensionMap } = state;
_.forEach(userDefinedGenes, gene => {
const dimension = dimensionMap[userDefinedDimensionName(gene)];
dimension.dispose();
delete dimensionMap[userDefinedDimensionName(gene)];
});
return {
...state,
dimensionMap,
userDefinedGenes: []
};
}
case "reset colorscale": {
const { world } = state;
const colorName = new Array(world.nObs).fill(globals.defaultCellColor);
const colorRGB = _.map(colorName, c => parseRGB(c));
return {
...state,
colorName,
colorRGB,
colorAccessor: null
};
}
case "expression load error":
case "initial data load error": {
return {
@@ -138,27 +368,153 @@ const Controls = (
/*******************************
User Events
*******************************/
case "change graph interaction mode":
case "graph brush selection change": {
state.dimensionMap[layoutDimensionName("X")].filterRange([
action.brushCoords.northwest[0],
action.brushCoords.southeast[0]
]);
state.dimensionMap[layoutDimensionName("Y")].filterRange([
action.brushCoords.southeast[1],
action.brushCoords.northwest[1]
]);
return {
...state,
graphInteractionMode: action.data
graphBrushSelection: action.brushCoords
};
}
case "graph brush deselect": {
state.dimensionMap[layoutDimensionName("X")].filterAll();
state.dimensionMap[layoutDimensionName("Y")].filterAll();
return {
...state,
graphBrushSelection: null
};
}
case "continuous metadata histogram brush": {
const name = makeContinuousDimensionName(
action.continuousNamespace,
action.selection
);
// action.selection: metadata name being selected
// action.range: filter range, or null if deselected
if (!action.range) {
state.dimensionMap[name].filterAll();
} else {
state.dimensionMap[name].filterRange(action.range);
}
return { ...state };
}
case "change opacity deselected cells in 2d graph background":
return {
...state,
opacityForDeselectedCells: action.data
};
case "increment graph render counter": {
const c = state.graphRenderCounter + 1;
/*******************************
Categorical metadata
*******************************/
case "categorical metadata filter select": {
const newCategoricalAsBooleansMap = {
...state.categoricalAsBooleansMap,
[action.metadataField]: {
...state.categoricalAsBooleansMap[action.metadataField],
[action.value]: true
}
};
// update the filter for the one category that changed state
state.dimensionMap[obsAnnoDimensionName(action.metadataField)].filterEnum(
_.filter(
_.map(
newCategoricalAsBooleansMap[action.metadataField],
(val, key) => (val ? key : false)
)
)
);
return {
...state,
graphRenderCounter: c
categoricalAsBooleansMap: newCategoricalAsBooleansMap
};
}
case "interface reset started": {
case "categorical metadata filter deselect": {
const newCategoricalAsBooleansMap = {
...state.categoricalAsBooleansMap,
[action.metadataField]: {
...state.categoricalAsBooleansMap[action.metadataField],
[action.value]: false
}
};
// update the filter for the one category that changed state
state.dimensionMap[obsAnnoDimensionName(action.metadataField)].filterEnum(
_.filter(
_.map(
newCategoricalAsBooleansMap[action.metadataField],
(val, key) => (val ? key : false)
)
)
);
return {
...state,
resettingInterface: true
categoricalAsBooleansMap: newCategoricalAsBooleansMap
};
}
case "categorical metadata filter none of these": {
const newCategoricalAsBooleansMap = {
...state.categoricalAsBooleansMap
};
_.forEach(
newCategoricalAsBooleansMap[action.metadataField],
(v, k, c) => {
c[k] = false;
}
);
state.dimensionMap[
obsAnnoDimensionName(action.metadataField)
].filterNone();
return {
...state,
categoricalAsBooleansMap: newCategoricalAsBooleansMap
};
}
case "categorical metadata filter all of these": {
const newCategoricalAsBooleansMap = {
...state.categoricalAsBooleansMap
};
_.forEach(
newCategoricalAsBooleansMap[action.metadataField],
(v, k, c) => {
c[k] = true;
}
);
state.dimensionMap[
obsAnnoDimensionName(action.metadataField)
].filterAll();
return {
...state,
categoricalAsBooleansMap: newCategoricalAsBooleansMap
};
}
/*******************************
Color Scale
*******************************/
case "color by categorical metadata":
case "color by continuous metadata": {
return {
...state,
colorName: action.colors.name,
colorRGB: action.colors.rgb,
colorAccessor: action.colorAccessor,
colorScale: action.colorScale
};
}
case "color by expression": {
return {
...state,
colorName: action.colors.name,
colorRGB: action.colors.rgb,
colorAccessor: action.gene,
colorScale: action.colorScale
};
}
-215
View File
@@ -1,215 +0,0 @@
import _ from "lodash";
import Crossfilter from "../util/typedCrossfilter";
import { World, ControlsHelpers } from "../util/stateManager";
import {
layoutDimensionName,
obsAnnoDimensionName,
userDefinedDimensionName,
diffexpDimensionName,
makeContinuousDimensionName
} from "../util/nameCreators";
const XYDimName = layoutDimensionName("XY");
const CrossfilterReducer = (
state = null,
action,
nextSharedState,
prevSharedState
) => {
switch (action.type) {
case "initial data load complete (universe exists)": {
const { world, layoutChoice } = nextSharedState;
const crossfilter = World.createObsDimensions(
new Crossfilter(world.obsAnnotations),
world,
layoutChoice.currentDimNames
);
return crossfilter;
}
case "reset World to eq Universe": {
const { userDefinedGenes, diffexpGenes } = prevSharedState.controls;
const { world } = nextSharedState;
const crossfilter = ControlsHelpers.createGeneDimensions(
userDefinedGenes,
diffexpGenes,
world,
prevSharedState.resetCache.crossfilter
);
return crossfilter;
}
case "set clip quantiles":
case "set World to current selection": {
const { userDefinedGenes, diffexpGenes } = prevSharedState.controls;
const { world, layoutChoice } = nextSharedState;
let crossfilter = new Crossfilter(world.obsAnnotations);
crossfilter = World.createObsDimensions(
crossfilter,
world,
layoutChoice.currentDimNames
);
crossfilter = ControlsHelpers.createGeneDimensions(
userDefinedGenes,
diffexpGenes,
world,
crossfilter
);
return crossfilter;
}
case "set layout choice": {
/*
when switching layouts:
- delete the existing XY index
- add the new XY index (which implicitly selects all on it)
*/
const { world, layoutChoice } = nextSharedState;
return state
.delDimension(layoutDimensionName("XY"))
.addDimension(
layoutDimensionName("XY"),
"spatial",
world.obsLayout.col(layoutChoice.currentDimNames[0]).asArray(),
world.obsLayout.col(layoutChoice.currentDimNames[1]).asArray()
);
}
case "request user defined gene success": {
const { world } = prevSharedState;
const gene = action.data.genes[0];
return state.addDimension(
userDefinedDimensionName(gene),
"scalar",
world.varData.col(gene).asArray(),
Float32Array
);
}
case "request differential expression success": {
const { world } = prevSharedState;
const varIndexName = world.schema.annotations.var.index;
const genes = _.map(action.data, d =>
world.varAnnotations.at(d[0], varIndexName)
);
const crossfilter = _.reduce(
genes,
(xfltr, gene) =>
xfltr.addDimension(
diffexpDimensionName(gene),
"scalar",
world.varData.col(gene).asArray(),
Float32Array
),
state
);
return crossfilter;
}
case "clear differential expression": {
const { world } = prevSharedState;
const varIndexName = world.schema.annotations.var.index;
const crossfilter = _.reduce(
action.diffExp,
(xfltr, values) => {
const name = world.varAnnotations.at(values[0], varIndexName);
return xfltr.delDimension(diffexpDimensionName(name));
},
state
);
return crossfilter;
}
case "clear user defined gene": {
return state.delDimension(userDefinedDimensionName(action.data));
}
case "clear all user defined genes": {
const { userDefinedGenes } = prevSharedState.controls;
const crossfilter = _.reduce(
userDefinedGenes,
(xfltr, gene) => xfltr.delDimension(userDefinedDimensionName(gene)),
state
);
return crossfilter;
}
case "graph brush end":
case "graph brush change": {
const [minX, maxY] = action.brushCoords.northwest;
const [maxX, minY] = action.brushCoords.southeast;
return state.select(XYDimName, {
mode: "within-rect",
minX,
minY,
maxX,
maxY
});
}
case "graph lasso end": {
const { polygon } = action;
return state.select(XYDimName, {
mode: "within-polygon",
polygon
});
}
case "graph lasso cancel":
case "graph brush cancel":
case "graph lasso deselect":
case "graph brush deselect": {
return state.select(XYDimName, { mode: "all" });
}
case "continuous metadata histogram start":
case "continuous metadata histogram brush":
case "continuous metadata histogram cancel":
case "continuous metadata histogram end": {
const name = makeContinuousDimensionName(
action.continuousNamespace,
action.selection
);
// action.selection: metadata name being selected
// action.range: filter range, or null if deselected
if (!action.range) {
return state.select(name, { mode: "all" });
}
const [lo, hi] = action.range;
const newState = state.select(name, { mode: "range", lo, hi });
return newState;
}
case "categorical metadata filter select":
case "categorical metadata filter deselect": {
const { categoricalSelection } = nextSharedState;
const { world } = prevSharedState;
const cat = categoricalSelection[action.metadataField];
const col = world.obsAnnotations.col(action.metadataField);
return state.select(obsAnnoDimensionName(action.metadataField), {
mode: "exact",
values: ControlsHelpers.selectedValuesForCategory(cat, col)
});
}
case "categorical metadata filter none of these": {
return state.select(obsAnnoDimensionName(action.metadataField), {
mode: "none"
});
}
case "categorical metadata filter all of these": {
return state.select(obsAnnoDimensionName(action.metadataField), {
mode: "all"
});
}
default: {
return state;
}
}
};
export default CrossfilterReducer;
-61
View File
@@ -1,61 +0,0 @@
const GraphSelection = (
state = {
tool: "lasso", // what selection tool mode (lasso, brush, ...)
selection: { mode: "all" } // current selection, which is tool specific
},
action
) => {
switch (action.type) {
case "set clip quantiles":
case "reset World to eq Universe":
case "set layout choice": {
return {
...state,
selection: {
mode: "all"
}
};
}
case "graph brush end":
case "graph brush change": {
const { brushCoords } = action;
return {
...state,
selection: {
mode: "within-rect",
brushCoords
}
};
}
case "graph lasso end": {
const { polygon } = action;
return {
...state,
selection: {
mode: "within-polygon",
polygon
}
};
}
case "graph lasso cancel":
case "graph brush cancel":
case "graph lasso deselect":
case "graph brush deselect": {
return {
...state,
selection: {
mode: "all"
}
};
}
default: {
return state;
}
}
};
export default GraphSelection;
+16 -45
View File
@@ -1,56 +1,27 @@
import { createStore, applyMiddleware } from "redux";
// jshint esversion: 6
import { combineReducers, createStore, applyMiddleware } from "redux";
import thunk from "redux-thunk";
import updateURLMiddleware from "../middleware/updateURLMiddleware";
import updateCellColors from "../middleware/updateCellColors";
import { composeWithDevTools } from "redux-devtools-extension";
import cascadeReducers from "./cascade";
import undoable from "./undoable";
import config from "./config";
import universe from "./universe";
import world from "./world";
import categoricalSelection from "./categoricalSelection";
import continuousSelection from "./continuousSelection";
import graphSelection from "./graphSelection";
import crossfilter from "./crossfilter";
import colors from "./colors";
import differential from "./differential";
import layoutChoice from "./layoutChoice";
import responsive from "./responsive";
import controls from "./controls";
import resetCache from "./resetCache";
import centroidLabel from "./centroidLabel";
import undoableConfig from "./undoableConfig";
const Reducer = combineReducers({
config,
responsive,
controls,
differential
});
const Reducer = undoable(
cascadeReducers([
["config", config],
["universe", universe],
["world", world],
["layoutChoice", layoutChoice],
["categoricalSelection", categoricalSelection],
["continuousSelection", continuousSelection],
["graphSelection", graphSelection],
["crossfilter", crossfilter],
["colors", colors],
["controls", controls],
["differential", differential],
["responsive", responsive],
["centroidLabel", centroidLabel],
["resetCache", resetCache]
]),
[
"world",
"categoricalSelection",
"continuousSelection",
"graphSelection",
"crossfilter",
"colors",
"controls",
"differential",
"layoutChoice"
],
undoableConfig
const store = createStore(
Reducer,
composeWithDevTools(
applyMiddleware(thunk, updateURLMiddleware, updateCellColors)
)
);
const store = createStore(Reducer, applyMiddleware(thunk));
export default store;
-50
View File
@@ -1,50 +0,0 @@
/*
we have a UI heuristic to pick the default layout, based on assumptions
about commonly used names. Preferentially, pick in the following order:
1. "umap"
2. "tsne"
3. "pca"
4. give up, use the first available
*/
function bestDefaultLayout(layouts) {
const preferredNames = ["umap", "tsne", "pca"];
const idx = preferredNames.findIndex(name => layouts.indexOf(name) !== -1);
if (idx !== -1) return preferredNames[idx];
return layouts[0];
}
const LayoutChoice = (
state = {
available: [], // all available choices
current: undefined, // name of the current layout, eg, 'umap'
currentDimNames: [] // dimension name
},
action,
nextSharedState
) => {
switch (action.type) {
case "initial data load complete (universe exists)":
case "reset World to eq Universe": {
// set default to default
const { schema } = nextSharedState.world;
const available = schema.layout.obs.map(v => v.name);
const current = bestDefaultLayout(available);
const currentDimNames = schema.layout.obsByName[current].dims;
return { available, current, currentDimNames };
}
case "set layout choice": {
const { schema } = nextSharedState.world;
const current = action.layoutChoice;
const currentDimNames = schema.layout.obsByName[current].dims;
return { ...state, current, currentDimNames };
}
default: {
return state;
}
}
};
export default LayoutChoice;
-31
View File
@@ -1,31 +0,0 @@
/*
Reducer which caches derived state to be used in a reset or other
recomputation.
Currently this only caches the baseline (full universe) world & crossfilter,
for use in a Reset.
*/
const ResetCacheReducer = (
state = {
world: null,
crossfilter: null
},
action,
nextSharedState
) => {
switch (action.type) {
case "initial data load complete (universe exists)": {
const { world, crossfilter } = nextSharedState;
return {
...state,
world,
crossfilter
};
}
default: {
return state;
}
}
};
export default ResetCacheReducer;
-287
View File
@@ -1,287 +0,0 @@
/*
A redo/undo meta reducer for Redux. Designed to work well with the cascadeReducer().
Requires three parameters:
* reducer - a reducer, which MUST return an object as state.
* undoableKeys - an array of object keys (strings). If any of these keys
are in the object/state returned by the reducer, they will be treated as
state to be made "undoable".
* options - an optional object, which may contain the following parameters:
* historyLimit: max number of historical states to remember (aka max undo depth)
* actionFilter: filter function, (state, action, filterState) => value.
See below for details.
* debug: if truish, will print helpful log messages about history manipulation
This meta reducer accepts three actions types:
* @@undoable/undo - move back in history
* @@undoable/redo - move forward in history
* @@undoable/clear - clear history
---
Action filter - controls the undoable reducer side-effects. If not
specified, the action filter defaults to "save", ie, pushes a redo
point upon each action.
The action filter callback has access to the current action, the entire
undoable reducer state, and any state it wants to manage ("filterState").
This filter state will be passed to each action filter call, and any
value returned (via @@undoable/filterState field described below) will be
MERGED into the current filter state.
An object must be returned (the "undoable action"), indicating desired
history state processing. The undoable action object contents, by key:
@@undoable/filterAction: required. Can be one of:
"skip" - reduce the current action, but no other side effects.
Same as returning false.
"clear" - reduce the current action, and clear history state.
"save" - push the previous state onto the history stack (ie,
before reducing the action)
"stashPending" - reduce action, save state as pending. Does not
not commit it to history. Along with cancelPending and applyPending,
can be used to delay commit of history (eg, for multi-action
groupings, asynch operations, etc).
"cancelPending" - reduce action, cancel any pending state save.
"applyPending" - commit any pending state to the history stack,
then reduce action.
@@undoable/filterState: optional. If this value is set, it will be
MERGED into the current filter state. The value and semantics of any
filter state are entirely at the discretion of the action filter.
*/
import fromEntries from "../util/fromEntries";
const historyKeyPrefix = "@@undoable/";
const pastKey = `${historyKeyPrefix}past`;
const futureKey = `${historyKeyPrefix}future`;
const filterStateKey = `${historyKeyPrefix}filterState`;
const filterActionKey = `${historyKeyPrefix}filterAction`;
const pendingKey = `${historyKeyPrefix}pending`;
const defaultHistoryLimit = -100;
const Undoable = (reducer, undoableKeys, options = {}) => {
const { debug } = options;
let { historyLimit } = options;
if (!historyLimit) historyLimit = defaultHistoryLimit;
if (historyLimit > 0) historyLimit = -historyLimit;
const actionFilter =
options.actionFilter || (() => ({ [filterActionKey]: "save" }));
if (!Array.isArray(undoableKeys) || undoableKeys.length === 0)
throw new Error("undoable keys array must be specified");
const undoableKeysSet = new Set(undoableKeys);
/*
Undo the current to previous history
*/
function undo(currentState) {
const past = currentState[pastKey];
const future = currentState[futureKey];
if (past.length === 0) return currentState;
const currentUndoableState = Object.entries(currentState).filter(kv =>
undoableKeysSet.has(kv[0])
);
const newPast = [...past];
const newState = newPast.pop();
const newFuture = push(future, currentUndoableState);
const nextState = {
...currentState,
...fromEntries(newState),
[pastKey]: newPast,
[futureKey]: newFuture,
[pendingKey]: null
};
return nextState;
}
/*
Replay future, previously undone.
*/
function redo(currentState) {
const past = currentState[pastKey] || [];
const future = currentState[futureKey] || [];
if (future.length === 0) return currentState;
const currentUndoableState = Object.entries(currentState).filter(kv =>
undoableKeysSet.has(kv[0])
);
const newFuture = [...future];
const newState = newFuture.pop();
const newPast = push(past, currentUndoableState);
const nextState = {
...currentState,
...fromEntries(newState),
[pastKey]: newPast,
[futureKey]: newFuture,
[pendingKey]: null
};
return nextState;
}
/*
Clear the history state. No side-effects on current state.
*/
function clear(currentState) {
return {
...currentState,
[pastKey]: [],
[futureKey]: [],
[filterStateKey]: {},
[pendingKey]: null
};
}
/*
Reduce current action, with no history side-effects
*/
function skip(currentState, action, filterState) {
const past = currentState[pastKey] || [];
const pending = currentState[pendingKey];
const res = reducer(currentState, action);
return {
...res,
[pastKey]: past,
[futureKey]: [],
[filterStateKey]: filterState,
[pendingKey]: pending
};
}
/*
Save current state in the history, then reduce action.
*/
function save(currentState, action, filterState) {
const past = currentState[pastKey] || [];
const currentUndoableState = Object.entries(currentState).filter(kv =>
undoableKeysSet.has(kv[0])
);
const res = reducer(currentState, action);
const newPast = push(past, currentUndoableState, historyLimit);
const nextState = {
...res,
[pastKey]: newPast,
[futureKey]: [],
[filterStateKey]: filterState,
[pendingKey]: null
};
return nextState;
}
/*
Save current state as pending history change. No other side effects.
*/
function stashPending(currentState) {
const currentUndoableState = Object.entries(currentState).filter(kv =>
undoableKeysSet.has(kv[0])
);
return {
...currentState,
[pendingKey]: currentUndoableState
};
}
/*
Cancel pending history state change. No other side effects.
*/
function cancelPending(currentState) {
return {
...currentState,
[pendingKey]: null
};
}
/*
Push pending state onto the history stack
*/
function applyPending(currentState) {
const past = currentState[pastKey] || [];
const pendingState = currentState[pendingKey];
const newPast = push(past, pendingState, historyLimit);
const nextState = {
...currentState,
[pastKey]: newPast,
[futureKey]: [],
[pendingKey]: null
};
return nextState;
}
return (
currentState = {
[pastKey]: [],
[futureKey]: [],
[filterStateKey]: {},
[pendingKey]: null
},
action
) => {
const aType = action.type;
switch (aType) {
case "@@undoable/undo": {
return undo(currentState, action);
}
case "@@undoable/redo": {
return redo(currentState, action);
}
case "@@undoable/clear": {
return clear(currentState, action);
}
default: {
const currentFilterState = currentState[filterStateKey];
const actionFilterResp = actionFilter(
currentState,
action,
currentFilterState
);
const {
[filterActionKey]: filterAction,
[filterStateKey]: filterStateUpdate
} = actionFilterResp;
const nextFilterState = { ...currentFilterState, ...filterStateUpdate };
switch (filterAction) {
case "clear":
if (debug) console.log("---- CLEAR HISTO", action.type);
return clear(skip(currentState, action, nextFilterState));
case "save":
if (debug) console.log("---- SAVE HISTO", action.type);
return save(currentState, action, nextFilterState);
case "stashPending":
if (debug) console.log("---- STASH PENDING", action.type);
return skip(stashPending(currentState), action, nextFilterState);
case "cancelPending":
if (debug) console.log("---- CANCEL PENDING", action.type);
return skip(cancelPending(currentState), action, nextFilterState);
case "applyPending":
if (debug) console.log("---- APPLY PENDING", action.type);
return skip(applyPending(currentState), action, nextFilterState);
case "skip":
default:
return skip(currentState, action, nextFilterState);
}
}
}
};
};
function push(arr, val, limit = undefined) {
/*
functional array push, with a max length limit to the new array.
Like Array.push, except it returns new array and discards as needed
to enforce the length limit.
*/
const narr = arr.slice(limit);
narr.push(val);
return narr;
}
export default Undoable;
-263
View File
@@ -1,263 +0,0 @@
import StateMachine from "../util/statemachine";
import createFsmTransitions from "./undoableFsm";
const actionKey = "@@undoable/filterAction";
const stateKey = "@@undoable/filterState";
/*
these actions will not affect history
*/
const skipOnActions = new Set([
"url changed",
"interface reset started",
"initial data load start",
"configuration load complete",
"increment graph render counter",
"window resize",
"user reset start",
"reset colorscale",
"graph brush change",
"continuous metadata histogram brush",
"expression load start",
"expression load success",
"expression load error",
"request user defined gene started",
"request user defined gene success",
"clear all user defined genes",
"get single gene expression for coloring started",
"get single gene expression for coloring error",
"category value mouse hover start",
"category value mouse hover end"
]);
/*
identical, repeated occurances of these action types will be debounced.
Entire action must be identical (all keys).
*/
const debounceOnActions = new Set([
"color by categorical metadata",
"color by continuous metadata",
"color by expression"
]);
/*
history will be cleared when these actions occur
*/
const clearOnActions = new Set([
"initial data load complete (universe exists)",
"reset World to eq Universe",
"initial data load error",
"user reset end"
]);
/*
An immediate history save will be done for these
*/
const saveOnActions = new Set([
"categorical metadata filter select",
"categorical metadata filter deselect",
"categorical metadata filter all of these",
"categorical metadata filter none of these",
"color by categorical metadata",
"color by continuous metadata",
"color by expression",
"set scatterplot x",
"set scatterplot y",
"store current cell selection as differential set 1",
"store current cell selection as differential set 2",
"set World to current selection",
"set clip quantiles",
"set layout choice",
"change graph interaction mode"
]);
/**
StateMachine - processing complex action handling - see FSM graph for
actual structure, in undoableFsm.js
**/
/*
Default FSM actions. Used to side-effect transitions in the graph.
See graph definition for the transitions that use each.
Signature: (fsm, transition, reducerState, reducerAction) => undoableAction
*/
const stashPending = fsm => ({
[actionKey]: "stashPending",
[stateKey]: { fsm }
});
const cancelPending = () => ({
[actionKey]: "cancelPending",
[stateKey]: { fsm: null }
});
const applyPending = () => ({
[actionKey]: "applyPending",
[stateKey]: { fsm: null }
});
const skip = (fsm, transition) => ({
[actionKey]: "skip",
[stateKey]: { fsm: transition.to !== "done" ? fsm : null }
});
const clear = () => ({ [actionKey]: "clear", [stateKey]: { fsm: null } });
const save = (fsm, transition) => ({
[actionKey]: "save",
[stateKey]: { fsm: transition.to !== "done" ? fsm : null }
});
/*
Error handler for state transitions that are unexpected. Called by
StateMachine when it doesn't know what to do.
Signature: (fsm, event, from) => undoableAction
*/
const onFsmError = (fsm, event, from) => {
console.error(`FSM error [event: "${event}", state: "${from}"]`, fsm);
// In production, try to recover gracefully if we have unexpected state
return clear(fsm);
};
/*
Definition of the transition graph mapping action types to history side effects.
*/
const fsmTransitions = createFsmTransitions(
stashPending,
cancelPending,
applyPending,
skip,
clear,
save
);
/* State machine we clone whenever we need to run it */
const seedFsm = new StateMachine("init", fsmTransitions, onFsmError);
/*
See undoable.js for description action filter interface description.
Basic approach:
* trivial handlers for skip, clear & save cases to keep config simple.
* only implement complex state machines where absolutely required (eg,
multi-event seleciton and the like)
*/
const actionFilter = debug => (state, action, prevFilterState) => {
const actionType = action.type;
const filterState = {
...prevFilterState,
prevAction: action
};
if (skipOnActions.has(actionType)) {
return { [actionKey]: "skip", [stateKey]: filterState };
}
if (
debounceOnActions.has(actionType) &&
shallowObjectEq(action, prevFilterState.prevAction)
) {
return { [actionKey]: "skip", [stateKey]: filterState };
}
if (clearOnActions.has(actionType)) {
return { [actionKey]: "clear", [stateKey]: filterState };
}
if (saveOnActions.has(actionType)) {
return { [actionKey]: "save", [stateKey]: filterState };
}
/*
Else, something more complex OR unknown to us....
*/
if (seedFsm.events.has(actionType)) {
let { fsm } = filterState;
if (!fsm) {
/* no active FSM, so create one in init state */
fsm = seedFsm.clone("init");
}
return fsm.next(action.type, { state, action });
}
/* else, we have no idea what this is - skip it */
if (debug) console.log("**** ACTION FILTER EVENT HANDLER MISS", actionType);
return { [actionKey]: "skip", [stateKey]: filterState };
};
/*
return true if objA and objB are ===, OR if:
- are both objects and not null
- have same own properties
- all values are strict equal (===)
*/
function shallowObjectEq(objA, objB) {
if (objA === objB) return true;
if (!objA || !objB) return false;
if (!shallowArrayEq(Object.keys(objA), Object.keys(objB))) return false;
if (!shallowArrayEq(Object.values(objA), Object.values(objB))) return false;
return true;
}
/*
return true if arrA and arrB contain the same strict-equal values,
in the same order.
*/
function shallowArrayEq(arrA, arrB) {
if (arrA.length !== arrB.length) return false;
for (let i = 0, l = arrA.length; i < l; i += 1) {
if (arrA[i] !== arrB[i]) return false;
}
return true;
}
/* configuration for the undoable meta reducer */
const debug = false; // set truish for undoble debugging
const undoableConfig = {
debug,
historyLimit: 50, // maximum history size
actionFilter: actionFilter(debug)
};
/*
this code is strictly for sanity checking configuration, and is only
enabled when we are debugging the undoable configuration (ie, debug === true).
*/
if (debug) {
/*
Confirm no intersection between the various trivial rejection action filters
*/
if (
new Set([...skipOnActions].filter(x => clearOnActions.has(x))).size > 0 ||
new Set([...skipOnActions].filter(x => saveOnActions.has(x))).size > 0 ||
new Set([...clearOnActions].filter(x => saveOnActions.has(x))).size > 0
) {
console.error(
"Undoable misconfiguration - action filters have redundant events"
);
}
/*
Confirm that no FSM events are blocked by a trivial rejection filter.
If this occurs, the FSM can't ever see the events needed to process
state transitions.
*/
const trivialFilters = new Set([
...skipOnActions,
...clearOnActions,
...saveOnActions
]);
const trivialOverlapWithFsm = new Set(
[...trivialFilters].filter(x => seedFsm.events.has(x))
);
if (trivialOverlapWithFsm.size > 0) {
console.error(
"Undoable misconfiguration - trivival action filter blocking FSM filter",
[...trivialOverlapWithFsm]
);
}
}
export default undoableConfig;
-206
View File
@@ -1,206 +0,0 @@
/*
State transition graph for complex action/history interactions.
Assumed configuration from undoableConfig:
* By convention, "init" is used as the start state for all, and "done"
as the final state.
* Unexpected states will result in an error, plus a clear and cancelPending
side-effect.
TODO: is is possible there is a more concise format for this, as it is
a fairly repetitive pattern.
These events are largely one of two types:
a) async operations or multi-event options that should only be committed
upon some success criteria, otherwise cancelled.
b) compound actions that should be collapsed into a single history change.
*/
const createFsmTransitions = (
stashPending,
cancelPending,
applyPending,
skip,
clear,
save
) => {
return [
/* graph selection brushing */
{
event: "graph brush start",
from: "init",
to: "graph brush in progress",
action: stashPending
},
{
event: "graph brush cancel",
from: "graph brush in progress",
to: "done",
action: applyPending
},
{
event: "graph brush deselect",
from: "graph brush in progress",
to: "done",
/* if current selection is all, cancelPending. Else, applyPending */
action: (fsm, transition, data) =>
data.state.graphSelection.selection.mode === "all"
? cancelPending()
: applyPending()
},
{
event: "graph brush end",
from: "graph brush in progress",
to: "done",
action: applyPending
},
/* graph selection lasso */
{
event: "graph lasso start",
from: "init",
to: "graph lasso in progress",
action: stashPending
},
{
event: "graph lasso cancel",
from: "graph lasso in progress",
to: "done",
action: applyPending
},
{
event: "graph lasso deselect",
from: "graph lasso in progress",
to: "done",
/* if current selection is all, cancelPending. Else, applyPending */
action: (fsm, transition, data) =>
data.state.graphSelection.selection.mode === "all"
? cancelPending()
: applyPending()
},
{
event: "graph lasso end",
from: "graph lasso in progress",
to: "done",
action: applyPending
},
/* Continuous metadata histogram brush selection */
{
event: "continuous metadata histogram start",
from: "init",
to: "continuous histo select in progress",
action: stashPending
},
{
event: "continuous metadata histogram cancel",
from: "continuous histo select in progress",
to: "done",
action: cancelPending
},
{
event: "continuous metadata histogram end",
from: "continuous histo select in progress",
to: "done",
action: applyPending
},
/* Single gene request by user */
{
event: "single user defined gene start",
from: "init",
to: "single user gene request in progress",
action: stashPending
},
{
event: "request user defined gene error",
from: "single user gene request in progress",
to: "single user gene error in progress",
action: skip
},
{
event: "single user defined gene error",
from: "single user gene error in progress",
to: "done",
action: cancelPending
},
{
event: "single user defined gene complete",
from: "single user gene request in progress",
to: "done",
action: applyPending
},
/* Bulk gene request by user */
{
event: "bulk user defined gene start",
from: "init",
to: "bulk user gene request in progress",
action: stashPending
},
{
event: "request user defined gene error",
from: "bulk user gene request in progress",
to: "bulk user gene request error in progress",
action: skip
},
{
event: "bulk user defined gene error",
from: "bulk user gene request error in progress",
to: "done",
action: cancelPending
},
{
event: "bulk user defined gene complete",
from: "bulk user gene request in progress",
to: "done",
action: applyPending
},
/* Compute Differential Expression button user action */
{
event: "request differential expression started",
from: "init",
to: "diffexp in progress",
action: stashPending
},
{
event: "request user defined gene error",
from: "diffexp in progress",
to: "done",
action: cancelPending
},
{
event: "request differential expression success",
from: "diffexp in progress",
to: "done",
action: applyPending
},
/* Clear Differential Expression button user action */
{
event: "clear differential expression",
from: "init",
to: "CDE Button in progress",
action: stashPending
},
{
event: "clear scatterplot",
from: "CDE Button in progress",
to: "done",
action: applyPending
},
/* clear scatter plot button (eg, on scatterplot view) */
{
event: "clear scatterplot",
from: "init",
to: "done",
action: save
}
];
};
export default createFsmTransitions;
-47
View File
@@ -1,47 +0,0 @@
import { ControlsHelpers } from "../util/stateManager";
const Universe = (state = null, action, nextSharedState, prevSharedState) => {
switch (action.type) {
case "initial data load complete (universe exists)": {
const { universe } = action;
return universe;
}
case "expression load success": {
let { varData } = state;
// Lazy load new expression data into the varData dataframe, if
// not already present.
//
Object.entries(action.expressionData).forEach(([key, val]) => {
// If not already in universe.varData, save entire expression column
if (!varData.hasCol(key)) {
varData = varData.withCol(key, val);
}
});
// Prune size of varData "cache" if getting out of hand....
//
const { userDefinedGenes, diffexpGenes } = prevSharedState;
const allTheGenesWeNeed = [
...new Set(
userDefinedGenes,
diffexpGenes,
Object.keys(action.expressionData)
)
];
varData = ControlsHelpers.pruneVarDataCache(varData, allTheGenesWeNeed);
return {
...state,
varData
};
}
default: {
return state;
}
}
};
export default Universe;
-131
View File
@@ -1,131 +0,0 @@
import { World, ControlsHelpers } from "../util/stateManager";
import clip from "../util/clip";
import quantile from "../util/quantile";
const WorldReducer = (
state = null,
action,
nextSharedState,
prevSharedState
) => {
switch (action.type) {
case "initial data load complete (universe exists)": {
const { universe } = nextSharedState;
const world = World.createWorldFromEntireUniverse(universe);
return world;
}
case "reset World to eq Universe": {
return prevSharedState.resetCache.world;
}
case "set World to current selection": {
/* Set viewable world to be the currently selected data */
const world = World.createWorldBySelection(
action.universe,
action.world,
action.crossfilter
);
return world;
}
case "set clip quantiles": {
const world = World.createWorldWithNewClip(
prevSharedState.universe,
state,
prevSharedState.crossfilter,
action.clipQuantiles
);
return world;
}
case "expression load success": {
const { universe } = nextSharedState;
const universeVarData = universe.varData;
let unclippedVarData = state.unclipped.varData;
// Lazy load new expression data into the unclipped varData dataframe, if
// not already present.
//
Object.entries(action.expressionData).forEach(([key, val]) => {
// If not already in world.varData, save sliced expression column
if (!unclippedVarData.hasCol(key)) {
// Slice if world !== universe, else just use whole column.
// Use the obsAnnotation index as the cut key, as we keep
// all world dataframes in sync.
let worldValSlice = val;
if (!World.worldEqUniverse(state, universe)) {
worldValSlice = universeVarData
.subset(state.obsAnnotations.rowIndex.keys(), [key], null)
.icol(0)
.asArray();
}
// Now build world's varData dataframe
unclippedVarData = unclippedVarData.withCol(
key,
worldValSlice,
state.obsAnnotations.rowIndex
);
}
});
// Prune size of varData unclipped dataframe if getting out of hand....
//
const { userDefinedGenes, diffexpGenes } = prevSharedState;
const allTheGenesWeNeed = [
...new Set(
userDefinedGenes,
diffexpGenes,
Object.keys(action.expressionData)
)
];
unclippedVarData = ControlsHelpers.pruneVarDataCache(
unclippedVarData,
allTheGenesWeNeed
);
// at this point, we have the unclipped data in unclippedVarData.
// Now create clipped.
// - Drop columns no longer needed
// - Add new columns
//
let clippedVarData = state.varData;
const keysToDrop = clippedVarData.colIndex
.keys()
.filter(k => !unclippedVarData.hasCol(k));
const keysToAdd = unclippedVarData.colIndex
.keys()
.filter(k => !clippedVarData.hasCol(k));
keysToDrop.forEach(k => {
clippedVarData = clippedVarData.dropCol(k);
});
keysToAdd.forEach(k => {
const data = unclippedVarData.col(k).asArray();
const q = [state.clipQuantiles.min, state.clipQuantiles.max];
const [qMinVal, qMaxVal] = quantile(q, data);
const clippedData = clip(data, qMinVal, qMaxVal, Number.NaN);
clippedVarData = clippedVarData.withCol(
k,
clippedData,
state.obsAnnotations.rowIndex
);
});
return {
...state,
varData: clippedVarData,
unclipped: {
...state.unclipped,
varData: unclippedVarData
}
};
}
default: {
return state;
}
}
};
export default WorldReducer;
+14 -34
View File
@@ -24,44 +24,24 @@ export function catchErrorsWrap(fn, dispatchToUser = false) {
};
}
/*
Wrapper to perform async fetch with some modest error handling
and decoding.
*/
const doFetch = async (url, acceptType) => {
const res = await fetch(url, {
method: "get",
headers: new Headers({
Accept: acceptType
})
});
if (res.ok && res.headers.get("Content-Type").includes(acceptType)) {
return res;
}
// else an error
let msg = `Unexpected HTTP response ${res.status}, ${res.statusText}`;
const body = await res.text();
if (body && body.length > 0) {
msg = `${msg} -- ${body}`;
}
dispatchNetworkErrorMessageToUser(msg);
throw new Error(msg);
};
/*
Wrapper to perform an async fetch and JSON decode response.
*/
export const doJsonRequest = async url => {
const res = await doFetch(url, "application/json");
return res.json();
};
/*
Wrapper to perform an async fetch for binary data.
*/
export const doBinaryRequest = async url => {
const res = await doFetch(url, "application/octet-stream");
return res.arrayBuffer();
const res = await fetch(url, {
method: "get",
headers: new Headers({
"Content-Type": "application/json",
"Accept-Encoding": "gzip, deflate, br"
})
});
if (res.ok && res.headers.get("Content-Type") === "application/json") {
return res.json();
}
// else an error
const msg = `Unexpected HTTP response ${res.status}, ${res.statusText}`;
dispatchNetworkErrorMessageToUser(msg);
throw new Error(msg);
};
/*
+2 -1
View File
@@ -5,9 +5,10 @@ const mp = require("mouse-position");
const mb = require("mouse-pressed");
const key = require("key-pressed");
const panSpeed = 1.0; // changed from 0.4 to 1.0 per issue #722
const panSpeed = 0.4;
const scaleSpeed = 0.5;
const scaleMax = 3;
// const scaleMin = 1.15
const scaleMin = 1.03;
function attachCamera(canvas, opts) {
-58
View File
@@ -1,58 +0,0 @@
import quantile from "./quantile";
/*
Centroid coordinate calculation
*/
const calcMeanCentroid = (world, annoName, annoValue, layoutDimNames) => {
const centroid = { x: 0, y: 0, size: 0 };
const annoArray = world.obsAnnotations.col(annoName).asArray();
const layoutXArray = world.obsLayout.col(layoutDimNames[0]).asArray();
const layoutYArray = world.obsLayout.col(layoutDimNames[1]).asArray();
for (let i = 0, len = annoArray.length; i < len; i += 1) {
if (annoArray[i] === annoValue) {
centroid.x += layoutXArray[i];
centroid.y += layoutYArray[i];
centroid.size += 1;
}
}
if (centroid[2] !== 0) {
centroid.x /= centroid.size;
centroid.y /= centroid.size;
}
return [centroid.x, centroid.y];
};
const calcMedianCentroid = (world, annoName, annoValue, layoutDimNames) => {
const centroidX = [];
const centroidY = [];
let hasFinite = false;
const annoArray = world.obsAnnotations.col(annoName).asArray();
const layoutXArray = world.obsLayout.col(layoutDimNames[0]).asArray();
const layoutYArray = world.obsLayout.col(layoutDimNames[1]).asArray();
for (let i = 0, len = annoArray.length; i < len; i += 1) {
if (annoArray[i] === annoValue) {
hasFinite =
Number.isFinite(layoutXArray[i]) || Number.isFinite(layoutYArray[i])
? true
: hasFinite;
centroidX.push(layoutXArray[i]);
centroidY.push(layoutYArray[i]);
}
}
if (hasFinite) {
const medianX = quantile([0.5], Float64Array.from(centroidX));
const medianY = quantile([0.5], Float64Array.from(centroidY));
return [medianX, medianY];
}
return null;
};
export default calcMedianCentroid;

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