Compare commits

..
1 Commits
Author SHA1 Message Date
Charlotte Weaver 07cee2c710 0.0.3 bump 2018-11-15 11:37:27 -08:00
125 changed files with 5642 additions and 10642 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
[bumpversion] [bumpversion]
current_version = 0.7.0 current_version = 0.0.3
[bumpversion:file:setup.py] [bumpversion:file:setup.py]
search = version="{current_version}" search = version="{current_version}"
-5
View File
@@ -1,5 +0,0 @@
bin
client
dist
docs
server
-4
View File
@@ -35,7 +35,3 @@ npm-debug.log
__pycache__ __pycache__
*.DS_Store* *.DS_Store*
data data
# Jekyll
docs/_site/
docs/Gemfile.lock
+9 -18
View File
@@ -8,22 +8,13 @@ cache:
install: install:
- set -eo pipefail - set -eo pipefail
- pip install flake8 - pip install flake8
- make build - ./bin/build-client
- make install - pip install -e .
- pip install -r server/requirements-dev.txt - pip install -r server/requirements-dev.txt
- docker build . script:
- set -eo pipefail
jobs: - flake8 server/app/
include: - flake8 server/cli/
- name: "Branch Tests" - npm run --prefix client/ build
script: - npm run --prefix client/ test
- set -eo pipefail - pytest -s server/test
- flake8 server
- black --check
- npm run --prefix client/ build
- npm run --prefix client/ unit-test
- pytest -s server/test
- name: "Smoke Tests"
if: branch = master AND type = cron
script:
- npm run --prefix client/ smoke-test
-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/templates *
recursive-include server/app/web/static * recursive-include server/app/web/static *
include server/requirements.txt
+78 -255
View File
@@ -1,288 +1,111 @@
# cellxgene # cellxgene
> an interactive explorer for single-cell transcriptomics data ### An interactive, performant explorer for single cell transcriptomics data.
`cellxgene` is an interactive data explorer for single-cell transcriptomics datasets, such as those coming from the [Human Cell Atlas](https://humancellatlas.org). Leveraging modern web development techniques to enable fast visualizations of at least 1 million cells, we hope to enable biologists and computational researchers to explore their data, and to demonstrate general, scalable, and reusable patterns for scientific data visualization. <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.
<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"> ## Features
## getting started - **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.
You'll need **python 3.6** and **Google Chrome**. (_Warning_: Python 3.7 is **not** supported at this time) - **Interactive exploration:** select, cross-filter, and compare subsets of your data with performant indexing and data handling.
The web UI is tested on OSX and Windows using Chrome, and the python CLI is tested on OSX and Ubuntu (via WSL/Windows). It should work on other platforms, but if you run into trouble let us know (see [help](#help-and-contact) below).
To install run - **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/).
``` ## Getting Started
pip install cellxgene
```
To start exploring a dataset call **Requirements**
```
cellxgene launch dataset.h5ad --open
```
If you want an example dataset download [this file](https://github.com/chanzuckerberg/cellxgene/raw/master/example-dataset/pbmc3k.h5ad) and then call
```
cellxgene launch pbmc3k.h5ad --open
```
You should see your web browser open with the following
<img width="450" src="https://raw.githubusercontent.com/chanzuckerberg/cellxgene/master/docs/cellxgene-opening-screenshot.png" pad="50px">
**Note**: automatic opening of the browser with the `--open` flag only works on OS X, on other platforms you'll need to directly point to the provided link in your browser.
There are several options available, such as:
- `--layout` to specify the layout as `tsne` or `umap`
- `--title` to show a title on the explorer
- `--open` to automatically open the web browser after launching (OS X only)
To see all options call
```
cellxgene launch --help
```
There is an additional subcommand called `cellxgene prepare` that takes an existing dataset in one of several formats and applies minimal preprocessing and reformatting so that `launch` can use it (see [the next section](##data-formatting) for more info on `prepare`).
## data formatting
### assumptions
The `launch` command assumes that the data is stored in the `.h5ad` format from the [`anndata`](https://anndata.readthedocs.io/en/latest/index.html) library. It also assumes that certain computations have already been performed. Briefly, the `.h5ad` format wraps a two-dimensional `ndarray` and stores additional metadata as "annotations" for either observations (referred to as `obs` and `obsm`) or variables (`var` and `varm`). `cellxgene launch` makes the following assumptions about your data (we recommend loading and inspecting your data using `scanpy` to validate these assumptions)
- an `obs` field has a unique identifier for every cell (you can specify which field to use with the `--obs-names` option, by default it will use the value of `data.obs_names`)
- a `var` field has a unique identifier for every gene (you can specify which field to use with the `--var-names` option, by default it will use the value of `data.var_names`)
- an `obsm` field contains the two-dimensional coordinates for the layout that you want to render (e.g. `X_tsne` for the `tsne` layout or `X_umap` for the `umap` layout)
- any additional `obs` fields will be rendered as per-cell continuous or categorical metadata by the app (e.g. `louvain` cluster assignments)
### prepare
The `prepare` command is included to help you format your data. It uses `scanpy` under the hood. This is especially useful if you are starting with raw unanalyzed data and are unfamiliar with `scanpy`.
To prepare from an existing `.h5ad` file use
```
cellxgene prepare dataset.h5ad --output=dataset-processed.h5ad
```
This will load the input data, perform PCA and nearest neighbor calculations, compute `umap` and `tsne` layouts and `louvain` cluster assignments, and save the results in a new file called `dataset-processed.h5ad` that can be loaded using `cellxgene launch`. Data can be loaded from several formats, including `.h5ad` `.loom` and a `10-Genomics-formatted` `mtx` directory. Several options are available, including running one of the preprocessing `recipes` included with `scanpy`, which include steps like cell filtering and gene selection. To learn more about the `recipes` please see the `scanpy` [documentation](https://scanpy.readthedocs.io/en/latest/api/index.html#recipes).
Depending on the options chosen, `prepare` can take a long time to run (a few minutes for datasets with 10-100k cells, up to an hour or more for datasets with >100k cells). If you want `prepare` to run faster we recommend using the `sparse` option and only computing the layout for `umap`, using a call like this
```
cellxgene prepare dataset.h5ad --output=dataset-processed.h5ad --layout=umap --sparse
```
To see all options call
```
cellxgene prepare --help
```
**Note**: `cellxgene prepare` will only perform `louvain` clustering if you have the `python-igraph` and `louvain` packages installed. To make sure they are installed alongside `cellxgene` use
```
pip install cellxgene[louvain]
```
If the aforementioned optional package installation fails, you can also install these packages directly:
```
pip install python-igraph louvain>=0.6
```
## conda and virtual environments
If you use conda and want to create a conda environment for `cellxgene` you can use the following commands
```
conda create --yes -n cellxgene python=3.6
conda activate cellxgene
pip install cellxgene
```
Or you can create a virtual environment by using
```
ENV_NAME=cellxgene
python3.6 -m venv ${ENV_NAME}
source ${ENV_NAME}/bin/activate
pip install cellxgene
```
## docker
We have included a dockerfile to conveniently run cellxgene from docker.
1. Build the image `docker build . -t cellxgene`
2. Run the container and mount data `docker run -v "$PWD/example-dataset/:/data/" -p 5005:5005 cellxgene launch --host 0.0.0.0 data/pbmc3k.h5ad`
- You will need to use --host 0.0.0.0 to have the container listen to incoming requests from the browser
## FAQ
<details>
<summary> questions about data formatting </summary>
<hr>
> Someone sent me a directory of `10X-Genomics` data with a `mtx` file and I've never used `scanpy`, can I use `cellxgene`?
Yep! This should only take a couple steps. We'll assume your data is in a folder called `data/` and you've successfully installed `cellxgene` with the `louvain` packages as described above. Just run
```
cellxgene prepare data/ --output=data-processed.h5ad --layout=umap
```
Depending on the size of the dataset, this may take some time. Once it's done, call
```
cellxgene launch data-processed.h5ad --layout=umap --open
```
And your web browser should open with an interactive view of your data.
<hr>
> In my `prepare` command I received the following error `Warning: louvain module is not installed, no clusters will be calculated. To fix this please install cellxgene with the optional feature louvain enabled`
Louvain clustering requires additional dependencies that are somewhat complex, so we don't include them by default. For now, you need to specify that you want these packages by using
```
pip install cellxgene[louvain]
```
<hr>
> I ran `prepare` and I'm getting results that look unexpected
You might want to try running one of the preprocessing recipes included with `scanpy` (read more about them [here](https://scanpy.readthedocs.io/en/latest/api/index.html#recipes)). You can specify this with the `--recipe` option, such as
```
cellxgene prepare data/ --output=data-processed.h5ad --recipe=zheng17
```
It should be easy to run `prepare` then call `cellxgene launch` a few times with different settings to explore different behaviors. We may explore adding other preprocessing options in the future.
<hr>
> I have extra metadata that I want to add to my dataset
Currently this is not supported directly, but you should be able to do this manually using `scanpy`. For example, this [notebook](https://github.com/falexwolf/fun-analyses/blob/master/tabula_muris/tabula_muris.ipynb) shows adding the contents of a `csv` file with metadata to an `anndata` object. For now, you could do this manually on your data in the same way and then save out the result before loading into `cellxgene`.
<hr>
> What part of the anndata objects does cellxgene pull in for visualization?
- `.obs` and `.var` annotations are use to extract metadata for filtering
- `.X` is used to display expression (histograms, scatterplot & colorscale) and to compute differential expression
- `.obsm` is used for layout
</details>
<details>
<summary> questions about installing and building </summary>
<hr>
> I tried to `pip install cellxgene` and got a weird error about missing paths to an HDF5 library?
You probably just need to install HDF5 first. If you're on a mac, you can simply `brew install hdf5` and then try `pip install cellxgene` again.
> I tried to `pip install cellxgene` and got another weird error I don't understand
This may happen, especially as we work out bugs in our installation process! Please create a new [Github issue](https://github.com/chanzuckerberg/cellxgene/issues), explain what you did, and include all the error messages you saw. It'd also be super helpful if you call `pip freeze` and include the full output alongside your issue.
<hr>
> I'm following the developer instructions and get an error about "missing files and directories” when trying to build the client
This is likely because you do not have node and npm installed, we recommend using [nvm](https://github.com/creationix/nvm) if you're new to using these tools.
</details>
<details>
<summary> questions about algorithms </summary>
<hr>
> How are you computing and sorting differential expression results?
Currently we use a [Welch's _t_-test](https://en.wikipedia.org/wiki/Welch%27s_t-test) implementation including the same variance overestimation correction as used in `scanpy`. We sort the `tscore` to identify the top N genes, and then filter to remove any that fall below a cutoff log fold change value, which can help remove spurious test results. The default threshold is `0.01` and can be changed using the option `--diffexp-lfc-cutoff`. We can explore adding support for other test types in the future.
</details>
## 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
- 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 - python 3.6
- node and npm (we recommend using [nvm](https://github.com/creationix/nvm) if this is your first time with node) - python3 tkinter
- npm
- Google Chrome
Then clone the project **Clone project**
``` git clone https://github.com/chanzuckerberg/cellxgene.git
git clone https://github.com/chanzuckerberg/cellxgene.git
```
Build the client web assets by calling this from inside the `cellxgene` folder **Install client**
``` cd cellxgene
./bin/build-client ./bin/build-client
```
Install all requirements (we recommend doing this inside a virtual environment) **To use with virtual env for python**
(optional, but recommended)
``` ENV_NAME=cellxgene
pip install -e . python3 -m venv ${ENV_NAME}
``` source ${ENV_NAME}/bin/activate
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. **Install server**
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. pip install -e .
## development roadmap **Run (with demo data)**
`cellxgene` is still very much in development, and we've love to include the community as we plan new features to work on. We are thinking about working on the following features over the next 3-12 months. If you are interested in updates, want to give feedback, want to contribute, or have ideas about other features we should work on, please [contact us](#help-and-contact) cellxgene launch --title PBMC3K example-dataset/pbmc3k.h5ad
- **Visualizaling spatial metadata** Image-based transcriptomics methods also generate large cell by gene matrices, alongside rich metadata about spatial location; we would like to render this information in `cellxgene` **Help**
- **Visualizing trajectories** Trajectory analyses infer progression along some ordering or pseudotime; we would like `cellxgene` to render the results of these analyses when they have been performed
- **Deploy to web** Many projects release public data browser websites alongside their publicatons; we would like to make it easy for anyone to deploy `cellxgene` to a custom URL with their own dataset that they own and operate
- **HCA Integration** The [Human Cell Atlas](https://humancellatlas.org) is generating a large corpus of single-cell expression data and will make it available through the Data Coordination Platform; we would like `cellxgene` to be one of several different portals for browsing these data
## contributing cellxgene --help
We warmly welcome contributions from the community! Please submit any bug reports and feature requests through [Github issues](https://github.com/chanzuckerberg/cellxgene/issues). Please submit any direct contributions by forking the repository, creating a branch, and submitting a Pull Request. It'd be great for PRs to include test cases and documentation updates where relevant, though we know the core test suite is itself still a work in progress. And all code contributions and dependencies must be compatible with the project's open-source license (MIT). If you have any questions about this stuff, just ask! _For help with the scanpy engine_
## inspiration and collaboration cellxgene scanpy --help
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. ## Using your own data
We were inspired by Mike Bostock and the [crossfilter](https://github.com/crossfilter) team for the design of our filtering implementation. ### Scanpy
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. 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.
We are eager to explore integrations with other computational backends such as [`Seurat`](https://github.com/satijalab/seurat) or [`Bioconductor`](https://github.com/Bioconductor) 1. [Load data into scanpy](https://scanpy.readthedocs.io/en/latest/api/index.html#reading)
## help and contact - 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()`.
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. As mentioned above, please submit any feature requests or bugs as [Github issues](https://github.com/chanzuckerberg/cellxgene/issues). We'd love to hear from you! 2. Calculate PCA
## reuse sc.pp.pca(data) ## sc is scanpy.api
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). 3. Calculate nearest neighbors (depending on layout algorithm)
```
# 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")
# 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")
```
4. Save file
```
# cellxgene requires file to be named data.h5ad
data.write("data.h5ad")
```
## 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.
+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"
-185
View File
@@ -1,185 +0,0 @@
import puppeteer from "puppeteer";
const jest_env = process.env.JEST_ENV || "dev";
const appPort = process.env.JEST_CXG_PORT || 3000;
const appUrlBase = `http://localhost:${appPort}`;
const DEV = jest_env === "dev";
let browser;
let page;
const browserViewport = { width: 1280, height: 960 };
beforeAll(async () => {
const browser_params = DEV
? { headless: false, slowMo: 100, devtools: true }
: {};
browser = await puppeteer.launch(browser_params);
page = await browser.newPage();
page.setViewport(browserViewport);
if (DEV) page.on("console", msg => console.log("PAGE LOG:", msg.text()));
});
afterAll(() => {
if (!DEV) {
browser.close();
}
});
const getOneElementInnerHTML = async function(selector) {
let text = await page.$eval(selector, el => el.innerHTML);
return text;
};
const drag = async function(el_box, start, end, lasso = false) {
const x1 = el_box.content[0].x + start.x;
const x2 = el_box.content[0].x + end.x;
const y1 = el_box.content[0].y + start.y;
const y2 = el_box.content[0].y + end.y;
await page.mouse.move(x1, y1);
await page.mouse.down();
if (lasso) {
await page.mouse.move(x2, y1);
await page.mouse.move(x2, y2);
await page.mouse.move(x1, y2);
await page.mouse.move(x1, y1);
} else {
await page.mouse.move(x2, y2);
}
await page.mouse.up();
};
describe("did launch", () => {
test("page launched", async () => {
await page.goto(appUrlBase);
let el = await getOneElementInnerHTML("[data-testid='header']");
expect(el).toBe("cellxgene: pbmc3k");
});
});
describe("search for genes", () => {
test("search for known gene and add to metadata", async () => {
await page.goto(appUrlBase);
await page.waitForSelector("[ data-testid='gene-search']");
// blueprint's typeahead is treating typing weird, clicking & waiting first solves this
await page.click("[data-testid='gene-search']");
await page.waitFor(200);
await page.type("[data-testid='gene-search']", "ACD");
await page.keyboard.press("Enter");
await page.waitForSelector("[data-testid='histogram-ACD']");
});
});
describe("select cells and diffexp", () => {
test("selects cells from layout and adds to cell set 1", async () => {
await page.goto(appUrlBase);
const layout = await page.waitForSelector("[data-testid='layout']");
const size = await layout.boxModel();
const cellset1 = {
start: {
x: Math.floor(size.width * 0.25),
y: Math.floor(size.height * 0.25)
},
end: {
x: Math.floor(size.width * 0.35),
y: Math.floor(size.height * 0.35)
}
};
await drag(size, cellset1.start, cellset1.end, true);
await page.click("[data-testid='cellset-button-1");
let button = await getOneElementInnerHTML("[data-testid='cellset-button-1");
expect(button).toMatch(/26 cells/);
});
test("selects cells from layout and adds to cell set 2", async () => {
await page.goto(appUrlBase);
const layout = await page.waitForSelector("[data-testid='layout']");
const size = await layout.boxModel();
const cellset2 = {
start: {
x: Math.floor(size.width * 0.45),
y: Math.floor(size.height * 0.45)
},
end: {
x: Math.floor(size.width * 0.55),
y: Math.floor(size.height * 0.55)
}
};
await drag(size, cellset2.start, cellset2.end, true);
await page.click("[data-testid='cellset-button-2");
let button = await getOneElementInnerHTML("[data-testid='cellset-button-2");
expect(button).toMatch(/49 cells/);
});
test("selects cells, saves them and performs diffexp", async () => {
await page.goto(appUrlBase);
const layout = await page.waitForSelector("[data-testid='layout']");
const size = await layout.boxModel();
const cellset1 = {
start: {
x: Math.floor(size.width * 0.25),
y: Math.floor(size.height * 0.25)
},
end: {
x: Math.floor(size.width * 0.35),
y: Math.floor(size.height * 0.35)
}
};
await drag(size, cellset1.start, cellset1.end, true);
await page.click("[data-testid='cellset-button-1");
const cellset2 = {
start: {
x: Math.floor(size.width * 0.45),
y: Math.floor(size.height * 0.45)
},
end: {
x: Math.floor(size.width * 0.55),
y: Math.floor(size.height * 0.55)
}
};
await drag(size, cellset2.start, cellset2.end, true);
await page.click("[data-testid='cellset-button-2");
await page.click("[data-testid='diffexp-button");
await page.waitForSelector("[data-testclass='histogram-diffexp']");
const diffexps = await page.$$eval(
"[data-testclass='histogram-diffexp']",
divs => {
return divs.map(div =>
div.id.substring("histogram-".length, div.id.length)
);
}
);
expect(diffexps).toMatchObject([
"HLA-DPA1",
"HLA-DQA1",
"HLA-DRB1",
"HLA-DMA",
"CST3",
"HLA-DPB1",
"HLA-DQB1",
"LGALS2",
"FCER1A",
"LTB"
]);
});
});
describe("brushable histogram", () => {
test("can brush historgram", async () => {
await page.goto(appUrlBase);
const hist = await page.waitForSelector(
"[data-testid='histogram_n_genes_svg-brush'] > .overlay"
);
const hist_size = await hist.boxModel();
const draghist = {
start: {
x: Math.floor(hist_size.width * 0.25),
y: Math.floor(hist_size.height * 0.5)
},
end: {
x: Math.floor(hist_size.width * 0.55),
y: Math.floor(hist_size.height * 0.5)
}
};
await drag(hist_size, draghist.start, draghist.end);
});
});
-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,616 +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("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,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
})
);
});
});
@@ -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" */ /* eslint no-bitwise: "off" */
import _ from "lodash"; 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. test data mocking REST 0.2 API responses. Used in several tests.
@@ -60,7 +58,7 @@ const aSchemaResponse = {
} }
}; };
const anAnnotationsObsJSONResponse = { const anAnnotationsObsResponse = {
names: ["name", "field1", "field2", "field3", "field4"], names: ["name", "field1", "field2", "field3", "field4"],
data: _() data: _()
.range(nObs) .range(nObs)
@@ -75,7 +73,7 @@ const anAnnotationsObsJSONResponse = {
.value() .value()
}; };
const anAnnotationsVarJSONResponse = { const anAnnotationsVarResponse = {
names: ["fieldA", "fieldB", "fieldC", "fieldD", "name"], names: ["fieldA", "fieldB", "fieldC", "fieldD", "name"],
data: _() data: _()
.range(nVar) .range(nVar)
@@ -90,102 +88,15 @@ const anAnnotationsVarJSONResponse = {
.value() .value()
}; };
function encodeTypedArray(builder, uType, uData) { const aLayoutResponse = {
const uTypeName = NetEncoding.TypedArray[uType]; layout: {
const ArrayType = NetEncoding[uTypeName]; ndims: 2,
const dv = ArrayType.createDataVector(builder, uData); coordinates: _()
builder.startObject(1); .range(nObs)
builder.addFieldOffset(0, dv, 0); .map(idx => [idx, Math.random(), Math.random()])
return builder.endObject(); .value()
}
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))
);
} }
};
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())
];
const builder = new flatbuffers.Builder(1024);
const cols = _.map(coords, carr => {
const cdv = NetEncoding.Float32Array.createDataVector(builder, carr);
NetEncoding.Float32Array.startFloat32Array(builder);
NetEncoding.Float32Array.addData(builder, cdv);
const floatArr = NetEncoding.Float32Array.endFloat32Array(builder);
NetEncoding.Column.startColumn(builder);
NetEncoding.Column.addUType(builder, NetEncoding.TypedArray.Float32Array);
NetEncoding.Column.addU(builder, floatArr);
return NetEncoding.Column.endColumn(builder);
});
const columns = NetEncoding.Matrix.createColumnsVector(builder, cols);
NetEncoding.Matrix.startMatrix(builder);
NetEncoding.Matrix.addNRows(builder, nObs);
NetEncoding.Matrix.addNCols(builder, coords.length);
NetEncoding.Matrix.addColumns(builder, columns);
const matrix = NetEncoding.Matrix.endMatrix(builder);
builder.finish(matrix);
return builder.asUint8Array();
})();
const aDataObsResponse = { const aDataObsResponse = {
var: [2, 4, 29], var: [2, 4, 29],
@@ -196,10 +107,10 @@ const aDataObsResponse = {
}; };
export { export {
aLayoutFBSResponse as layoutObs, aLayoutResponse as layoutObs,
aDataObsResponse as dataObs, aDataObsResponse as dataObs,
anAnnotationsVarFBSResponse as annotationsVar, anAnnotationsVarResponse as annotationsVar,
anAnnotationsObsFBSResponse as annotationsObs, anAnnotationsObsResponse as annotationsObs,
aSchemaResponse as schema, aSchemaResponse as schema,
aConfigResponse as config aConfigResponse as config
}; };
@@ -1,13 +1,13 @@
import _ from "lodash";
import * as Universe from "../../../src/util/stateManager/universe"; import * as Universe from "../../../src/util/stateManager/universe";
import * as Dataframe from "../../../src/util/dataframe";
import * as REST from "./sampleResponses"; 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. a set of REST 0.2 responses into a "new" Universe.
createUniverseFromResponse( createUniverseFromRestV02Response(
configResponse, configResponse,
schemaResponse, schemaResponse,
annotationsObsResponse, annotationsObsResponse,
@@ -30,7 +30,8 @@ describe("createUniverseFromResponse", () => {
create a universe from sample data nad validate its shape & contents create a universe from sample data nad validate its shape & contents
*/ */
const { nObs, nVar } = REST.schema.schema.dataframe; const { nObs, nVar } = REST.schema.schema.dataframe;
const universe = Universe.createUniverseFromResponse(
const universe = Universe.createUniverseFromRestV02Response(
REST.config, REST.config,
REST.schema, REST.schema,
REST.annotationsObs, REST.annotationsObs,
@@ -41,26 +42,80 @@ describe("createUniverseFromResponse", () => {
expect(universe).toBeDefined(); expect(universe).toBeDefined();
expect(universe).toMatchObject( expect(universe).toMatchObject(
expect.objectContaining({ expect.objectContaining({
api: "0.2",
nObs, nObs,
nVar, nVar,
schema: REST.schema.schema, schema: REST.schema.schema,
obsAnnotations: expect.any(Dataframe.Dataframe), obsAnnotations: expect.any(Array),
varAnnotations: expect.any(Dataframe.Dataframe), varAnnotations: expect.any(Array),
obsLayout: expect.any(Dataframe.Dataframe), obsNameToIndexMap: expect.any(Object),
varData: expect.any(Dataframe.Dataframe) varNameToIndexMap: expect.any(Object),
obsLayout: expect.objectContaining({
X: expect.any(Float32Array),
Y: expect.any(Float32Array)
}),
varDataCache: expect.any(Object)
}) })
); );
expect(universe.obsAnnotations.dims).toEqual([ expect(universe.obsAnnotations).toHaveLength(nObs);
nObs, expect(_.keys(universe.obsNameToIndexMap)).toHaveLength(nObs);
REST.schema.schema.annotations.obs.length expect(universe.obsLayout.X).toHaveLength(nObs);
]); expect(universe.obsLayout.Y).toHaveLength(nObs);
expect(universe.obsLayout.dims).toEqual([nObs, 2]); expect(universe.varAnnotations).toHaveLength(nVar);
expect(universe.obsLayout.colIndex.keys()).toEqual(["X", "Y"]); expect(_.keys(universe.varNameToIndexMap)).toHaveLength(nVar);
expect(universe.varAnnotations.dims).toEqual([ });
nVar, });
REST.schema.schema.annotations.var.length
]); describe("convertExpressionRESTv02ToObject", () => {
expect(universe.varData.isEmpty()).toBeTruthy(); /*
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]);
});
}); });
}); });
+111 -36
View File
@@ -1,13 +1,13 @@
import _ from "lodash"; import _ from "lodash";
import * as Universe from "../../../src/util/stateManager/universe"; import * as Universe from "../../../src/util/stateManager/universe";
import * as World from "../../../src/util/stateManager/world"; import * as World from "../../../src/util/stateManager/world";
import * as Dataframe from "../../../src/util/dataframe";
import Crossfilter from "../../../src/util/typedCrossfilter"; import Crossfilter from "../../../src/util/typedCrossfilter";
import * as REST from "./sampleResponses"; import * as REST from "./sampleResponses";
import { import {
obsAnnoDimensionName, obsAnnoDimensionName,
layoutDimensionName layoutDimensionName
} from "../../../src/util/nameCreators"; } from "../../../src/util/nameCreators";
import * as kvCache from "../../../src/util/stateManager/keyvalcache";
/* /*
Helper - creates universe, world, corssfilter and dimensionMap from Helper - creates universe, world, corssfilter and dimensionMap from
@@ -16,7 +16,7 @@ the default REST test response.
const defaultBigBang = () => { const defaultBigBang = () => {
/* create unverse, world, crossfilter and dimensionMap */ /* create unverse, world, crossfilter and dimensionMap */
/* create universe */ /* create universe */
const universe = Universe.createUniverseFromResponse( const universe = Universe.createUniverseFromRestV02Response(
REST.config, REST.config,
REST.schema, REST.schema,
REST.annotationsObs, REST.annotationsObs,
@@ -40,7 +40,7 @@ const defaultBigBang = () => {
describe("createWorldFromEntireUniverse", () => { describe("createWorldFromEntireUniverse", () => {
test("create from REST sample", () => { test("create from REST sample", () => {
const universe = Universe.createUniverseFromResponse( const universe = Universe.createUniverseFromRestV02Response(
REST.config, REST.config,
REST.schema, REST.schema,
REST.annotationsObs, REST.annotationsObs,
@@ -54,13 +54,31 @@ describe("createWorldFromEntireUniverse", () => {
expect(world).toMatchObject( expect(world).toMatchObject(
expect.objectContaining({ expect.objectContaining({
api: "0.2",
nObs: universe.nObs, nObs: universe.nObs,
nVar: universe.nVar, nVar: universe.nVar,
schema: universe.schema, schema: universe.schema,
obsAnnotations: universe.obsAnnotations, obsAnnotations: universe.obsAnnotations,
varAnnotations: universe.varAnnotations, varAnnotations: universe.varAnnotations,
obsLayout: universe.obsLayout, obsLayout: universe.obsLayout,
varData: expect.any(Dataframe.Dataframe)
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
}) })
); );
}); });
@@ -93,38 +111,51 @@ describe("createWorldFromCurrentSelection", () => {
*/ */
/* matchFilter must match the dimension filters above */ /* matchFilter must match the dimension filters above */
const matchFilter = (df, row) => { const matchFilter = val => val.field1 >= 0 && val.field1 < 5 && !val.field3;
const field1 = df.at(row, "field1"); const universeIndices = _()
const field3 = df.at(row, "field3");
return field1 >= 0 && field1 < 5 && !field3;
};
const matchingIndices = _()
.range(universe.nObs) .range(universe.nObs)
.filter(idx => matchFilter(universe.obsAnnotations, idx)) .filter(idx => matchFilter(universe.obsAnnotations[idx]))
.value(); .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(world).toMatchObject(
expect.objectContaining({ expect.objectContaining({
nObs: matchingIndices.length, api: "0.2",
nObs: expected.nObs,
nVar: universe.nVar, nVar: universe.nVar,
schema: universe.schema, schema: universe.schema,
obsAnnotations: expect.any(Dataframe.Dataframe), obsAnnotations: expected.obsAnnotations,
varAnnotations: universe.varAnnotations, varAnnotations: universe.varAnnotations,
obsLayout: expect.any(Dataframe.Dataframe), obsLayout: expected.obsLayout,
varData: expect.any(Dataframe.Dataframe) 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(["X", "Y"]);
}); });
}); });
@@ -137,13 +168,10 @@ describe("createObsDimensionMap", () => {
*/ */
const { dimensionMap } = defaultBigBang(); const { dimensionMap } = defaultBigBang();
const annotationNames = _.map(
REST.schema.schema.annotations.obs,
c => c.name
);
const schemaByObsName = _.keyBy(REST.schema.schema.annotations.obs, "name"); const schemaByObsName = _.keyBy(REST.schema.schema.annotations.obs, "name");
expect(dimensionMap).toBeDefined(); expect(dimensionMap).toBeDefined();
annotationNames.forEach(name => { REST.annotationsObs.names.forEach(name => {
const dim = dimensionMap[obsAnnoDimensionName(name)]; const dim = dimensionMap[obsAnnoDimensionName(name)];
if (name === "name") { if (name === "name") {
expect(dim).toBeUndefined(); expect(dim).toBeUndefined();
@@ -156,20 +184,67 @@ describe("createObsDimensionMap", () => {
} }
} }
}); });
expect(dimensionMap[layoutDimensionName("XY")]).toBeInstanceOf( expect(dimensionMap[layoutDimensionName("X")]).toBeInstanceOf(
Crossfilter.SpatialDimension Crossfilter.ScalarDimension
);
expect(dimensionMap[layoutDimensionName("Y")]).toBeInstanceOf(
Crossfilter.ScalarDimension
); );
}); });
}); });
describe("createVarDataDimension", () => { 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 */ /* create default universe */
const { world, crossfilter } = defaultBigBang(); const { world, crossfilter } = defaultBigBang();
world.varData = world.varData.withCol( /* create a mock var data cache */
const varDataCache = kvCache.set(
kvCache.create(),
"GENE", "GENE",
Float32Array.from(_.range(world.nObs)) Float32Array.from(_.range(world.nObs))
); );
const result = World.createVarDataDimension(world, crossfilter, "GENE"); const result = World.createVarDimension(
world,
varDataCache,
crossfilter,
"GENE"
);
expect(result).toBeInstanceOf(Crossfilter.ScalarDimension); expect(result).toBeInstanceOf(Crossfilter.ScalarDimension);
}); });
@@ -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(); const dim2 = ba.allocDimension();
expect(dim2).toBeDefined(); expect(dim2).toBeDefined();
expect(ba.selectionCount()).toEqual(0); expect(ba.selectionCount).toEqual(0);
ba.selectAll(dim1); ba.selectAll(dim1);
expect(ba.selectionCount()).toEqual(0); expect(ba.selectionCount).toEqual(0);
ba.selectAll(dim2); ba.selectAll(dim2);
expect(ba.selectionCount()).toEqual(defaultTestLength); expect(ba.selectionCount).toEqual(defaultTestLength);
for (let i = 0; i < defaultTestLength; i += 1) { for (let i = 0; i < defaultTestLength; i += 1) {
ba.deselectOne(dim1, i); ba.deselectOne(dim1, i);
expect(ba.selectionCount()).toEqual(defaultTestLength - i - 1); expect(ba.selectionCount).toEqual(defaultTestLength - i - 1);
expect(ba.selectionCount()).toEqual(ba.countAllOnes()); expect(ba.selectionCount).toEqual(ba.countAllOnes());
} }
ba.freeDimension(dim1); ba.freeDimension(dim1);
@@ -102,39 +102,33 @@ const someData = [
]; ];
function groupReduce(data, valueMap, valueReduce, valueInit) { function groupReduce(data, valueMap, valueReduce, valueInit) {
return _.reduce( return _
data, .reduce(
(acc, value) => { data,
const k = valueMap(value); (acc, value) => {
let r = _.find(acc, o => o.key === k); const k = valueMap(value);
if (!r) { let r = _.find(acc, o => o.key === k);
r = { key: k, value: valueInit() }; if (!r) {
acc.push(r); r = { key: k, value: valueInit() };
} acc.push(r);
r.value = valueReduce(r.value, value); }
return acc; r.value = valueReduce(r.value, value);
}, return acc;
[] },
).sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)); []
)
.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
} }
function groupCount(data, map) { function groupCount(data, map) {
return groupReduce(data, map, p => p + 1, () => 0); return groupReduce(data, map, (p, v) => p + 1, () => 0);
} }
function groupSum(data, map) { function groupSum(data, map) {
return groupReduce( return groupReduce(data, map, (p, v) => (p += map(v)), () => 0);
data,
map,
(p, v) => {
p += map(v);
return p;
},
() => 0
);
} }
let payments = null; var payments = null;
beforeEach(() => { beforeEach(() => {
payments = crossfilter(someData); payments = crossfilter(someData);
}); });
@@ -145,11 +139,7 @@ describe("typedCrossfilter", () => {
expect(payments.size()).toEqual(someData.length); expect(payments.size()).toEqual(someData.length);
expect(payments.all()).toEqual(someData); expect(payments.all()).toEqual(someData);
const quantity = payments.dimension( const quantity = payments.dimension(r => r.quantity, Int32Array);
crossfilter.ScalarDimension,
(i, data) => data[i].quantity,
Int32Array
);
expect(quantity).toBeDefined(); expect(quantity).toBeDefined();
expect(quantity.id()).toBeDefined(); expect(quantity.id()).toBeDefined();
@@ -160,25 +150,10 @@ describe("typedCrossfilter", () => {
test("filterAll and filterNone", () => { test("filterAll and filterNone", () => {
expect(payments).toBeDefined(); expect(payments).toBeDefined();
const quantity = payments.dimension( const quantity = payments.dimension(r => r.quantity, Int32Array);
crossfilter.ScalarDimension, const tip = payments.dimension(r => r.tip, Float32Array);
(i, data) => data[i].quantity, const total = payments.dimension(r => r.total, Float32Array);
Int32Array const type = payments.dimension(r => r.type, "enum");
);
const tip = payments.dimension(
crossfilter.ScalarDimension,
(i, data) => data[i].tip,
Float32Array
);
const total = payments.dimension(
crossfilter.ScalarDimension,
(i, data) => data[i].total,
Float32Array
);
const type = payments.dimension(
crossfilter.EnumDimension,
(i, data) => data[i].type
);
expect(quantity).toBeDefined(); expect(quantity).toBeDefined();
expect(tip).toBeDefined(); expect(tip).toBeDefined();
@@ -223,20 +198,10 @@ describe("typedCrossfilter", () => {
test("filterExact", () => { test("filterExact", () => {
expect(payments).toBeDefined(); expect(payments).toBeDefined();
const quantity = payments.dimension( const quantity = payments.dimension(r => r.quantity, Int32Array);
crossfilter.ScalarDimension, const tip = payments.dimension(r => r.tip, Float32Array);
(i, data) => data[i].quantity, const total = payments.dimension(r => r.total, Float32Array);
Int32Array const type = payments.dimension(r => r.type, "enum");
);
const tip = payments.dimension(
crossfilter.ScalarDimension,
(i, data) => data[i].tip,
Float32Array
);
const type = payments.dimension(
crossfilter.EnumDimension,
(i, data) => data[i].type
);
quantity.filterExact(1); quantity.filterExact(1);
expect(payments.countFiltered()).toEqual( expect(payments.countFiltered()).toEqual(
@@ -257,25 +222,10 @@ describe("typedCrossfilter", () => {
test("filterRange", () => { test("filterRange", () => {
expect(payments).toBeDefined(); expect(payments).toBeDefined();
const quantity = payments.dimension( const quantity = payments.dimension(r => r.quantity, Int32Array);
crossfilter.ScalarDimension, const tip = payments.dimension(r => r.tip, Float32Array);
(i, data) => data[i].quantity, const total = payments.dimension(r => r.total, Float32Array);
Int32Array const type = payments.dimension(r => r.type, "enum");
);
const tip = payments.dimension(
crossfilter.ScalarDimension,
(i, data) => data[i].tip,
Float32Array
);
const total = payments.dimension(
crossfilter.ScalarDimension,
(i, data) => data[i].total,
Float32Array
);
const type = payments.dimension(
crossfilter.EnumDimension,
(i, data) => data[i].type
);
tip.filterRange([0, 91]); tip.filterRange([0, 91]);
expect(payments.allFiltered()).toEqual( expect(payments.allFiltered()).toEqual(
@@ -301,25 +251,10 @@ describe("typedCrossfilter", () => {
test("filterEnum", () => { test("filterEnum", () => {
expect(payments).toBeDefined(); expect(payments).toBeDefined();
const quantity = payments.dimension( const quantity = payments.dimension(r => r.quantity, Int32Array);
crossfilter.ScalarDimension, const tip = payments.dimension(r => r.tip, Float32Array);
(i, data) => data[i].quantity, const total = payments.dimension(r => r.total, Float32Array);
Int32Array const type = payments.dimension(r => r.type, "enum");
);
const tip = payments.dimension(
crossfilter.ScalarDimension,
(i, data) => data[i].tip,
Float32Array
);
const total = payments.dimension(
crossfilter.ScalarDimension,
(i, data) => data[i].total,
Float32Array
);
const type = payments.dimension(
crossfilter.EnumDimension,
(i, data) => data[i].type
);
type.filterEnum(["tab", "cash"]); type.filterEnum(["tab", "cash"]);
expect(payments.allFiltered()).toEqual( expect(payments.allFiltered()).toEqual(
@@ -339,34 +274,15 @@ describe("typedCrossfilter", () => {
test("more than 32 dimensions", () => { test("more than 32 dimensions", () => {
expect(payments).toBeDefined(); expect(payments).toBeDefined();
const quantity = payments.dimension( const quantity = payments.dimension(r => r.quantity, Int32Array);
crossfilter.ScalarDimension, const tip = payments.dimension(r => r.tip, Float32Array);
(i, data) => data[i].quantity, const total = payments.dimension(r => r.total, Float32Array);
Int32Array const type = payments.dimension(r => r.type, "enum");
);
const tip = payments.dimension(
crossfilter.ScalarDimension,
(i, data) => data[i].tip,
Float32Array
);
const total = payments.dimension(
crossfilter.ScalarDimension,
(i, data) => data[i].total,
Float32Array
);
const type = payments.dimension(
crossfilter.EnumDimension,
(i, data) => data[i].type
);
// Create a bunch of fake dimensions to ensure we can handle > 32 // Create a bunch of fake dimensions to ensure we can handle > 32
let dimMap = {}; let dimMap = {};
for (let i = 0; i < 65; i++) { for (let i = 0; i < 65; i++) {
dimMap[i] = payments.dimension( dimMap[i] = payments.dimension(r => Math.random(), Float32Array);
crossfilter.ScalarDimension,
() => Math.random(),
Float32Array
);
expect(dimMap[i]).toBeDefined(); expect(dimMap[i]).toBeDefined();
expect(dimMap[i].id()).toBeDefined(); expect(dimMap[i].id()).toBeDefined();
} }
@@ -388,25 +304,10 @@ describe("typedCrossfilter", () => {
test("group, default mapping, default reducer, no filter", () => { test("group, default mapping, default reducer, no filter", () => {
expect(payments).toBeDefined(); expect(payments).toBeDefined();
const quantity = payments.dimension( var quantity = payments.dimension(r => r.quantity, Int32Array);
crossfilter.ScalarDimension, var tip = payments.dimension(r => r.tip, Int32Array);
(i, data) => data[i].quantity, var type = payments.dimension(r => r.type, "enum");
Int32Array var total = payments.dimension(r => r.total, Int32Array);
);
const tip = payments.dimension(
crossfilter.ScalarDimension,
(i, data) => data[i].tip,
Int32Array
);
const type = payments.dimension(
crossfilter.EnumDimension,
(i, data) => data[i].type
);
const total = payments.dimension(
crossfilter.ScalarDimension,
(i, data) => data[i].total,
Int32Array
);
_.each( _.each(
{ {
@@ -430,20 +331,9 @@ describe("typedCrossfilter", () => {
// custom mapping in groups only works for scalar types. Enums do not // custom mapping in groups only works for scalar types. Enums do not
// currently implement it. // currently implement it.
const tip = payments.dimension( const tip = payments.dimension(r => r.tip, Int32Array);
crossfilter.ScalarDimension, const totalX10 = payments.dimension(r => r.total * 10, Int32Array);
(i, data) => data[i].tip, const type = payments.dimension(r => r.type, "enum");
Int32Array
);
const totalX10 = payments.dimension(
crossfilter.ScalarDimension,
(i, data) => data[i].total * 10,
Int32Array
);
const type = payments.dimension(
crossfilter.EnumDimension,
(i, data) => data[i].type
);
const paymentsByTip_A = tip.group(); const paymentsByTip_A = tip.group();
const paymentsByTip_B = tip.group(r => 10 * r); const paymentsByTip_B = tip.group(r => 10 * r);
@@ -480,15 +370,8 @@ describe("typedCrossfilter", () => {
test("group, default map, custom reducer, no filters", () => { test("group, default map, custom reducer, no filters", () => {
expect(payments).toBeDefined(); expect(payments).toBeDefined();
const total = payments.dimension( const total = payments.dimension(r => r.total, Float32Array);
crossfilter.ScalarDimension, const type = payments.dimension(r => r.type, "enum");
(i, data) => data[i].total,
Float32Array
);
const type = payments.dimension(
crossfilter.EnumDimension,
(i, data) => data[i].type
);
const paymentsByTotal = total.group(); const paymentsByTotal = total.group();
const paymentsByType = type.group(); const paymentsByType = type.group();
@@ -524,20 +407,9 @@ describe("typedCrossfilter", () => {
expect(payments).toBeDefined(); expect(payments).toBeDefined();
const tip = payments.dimension( const tip = payments.dimension(r => r.tip, Int32Array);
crossfilter.ScalarDimension, const total = payments.dimension(r => r.total, Int32Array);
(i, data) => data[i].tip, const type = payments.dimension(r => r.type, "enum");
Int32Array
);
const total = payments.dimension(
crossfilter.ScalarDimension,
(i, data) => data[i].total,
Int32Array
);
const type = payments.dimension(
crossfilter.EnumDimension,
(i, data) => data[i].type
);
const paymentsByTip = tip.group(); const paymentsByTip = tip.group();
const paymentsByTotal = total.group(); const paymentsByTotal = total.group();
+1 -2
View File
@@ -34,8 +34,7 @@ module.exports = {
"object-curly-newline": ["error", { consistent: true }], "object-curly-newline": ["error", { consistent: true }],
"react/prop-types": [0], "react/prop-types": [0],
"space-before-function-paren": "off", "space-before-function-paren": "off",
"function-paren-newline": "off", "function-paren-newline": "off"
"prefer-destructuring": ["error", { object: true, array: false }]
}, },
overrides: [ overrides: [
{ {
@@ -10,7 +10,7 @@ const nodeModules = path.resolve("node_modules");
const babelOptions = require("../babel/babel.prod"); const babelOptions = require("../babel/babel.prod");
const publicPath = ""; const publicPath = "/";
module.exports = { module.exports = {
mode: "production", mode: "production",
+1 -1
View File
@@ -24,7 +24,7 @@
<script type="text/javascript"> <script type="text/javascript">
window.CELLXGENE = {}; window.CELLXGENE = {};
window.CELLXGENE.API = { window.CELLXGENE.API = {
prefix: window.location.href + "api/", prefix: "{{ prefix | safe }}",
version: "v0.2/" version: "v0.2/"
}; };
</script> </script>
+1974 -2898
View File
File diff suppressed because it is too large Load Diff
+22 -31
View File
@@ -1,20 +1,16 @@
{ {
"name": "cellxgene", "name": "cellxgene",
"version": "0.7.0", "version": "0.0.3",
"license": "MIT", "license": "MIT",
"description": "cellxgene is a web application for the interactive exploration of single cell sequence data.", "description": "cellxgene is a web application for the interactive exploration of single cell sequence data.",
"repository": "https://github.com/chanzuckerberg/cellxgene", "repository": "https://github.com/chanzuckerberg/cellxgene",
"scripts": { "scripts": {
"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", "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", "dev": "npm run clean && webpack --config configuration/webpack/webpack.config.dev.js",
"e2e": "jest e2e", "clean": "rimraf build",
"lint": "eslint src",
"smoke-test": "start-server-and-test start-server-for-test :5000 e2e",
"start": "node server/development.js", "start": "node server/development.js",
"start-server-for-test": "cellxgene launch -p 5000 ../example-dataset/pbmc3k.h5ad", "lint": "eslint src",
"unit-test": "jest --testPathIgnorePatterns e2e" "test": "jest"
}, },
"engineStrict": true, "engineStrict": true,
"engines": { "engines": {
@@ -23,9 +19,10 @@
"eslintConfig": { "eslintConfig": {
"extends": "./configuration/eslint/eslint.js" "extends": "./configuration/eslint/eslint.js"
}, },
"eslintIgnore": [ "nyc": {
"src/util/stateManager/matrix_generated.js" "sourceMap": false,
], "instrument": false
},
"resolutions": { "resolutions": {
"eslint-scope": "3.7.1" "eslint-scope": "3.7.1"
}, },
@@ -36,12 +33,9 @@
"canvas-fit": "^1.5.0", "canvas-fit": "^1.5.0",
"d3": "^4.10.0", "d3": "^4.10.0",
"d3-scale-chromatic": "^1.3.0", "d3-scale-chromatic": "^1.3.0",
"flatbuffers": "^1.10.2",
"font-color-contrast": "^1.0.3", "font-color-contrast": "^1.0.3",
"fuzzysort": "^1.1.4",
"gl-mat4": "^1.1.4", "gl-mat4": "^1.1.4",
"gl-matrix": "^2.7.1", "gl-matrix": "^2.7.1",
"is-number": "^7.0.0",
"key-pressed": "0.0.1", "key-pressed": "0.0.1",
"lodash": "^4.17.4", "lodash": "^4.17.4",
"memoize-one": "^4.0.0", "memoize-one": "^4.0.0",
@@ -79,31 +73,31 @@
"babel-eslint": "^10.0.1", "babel-eslint": "^10.0.1",
"babel-jest": "^23.6.0", "babel-jest": "^23.6.0",
"babel-loader": "^8.0.0", "babel-loader": "^8.0.0",
"babel-plugin-istanbul": "^5.1.0",
"babel-preset-modern-browsers": "^12.0.0", "babel-preset-modern-browsers": "^12.0.0",
"chalk": "^2.4.2", "chalk": "^2.4.1",
"connect-history-api-fallback": "^1.6.0", "connect-history-api-fallback": "^1.3.0",
"copy-webpack-plugin": "^4.6.0", "copy-webpack-plugin": "^4.6.0",
"css-loader": "^1.0.1", "css-loader": "^1.0.1",
"eslint": "^5.13.0", "eslint": "^5.8.0",
"eslint-config-airbnb": "^17.1.0", "eslint-config-airbnb": "^17.1.0",
"eslint-config-prettier": "^4.0.0", "eslint-config-prettier": "^3.1.0",
"eslint-loader": "^2.1.2", "eslint-loader": "^2.1.1",
"eslint-plugin-filenames": "^1.3.2", "eslint-plugin-filenames": "^1.3.2",
"eslint-plugin-import": "^2.16.0", "eslint-plugin-import": "^2.14.0",
"eslint-plugin-jest": "^22.2.2", "eslint-plugin-jest": "^21.27.2",
"eslint-plugin-jsx-a11y": "^6.2.1", "eslint-plugin-jsx-a11y": "^6.1.1",
"eslint-plugin-react": "^7.12.4", "eslint-plugin-react": "^7.11.1",
"express": "^4.14.0", "express": "^4.14.0",
"file-loader": "^2.0.0", "file-loader": "^2.0.0",
"html-webpack-inline-source-plugin": "0.0.10", "html-webpack-inline-source-plugin": "0.0.10",
"html-webpack-plugin": "^3.2.0", "html-webpack-plugin": "^3.2.0",
"jest": "^24.1.0", "jest": "^23.5.0",
"json-loader": "^0.5.4", "json-loader": "^0.5.4",
"mini-css-extract-plugin": "^0.4.1", "mini-css-extract-plugin": "^0.4.1",
"puppeteer": "^1.12.1", "nyc": "^13.0.1",
"rimraf": "^2.6.3", "rimraf": "^2.5.4",
"serve-favicon": "^2.3.0", "serve-favicon": "^2.3.0",
"start-server-and-test": "^1.7.11",
"style-loader": "^0.23.1", "style-loader": "^0.23.1",
"sw-precache-webpack-plugin": "^0.11.5", "sw-precache-webpack-plugin": "^0.11.5",
"url-loader": "^1.1.0", "url-loader": "^1.1.0",
@@ -115,10 +109,7 @@
"testMatch": [ "testMatch": [
"**/__tests__/**/?(*.)(spec|test).js?(x)" "**/__tests__/**/?(*.)(spec|test).js?(x)"
], ],
"testURL": "http://localhost/", "testURL": "http://localhost/"
"setupFiles": [
"./__tests__/setupMissingGlobals.js"
]
}, },
"babel": { "babel": {
"env": { "env": {
+50 -69
View File
@@ -1,11 +1,11 @@
// jshint esversion: 6 // jshint esversion: 6
import _ from "lodash"; import _ from "lodash";
import * as globals from "../globals"; import * as globals from "../globals";
import { Universe } from "../util/stateManager"; import { Universe, kvCache } from "../util/stateManager";
import { import {
catchErrorsWrap, catchErrorsWrap,
doJsonRequest, doJsonRequest,
doBinaryRequest, rangeEncodeIndices,
dispatchNetworkErrorMessageToUser dispatchNetworkErrorMessageToUser
} from "../util/actionHelpers"; } from "../util/actionHelpers";
@@ -13,33 +13,29 @@ import {
Bootstrap application with the initial data loading. Bootstrap application with the initial data loading.
* /config - application configuration * /config - application configuration
* /schema - schema of dataframe * /schema - schema of dataframe
* /annotations - all metadata annotation * /annotations/obs - all metadata annotation
* /layout - all default layout
*/ */
const doInitialDataLoad = () => const doInitialDataLoad = () =>
catchErrorsWrap(async dispatch => { catchErrorsWrap(async dispatch => {
dispatch({ type: "initial data load start" }); dispatch({ type: "initial data load start" });
try { try {
const requestJson = _(["config", "schema"]) const requests = _([
.map(r => `${globals.API.prefix}${globals.API.version}${r}`) "config",
.map(url => doJsonRequest(url)) "schema",
.value();
const requestBinary = _([
"annotations/obs", "annotations/obs",
"annotations/var?annotation-name=name", "annotations/var",
"layout/obs" "layout/obs"
]) ])
.map(r => `${globals.API.prefix}${globals.API.version}${r}`) .map(r => `${globals.API.prefix}${globals.API.version}${r}`)
.map(url => doBinaryRequest(url)) .map(url => doJsonRequest(url))
.value(); .value();
const results = await Promise.all(requests);
const results = await Promise.all(_.concat(requestJson, requestBinary));
/* set config defaults */ /* set config defaults */
const config = { ...globals.configDefaults, ...results[0].config }; const config = { ...globals.configDefaults, ...results[0].config };
const [, schema, obsAnno, varAnno, obsLayout] = [...results]; const [, schema, obsAnno, varAnno, obsLayout] = [...results];
const universe = Universe.createUniverseFromResponse( const universe = Universe.createUniverseFromRestV02Response(
config, config,
schema, schema,
obsAnno, obsAnno,
@@ -91,47 +87,15 @@ needs expression data.
Transparently utilizes cached data if it is already present. Transparently utilizes cached data if it is already present.
*/ */
async function _doRequestExpressionData(dispatch, getState, genes) { async function _doRequestExpressionData(dispatch, getState, genes) {
/* 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: "name", 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 state = getState(); const state = getState();
const { universe } = state.controls; const { universe } = state.controls;
/* preload data already in cache */ /* preload data already in cache */
let expressionData = _.transform( let expressionData = _.transform(
genes, genes,
(expData, g) => { (expData, g) => {
const data = universe.varData.col(g); const data = kvCache.get(universe.varDataCache, g);
if (data) { if (data) {
expData[g] = data.asArray(); expData[g] = data;
} }
}, },
{} {}
@@ -144,10 +108,35 @@ async function _doRequestExpressionData(dispatch, getState, genes) {
/* Fetch data for any genes not in cache */ /* Fetch data for any genes not in cache */
if (genesToFetch.length) { if (genesToFetch.length) {
try { try {
const newExpressionData = await fetchData(genesToFetch); // XXX: TODO - this could be using /data/var rather than /data/obs,
// as that would simplify the transformation in convertExpressionRESTv02ToObject
const res = await fetch(
`${globals.API.prefix}${globals.API.version}data/obs`,
{
method: "PUT",
body: JSON.stringify({
filter: {
var: {
annotation_value: [{ name: "name", values: genesToFetch }]
}
}
}),
headers: new Headers({
accept: "application/json",
"Content-Type": "application/json"
})
}
);
if (!res.ok || res.headers.get("Content-Type") !== "application/json") {
// WILL throw
return dispatchExpressionErrors(dispatch, res);
}
const data = await res.json();
expressionData = { expressionData = {
...expressionData, ...expressionData,
...newExpressionData ...Universe.convertExpressionRESTv02ToObject(universe, data)
}; };
} catch (error) { } catch (error) {
dispatch({ type: "expression load error", error }); dispatch({ type: "expression load error", error });
@@ -169,7 +158,7 @@ function requestSingleGeneExpressionCountsForColoringPOST(gene) {
type: "color by expression", type: "color by expression",
gene, gene,
data: { data: {
[gene]: world.varData.col(gene).asArray() [gene]: kvCache.get(world.varDataCache, gene)
} }
}); });
} catch (error) { } catch (error) {
@@ -192,7 +181,7 @@ const requestUserDefinedGene = gene => async (dispatch, getState) => {
type: "request user defined gene success", type: "request user defined gene success",
data: { data: {
genes: [gene], genes: [gene],
expression: world.varData.col(gene).asArray() expression: kvCache.get(world.varDataCache, gene)
} }
}); });
} catch (error) { } catch (error) {
@@ -241,18 +230,12 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async (
*/ */
const state = getState(); const state = getState();
const { universe } = state.controls; const { universe } = state.controls;
const set1ByIndex = rangeEncodeIndices(
// Legal values are null, Array or TypedArray. Null is initial state. _.map(set1, s => universe.obsNameToIndexMap[s])
if (!set1) set1 = []; );
if (!set2) set2 = []; const set2ByIndex = rangeEncodeIndices(
_.map(set2, s => universe.obsNameToIndexMap[s])
// 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 res = await fetch( const res = await fetch(
`${globals.API.prefix}${globals.API.version}diffexp/obs`, `${globals.API.prefix}${globals.API.version}diffexp/obs`,
{ {
@@ -264,8 +247,8 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async (
body: JSON.stringify({ body: JSON.stringify({
mode: "topN", mode: "topN",
count: num_genes, count: num_genes,
set1: { filter: { obs: { index: set1 } } }, set1: { filter: { obs: { index: set1ByIndex } } },
set2: { filter: { obs: { index: set2 } } } set2: { filter: { obs: { index: set2ByIndex } } }
}) })
} }
); );
@@ -276,9 +259,7 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async (
const data = await res.json(); const data = await res.json();
// result is [ [varIdx, ...], ... ] // result is [ [varIdx, ...], ... ]
const topNGenes = _.map(data, r => const topNGenes = _.map(data, r => universe.varAnnotations[r[0]].name);
universe.varAnnotations.at(r[0], "name")
);
/* /*
Kick off secondary action to fetch all of the expression data for the Kick off secondary action to fetch all of the expression data for the
@@ -10,9 +10,9 @@ import { Button, ButtonGroup, Tooltip } from "@blueprintjs/core";
import { connect } from "react-redux"; import { connect } from "react-redux";
import * as d3 from "d3"; import * as d3 from "d3";
import memoize from "memoize-one"; import memoize from "memoize-one";
import { kvCache } from "../../util/stateManager";
import * as globals from "../../globals"; import * as globals from "../../globals";
import actions from "../../actions"; import actions from "../../actions";
import finiteExtent from "../../util/finiteExtent";
@connect(state => ({ @connect(state => ({
world: state.controls.world, world: state.controls.world,
@@ -20,12 +20,13 @@ import finiteExtent from "../../util/finiteExtent";
scatterplotYYaccessor: state.controls.scatterplotYYaccessor, scatterplotYYaccessor: state.controls.scatterplotYYaccessor,
crossfilter: state.controls.crossfilter, crossfilter: state.controls.crossfilter,
differential: state.differential, differential: state.differential,
initializeRanges: _.get(state.controls.world, "summary.obs"),
colorAccessor: state.controls.colorAccessor, colorAccessor: state.controls.colorAccessor,
colorScale: state.controls.colorScale, colorScale: state.controls.colorScale,
obsAnnotations: _.get(state.controls.world, "obsAnnotations", null) obsAnnotations: _.get(state.controls.world, "obsAnnotations", null)
})) }))
class HistogramBrush extends React.Component { class HistogramBrush extends React.Component {
calcHistogramCache = memoize((obsAnnotations, field, rangeMin, rangeMax) => { calcHistogramCache = memoize((obsAnnotations, field, ranges) => {
const { world } = this.props; const { world } = this.props;
const histogramCache = {}; const histogramCache = {};
@@ -33,15 +34,13 @@ class HistogramBrush extends React.Component {
.scaleLinear() .scaleLinear()
.range([this.height - this.marginBottom, 0]); .range([this.height - this.marginBottom, 0]);
if (obsAnnotations.hasCol(field)) { if (obsAnnotations[0][field]) {
// recalculate expensive stuff // recalculate expensive stuff
const allValuesForContinuousFieldAsArray = obsAnnotations const allValuesForContinuousFieldAsArray = _.map(obsAnnotations, field);
.col(field)
.asArray();
histogramCache.x = d3 histogramCache.x = d3
.scaleLinear() .scaleLinear()
.domain([rangeMin, rangeMax]) .domain([ranges.min, ranges.max])
.range([0, this.width]); .range([0, this.width]);
histogramCache.bins = d3 histogramCache.bins = d3
@@ -50,13 +49,14 @@ class HistogramBrush extends React.Component {
.thresholds(40)(allValuesForContinuousFieldAsArray); .thresholds(40)(allValuesForContinuousFieldAsArray);
histogramCache.numValues = allValuesForContinuousFieldAsArray.length; histogramCache.numValues = allValuesForContinuousFieldAsArray.length;
} else if (world.varData.hasCol(field)) { } else if (kvCache.get(world.varDataCache, field)) {
const varValues = world.varData.col(field).asArray(); /* 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 histogramCache.x = d3
.scaleLinear() .scaleLinear()
.domain( .domain(
finiteExtent(varValues) d3.extent(varValues)
) /* replace this if we have ranges for genes back from server like we do for annotations on cells */ ) /* replace this if we have ranges for genes back from server like we do for annotations on cells */
.range([0, this.width]); .range([0, this.width]);
@@ -130,8 +130,7 @@ class HistogramBrush extends React.Component {
const histogramCache = this.calcHistogramCache( const histogramCache = this.calcHistogramCache(
obsAnnotations, obsAnnotations,
field, field,
ranges.min, ranges
ranges.max
); );
const { x, y, bins, numValues } = histogramCache; const { x, y, bins, numValues } = histogramCache;
@@ -140,27 +139,27 @@ class HistogramBrush extends React.Component {
} }
handleColorAction() { handleColorAction() {
const { obsAnnotations, dispatch, field, world, ranges } = this.props; const {
obsAnnotations,
dispatch,
field,
world,
initializeRanges
} = this.props;
if (obsAnnotations.hasCol(field)) { if (obsAnnotations[0][field]) {
dispatch({ dispatch({
type: "color by continuous metadata", type: "color by continuous metadata",
colorAccessor: field, 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)); dispatch(actions.requestSingleGeneExpressionCountsForColoringPOST(field));
} }
} }
removeHistogram() { removeHistogram() {
const { const { dispatch, field, colorAccessor } = this.props;
dispatch,
field,
colorAccessor,
scatterplotXXaccessor,
scatterplotYYaccessor
} = this.props;
dispatch({ dispatch({
type: "clear user defined gene", type: "clear user defined gene",
data: field data: field
@@ -170,18 +169,6 @@ class HistogramBrush extends React.Component {
type: "reset colorscale" type: "reset colorscale"
}); });
} }
if (field === scatterplotXXaccessor) {
dispatch({
type: "set scatterplot x",
data: null
});
}
if (field === scatterplotYYaccessor) {
dispatch({
type: "set scatterplot y",
data: null
});
}
} }
handleSetGeneAsScatterplotX() { handleSetGeneAsScatterplotX() {
@@ -228,7 +215,6 @@ class HistogramBrush extends React.Component {
d3.select(svgRef) d3.select(svgRef)
.append("g") .append("g")
.attr("class", "brush") .attr("class", "brush")
.attr("data-testid", `${svgRef.id}-brush`)
.call( .call(
d3 d3
.brushX() .brushX()
@@ -273,15 +259,13 @@ class HistogramBrush extends React.Component {
return ( return (
<div <div
id={`histogram_${field}`} id={`histogram_${field}`}
data-testid={`histogram-${field}`}
data-testclass={isDiffExp ? `histogram-diffexp` : ""}
style={{ style={{
padding: globals.leftSidebarSectionPadding, padding: globals.leftSidebarSectionPadding,
backgroundColor: zebra ? globals.lightestGrey : "white" backgroundColor: zebra ? globals.lightestGrey : "white"
}} }}
> >
<div style={{ display: "flex", justifyContent: "flex-end" }}> <div style={{ display: "flex", justifyContent: "flex-end" }}>
{isDiffExp || isUserDefined ? ( {isDiffExp ? (
<span> <span>
<span <span
style={{ marginRight: 7 }} style={{ marginRight: 7 }}
@@ -5,13 +5,34 @@ import { connect } from "react-redux";
import * as globals from "../../globals"; import * as globals from "../../globals";
import Category from "./category"; 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 => ({ @connect(state => ({
categoricalSelectionState: state.controls.categoricalSelectionState 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 { class Categories extends React.Component {
render() { render() {
const { categoricalSelectionState } = this.props; const { ranges, categorySelectionLimit } = this.props;
if (!categoricalSelectionState) return null; if (!ranges) return null;
return ( return (
<div <div
@@ -26,9 +47,27 @@ class Categories extends React.Component {
> >
Categorical Metadata Categorical Metadata
</p> </p>
{_.map(categoricalSelectionState, (catState, catName) => ( {_.map(ranges, (value, key) => {
<Category key={catName} metadataField={catName} /> 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> </div>
); );
} }
+39 -34
View File
@@ -2,15 +2,29 @@ import React from "react";
import _ from "lodash"; import _ from "lodash";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { FaChevronRight, FaChevronDown } from "react-icons/fa"; 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 * as globals from "../../globals";
import Value from "./value"; 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 => ({ @connect(state => ({
colorAccessor: state.controls.colorAccessor, colorAccessor: state.controls.colorAccessor,
categoricalSelectionState: state.controls.categoricalSelectionState categoricalAsBooleansMap: state.controls.categoricalAsBooleansMap
})) }))
class Category extends React.Component { class Category extends React.Component {
constructor(props) { constructor(props) {
@@ -19,28 +33,24 @@ class Category extends React.Component {
isChecked: true, isChecked: true,
isExpanded: false isExpanded: false
}; };
this.countCategories = memoize((values, optsAsBools) =>
countCategories(values, optsAsBools)
);
} }
componentDidUpdate() { componentDidUpdate() {
const { categoricalSelectionState, metadataField } = this.props; const { categoricalAsBooleansMap, metadataField, values } = this.props;
const cat = categoricalSelectionState[metadataField]; const categoryCount = this.countCategories(
const categoryCount = { values,
// total number of categories in this dimension categoricalAsBooleansMap[metadataField]
totalCatCount: cat.numCategories, );
// number of selected options in this category if (categoryCount.on === categoryCount.total) {
selectedCatCount: _.reduce(
cat.categorySelected,
(res, cond) => (cond ? res + 1 : res),
0
)
};
if (categoryCount.selectedCatCount === categoryCount.totalCatCount) {
/* everything is on, so not indeterminate */ /* everything is on, so not indeterminate */
this.checkbox.indeterminate = false; this.checkbox.indeterminate = false;
} else if (categoryCount.selectedCatCount === 0) { } else if (categoryCount.on === 0) {
/* nothing is on, so no */ /* nothing is on, so no */
this.checkbox.indeterminate = false; this.checkbox.indeterminate = false;
} else if (categoryCount.selectedCatCount < categoryCount.totalCatCount) { } else if (categoryCount.on < categoryCount.total) {
/* to be explicit... */ /* to be explicit... */
this.checkbox.indeterminate = true; this.checkbox.indeterminate = true;
} }
@@ -64,10 +74,11 @@ class Category extends React.Component {
} }
toggleNone() { toggleNone() {
const { dispatch, metadataField } = this.props; const { dispatch, metadataField, value } = this.props;
dispatch({ dispatch({
type: "categorical metadata filter none of these", type: "categorical metadata filter none of these",
metadataField metadataField,
value
}); });
this.setState({ isChecked: false }); this.setState({ isChecked: false });
} }
@@ -83,16 +94,13 @@ class Category extends React.Component {
} }
renderCategoryItems() { renderCategoryItems() {
const { categoricalSelectionState, metadataField } = this.props; const { values, metadataField } = this.props;
return _.map(alphabeticallySortedValues(values), (v, i) => (
const cat = categoricalSelectionState[metadataField];
const optTuples = sortedCategoryValues([...cat.categoryIndices]);
return _.map(optTuples, (tuple, i) => (
<Value <Value
optTuples={optTuples} key={v}
key={tuple[1]}
metadataField={metadataField} metadataField={metadataField}
categoryIndex={tuple[1]} count={values[v]}
value={v}
i={i} i={i}
/> />
)); ));
@@ -100,15 +108,12 @@ class Category extends React.Component {
render() { render() {
const { isExpanded, isChecked } = this.state; const { isExpanded, isChecked } = this.state;
const { const { metadataField, colorAccessor, isTruncated } = this.props;
metadataField,
colorAccessor,
categoricalSelectionState
} = this.props;
const { isTruncated } = categoricalSelectionState[metadataField];
return ( return (
<div <div
style={{ style={{
// display: "flex",
// alignItems: "baseline",
maxWidth: globals.maxControlsWidth maxWidth: globals.maxControlsWidth
}} }}
> >
@@ -1,73 +0,0 @@
// jshint esversion: 6
import React from "react";
import _ from "lodash";
import { connect } from "react-redux";
import * as d3 from "d3";
@connect()
class Occupancy extends React.Component {
render() {
const {
occupancy,
colorScale,
categoricalSelectionState,
colorAccessor,
schema
} = this.props;
const width = 100;
const height = 11;
const categories = _.filter(schema.annotations.obs, {
name: colorAccessor
})[0].categories;
const x = d3
.scaleLinear()
/* get all the keys d[1] as an array, then find the sum */
.domain([0, d3.sum(Array.from(occupancy, d => d[1]))])
.range([0, width]);
let currentOffset = 0;
const stacks = categoricalSelectionState[colorAccessor].categoryValues.map(
d => {
const o = occupancy.get(d);
const scaledValue = x(o);
const stackItem = {
key: d,
value: o || 0,
rectWidth: o ? scaledValue : 0,
offset: currentOffset,
fill: o ? colorScale(categories.indexOf(d)) : "rgb(255,255,255)"
};
currentOffset += o ? scaledValue : 0;
return stackItem;
}
);
return (
<svg
style={{
marginRight: 5,
width,
height
}}
>
{stacks.map(d => (
<rect
key={d.key}
width={d.rectWidth}
height={height}
x={d.offset}
title={d.metadataField}
fill={d.fill}
/>
))}
</svg>
);
}
}
export default Occupancy;
+4 -32
View File
@@ -1,35 +1,7 @@
// jshint esversion: 6 // jshint esversion: 6
export default values =>
// values is [ [optVal, optIdx], ...] Object.keys(values).sort((a, b) => {
// index is range array const textA = a.toUpperCase();
// return sorted index const textB = b.toUpperCase();
import isNumber from "is-number";
import _ from "lodash";
const sortedCategoryValues = values => {
/* this sort could be memoized for perf */
const strings = [];
const ints = [];
_.forEach(values, 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();
return textA < textB ? -1 : textA > textB ? 1 : 0; return textA < textB ? -1 : textA > textB ? 1 : 0;
}); });
ints.sort((a, b) => +a[0] - +b[0]);
return ints.concat(strings);
};
export default sortedCategoryValues;
+16 -68
View File
@@ -1,81 +1,47 @@
// jshint esversion: 6 // jshint esversion: 6
import { connect } from "react-redux"; import { connect } from "react-redux";
import React from "react"; import React from "react";
import _ from "lodash";
import Occupancy from "./occupancy";
import { countCategoryValues2D } from "../../util/stateManager/worldUtil";
import * as globals from "../../globals";
@connect(state => ({ @connect(state => ({
categoricalSelectionState: state.controls.categoricalSelectionState, categoricalAsBooleansMap: state.controls.categoricalAsBooleansMap,
colorScale: state.controls.colorScale, colorScale: state.controls.colorScale,
colorAccessor: state.controls.colorAccessor, colorAccessor: state.controls.colorAccessor
schema: _.get(state.controls.world, "schema", null),
world: state.controls.world
})) }))
class CategoryValue extends React.Component { class CategoryValue extends React.Component {
toggleOff() { toggleOff() {
const { dispatch, metadataField, categoryIndex } = this.props; const { dispatch, metadataField, value } = this.props;
dispatch({ dispatch({
type: "categorical metadata filter deselect", type: "categorical metadata filter deselect",
metadataField, metadataField,
categoryIndex value
}); });
} }
toggleOn() { toggleOn() {
const { dispatch, metadataField, categoryIndex } = this.props; const { dispatch, metadataField, value } = this.props;
dispatch({ dispatch({
type: "categorical metadata filter select", type: "categorical metadata filter select",
metadataField, metadataField,
categoryIndex value
}); });
} }
render() { render() {
const { const {
categoricalSelectionState, categoricalAsBooleansMap,
metadataField, metadataField,
categoryIndex, count,
value,
colorAccessor, colorAccessor,
colorScale, colorScale,
i, i
schema,
world
} = this.props; } = this.props;
if (!categoricalSelectionState) return null; if (!categoricalAsBooleansMap) return null;
const category = categoricalSelectionState[metadataField];
const selected = category.categorySelected[categoryIndex];
const count = category.categoryCounts[categoryIndex];
const value = category.categoryValues[categoryIndex];
const displayString = String(
category.categoryValues[categoryIndex]
).valueOf();
const selected = categoricalAsBooleansMap[metadataField][value];
/* this is the color scale, so add swatches below */ /* this is the color scale, so add swatches below */
const isColorBy = metadataField === colorAccessor; const c = metadataField === colorAccessor;
let categories = null;
let occupancy = null;
if (isColorBy && schema) {
categories = _.filter(schema.annotations.obs, {
name: colorAccessor
})[0].categories;
}
if (
colorAccessor &&
!isColorBy &&
categoricalSelectionState[colorAccessor]
) {
occupancy = countCategoryValues2D(
metadataField,
colorAccessor,
world.obsAnnotations
);
}
return ( return (
<div <div
@@ -90,10 +56,7 @@ class CategoryValue extends React.Component {
style={{ style={{
margin: 0, margin: 0,
padding: 0, padding: 0,
userSelect: "none", userSelect: "none"
width: globals.leftSidebarWidth - 130,
display: "flex",
justifyContent: "space-between"
}} }}
> >
<label className="bp3-control bp3-checkbox"> <label className="bp3-control bp3-checkbox">
@@ -105,20 +68,8 @@ class CategoryValue extends React.Component {
type="checkbox" type="checkbox"
/> />
<span className="bp3-control-indicator" /> <span className="bp3-control-indicator" />
{displayString} {value}
</label> </label>
<span style={{ flexShrink: 0 }}>
{colorAccessor &&
!isColorBy &&
categoricalSelectionState[colorAccessor] ? (
<Occupancy
occupancy={occupancy.get(
category.categoryValues[categoryIndex]
)}
{...this.props}
/>
) : null}
</span>
</div> </div>
<span> <span>
<span>{count}</span> <span>{count}</span>
@@ -127,10 +78,7 @@ class CategoryValue extends React.Component {
marginLeft: 5, marginLeft: 5,
width: 11, width: 11,
height: 11, height: 11,
backgroundColor: backgroundColor: c ? colorScale(value) : "inherit"
isColorBy && categories
? colorScale(categories.indexOf(value))
: "inherit"
}} }}
/> />
</span> </span>
+23 -33
View File
@@ -9,7 +9,8 @@ import * as globals from "../../globals";
import HistogramBrush from "../brushableHistogram"; import HistogramBrush from "../brushableHistogram";
@connect(state => ({ @connect(state => ({
obsAnnotations: _.get(state.controls.world, "obsAnnotations", null), ranges: _.get(state.controls.world, "summary.obs", null),
metadata: _.get(state.controls.world, "obsAnnotations", null),
colorAccessor: state.controls.colorAccessor, colorAccessor: state.controls.colorAccessor,
colorScale: state.controls.colorScale, colorScale: state.controls.colorScale,
selectionUpdate: _.get(state.controls, "crossfilter.updateTime", null), selectionUpdate: _.get(state.controls, "crossfilter.updateTime", null),
@@ -28,18 +29,17 @@ class Continuous extends React.Component {
handleColorAction(key) { handleColorAction(key) {
return () => { return () => {
const { dispatch, obsAnnotations } = this.props; const { dispatch, ranges } = this.props;
const summary = obsAnnotations.col(key).summarize();
dispatch({ dispatch({
type: "color by continuous metadata", type: "color by continuous metadata",
colorAccessor: key, colorAccessor: key,
rangeForColorAccessor: summary rangeMaxForColorAccessor: ranges[key].range.max
}); });
}; };
} }
render() { render() {
const { obsAnnotations, schema } = this.props; const { ranges, obsAnnotations, schema } = this.props;
if (schema && !this.continuousChecked) { if (schema && !this.continuousChecked) {
this.hasContinuous = _.some( this.hasContinuous = _.some(
schema.annotations.obs, schema.annotations.obs,
@@ -63,34 +63,24 @@ class Continuous extends React.Component {
Continuous metadata Continuous metadata
</p> </p>
) : null} ) : null}
{obsAnnotations {_.map(ranges, (value, key) => {
? _.map(obsAnnotations.colIndex.keys(), key => { const isColorField = key.includes("color") || key.includes("Color");
const summary = obsAnnotations.col(key).summarize(); zebra += 1;
const isColorField = if (value.range && key !== "name" && !isColorField) {
key.includes("color") || key.includes("Color"); return (
const nonFiniteExtent = <HistogramBrush
summary.min === undefined || summary.max === undefined; key={key}
zebra += 1; field={key}
if ( isObs
!summary.categorical && zebra={zebra % 2 === 0}
key !== "name" && fieldValues={obsAnnotations}
!isColorField && ranges={value.range}
!nonFiniteExtent handleColorAction={this.handleColorAction(key).bind(this)}
) { />
return ( );
<HistogramBrush }
key={key} return null;
field={key} })}
isObs
zebra={zebra % 2 === 0}
ranges={summary}
handleColorAction={this.handleColorAction(key).bind(this)}
/>
);
}
return null;
})
: null}
</div> </div>
); );
} }
@@ -2,7 +2,7 @@
import React from "react"; import React from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import * as d3 from "d3"; import * as d3 from "d3";
import { interpolateViridis, interpolateCool } from "d3-scale-chromatic"; import { interpolateViridis } from "d3-scale-chromatic";
// create continuous color legend // create continuous color legend
// http://bl.ocks.org/syntagmatic/e8ccca52559796be775553b467593a9f // http://bl.ocks.org/syntagmatic/e8ccca52559796be775553b467593a9f
@@ -121,12 +121,12 @@ class ContinuousLegend extends React.Component {
.remove(); .remove();
} }
if (colorAccessor && colorScale && colorScale.range) { if (colorAccessor && colorScale) {
/* fragile! continuous range is 0 to 1, not [#fa4b2c, ...], make this a flag? */ /* fragile! continuous range is 0 to 1, not [#fa4b2c, ...], make this a flag? */
if (colorScale.range()[0][0] !== "#") { if (colorScale.range()[0][0] !== "#") {
continuous( continuous(
"#continuous_legend", "#continuous_legend",
d3.scaleSequential(interpolateCool).domain(colorScale.domain()), d3.scaleSequential(interpolateViridis).domain(colorScale.domain()),
colorAccessor colorAccessor
); );
} }
@@ -13,13 +13,6 @@ A "user" error - eg, bad input
export const postUserErrorToast = message => export const postUserErrorToast = message =>
ErrorToastTopCenter.show({ message, intent: Intent.WARNING }); 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 a hard network error
*/ */
@@ -1,8 +1,8 @@
// jshint esversion: 6 // jshint esversion: 6
import React from "react"; 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 { connect } from "react-redux";
import { World } from "../../util/stateManager";
@connect() @connect()
class CellSetButton extends React.Component { class CellSetButton extends React.Component {
@@ -14,11 +14,7 @@ class CellSetButton extends React.Component {
eitherCellSetOneOrTwo eitherCellSetOneOrTwo
} = this.props; } = this.props;
// Reducer and components assume that value will be null if const set = _.map(crossfilter.allFiltered(), "name");
// no selection made. World..getSelectedByIndex() returns a
// zero length TypedArray when nothing is selected.
let set = World.getSelectedByIndex(crossfilter);
if (set.length === 0) set = null;
if (!differential.diffExp) { if (!differential.diffExp) {
/* diffexp needs to be cleared before we store a new set */ /* diffexp needs to be cleared before we store a new set */
@@ -37,19 +33,17 @@ class CellSetButton extends React.Component {
content="Save current selection for differential expression computation" content="Save current selection for differential expression computation"
position="top" position="top"
> >
<AnchorButton <Button
style={{ marginRight: 10 }} style={{ marginRight: 10 }}
type="button" type="button"
disabled={differential.diffExp}
onClick={this.set.bind(this)} onClick={this.set.bind(this)}
data-testid={`cellset-button-${eitherCellSetOneOrTwo}`}
> >
{eitherCellSetOneOrTwo} {eitherCellSetOneOrTwo}
{": "} {": "}
{differential[cellListName] {differential[cellListName]
? `${differential[cellListName].length} cells` ? `${differential[cellListName].length} cells`
: "0 cells"} : "0 cells"}
</AnchorButton> </Button>
</Tooltip> </Tooltip>
); );
} }
@@ -68,8 +68,6 @@ class Expression extends React.Component {
style={{ marginTop: 10 }} style={{ marginTop: 10 }}
disabled={!haveBothCellSets} disabled={!haveBothCellSets}
intent="primary" intent="primary"
data-testid="diffexp-button"
loading={differential.loading}
fill fill
type="button" type="button"
onClick={this.computeDiffExp.bind(this)} onClick={this.computeDiffExp.bind(this)}
+48 -227
View File
@@ -3,65 +3,28 @@
import React from "react"; import React from "react";
import _ from "lodash"; import _ from "lodash";
import fuzzysort from "fuzzysort"; import * as d3 from "d3";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { import { Button, Tooltip } from "@blueprintjs/core";
MenuItem,
Button,
FormGroup,
InputGroup,
ControlGroup
} from "@blueprintjs/core";
import { Suggest } from "@blueprintjs/select";
import HistogramBrush from "../brushableHistogram"; import HistogramBrush from "../brushableHistogram";
import * as globals from "../../globals"; import * as globals from "../../globals";
import actions from "../../actions"; import actions from "../../actions";
import { import { postUserErrorToast } from "../framework/toasters";
postUserErrorToast,
keepAroundErrorToast
} from "../framework/toasters";
import ExpressionButtons from "./expressionButtons"; import ExpressionButtons from "./expressionButtons";
import finiteExtent from "../../util/finiteExtent";
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}
// 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
});
@connect(state => { @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 { return {
obsAnnotations: _.get(state.controls.world, "obsAnnotations", null), ranges,
metadata,
initializeRanges,
userDefinedGenes: state.controls.userDefinedGenes, userDefinedGenes: state.controls.userDefinedGenes,
userDefinedGenesLoading: state.controls.userDefinedGenesLoading,
world: state.controls.world, world: state.controls.world,
colorAccessor: state.controls.colorAccessor, colorAccessor: state.controls.colorAccessor,
allGeneNames: state.controls.allGeneNames,
differential: state.differential differential: state.differential
}; };
}) })
@@ -69,52 +32,27 @@ class GeneExpression extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
bulkAdd: "", gene: ""
tab: "autosuggest"
}; };
} }
placeholderGeneNames() { keyPress(e) {
/* if (e.keyCode === 13) {
return a string containing gene name suggestions for use as a user hint. this.handleClick();
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 geneNames = varAnnotations.col("name").asArray();
if (geneNames.length > 0) {
const placeholder = [];
let len = geneNames.length;
const maxGeneNameCount = 3;
const maxStrLength = 15;
len = len < maxGeneNameCount ? len : maxGeneNameCount;
for (let i = 0, strLen = 0; i < len && strLen < maxStrLength; i += 1) {
const deal = Math.floor(Math.random() * geneNames.length);
const geneName = geneNames[deal];
placeholder.push(geneName);
strLen += geneName.length + 2; // '2' is the length of a comma and space
}
placeholder.push("...");
return placeholder.join(", ");
} }
// default - should never happen.
return "Apod, Cd74, ...";
} }
handleClick(g) { handleClick() {
const { world, dispatch, userDefinedGenes } = this.props; const { world, dispatch, userDefinedGenes } = this.props;
const gene = g.target; const { gene } = this.state;
if (userDefinedGenes.indexOf(gene) !== -1) { if (userDefinedGenes.indexOf(gene) !== -1) {
postUserErrorToast("That gene already exists"); postUserErrorToast("That gene already exists");
} else if (userDefinedGenes.length > 15) { } else if (userDefinedGenes.length > 15) {
postUserErrorToast( postUserErrorToast(
"That's too many genes, you can have at most 15 user defined genes" "That's too many genes, you can have at most 15 user defined genes"
); );
} else if (world.varAnnotations.col("name").indexOf(gene) === undefined) { } else if (!_.find(world.varAnnotations, { name: gene })) {
postUserErrorToast("That doesn't appear to be a valid gene name."); postUserErrorToast("That doesn't appear to be a valid gene name.");
} else { } else {
dispatch(actions.requestUserDefinedGene(gene)); dispatch(actions.requestUserDefinedGene(gene));
@@ -122,53 +60,13 @@ class GeneExpression extends React.Component {
type: "user defined gene", type: "user defined gene",
data: gene data: gene
}); });
this.setState({ gene: "" });
} }
} }
handleBulkAddClick() {
const { world, dispatch, userDefinedGenes } = this.props;
const { bulkAdd } = this.state;
/*
test:
Apod,,, Cd74,, ,,, Foo, Bar-2,,
*/
if (bulkAdd !== "") {
const genes = _.pull(_.uniq(bulkAdd.split(/[ ,]+/)), "");
genes.forEach(gene => {
if (gene.length === 0) {
keepAroundErrorToast("Must enter a gene name.");
} else if (userDefinedGenes.indexOf(gene) !== -1) {
keepAroundErrorToast("That gene already exists");
} else if (
world.varAnnotations.col("name").indexOf(gene) === undefined
) {
keepAroundErrorToast(
`${gene} doesn't appear to be a valid gene name.`
);
} else {
dispatch(actions.requestUserDefinedGene(gene));
dispatch({
type: "user defined gene",
data: gene
});
}
});
}
this.setState({ bulkAdd: "" });
}
render() { render() {
const { const { world, userDefinedGenes, differential } = this.props;
world, const { gene } = this.state;
userDefinedGenes,
userDefinedGenesLoading,
differential
} = this.props;
const { tab, bulkAdd } = this.state;
return ( return (
<div> <div>
@@ -186,120 +84,43 @@ class GeneExpression extends React.Component {
Selected Genes Selected Genes
</p> </p>
<div <div
style={{ style={{ padding: globals.leftSidebarSectionPadding }}
padding: globals.leftSidebarSectionPadding className="bp3-control-group"
}}
> >
<Button <div className="bp3-input-group bp3-fill">
active={tab === "autosuggest"} <input
style={{ marginRight: 5 }} onKeyDown={this.keyPress.bind(this)}
minimal onChange={e => {
small this.setState({ gene: e.target.value });
onClick={() => {
this.setState({ tab: "autosuggest" });
}}
>
Autosuggest
</Button>
<Button
active={tab === "bulkadd"}
minimal
small
onClick={() => {
this.setState({ tab: "bulkadd" });
}}
>
Bulk add genes
</Button>
</div>
{tab === "autosuggest" ? (
<ControlGroup
style={{
paddingLeft: globals.leftSidebarSectionPadding,
paddingBottom: globals.leftSidebarSectionPadding
}}
>
<Suggest
closeOnSelect
openOnKeyDown
resetOnSelect
itemDisabled={
userDefinedGenesLoading ? () => true : () => false
}
noResults={<MenuItem disabled text="No matching genes." />}
onItemSelect={g => {
/* this happens on 'enter' */
this.handleClick(g);
}} }}
inputProps={{ "data-testid": "gene-search" }} value={gene}
inputValueRenderer={g => { type="text"
return ""; className="bp3-input"
}} placeholder="Enter a gene name"
itemListPredicate={filterGenes} style={{ paddingRight: 94 }}
itemRenderer={renderGene.bind(this)}
items={
world && world.varAnnotations
? world.varAnnotations.col("name").asArray()
: ["No genes"]
}
popoverProps={{ minimal: true }}
/> />
<Button </div>
className="bp3-button bp3-intent-primary" <Tooltip
data-testid={"add-gene"} content="Add a gene to see its expression levels"
loading={userDefinedGenesLoading} position="bottom"
> >
<Button intent="primary" onClick={this.handleClick.bind(this)}>
Add Add
</Button> </Button>
</ControlGroup> </Tooltip>
) : null} </div>
{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"
placeholder={this.placeholderGeneNames()}
value={bulkAdd}
/>
<Button
intent="primary"
onClick={this.handleBulkAddClick.bind(this)}
loading={userDefinedGenesLoading}
>
Add
</Button>
</ControlGroup>
</FormGroup>
</form>
</div>
) : null}
{world && userDefinedGenes.length > 0 {world && userDefinedGenes.length > 0
? _.map(userDefinedGenes, (geneName, index) => { ? _.map(userDefinedGenes, (geneName, index) => {
const values = world.varData.col(geneName); const values = world.varDataCache[geneName];
if (!values) { if (!values) {
return null; return null;
} }
const summary = values.summarize();
return ( return (
<HistogramBrush <HistogramBrush
key={geneName} key={geneName}
field={geneName} field={geneName}
zebra={index % 2 === 0} zebra={index % 2 === 0}
ranges={summary} ranges={d3.extent(values)}
isUserDefined isUserDefined
/> />
); );
@@ -318,18 +139,18 @@ class GeneExpression extends React.Component {
<ExpressionButtons /> <ExpressionButtons />
{differential.diffExp {differential.diffExp
? _.map(differential.diffExp, (value, index) => { ? _.map(differential.diffExp, (value, index) => {
const name = world.varAnnotations.at(value[0], "name"); const annotations = world.varAnnotations[value[0]];
const values = world.varData.col(name); const { name } = annotations;
const values = world.varDataCache[name];
if (!values) { if (!values) {
return null; return null;
} }
const summary = values.summarize();
return ( return (
<HistogramBrush <HistogramBrush
key={name} key={name}
field={name} field={name}
zebra={index % 2 === 0} zebra={index % 2 === 0}
ranges={summary} ranges={d3.extent(values)}
isDiffExp isDiffExp
logFoldChange={value[1]} logFoldChange={value[1]}
pval={value[2]} pval={value[2]}
+82 -112
View File
@@ -22,8 +22,7 @@ import scaleLinear from "../../util/scaleLinear";
responsive: state.responsive, responsive: state.responsive,
colorRGB: _.get(state.controls, "colorRGB", null), colorRGB: _.get(state.controls, "colorRGB", null),
opacityForDeselectedCells: state.controls.opacityForDeselectedCells, opacityForDeselectedCells: state.controls.opacityForDeselectedCells,
selectionUpdate: _.get(state.controls, "crossfilter.updateTime", null), selectionUpdate: _.get(state.controls, "crossfilter.updateTime", null)
resettingInterface: state.controls.resettingInterface
})) }))
class Graph extends React.Component { class Graph extends React.Component {
constructor(props) { constructor(props) {
@@ -35,13 +34,12 @@ class Graph extends React.Component {
this.graphPaddingRight = globals.leftSidebarWidth; this.graphPaddingRight = globals.leftSidebarWidth;
this.renderCache = { this.renderCache = {
positions: null, positions: null,
colors: null, colors: null
sizes: null
}; };
this.state = { this.state = {
svg: null, svg: null,
brush: null, brush: null,
mode: "lasso" mode: "brush"
}; };
} }
@@ -84,13 +82,12 @@ class Graph extends React.Component {
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
const { renderCache } = this;
const { const {
world, world,
crossfilter, crossfilter,
selectionUpdate,
colorRGB, colorRGB,
responsive, responsive
selectionUpdate
} = this.props; } = this.props;
const { const {
reglRender, reglRender,
@@ -111,27 +108,35 @@ class Graph extends React.Component {
if (regl && world) { if (regl && world) {
/* update the regl state */ /* update the regl state */
const { obsLayout, nObs } = world; const { obsLayout } = world;
const X = obsLayout.col("X").asArray(); const cellCount = crossfilter.size();
const Y = obsLayout.col("Y").asArray();
// X/Y positions for each point - a cached value that only // X/Y positions for each point - a cached value that only
// changes if we have loaded entirely new cell data // changes if we have loaded entirely new cell data
// //
if (!renderCache.positions || world !== prevProps.world) { if (
renderCache.positions = new Float32Array(2 * nObs); !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 glScaleX = scaleLinear([0, 1], [-1, 1]);
const glScaleY = scaleLinear([0, 1], [1, -1]); const glScaleY = scaleLinear([0, 1], [1, -1]);
const offset = [d3.mean(X) - 0.5, d3.mean(Y) - 0.5]; const offset = [d3.mean(obsLayout.X) - 0.5, d3.mean(obsLayout.Y) - 0.5];
for (let i = 0, { positions } = renderCache; i < nObs; i += 1) { for (
positions[2 * i] = glScaleX(X[i] - offset[0]); let i = 0, { positions } = this.renderCache;
positions[2 * i + 1] = glScaleY(Y[i] - offset[1]); i < cellCount;
i += 1
) {
positions[2 * i] = glScaleX(obsLayout.X[i] - offset[0]);
positions[2 * i + 1] = glScaleY(obsLayout.Y[i] - offset[1]);
} }
pointBuffer({ pointBuffer({
data: renderCache.positions, data: this.renderCache.positions,
dimension: 2 dimension: 2
}); });
@@ -146,28 +151,30 @@ class Graph extends React.Component {
// could have changed for some other reason, but for now color is // could have changed for some other reason, but for now color is
// the only metadata that changes client-side. If this is problematic, // the only metadata that changes client-side. If this is problematic,
// we could add some sort of color-specific indicator to the app state. // we could add some sort of color-specific indicator to the app state.
if (!renderCache.colors || colorRGB !== prevProps.colorRGB) { if (!this.renderCache.colors || colorRGB !== prevProps.colorRGB) {
const rgb = colorRGB; const rgb = colorRGB;
if (!renderCache.colors) { if (!this.renderCache.colors) {
renderCache.colors = new Float32Array(3 * rgb.length); this.renderCache.colors = new Float32Array(3 * rgb.length);
} }
for (let i = 0, { colors } = renderCache; i < rgb.length; i += 1) { for (let i = 0, { colors } = this.renderCache; i < rgb.length; i += 1) {
colors.set(rgb[i], 3 * i); colors.set(rgb[i], 3 * i);
} }
colorBuffer({ data: renderCache.colors, dimension: 3 }); colorBuffer({ data: this.renderCache.colors, dimension: 3 });
} }
// Sizes for each point - updates are triggered only when selected // Sizes for each point - this is presumed to change each time the
// obs change // component receives new props. Almost always a true assumption, as
if (!renderCache.sizes || selectionUpdate !== prevProps.selectionUpdate) { // most property upates are due to changes driving a crossfilter
if (!renderCache.sizes) { // selection set change.
renderCache.sizes = new Float32Array(nObs); //
} if (!this.renderCache.sizes) {
crossfilter.fillByIsFiltered(renderCache.sizes, 4, 0.2); this.renderCache.sizes = new Float32Array(cellCount);
sizeBuffer({ data: renderCache.sizes, dimension: 1 });
} }
this.count = nObs; crossfilter.fillByIsFiltered(this.renderCache.sizes, 4, 0.2);
sizeBuffer({ data: this.renderCache.sizes, dimension: 1 });
this.count = cellCount;
regl._refresh(); regl._refresh();
this.reglDraw( this.reglDraw(
@@ -194,9 +201,7 @@ class Graph extends React.Component {
this.handleBrushSelectAction.bind(this), this.handleBrushSelectAction.bind(this),
this.handleBrushDeselectAction.bind(this), this.handleBrushDeselectAction.bind(this),
responsive, responsive,
this.graphPaddingRight, this.graphPaddingRight
this.handleLassoStart.bind(this),
this.handleLassoEnd.bind(this)
); );
this.setState({ svg: newSvg, brush }); this.setState({ svg: newSvg, brush });
} }
@@ -245,54 +250,54 @@ class Graph extends React.Component {
}); });
} }
invertPoint(pin) {
const { responsive } = this.props;
const { regl, camera, offset } = this.state;
const gl = regl._gl;
// get aspect ratio
const aspect = gl.drawingBufferWidth / gl.drawingBufferHeight;
// 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 + inverse[12],
y * inverse[14] + inverse[13]
];
return [(pout[0] + 1) / 2 + offset[0], (pout[1] + 1) / 2 + offset[1]];
}
handleBrushSelectAction() { handleBrushSelectAction() {
/* /*
This conditional handles procedural brush deselect. Brush emits This conditional handles procedural brush deselect. Brush emits
an event on procedural deselect because it is move: null an event on procedural deselect because it is move: null
*/ */
/*
const { camera, offset } = this.state;
const { dispatch, responsive } = this.props;
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 s = d3.event.selection;
const gl = this.state.regl._gl;
/*
event describing brush position: event describing brush position:
@-------| @-------|
| | | |
| | | |
|-------@ |-------@
*/ */
/*
No idea why d3 event scope works like this
but apparently
it does
https://bl.ocks.org/EfratVil/0e542f5fc426065dd1d4b6daaa345a9f
*/
const { dispatch } = this.props;
if (d3.event.sourceEvent !== null) { // get aspect ratio
const s = d3.event.selection; const aspect = gl.drawingBufferWidth / gl.drawingBufferHeight;
// compute inverse view matrix
const inverse = mat4.invert([], camera.view());
// transform screen coordinates -> cell coordinates
const invert = pin => {
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 + inverse[12],
y * inverse[14] + inverse[13]
];
return [(pout[0] + 1) / 2 + offset[0], (pout[1] + 1) / 2 + offset[1]];
};
const brushCoords = { const brushCoords = {
northwest: this.invertPoint([s[0][0], s[0][1]]), northwest: invert([s[0][0], s[0][1]]),
southeast: this.invertPoint([s[1][0], s[1][1]]) southeast: invert([s[1][0], s[1][1]])
}; };
dispatch({ dispatch({
@@ -324,25 +329,6 @@ class Graph extends React.Component {
} }
} }
handleLassoStart() {
const { dispatch } = this.props;
// reset selected points when starting a new polygon
// making it easier for the user to make the next selection
dispatch({
type: "lasso started"
});
}
// when a lasso is completed, filter to the points within the lasso polygon
handleLassoEnd(polygon) {
const { dispatch } = this.props;
dispatch({
type: "lasso selection",
polygon: polygon.map(xy => this.invertPoint(xy)) // transform the polygon
});
}
handleOpacityRangeChange(e) { handleOpacityRangeChange(e) {
const { dispatch } = this.props; const { dispatch } = this.props;
dispatch({ dispatch({
@@ -353,20 +339,11 @@ class Graph extends React.Component {
resetInterface() { resetInterface() {
const { dispatch } = this.props; const { dispatch } = this.props;
dispatch({
type: "interface reset started"
});
dispatch(actions.resetInterface()); dispatch(actions.resetInterface());
} }
render() { render() {
const { const { dispatch, responsive, crossfilter } = this.props;
dispatch,
responsive,
crossfilter,
resettingInterface
} = this.props;
const { mode } = this.state; const { mode } = this.state;
return ( return (
<div id="graphWrapper"> <div id="graphWrapper">
@@ -415,7 +392,6 @@ class Graph extends React.Component {
/* world && universe ? worldEqUniverse(world, universe) : false */ /* world && universe ? worldEqUniverse(world, universe) : false */
} }
type="button" type="button"
loading={resettingInterface}
intent="warning" intent="warning"
style={{ marginRight: 10 }} style={{ marginRight: 10 }}
onClick={this.resetInterface.bind(this)} onClick={this.resetInterface.bind(this)}
@@ -425,18 +401,13 @@ class Graph extends React.Component {
</Tooltip> </Tooltip>
<div> <div>
<div className="bp3-button-group"> <div className="bp3-button-group">
<Tooltip content="Lasso selection" position="left"> <Tooltip content="Lasso cells" position="left">
<Button <Button
className="bp3-button bp3-icon-select"
type="button" type="button"
className="bp3-button bp3-icon-polygon-filter" active={mode === "brush"}
active={mode === "lasso"}
onClick={() => { onClick={() => {
this.handleBrushDeselectAction(); this.setState({ mode: "brush" });
// this.restartReglLoop();
this.setState({ mode: "lasso" });
}}
style={{
cursor: "pointer"
}} }}
/> />
</Tooltip> </Tooltip>
@@ -469,7 +440,7 @@ class Graph extends React.Component {
> >
<div <div
style={{ style={{
display: mode === "lasso" ? "inherit" : "none" display: mode === "brush" ? "inherit" : "none"
}} }}
id="graphAttachPoint" id="graphAttachPoint"
/> />
@@ -477,7 +448,6 @@ class Graph extends React.Component {
<canvas <canvas
width={responsive.width - this.graphPaddingRight} width={responsive.width - this.graphPaddingRight}
height={responsive.height - this.graphPaddingTop} height={responsive.height - this.graphPaddingTop}
data-testid="layout"
ref={canvas => { ref={canvas => {
this.reglCanvas = canvas; this.reglCanvas = canvas;
}} }}
-127
View File
@@ -1,127 +0,0 @@
// https://bl.ocks.org/pbeshai/8008075f9ce771ee8be39e8c38907570
import * as d3 from "d3";
const Lasso = () => {
const dispatch = d3.dispatch("start", "end");
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("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;
}
};
// 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.on = (type, callback) => {
dispatch.on(type, callback);
return lasso;
};
return lasso;
};
export default Lasso;
@@ -1,7 +1,6 @@
// jshint esversion: 6 // jshint esversion: 6
import * as d3 from "d3"; import * as d3 from "d3";
import styles from "./graph.css"; import styles from "./graph.css";
import Lasso from "./setupLasso";
/****************************************** /******************************************
******************************************* *******************************************
@@ -13,14 +12,11 @@ export default (
handleBrushSelectAction, handleBrushSelectAction,
handleBrushDeselectAction, handleBrushDeselectAction,
responsive, responsive,
graphPaddingRight, graphPaddingRight
handleLassoStart,
handleLassoEnd
) => { ) => {
const svg = d3 const svg = d3
.select("#graphAttachPoint") .select("#graphAttachPoint")
.append("svg") .append("svg")
.attr("data-testid", "layout-overlay")
.attr("width", responsive.width - graphPaddingRight) .attr("width", responsive.width - graphPaddingRight)
.attr("height", responsive.height) .attr("height", responsive.height)
.attr("class", `${styles.graphSVG}`); .attr("class", `${styles.graphSVG}`);
@@ -36,16 +32,9 @@ export default (
.attr("class", "graph_brush") .attr("class", "graph_brush")
.call(brush); .call(brush);
const lassoInstance = Lasso()
.on("end", handleLassoEnd)
.on("start", handleLassoStart);
const lasso = svg.call(lassoInstance);
return { return {
svg, svg,
brushContainer, brushContainer,
brush, brush
lasso
}; };
}; };
-1
View File
@@ -40,7 +40,6 @@ class LeftSideBar extends React.Component {
}} }}
> >
<p <p
data-testid="header"
style={{ style={{
position: "fixed", position: "fixed",
top: globals.cellxgeneTitleTopPadding, top: globals.cellxgeneTitleTopPadding,
@@ -19,7 +19,7 @@ import _drawPoints from "./drawPointsRegl";
import scaleLinear from "../../util/scaleLinear"; import scaleLinear from "../../util/scaleLinear";
import { margin, width, height } from "./util"; import { margin, width, height } from "./util";
import finiteExtent from "../../util/finiteExtent"; import { kvCache } from "../../util/stateManager";
@connect(state => { @connect(state => {
const { const {
@@ -29,16 +29,12 @@ import finiteExtent from "../../util/finiteExtent";
scatterplotYYaccessor scatterplotYYaccessor
} = state.controls; } = state.controls;
const expressionX = const expressionX =
world && world && scatterplotXXaccessor
scatterplotXXaccessor && ? kvCache.get(world.varDataCache, scatterplotXXaccessor)
world.varData.hasCol(scatterplotXXaccessor)
? world.varData.col(scatterplotXXaccessor).asArray()
: null; : null;
const expressionY = const expressionY =
world && world && scatterplotYYaccessor
scatterplotYYaccessor && ? kvCache.get(world.varDataCache, scatterplotYYaccessor)
world.varData.hasCol(scatterplotYYaccessor)
? world.varData.col(scatterplotYYaccessor).asArray()
: null; : null;
return { return {
@@ -68,16 +64,11 @@ class Scatterplot extends React.Component {
super(props); super(props);
this.count = 0; this.count = 0;
this.axes = false; this.axes = false;
this.renderCache = {
positions: null,
colors: null,
sizes: null,
xScale: null,
yScale: null
};
this.state = { this.state = {
svg: null, svg: null,
minimized: null minimized: null,
xScale: null,
yScale: null
}; };
} }
@@ -89,7 +80,6 @@ class Scatterplot extends React.Component {
if (svg && expressionX && expressionY) { if (svg && expressionX && expressionY) {
scales = Scatterplot.setupScales(expressionX, expressionY); scales = Scatterplot.setupScales(expressionX, expressionY);
this.drawAxesSVG(scales.xScale, scales.yScale, svg); this.drawAxesSVG(scales.xScale, scales.yScale, svg);
this.renderCache = { ...this.renderCache, ...scales };
} }
const camera = _camera(this.reglCanvas, { scale: true, rotate: false }); const camera = _camera(this.reglCanvas, { scale: true, rotate: false });
@@ -122,6 +112,8 @@ class Scatterplot extends React.Component {
pointBuffer, pointBuffer,
colorBuffer, colorBuffer,
svg, svg,
xScale: scales ? scales.xScale : null,
yScale: scales ? scales.yScale : null,
reglRender, reglRender,
camera, camera,
drawPoints drawPoints
@@ -136,11 +128,12 @@ class Scatterplot extends React.Component {
scatterplotYYaccessor, scatterplotYYaccessor,
expressionX, expressionX,
expressionY, expressionY,
colorRGB, colorRGB
selectionUpdate
} = this.props; } = this.props;
const { const {
reglRender, reglRender,
xScale,
yScale,
regl, regl,
pointBuffer, pointBuffer,
colorBuffer, colorBuffer,
@@ -151,12 +144,17 @@ class Scatterplot extends React.Component {
} = this.state; } = this.state;
if ( if (
scatterplotXXaccessor !== prevProps.scatterplotXXaccessor || // was CLU now FTH1 etc world &&
scatterplotYYaccessor !== prevProps.scatterplotYYaccessor // was CLU now FTH1 etc svg &&
xScale &&
yScale &&
scatterplotXXaccessor &&
scatterplotYYaccessor &&
(scatterplotXXaccessor !== prevProps.scatterplotXXaccessor || // was CLU now FTH1 etc
scatterplotYYaccessor !== prevProps.scatterplotYYaccessor || // was CLU now FTH1 etc
!this.axes) // clicked off the tab and back again, rerender
) { ) {
const scales = Scatterplot.setupScales(expressionX, expressionY); this.drawAxesSVG(xScale, yScale, svg);
this.drawAxesSVG(scales.xScale, scales.yScale, svg);
this.renderCache = { ...this.renderCache, ...scales };
} }
if (reglRender && this.reglRenderState === "rendering") { if (reglRender && this.reglRenderState === "rendering") {
@@ -173,51 +171,35 @@ class Scatterplot extends React.Component {
expressionX && expressionX &&
expressionY && expressionY &&
scatterplotXXaccessor && scatterplotXXaccessor &&
scatterplotYYaccessor scatterplotYYaccessor &&
xScale &&
yScale
) { ) {
const { renderCache } = this;
const { xScale, yScale } = this.renderCache;
const cellCount = expressionX.length; 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. const glScaleX = scaleLinear([0, width], [-0.95, 0.95]);
if ( const glScaleY = scaleLinear([0, height], [-1, 1]);
!renderCache.positions ||
expressionX !== prevProps.expressionX || /*
expressionY !== prevProps.expressionY Construct Vectors
) { */
if (!renderCache.positions) { for (let i = 0; i < cellCount; i += 1) {
renderCache.positions = new Float32Array(2 * cellCount); positionsBuf[2 * i] = glScaleX(xScale(expressionX[i]));
} positionsBuf[2 * i + 1] = glScaleY(yScale(expressionY[i]));
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 });
} }
// Colors for each point - change only when props.colorsRGB change. for (let i = 0; i < cellCount; i += 1) {
if (!renderCache.colors || colorRGB !== prevProps.colorRGB) { colorsBuf.set(colorRGB[i], 3 * i);
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 });
} }
// Sizes for each point - updates are triggered only when selected crossfilter.fillByIsFiltered(sizesBuf, 4, 0.2);
// obs change
if (!renderCache.sizes || selectionUpdate !== prevProps.selctionUpdate) {
if (!renderCache.sizes) {
renderCache.sizes = new Float32Array(cellCount);
}
crossfilter.fillByIsFiltered(renderCache.sizes, 4, 0.2);
sizeBuffer({ data: renderCache.sizes, dimension: 1 });
}
pointBuffer({ data: positionsBuf, dimension: 2 });
colorBuffer({ data: colorsBuf, dimension: 3 });
sizeBuffer({ data: sizesBuf, dimension: 1 });
this.count = cellCount; this.count = cellCount;
regl._refresh(); regl._refresh();
@@ -230,16 +212,26 @@ class Scatterplot extends React.Component {
camera 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) { static setupScales(expressionX, expressionY) {
const xScale = d3 const xScale = d3
.scaleLinear() .scaleLinear()
.domain(finiteExtent(expressionX)) .domain(d3.extent(expressionX))
.range([0, width]); .range([0, width]);
const yScale = d3 const yScale = d3
.scaleLinear() .scaleLinear()
.domain(finiteExtent(expressionY)) .domain(d3.extent(expressionY))
.range([height, 0]); .range([height, 0]);
return { return {
+2 -5
View File
@@ -1,5 +1,3 @@
import { Colors } from "@blueprintjs/core";
// jshint esversion: 6 // jshint esversion: 6
/* these will be either (preferably) specified or inferred */ /* these will be either (preferably) specified or inferred */
export const categories = [ export const categories = [
@@ -46,8 +44,8 @@ export const configDefaults = {
}; };
/* colors */ /* colors */
export const blue = Colors.BLUE3; export const blue = "#4a90e2";
export const linkBlue = Colors.BLUE5; export const hcaBlue = "#1c7cc7";
export const lightestGrey = "rgb(249,249,249)"; export const lightestGrey = "rgb(249,249,249)";
export const lighterGrey = "rgb(245,245,245)"; export const lighterGrey = "rgb(245,245,245)";
export const lightGrey = "rgb(211,211,211)"; export const lightGrey = "rgb(211,211,211)";
@@ -59,7 +57,6 @@ export const brightBlue = "#4a90e2";
export const brightGreen = "#A2D729"; export const brightGreen = "#A2D729";
export const darkGreen = "#448C4D"; export const darkGreen = "#448C4D";
export const nonFiniteCellColor = lightGrey;
export const defaultCellColor = "rgb(0,0,0,1)"; export const defaultCellColor = "rgb(0,0,0,1)";
/* typography constants */ /* typography constants */
+29 -64
View File
@@ -1,10 +1,9 @@
// jshint esversion: 6 // jshint esversion: 6
import _ from "lodash"; import _ from "lodash";
import * as d3 from "d3"; import * as d3 from "d3";
import { interpolateRainbow, interpolateCool } from "d3-scale-chromatic"; import { interpolateViridis } from "d3-scale-chromatic";
import * as globals from "../globals"; import * as globals from "../globals";
import parseRGB from "../util/parseRGB"; import parseRGB from "../util/parseRGB";
import finiteExtent from "../util/finiteExtent";
/* /*
https://medium.com/@jacobp100/you-arent-using-redux-middleware-enough-94ffe991e6 https://medium.com/@jacobp100/you-arent-using-redux-middleware-enough-94ffe991e6
@@ -41,98 +40,64 @@ const updateCellColorsMiddleware = store => next => action => {
action.type === "color by continuous metadata" || action.type === "color by continuous metadata" ||
action.type === "color by categorical metadata"; action.type === "color by categorical metadata";
const obsAnnotations = _.get(s.controls, "world.obsAnnotations", null); if (!filterJustChanged || !s.controls.world.obsAnnotations) {
if (!filterJustChanged || !obsAnnotations) {
return next( return next(
action action
); /* if the cells haven't loaded or the action wasn't a color change, bail */ ); /* if the cells haven't loaded or the action wasn't a color change, bail */
} }
const { obsAnnotations } = s.controls.world;
let colorScale; let colorScale;
const colorsByName = new Array(obsAnnotations.length);
const colorsByRGB = new Array(obsAnnotations.length); const colorsByRGB = new Array(obsAnnotations.length);
/* /*
in plain language... in plain language...
(a) once the cells have loaded. (a) once the cells have loaded.
(b) each time a user changes a color control we need to update cellsMetadata colors (b) each time a user changes a color control we need to update cellsMetadata colors
This is available to all the draw functions as controls.colorRGB[index] This is available to all the draw functions as world.colorName[index] or world.colorRGB[index]
*/ */
if (action.type === "color by categorical metadata") { if (action.type === "color by categorical metadata") {
const { categories } = _.filter(s.controls.world.schema.annotations.obs, { colorScale = d3.scaleOrdinal().range(globals.ordinalColors);
name: action.colorAccessor
})[0];
colorScale = d3 for (let i = 0; i < obsAnnotations.length; i += 1) {
.scaleSequential(interpolateRainbow) const obs = obsAnnotations[i];
.domain([0, categories.length]); const c = colorScale(obs[action.colorAccessor]);
colorsByName[i] = c;
/* pre-create colors - much faster than doing it for each obs */ colorsByRGB[i] = parseRGB(c);
const colors = _.transform(categories, (acc, cat, idx) => {
acc[cat] = parseRGB(colorScale(idx));
});
const key = action.colorAccessor;
const col = obsAnnotations.col(key).asArray();
for (let i = 0, len = obsAnnotations.length; i < len; i += 1) {
const cat = col[i];
colorsByRGB[i] = colors[cat];
} }
} }
if (action.type === "color by continuous metadata") { if (action.type === "color by continuous metadata") {
const colorBins = 100;
const { min, max } = action.rangeForColorAccessor;
colorScale = d3 colorScale = d3
.scaleQuantile() .scaleLinear()
.domain([min, max]) .domain([0, action.rangeMaxForColorAccessor])
.range(_.range(colorBins - 1, -1, -1)); .range([1, 0]);
/* pre-create colors - much faster than doing it for each obs */ for (let i = 0; i < obsAnnotations.length; i += 1) {
const colors = new Array(colorBins); const obs = obsAnnotations[i];
for (let i = 0; i < colorBins; i += 1) { const c = interpolateViridis(colorScale(obs[action.colorAccessor]));
colors[i] = parseRGB(interpolateCool(i / colorBins)); colorsByName[i] = c;
} colorsByRGB[i] = parseRGB(c);
const key = action.colorAccessor;
const nonFiniteColor = parseRGB(globals.nonFiniteCellColor);
const col = obsAnnotations.col(key).asArray();
for (let i = 0, len = obsAnnotations.length; i < len; i += 1) {
const val = col[i];
if (Number.isFinite(val)) {
const c = colorScale(val);
colorsByRGB[i] = colors[c];
} else {
colorsByRGB[i] = nonFiniteColor;
}
} }
} }
if (action.type === "color by expression") { if (action.type === "color by expression") {
const { gene, data } = action; const { gene, data } = action;
const expression = data[gene]; // Float32Array const expression = data[gene]; // Float32Array
const colorBins = 100;
const [min, max] = finiteExtent(expression);
colorScale = d3 colorScale = d3
.scaleQuantile() .scaleLinear()
.domain([min, max]) .domain([_.min(expression), _.max(expression)])
.range(_.range(colorBins - 1, -1, -1)); .range([
1,
/* pre-create colors - much faster than doing it for each obs */ 0
const colors = new Array(colorBins); ]); /* invert viridis... probably pass this scale through to others */
for (let i = 0; i < colorBins; i += 1) {
colors[i] = parseRGB(interpolateCool(i / colorBins));
}
const nonFiniteColor = parseRGB(globals.nonFiniteCellColor);
for (let i = 0, len = expression.length; i < len; i += 1) { for (let i = 0, len = expression.length; i < len; i += 1) {
const e = expression[i]; const c = interpolateViridis(colorScale(expression[i]));
if (Number.isFinite(e)) { colorsByName[i] = c;
const c = colorScale(e); colorsByRGB[i] = parseRGB(c);
colorsByRGB[i] = colors[c];
} else {
colorsByRGB[i] = nonFiniteColor;
}
} }
} }
@@ -140,7 +105,7 @@ const updateCellColorsMiddleware = store => next => action => {
append the result of all the filters to the action the user just triggered append the result of all the filters to the action the user just triggered
*/ */
const modifiedAction = Object.assign({}, action, { const modifiedAction = Object.assign({}, action, {
colors: { rgb: colorsByRGB }, colors: { name: colorsByName, rgb: colorsByRGB },
colorScale colorScale
}); });
+200 -243
View File
@@ -1,8 +1,7 @@
// jshint esversion: 6 // jshint esversion: 6
import _ from "lodash"; import _ from "lodash";
import { World, kvCache } from "../util/stateManager";
import { World, WorldUtil, ControlsHelper } from "../util/stateManager";
import parseRGB from "../util/parseRGB"; import parseRGB from "../util/parseRGB";
import Crossfilter from "../util/typedCrossfilter"; import Crossfilter from "../util/typedCrossfilter";
import * as globals from "../globals"; import * as globals from "../globals";
@@ -14,32 +13,40 @@ import {
makeContinuousDimensionName makeContinuousDimensionName
} from "../util/nameCreators"; } from "../util/nameCreators";
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 = ( const Controls = (
state = { state = {
// data loading flag // data loading flag
loading: false, loading: false,
error: null, error: null,
// configuration
maxCategoryItems: globals.configDefaults.parameters["max-category-items"],
// the whole big bang
universe: null, universe: null,
fullUniverseCache: null,
// all of the data + selection state // all of the data + selection state
world: null, world: null,
colorName: null,
colorRGB: null, colorRGB: null,
categoricalSelectionState: null, categoricalAsBooleansMap: null,
crossfilter: null, crossfilter: null,
dimensionMap: null, dimensionMap: null,
userDefinedGenes: [], userDefinedGenes: [],
userDefinedGenesLoading: false,
diffexpGenes: [], diffexpGenes: [],
colorAccessor: null, colorAccessor: null,
colorScale: null, colorScale: null,
resettingInterface: false,
opacityForDeselectedCells: 0.2, opacityForDeselectedCells: 0.2,
graphBrushSelection: null, graphBrushSelection: null,
@@ -65,94 +72,73 @@ const Controls = (
Initialization, World/Universe management Initialization, World/Universe management
and data loading. and data loading.
******************************************************/ ******************************************************/
case "configuration load complete": {
// there are a couple of configuration items we need to retain
return {
...state,
maxCategoryItems: _.get(
state.config,
"parameters.max-category-items",
globals.configDefaults.parameters["max-category-items"]
)
};
}
case "initial data load start": { case "initial data load start": {
return { ...state, loading: true }; 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 */ /* first light - create world & other data-driven defaults */
const { universe } = action; const { universe } = action;
const world = World.createWorldFromEntireUniverse(universe); const world = World.createWorldFromEntireUniverse(universe);
const colorRGB = new Array(universe.nObs).fill( const colorName = new Array(universe.nObs).fill(globals.defaultCellColor);
parseRGB(globals.defaultCellColor) const colorRGB = _.map(colorName, c => parseRGB(c));
); const categoricalAsBooleansMap = createCategoricalAsBooleansMap(world);
const categoricalSelectionState = ControlsHelper.createCategoricalSelectionState(
state,
world
);
const crossfilter = Crossfilter(world.obsAnnotations); const crossfilter = Crossfilter(world.obsAnnotations);
const dimensionMap = World.createObsDimensionMap(crossfilter, world); const dimensionMap = World.createObsDimensionMap(crossfilter, world);
WorldUtil.clearCaches();
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 { return {
...state, ...state,
loading: false, loading: false,
error: null, error: null,
universe, universe,
fullUniverseCache: { world, crossfilter, dimensionMap },
world, world,
colorName,
colorRGB, colorRGB,
categoricalSelectionState, categoricalAsBooleansMap,
crossfilter, crossfilter,
dimensionMap, dimensionMap,
colorAccessor: null, colorAccessor: null
resettingInterface: false
};
}
case "reset World to eq Universe": {
const {
userDefinedGenes,
diffexpGenes,
universe,
fullUniverseCache
} = state;
const { world, crossfilter } = fullUniverseCache;
// reset all crossfilter dimensions
_.forEach(fullUniverseCache.dimensionMap, dim => dim.filterAll());
const colorRGB = new Array(universe.nObs).fill(
parseRGB(globals.defaultCellColor)
);
const categoricalSelectionState = ControlsHelper.createCategoricalSelectionState(
state,
world
);
/* free dimensions not in cache (otherwise they leak) */
_.forEach(state.dimensionMap, (dim, dimName) => {
if (!fullUniverseCache.dimensionMap[dimName]) {
dim.dispose();
}
});
const dimensionMap = {
...fullUniverseCache.dimensionMap,
...ControlsHelper.createGenesDimMap(
userDefinedGenes,
diffexpGenes,
world,
crossfilter
)
};
WorldUtil.clearCaches();
return {
...state,
world,
colorRGB,
categoricalSelectionState,
crossfilter,
dimensionMap,
colorAccessor: null,
resettingInterface: false
}; };
} }
case "set World to current selection": { case "set World to current selection": {
@@ -164,32 +150,54 @@ const Controls = (
action.world, action.world,
action.crossfilter action.crossfilter
); );
const colorRGB = new Array(world.nObs).fill( const colorName = new Array(world.nObs).fill(globals.defaultCellColor);
parseRGB(globals.defaultCellColor) const colorRGB = _.map(colorName, c => parseRGB(c));
); const categoricalAsBooleansMap = createCategoricalAsBooleansMap(world);
const categoricalSelectionState = ControlsHelper.createCategoricalSelectionState(
state,
world
);
const crossfilter = Crossfilter(world.obsAnnotations); const crossfilter = Crossfilter(world.obsAnnotations);
const dimensionMap = { const dimensionMap = World.createObsDimensionMap(crossfilter, world);
...World.createObsDimensionMap(crossfilter, world),
...ControlsHelper.createGenesDimMap( const worldVarDataCache = world.varDataCache;
userDefinedGenes, /* var dimensions */
diffexpGenes,
world, if (userDefinedGenes.length > 0) {
crossfilter /*
) verbose & slightly confusing that we also access this as an object
}; in controls rather than an array, should be abstracted into
WorldUtil.clearCaches(); 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 { return {
...state, ...state,
loading: false, loading: false,
error: null, error: null,
world, world,
colorName,
colorRGB, colorRGB,
categoricalSelectionState, categoricalAsBooleansMap,
crossfilter, crossfilter,
dimensionMap, dimensionMap,
colorAccessor: null colorAccessor: null
@@ -197,108 +205,65 @@ const Controls = (
} }
case "expression load success": { case "expression load success": {
const { world, universe } = state; const { world, universe } = state;
let universeVarData = universe.varData; let universeVarDataCache = universe.varDataCache;
let worldVarData = world.varData; let worldVarDataCache = world.varDataCache;
// Load new expression data into the varData dataframes, if
// not already present.
_.forEach(action.expressionData, (val, key) => { _.forEach(action.expressionData, (val, key) => {
// If not already in universe.varData, save entire expression column universeVarDataCache = kvCache.set(universeVarDataCache, key, val);
if (!universeVarData.hasCol(key)) { if (kvCache.get(worldVarDataCache, key) === undefined) {
universeVarData = universeVarData.withCol(key, val); worldVarDataCache = kvCache.set(
} worldVarDataCache,
// If not already in world.varData, save sliced expression column
if (!worldVarData.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(world, universe)) {
worldValSlice = universeVarData
.subset(world.obsAnnotations.rowIndex.keys(), [key], null)
.icol(0)
.asArray();
}
// Now build world's varData dataframe
worldVarData = worldVarData.withCol(
key, key,
worldValSlice, World.subsetVarData(world, universe, val)
world.obsAnnotations.rowIndex
); );
} }
}); });
// Prune size of varData "cache" if getting out of hand....
const { userDefinedGenes, diffexpGenes } = state;
const allTheGenesWeNeed = _.uniq(
[].concat(
userDefinedGenes,
diffexpGenes,
Object.keys(action.expressionData)
)
);
universeVarData = ControlsHelper.pruneVarDataCache(
universeVarData,
allTheGenesWeNeed
);
worldVarData = ControlsHelper.pruneVarDataCache(
worldVarData,
allTheGenesWeNeed
);
return { return {
...state, ...state,
universe: { universe: {
...universe, ...universe,
varData: universeVarData varDataCache: universeVarDataCache
}, },
world: { world: {
...world, ...world,
varData: worldVarData varDataCache: worldVarDataCache
} }
}; };
} }
case "request user defined gene started": {
return {
...state,
userDefinedGenesLoading: true
};
}
case "request user defined gene error": {
return {
...state,
userDefinedGenesLoading: false
};
}
case "request user defined gene success": { case "request user defined gene success": {
const { world, crossfilter, dimensionMap, userDefinedGenes } = state; const { world, crossfilter, dimensionMap, userDefinedGenes } = state;
const worldVarDataCache = world.varDataCache;
const _userDefinedGenes = userDefinedGenes.slice(); const _userDefinedGenes = userDefinedGenes.slice();
const gene = action.data.genes[0]; const gene = action.data.genes[0];
dimensionMap[ dimensionMap[userDefinedDimensionName(gene)] = World.createVarDimension(
userDefinedDimensionName(gene) /* "__var__" + */
] = World.createVarDataDimension(world, crossfilter, gene); world,
worldVarDataCache,
crossfilter,
gene
);
return { return {
...state, ...state,
dimensionMap, dimensionMap,
userDefinedGenes: _userDefinedGenes, userDefinedGenes: _userDefinedGenes
userDefinedGenesLoading: false
}; };
} }
case "request differential expression success": { case "request differential expression success": {
const { world, crossfilter, dimensionMap } = state; const { world, crossfilter, dimensionMap } = state;
const worldVarDataCache = world.varDataCache;
const _diffexpGenes = []; const _diffexpGenes = [];
action.data.forEach(d => { action.data.forEach(d => {
_diffexpGenes.push(world.varAnnotations.at(d[0], "name")); _diffexpGenes.push(world.varAnnotations[d[0]].name);
}); });
_.forEach(_diffexpGenes, gene => { _.forEach(_diffexpGenes, gene => {
dimensionMap[diffexpDimensionName(gene)] = World.createVarDataDimension( dimensionMap[diffexpDimensionName(gene)] = World.createVarDimension(
/* "__var__" + */
world, world,
worldVarDataCache,
crossfilter, crossfilter,
gene gene
); );
@@ -311,10 +276,13 @@ const Controls = (
}; };
} }
case "clear differential expression": { case "clear differential expression": {
const { world, dimensionMap } = state; const { world, universe, dimensionMap } = state;
const _dimensionMap = dimensionMap; const _dimensionMap = dimensionMap;
const universeVarDataCache = universe.varDataCache;
const worldVarDataCache = world.varDataCache;
_.forEach(action.diffExp, values => { _.forEach(action.diffExp, values => {
const name = world.varAnnotations.at(values[0], "name"); const { name } = world.varAnnotations[values[0]];
// clean up crossfilter dimensions // clean up crossfilter dimensions
const dimension = dimensionMap[diffexpDimensionName(name)]; const dimension = dimensionMap[diffexpDimensionName(name)];
dimension.dispose(); dimension.dispose();
@@ -323,7 +291,15 @@ const Controls = (
return { return {
...state, ...state,
dimensionMap: _dimensionMap, dimensionMap: _dimensionMap,
diffexpGenes: [] diffexpGenes: [],
universe: {
...universe,
varDataCache: universeVarDataCache
},
world: {
...world,
varDataCache: worldVarDataCache
}
}; };
} }
case "user defined gene": { case "user defined gene": {
@@ -372,11 +348,11 @@ const Controls = (
} }
case "reset colorscale": { case "reset colorscale": {
const { world } = state; const { world } = state;
const colorRGB = new Array(world.nObs).fill( const colorName = new Array(world.nObs).fill(globals.defaultCellColor);
parseRGB(globals.defaultCellColor) const colorRGB = _.map(colorName, c => parseRGB(c));
);
return { return {
...state, ...state,
colorName,
colorRGB, colorRGB,
colorAccessor: null colorAccessor: null
}; };
@@ -394,36 +370,27 @@ const Controls = (
User Events User Events
*******************************/ *******************************/
case "graph brush selection change": { case "graph brush selection change": {
state.dimensionMap[layoutDimensionName("XY")].filterWithinRect( state.dimensionMap[layoutDimensionName("X")].filterRange([
action.brushCoords.northwest, action.brushCoords.northwest[0],
action.brushCoords.southeast action.brushCoords.southeast[0]
); ]);
state.dimensionMap[layoutDimensionName("Y")].filterRange([
action.brushCoords.southeast[1],
action.brushCoords.northwest[1]
]);
return { return {
...state, ...state,
graphBrushSelection: action.brushCoords graphBrushSelection: action.brushCoords
}; };
} }
case "lasso deselect":
case "graph brush deselect": { case "graph brush deselect": {
state.dimensionMap[layoutDimensionName("XY")].filterAll(); state.dimensionMap[layoutDimensionName("X")].filterAll();
state.dimensionMap[layoutDimensionName("Y")].filterAll();
return { return {
...state, ...state,
graphBrushSelection: null graphBrushSelection: null
}; };
} }
case "lasso selection": {
const { polygon } = action;
const dXY = state.dimensionMap[layoutDimensionName("XY")];
if (polygon.length < 3) {
// single point or a line is not a polygon, and is therefore a deselect
dXY.filterAll();
} else {
dXY.filterWithinPolygon(polygon);
}
return {
...state
};
}
case "continuous metadata histogram brush": { case "continuous metadata histogram brush": {
const name = makeContinuousDimensionName( const name = makeContinuousDimensionName(
action.continuousNamespace, action.continuousNamespace,
@@ -451,99 +418,87 @@ const Controls = (
graphRenderCounter: c graphRenderCounter: c
}; };
} }
case "interface reset started": {
return {
...state,
resettingInterface: true
};
}
/******************************* /*******************************
Categorical metadata Categorical metadata
*******************************/ *******************************/
case "categorical metadata filter select": { case "categorical metadata filter select": {
const newCategorySelected = Array.from( const newCategoricalAsBooleansMap = {
state.categoricalSelectionState[action.metadataField].categorySelected ...state.categoricalAsBooleansMap,
);
newCategorySelected[action.categoryIndex] = true;
const newCategoricalSelectionState = {
...state.categoricalSelectionState,
[action.metadataField]: { [action.metadataField]: {
...state.categoricalSelectionState[action.metadataField], ...state.categoricalAsBooleansMap[action.metadataField],
categorySelected: newCategorySelected [action.value]: true
} }
}; };
// update the filter for the one category that changed state
// update the filter to match all selected options
const cat = newCategoricalSelectionState[action.metadataField];
state.dimensionMap[obsAnnoDimensionName(action.metadataField)].filterEnum( state.dimensionMap[obsAnnoDimensionName(action.metadataField)].filterEnum(
ControlsHelper.selectedValuesForCategory(cat) _.filter(
_.map(
newCategoricalAsBooleansMap[action.metadataField],
(val, key) => (val ? key : false)
)
)
); );
return { return {
...state, ...state,
categoricalSelectionState: newCategoricalSelectionState categoricalAsBooleansMap: newCategoricalAsBooleansMap
}; };
} }
case "categorical metadata filter deselect": { case "categorical metadata filter deselect": {
const newCategorySelected = Array.from( const newCategoricalAsBooleansMap = {
state.categoricalSelectionState[action.metadataField].categorySelected ...state.categoricalAsBooleansMap,
);
newCategorySelected[action.categoryIndex] = false;
const newCategoricalSelectionState = {
...state.categoricalSelectionState,
[action.metadataField]: { [action.metadataField]: {
...state.categoricalSelectionState[action.metadataField], ...state.categoricalAsBooleansMap[action.metadataField],
categorySelected: newCategorySelected [action.value]: false
} }
}; };
// update the filter for the one category that changed state
// update the filter to match all selected options
const cat = newCategoricalSelectionState[action.metadataField];
state.dimensionMap[obsAnnoDimensionName(action.metadataField)].filterEnum( state.dimensionMap[obsAnnoDimensionName(action.metadataField)].filterEnum(
ControlsHelper.selectedValuesForCategory(cat) _.filter(
_.map(
newCategoricalAsBooleansMap[action.metadataField],
(val, key) => (val ? key : false)
)
)
); );
return { return {
...state, ...state,
categoricalSelectionState: newCategoricalSelectionState categoricalAsBooleansMap: newCategoricalAsBooleansMap
}; };
} }
case "categorical metadata filter none of these": { case "categorical metadata filter none of these": {
const newCategoricalSelectionState = { const newCategoricalAsBooleansMap = {
...state.categoricalSelectionState, ...state.categoricalAsBooleansMap
[action.metadataField]: {
...state.categoricalSelectionState[action.metadataField],
categorySelected: Array.from(
state.categoricalSelectionState[action.metadataField]
.categorySelected
).fill(false)
}
}; };
_.forEach(
newCategoricalAsBooleansMap[action.metadataField],
(v, k, c) => {
c[k] = false;
}
);
state.dimensionMap[ state.dimensionMap[
obsAnnoDimensionName(action.metadataField) obsAnnoDimensionName(action.metadataField)
].filterNone(); ].filterNone();
return { return {
...state, ...state,
categoricalSelectionState: newCategoricalSelectionState categoricalAsBooleansMap: newCategoricalAsBooleansMap
}; };
} }
case "categorical metadata filter all of these": { case "categorical metadata filter all of these": {
const newCategoricalSelectionState = { const newCategoricalAsBooleansMap = {
...state.categoricalSelectionState, ...state.categoricalAsBooleansMap
[action.metadataField]: {
...state.categoricalSelectionState[action.metadataField],
categorySelected: Array.from(
state.categoricalSelectionState[action.metadataField]
.categorySelected
).fill(true)
}
}; };
_.forEach(
newCategoricalAsBooleansMap[action.metadataField],
(v, k, c) => {
c[k] = true;
}
);
state.dimensionMap[ state.dimensionMap[
obsAnnoDimensionName(action.metadataField) obsAnnoDimensionName(action.metadataField)
].filterAll(); ].filterAll();
return { return {
...state, ...state,
categoricalSelectionState: newCategoricalSelectionState categoricalAsBooleansMap: newCategoricalAsBooleansMap
}; };
} }
@@ -554,6 +509,7 @@ const Controls = (
case "color by continuous metadata": { case "color by continuous metadata": {
return { return {
...state, ...state,
colorName: action.colors.name,
colorRGB: action.colors.rgb, colorRGB: action.colors.rgb,
colorAccessor: action.colorAccessor, colorAccessor: action.colorAccessor,
colorScale: action.colorScale colorScale: action.colorScale
@@ -562,6 +518,7 @@ const Controls = (
case "color by expression": { case "color by expression": {
return { return {
...state, ...state,
colorName: action.colors.name,
colorRGB: action.colors.rgb, colorRGB: action.colors.rgb,
colorAccessor: action.gene, colorAccessor: action.gene,
colorScale: action.colorScale colorScale: action.colorScale
+1 -1
View File
@@ -1,9 +1,9 @@
// jshint esversion: 6 // jshint esversion: 6
import { combineReducers, createStore, applyMiddleware } from "redux"; import { combineReducers, createStore, applyMiddleware } from "redux";
import thunk from "redux-thunk"; import thunk from "redux-thunk";
import { composeWithDevTools } from "redux-devtools-extension";
import updateURLMiddleware from "../middleware/updateURLMiddleware"; import updateURLMiddleware from "../middleware/updateURLMiddleware";
import updateCellColors from "../middleware/updateCellColors"; import updateCellColors from "../middleware/updateCellColors";
import { composeWithDevTools } from "redux-devtools-extension";
import config from "./config"; import config from "./config";
import differential from "./differential"; import differential from "./differential";
+13 -34
View File
@@ -24,44 +24,23 @@ 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. Wrapper to perform an async fetch and JSON decode response.
*/ */
export const doJsonRequest = async url => { export const doJsonRequest = async url => {
const res = await doFetch(url, "application/json"); const res = await fetch(url, {
return res.json(); method: "get",
}; headers: new Headers({
"Content-Type": "application/json"
/* })
Wrapper to perform an async fetch for binary data. });
*/ if (res.ok && res.headers.get("Content-Type") === "application/json") {
export const doBinaryRequest = async url => { return res.json();
const res = await doFetch(url, "application/octet-stream"); }
return res.arrayBuffer(); // else an error
const msg = `Unexpected HTTP response ${res.status}, ${res.statusText}`;
dispatchNetworkErrorMessageToUser(msg);
throw new Error(msg);
}; };
/* /*
-574
View File
@@ -1,574 +0,0 @@
import { IdentityInt32Index, isLabelIndex } from "./labelIndex";
// weird cross-dependency that we should clean up someday...
import { sort } from "../typedCrossfilter/sort";
import { isTypedArray, isArrayOrTypedArray, callOnceLazy } from "./util";
import { summarizeContinuous, summarizeCategorical } from "./summarize";
/*
Dataframe is an immutable 2D matrix similiar to Python Pandas Dataframe,
but (currently) without all of the surrounding support functions.
Data is stored in column-major layout, and each column is monomorphic.
It supports:
* Relatively efficient creation, cloning and subsetting
* Very efficient columnar access (eg, sum down a column), and access
to the underlying column arrays.
* Data access by row/col offset or label. Labels are reasonably well
optimized for both numeric lables and arbitrary (eg, sting) labels.
It does not currently support:
* Views on matrix subset - for currently known access patterns,
it is more effiicent to copy on subsetting, optimizing for access
speed over memory use.
* JS iterators - they are too slow. Use explicit iteration over
offest or labels.
Important assumptions embedded in the API:
* Columns are implicitly categorical if they are a JS Array and numeric
(aka continuous) if they are a TypedArray.
There are three index types for row/col indexing:
* IdentityInt32Index - noop index, where the index label is the offset.
* KeyIndex - index arbitrary JS objects.
* DenseInt32Index - integer indexing. Optimization over KeyIndex as it uses
Int32Array as a back-map to offsets. This means that the index array
must be sized to [minLabel, maxLabel), so this is only useful when the label
range is relatively close the underlying offset range [minOffset, maxOffset).
All private functions/methods/fields are prefixed by '__', eg, __compile().
Don't use them outside of this file.
Simple example:
// default indexing is integer offset.
const df = Dataframe.create([2,2], [['a', 'b'], [0, 1]])
console.log(df.at(0,0)); // outputs: a
console.log(df.col(1).asArray()); // outputs: [0, 1]
// KeyIndex
const df = new Dataframe([1,2], [['a'], ['b']], null, new KeyIndex(['A', 'B']))
console.log(df.at(0, 'A')); // outputs: a
console.log(df.col('A').asArray(); // outputs: ['a']
Performance tuning is primarily focused on columnar access patterns, which is the
dominant pattern in cellxgene.
*/
/**
Dataframe
**/
class Dataframe {
/**
Constructors & factories
**/
constructor(dims, columnarData, rowIndex = null, colIndex = null) {
/*
The base constructor is relatively hard to use - as an alternative,
see factory methods and clone/slice, below.
Parameters:
* dims - 2D array describing intendend dimensionality: [nRows,nCols].
* columnarData - JS array, nCols in length, containing array
or TypedArray of length nRows.
* rowIndex/colIndex - null (create default index using offsets as key),
or a caller-provided index.
All columns and indices must have appropriate dimensionality.
*/
const [nRows, nCols] = dims;
if (nRows < 0 || nCols < 0) {
throw new RangeError("Dataframe dimensions must be positive");
}
if (!rowIndex) {
rowIndex = new IdentityInt32Index(nRows);
}
if (!colIndex) {
colIndex = new IdentityInt32Index(nCols);
}
Dataframe.__errorChecks(dims, columnarData, rowIndex, colIndex);
this.__columns = Array.from(columnarData);
this.dims = dims;
this.length = nRows; // convenience accessor for row dimension
this.rowIndex = rowIndex;
this.colIndex = colIndex;
this.__compile();
}
static __errorChecks(dims, columnarData, rowIndex, colIndex) {
const [nRows, nCols] = dims;
/* check for expected types */
if (!Array.isArray(columnarData)) {
throw new TypeError("Dataframe constructor requires array of columns");
}
if (!columnarData.every(c => isArrayOrTypedArray(c))) {
throw new TypeError("Dataframe columns must all be Array or TypedArray");
}
if (!isLabelIndex(rowIndex)) {
throw new TypeError("Dataframe rowIndex is an unsupported type.");
}
if (!isLabelIndex(colIndex)) {
throw new TypeError("Dataframe colIndex is an unsupported type.");
}
/* check for expected dimensionality / size */
if (
nCols !== columnarData.length ||
!columnarData.every(c => c.length === nRows)
) {
throw new RangeError(
"Dataframe dimension does not match provided data shape"
);
}
if (nRows !== rowIndex.size()) {
throw new RangeError(
"Dataframe rowIndex must have same size as underlying data"
);
}
if (nCols !== colIndex.size()) {
throw new RangeError(
"Dataframe colIndex must have same size as underlying data"
);
}
}
__compile() {
/*
Compile data accessors for each column.
Each column accessor is a function which will lookup data by
index (ie, is equivalent to dataframe.get(row, col), where 'col'
is fixed.
In addition, each column accessor has several functions:
asArray() -- return the entire column as a native Array or TypedArray.
Crucially, this native array only supports label indexing.
Example:
const arr = df.col('a').asArray();
has(rlabel) -- return boolean indicating of the row label
is contained within the column. Example:
const isInColumn = df.col('a').includes(99)
For the default offset indexing, this is identical to:
const isInColumn = (99 > 0) && (99 < df.nRows);
ihas(roffset) -- same as has(), but accepts a row offset
instead of a row label.
indexOf(value) -- return the label (not offset) of the first instance of
'value' in the column. If you want the offset, just use the builtin JS
indexOf() function, available on both Array and TypedArray.
iget(offset) -- return the value at 'offset'
*/
const { getOffset, getLabel } = this.rowIndex;
this.__columnsAccessor = this.__columns.map(column => {
const { length } = column;
/* get value by row label */
const get = function get(rlabel) {
return column[getOffset(rlabel)];
};
/* get value by row offset */
const iget = function iget(roffset) {
return column[roffset];
};
/* full column array access */
const asArray = function asArray() {
return column;
};
/* test for row label inclusion in column */
const has = function has(rlabel) {
const offset = getOffset(rlabel);
return offset >= 0 && offset < length;
};
const ihas = function ihas(offset) {
return offset >= 0 && offset < length;
};
/*
return first label (index) at which the value is found in this column,
or undefined if not found.
NOTE: not found return is DIFFERENT than the default Array.indexOf as
-1 is a plausible Dataframe row/col label.
*/
const indexOf = function indexOf(value) {
const offset = column.indexOf(value);
if (offset === -1) {
return undefined;
}
return getLabel(offset);
};
/*
Summarize the column data. Lazy eval;
*/
const summarize = callOnceLazy(() =>
isTypedArray(column)
? summarizeContinuous(column)
: summarizeCategorical(column)
);
get.summarize = summarize;
get.asArray = asArray;
get.has = has;
get.ihas = ihas;
get.indexOf = indexOf;
get.iget = iget;
return get;
});
}
clone() {
/*
Clone this dataframe
*/
return new this.constructor(
this.dims,
[...this.__columns],
this.rowIndex,
this.colIndex
);
}
withCol(label, colData, withRowIndex = null) {
/*
Create a new DF, which is `this` plus the new column. Example:
const newDf = df.withCol("foo", [1,2,3]);
Dimensionality of new column must match existing dataframe.
Special case: empty dataframe will accept any size column. Example:
const newDf = Dataframe.empty().withCol("foo", [1,2,3]);
If `withRowIndex` specified, the provided index will become the
rowIndex for the newly created dataframe. If not specified,
the rowIndex from `this` will be used (ie, the rowIndex is
unchanged).
*/
let dims;
let rowIndex;
if (this.isEmpty()) {
dims = [colData.length, 1];
rowIndex = null;
} else {
dims = [this.dims[0], this.dims[1] + 1];
({ rowIndex } = this);
}
if (withRowIndex) {
rowIndex = withRowIndex;
}
const columns = [...this.__columns];
columns.push(colData);
const colIndex = this.colIndex.withLabel(label);
return new this.constructor(dims, columns, rowIndex, colIndex);
}
dropCol(label) {
/*
Create a new dataframe, omitting one columns.
const newDf = df.dropCol("colors");
*/
const dims = [this.dims[0], this.dims[1] - 1];
const coffset = this.colIndex.getOffset(label);
const columns = [...this.__columns];
columns.splice(coffset, 1);
const colIndex = this.colIndex.dropLabel(label);
return new this.constructor(dims, columns, this.rowIndex, colIndex);
}
static empty(rowIndex = null, colIndex = null) {
return new Dataframe([0, 0], [], rowIndex, colIndex);
}
static create(dims, columnarData) {
/*
Create a dataframe from raw columnar data. All column arrays
must have the same length. Identity indexing will be used.
Example:
const df = Dataframe.create([2,2], [new Uint32Array(2), new Float32Array(2)]);
*/
return new Dataframe(dims, columnarData, null, null);
}
__subset(rowOffsets, colOffsets, withRowIndex) {
const dims = [...this.dims];
const getSortedLabelAndOffsets = (offsets, index) => {
/*
Given offsets, return both offsets and associated lables,
sorted by offset.
*/
if (!offsets) {
return [null, null];
}
const sortedOffsets = sort(offsets);
const sortedLabels = new Array(sortedOffsets.length);
for (let i = 0, l = sortedOffsets.length; i < l; i += 1) {
sortedLabels[i] = index.getLabel(sortedOffsets[i]);
}
return [sortedLabels, sortedOffsets];
};
let { colIndex } = this;
if (colOffsets) {
let colLabels;
[colLabels, colOffsets] = getSortedLabelAndOffsets(
colOffsets,
this.colIndex
);
dims[1] = colOffsets.length;
colIndex = this.colIndex.subsetLabels(colLabels);
}
let { rowIndex } = this;
if (withRowIndex) rowIndex = withRowIndex;
if (rowOffsets) {
let rowLabels;
[rowLabels, rowOffsets] = getSortedLabelAndOffsets(
rowOffsets,
this.rowIndex
);
dims[0] = rowLabels.length;
if (!withRowIndex) rowIndex = this.rowIndex.subsetLabels(rowLabels);
}
/* subset columns */
let columns = this.__columns;
if (colOffsets) {
columns = new Array(colOffsets.length);
for (let i = 0, l = colOffsets.length; i < l; i += 1) {
columns[i] = this.__columns[colOffsets[i]];
}
}
/* subset rows */
if (rowOffsets) {
columns = columns.map(col => {
const newCol = new col.constructor(rowOffsets.length);
for (let i = 0, l = rowOffsets.length; i < l; i += 1) {
newCol[i] = col[rowOffsets[i]];
}
return newCol;
});
}
return new Dataframe(dims, columns, rowIndex, colIndex);
}
subset(rowLabels, colLabels = null, withRowIndex = null) {
/*
Subset by row/col labels.
withRowIndex allows assignment of new row index during subset operation.
If withRowIndex === null, it will reset the index to identity (offset)
indexing. if withRowIndex is a label index object, it will be used
for the new dataframe.
*/
const toOffsets = (labels, index) => {
if (!labels) {
return null;
}
return labels.map(label => {
const off = index.getOffset(label);
if (off === undefined) {
throw new RangeError(`unknown label: ${label}`);
}
return off;
});
};
const rowOffsets = toOffsets(rowLabels, this.rowIndex);
const colOffsets = toOffsets(colLabels, this.colIndex);
return this.__subset(rowOffsets, colOffsets, withRowIndex);
}
isubset(rowOffsets, colOffsets = null, withRowIndex = null) {
/*
Subset by row/col offset.
withRowIndex allows assignment of new row index during subset operation.
If withRowIndex === null, it will reset the index to identity (offset)
indexing. if withRowIndex is a label index object, it will be used
for the new dataframe.
*/
return this.__subset(rowOffsets, colOffsets, withRowIndex);
}
isubsetMask(rowMask, colMask = null, withRowIndex = null) {
/*
Subset on row/column based upon a truthy/falsey array (a mask).
withRowIndex allows assignment of new row index during subset operation.
If withRowIndex === null, it will reset the index to identity (offset)
indexing. if withRowIndex is a label index object, it will be used
for the new dataframe.
*/
const [nRows, nCols] = this.dims;
if (
(rowMask && rowMask.length !== nRows) ||
(colMask && colMask.length !== nCols)
) {
throw new RangeError("boolean arrays must match row/col dimensions");
}
/* convert masks to lists - method wastes space, but is fast */
const toList = (mask, maxSize) => {
if (!mask) {
return null;
}
const list = new Int32Array(maxSize);
let elems = 0;
for (let i = 0, l = mask.length; i < l; i += 1) {
if (mask[i]) {
list[elems] = i;
elems += 1;
}
}
return new Int32Array(list.buffer, 0, elems);
};
const rowOffsets = toList(rowMask, nRows);
const colOffsets = toList(colMask, nCols);
return this.__subset(rowOffsets, colOffsets, withRowIndex);
}
/**
Data access with row/col.
**/
col(columnLabel) {
/*
Return accessor bound to a column. Allows random row access
based upon the row indexing. Returns undefined if the
columnLabel is not present in the dataframe.
Example for a dataframe with string labeled columns, and
default (offset) indices for rows (eg, [0, 'foo'])
const getValue = df.col('foo');
for (let r = 0; r < df.nRows; r += 1) {
console.log(r, getValue(r));
}
See __compile() for the functions available in a column accessor.
*/
const coff = this.colIndex.getOffset(columnLabel);
return this.__columnsAccessor[coff];
}
icol(columnOffset) {
/*
Return column accessor by offset.
*/
return this.__columnsAccessor[columnOffset];
}
at(r, c) {
/*
Access a single value, for a row/col label pair.
For performance reasons, there are no bounds or existance
checks on labels, and no defined behavior when these are supplied.
May return undefined, throw an Error, or do something else for
non-existant labels. If you want predictable out-of-bounds
behavior, use has(), eg,
const myVal = df.has(r,l) ? df.at(r,l) : undefined;
*/
const coff = this.colIndex.getOffset(c);
const roff = this.rowIndex.getOffset(r);
return this.__columns[coff][roff];
}
iat(r, c) {
/*
Access a single value, for a row/col offset (integer) position.
For performance reasons, there are no bounds checks on row/col offsets
or other well-defined behavior for out-of-bounds values. If you want
well-defined bounds checking, use ihas(), eg,
const myVal = df.ihas(r, c) ? df.iat(r, c) : undefined;
*/
return this.__columns[c][r];
}
has(r, c) {
/*
Test if row/col labels exist in the dataframe - returns true/false
*/
const [nRows, nCols] = this.dims;
const coff = this.colIndex.getOffset(c);
const roff = this.rowIndex.getOffset(r);
return coff >= 0 && coff < nCols && roff >= 0 && roff < nRows;
}
ihas(r, c) {
/*
Test if row/col offset (integer) position exists in the
dataframe - returns true/false
*/
const [nRows, nCols] = this.dims;
return c >= 0 && c < nCols && r >= 0 && r < nRows;
}
hasCol(c) {
/*
Test if col label exists - return true/false
*/
return !!this.col(c);
}
isEmpty() {
/*
Return true if this is an empty dataframe, ie, has dimensions [0,0]
*/
const [rows, cols] = this.dims;
return rows === 0 && cols === 0;
}
/****
Functional (map/reduce/etc) data access
XXX: not yet implemented, as there is no clear use case. Can easily
add these as useful.
****/
/*
Map & reduce of column or row
XXX TODO remainder of map/reduce functions: mapCol, mapRow, reduceRow, ...
*/
/* comment out until we have a use for this
reduceCol(clabel, callback, initialValue) {
const coff = this.colIndex.getOffset(clabel);
const column = this.__columns[coff];
let start = 0;
let acc = initialValue;
if (initialValue === undefined) {
acc = column[0];
start = 1;
}
for (let i = start, l = column.length; i < l; i += 1) {
acc = callback(acc, column[i]);
}
return acc;
}
*/
}
export default Dataframe;
-2
View File
@@ -1,2 +0,0 @@
export { default as Dataframe } from "./dataframe";
export { DenseInt32Index, IdentityInt32Index, KeyIndex } from "./labelIndex";
-244
View File
@@ -1,244 +0,0 @@
/**
Label indexing - map a label to & from an integer offset. See Dataframe
for how this is used.
**/
/*
Private utility functions
*/
function extent(tarr) {
let min = 0x7fffffff;
let max = ~min; // eslint-disable-line no-bitwise
for (let i = 0, l = tarr.length; i < l; i += 1) {
const v = tarr[i];
if (v < min) {
min = v;
}
if (v > max) {
max = v;
}
}
return [min, max];
}
function fillRange(arr, start = 0) {
const larr = arr;
for (let i = 0, l = larr.length; i < l; i += 1) {
larr[i] = i + start;
}
return larr;
}
/* eslint-disable class-methods-use-this */
class IdentityInt32Index {
/*
identity/noop index, with small assumptions that labels are int32
*/
constructor(maxOffset) {
this.maxOffset = maxOffset;
}
keys() {
// memoize
const k = fillRange(new Int32Array(this.maxOffset));
this.keys = function keys() {
return k;
};
return k;
}
getOffset(i) {
// label to offset
return i;
}
getLabel(i) {
// offset to label
return i;
}
size() {
return this.maxOffset;
}
__promote(labelArray) {
/*
time/space decision - based on the resulting density
*/
const [minLabel, maxLabel] = extent(labelArray);
const labelSpaceSize = maxLabel - minLabel + 1;
const density = labelSpaceSize / this.maxOffset;
/* 0.1 is a magic number, that needs testing to optimize */
if (density < 0.1) {
return new KeyIndex(labelArray);
}
return new DenseInt32Index(labelArray, [minLabel, maxLabel]);
}
subsetLabels(labelArray) {
return this.__promote(labelArray);
}
withLabel(label) {
if (label === this.maxOffset) {
return new IdentityInt32Index(label + 1);
}
return this.__promote([...this.keys(), label]);
}
dropLabel(label) {
if (label === this.maxOffset - 1) {
return new IdentityInt32Index(label);
}
const labelArray = [...this.keys()];
labelArray.splice(labelArray.indexOf(label), 1);
return this.__promote(labelArray);
}
}
/* eslint-enable class-methods-use-this */
/* eslint-disable class-methods-use-this */
class DenseInt32Index {
/*
DenseInt32Index indexes integer labels, and uses Int32Array typed arrays
for both forward and reverse indexing. This means that the min/max range
of the forward index labels must be known a priori (so that the index
array can be pre-allocated).
*/
constructor(labels, labelRange = null) {
if (labels.constructor !== Int32Array) {
labels = new Int32Array(labels);
}
if (!labelRange) {
labelRange = extent(labels);
}
const [minLabel, maxLabel] = labelRange;
const labelSpaceSize = maxLabel - minLabel + 1;
const index = new Int32Array(labelSpaceSize).fill(-1);
for (let i = 0, l = labels.length; i < l; i += 1) {
const label = labels[i];
index[label - minLabel] = i;
}
this.minLabel = minLabel;
this.rindex = labels;
this.index = index;
this.__compile();
}
__compile() {
const { minLabel, index, rindex } = this;
this.getOffset = function getOffset(l) {
return index[l - minLabel];
};
this.getLabel = function getLabel(i) {
return rindex[i];
};
}
keys() {
return this.rindex;
}
size() {
return this.rindex.length;
}
__promote(labelArray) {
/*
time/space decision - if we are going to use less than 10% of the
dense index space, switch to a KeyIndex (which is slower, but uses
less memory for sparse label spaces).
*/
const [minLabel, maxLabel] = extent(labelArray);
const labelSpaceSize = maxLabel - minLabel + 1;
const density = labelSpaceSize / this.rindex.length;
/* 0.1 is a magic number, that needs testing to optimize */
if (density < 0.1) {
return new KeyIndex(labelArray);
}
return new DenseInt32Index(labelArray, [minLabel, maxLabel]);
}
subsetLabels(labelArray) {
return this.__promote(labelArray);
}
withLabel(label) {
return this.__promote([...this.keys(), label]);
}
dropLabel(label) {
const labelArray = [...this.keys()];
labelArray.splice(labelArray.indexOf(label), 1);
return this.__promote(labelArray);
}
}
/* eslint-enable class-methods-use-this */
/* eslint-disable class-methods-use-this */
class KeyIndex {
/*
KeyIndex indexes arbitrary JS primitive types, and uses a Map()
as its core data structure.
*/
constructor(labels) {
const index = new Map();
if (labels === undefined) {
labels = [];
}
const rindex = labels;
labels.forEach((v, i) => {
index.set(v, i);
});
this.index = index;
this.rindex = rindex;
this.__compile();
}
__compile() {
const { index, rindex } = this;
this.getOffset = function getOffset(k) {
return index.get(k);
};
this.getLabel = function getLabel(i) {
return rindex[i];
};
}
keys() {
return this.rindex;
}
size() {
return this.rindex.length;
}
subsetLabels(labelArray) {
return new KeyIndex(labelArray);
}
withLabel(label) {
return new KeyIndex([...this.rindex, label]);
}
dropLabel(label) {
const idx = this.rindex.indexOf(label);
const labelArray = [...this.rindex];
labelArray.splice(idx, 1);
return new KeyIndex(labelArray);
}
}
/* eslint-enable class-methods-use-this */
function isLabelIndex(i) {
return (
i instanceof IdentityInt32Index ||
i instanceof DenseInt32Index ||
i instanceof KeyIndex
);
}
export { DenseInt32Index, IdentityInt32Index, KeyIndex, isLabelIndex };
-57
View File
@@ -1,57 +0,0 @@
/*
Private dataframe support functions
*/
export function summarizeContinuous(col) {
let min;
let max;
let nan = 0;
let pinf = 0;
let ninf = 0;
if (col) {
for (let r = 0, l = col.length; r < l; r += 1) {
const val = Number(col[r]);
if (Number.isFinite(val)) {
if (min === undefined) {
min = val;
max = val;
} else {
min = val < min ? val : min;
max = val > max ? val : max;
}
} else if (Number.isNaN(val)) {
nan += 1;
} else if (val > 0) {
pinf += 1;
} else {
ninf += 1;
}
}
}
return {
categorical: false,
min,
max,
nan,
pinf,
ninf
};
}
export function summarizeCategorical(col) {
const categoryCounts = new Map();
if (col) {
for (let r = 0, l = col.length; r < l; r += 1) {
const val = col[r];
let curCount = categoryCounts.get(val);
if (curCount === undefined) curCount = 0;
categoryCounts.set(val, curCount + 1);
}
}
return {
categorical: true,
categories: [...categoryCounts.keys()],
categoryCounts,
numCategories: categoryCounts.size
};
}
-28
View File
@@ -1,28 +0,0 @@
/*
Private utility code for dataframe
*/
export function isTypedArray(x) {
return (
ArrayBuffer.isView(x) &&
Object.prototype.toString.call(x) !== "[object DataView]"
);
}
export function isArrayOrTypedArray(x) {
return Array.isArray(x) || isTypedArray(x);
}
export function callOnceLazy(f) {
let value;
let calledOnce = false;
const result = function result(...args) {
if (!calledOnce) {
value = f(...args);
calledOnce = true;
}
return value;
};
return result;
}
-33
View File
@@ -1,33 +0,0 @@
/*
Return the [minimum, maximum] extent, of the given typed array, ignoring
non-finite values (ie, +Infinity, -Infinity).
If undefined or empty array, or array contains only non-finite numbers,
will return [undefined, undefined]
*/
function finiteExtent(tarr) {
let min;
let max;
let i;
for (i = 0; i < tarr.length; i += 1) {
const val = tarr[i];
if (Number.isFinite(val)) {
min = val;
max = val;
i += 1;
break;
}
}
for (; i < tarr.length; i += 1) {
const val = tarr[i];
if (Number.isFinite(val)) {
if (min > val) min = val;
if (max < val) max = val;
}
}
return [min, max];
}
export default finiteExtent;
@@ -1,164 +0,0 @@
/*
Helper functions for the controls reducer
*/
import _ from "lodash";
import * as globals from "../../globals";
import { fillRange } from "../typedCrossfilter/util";
import {
userDefinedDimensionName,
diffexpDimensionName
} from "../nameCreators";
import * as World from "./world";
/*
Selection state for categoricals are tracked in an Object that
has two main components for each category:
1. mapping of option value to an index
2. array of bool selection state by index
Remember that option values can be ANY js type, except undefined/null.
{
_category_name_1: {
// map of option value to index
categoryIndices: Map([
catval1: index,
...
])
// index->selection true/false state
categorySelected: [ true/false, true/false, ... ]
// number of options
numCategories: number,
// isTruncated - true if the options for selection has
// been truncated (ie, was too large to implement)
}
}
*/
function topNCategories(summary) {
const counts = _.map(summary.categories, cat =>
summary.categoryCounts.get(cat)
);
const sortIndex = fillRange(new Array(summary.numCategories)).sort(
(a, b) => counts[b] - counts[a]
);
const sortedCategories = _.map(sortIndex, i => summary.categories[i]);
const sortedCounts = _.map(sortIndex, i => counts[i]);
const N = globals.maxCategoricalOptionsToDisplay;
if (sortedCategories.length < N) {
return [sortedCategories, sortedCounts];
}
return [sortedCategories.slice(0, N), sortedCounts.slice(0, N)];
}
export function createCategoricalSelectionState(state, world) {
const res = {};
_.forEach(world.obsAnnotations.colIndex.keys(), key => {
const summary = world.obsAnnotations.col(key).summarize();
if (summary.categories) {
const isColorField = key.includes("color") || key.includes("Color");
const isSelectableCategory =
!isColorField &&
key !== "name" &&
summary.categories.length < state.maxCategoryItems;
if (isSelectableCategory) {
const [categoryValues, categoryCounts] = topNCategories(summary);
const categoryIndices = new Map(categoryValues.map((v, i) => [v, i]));
const numCategories = categoryIndices.size;
const categorySelected = new Array(numCategories).fill(true);
const isTruncated = categoryValues.length < summary.numCategories;
res[key] = {
categoryValues, // array: of natively typed category values
categoryIndices, // map: category value (native type) -> category index
categorySelected, // array: t/f selection state
numCategories, // number: of categories
isTruncated, // bool: true if list was truncated
categoryCounts // array: cardinality of each category
};
}
}
});
return res;
}
/*
given a categoricalSelectionState, return the list of all category values
where selection state is true (ie, they are selected).
*/
export function selectedValuesForCategory(categorySelectionState) {
const selectedValues = _([...categorySelectionState.categoryIndices])
.filter(tuple => categorySelectionState.categorySelected[tuple[1]])
.map(tuple => tuple[0])
.value();
return selectedValues;
}
/*
build a crossfilter dimension map for all gene expression related dimensions.
*/
export function createGenesDimMap(
userDefinedGenes,
diffexpGenes,
world,
crossfilter
) {
function _createGenesDimMap(genes, nameCreator) {
return genes.reduce((acc, gene) => {
acc[nameCreator(gene)] = World.createVarDataDimension(
world,
crossfilter,
gene
);
return acc;
}, {});
}
return {
..._createGenesDimMap(userDefinedGenes, userDefinedDimensionName),
..._createGenesDimMap(diffexpGenes, diffexpDimensionName)
};
}
export function pruneVarDataCache(varData, needed) {
/*
Remove any unneeded columns from the varData dataframe. Will only
prune / remove if the total column count exceeds VarDataCacheLowWatermark
Note: this code leverages the fact that dataframe offsets indicate
the order in which the columns were added. This crudely provides
LRU semantics, so we can delete "older" columns first.
*/
/*
VarDataCacheLowWatermark - this cofig value sets the minimum cache size,
in columns, below which we don't throw away data.
The value should be high enough so we are caching the maximum which will
"typically" be used in the UI (currently: 10 for diffexp, and N for user-
specified genes), and low enough to account for memory use (any single
column size is 4 bytes * numObs, so a column can be multi-megabyte in common
use cases).
*/
const VarDataCacheLowWatermark = 32;
const numOverWatermark = varData.dims[1] - VarDataCacheLowWatermark;
if (numOverWatermark <= 0) return varData;
const { colIndex } = varData;
const all = colIndex.keys();
const unused = _.difference(all, needed);
if (unused.length > 0) {
// sort by offset in the dataframe - ie, psuedo-LRU
unused.sort((a, b) => colIndex.getOffset(a) - colIndex.getOffset(b));
const numToDrop =
unused.length < numOverWatermark ? unused.length : numOverWatermark;
for (let i = 0; i < numToDrop; i += 1) {
varData = varData.dropCol(unused[i]);
}
}
return varData;
}
+2 -3
View File
@@ -3,7 +3,7 @@
/* /*
Model manager providing an abstraction for the use of the reducer code. Model manager providing an abstraction for the use of the reducer code.
This module provides several buckets of functionality: This module provides several buckets of functionality:
- schema and config driven tranformation of the wire protocol - schema and config driven tranformation of the dataframe wire protocol
into a format that is easy for the UI code to use. into a format that is easy for the UI code to use.
- manage the universe/world abstraction: - manage the universe/world abstraction:
+ universe: all of the server-provided, read-only data + universe: all of the server-provided, read-only data
@@ -16,5 +16,4 @@ exists to support those concepts.
export * as Universe from "./universe"; export * as Universe from "./universe";
export * as World from "./world"; export * as World from "./world";
export * as WorldUtil from "./worldUtil"; export * as kvCache from "./keyvalcache";
export * as ControlsHelper from "./controlsHelpers";
+122
View File
@@ -0,0 +1,122 @@
// jshint esversion: 6
import _ from "lodash";
/*
Very simple key/value cache for use by World & Universe. Cache keys must
be a string, and values are any JS non-primitive value.
* constructor(lowWatermark, minTTL):
- lowWatermark defines the number of cache elements below which
flushing will not occur.
- minTTL defines minimum time in milliseconds that cache entries will live.
A value of -1 disables automatic flushing (flush() can still
be called by external user).
* set() - add a key/val pair.
* get() - get a value or undefined if not present.
* flush(minAgeMs) - flush cache entries in excess of lowWatermark if those
entries are older than minAgeMs.
*/
const cachePrivateKey = "__kvcachekey__";
const defaultLowWatermark = 32;
const defaultMinTTL = 1000;
function create(lowWatermark = defaultLowWatermark, minTTL = defaultMinTTL) {
if (typeof minTTL !== "number" || typeof lowWatermark !== "number") {
throw new TypeError(
"minTTL and lowWatermark parameters must be a primitive number"
);
}
if (lowWatermark < 0 || minTTL < 0) {
throw new RangeError(
"minTTL and lowWatermark parameters must be number greater than zero"
);
}
return {
[cachePrivateKey]: {
lowWatermark,
minTTL
}
};
}
function get(kvcache, key) {
if (key === cachePrivateKey) {
throw new RangeError(`key parameter may not have value ${cachePrivateKey}`);
}
const val = kvcache[key];
if (val) {
val[cachePrivateKey] = Date.now();
}
return val;
}
function set(kvcache, key, val) {
if (key === cachePrivateKey) {
throw new RangeError(`key parameter may not have value ${cachePrivateKey}`);
}
const newKvCache = { ...kvcache };
newKvCache[key] = val;
val[cachePrivateKey] = Date.now();
flushInPlace(newKvCache);
return newKvCache;
}
function flush(kvcache) {
const newKvCache = { ...kvcache };
flushInPlace(newKvCache);
return newKvCache;
}
/*
Flush elements from cache IF cache size is greater than lowWatermark, and
those elements are older than minAgeMS
*/
function flushInPlace(kvCache) {
const { lowWatermark, minTTL } = kvCache[cachePrivateKey];
const eol = Date.now() - minTTL;
const allKeys = _(kvCache)
.keys()
.filter(k => k !== cachePrivateKey)
.sortBy([k => kvCache[k][cachePrivateKey]])
.value();
if (allKeys.length > lowWatermark) {
const keysToDelete = _(allKeys)
.slice(0, allKeys.length - lowWatermark)
.filter(k => kvCache[k][cachePrivateKey] <= eol)
.value();
_.forEach(keysToDelete, k => delete kvCache[k]);
}
return kvCache;
}
/*
use to create a cache that is a transformation of another cache.
*/
function map(srcKvCache, cb, createOptions) {
const keysInSrcKvCache = _(srcKvCache)
.keys()
.filter(k => k !== cachePrivateKey)
.value();
const lowWatermark = _.get(
createOptions,
"lowWatermark",
defaultLowWatermark
);
const minTTL = _.get(createOptions, "minTTL", defaultMinTTL);
const newKvCache = create(lowWatermark, minTTL);
_.forEach(keysInSrcKvCache, key => {
const val = cb(get(srcKvCache, key), key);
newKvCache[key] = val;
val[cachePrivateKey] = Date.now();
});
return newKvCache;
}
export { create, get, set, flush, map };
-75
View File
@@ -1,75 +0,0 @@
import { flatbuffers } from "flatbuffers";
import { NetEncoding } from "./matrix_generated";
const utf8Decoder = new TextDecoder("utf-8");
/*
Matrix flatbuffer decoding support. See fbs/matrix.fbs
*/
/*
Decode NetEncoding.TypedArray
*/
function decodeTypedArray(uType, uValF, inplace = false) {
if (uType === NetEncoding.TypedArray.NONE) {
return null;
}
// Convert to a JS class that supports this type
const TypeClass = NetEncoding[NetEncoding.TypedArray[uType]];
// Create a TypedArray that references the underlying buffer
let arr = uValF(new TypeClass()).dataArray();
if (uType === NetEncoding.TypedArray.JSONEncodedArray) {
const json = utf8Decoder.decode(arr);
arr = JSON.parse(json);
} else if (!inplace) {
/* force copy to release underlying FBS buffer */
arr = new arr.constructor(arr);
}
return arr;
}
/*
Parameter: Uint8Array or ArrayBuffer containing raw flatbuffer Matrix
Returns: object containing decoded Matrix:
{
nRows: num,
nCols: num,
columns: [
each column, which will be a TypedArray or Array
]
colIdx: []|null
}
*/
function decodeMatrixFBS(arrayBuffer, inplace = false) {
const bb = new flatbuffers.ByteBuffer(new Uint8Array(arrayBuffer));
const df = NetEncoding.Matrix.getRootAsMatrix(bb);
const nRows = df.nRows();
const nCols = df.nCols();
/* decode columns */
const columnsLength = df.columnsLength();
const columns = Array(columnsLength).fill(null);
for (let c = 0; c < columnsLength; c += 1) {
const col = df.columns(c);
columns[c] = decodeTypedArray(col.uType(), col.u.bind(col), inplace);
}
/* decode col_idx */
const colIdx = decodeTypedArray(
df.colIndexType(),
df.colIndex.bind(df),
inplace
);
return {
nRows,
nCols,
columns,
colIdx,
rowIdx: null
};
}
export default decodeMatrixFBS;
@@ -1,835 +0,0 @@
// automatically generated by the FlatBuffers compiler, do not modify
/**
* @const
* @namespace
*/
var NetEncoding = NetEncoding || {};
/**
* @enum
*/
NetEncoding.TypedArray = {
NONE: 0, 0: 'NONE',
Float32Array: 1, 1: 'Float32Array',
Int32Array: 2, 2: 'Int32Array',
Uint32Array: 3, 3: 'Uint32Array',
Float64Array: 4, 4: 'Float64Array',
JSONEncodedArray: 5, 5: 'JSONEncodedArray'
};
/**
* @constructor
*/
NetEncoding.Float32Array = function() {
/**
* @type {flatbuffers.ByteBuffer}
*/
this.bb = null;
/**
* @type {number}
*/
this.bb_pos = 0;
};
/**
* @param {number} i
* @param {flatbuffers.ByteBuffer} bb
* @returns {NetEncoding.Float32Array}
*/
NetEncoding.Float32Array.prototype.__init = function(i, bb) {
this.bb_pos = i;
this.bb = bb;
return this;
};
/**
* @param {flatbuffers.ByteBuffer} bb
* @param {NetEncoding.Float32Array=} obj
* @returns {NetEncoding.Float32Array}
*/
NetEncoding.Float32Array.getRootAsFloat32Array = function(bb, obj) {
return (obj || new NetEncoding.Float32Array).__init(bb.readInt32(bb.position()) + bb.position(), bb);
};
/**
* @param {number} index
* @returns {number}
*/
NetEncoding.Float32Array.prototype.data = function(index) {
var offset = this.bb.__offset(this.bb_pos, 4);
return offset ? this.bb.readFloat32(this.bb.__vector(this.bb_pos + offset) + index * 4) : 0;
};
/**
* @returns {number}
*/
NetEncoding.Float32Array.prototype.dataLength = function() {
var offset = this.bb.__offset(this.bb_pos, 4);
return offset ? this.bb.__vector_len(this.bb_pos + offset) : 0;
};
/**
* @returns {Float32Array}
*/
NetEncoding.Float32Array.prototype.dataArray = function() {
var offset = this.bb.__offset(this.bb_pos, 4);
return offset ? new Float32Array(this.bb.bytes().buffer, this.bb.bytes().byteOffset + this.bb.__vector(this.bb_pos + offset), this.bb.__vector_len(this.bb_pos + offset)) : null;
};
/**
* @param {flatbuffers.Builder} builder
*/
NetEncoding.Float32Array.startFloat32Array = function(builder) {
builder.startObject(1);
};
/**
* @param {flatbuffers.Builder} builder
* @param {flatbuffers.Offset} dataOffset
*/
NetEncoding.Float32Array.addData = function(builder, dataOffset) {
builder.addFieldOffset(0, dataOffset, 0);
};
/**
* @param {flatbuffers.Builder} builder
* @param {Array.<number>} data
* @returns {flatbuffers.Offset}
*/
NetEncoding.Float32Array.createDataVector = function(builder, data) {
builder.startVector(4, data.length, 4);
for (var i = data.length - 1; i >= 0; i--) {
builder.addFloat32(data[i]);
}
return builder.endVector();
};
/**
* @param {flatbuffers.Builder} builder
* @param {number} numElems
*/
NetEncoding.Float32Array.startDataVector = function(builder, numElems) {
builder.startVector(4, numElems, 4);
};
/**
* @param {flatbuffers.Builder} builder
* @returns {flatbuffers.Offset}
*/
NetEncoding.Float32Array.endFloat32Array = function(builder) {
var offset = builder.endObject();
return offset;
};
/**
* @constructor
*/
NetEncoding.Uint32Array = function() {
/**
* @type {flatbuffers.ByteBuffer}
*/
this.bb = null;
/**
* @type {number}
*/
this.bb_pos = 0;
};
/**
* @param {number} i
* @param {flatbuffers.ByteBuffer} bb
* @returns {NetEncoding.Uint32Array}
*/
NetEncoding.Uint32Array.prototype.__init = function(i, bb) {
this.bb_pos = i;
this.bb = bb;
return this;
};
/**
* @param {flatbuffers.ByteBuffer} bb
* @param {NetEncoding.Uint32Array=} obj
* @returns {NetEncoding.Uint32Array}
*/
NetEncoding.Uint32Array.getRootAsUint32Array = function(bb, obj) {
return (obj || new NetEncoding.Uint32Array).__init(bb.readInt32(bb.position()) + bb.position(), bb);
};
/**
* @param {number} index
* @returns {number}
*/
NetEncoding.Uint32Array.prototype.data = function(index) {
var offset = this.bb.__offset(this.bb_pos, 4);
return offset ? this.bb.readUint32(this.bb.__vector(this.bb_pos + offset) + index * 4) : 0;
};
/**
* @returns {number}
*/
NetEncoding.Uint32Array.prototype.dataLength = function() {
var offset = this.bb.__offset(this.bb_pos, 4);
return offset ? this.bb.__vector_len(this.bb_pos + offset) : 0;
};
/**
* @returns {Uint32Array}
*/
NetEncoding.Uint32Array.prototype.dataArray = function() {
var offset = this.bb.__offset(this.bb_pos, 4);
return offset ? new Uint32Array(this.bb.bytes().buffer, this.bb.bytes().byteOffset + this.bb.__vector(this.bb_pos + offset), this.bb.__vector_len(this.bb_pos + offset)) : null;
};
/**
* @param {flatbuffers.Builder} builder
*/
NetEncoding.Uint32Array.startUint32Array = function(builder) {
builder.startObject(1);
};
/**
* @param {flatbuffers.Builder} builder
* @param {flatbuffers.Offset} dataOffset
*/
NetEncoding.Uint32Array.addData = function(builder, dataOffset) {
builder.addFieldOffset(0, dataOffset, 0);
};
/**
* @param {flatbuffers.Builder} builder
* @param {Array.<number>} data
* @returns {flatbuffers.Offset}
*/
NetEncoding.Uint32Array.createDataVector = function(builder, data) {
builder.startVector(4, data.length, 4);
for (var i = data.length - 1; i >= 0; i--) {
builder.addInt32(data[i]);
}
return builder.endVector();
};
/**
* @param {flatbuffers.Builder} builder
* @param {number} numElems
*/
NetEncoding.Uint32Array.startDataVector = function(builder, numElems) {
builder.startVector(4, numElems, 4);
};
/**
* @param {flatbuffers.Builder} builder
* @returns {flatbuffers.Offset}
*/
NetEncoding.Uint32Array.endUint32Array = function(builder) {
var offset = builder.endObject();
return offset;
};
/**
* @constructor
*/
NetEncoding.Int32Array = function() {
/**
* @type {flatbuffers.ByteBuffer}
*/
this.bb = null;
/**
* @type {number}
*/
this.bb_pos = 0;
};
/**
* @param {number} i
* @param {flatbuffers.ByteBuffer} bb
* @returns {NetEncoding.Int32Array}
*/
NetEncoding.Int32Array.prototype.__init = function(i, bb) {
this.bb_pos = i;
this.bb = bb;
return this;
};
/**
* @param {flatbuffers.ByteBuffer} bb
* @param {NetEncoding.Int32Array=} obj
* @returns {NetEncoding.Int32Array}
*/
NetEncoding.Int32Array.getRootAsInt32Array = function(bb, obj) {
return (obj || new NetEncoding.Int32Array).__init(bb.readInt32(bb.position()) + bb.position(), bb);
};
/**
* @param {number} index
* @returns {number}
*/
NetEncoding.Int32Array.prototype.data = function(index) {
var offset = this.bb.__offset(this.bb_pos, 4);
return offset ? this.bb.readInt32(this.bb.__vector(this.bb_pos + offset) + index * 4) : 0;
};
/**
* @returns {number}
*/
NetEncoding.Int32Array.prototype.dataLength = function() {
var offset = this.bb.__offset(this.bb_pos, 4);
return offset ? this.bb.__vector_len(this.bb_pos + offset) : 0;
};
/**
* @returns {Int32Array}
*/
NetEncoding.Int32Array.prototype.dataArray = function() {
var offset = this.bb.__offset(this.bb_pos, 4);
return offset ? new Int32Array(this.bb.bytes().buffer, this.bb.bytes().byteOffset + this.bb.__vector(this.bb_pos + offset), this.bb.__vector_len(this.bb_pos + offset)) : null;
};
/**
* @param {flatbuffers.Builder} builder
*/
NetEncoding.Int32Array.startInt32Array = function(builder) {
builder.startObject(1);
};
/**
* @param {flatbuffers.Builder} builder
* @param {flatbuffers.Offset} dataOffset
*/
NetEncoding.Int32Array.addData = function(builder, dataOffset) {
builder.addFieldOffset(0, dataOffset, 0);
};
/**
* @param {flatbuffers.Builder} builder
* @param {Array.<number>} data
* @returns {flatbuffers.Offset}
*/
NetEncoding.Int32Array.createDataVector = function(builder, data) {
builder.startVector(4, data.length, 4);
for (var i = data.length - 1; i >= 0; i--) {
builder.addInt32(data[i]);
}
return builder.endVector();
};
/**
* @param {flatbuffers.Builder} builder
* @param {number} numElems
*/
NetEncoding.Int32Array.startDataVector = function(builder, numElems) {
builder.startVector(4, numElems, 4);
};
/**
* @param {flatbuffers.Builder} builder
* @returns {flatbuffers.Offset}
*/
NetEncoding.Int32Array.endInt32Array = function(builder) {
var offset = builder.endObject();
return offset;
};
/**
* @constructor
*/
NetEncoding.Float64Array = function() {
/**
* @type {flatbuffers.ByteBuffer}
*/
this.bb = null;
/**
* @type {number}
*/
this.bb_pos = 0;
};
/**
* @param {number} i
* @param {flatbuffers.ByteBuffer} bb
* @returns {NetEncoding.Float64Array}
*/
NetEncoding.Float64Array.prototype.__init = function(i, bb) {
this.bb_pos = i;
this.bb = bb;
return this;
};
/**
* @param {flatbuffers.ByteBuffer} bb
* @param {NetEncoding.Float64Array=} obj
* @returns {NetEncoding.Float64Array}
*/
NetEncoding.Float64Array.getRootAsFloat64Array = function(bb, obj) {
return (obj || new NetEncoding.Float64Array).__init(bb.readInt32(bb.position()) + bb.position(), bb);
};
/**
* @param {number} index
* @returns {number}
*/
NetEncoding.Float64Array.prototype.data = function(index) {
var offset = this.bb.__offset(this.bb_pos, 4);
return offset ? this.bb.readFloat64(this.bb.__vector(this.bb_pos + offset) + index * 8) : 0;
};
/**
* @returns {number}
*/
NetEncoding.Float64Array.prototype.dataLength = function() {
var offset = this.bb.__offset(this.bb_pos, 4);
return offset ? this.bb.__vector_len(this.bb_pos + offset) : 0;
};
/**
* @returns {Float64Array}
*/
NetEncoding.Float64Array.prototype.dataArray = function() {
var offset = this.bb.__offset(this.bb_pos, 4);
return offset ? new Float64Array(this.bb.bytes().buffer, this.bb.bytes().byteOffset + this.bb.__vector(this.bb_pos + offset), this.bb.__vector_len(this.bb_pos + offset)) : null;
};
/**
* @param {flatbuffers.Builder} builder
*/
NetEncoding.Float64Array.startFloat64Array = function(builder) {
builder.startObject(1);
};
/**
* @param {flatbuffers.Builder} builder
* @param {flatbuffers.Offset} dataOffset
*/
NetEncoding.Float64Array.addData = function(builder, dataOffset) {
builder.addFieldOffset(0, dataOffset, 0);
};
/**
* @param {flatbuffers.Builder} builder
* @param {Array.<number>} data
* @returns {flatbuffers.Offset}
*/
NetEncoding.Float64Array.createDataVector = function(builder, data) {
builder.startVector(8, data.length, 8);
for (var i = data.length - 1; i >= 0; i--) {
builder.addFloat64(data[i]);
}
return builder.endVector();
};
/**
* @param {flatbuffers.Builder} builder
* @param {number} numElems
*/
NetEncoding.Float64Array.startDataVector = function(builder, numElems) {
builder.startVector(8, numElems, 8);
};
/**
* @param {flatbuffers.Builder} builder
* @returns {flatbuffers.Offset}
*/
NetEncoding.Float64Array.endFloat64Array = function(builder) {
var offset = builder.endObject();
return offset;
};
/**
* @constructor
*/
NetEncoding.JSONEncodedArray = function() {
/**
* @type {flatbuffers.ByteBuffer}
*/
this.bb = null;
/**
* @type {number}
*/
this.bb_pos = 0;
};
/**
* @param {number} i
* @param {flatbuffers.ByteBuffer} bb
* @returns {NetEncoding.JSONEncodedArray}
*/
NetEncoding.JSONEncodedArray.prototype.__init = function(i, bb) {
this.bb_pos = i;
this.bb = bb;
return this;
};
/**
* @param {flatbuffers.ByteBuffer} bb
* @param {NetEncoding.JSONEncodedArray=} obj
* @returns {NetEncoding.JSONEncodedArray}
*/
NetEncoding.JSONEncodedArray.getRootAsJSONEncodedArray = function(bb, obj) {
return (obj || new NetEncoding.JSONEncodedArray).__init(bb.readInt32(bb.position()) + bb.position(), bb);
};
/**
* @param {number} index
* @returns {number}
*/
NetEncoding.JSONEncodedArray.prototype.data = function(index) {
var offset = this.bb.__offset(this.bb_pos, 4);
return offset ? this.bb.readUint8(this.bb.__vector(this.bb_pos + offset) + index) : 0;
};
/**
* @returns {number}
*/
NetEncoding.JSONEncodedArray.prototype.dataLength = function() {
var offset = this.bb.__offset(this.bb_pos, 4);
return offset ? this.bb.__vector_len(this.bb_pos + offset) : 0;
};
/**
* @returns {Uint8Array}
*/
NetEncoding.JSONEncodedArray.prototype.dataArray = function() {
var offset = this.bb.__offset(this.bb_pos, 4);
return offset ? new Uint8Array(this.bb.bytes().buffer, this.bb.bytes().byteOffset + this.bb.__vector(this.bb_pos + offset), this.bb.__vector_len(this.bb_pos + offset)) : null;
};
/**
* @param {flatbuffers.Builder} builder
*/
NetEncoding.JSONEncodedArray.startJSONEncodedArray = function(builder) {
builder.startObject(1);
};
/**
* @param {flatbuffers.Builder} builder
* @param {flatbuffers.Offset} dataOffset
*/
NetEncoding.JSONEncodedArray.addData = function(builder, dataOffset) {
builder.addFieldOffset(0, dataOffset, 0);
};
/**
* @param {flatbuffers.Builder} builder
* @param {Array.<number>} data
* @returns {flatbuffers.Offset}
*/
NetEncoding.JSONEncodedArray.createDataVector = function(builder, data) {
builder.startVector(1, data.length, 1);
for (var i = data.length - 1; i >= 0; i--) {
builder.addInt8(data[i]);
}
return builder.endVector();
};
/**
* @param {flatbuffers.Builder} builder
* @param {number} numElems
*/
NetEncoding.JSONEncodedArray.startDataVector = function(builder, numElems) {
builder.startVector(1, numElems, 1);
};
/**
* @param {flatbuffers.Builder} builder
* @returns {flatbuffers.Offset}
*/
NetEncoding.JSONEncodedArray.endJSONEncodedArray = function(builder) {
var offset = builder.endObject();
return offset;
};
/**
* @constructor
*/
NetEncoding.Column = function() {
/**
* @type {flatbuffers.ByteBuffer}
*/
this.bb = null;
/**
* @type {number}
*/
this.bb_pos = 0;
};
/**
* @param {number} i
* @param {flatbuffers.ByteBuffer} bb
* @returns {NetEncoding.Column}
*/
NetEncoding.Column.prototype.__init = function(i, bb) {
this.bb_pos = i;
this.bb = bb;
return this;
};
/**
* @param {flatbuffers.ByteBuffer} bb
* @param {NetEncoding.Column=} obj
* @returns {NetEncoding.Column}
*/
NetEncoding.Column.getRootAsColumn = function(bb, obj) {
return (obj || new NetEncoding.Column).__init(bb.readInt32(bb.position()) + bb.position(), bb);
};
/**
* @returns {NetEncoding.TypedArray}
*/
NetEncoding.Column.prototype.uType = function() {
var offset = this.bb.__offset(this.bb_pos, 4);
return offset ? /** @type {NetEncoding.TypedArray} */ (this.bb.readUint8(this.bb_pos + offset)) : NetEncoding.TypedArray.NONE;
};
/**
* @param {flatbuffers.Table} obj
* @returns {?flatbuffers.Table}
*/
NetEncoding.Column.prototype.u = function(obj) {
var offset = this.bb.__offset(this.bb_pos, 6);
return offset ? this.bb.__union(obj, this.bb_pos + offset) : null;
};
/**
* @param {flatbuffers.Builder} builder
*/
NetEncoding.Column.startColumn = function(builder) {
builder.startObject(2);
};
/**
* @param {flatbuffers.Builder} builder
* @param {NetEncoding.TypedArray} uType
*/
NetEncoding.Column.addUType = function(builder, uType) {
builder.addFieldInt8(0, uType, NetEncoding.TypedArray.NONE);
};
/**
* @param {flatbuffers.Builder} builder
* @param {flatbuffers.Offset} uOffset
*/
NetEncoding.Column.addU = function(builder, uOffset) {
builder.addFieldOffset(1, uOffset, 0);
};
/**
* @param {flatbuffers.Builder} builder
* @returns {flatbuffers.Offset}
*/
NetEncoding.Column.endColumn = function(builder) {
var offset = builder.endObject();
return offset;
};
/**
* @constructor
*/
NetEncoding.Matrix = function() {
/**
* @type {flatbuffers.ByteBuffer}
*/
this.bb = null;
/**
* @type {number}
*/
this.bb_pos = 0;
};
/**
* @param {number} i
* @param {flatbuffers.ByteBuffer} bb
* @returns {NetEncoding.Matrix}
*/
NetEncoding.Matrix.prototype.__init = function(i, bb) {
this.bb_pos = i;
this.bb = bb;
return this;
};
/**
* @param {flatbuffers.ByteBuffer} bb
* @param {NetEncoding.Matrix=} obj
* @returns {NetEncoding.Matrix}
*/
NetEncoding.Matrix.getRootAsMatrix = function(bb, obj) {
return (obj || new NetEncoding.Matrix).__init(bb.readInt32(bb.position()) + bb.position(), bb);
};
/**
* @returns {number}
*/
NetEncoding.Matrix.prototype.nRows = function() {
var offset = this.bb.__offset(this.bb_pos, 4);
return offset ? this.bb.readUint32(this.bb_pos + offset) : 0;
};
/**
* @returns {number}
*/
NetEncoding.Matrix.prototype.nCols = function() {
var offset = this.bb.__offset(this.bb_pos, 6);
return offset ? this.bb.readUint32(this.bb_pos + offset) : 0;
};
/**
* @param {number} index
* @param {NetEncoding.Column=} obj
* @returns {NetEncoding.Column}
*/
NetEncoding.Matrix.prototype.columns = function(index, obj) {
var offset = this.bb.__offset(this.bb_pos, 8);
return offset ? (obj || new NetEncoding.Column).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + offset) + index * 4), this.bb) : null;
};
/**
* @returns {number}
*/
NetEncoding.Matrix.prototype.columnsLength = function() {
var offset = this.bb.__offset(this.bb_pos, 8);
return offset ? this.bb.__vector_len(this.bb_pos + offset) : 0;
};
/**
* @returns {NetEncoding.TypedArray}
*/
NetEncoding.Matrix.prototype.colIndexType = function() {
var offset = this.bb.__offset(this.bb_pos, 10);
return offset ? /** @type {NetEncoding.TypedArray} */ (this.bb.readUint8(this.bb_pos + offset)) : NetEncoding.TypedArray.NONE;
};
/**
* @param {flatbuffers.Table} obj
* @returns {?flatbuffers.Table}
*/
NetEncoding.Matrix.prototype.colIndex = function(obj) {
var offset = this.bb.__offset(this.bb_pos, 12);
return offset ? this.bb.__union(obj, this.bb_pos + offset) : null;
};
/**
* @returns {NetEncoding.TypedArray}
*/
NetEncoding.Matrix.prototype.rowIndexType = function() {
var offset = this.bb.__offset(this.bb_pos, 14);
return offset ? /** @type {NetEncoding.TypedArray} */ (this.bb.readUint8(this.bb_pos + offset)) : NetEncoding.TypedArray.NONE;
};
/**
* @param {flatbuffers.Table} obj
* @returns {?flatbuffers.Table}
*/
NetEncoding.Matrix.prototype.rowIndex = function(obj) {
var offset = this.bb.__offset(this.bb_pos, 16);
return offset ? this.bb.__union(obj, this.bb_pos + offset) : null;
};
/**
* @param {flatbuffers.Builder} builder
*/
NetEncoding.Matrix.startMatrix = function(builder) {
builder.startObject(7);
};
/**
* @param {flatbuffers.Builder} builder
* @param {number} nRows
*/
NetEncoding.Matrix.addNRows = function(builder, nRows) {
builder.addFieldInt32(0, nRows, 0);
};
/**
* @param {flatbuffers.Builder} builder
* @param {number} nCols
*/
NetEncoding.Matrix.addNCols = function(builder, nCols) {
builder.addFieldInt32(1, nCols, 0);
};
/**
* @param {flatbuffers.Builder} builder
* @param {flatbuffers.Offset} columnsOffset
*/
NetEncoding.Matrix.addColumns = function(builder, columnsOffset) {
builder.addFieldOffset(2, columnsOffset, 0);
};
/**
* @param {flatbuffers.Builder} builder
* @param {Array.<flatbuffers.Offset>} data
* @returns {flatbuffers.Offset}
*/
NetEncoding.Matrix.createColumnsVector = function(builder, data) {
builder.startVector(4, data.length, 4);
for (var i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]);
}
return builder.endVector();
};
/**
* @param {flatbuffers.Builder} builder
* @param {number} numElems
*/
NetEncoding.Matrix.startColumnsVector = function(builder, numElems) {
builder.startVector(4, numElems, 4);
};
/**
* @param {flatbuffers.Builder} builder
* @param {NetEncoding.TypedArray} colIndexType
*/
NetEncoding.Matrix.addColIndexType = function(builder, colIndexType) {
builder.addFieldInt8(3, colIndexType, NetEncoding.TypedArray.NONE);
};
/**
* @param {flatbuffers.Builder} builder
* @param {flatbuffers.Offset} colIndexOffset
*/
NetEncoding.Matrix.addColIndex = function(builder, colIndexOffset) {
builder.addFieldOffset(4, colIndexOffset, 0);
};
/**
* @param {flatbuffers.Builder} builder
* @param {NetEncoding.TypedArray} rowIndexType
*/
NetEncoding.Matrix.addRowIndexType = function(builder, rowIndexType) {
builder.addFieldInt8(5, rowIndexType, NetEncoding.TypedArray.NONE);
};
/**
* @param {flatbuffers.Builder} builder
* @param {flatbuffers.Offset} rowIndexOffset
*/
NetEncoding.Matrix.addRowIndex = function(builder, rowIndexOffset) {
builder.addFieldOffset(6, rowIndexOffset, 0);
};
/**
* @param {flatbuffers.Builder} builder
* @returns {flatbuffers.Offset}
*/
NetEncoding.Matrix.endMatrix = function(builder) {
var offset = builder.endObject();
return offset;
};
/**
* @param {flatbuffers.Builder} builder
* @param {flatbuffers.Offset} offset
*/
NetEncoding.Matrix.finishMatrixBuffer = function(builder, offset) {
builder.finish(offset);
};
// Exports for ECMAScript6 Modules
export {NetEncoding};
@@ -0,0 +1,94 @@
import _ from "lodash";
/*
Build and return obs/var summary using any annotation in the schema
Summary information for each annotation, keyed by annotation name.
Value will be an object, containing summary information.
For continuous annotations (int, float, etc):
<annotation_name>: {
range {
min: <number>,
max: <number>
}
}
For categorical annotations (boolean, string, category):
<annotatoin_name>: {
options: {
<option1>: <number>,
...
},
numOptions: <number>
}
Summarize will be returned for BOTH obs and var annotations.
Example:
{
"Splice_sites_Annotated": {
"range": {
"min": 26,
"max": 1075869
}
},
"Selection": {
numOptions, 6,
"options": {
"Astrocytes(HEPACAM)": 714,
"Endothelial(BSC)": 123,
"Oligodendrocytes(GC)": 294,
"Neurons(Thy1)": 685,
"Microglia(CD45)": 1108,
"Unpanned": 665
}
}
}
NOTE: will not summarize the required 'name' annotation, as that is
specified as unique per element.
*/
function summarizeDimension(schema, annotations) {
return _(schema)
.filter(v => v.name !== "name")
.keyBy("name")
.mapValues(anno => {
const { name, type } = anno;
const continuous = type === "int32" || type === "float32";
if (!continuous) {
const options = _.countBy(annotations, name);
const numOptions = _.size(options);
return {
numOptions,
options
};
}
if (continuous) {
let min = Number.POSITIVE_INFINITY;
let max = Number.NEGATIVE_INFINITY;
_.forEach(annotations, obs => {
const val = Number(obs[name]);
min = val < min ? val : min;
max = val > max ? val : max;
});
return { range: { min, max } };
}
throw new Error("incomprehensible schema");
})
.value();
}
export default function summarizeAnnotations(
schema,
obsAnnotations,
varAnnotations
) {
return {
obs: summarizeDimension(schema.annotations.obs, obsAnnotations),
var: summarizeDimension(schema.annotations.var, varAnnotations)
};
}
+162 -77
View File
@@ -1,16 +1,23 @@
// jshint esversion: 6 // jshint esversion: 6
import _ from "lodash"; import _ from "lodash";
import * as kvCache from "./keyvalcache";
import decodeMatrixFBS from "./matrix"; import summarizeAnnotations from "./summarizeAnnotations";
import * as Dataframe from "../dataframe";
/* /*
Private helper function - create and return a template Universe Private helper function - create and return a template Universe
*/ */
function templateUniverse() { function templateUniverse() {
/* default universe template */ /* default universe template */
/* varDataCache config - see kvCache for semantics */
const VarDataCacheLowWatermark = 32; // cache element count
const VarDataCacheTTLMs = 1000; // min cache time in MS
return { return {
api: null,
finalized: false, // XXX: may not be needed
nObs: 0, nObs: 0,
nVar: 0, nVar: 0,
schema: {}, schema: {},
@@ -18,14 +25,21 @@ function templateUniverse() {
/* /*
Annotations Annotations
*/ */
obsAnnotations: Dataframe.Dataframe.empty(), obsAnnotations: [] /* all obs annotations, by obs index */,
varAnnotations: Dataframe.Dataframe.empty(), varAnnotations: [] /* all var annotations, by var index */,
obsLayout: Dataframe.Dataframe.empty(), obsNameToIndexMap: {} /* reverse map 'name' to index */,
varNameToIndexMap: {} /* reverse map 'name' to index */,
summary: null /* derived data summaries XXX: consider exploding in place */,
obsLayout: { X: [], Y: [] } /* xy layout */,
/* /*
Var data columns - subset of all Cache of var data (expression), by var annotation name. Data can be
accesses as a POJO, but if you want caching semantics, use the kvCache
API (eg., kvCache.get(), kvCache.set(), ...), which will maintain the
LRU semantics.
*/ */
varData: Dataframe.Dataframe.empty(null, new Dataframe.KeyIndex()) varDataCache: kvCache.create(VarDataCacheLowWatermark, VarDataCacheTTLMs)
}; };
} }
@@ -37,63 +51,122 @@ These functions are used exclusively by the actions and reducers to
build an internal POJO for use by the rendering components. build an internal POJO for use by the rendering components.
*/ */
function AnnotationsFBSToDataframe(arrayBuffer) { /*
generate any client-side transformations or summarization that
is independent of REST API response formats.
*/
function finalize(universe) {
/* A bit of sanity checking! */
const { nObs, nVar } = universe;
if (
nObs !== universe.obsAnnotations.length ||
nObs !== universe.obsLayout.X.length ||
nObs !== universe.obsLayout.Y.length ||
nVar !== universe.varAnnotations.length
) {
throw new Error("Universe dimensionality mismatch - failed to load");
}
// TODO: add more sanity checks, such as:
// - all annotations in the schema
// - layout has supported number of dimensions
// - ...
/* /*
Convert a Matrix FBS to a Dataframe. Create all derived (convenience) data structures.
*/ */
const fbs = decodeMatrixFBS(arrayBuffer); universe.obsNameToIndexMap = _.transform(
const df = new Dataframe.Dataframe( universe.obsAnnotations,
[fbs.nRows, fbs.nCols], (acc, value, idx) => {
fbs.columns, acc[value.name] = idx;
null, },
new Dataframe.KeyIndex(fbs.colIdx) {}
); );
return df; universe.varNameToIndexMap = _.transform(
universe.varAnnotations,
(acc, value, idx) => {
acc[value.name] = idx;
},
{}
);
universe.finalized = true;
return universe;
} }
function LayoutFBSToDataframe(arrayBuffer) { function RESTv02AnnotationsResponseToInternal(response) {
const fbs = decodeMatrixFBS(arrayBuffer, true); /*
const df = new Dataframe.Dataframe( Source per the spec:
[fbs.nRows, fbs.nCols], {
fbs.columns, names: [
null, 'tissue_type', 'sex', 'num_reads', 'clusters'
new Dataframe.KeyIndex(["X", "Y"]) ],
); data: [
return df; [ 0, 'lung', 'F', 39844, 99 ],
[ 1, 'heart', 'M', 83, 1 ],
[ 49, 'spleen', null, 2, "unknown cluster" ],
// [ obsOrVarIndex, value, value, value, value ],
// ...
]
}
Internal (target) format:
[
{ __index__: 0, tissue_type: "lung", sex: "F", ... },
...
]
*/
const { names, data } = response;
const keys = ["__index__", ...names];
return _(data)
.map(obs => _.zipObject(keys, obs))
.value();
} }
function reconcileSchemaCategoriesWithSummary(universe) { function RESTv02LayoutResponseToInternal(response) {
/* /*
where we treat types as (essentially) categorical metadata, update Source per the spec:
the schema with data-derived categories (in addition to those in {
the server declared schema). layout: {
ndims: 2,
For example, boolean defined fields in the schema do not contain coordinates: [
explicit declaration of categories (nor do string fields). In these [ 0, 0.284483, 0.983744 ],
cases, add a 'categories' field to the schema so it is accessible. [ 1, 0.038844, 0.739444 ],
*/ // [ obsOrVarIndex, X_coord, Y_coord ],
// ...
_.forEach(universe.schema.annotations.obs, s => { ]
if (
s.type === "string" ||
s.type === "boolean" ||
s.type === "categorical"
) {
const categories = _.union(
_.get(s, "categories", []),
_.get(universe.obsAnnotations.col(s.name).summarize(), "categories", [])
);
s.categories = categories;
} }
}); }
Target (internal) format:
{
X: Float32Array(numObs),
Y: Float32Array(numObs)
}
In the same order as obsAnnotations
*/
const { ndims, coordinates } = response.layout;
if (ndims !== 2) {
throw new Error("Unsupported layout dimensionality");
}
const layout = {
X: new Float32Array(coordinates.length),
Y: new Float32Array(coordinates.length)
};
for (let i = 0; i < coordinates.length; i += 1) {
const [idx, x, y] = coordinates[i];
layout.X[idx] = x;
layout.Y[idx] = y;
}
return layout;
} }
export function createUniverseFromResponse( export function createUniverseFromRestV02Response(
configResponse, configResponse,
schemaResponse, schemaResponse,
annotationsObsResponse, annotationsObsResponse,
annotationsVarResponse, annotationsVarResponse,
layoutFBSResponse layoutObsResponse
) { ) {
/* /*
build & return universe from a REST 0.2 /config, /schema and /annotations/obs response build & return universe from a REST 0.2 /config, /schema and /annotations/obs response
@@ -101,48 +174,60 @@ export function createUniverseFromResponse(
const { schema } = schemaResponse; const { schema } = schemaResponse;
const universe = templateUniverse(); const universe = templateUniverse();
/* constants */
universe.api = "0.2";
/* schema related */ /* schema related */
universe.schema = schema; universe.schema = schema;
universe.nObs = schema.dataframe.nObs; universe.nObs = schema.dataframe.nObs;
universe.nVar = schema.dataframe.nVar; universe.nVar = schema.dataframe.nVar;
/* annotations */ /* annotations */
universe.obsAnnotations = AnnotationsFBSToDataframe(annotationsObsResponse); universe.obsAnnotations = RESTv02AnnotationsResponseToInternal(
universe.varAnnotations = AnnotationsFBSToDataframe(annotationsVarResponse); annotationsObsResponse
);
universe.varAnnotations = RESTv02AnnotationsResponseToInternal(
annotationsVarResponse
);
/* layout */ /* layout */
universe.obsLayout = LayoutFBSToDataframe(layoutFBSResponse); universe.obsLayout = RESTv02LayoutResponseToInternal(layoutObsResponse);
/* sanity check */ universe.summary = summarizeAnnotations(
if ( universe.schema,
universe.nObs !== universe.obsLayout.length || universe.obsAnnotations,
universe.nObs !== universe.obsAnnotations.length || universe.varAnnotations
universe.nVar !== universe.varAnnotations.length );
) {
throw new Error("Universe dimensionality mismatch - failed to load");
}
reconcileSchemaCategoriesWithSummary(universe); return finalize(universe);
return universe;
} }
export function convertDataFBStoObject(universe, arrayBuffer) { export function convertExpressionRESTv02ToObject(universe, response) {
/* /*
/data/var returns a flatbuffer (FBS) as described by cellxgene/fbs/matrix.fbs /data/obs response looks like:
This routine converts the binary wire encoding into a JS object:
{ {
gene: Float32Array, var: [ varIndices fetched ],
... obs: [
[ obsIndex, evalue, ... ],
...
]
} }
*/
const fbs = decodeMatrixFBS(arrayBuffer);
const { colIdx, columns } = fbs;
const result = {};
for (let c = 0; c < colIdx.length; c += 1) { convert expression toa simple Float32Array, and return
const varName = universe.varAnnotations.at(colIdx[c], "name"); { geneName: array, geneName: array, ... }
result[varName] = columns[c]; NOTE: geneName, not varIndex
*/
const vars = response.var;
const { obs } = response;
const result = {};
// XXX TODO: could this use _.unzip and have less code?
for (let varIdx = 0; varIdx < vars.length; varIdx += 1) {
const gene = universe.varAnnotations[vars[varIdx]].name;
const data = new Float32Array(universe.nObs);
for (let obsIdx = 0; obsIdx < obs.length; obsIdx += 1) {
data[obsIdx] = obs[obsIdx][varIdx + 1];
}
result[gene] = data;
} }
return result; return result;
} }
+143 -80
View File
@@ -1,12 +1,12 @@
// jshint esversion: 6 // jshint esversion: 6
import _ from "lodash"; import _ from "lodash";
import * as kvCache from "./keyvalcache";
import summarizeAnnotations from "./summarizeAnnotations";
import { layoutDimensionName, obsAnnoDimensionName } from "../nameCreators"; import { layoutDimensionName, obsAnnoDimensionName } from "../nameCreators";
import Crossfilter from "../typedCrossfilter"; import { sliceByIndex } from "../typedCrossfilter/util";
import * as Dataframe from "../dataframe";
/* /*
World is a subset of universe. Most code should use world, and should World is a subset of universe. Most code should use world, and should
(generally) not use Universe. World contains any per-obs or per-var data (generally) not use Universe. World contains any per-obs or per-var data
that must be consistent acorss the app when we view/manipulate subsets that must be consistent acorss the app when we view/manipulate subsets
@@ -15,76 +15,120 @@ of Universe.
Private API indicated by leading underscore in key name (eg, _foo). Anything else Private API indicated by leading underscore in key name (eg, _foo). Anything else
is public. is public.
Notable keys in the world object: World contains several public keys, obsAnnotations, and obsLayout, which are
arrays contianing information about an OBS in the same order/offset. In
* nObs, nVar: dimensions other words, world.obsAnnotations[0] and world.obsLayout.X[0] refer to the same
obs/cell.
* schema: data schema from the server
* obsAnnotations: * obsAnnotations:
Dataframe containing obs annotations. Columns are indexed by annotation obsAnnotations will return an array of objects. Each object contains all annotation
name (eg, 'tissue type'), and rows are indexed by the REST API obsIndex values for a given observation/cell, keyed by annotation name, PLUS a key
(ie, the offset into the underlying server-side dataframe). '__cellId__', containing a REST API ID for this obs/cell (referred to as the
obsIndex in the REST 0.2 spec or cellIndex in the 0.1 spec.
This indexing means that you can access data by _either_ the server's Example: [ { __cellId__: 99, cluster: 'blue', numReads: 93933 } ]
obxIndex, or the offset into the client-side column array . Be careful
to know which you want and are using. NOTE: world.obsAnnotation should be identical to the old state.cells value,
EXCEPT that
* __cellIndex__ renamed to __index__
* __x__ and __y__ are now in world.obsLayout
* __color__ and __colorRBG__ should be moved to controls reducer
* obsLayout: * obsLayout:
A dataframe containing the X/Y layout for all obs. Columns are named obsLayout will return an object containing two arrays, containing X and Y
'X' and 'Y', and rows are indexed in the same way as obsAnnotation. coordinates respectively.
* varData: a cache of expression columns, stored in a Dataframe. Cache Example: { X: [ 0.33, 0.23, ... ], Y: [ 0.8, 0.777, ... ]}
managed by controls reducer.
* crossfilter - a crossfilter object across world.obsAnnotations
* dimensionMap - an object mapping annotation names to dimensions on
the crossfilter
*/ */
/* varDataCache config - see kvCache for semantics */
const VarDataCacheLowWatermark = 32; // cache element count
const VarDataCacheTTLMs = 1000; // min cache time in MS
function templateWorld() { function templateWorld() {
return { return {
// map from universe obsIndex to world offset.
// Undefined / null indicates identity mapping.
obsIndex: null,
obsBackIndex: null,
/* schema/version related */ /* schema/version related */
api: null,
schema: null, schema: null,
nObs: 0, nObs: 0,
nVar: 0, nVar: 0,
/* annotations */ /* annotations */
obsAnnotations: Dataframe.Dataframe.empty(), obsAnnotations: null,
varAnnotations: Dataframe.Dataframe.empty(), varAnnotations: null,
/* layout of graph. Dataframe. */ /* layout of graph */
obsLayout: Dataframe.Dataframe.empty(), obsLayout: null,
/* /* derived data summaries XXX: consider exploding in place */
Var data columns - subset of all data (may be empty) summary: null,
*/
varData: Dataframe.Dataframe.empty(null, new Dataframe.KeyIndex()) varDataCache: kvCache.create(
VarDataCacheLowWatermark,
VarDataCacheTTLMs
) /* cache of var data (expression) */
}; };
} }
export function createWorldFromEntireUniverse(universe) { export function createWorldFromEntireUniverse(universe) {
if (!universe.finalized) {
throw new Error("World can't be created from an partial Universe");
}
const world = templateWorld(); const world = templateWorld();
// map from the universe obsIndex to our world offset.
// undefined/null indicates identity map.
// In other words obsBackIndex[universeIdx] -> worldIdx
world.obsBackIndex = null;
// Map to the universe index for each element in world.
// Null indicates identity map (aka world === universe)
// In other wrods obsIndex[worldIdx] -> universeIdx
world.obsIndex = null;
/* /*
public interface follows public interface follows
*/ */
/* Schema related */ /* Schema related */
world.api = universe.api;
world.schema = universe.schema; world.schema = universe.schema;
world.nObs = universe.nObs; world.nObs = universe.nObs;
world.nVar = universe.nVar; world.nVar = universe.nVar;
/* annotation dataframes */ /* annotations */
world.obsAnnotations = universe.obsAnnotations; world.obsAnnotations = universe.obsAnnotations;
world.varAnnotations = universe.varAnnotations; world.varAnnotations = universe.varAnnotations;
/* layout and display characteristics dataframe */ /* layout and display characteristics */
world.obsLayout = universe.obsLayout; world.obsLayout = universe.obsLayout;
/* /* derived data & summaries */
Var data columns - subset of all world.summary = summarizeAnnotations(
*/ world.schema,
world.varData = universe.varData.clone(); world.obsAnnotations,
world.varAnnotations
);
/* build the varDataCache */
world.varDataCache = kvCache.map(
universe.varDataCache,
val => subsetVarData(world, universe, val),
{ lowWatermark: VarDataCacheLowWatermark, minTTL: VarDataCacheTTLMs }
);
return world; return world;
} }
@@ -93,24 +137,54 @@ export function createWorldFromCurrentSelection(universe, world, crossfilter) {
const newWorld = templateWorld(); const newWorld = templateWorld();
/* these don't change as only OBS are selected in our current implementation */ /* these don't change as only OBS are selected in our current implementation */
newWorld.api = universe.api;
newWorld.nVar = universe.nVar; newWorld.nVar = universe.nVar;
newWorld.schema = universe.schema; newWorld.schema = universe.schema;
newWorld.varAnnotations = universe.varAnnotations; newWorld.varAnnotations = universe.varAnnotations;
/* now subset/cut obs */ /* build index maps and back maps based upon current selection state */
const mask = crossfilter.allFilteredMask(); const obsBackIndex = new Uint32Array(universe.nObs);
newWorld.obsAnnotations = world.obsAnnotations.isubsetMask(mask); obsBackIndex.fill(-1); // default - aka unused
newWorld.obsLayout = world.obsLayout.isubsetMask(mask); const notSelected = obsBackIndex[0];
newWorld.nObs = newWorld.obsAnnotations.dims[0]; let nObs = 0;
for (let i = 0; i < universe.nObs; i += 1) {
/* if (crossfilter.isElementFiltered(i)) {
Var data columns - subset of all obsBackIndex[i] = nObs;
*/ nObs += 1;
if (world.varData.isEmpty()) { }
newWorld.varData = world.varData.clone();
} else {
newWorld.varData = world.varData.isubsetMask(mask);
} }
const obsIndex = new Uint32Array(nObs);
for (let i = 0; i < universe.nObs; i += 1) {
const worldIdx = obsBackIndex[i];
if (worldIdx !== notSelected) {
obsIndex[worldIdx] = i;
}
}
newWorld.nObs = nObs;
newWorld.obsIndex = obsIndex;
newWorld.obsBackIndex = obsBackIndex;
/* now slice */
newWorld.obsAnnotations = sliceByIndex(universe.obsAnnotations, obsIndex);
newWorld.obsLayout = {
X: sliceByIndex(universe.obsLayout.X, obsIndex),
Y: sliceByIndex(universe.obsLayout.Y, obsIndex)
};
/* derived data & summaries */
newWorld.summary = summarizeAnnotations(
newWorld.schema,
newWorld.obsAnnotations,
newWorld.varAnnotations
);
/* build the varDataCache */
newWorld.varDataCache = kvCache.map(
universe.varDataCache,
val => subsetVarData(newWorld, universe, val),
{ lowWatermark: VarDataCacheLowWatermark, minTTL: VarDataCacheTTLMs }
);
return newWorld; return newWorld;
} }
@@ -150,12 +224,14 @@ function deduceDimensionType(attributes, fieldName) {
when it is no longer needed when it is no longer needed
(it will not be garbage collected without this call) (it will not be garbage collected without this call)
*/ */
export function createVarDataDimension(world, crossfilter, name) {
return crossfilter.dimension( export function createVarDimension(
Crossfilter.ScalarDimension, world,
world.varData.col(name).asArray(), _worldVarDataCache,
Float32Array crossfilter,
); geneName
) {
return crossfilter.dimension(_worldVarDataCache[geneName], Float32Array);
} }
export function createObsDimensionMap(crossfilter, world) { export function createObsDimensionMap(crossfilter, world) {
@@ -163,23 +239,17 @@ export function createObsDimensionMap(crossfilter, world) {
create and return a crossfilter dimension for every obs annotation create and return a crossfilter dimension for every obs annotation
for which we have a supported type. for which we have a supported type.
*/ */
const { schema, obsLayout, obsAnnotations } = world; const { schema, obsLayout } = world;
// Create a crossfilter dimension for all obs annotations *except* 'name' // Create a crossfilter dimension for all obs annotations *except* 'name'
const dimensionMap = _(schema.annotations.obs) const dimensionMap = _(schema.annotations.obs)
.filter(anno => anno.name !== "name") .filter(anno => anno.name !== "name")
.transform((result, anno) => { .transform((result, anno) => {
const dimType = deduceDimensionType(anno, anno.name); const dimType = deduceDimensionType(anno, anno.name);
const colData = obsAnnotations.col(anno.name).asArray(); // XXX if dimtype is a scalar, we may be able to do better?
if (dimType === "enum") { if (dimType) {
result[obsAnnoDimensionName(anno.name)] = crossfilter.dimension( result[obsAnnoDimensionName(anno.name)] = crossfilter.dimension(
Crossfilter.EnumDimension, r => r[anno.name],
colData
);
} else if (dimType) {
result[obsAnnoDimensionName(anno.name)] = crossfilter.dimension(
Crossfilter.ScalarDimension,
colData,
dimType dimType
); );
} // else ignore the annotation } // else ignore the annotation
@@ -189,10 +259,13 @@ export function createObsDimensionMap(crossfilter, world) {
/* /*
Add crossfilter dimensions allowing filtering on layout Add crossfilter dimensions allowing filtering on layout
*/ */
dimensionMap[layoutDimensionName("XY")] = crossfilter.dimension( dimensionMap[layoutDimensionName("X")] = crossfilter.dimension(
Crossfilter.SpatialDimension, obsLayout.X,
obsLayout.col("X").asArray(), Float32Array
obsLayout.col("Y").asArray() );
dimensionMap[layoutDimensionName("Y")] = crossfilter.dimension(
obsLayout.Y,
Float32Array
); );
return dimensionMap; return dimensionMap;
@@ -202,20 +275,10 @@ export function worldEqUniverse(world, universe) {
return world.obsAnnotations === universe.obsAnnotations; return world.obsAnnotations === universe.obsAnnotations;
} }
export function getSelectedByIndex(crossfilter) { export function subsetVarData(world, universe, varData) {
/* // If world === universe, just return the entire varData array
return array of obsIndex, containing all selected obs/cells. if (worldEqUniverse(world, universe)) {
*/ return varData;
const selected = crossfilter.allFilteredMask(); // array of bool-ish
const keys = crossfilter.data.rowIndex.keys(); // row keys, aka universe rowIndex
const set = new Int32Array(selected.length);
let numElems = 0;
for (let i = 0, l = selected.length; i < l; i += 1) {
if (selected[i]) {
set[numElems] = keys[i];
numElems += 1;
}
} }
return new Int32Array(set.buffer, 0, numElems); return sliceByIndex(varData, world.obsIndex);
} }
-75
View File
@@ -1,75 +0,0 @@
/* eslint-disable import/prefer-default-export */
import _ from "lodash";
/*
Various utility functions operating on World/Universe
*/
/*
Count unique category values, binning first by dim1 then by dim2
Return:
Map {
dim1_val1: Map {
dim2_val1: number,
dim2_val2: number,
...
},
...
}
Parameters are:
- dim1: dimension 1 name/label
- dim2: dimension 2 name/label
- df: dataframe containing dim1 and dim2 on the column axis
*/
function _countCategoryValues2D(dim1, dim2, df) {
const dimMap = new Map();
const col1 = df.col(dim1) ? df.col(dim1).asArray() : null;
const col2 = df.col(dim2) ? df.col(dim2).asArray() : null;
if (!col1 || !col2) {
return dimMap;
}
for (let r = 0, l = df.length; r < l; r += 1) {
const val1 = col1[r];
const val2 = col2[r];
let d2Map = dimMap.get(val1);
if (d2Map === undefined) {
d2Map = new Map();
dimMap.set(val1, d2Map);
}
let curCount = d2Map.get(val2);
if (curCount === undefined) {
curCount = 0;
}
d2Map.set(val2, curCount + 1);
}
return dimMap;
}
let __worldUtilMemoId__ = 0;
function _memoizedId(x) {
if (!x.__worldUtilMemoId__) {
__worldUtilMemoId__ += 1;
x.__worldUtilMemoId__ = __worldUtilMemoId__;
}
return x.__worldUtilMemoId__;
}
function _countCategoryValues2DResolver(...args) {
const id = args[0] + args[1] + _memoizedId(args[2]);
return id;
}
export const countCategoryValues2D = _.memoize(
_countCategoryValues2D,
_countCategoryValues2DResolver
);
/*
Clear any cached data within WorldUtil caches, eg, memoized functions
*/
export function clearCaches() {
countCategoryValues2D.cache.clear();
}
+15 -28
View File
@@ -40,7 +40,7 @@ class BitArray {
// Return the number of records that are selected, ie, have a one bit in // Return the number of records that are selected, ie, have a one bit in
// all allocated dimensions. // all allocated dimensions.
// //
selectionCount() { get selectionCount() {
return this.countAllOnes(); return this.countAllOnes();
} }
@@ -48,27 +48,16 @@ class BitArray {
// //
countAllOnes() { countAllOnes() {
let count = 0; let count = 0;
const { bitarray, length, width } = this; const { bitarray, bitmask, length, width } = this;
if (width === 1) { for (let l = 0; l < length; l += 1) {
// special case, width === 1, for performance let dimensionsSet = 0;
const bitmask = this.bitmask[0]; for (let w = 0; w < width; w += 1) {
for (let l = 0; l < length; l += 1) { if (bitarray[w * length + l] === bitmask[w]) {
if (bitarray[l] === bitmask) { dimensionsSet += 1;
count += 1;
} }
} }
} else { if (dimensionsSet === width) {
const { bitmask } = this; count += 1;
for (let l = 0; l < length; l += 1) {
let dimensionsSet = 0;
for (let w = 0; w < width; w += 1) {
if (bitarray[w * length + l] === bitmask[w]) {
dimensionsSet += 1;
}
}
if (dimensionsSet === width) {
count += 1;
}
} }
} }
return count; return count;
@@ -244,14 +233,12 @@ class BitArray {
fillBySelection(result, selectedValue, deselectedValue) { fillBySelection(result, selectedValue, deselectedValue) {
// special case (width === 1) for performance // special case (width === 1) for performance
if (this.width === 1) { if (this.width === 1) {
const { bitmask, bitarray } = this; const bitmask = this.bitmask[0];
const mask = bitmask[0]; for (let i = 0, len = this.length; i < len; i += 1) {
if (!mask) { result[i] =
result.fill(deselectedValue); bitmask && this.bitarray[i] === bitmask
} else { ? selectedValue
for (let i = 0, len = this.length; i < len; i += 1) { : deselectedValue;
result[i] = bitarray[i] === mask ? selectedValue : deselectedValue;
}
} }
} else { } else {
for (let i = 0, len = this.length; i < len; i += 1) { for (let i = 0, len = this.length; i < len; i += 1) {
+38 -214
View File
@@ -27,8 +27,6 @@ more complex API. In a few cases, elements of that API were incorporated.
https://github.com/square/crossfilter/ https://github.com/square/crossfilter/
*/ */
// XXX replace
import { polygonContains } from "d3";
import PositiveIntervals from "./positiveIntervals"; import PositiveIntervals from "./positiveIntervals";
import BitArray from "./bitArray"; import BitArray from "./bitArray";
@@ -39,14 +37,6 @@ import {
upperBoundIndirect upperBoundIndirect
} from "./util"; } from "./util";
function isArrayOrTypedArray(x) {
return (
Array.isArray(x) ||
(ArrayBuffer.isView(x) &&
Object.prototype.toString.call(x) !== "[object DataView]")
);
}
class NotImplementedError extends Error { class NotImplementedError extends Error {
constructor(...params) { constructor(...params) {
super(...params); super(...params);
@@ -60,11 +50,6 @@ class NotImplementedError extends Error {
class TypedCrossfilter { class TypedCrossfilter {
constructor(data) { constructor(data) {
/*
Typically, data is one of:
- Array of objects/records
- Dataframe (util/dataframe)
*/
this.data = data; this.data = data;
// filters: array of { id, dimension } // filters: array of { id, dimension }
@@ -81,22 +66,14 @@ class TypedCrossfilter {
return this.data; return this.data;
} }
/* dimension(value, valueArrayType) {
Create a crossfilter dimension, upon which filtering (subselection) can
be done. Each dimension is typed, and has a particular set of filtering
semantics.
* ScalarDimension - backed by TypedArray values, supporting filtering
by value (within a value range, or one or more exact values)
* EnumDimension - backed by an enumeration (eg, strings, bools), filtering
by one or more enum categories.
* SpatialDimension - backed by 2D points, filter by containment within
various shapes (currently supports within Rectangle and within Polygon).
Call this method to create a dimension, passing arguments appropriate for
the dimension constructor.
*/
dimension(DimensionType, ...rest) {
const id = this.selection.allocDimension(); const id = this.selection.allocDimension();
const dim = new DimensionType(this, id, ...rest); let dim;
if (valueArrayType === "enum") {
dim = new EnumDimension(value, this, id);
} else {
dim = new ScalarDimension(value, valueArrayType, this, id);
}
this.filters.push({ id, dim }); this.filters.push({ id, dim });
dim.filterAll(); dim.filterAll();
return dim; return dim;
@@ -110,32 +87,18 @@ class TypedCrossfilter {
// return array of all records that are selected/filtered // return array of all records that are selected/filtered
// by all dimensions. // by all dimensions.
allFiltered() { allFiltered() {
const { data, selection } = this; const { selection } = this;
if (Array.isArray(data)) { const res = [];
const res = []; for (let i = 0, len = this.data.length; i < len; i += 1) {
for (let i = 0, len = data.length; i < len; i += 1) { if (selection.isSelected(i)) {
if (selection.isSelected(i)) { res.push(this.data[i]);
res.push(data[i]);
}
} }
return res;
} }
/* else, Dataframe-like */ return res;
return data.isubsetMask(this.allFilteredMask());
}
// return Uint8array containing selection state (truthy/falsey) for each record.
//
allFilteredMask() {
return this.selection.fillBySelection(
new Uint8Array(this.data.length),
1,
0
);
} }
countFiltered() { countFiltered() {
return this.selection.selectionCount(); return this.selection.selectionCount;
} }
isElementFiltered(i) { isElementFiltered(i) {
@@ -152,34 +115,13 @@ class TypedCrossfilter {
} }
} }
// Base dimension type - not exported. // Base dimension type - value must be a scalar type (eg, int, float),
class _Dimension {
constructor(xfltr, id) {
this.crossfilter = xfltr;
this._id = id;
this.groups = [];
}
dispose() {
this.crossfilter._freeDimension(this._id);
return this;
}
id() {
return this._id;
}
_filterUpdate() {
this.crossfilter.updateTime += 1;
}
}
// Scalar dimension type - value must be a scalar type (eg, int, float),
// and value array must be a TypedArray. // and value array must be a TypedArray.
// //
class ScalarDimension extends _Dimension { class ScalarDimension {
constructor(xfltr, id, value, ValueArrayType) { constructor(value, ValueArrayType, xfltr, id) {
super(xfltr, id); this.crossfilter = xfltr;
this._id = id;
// current selection filter, expressed as PostiveIntervals. // current selection filter, expressed as PostiveIntervals.
this.currentFilter = []; this.currentFilter = [];
@@ -188,7 +130,6 @@ class ScalarDimension extends _Dimension {
// or a map function which will create it. // or a map function which will create it.
let array; let array;
if (value instanceof ValueArrayType) { if (value instanceof ValueArrayType) {
// user has provided the final typed array - just use it
if (value.length !== this.crossfilter.data.length) { if (value.length !== this.crossfilter.data.length) {
throw new RangeError( throw new RangeError(
"ScalarDimension values length must equal crossfilter data record count" "ScalarDimension values length must equal crossfilter data record count"
@@ -196,18 +137,11 @@ class ScalarDimension extends _Dimension {
} }
array = value; array = value;
} else if (value instanceof Function) { } else if (value instanceof Function) {
// Create value array from user-provided map function. // Create value array
array = this._createValueArray( array = this._createValueArray(
value, value,
new ValueArrayType(this.crossfilter.data.length) new ValueArrayType(this.crossfilter.data.length)
); );
} else if (isArrayOrTypedArray(value)) {
// Create value array from user-provided array. Typically used
// only by enumerated dimensions
array = this._createValueArray(
i => value[i],
new ValueArrayType(this.crossfilter.data.length)
);
} else { } else {
throw new NotImplementedError( throw new NotImplementedError(
"dimension value must be function or value array type" "dimension value must be function or value array type"
@@ -217,6 +151,9 @@ class ScalarDimension extends _Dimension {
// create sort index // create sort index
this.index = makeSortIndex(array); this.index = makeSortIndex(array);
// groups, if any
this.groups = [];
} }
_createValueArray(value, array) { _createValueArray(value, array) {
@@ -225,11 +162,20 @@ class ScalarDimension extends _Dimension {
const len = data.length; const len = data.length;
const larray = array; const larray = array;
for (let i = 0; i < len; i += 1) { for (let i = 0; i < len; i += 1) {
larray[i] = value(i, data); larray[i] = value(data[i]);
} }
return larray; return larray;
} }
dispose() {
this.crossfilter._freeDimension(this._id);
return this;
}
id() {
return this._id;
}
// Argument is an array of intervals indicating records newly selected/filtered // Argument is an array of intervals indicating records newly selected/filtered
// //
_updateFilters(newFilter) { _updateFilters(newFilter) {
@@ -263,7 +209,7 @@ class ScalarDimension extends _Dimension {
); );
this.currentFilter = cNewFilter; this.currentFilter = cNewFilter;
this._filterUpdate(); this.crossfilter.updateTime += 1;
} }
// filter by value - exact match // filter by value - exact match
@@ -409,8 +355,8 @@ class ScalarDimension extends _Dimension {
// strings, which can be mapped into an fixed numeric range [0..n). // strings, which can be mapped into an fixed numeric range [0..n).
// //
class EnumDimension extends ScalarDimension { class EnumDimension extends ScalarDimension {
constructor(xfltr, id, value) { constructor(value, xfltr, id) {
super(xfltr, id, value, Uint32Array); super(value, Uint32Array, xfltr, id);
} }
_createValueArray(value, array) { _createValueArray(value, array) {
@@ -422,7 +368,7 @@ class EnumDimension extends ScalarDimension {
// and the enum. // and the enum.
const s = new Set(); const s = new Set();
for (let i = 0; i < len; i += 1) { for (let i = 0; i < len; i += 1) {
s.add(value(i, data)); s.add(value(data[i]));
} }
this.enumIndex = Array.from(s); this.enumIndex = Array.from(s);
this.enumIndex.sort(); this.enumIndex.sort();
@@ -430,7 +376,7 @@ class EnumDimension extends ScalarDimension {
// create dimension value array // create dimension value array
const enumLen = this.enumIndex.length; const enumLen = this.enumIndex.length;
for (let i = 0; i < len; i += 1) { for (let i = 0; i < len; i += 1) {
const v = value(i, data); const v = value(data[i]);
const e = lowerBound(this.enumIndex, v, 0, enumLen); const e = lowerBound(this.enumIndex, v, 0, enumLen);
larray[i] = e; larray[i] = e;
} }
@@ -462,127 +408,6 @@ class EnumDimension extends ScalarDimension {
} }
} }
/*
Super simple 2D spatial dimension, supporting basic "filter within"
operations.
*/
class SpatialDimension extends _Dimension {
constructor(xfltr, id, X, Y) {
super(xfltr, id);
if (X.length !== Y.length && X.length !== this.crossfilter.data.length) {
throw new RangeError(
"SpatialDimension values must have same dimensionality as crossfilter"
);
}
this.X = X;
this.Y = Y;
this.Xindex = makeSortIndex(X);
this.Yindex = makeSortIndex(Y);
}
filterAll() {
this.crossfilter.selection.selectAll(this._id);
this._filterUpdate();
}
filterNone() {
this.crossfilter.selection.deselectAll(this._id);
this._filterUpdate();
}
/*
this could be smarter, but we don't currently use it...
*/
filterWithinRect(northwest, southeast) {
const [x0, y0] = northwest;
const [x1, y1] = southeast;
const { X, Y } = this;
const seln = this.crossfilter.selection;
const { _id } = this;
seln.deselectAll(_id);
for (let i = 0, l = this.X.length; i < l; i += 1) {
const x = X[i];
const y = Y[i];
if (x0 <= x && x < x1 && y0 <= y && y < y1) {
seln.selectOne(_id, i);
}
}
this._filterUpdate();
}
/*
Relatively brute force filter by polygon. Polygon is array of points, where
each point is [x,y]. Eg, [[x0,y0], [x1,y1], ...].
Currently uses d3.polygonContains() to test for polygon inclusion, which itself
uses a ray casting (crossing number) algorithm. There are a series of optimizations
to make this faster:
* first sliced by X or Y, using an index on the axis
* then the polygon bounding box is used for trivial rejection
* then the polygon test is applied
*/
filterWithinPolygon(polygon) {
/* return bounding box of the polygon */
function polygonBoundingBox(pg) {
let minX = Number.MAX_VALUE;
let minY = Number.MAX_VALUE;
let maxX = Number.MIN_VALUE;
let maxY = Number.MIN_VALUE;
for (let i = 0, l = pg.length; i < l; i += 1) {
const p = pg[i];
const x = p[0];
const y = p[1];
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
}
return [minX, minY, maxX, maxY];
}
const [minX, minY, maxX, maxY] = polygonBoundingBox(polygon);
const { X, Y } = this;
let slice;
let index;
if (maxY - minY > maxX - minX) {
slice = [
lowerBoundIndirect(X, this.Xindex, minX, 0, X.length),
upperBoundIndirect(X, this.Xindex, maxX, 0, X.length)
];
index = this.Xindex;
} else {
slice = [
lowerBoundIndirect(Y, this.Yindex, minY, 0, Y.length),
upperBoundIndirect(Y, this.Yindex, maxY, 0, Y.length)
];
index = this.Yindex;
}
const seln = this.crossfilter.selection;
const { _id } = this;
const testWithin = polygonContains; // d3.polygonContains()
seln.deselectAll(_id);
for (let i = slice[0], e = slice[1]; i < e; i += 1) {
const rid = index[i];
const x = X[rid];
const y = Y[rid];
if (
minX <= x &&
x < maxX &&
minY <= y &&
y < maxY &&
testWithin(polygon, [x, y])
) {
seln.selectOne(_id, rid);
}
}
this._filterUpdate();
}
}
// Groups! Map/reduce // Groups! Map/reduce
// //
class ScalarGroup { class ScalarGroup {
@@ -786,6 +611,5 @@ crossfilter.BitArray = BitArray;
crossfilter.TypedCrossfilter = TypedCrossfilter; crossfilter.TypedCrossfilter = TypedCrossfilter;
crossfilter.ScalarDimension = ScalarDimension; crossfilter.ScalarDimension = ScalarDimension;
crossfilter.EnumDimension = EnumDimension; crossfilter.EnumDimension = EnumDimension;
crossfilter.SpatialDimension = SpatialDimension;
export default crossfilter; export default crossfilter;
-35
View File
@@ -1,35 +0,0 @@
## Creating PR
1. Name [username]/branchname
1. Branch name should be all lowercase
2. Words separated by “-”
2. Code should address only one issue ideally, make a separate PR for each task
3. Description
1. Clear explanation of issues solved
2. Describe why and how, when appropriate
3. Call out specific areas you want extra attention in review (optional)
4. If your PR requires more than one reviewer tag those people in the description or comments and let them know that you specifically require them
4. Ensure that the PR updates tests and documentation and adds tests where appropriate
5. Use github’s issue keywords when PR is addressing an issue https://help.github.com/articles/closing-issues-using-keywords/
6. Tags (add at beginning of title)
1. [EASY] - small non-controversial change, easy to review
2. [DO NOT MERGE] - PR is in progress, do not merge changes
## Review
1. Assign at least one reviewer to submitted PRs. Reviewers should be selected based on expertise in areas affected by the PR (eg, web UI: Colin), and should include Comp Bio and PM as needed.
2. Reviewers should approve or request changes (not just comment) and put general and line level comments where appropriate
3. As a PR submitter respond to all comments (eg, comment, commit a change, etc)
4. External PRs
1. For external PRs or PRs not from our core team, core team should assign a reviewer and make initial contact within 1 business day
2. Build code on local environment and run smoke tests
## Required to Merge
1. Travis CI Build passing
2. At least one reviewer approved
1. Exceptions:
1. Release PRs where version is just bumped should not need review
2. Complex PRs which touch multiple parts of the codebase should have reviews from all relevant parties
3. License and Security checks (SNYK) passing. If their server is down and you didn’t add any new external npm or python packages, merge is OK
## Merging
1. Use "squash and merge" option when merging
2. If you resolved conflicts, wait until the build passes to merge
-66
View File
@@ -1,66 +0,0 @@
# cellxgene release process
_This document defines the release process for cellxgene_
## Overview
The goal of the release process is to publish an installable package
to PyPi, with a matching tagged release on github.
The release process should result in the following side-effects:
- Version number bump, using semantic versioning
- JS assets built & packaged, committed to the repo
- Tagged github release
- Publication to PyPi
## Recipe
Follow these steps to create a release.
1. Preparation:
- python3.6 environment, and a cellxgene clone
- Define the release version number, using [semantic versioning](https://semver.org/),
and specifying all three digits (eg, 0.3.0)
- Write the release title and release notes and add to
[release notes document](https://docs.google.com/document/d/1KnHwkYfhyWO5H8BDcMu7y3ogjvq5Yi4OwpmZ8DB6w0Y/edit)
2. Create a release branch, eg, `release-version`
3. In the release branch:
- Run `make release-stage-1 PART=[major | minor | patch]` where you choose major/minor/patch depending on which part of the version
is being bumped (eg, 0.2.9->0.3 is minor).
4. Commit and push the new branch
5. Create a PR for the release.
- [optional] As needed, conduct PR review.
6. Merge to master
7. Create Github release using the version number and release notes
([instructions](https://help.github.com/articles/creating-releases/)).
- Draft new release
- Type version name matching release version number from (1)
- Select `master` as release branch (ensure you merged the release PR)
- Type title `Release {version num}`
- [optional] Check pre-release if this release is not ready for production
- Publish Release
8. Publish to pypi by performing the following steps (assumes you that you have registered for pypi,
and that you have write access to the cellxgene pypi package):
- Build the distribution and upload to test pypi `make release-stage-2`
- [optional] Test the test installation in a fresh virtual environment using `make install-release-test`
- Upload the package to real pypi using `make release-stage-final`
- [optional] Test the installation in a fresh virtual environment using
`pip install cellxgene`
- **Troubleshooting**:
- Fails to upload to test.pypi: pypi doesn't allow you to reupload a release with the same version number,
if you accidentally burned a release number you want to use on prod, you have a couple options.
1) OPTION 1: Create distribution `make pydist`; test release locally `pip install dist/<release tarball>`;
then upload to prod `make release-stage-final`.
2) OPTION 2: (DANGER) release directly to prod: `make release-burned`.
3) OPTION 3: If the release was burned on prod as well run from Step 3 again with option
PART=patch until you get to an unburned version.
- The release doesn't install or fails your tests when you install it: Delete it from pypi - Go to pypi.org, sign in,
go to the cellxgene package, click manage, then in the options drop down, click delete and
follow the instructions. You will not be able to use that release number again. If it is a minor bug
and not a major regression, you can just release a patch.
The optional steps are for testing purposes, and are recommended
for publishing any major releases, and any releases that significantly
change the packaging (e.g. new bundled files, new dependencies, etc.)
@@ -1,69 +0,0 @@
### How to set up a testing environment for changes related to web hosting.
We often get PRs related to someone using a server to host cellxgene externally or on a local network (ex. https://github.com/chanzuckerberg/cellxgene/pull/568 ). Here is how you can test these changes locally.
We are going to run docker containers for cellxgene and an apache server running a reverse proxy on a local docker network. We run the cellxgene container without exposing any ports so that we cannot access it directly, only through the apache server. We can also update our cellxgene Dockerfile so that we can install a local build instead of having to deploy to pypi.
1 Create Docker network, this allows the containers to communicate with each other.
```
docker network create cxg
```
2 Create and run cellxgene container
(optional) To install cellxgene from the local codebase
a Create sdist file
`make pydist`
b Update Dockerfile to install from dist
```
FROM ubuntu:bionic
ENV LC_ALL=C.UTF-8
ENV LANG=C.UTF-8
COPY [ "dist/", "/cellxgene/dist/" ]
RUN apt-get update && \
apt-get install -y build-essential libxml2-dev python3-dev python3-pip zlib1g-dev && \
pip3 install /cellxgene/dist/cellxgene-0.5.1.tar.gz
ENTRYPOINT ["cellxgene"]
```
(required) Build container
`docker build . -t cellxgene`
3 Create the proxy container
In a separate directory create these two files
Dockerfile
```
FROM rgoyard/apache-proxy:latest
ADD proxy.conf /conf/
```
proxy.conf
```
ProxyPass "/data/" http://cellxgene:5005/
ProxyPassReverse "/data/" http://cellxgene:5005/
```
Build the container
`docker build -t proxy .`
4 Run containers and attach to network
```
docker run -d -p 80:80 --network cxg --name proxy proxy
docker run -v "$PWD/example-dataset/:/data/" --name cellxgene --network cxg cellxgene launch --host 0.0.0.0 data/pbmc3k.h5ad
```
5 Go to served site
http://localhost/data/
-2
View File
@@ -1,2 +0,0 @@
source 'https://rubygems.org'
gem "github-pages", group: :jekyll_plugins
+2 -8
View File
@@ -1,11 +1,5 @@
# cellxgene REST API 0.2 specification # cellxgene REST API 0.2 specification
_Note:_ this document is increasingly divergent from the code base and should be read with great suspicion. For example, it lacks any information
about the binary encoding used by various routes, and has incorrect information about "required" routes and features. We may update it at a
later date when the protocol stabilizes a bit.
---
Items marked as (_future_) are intended for future implementation, and are included in the design to round out the concept, and highlight what we would do when/if we needed more functionality. The (_future_) items are not currently used by the cellxgene web application, and may be omitted from any backend - see [Current Front-End Dependencies](#current-front-end-dependencies) for more details. Items marked as (_future_) are intended for future implementation, and are included in the design to round out the concept, and highlight what we would do when/if we needed more functionality. The (_future_) items are not currently used by the cellxgene web application, and may be omitted from any backend - see [Current Front-End Dependencies](#current-front-end-dependencies) for more details.
_Caveat emptor, partial spec_: this is a sketch for a spec, not a full spec, and some shortcuts have been taken in the authorship. Best practices for a REST API are assumed but not documented here, such as API versioning, reasonable choices for HTTP response codes, etc. In addition, for clarity the JSON examples will not always have all required quoting (eg, on keys) - the actual implementation should use legal JSON/CSV. _Caveat emptor, partial spec_: this is a sketch for a spec, not a full spec, and some shortcuts have been taken in the authorship. Best practices for a REST API are assumed but not documented here, such as API versioning, reasonable choices for HTTP response codes, etc. In addition, for clarity the JSON examples will not always have all required quoting (eg, on keys) - the actual implementation should use legal JSON/CSV.
@@ -574,14 +568,14 @@ If differential expression is not supported by the server, must return an HTTP 5
**Response body:** **Response body:**
- For 200 Success, differential expression statistics returned as array of arrays, where each contains the following values: - For 200 Success, differential expression statistics returned as array of arrays sorted by varindex, where each contains the following values:
- **varIndex**: variable index for the computed results - **varIndex**: variable index for the computed results
- **logfoldchange**: log fold-change of the average expression between the two groups. Positive values indicate that the gene is more highly expressed in the first group, - **logfoldchange**: log fold-change of the average expression between the two groups. Positive values indicate that the gene is more highly expressed in the first group,
- **pVal**: unadjusted p-value, - **pVal**: unadjusted p-value,
- **pValAdj**: adjusted p-value - **pValAdj**: adjusted p-value
Values ordered as: Statistics are encoded as an array of arrays, with fields ordered as:
_varIndex_, _logfoldchange_, _pVal_, _pValAdj_ _varIndex_, _logfoldchange_, _pVal_, _pValAdj_
-9
View File
@@ -1,9 +0,0 @@
theme: jekyll-theme-cayman
show_downloads: false
baseurl: /cellxgene
nav:
- title: Data
url: data.html
- title: FAQ
url: faq.html
-49
View File
@@ -1,49 +0,0 @@
<!DOCTYPE html>
<html lang="{{ site.lang | default: "en-US" }}">
<head>
{% if site.google_analytics %}
<script async src="https://www.googletagmanager.com/gtag/js?id={{ site.google_analytics }}"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '{{ site.google_analytics }}');
</script>
{% endif %}
<meta charset="UTF-8">
{% seo %}
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#157878">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="stylesheet" href="{{ '/assets/css/style.css?v=' | append: site.github.build_revision | relative_url }}">
</head>
<body>
<header class="page-header" role="banner">
<h1 class="project-name">{{ site.title | default: site.github.repository_name }}</h1>
<h2 class="project-tagline">{{ site.description | default: site.github.project_tagline }}</h2>
{% if site.nav %}
<a href="{{ site.baseurl }}/" class="btn">Home</a>
{% for item in site.nav %}
<a href="{{ item.url }}" class="btn">{{ item.title }}</a>
{% endfor %}
{% endif %}
{% if site.github.is_project_page %}
<a href="{{ site.github.repository_url }}" class="btn" target="_blank">Code</a>
{% endif %}
</header>
<main id="content" class="main-content" role="main">
{{ content }}
<footer class="site-footer">
{% if site.github.is_project_page %}
<span class="site-footer-owner"><a href="{{ site.github.repository_url }}">{{ site.github.repository_name }}</a> is maintained by <a href="{{ site.github.owner_url }}">{{ site.github.owner_name }}</a>.</span>
{% endif %}
<span class="site-footer-credits">This page was generated by <a href="https://pages.github.com">GitHub Pages</a>.</span>
</footer>
</main>
</body>
</html>
-4
View File
@@ -1,4 +0,0 @@
---
---
@import "{{ site.theme }}";
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 644 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 MiB

-29
View File
@@ -1,29 +0,0 @@
---
layout: default
title: data
description: Data
---
# data vignette: how to use cellxgene prepare
#### coming soon!
# example datasets to use with cellxgene
### Examination of single cells from primary human pancreas tissue
cells: 2,544
tissue(s): pancreas
data: [Human Cell Atlas Data Portal](https://prod.data.humancellatlas.org/explore/projects?filter=%5B%7B%22facetName%22%3A%22organ%22%2C%22terms%22%3A%5B%22pancreas%22%5D%7D%2C%7B%22facetName%22%3A%22project%22%2C%22terms%22%3A%5B%22Single+cell+transcriptome+analysis+of+human+pancreas%22%5D%7D%5D)
paper: [Enge, Martin, et al.](https://www.cell.com/cell/fulltext/S0092-8674(17)31053-X?_returnURL=https%3A%2F%2Flinkinghub.elsevier.com%2Fretrieve%2Fpii%2FS009286741731053X%3Fshowall%3Dtrue)
### Tabula Muris
cells: 53,800
tissue(s): muscle, pancreas, bone, large intestine, heart, brain, fat, mammary gland, tongue , diaphragm, bladder, spleen, thymus, lung , skin, liver, trachea, kidney
data: [Tabula Muris Data](https://github.com/czbiohub/tabula-muris-vignettes/tree/master/data)
paper: [Tabula Muris Consortium](https://www.nature.com/articles/s41586-018-0590-4)
### Transcriptional profiling of 1.3 million brain cells
cells: 1,330,000
tissue(s): brain
data: [10x Genomics](https://community.10xgenomics.com/t5/10x-Blog/Our-1-3-million-single-cell-dataset-is-ready-to-download/ba-p/276)
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 MiB

-94
View File
@@ -1,94 +0,0 @@
---
layout: default
title: FAQ
description: Frequently Asked Questions
---
# Data formatting
#### What file formats can I use with _cellxgene_?
Currently, you can go straight into `cellxgene launch` with your own analyzed data in h5ad format, after you have performed dimenstionality reduction (tsne, umap) and clustering (louvain).
If your data is in a different format, and/or you still need to perform dimensionality reduction and clustering, `cellxgene` can do that for you with the `prepare` command. `cellxgene prepare` runs `scanpy` under the hood and can read in any format that is currently supported by `scanpy` (including mtx, loom, and more listed [here](https://scanpy.readthedocs.io/en/latest/api/index.html#reading)).
The output of `cellxgene prepare` is a h5ad file with your computed clusters and tsne/umap projections that can be used in `cellxgene launch`.
#### I have a directory of 10X-Genomics data with _mtx_ files and I've never used _scanpy_, can I use _cellxgene_?
Yep! This should only take a couple steps. We'll assume your data is in a folder called `data/` and you've successfully installed `cellxgene` with the `louvain` packages as described above. Just run
```
cellxgene prepare data/ --output=data-processed.h5ad --layout=umap
```
Depending on the size of the dataset, this may take some time. Once it's done, call
```
cellxgene launch data-processed.h5ad --layout=umap --open
```
And your web browser should open with an interactive view of your data.
#### I have extra metadata that I want to add to my dataset
Currently this is not supported directly, but you should be able to do this yourself using `scanpy`. For example, this [notebook](https://github.com/falexwolf/fun-analyses/blob/master/tabula_muris/tabula_muris.ipynb) shows adding the contents of a `csv` file with metadata to an `anndata` object. For now, you could do this manually on your data in the same way and then save out the result before loading into `cellxgene`.
#### What part of the _anndata_ objects does cellxgene pull in for visualization?
- `.obs` and `.var` annotations are use to extract metadata for filtering
- `.X` is used to display expression (histograms, scatterplot & colorscale) and to compute differential expression
- `.obsm` is used for layout
# Algorithms
#### How are you computing and sorting differential expression results?
We use a [Welch's _t_-test](https://en.wikipedia.org/wiki/Welch%27s_t-test) implementation including the same variance overestimation correction as used in `scanpy`. We sort the `tscore` to identify the top N genes, and then filter to remove any that fall below a cutoff log fold change value, which can help remove spurious test results. The default threshold is `0.01` and can be changed using the option `--diffexp-lfc-cutoff`.
# Problems, errors, & bugs
#### How do I create a Python 3.6 environment for _cellxgene_?
If you use conda and want to create a [conda environment](https://conda.io/docs/user-guide/tasks/manage-environments.html) for _cellxgene_ you can use the following commands
```
conda create --yes -n cellxgene python=3.6
conda activate cellxgene
pip install cellxgene
```
Or you can create a virtual environment by using
```
ENV_NAME=cellxgene
python3.6 -m venv ${ENV_NAME}
source ${ENV_NAME}/bin/activate
pip install cellxgene
```
#### In my _prepare_ command I received the following error `Warning: louvain module is not installed, no clusters will be calculated. To fix this please install cellxgene with the optional feature louvain enabled`
Louvain clustering requires additional dependencies, so we don't include them by default. For now, you need to specify that you want these packages by using
```
pip install cellxgene[louvain]
```
#### I ran _prepare_ and I'm getting results that look unexpected
You might want to try running one of the preprocessing recipes included with `scanpy` (read more about them [here](https://scanpy.readthedocs.io/en/latest/api/index.html#recipes)). You can specify this with the `--recipe` option, such as
```
cellxgene prepare data/ --output=data-processed.h5ad --recipe=zheng17
```
It should be easy to run `prepare` then call `cellxgene launch` a few times with different settings to explore different behaviors. We may explore adding other preprocessing options in the future.
#### I tried to `pip install cellxgene` and got a weird error I don't understand
This may happen, especially as we work out bugs in our installation process! Please create a new [Github issue](https://github.com/chanzuckerberg/cellxgene/issues), explain what you did, and include all the error messages you saw. It'd also be super helpful if you call `pip freeze` and include the full output alongside your issue.
#### I'm following the developer instructions and get an error about "missing files and directories” when trying to build the client
This is likely because you do not have node and npm installed, we recommend using [nvm](https://github.com/creationix/nvm) if you're new to using these tools.
-37
View File
@@ -1,37 +0,0 @@
_cellxgene_ is an interactive data explorer for single-cell transcriptomics data. Whether you need to visualize one thousand cells or one million, _cellxgene_ helps you gain insight into your single-cell data.
## features
#### Flexible selections, coloring, and differential expression of your selected sets of cells
<img src="diffexp.gif" width="600"/>
#### Single-gene analyses (e.g. expression analysis)
<img src="customGene.gif" width="600" />
## getting started
_cellxgene_ **only** supports Python 3.6. We recommend [installing _cellxgene_ into a conda or virtual environment.](/faq.html#how-do-i-create-a-python-36-environment-for-cellxgene)
Install the package.
``` bash
pip install cellxgene
```
Download an example [anndata](https://anndata.readthedocs.io/en/latest/) file
``` bash
curl -o pbmc3k.h5ad https://raw.githubusercontent.com/chanzuckerberg/cellxgene/master/example-dataset/pbmc3k.h5ad
```
Launch _cellxgene_
``` bash
cellxgene launch pbmc3k.h5ad
```
## getting help
We'd love to hear from you!
For questions, suggestions, or accolades, [join the `#cellxgene-users` channel on the CZI Science Slack](https://join-cziscience-slack.herokuapp.com/) and say "hi!".
For any errors, [report bugs on Github](https://github.com/chanzuckerberg/cellxgene/issues).
+48
View File
@@ -0,0 +1,48 @@
# cellxgene release process
_This document defines the release process for cellxgene_
## Overview
The goal of the release process is to publish an installable package
to PyPi, with a matching tagged release on github.
The release process should result in the following side-effects:
- Version number bump, using semantic versioning
- JS assets built & packaged, committed to the repo
- Tagged github release
- Publication to PyPi
## Process
Follow these steps to create a release.
1. Preparation:
- Define the release version number, using [semantic versioning](https://semver.org/)
- Write the release title and release notes
2. Create a release branch, eg, `release-version`
3. In the release branch:
- run `bumpversion --config-file .bumpversion.cfg [major | minor | patch]`
- build the JS asserts using `bin/build-client`
4. Commit and push the new branch
5. Create a PR for the release.
- [optional] As needed, conduct PR review.
6. Create Github release using the version number and release notes ([instructions](https://help.github.com/articles/creating-releases/)).
7. Publish to pypi by performing the following steps
(assumes you have `setuptools` and `twine` installed and that you have
registered for pypi and have write access to the cellxgene pypi package)
- build the distribution by calling
`python setup.py sdist`
inside the top-level directory
- [optional] upload the package to test pypi
`twine upload --repository-url https://test.pypi.org/legacy/ dist/*`
- [optional] test the test installation in a fresh virtual environment using
`pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple cellxgene`
- upload the package to real pypi using `twine upload dist/*`
- [optional] test the installation in a fresh virtual environment using
`pip install cellxgene`
The optional steps are for testing purposes, and are recommended
for publishing any major releases, and any releases that significantly
change the packaging (e.g. new bundled files, new dependencies, etc.)

Before

Width:  |  Height:  |  Size: 6.0 MiB

After

Width:  |  Height:  |  Size: 6.0 MiB

-73
View File
@@ -1,73 +0,0 @@
/*
Flatbuffers schema for use in cellxgene wire-format.
Schema defines a general purpose, polymorphic, 2D matrix. Data is
organized in a columnar layout. Each column is homomorphic, and
several column types are supported:
- IEEE 32 and 64 bit floats
- signed and unsigned 32 bit integers
- JSON/UTF8 encoded array (for other types)
https://github.com/google/flatbuffers
http://google.github.io/flatbuffers/
NOTE: IF YOU MODIFY THIS FILE, YOU MUST RECOMPILE AND COMMIT
RESULTING FILES TO THE REPO:
* server/app/util/fbs/NetEncoding/*
* client/src/util/stateManager/matrix_generated.js
*/
namespace NetEncoding;
table Float32Array {
data: [float32];
}
table Uint32Array {
data: [uint32];
}
table Int32Array {
data: [int32];
}
table Float64Array {
data: [float64];
}
table JSONEncodedArray {
// contains a UTF-8/JSON encoded array. Used to store other
// types (or polymorphic arrays)
data: [uint8];
}
union TypedArray {
Float32Array,
Int32Array,
Uint32Array,
Float64Array,
JSONEncodedArray
}
// Extra level of indirection required because vector of union not yet supported
table Column {
u: TypedArray;
}
// 2D matrix stored in columnar layout
//
table Matrix {
n_rows: uint32; // all columns have this length
n_cols: uint32; // same as columns.length
columns: [Column]; // length n_cols
// optional row and column index, with same length as corresponding dimension.
// If null, defaults to numeric index, ie, [0, n_rows) or [0, n_cols)
col_index: TypedArray;
row_index: TypedArray;
}
root_type Matrix;
-125
View File
@@ -1,125 +0,0 @@
BUILDDIR := build
CLIENTBUILD := $(BUILDDIR)/client
SERVERBUILD := $(BUILDDIR)/server
CLEANFILES := $(BUILDDIR)/ client/build dist cellxgene.egg-info
PART ?= patch
# BUILDING PACKAGE
build : clean build-server
@echo "done"
build-server : build-client
mkdir -p $(SERVERBUILD)
cp -r server/* $(SERVERBUILD)
cp -r client/build/ $(CLIENTBUILD)
mkdir -p $(SERVERBUILD)/app/web/static/img
mkdir -p $(SERVERBUILD)/app/web/templates/
cp $(CLIENTBUILD)/index.html $(SERVERBUILD)/app/web/templates/
cp -r $(CLIENTBUILD)/static $(SERVERBUILD)/app/web/
cp $(CLIENTBUILD)/favicon.png $(SERVERBUILD)/app/web/static/img
cp $(CLIENTBUILD)/service-worker.js $(SERVERBUILD)/app/web/static/js/
cp MANIFEST.in README.md setup.cfg setup.py $(BUILDDIR)
build-client :
npm install --prefix client/ client
npm run --prefix client build
# If you are actively developing in the server folder use this, dirties the source tree
build-for-server-dev : clean-server build-client
mkdir -p server/app/web/static/img
mkdir -p server/app/web/static/js
mkdir -p server/app/web/templates/
cp client/build/index.html server/app/web/templates/
cp -r client/build/static server/app/web/
cp client/build/favicon.png server/app/web/static/img
cp client/build/service-worker.js server/app/web/static/js/
clean : clean-lite clean-server
rm -rf client/node_modules
# cleaning node_modules is the longest one, so we avoid that if possible
clean-lite :
rm -rf $(CLEANFILES)
clean-server :
rm -f server/app/web/templates/index.html
rm -rf server/app/web/static
.PHONY : build build-server build-client build-for-server-dev clean clean-lite clean-server
# CREATING DISTRIBUTION RELEASE
pydist : build
cd $(BUILDDIR); python setup.py sdist -d ../dist
@echo "done"
.PHONY : pydist
# RELEASE HELPERS
# create new version to commit to master
release-stage-1 : dev-env bump clean-lite gen-package-lock
@echo "Version bumped part:$(PART) and client built. Ready to commit and push"
# build dist and release to dev pypi
release-stage-2 : dev-env pydist twine
@echo "Dist built and uploaded to test.pypi.org"
@echo "Test the install `make install-release-test` and then upload to Pypi prod"
@echo "`make twine-prod`"
release-stage-final: twine-prod
@echo "Release uploaded to pypi.org"
# DANGER: releases directly to prod
# use this if you accidently burned a test release version number,
release-burned : dev-env pydist twine-prod
@echo "Dist built and uploaded to pypi.org"
@echo "Test the install `make install-release`"
dev-env :
pip install -r server/requirements-dev.txt
# give PART=[major, minor, part] as param to make bump
bump :
bumpversion --config-file .bumpversion.cfg $(PART)
twine :
twine upload --repository-url https://test.pypi.org/legacy/ dist/*
twine-prod :
twine upload dist/*
# quicker than re-building client
gen-package-lock :
npm install --prefix client/ client
.PHONY : release-stage-1 release-stage-2 release-stage-final release-burned dev-env bump twine twine-prod gen-package-lock
# INSTALL
# setup.py sucks when you have your library in a separate folder, adding these in to help setup envs
# install from build directory
install : uninstall
cd $(BUILDDIR); pip install -e .
# install from source tree for development
install-dev : uninstall
pip install -e .
# install from test.pypi to test your release
install-release-test : uninstall
pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple cellxgene
@echo "Installed cellxgene from test.pypi.org, now run and smoke test"
# install from pypi to test your release
install-release : uninstall
pip install cellxgene
@echo "Installed cellxgene from pypi.org"
uninstall :
yes | pip uninstall cellxgene || true
.PHONY : install install-dev install-release-test install-release uninstall
+2 -5
View File
@@ -2,14 +2,11 @@
if __package__ is None: if __package__ is None:
import sys import sys
from pathlib import Path from pathlib import Path
PKG_PATH = Path(__file__).parent PKG_PATH = Path(__file__).parent
sys.path.insert(0, str(PKG_PATH.parent)) sys.path.insert(0, str(PKG_PATH.parent))
import server # noqa F401 import server
__package__ = PKG_PATH.name __package__ = PKG_PATH.name
# Main thing # Main thing
from .cli.cli import cli # noqa F402 from .cli.cli import cli
cli() cli()
+13 -2
View File
@@ -4,6 +4,7 @@ from flask import Flask
from flask_caching import Cache from flask_caching import Cache
from flask_compress import Compress from flask_compress import Compress
from flask_cors import CORS from flask_cors import CORS
from flask_restful_swagger_2 import get_swagger_blueprint
from .rest_api.rest import get_api_resources from .rest_api.rest import get_api_resources
from .util.utils import Float32JSONEncoder from .util.utils import Float32JSONEncoder
@@ -13,19 +14,29 @@ REACTIVE_LIMIT = 1_000_000
app = Flask(__name__, static_folder="web/static") app = Flask(__name__, static_folder="web/static")
app.json_encoder = Float32JSONEncoder app.json_encoder = Float32JSONEncoder
cache = Cache(app, config={"CACHE_TYPE": "simple", "CACHE_DEFAULT_TIMEOUT": 860_000}) cache = Cache(app, config={"CACHE_TYPE": "simple", "CACHE_DEFAULT_TIMEOUT": 860000})
Compress(app) Compress(app)
CORS(app) CORS(app)
# Config # Config
SECRET_KEY = os.environ.get("CXG_SECRET_KEY", default="SparkleAndShine") SECRET_KEY = os.environ.get("CXG_SECRET_KEY", default="SparkleAndShine")
app.config.update(SECRET_KEY=SECRET_KEY) app.config.update(
SECRET_KEY=SECRET_KEY,
)
# Application Data # Application Data
data = None data = None
# A list of swagger document objects
docs = []
resources = get_api_resources() resources = get_api_resources()
docs.append(resources.get_swagger_doc())
app.register_blueprint(webapp.bp) app.register_blueprint(webapp.bp)
app.register_blueprint(resources.blueprint) app.register_blueprint(resources.blueprint)
app.register_blueprint(
get_swagger_blueprint(docs, "/api/swagger", produces=["application/json"], title="cellxgene rest api",
description="An API connecting ExpressionMatrix2 clustering algorithm to cellxgene"))
app.add_url_rule("/", endpoint="index") app.add_url_rule("/", endpoint="index")
+43 -11
View File
@@ -11,20 +11,23 @@ Sort order for methods
class CXGDriver(metaclass=ABCMeta): class CXGDriver(metaclass=ABCMeta):
def __init__(self, data, args): def __init__(self, data, args):
self.data = self._load_data(data) self.data = self._load_data(data)
self.layout_method = args["layout"] self.layout_method = args["layout"]
self.diffexp_method = args["diffexp"] self.diffexp_method = args["diffexp"]
self.max_category_items = args["max_category_items"] self.max_category_items = args["max_category_items"]
self.diffexp_lfc_cutoff = args["diffexp_lfc_cutoff"]
self.cluster = None self.cluster = None
@property @property
def features(self): def features(self):
features = { features = {
"cluster": {"available": False}, "cluster": {"available": False},
"layout": {"obs": {"available": False}, "var": {"available": False}}, "layout": {
"diffexp": {"available": False}, "obs": {"available": False},
"var": {"available": False},
},
"diffexp": {"available": False}
} }
# TODO - Interactive limit should be generated from the actual available methods see GH issue #94 # TODO - Interactive limit should be generated from the actual available methods see GH issue #94
if self.layout_method: if self.layout_method:
@@ -42,17 +45,41 @@ class CXGDriver(metaclass=ABCMeta):
pass pass
@abstractmethod @abstractmethod
def annotation_to_fbs_matrix(self, axis, field=None): def filter_dataframe(self, filter):
""" """
Gets annotation value for each observation Filter cells from data and return a subset of the data. They can operate on both obs and var dimension with
:param axis: string obs or var indexing and filtering by annotation value. Filters are combined with the and operator.
:param fields: list of keys for annotation to return, returns all annotation values if not set. See REST specs for info on filter format:
:return: flatbuffer: in fbs/matrix.fbs encoding https://github.com/chanzuckerberg/cellxgene/blob/master/docs/REST_API.md
:param filter: dictionary with filter params
:return: View into scanpy object with cells/genes filtered
""" """
pass pass
@abstractmethod @abstractmethod
def data_frame_to_fbs_matrix(self, filter, axis): def annotation(self, filter, axis, fields=None):
"""
Gets annotation value for each observation
:param filter: filter: dictionary with filter params
:param axis: string obs or var
:param fields: list of keys for annotation to return, returns all annotation values if not set.
:return: dict: names - list of fields in order, data - list of lists or metadata
[observation ids, val1, val2...]
"""
pass
@abstractmethod
def data_frame(self, filter, axis):
"""
Retrieves data for each variable for observations in data frame
:param filter: filter: dictionary with filter params
:param axis: string obs or var
:return: {
"var": list of variable ids,
"obs": [cellid, var1 expression, var2 expression, ...],
}
"""
pass pass
@abstractmethod @abstractmethod
@@ -72,6 +99,11 @@ class CXGDriver(metaclass=ABCMeta):
pass pass
@abstractmethod @abstractmethod
def layout_to_fbs_matrix(self, filter): def layout(self, filter, interactive_limit=None):
""" same as layout, except returns a flatbuffer """ """
Computes a n-d layout for cells through dimensionality reduction.
:param filter: filter: dictionary with filter params
:param interactive_limit: -- don't compute if total # genes in dataframes are larger than this
:return: [cellid, x, y, ...]
"""
pass pass
+602 -123
View File
@@ -1,21 +1,17 @@
from http import HTTPStatus from http import HTTPStatus
import pkg_resources import pkg_resources
import warnings
from flask import Blueprint, current_app, jsonify, make_response, request from flask import (
from flask_restful import Api, Resource Blueprint, current_app, jsonify, make_response, request
)
from flask_restful_swagger_2 import Api, swagger, Resource
from werkzeug.datastructures import ImmutableMultiDict
from server.app.util.constants import ( from server.app.util.constants import Axis, DiffExpMode
Axis, from server.app.util.filter import parse_filter, QueryStringError
DiffExpMode, from server.app.util.models import FilterModel
JSON_NaN_to_num_warning_msg, from server.app.util.utils import get_mime_type
) from server.app.util.errors import MimeTypeError, FilterError, InteractiveError, PrepareError
from server.app.util.errors import (
FilterError,
InteractiveError,
JSONEncodingValueError,
PrepareError,
)
""" """
Sort order for routes Sort order for routes
@@ -26,112 +22,558 @@ Sort order for routes
class SchemaAPI(Resource): class SchemaAPI(Resource):
@swagger.doc({
"summary": "get schema for dataframe and annotations",
"tags": ["initialize"],
"parameters": [],
"responses": {
"200": {
"description": "schema",
"examples": {
"application/json": {
"schema": {
"dataframe": {
"nObs": 383,
"nVar": 19944,
"type": "float32"
},
"annotations": {
"obs": [
{"name": "name", "type": "string"},
{"name": "tissue_type", "type": "string"},
{"name": "num_reads", "type": "int32"},
{"name": "sample_name", "type": "string"},
{
"name": "clusters",
"type": "categorical",
"categories": [99, 1, "unknown cluster"]
},
{"name": "QScore", "type": "float32"}
],
"var": [
{"name": "name", "type": "string"},
{"name": "gene", "type": "string"}
]
}
}
}
}
}
}
})
def get(self): def get(self):
return make_response( return make_response(jsonify({"schema": current_app.data.schema}), HTTPStatus.OK)
jsonify({"schema": current_app.data.schema}), HTTPStatus.OK
)
class ConfigAPI(Resource): class ConfigAPI(Resource):
@swagger.doc({
"summary": "Configuration information to assist in front-end adaptation"
" to underlying engine, available functionality, interactive time limits, etc",
"tags": ["initialize"],
"parameters": [],
"responses": {
"200": {
"description": "schema",
"examples": {
"application/json": {
"config": {
"features": [
{"method": "POST", "path": "/cluster/", "available": False},
{
"method": "POST",
"path": "/layout/obs",
"available": True,
"interactiveLimit": 10000
},
{"method": "POST", "path": "/layout/var", "available": False}
],
"displayNames": {
"engine": "ScanPy version 1.33",
"dataset": "/home/joe/mouse/blorth.csv"
},
}
}
}
}
}
})
def get(self): def get(self):
config = { config = {
"config": { "config": {
"features": [ "features": [
{ {"method": "POST", "path": "/cluster/", **current_app.data.features["cluster"]},
"method": "POST", {"method": "POST", "path": "/layout/obs", **current_app.data.features["layout"]["obs"]},
"path": "/cluster/", {"method": "POST", "path": "/layout/var", **current_app.data.features["layout"]["var"]},
**current_app.data.features["cluster"], {"method": "POST", "path": "/diffexp/", **current_app.data.features["diffexp"]},
},
{
"method": "POST",
"path": "/layout/obs",
**current_app.data.features["layout"]["obs"],
},
{
"method": "POST",
"path": "/layout/var",
**current_app.data.features["layout"]["var"],
},
{
"method": "POST",
"path": "/diffexp/",
**current_app.data.features["diffexp"],
},
], ],
"displayNames": { "displayNames": {
"engine": f"cellxgene Scanpy engine version {pkg_resources.get_distribution('cellxgene').version}", "engine": f"cellxgene Scanpy engine version {pkg_resources.get_distribution('cellxgene').version}",
"dataset": current_app.config["DATASET_TITLE"], "dataset": current_app.config["DATASET_TITLE"]
}, },
"parameters": { "parameters": {
"max_category_items": current_app.data.max_category_items "max_category_items": current_app.data.max_category_items
}, }
} }
} }
return make_response(jsonify(config), HTTPStatus.OK) return make_response(jsonify(config), HTTPStatus.OK)
class AnnotationsObsAPI(Resource): class AnnotationsObsAPI(Resource):
@swagger.doc({
"summary": "Fetch annotations (metadata) for all observations.",
"tags": ["annotations"],
"parameters": [{
"in": "query",
"name": "annotation-name",
"type": "string",
"description": "list of 1 or more annotation names"
}],
"responses": {
"200": {
"description": "annotations",
"examples": {
"application/json": {
"names": [
"tissue_type", "sex", "num_reads", "clusters"
],
"data": [
[0, "lung", "F", 39844, 99],
[1, "heart", "M", 83, 1],
[49, "spleen", None, 2, "unknown cluster"],
]
}
}
},
"400": {
"description": "one or more of the annotation-name identifiers were not associated with an "
"annotation name"
}
}
})
def get(self): def get(self):
fields = request.args.getlist("annotation-name", None) fields = request.args.getlist("annotation-name", None)
preferred_mimetype = request.accept_mimetypes.best_match(
["application/octet-stream"]
)
try: try:
if preferred_mimetype == "application/octet-stream": annotation_response = current_app.data.annotation({}, "obs", fields)
return make_response(current_app.data.annotation_to_fbs_matrix("obs", fields),
HTTPStatus.OK,
{"Content-Type": "application/octet-stream"})
else:
return make_response(f"Unsupported MIME type '{request.accept_mimetypes}'", HTTPStatus.NOT_ACCEPTABLE)
except KeyError: except KeyError:
return make_response(f"Error bad key in {fields}", HTTPStatus.BAD_REQUEST) return make_response(f"Error bad key in {fields}", HTTPStatus.BAD_REQUEST)
except ValueError as e: return make_response(jsonify(annotation_response), HTTPStatus.OK)
return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR)
@swagger.doc({
"summary": "Fetch annotations (metadata) for filtered subset of observations.",
"tags": ["annotations"],
"parameters": [
{
"in": "query",
"name": "annotation-name",
"type": "string",
"description": "list of 1 or more annotation names"
},
{
"name": "filter",
"description": "Complex Filter",
"in": "body",
"schema": FilterModel
}
],
"responses": {
"200": {
"description": "annotations",
"examples": {
"application/json": {
"names": [
"tissue_type", "sex", "num_reads", "clusters"
],
"data": [
[0, "lung", "F", 39844, 99],
[1, "heart", "M", 83, 1],
[49, "spleen", None, 2, "unknown cluster"],
]
}
}
},
"400": {
"description": "malformed filter or one or more of the annotation-name identifiers were"
"not associated with an annotation name"
}
}
})
def put(self):
fields = request.args.getlist("annotation-name", None)
try:
annotation_response = current_app.data.annotation(request.get_json()["filter"], "obs", fields)
except KeyError:
return make_response(f"Error bad key in {fields}", HTTPStatus.BAD_REQUEST)
except FilterError as e:
return make_response(e.message, HTTPStatus.BAD_REQUEST)
return make_response(jsonify(annotation_response), HTTPStatus.OK)
class AnnotationsVarAPI(Resource): class AnnotationsVarAPI(Resource):
@swagger.doc({
"summary": "Fetch annotations (metadata) for all variables.",
"tags": ["annotations"],
"parameters": [{
"in": "query",
"name": "annotation-name",
"type": "string",
"description": "list of 1 or more annotation names"
}],
"responses": {
"200": {
"description": "annotations",
"examples": {
"application/json": {
"names": [
"name", "category"
],
"data": [
[0, "ATAD3C", 1],
[1, "RER1", None],
[49, "S100B", 6]
]
}
}
},
"400": {
"description": "one or more of the annotation-name identifiers were not associated with an"
" annotation name"
}
}
})
def get(self): def get(self):
fields = request.args.getlist("annotation-name", None) fields = request.args.getlist("annotation-name", None)
preferred_mimetype = request.accept_mimetypes.best_match(
["application/octet-stream"]
)
try: try:
if preferred_mimetype == "application/octet-stream": annotation_response = current_app.data.annotation({}, "var", fields)
return make_response(current_app.data.annotation_to_fbs_matrix("var", fields),
HTTPStatus.OK,
{"Content-Type": "application/octet-stream"})
else:
return make_response(f"Unsupported MIME type '{request.accept_mimetypes}'", HTTPStatus.NOT_ACCEPTABLE)
except KeyError: except KeyError:
return make_response(f"Error bad key in {fields}", HTTPStatus.BAD_REQUEST) return make_response(f"Error bad key in {fields}", HTTPStatus.BAD_REQUEST)
except ValueError as e: return make_response(jsonify(annotation_response), HTTPStatus.OK)
return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR)
@swagger.doc({
"summary": "Fetch annotations (metadata) for filtered subset of variables.",
"tags": ["annotations"],
"parameters": [
{
"in": "query",
"name": "annotation-name",
"type": "string",
"description": "list of 1 or more annotation names"
},
{
"name": "filter",
"description": "Complex Filter",
"in": "body",
"schema": FilterModel
}
],
"responses": {
"200": {
"description": "annotations",
"examples": {
"application/json": {
"names": [
"name", "category"
],
"data": [
[0, "ATAD3C", 1],
[1, "RER1", None],
[49, "S100B", 6]
]
}
}
},
"400": {
"description": "malformed filter or one or more of the annotation-name identifiers were"
"not associated with an annotation name"
}
}
})
def put(self):
fields = request.args.getlist("annotation-name", None)
try:
annotation_response = current_app.data.annotation(request.get_json()["filter"], "var", fields)
except KeyError:
return make_response(f"Error bad key in {fields}", HTTPStatus.BAD_REQUEST)
except FilterError:
return make_response("Malformed filter", HTTPStatus.BAD_REQUEST)
return make_response(jsonify(annotation_response), HTTPStatus.OK)
class DataObsAPI(Resource):
@swagger.doc({
"summary": "Get data (expression values) from the dataframe.",
"tags": ["data"],
"parameters": [
{
"in": "query",
"name": "filter",
"type": "string",
"description": "axis:key:value"
},
{
"in": "query",
"name": "accept-type",
"type": "string",
"description": "MIME type"
},
],
"responses": {
"200": {
"description": "expression",
"examples": {
"application/json": {
"var": [0, 20000],
"obs": [
[1, 39483, 3902, 203, 0, 0, 28]
]
}
}
},
"400": {
"description": "Malformed filter"
},
"406": {
"description": "Unacceptable MIME type"
},
}
})
def get(self):
accept_type = request.args.get("accept-type", None)
# request.args is immutable
args = dict(request.args)
args.pop("accept-type", None)
try:
filter_ = parse_filter(ImmutableMultiDict(args), current_app.data.schema['annotations'])
except QueryStringError as e:
return make_response(e.message, HTTPStatus.BAD_REQUEST)
# TODO support CSV
try:
# TODO store mime_type when more than one is supported
get_mime_type(acceptable_types=["application/json"], query_param=accept_type,
header=request.accept_mimetypes)
except MimeTypeError as e:
return make_response(e.message, HTTPStatus.NOT_ACCEPTABLE)
try:
return make_response((jsonify(current_app.data.data_frame(filter_, axis=Axis.OBS))), HTTPStatus.OK)
except FilterError as e:
return make_response(e.message, HTTPStatus.BAD_REQUEST)
@swagger.doc({
"summary": "Get data (expression values) from the dataframe.",
"tags": ["data"],
"parameters": [
{
'name': 'filter',
'description': 'Complex Filter',
'in': 'body',
'schema': FilterModel
}
],
"responses": {
"200": {
"description": "expression",
"examples": {
"application/json": {
"var": [0, 20000],
"obs": [
[1, 39483, 3902, 203, 0, 0, 28]
]
}
}
},
"400": {
"description": "Malformed filter"
},
"406": {
"description": "Unacceptable MIME type"
},
}
})
def put(self):
if not request.accept_mimetypes.best_match(["application/json", "text/csv"]):
return make_response(f"Unsupported MIME type '{request.accept_mimetypes}'", HTTPStatus.NOT_ACCEPTABLE)
try:
get_mime_type(acceptable_types=["application/json"], header=request.accept_mimetypes)
except MimeTypeError as e:
return make_response(e.message, HTTPStatus.NOT_ACCEPTABLE)
try:
return make_response((jsonify(current_app.data.data_frame(request.get_json()["filter"], axis=Axis.OBS))),
HTTPStatus.OK)
except FilterError as e:
return make_response(e.message, HTTPStatus.BAD_REQUEST)
class DataVarAPI(Resource): class DataVarAPI(Resource):
def put(self): @swagger.doc({
preferred_mimetype = request.accept_mimetypes.best_match( "summary": "Get data (expression values) from the dataframe.",
["application/octet-stream"] "tags": ["data"],
) "parameters": [
{
"in": "query",
"name": "filter",
"type": "string",
"description": "axis:key:value"
},
{
"in": "query",
"name": "accept-type",
"type": "string",
"description": "MIME type"
},
],
"responses": {
"200": {
"description": "expression",
"examples": {
"application/json": {
"obs": [0, 20000],
"var": [
[1, 39483, 3902, 203, 0, 0, 28]
]
}
}
},
"400": {
"description": "Malformed filter"
},
"406": {
"description": "Unacceptable MIME type"
},
}
})
def get(self):
accept_type = request.args.get("accept-type", None)
# request.args is immutable
args = dict(request.args)
args.pop("accept-type", None)
try: try:
if preferred_mimetype == "application/octet-stream": filter_ = parse_filter(ImmutableMultiDict(args), current_app.data.schema['annotations'])
filter_json = request.get_json() except QueryStringError as e:
filter = filter_json["filter"] if filter_json else None return make_response(e.message, HTTPStatus.BAD_REQUEST)
return make_response( try:
current_app.data.data_frame_to_fbs_matrix( get_mime_type(acceptable_types=["application/json"], query_param=accept_type,
filter, axis=Axis.VAR header=request.accept_mimetypes)
), except MimeTypeError as e:
HTTPStatus.OK, return make_response(e.message, HTTPStatus.NOT_ACCEPTABLE)
{"Content-Type": "application/octet-stream"}) try:
else: return make_response((jsonify(current_app.data.data_frame(filter_, axis=Axis.VAR))), HTTPStatus.OK)
return make_response(f"Unsupported MIME type '{request.accept_mimetypes}'", HTTPStatus.NOT_ACCEPTABLE) except FilterError as e:
return make_response(e.message, HTTPStatus.BAD_REQUEST)
@swagger.doc({
"summary": "Get data (expression values) from the dataframe.",
"tags": ["data"],
"parameters": [
{
'name': 'filter',
'description': 'Complex Filter',
'in': 'body',
'schema': FilterModel
}
],
"responses": {
"200": {
"description": "expression",
"examples": {
"application/json": {
"obs": [0, 20000],
"var": [
[1, 39483, 3902, 203, 0, 0, 28]
]
}
}
},
"400": {
"description": "Malformed filter"
},
"406": {
"description": "Unacceptable MIME type"
},
}
})
def put(self):
if not request.accept_mimetypes.best_match(["application/json", "text/csv"]):
return make_response(f"Unsupported MIME type '{request.accept_mimetypes}'", HTTPStatus.NOT_ACCEPTABLE)
# TODO support CSV
try:
get_mime_type(acceptable_types=["application/json"], header=request.accept_mimetypes)
except MimeTypeError as e:
return make_response(e.message, HTTPStatus.NOT_ACCEPTABLE)
try:
return make_response((jsonify(current_app.data.data_frame(request.get_json()["filter"], axis=Axis.VAR))),
HTTPStatus.OK)
except FilterError as e: except FilterError as e:
return make_response(e.message, HTTPStatus.BAD_REQUEST) return make_response(e.message, HTTPStatus.BAD_REQUEST)
except ValueError as e:
return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR)
class DiffExpObsAPI(Resource): class DiffExpObsAPI(Resource):
@swagger.doc({
"summary": "Generate differential expression (DE) statistics for two specified subsets of data, "
"as indicated by the two provided observation complex filters",
"tags": ["diffexp"],
# TODO sort out params
# "parameters": [
# # {
# # "in": "body",
# # "name": "mode",
# # "type": "string",
# # "required": True,
# # "description": "topN or varFilter"
# # },
# {
# "in": "query",
# "name": "count",
# "type": "int32",
# "description": "TopN mode: how many vars to return"
# },
# {
# "in": "body",
# "name": "varFilter",
# "schema": FilterModel,
# "description": "varFilter: Complex filter, only var for which vars to return"
# },
# {
# "in": "body",
# "name": "set1",
# "schema": FilterModel,
# "required": True,
# "description": "Complex filter, only obs - observations in set1"
# },
# {
# "in": "body",
# "name": "set2",
# "schema": FilterModel,
# "description": "Complex filter, only obs - observations in set2. If not included, inverse of set1."
# },
# ],
"responses": {
"200": {
"description": "Statistics are encoded as an array of arrays, with fields ordered as: "
"varIndex, logfoldchange, pVal, pValAdj",
"examples": {
"application/json": [
[328, -2.569489, 2.655706e-63, 3.642036e-57],
[1250, -2.569489, 2.655706e-63, 3.642036e-57],
]
}
},
"400": {
"description": "malformed filter"
},
"403": {
"description": "non-interactive request"
},
"501": {
"description": "diffexp is not implemented"
}
}
})
def post(self): def post(self):
args = request.get_json() args = request.get_json()
# confirm mode is present and legal # confirm mode is present and legal
@@ -140,35 +582,23 @@ class DiffExpObsAPI(Resource):
except KeyError: except KeyError:
return make_response("Error: mode is required", HTTPStatus.BAD_REQUEST) return make_response("Error: mode is required", HTTPStatus.BAD_REQUEST)
except ValueError: except ValueError:
return make_response( return make_response(f"Error: invalid mode option {args['mode']}", HTTPStatus.BAD_REQUEST)
f"Error: invalid mode option {args['mode']}", HTTPStatus.BAD_REQUEST
)
# Validate filters # Validate filters
if mode == DiffExpMode.VAR_FILTER or "varFilter" in args: if mode == DiffExpMode.VAR_FILTER or "varFilter" in args:
# not NOT_IMPLEMENTED # not NOT_IMPLEMENTED
return make_response( return make_response("mode=varfilter not implemented", HTTPStatus.NOT_IMPLEMENTED)
"mode=varfilter not implemented", HTTPStatus.NOT_IMPLEMENTED
)
if mode == DiffExpMode.TOP_N and "count" not in args: if mode == DiffExpMode.TOP_N and "count" not in args:
return make_response( return make_response("mode=topN requires a count parameter", HTTPStatus.BAD_REQUEST)
"mode=topN requires a count parameter", HTTPStatus.BAD_REQUEST
)
if "set1" not in args: if "set1" not in args:
return make_response("set1 is required.", HTTPStatus.BAD_REQUEST) return make_response("set1 is required.", HTTPStatus.BAD_REQUEST)
if Axis.VAR in args["set1"]["filter"]: if Axis.VAR in args["set1"]["filter"]:
return make_response( return make_response("Var filter not allowed for set1", HTTPStatus.BAD_REQUEST)
"Var filter not allowed for set1", HTTPStatus.BAD_REQUEST
)
# set2 # set2
if "set2" not in args: if "set2" not in args:
return make_response( return make_response("Set2 as inverse of set1 is not implemented", HTTPStatus.NOT_IMPLEMENTED)
"Set2 as inverse of set1 is not implemented", HTTPStatus.NOT_IMPLEMENTED
)
if Axis.VAR in args["set2"]["filter"]: if Axis.VAR in args["set2"]["filter"]:
return make_response( return make_response("Var filter not allowed for set2", HTTPStatus.BAD_REQUEST)
"Var filter not allowed for set2", HTTPStatus.BAD_REQUEST
)
set1_filter = args["set1"]["filter"] set1_filter = args["set1"]["filter"]
set2_filter = args.get("set2", {"filter": {}})["filter"] set2_filter = args.get("set2", {"filter": {}})["filter"]
@@ -178,54 +608,103 @@ class DiffExpObsAPI(Resource):
# mode=topN # mode=topN
count = args.get("count", None) count = args.get("count", None)
try: try:
diffexp = current_app.data.diffexp_topN( diffexp = current_app.data.diffexp_topN(set1_filter, set2_filter, count,
set1_filter, current_app.data.features["diffexp"]["interactiveLimit"])
set2_filter,
count,
current_app.data.features["diffexp"]["interactiveLimit"],
)
return make_response(
diffexp, HTTPStatus.OK, {"Content-Type": "application/json"}
)
except (ValueError, FilterError) as e: except (ValueError, FilterError) as e:
return make_response(e.message, HTTPStatus.BAD_REQUEST) return make_response(e.message, HTTPStatus.BAD_REQUEST)
except InteractiveError: except InteractiveError:
return make_response("Non-interactive request", HTTPStatus.FORBIDDEN) return make_response("Non-interactive request", HTTPStatus.FORBIDDEN)
except JSONEncodingValueError as e: return make_response(jsonify(diffexp), HTTPStatus.OK)
# JSON encoding failure, usually due to bad data
warnings.warn(JSON_NaN_to_num_warning_msg)
return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR)
except ValueError as e:
return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR)
class LayoutObsAPI(Resource): class LayoutObsAPI(Resource):
@swagger.doc({
"summary": "Get the default layout for all observations.",
"tags": ["layout"],
"parameters": [],
"responses": {
"200": {
"description": "layout",
"examples": {
"application/json": {
"layout": {
"ndims": 2,
"coordinates": [
[0, 0.284483, 0.983744],
[1, 0.038844, 0.739444]
]
}
}
}
},
"400": {
"description": "Data preparation error"
}
}
})
def get(self): def get(self):
preferred_mimetype = request.accept_mimetypes.best_match(
["application/octet-stream"]
)
try: try:
if preferred_mimetype == "application/octet-stream": layout = current_app.data.layout({})
return make_response(current_app.data.layout_to_fbs_matrix(),
HTTPStatus.OK,
{"Content-Type": "application/octet-stream"})
else:
return make_response(f"Unsupported MIME type '{request.accept_mimetypes}'", HTTPStatus.NOT_ACCEPTABLE)
except PrepareError as e: except PrepareError as e:
return make_response(e.message, HTTPStatus.INTERNAL_SERVER_ERROR) return make_response(e.message, HTTPStatus.INTERNAL_SERVER_ERROR)
except ValueError as e: return make_response((jsonify({"layout": layout})), HTTPStatus.OK)
return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR)
# @swagger.doc({
# "summary": "Observation layout for filtered subset.",
# "tags": ["layout"],
# "parameters": [
# {
# "name": "filter",
# "description": "Complex Filter",
# "in": "body",
# "schema": FilterModel
# }
# ],
# "responses": {
# "200": {
# "description": "layout",
# "examples": {
# "application/json": {
# "layout": {
# "ndims": 2,
# "coordinates": [
# [0, 0.284483, 0.983744],
# [1, 0.038844, 0.739444]
# ]
# }
# }
# }
# },
# "400": {
# "description": "Malformed filter"
# },
# "403": {
# "description": "Non-interactive request"
# },
# }
# })
# def put(self):
# try:
# filter = request.get_json()["filter"]
# interactive_limit = current_app.data.features["layout"]["obs"]["interactiveLimit"]
# layout = current_app.data.layout(filter, interactive_limit=interactive_limit)
# return make_response(jsonify({"layout": layout}), HTTPStatus.OK)
# except FilterError as e:
# return make_response(e.message, HTTPStatus.BAD_REQUEST)
# except InteractiveError:
# return make_response("Non-interactive request", HTTPStatus.FORBIDDEN)
def get_api_resources(): def get_api_resources():
bp = Blueprint("api", __name__, url_prefix="/api/v0.2") bp = Blueprint("api", __name__, url_prefix="/api/v0.2")
api = Api(bp) api = Api(bp, add_api_spec_resource=False)
# Initialization routes # Initialization routes
api.add_resource(SchemaAPI, "/schema") api.add_resource(SchemaAPI, "/schema")
api.add_resource(ConfigAPI, "/config") api.add_resource(ConfigAPI, "/config")
# Data routes # Data routes
api.add_resource(AnnotationsObsAPI, "/annotations/obs") api.add_resource(AnnotationsObsAPI, "/annotations/obs")
api.add_resource(AnnotationsVarAPI, "/annotations/var") api.add_resource(AnnotationsVarAPI, "/annotations/var")
api.add_resource(DataObsAPI, "/data/obs")
api.add_resource(DataVarAPI, "/data/var") api.add_resource(DataVarAPI, "/data/var")
# Computation routes # Computation routes
api.add_resource(DiffExpObsAPI, "/diffexp/obs") api.add_resource(DiffExpObsAPI, "/diffexp/obs")
+31 -70
View File
@@ -1,3 +1,4 @@
import numpy as np import numpy as np
from scipy import sparse, stats from scipy import sparse, stats
@@ -9,114 +10,74 @@ def _mean_var_n(X):
than naive methods (and same method used by numpy.var()) than naive methods (and same method used by numpy.var())
https://en.wikipedia.org/wiki/Algorithms_for_calculating_variance#Two-pass https://en.wikipedia.org/wiki/Algorithms_for_calculating_variance#Two-pass
""" """
# fp_err_occurred is a flag indicating that a floating point error n = X.shape[0]
# occured somewhere in our compute. Used to trigger non-finite if sparse.issparse(X):
# number handling. mean = X.mean(axis=0).A1
fp_err_occurred = False dfm = X - mean
sumsq = np.sum(np.multiply(dfm, dfm), axis=0).A1
v = sumsq / (n - 1)
else:
mean = X.mean(axis=0)
dfm = X - mean
sumsq = np.sum(np.multiply(dfm, dfm), axis=0)
v = sumsq / (n - 1)
def fp_err_set(err, flag):
nonlocal fp_err_occurred
fp_err_occurred = True
with np.errstate(divide="call", invalid="call", call=fp_err_set):
n = X.shape[0]
if sparse.issparse(X):
mean = X.mean(axis=0).A1
dfm = X - mean
sumsq = np.sum(np.multiply(dfm, dfm), axis=0).A1
v = sumsq / (n - 1)
else:
mean = X.mean(axis=0)
dfm = X - mean
sumsq = np.sum(np.multiply(dfm, dfm), axis=0)
v = sumsq / (n - 1)
if fp_err_occurred:
mean[np.isfinite(mean) == False] = 0 # noqa: E712
v[np.isfinite(v) == False] = 0 # noqa: E712
return mean, v, n return mean, v, n
def diffexp_ttest(adata, maskA, maskB, top_n=8, diffexp_lfc_cutoff=0.01): def diffexp_ttest(adata, maskA, maskB, top_n=8):
""" """
Return differential expression statistics for top N variables. Return differential expression statistics for top N variables, sorted by
t statistic. Implemented as a unequal variance t-test.
Algorithm:
- compute log fold change (log2(meanA/meanB))
- compute Welch's t-test statistic and pvalue (w/ Bonferroni correction)
- return top N abs(logfoldchange) where lfc > diffexp_lfc_cutoff
If there are not N which meet criteria, augment by removing the logfoldchange
threshold requirement.
Notes on alogrithm:
- Welch's ttest provides basic statistics test.
https://en.wikipedia.org/wiki/Welch%27s_t-test
- p-values adjusted with Bonferroni correction.
https://en.wikipedia.org/wiki/Bonferroni_correction
:param adata: anndata dataframe :param adata: anndata dataframe
:param maskA: observation selection mask for set 1 :param maskA: observation selection mask for set 1
:param maskB: observation selection mask for set 2 :param maskB: observation selection mask for set 2
:param top_n: number of variables to return stats for :param top_n: number of variables to return stats for
:param diffexp_lfc_cutoff: minimum
:return: for top N genes, [ varindex, logfoldchange, pval, pval_adj ] :return: for top N genes, [ varindex, logfoldchange, pval, pval_adj ]
""" """
if top_n > adata.n_obs: # mean, variance, N
top_n = adata.n_obs
# mean, variance, N - calculate for both selections
meanA, vA, nA = _mean_var_n(adata._X[maskA]) meanA, vA, nA = _mean_var_n(adata._X[maskA])
meanB, vB, nB = _mean_var_n(adata._X[maskB]) meanB, vB, nB = _mean_var_n(adata._X[maskB])
# variance / N # variance / N
vnA = vA / min(nA, nB) # overestimate variance, would normally be nA vnA = vA / nA
vnB = vB / min(nA, nB) # overestimate variance, would normally be nB vnB = vB / nB
sum_vn = vnA + vnB sum_vn = vnA + vnB
# degrees of freedom for Welch's t-test # degrees of freedom for Welch's t-test
with np.errstate(divide="ignore", invalid="ignore"): with np.errstate(divide='ignore', invalid='ignore'):
dof = sum_vn ** 2 / (vnA ** 2 / (nA - 1) + vnB ** 2 / (nB - 1)) dof = sum_vn**2 / (vnA**2 / (nA - 1) + vnB**2 / (nB - 1))
dof[np.isnan(dof)] = 1 dof[np.isnan(dof)] = 1
# Welch's t-test score calculation # Welch's t-test score calculation
with np.errstate(divide="ignore", invalid="ignore"): with np.errstate(divide='ignore', invalid='ignore'):
tscores = (meanA - meanB) / np.sqrt(sum_vn) tscores = (meanA - meanB) / np.sqrt(sum_vn)
tscores[np.isnan(tscores)] = 0 tscores[np.isnan(tscores)] = 0
# p-value # p-value
pvals = stats.t.sf(np.abs(tscores), dof) * 2 pvals = stats.t.sf(np.abs(tscores), dof) * 2
pvals_adj = pvals * adata._X.shape[1] pvals_adj = pvals * adata._X.shape[1]
pvals_adj[pvals_adj > 1] = 1 # cap adjusted p-value at 1
# logfoldchanges: log2(meanA / meanB) # logfoldchanges: log2(meanA / meanB)
logfoldchanges = np.log2(np.abs((meanA + 1e-9) / (meanB + 1e-9))) logfoldchanges = np.log2(np.abs((meanA + 1e-9) / (meanB + 1e-9)))
# find all with lfc > cutoff # top n sort
lfc_above_cutoff_idx = np.nonzero(np.abs(logfoldchanges) > diffexp_lfc_cutoff)[0]
stats_to_sort = np.abs(tscores) stats_to_sort = np.abs(tscores)
partition = np.argpartition(stats_to_sort, -top_n)[-top_n:]
rel_sort_order = np.argsort(stats_to_sort[partition])[::-1]
vars_indices = np.arange(adata.n_vars, dtype=int)
sort_order = vars_indices[partition][rel_sort_order]
# derive sort order # top n slice
if lfc_above_cutoff_idx.shape[0] > top_n:
# partition top N
rel_t_partition = np.argpartition(stats_to_sort[lfc_above_cutoff_idx], -top_n)[-top_n:]
t_partition = lfc_above_cutoff_idx[rel_t_partition]
# sort the top N partition
rel_sort_order = np.argsort(stats_to_sort[t_partition])[::-1]
sort_order = t_partition[rel_sort_order]
else:
# partition and sort top N, ignoring lfc cutoff
partition = np.argpartition(stats_to_sort, -top_n)[-top_n:]
rel_sort_order = np.argsort(stats_to_sort[partition])[::-1]
indices = np.indices(stats_to_sort.shape)[0]
sort_order = indices[partition][rel_sort_order]
# top n slice based upon sort order
logfoldchanges_top_n = logfoldchanges[sort_order] logfoldchanges_top_n = logfoldchanges[sort_order]
pvals_top_n = pvals[sort_order] pvals_top_n = pvals[sort_order]
pvals_adj_top_n = pvals_adj[sort_order] pvals_adj_top_n = pvals_adj[sort_order]
# varIndex, logfoldchange, pval, pval_adj # varIndex, logfoldchange, pval, pval_adj
result = [[sort_order[i], logfoldchanges_top_n[i], pvals_top_n[i], pvals_adj_top_n[i]] for i in range(top_n)] result = [[sort_order[i],
logfoldchanges_top_n[i],
pvals_top_n[i],
pvals_adj_top_n[i]] for i in range(top_n)]
return result return result
+179 -153
View File
@@ -1,20 +1,15 @@
import warnings import warnings
import numpy as np import numpy as np
from pandas import DataFrame
from pandas.core.dtypes.dtypes import CategoricalDtype from pandas.core.dtypes.dtypes import CategoricalDtype
import scanpy.api as sc import scanpy.api as sc
from scipy import sparse
from server.app.driver.driver import CXGDriver from server.app.driver.driver import CXGDriver
from server.app.util.constants import Axis, DEFAULT_TOP_N from server.app.util.constants import Axis, DEFAULT_TOP_N
from server.app.util.errors import ( from server.app.util.errors import FilterError, InteractiveError, PrepareError, ScanpyFileError
FilterError,
JSONEncodingValueError,
PrepareError,
ScanpyFileError,
)
from server.app.util.utils import jsonify_scanpy
from server.app.scanpy_engine.diffexp import diffexp_ttest from server.app.scanpy_engine.diffexp import diffexp_ttest
from server.app.util.fbs.matrix import encode_matrix_fbs
""" """
Sort order for methods Sort order for methods
@@ -27,6 +22,7 @@ Sort order for methods
class ScanpyEngine(CXGDriver): class ScanpyEngine(CXGDriver):
def __init__(self, data, args): def __init__(self, data, args):
super().__init__(data, args) super().__init__(data, args)
self._alias_annotation_names(Axis.OBS, args["obs_names"]) self._alias_annotation_names(Axis.OBS, args["obs_names"])
@@ -59,73 +55,46 @@ class ScanpyEngine(CXGDriver):
df_axis.rename(inplace=True, columns={"index": "name"}) df_axis.rename(inplace=True, columns={"index": "name"})
elif name in df_axis.columns: elif name in df_axis.columns:
if name not in df_axis.columns: if name not in df_axis.columns:
raise KeyError( raise KeyError(f"Annotation name {name}, specified in --{ax_name}-name does not exist.")
f"Annotation name {name}, specified in --{ax_name}-name does not exist."
)
if not df_axis[name].is_unique: if not df_axis[name].is_unique:
raise KeyError( raise KeyError(f"Values in -{ax_name}-name must be unique. "
f"Values in -{ax_name}-name must be unique. " "Please prepare data to contain unique values.")
"Please prepare data to contain unique values."
)
# reset index to simple range; alias user-specified annotation to "name" # reset index to simple range; alias user-specified annotation to "name"
df_axis.reset_index(drop=True, inplace=True) df_axis.reset_index(drop=True, inplace=True)
df_axis.rename(inplace=True, columns={name: "name"}) df_axis.rename(inplace=True, columns={name: "name"})
else: else:
raise KeyError( raise KeyError(f"Annotation name {name}, specified in --{ax_name}_name does not exist.")
f"Annotation name {name}, specified in --{ax_name}_name does not exist."
)
@staticmethod
def _can_cast_to_float32(ann):
if ann.dtype.kind == "f":
if not np.can_cast(ann.dtype, np.float32):
warnings.warn(
f"Annotation {ann.name} will be converted to 32 bit float and may lose precision."
)
return True
return False
@staticmethod
def _can_cast_to_int32(ann):
if ann.dtype.kind in ["i", "u"]:
if np.can_cast(ann.dtype, np.int32):
return True
ii32 = np.iinfo(np.int32)
if ann.min() >= ii32.min and ann.max() <= ii32.max:
return True
return False
def _create_schema(self): def _create_schema(self):
self.schema = { self.schema = {
"dataframe": { "dataframe": {
"nObs": self.cell_count, "nObs": self.cell_count,
"nVar": self.gene_count, "nVar": self.gene_count,
"type": str(self.data.X.dtype), "type": str(self.data.X.dtype)
}, },
"annotations": {"obs": [], "var": []}, "annotations": {
"obs": [],
"var": []
}
} }
for ax in Axis: for ax in Axis:
curr_axis = getattr(self.data, str(ax)) curr_axis = getattr(self.data, str(ax))
for ann in curr_axis: for ann in curr_axis:
ann_schema = {"name": ann} ann_schema = {"name": ann}
dtype = curr_axis[ann].dtype data_kind = curr_axis[ann].dtype.kind
data_kind = dtype.kind if data_kind == "f":
if self._can_cast_to_float32(curr_axis[ann]):
ann_schema["type"] = "float32" ann_schema["type"] = "float32"
elif self._can_cast_to_int32(curr_axis[ann]): elif data_kind in ["i", "u"]:
ann_schema["type"] = "int32" ann_schema["type"] = "int32"
elif dtype == np.bool_: elif data_kind == "?":
ann_schema["type"] = "boolean" ann_schema["type"] = "boolean"
elif data_kind == "O" and dtype == "object": elif data_kind == "O" and curr_axis[ann].dtype == "object":
ann_schema["type"] = "string" ann_schema["type"] = "string"
elif data_kind == "O" and dtype == "category": elif data_kind == "O" and curr_axis[ann].dtype == "category":
ann_schema["type"] = "categorical" ann_schema["type"] = "categorical"
ann_schema["categories"] = curr_axis[ann].dtype.categories.tolist() ann_schema["categories"] = curr_axis[ann].dtype.categories.tolist()
else: else:
raise TypeError( raise TypeError(f"Annotations of type {curr_axis[ann].dtype} are unsupported by cellxgene.")
f"Annotations of type {curr_axis[ann].dtype} are unsupported by cellxgene."
)
self.schema["annotations"][ax].append(ann_schema) self.schema["annotations"][ax].append(ann_schema)
@staticmethod @staticmethod
@@ -136,41 +105,32 @@ class ScanpyEngine(CXGDriver):
try: try:
result = sc.read(data, cache=True) result = sc.read(data, cache=True)
except ValueError: except ValueError:
raise ScanpyFileError( raise ScanpyFileError("File must be in the .h5ad format. Please read "
"File must be in the .h5ad format. Please read " "https://github.com/theislab/scanpy_usage/blob/master/170505_seurat/info_h5ad.md to "
"https://github.com/theislab/scanpy_usage/blob/master/170505_seurat/info_h5ad.md to " "learn more about this format. You may be able to convert your file into this format "
"learn more about this format. You may be able to convert your file into this format " "using `cellxgene prepare`, please run `cellxgene prepare --help` for more "
"using `cellxgene prepare`, please run `cellxgene prepare --help` for more " "information.")
"information."
)
except Exception as e: except Exception as e:
raise ScanpyFileError( raise ScanpyFileError(f"Error while loading file: {e}, File must be in the .h5ad format, please check "
f"Error while loading file: {e}, File must be in the .h5ad format, please check " f"that your input and try again.")
f"that your input and try again."
)
return result return result
def _validate_data_types(self): def _validate_data_types(self):
if self.data.X.dtype != "float32": if self.data.X.dtype != "float32":
warnings.warn( warnings.warn(f"Scanpy data matrix is in {self.data.X.dtype} format not float32. "
f"Scanpy data matrix is in {self.data.X.dtype} format not float32. " f"Precision may be truncated.")
f"Precision may be truncated."
)
for ax in Axis: for ax in Axis:
curr_axis = getattr(self.data, str(ax)) curr_axis = getattr(self.data, str(ax))
for ann in curr_axis: for ann in curr_axis:
datatype = curr_axis[ann].dtype datatype = curr_axis[ann].dtype
downcast_map = { downcast_map = {"int64": "int32",
"int64": "int32", "uint32": "int32",
"uint32": "int32", "uint64": "int32",
"uint64": "int32", "float64": "float32",
"float64": "float32", }
}
if datatype in downcast_map: if datatype in downcast_map:
warnings.warn( warnings.warn(f"Scanpy annotation {ax}:{ann} is in unsupported format: {datatype}. "
f"Scanpy annotation {ax}:{ann} is in unsupported format: {datatype}. " f"Data will be downcast to {downcast_map[datatype]}.")
f"Data will be downcast to {downcast_map[datatype]}."
)
if isinstance(datatype, CategoricalDtype): if isinstance(datatype, CategoricalDtype):
category_num = len(curr_axis[ann].dtype.categories) category_num = len(curr_axis[ann].dtype.categories)
if category_num > 500 and category_num > self.max_category_items: if category_num > 500 and category_num > self.max_category_items:
@@ -178,8 +138,7 @@ class ScanpyEngine(CXGDriver):
f"{str(ax).title()} annotation '{ann}' has {category_num} categories, this may be " f"{str(ax).title()} annotation '{ann}' has {category_num} categories, this may be "
f"cumbersome or slow to display. We recommend setting the " f"cumbersome or slow to display. We recommend setting the "
f"--max-category-items option to 500, this will hide categorical " f"--max-category-items option to 500, this will hide categorical "
f"annotations with more than 500 categories in the UI" f"annotations with more than 500 categories in the UI")
)
def _validate_data_calculations(self): def _validate_data_calculations(self):
layout_key = f"X_{self.layout_method}" layout_key = f"X_{self.layout_method}"
@@ -191,12 +150,28 @@ class ScanpyEngine(CXGDriver):
f" layout may have been computed. The requested layout must be pre-calculated and saved " f" layout may have been computed. The requested layout must be pre-calculated and saved "
f"back in the h5ad file. You can run " f"back in the h5ad file. You can run "
f"`cellxgene prepare --layout {self.layout_method} <datafile>` " f"`cellxgene prepare --layout {self.layout_method} <datafile>` "
f"to solve this problem. " f"to solve this problem. ")
)
def filter_dataframe(self, filter):
"""
Filter cells from data and return a subset of the data. They can operate on both obs and var dimension with
indexing and filtering by annotation value. Filters are combined with the and operator.
See REST specs for info on filter format:
# TODO update this link to swagger when it's done
https://docs.google.com/document/d/1Fxjp1SKtCk7l8QP9-7KAjGXL0eldi_qEnNT0NmlGzXI/edit#heading=h.8qc9q57amldx
:param filter: dictionary with filter params
:return: View into scanpy object with cells/genes filtered
"""
if not filter:
return self.data
obs_selector, var_selector = self._filter_to_mask(filter, use_slices=False)
data = self._slice(self.data, obs_selector, var_selector)
return data
@staticmethod @staticmethod
def _annotation_filter_to_mask(filter, d_axis, count): def _annotation_filter_to_mask(filter, d_axis, count):
mask = np.ones((count,), dtype=bool) mask = np.ones((count, ), dtype=bool)
for v in filter: for v in filter:
if d_axis[v["name"]].dtype.name in ["boolean", "category", "object"]: if d_axis[v["name"]].dtype.name in ["boolean", "category", "object"]:
key_idx = np.in1d(getattr(d_axis, v["name"]), v["values"]) key_idx = np.in1d(getattr(d_axis, v["name"]), v["values"])
@@ -214,28 +189,24 @@ class ScanpyEngine(CXGDriver):
@staticmethod @staticmethod
def _index_filter_to_mask(filter, count): def _index_filter_to_mask(filter, count):
mask = np.zeros((count,), dtype=bool) mask = np.zeros((count, ), dtype=bool)
for i in filter: for i in filter:
if type(i) == list: if type(i) == list:
mask[i[0] : i[1]] = True mask[i[0]:i[1]] = True
else: else:
mask[i] = True mask[i] = True
return mask return mask
@staticmethod @staticmethod
def _axis_filter_to_mask(filter, d_axis, count): def _axis_filter_to_mask(filter, d_axis, count):
mask = np.ones((count,), dtype=bool) mask = np.ones((count, ), dtype=bool)
if "index" in filter: if "index" in filter:
mask = np.logical_and( mask = np.logical_and(mask, ScanpyEngine._index_filter_to_mask(filter["index"], count))
mask, ScanpyEngine._index_filter_to_mask(filter["index"], count)
)
if "annotation_value" in filter: if "annotation_value" in filter:
mask = np.logical_and( mask = np.logical_and(mask,
mask, ScanpyEngine._annotation_filter_to_mask(filter["annotation_value"],
ScanpyEngine._annotation_filter_to_mask( d_axis,
filter["annotation_value"], d_axis, count count))
),
)
return mask return mask
def _filter_to_mask(self, filter, use_slices=True): def _filter_to_mask(self, filter, use_slices=True):
@@ -248,87 +219,142 @@ class ScanpyEngine(CXGDriver):
if filter is not None: if filter is not None:
if Axis.OBS in filter: if Axis.OBS in filter:
obs_selector = self._axis_filter_to_mask( obs_selector = self._axis_filter_to_mask(filter["obs"], self.data.obs, self.data.n_obs)
filter["obs"], self.data.obs, self.data.n_obs
)
if Axis.VAR in filter: if Axis.VAR in filter:
var_selector = self._axis_filter_to_mask( var_selector = self._axis_filter_to_mask(filter["var"], self.data.var, self.data.n_vars)
filter["var"], self.data.var, self.data.n_vars
)
return obs_selector, var_selector return obs_selector, var_selector
def annotation_to_fbs_matrix(self, axis, fields=None): @staticmethod
if axis == Axis.OBS: def _slice(data, obs_selector=None, vars_selector=None):
df = self.data.obs
else:
df = self.data.var
if fields is not None and len(fields) > 0:
df = df[fields]
return encode_matrix_fbs(df, col_idx=df.columns)
def data_frame_to_fbs_matrix(self, filter, axis):
""" """
Retrieves data 'X' and returns in a flatbuffer Matrix. Slice date using any selector that the AnnData object
supprots for slicing. If selector is None, will not slice
on that axis.
This method exists to optimize filtering/slicing sparse data that has
access patterns which impact slicing performance.
https://docs.scipy.org/doc/scipy/reference/sparse.html
"""
prefer_row_access = sparse.isspmatrix_csr(data._X) or sparse.isspmatrix_lil(data._X) \
or sparse.isspmatrix_bsr(data._X)
if prefer_row_access:
# Row-major slicing
if obs_selector is not None:
data = data[obs_selector, :]
if vars_selector is not None:
data = data[:, vars_selector]
else:
# Col-major slicing
if vars_selector is not None:
data = data[:, vars_selector]
if obs_selector is not None:
data = data[obs_selector, :]
return data
def annotation(self, filter, axis, fields=None):
"""
Gets annotation value for each observation
:param filter: filter: dictionary with filter params :param filter: filter: dictionary with filter params
:param axis: string obs or var :param axis: string obs or var
:return: flatbuffer Matrix :param fields: list of keys for annotation to return, returns all annotation values if not set.
:return: dict: names - list of fields in order, data - list of lists or metadata
Caveats: [observation ids, val1, val2...]
* currently only supports access on VAR axis
* currently only supports filtering on VAR axis
""" """
if axis != Axis.VAR:
raise ValueError("Only VAR dimension access is supported")
try: try:
obs_selector, var_selector = self._filter_to_mask(filter, use_slices=False) obs_selector, var_selector = self._filter_to_mask(filter)
except (KeyError, IndexError, TypeError) as e: except (KeyError, IndexError) as e:
raise FilterError(f"Error parsing filter: {e}") from e raise FilterError(f"Error parsing filter: {e}") from e
if obs_selector is not None: if axis == Axis.OBS:
raise FilterError("filtering on obs unsupported") obs = self.data.obs[obs_selector]
if not fields:
fields = obs.columns.tolist()
result = {
"names": fields,
"data": DataFrame(obs[fields]).to_records(index=True).tolist()
}
else:
var = self.data.var[var_selector]
if not fields:
fields = var.columns.tolist()
result = {
"names": fields,
"data": DataFrame(var[fields]).to_records(index=True).tolist()
}
return result
# Currently only handles VAR dimension def data_frame(self, filter, axis):
X = self.data._X """
if var_selector is not None: Retrieves data for each variable for observations in data frame
X = X[:, var_selector] :param filter: filter: dictionary with filter params
return encode_matrix_fbs(X, col_idx=np.nonzero(var_selector)[0], row_idx=None) :param axis: string obs or var
:return: {
"var": list of variable ids,
"obs": [cellid, var1 expression, var2 expression, ...],
}
"""
try:
obs_selector, var_selector = self._filter_to_mask(filter)
except (KeyError, IndexError) as e:
raise FilterError(f"Error parsing filter: {e}") from e
_X = self.data._X[obs_selector, var_selector]
if sparse.issparse(_X):
_X = _X.toarray()
var_index_sliced = self.data.var.index[var_selector]
obs_index_sliced = self.data.obs.index[obs_selector]
if axis == Axis.OBS:
result = {
"var": var_index_sliced.tolist(),
"obs": DataFrame(_X, index=obs_index_sliced).to_records(index=True).tolist()
}
else:
result = {
"obs": obs_index_sliced.tolist(),
"var": DataFrame(_X.T, index=var_index_sliced).to_records(index=True).tolist()
}
return result
def diffexp_topN(self, obsFilterA, obsFilterB, top_n=None, interactive_limit=None): def diffexp_topN(self, obsFilterA, obsFilterB, top_n=None, interactive_limit=None):
if Axis.VAR in obsFilterA or Axis.VAR in obsFilterB: if Axis.VAR in obsFilterA or Axis.VAR in obsFilterB:
raise FilterError("Observation filters may not contain vaiable conditions") raise FilterError("Observation filters may not contain vaiable conditions")
try: try:
obs_mask_A = self._axis_filter_to_mask( obs_mask_A = self._axis_filter_to_mask(obsFilterA["obs"], self.data.obs, self.data.n_obs)
obsFilterA["obs"], self.data.obs, self.data.n_obs obs_mask_B = self._axis_filter_to_mask(obsFilterB["obs"], self.data.obs, self.data.n_obs)
)
obs_mask_B = self._axis_filter_to_mask(
obsFilterB["obs"], self.data.obs, self.data.n_obs
)
except (KeyError, IndexError) as e: except (KeyError, IndexError) as e:
raise FilterError(f"Error parsing filter: {e}") from e raise FilterError(f"Error parsing filter: {e}") from e
if top_n is None: if top_n is None:
top_n = DEFAULT_TOP_N top_n = DEFAULT_TOP_N
result = diffexp_ttest( result = diffexp_ttest(self.data, obs_mask_A, obs_mask_B, top_n)
self.data, obs_mask_A, obs_mask_B, top_n, self.diffexp_lfc_cutoff return sorted(result, key=lambda r: r[0])
)
try:
return jsonify_scanpy(result)
except ValueError:
raise JSONEncodingValueError(
"Error encoding differential expression to JSON"
)
def layout_to_fbs_matrix(self): def layout(self, filter, interactive_limit=None):
""" """
Return the default 2-D layout for cells as a FBS Matrix. Computes a n-d layout for cells through dimensionality reduction.
:param filter: filter: dictionary with filter params
Caveats: :param interactive_limit: -- don't compute if total # genes in dataframes are larger than this
* does not support filtering :return: [cellid, x, y, ...]
* only returns Matrix in columnar layout
""" """
try: try:
df_layout = self.data.obsm[f"X_{self.layout_method}"] df = self.filter_dataframe(filter)
except (KeyError, IndexError) as e:
raise FilterError(f"Error parsing filter: {e}") from e
if interactive_limit and len(df.obs.index) > interactive_limit:
raise InteractiveError("Size data is too large for interactive computation")
# TODO Filtering cells is fine, but filtering genes does nothing because the neighbors are
# calculated using the original vars (geneset) and this doesn’t get updated when you use less.
# Need to recalculate neighbors (long) if user requests new layout filtered by var
# TODO for MVP we are pushing computation of layout to preprocessing and not allowing re-layout
# this will probably change after user feedback
# getattr(sc.tl, self.layout_method)(df, random_state=123)
try:
df_layout = df.obsm[f"X_{self.layout_method}"]
except ValueError as e: except ValueError as e:
raise PrepareError( raise PrepareError(f"Layout has not been calculated using {self.layout_method}, "
f"Layout has not been calculated using {self.layout_method}, " f"please prepare your datafile and relaunch cellxgene") from e
f"please prepare your datafile and relaunch cellxgene") from e normalized_layout = DataFrame((df_layout - df_layout.min()) / (df_layout.max() - df_layout.min()),
normalized_layout = (df_layout - df_layout.min()) / (df_layout.max() - df_layout.min()) index=df.obs.index)
return encode_matrix_fbs(normalized_layout.astype(dtype=np.float32), col_idx=None, row_idx=None) return {
"ndims": normalized_layout.shape[1],
"coordinates": normalized_layout.to_records(index=True).tolist()
}
-5
View File
@@ -25,8 +25,3 @@ class Axis(AugmentedEnum):
class DiffExpMode(AugmentedEnum): class DiffExpMode(AugmentedEnum):
TOP_N = "topN" TOP_N = "topN"
VAR_FILTER = "varFilter" VAR_FILTER = "varFilter"
JSON_NaN_to_num_warning_msg = (
"JSON encoding failure - please verify all data are finite values (no NaN or Infinities)"
)
-9
View File
@@ -16,15 +16,6 @@ class InteractiveError(Exception):
self.message = message self.message = message
class JSONEncodingValueError(Exception):
"""
Raised when file loaded into scanpy is misformatted
"""
def __init__(self, message):
self.message = message
class MimeTypeError(Exception): class MimeTypeError(Exception):
""" """
Raised when incompatible MIME type selected Raised when incompatible MIME type selected
-41
View File
@@ -1,41 +0,0 @@
# automatically generated by the FlatBuffers compiler, do not modify
# namespace: NetEncoding
import flatbuffers
class Column(object):
__slots__ = ['_tab']
@classmethod
def GetRootAsColumn(cls, buf, offset):
n = flatbuffers.encode.Get(flatbuffers.packer.uoffset, buf, offset)
x = Column()
x.Init(buf, n + offset)
return x
# Column
def Init(self, buf, pos):
self._tab = flatbuffers.table.Table(buf, pos)
# Column
def UType(self):
o = flatbuffers.number_types.UOffsetTFlags.py_type(self._tab.Offset(4))
if o != 0:
return self._tab.Get(flatbuffers.number_types.Uint8Flags, o + self._tab.Pos)
return 0
# Column
def U(self):
o = flatbuffers.number_types.UOffsetTFlags.py_type(self._tab.Offset(6))
if o != 0:
from flatbuffers.table import Table
obj = Table(bytearray(), 0)
self._tab.Union(obj, o)
return obj
return None
def ColumnStart(builder): builder.StartObject(2)
def ColumnAddUType(builder, uType): builder.PrependUint8Slot(0, uType, 0)
def ColumnAddU(builder, u): builder.PrependUOffsetTRelativeSlot(1, flatbuffers.number_types.UOffsetTFlags.py_type(u), 0)
def ColumnEnd(builder): return builder.EndObject()
@@ -1,46 +0,0 @@
# automatically generated by the FlatBuffers compiler, do not modify
# namespace: NetEncoding
import flatbuffers
class Float32Array(object):
__slots__ = ['_tab']
@classmethod
def GetRootAsFloat32Array(cls, buf, offset):
n = flatbuffers.encode.Get(flatbuffers.packer.uoffset, buf, offset)
x = Float32Array()
x.Init(buf, n + offset)
return x
# Float32Array
def Init(self, buf, pos):
self._tab = flatbuffers.table.Table(buf, pos)
# Float32Array
def Data(self, j):
o = flatbuffers.number_types.UOffsetTFlags.py_type(self._tab.Offset(4))
if o != 0:
a = self._tab.Vector(o)
return self._tab.Get(flatbuffers.number_types.Float32Flags, a + flatbuffers.number_types.UOffsetTFlags.py_type(j * 4))
return 0
# Float32Array
def DataAsNumpy(self):
o = flatbuffers.number_types.UOffsetTFlags.py_type(self._tab.Offset(4))
if o != 0:
return self._tab.GetVectorAsNumpy(flatbuffers.number_types.Float32Flags, o)
return 0
# Float32Array
def DataLength(self):
o = flatbuffers.number_types.UOffsetTFlags.py_type(self._tab.Offset(4))
if o != 0:
return self._tab.VectorLen(o)
return 0
def Float32ArrayStart(builder): builder.StartObject(1)
def Float32ArrayAddData(builder, data): builder.PrependUOffsetTRelativeSlot(0, flatbuffers.number_types.UOffsetTFlags.py_type(data), 0)
def Float32ArrayStartDataVector(builder, numElems): return builder.StartVector(4, numElems, 4)
def Float32ArrayEnd(builder): return builder.EndObject()
@@ -1,46 +0,0 @@
# automatically generated by the FlatBuffers compiler, do not modify
# namespace: NetEncoding
import flatbuffers
class Float64Array(object):
__slots__ = ['_tab']
@classmethod
def GetRootAsFloat64Array(cls, buf, offset):
n = flatbuffers.encode.Get(flatbuffers.packer.uoffset, buf, offset)
x = Float64Array()
x.Init(buf, n + offset)
return x
# Float64Array
def Init(self, buf, pos):
self._tab = flatbuffers.table.Table(buf, pos)
# Float64Array
def Data(self, j):
o = flatbuffers.number_types.UOffsetTFlags.py_type(self._tab.Offset(4))
if o != 0:
a = self._tab.Vector(o)
return self._tab.Get(flatbuffers.number_types.Float64Flags, a + flatbuffers.number_types.UOffsetTFlags.py_type(j * 8))
return 0
# Float64Array
def DataAsNumpy(self):
o = flatbuffers.number_types.UOffsetTFlags.py_type(self._tab.Offset(4))
if o != 0:
return self._tab.GetVectorAsNumpy(flatbuffers.number_types.Float64Flags, o)
return 0
# Float64Array
def DataLength(self):
o = flatbuffers.number_types.UOffsetTFlags.py_type(self._tab.Offset(4))
if o != 0:
return self._tab.VectorLen(o)
return 0
def Float64ArrayStart(builder): builder.StartObject(1)
def Float64ArrayAddData(builder, data): builder.PrependUOffsetTRelativeSlot(0, flatbuffers.number_types.UOffsetTFlags.py_type(data), 0)
def Float64ArrayStartDataVector(builder, numElems): return builder.StartVector(8, numElems, 8)
def Float64ArrayEnd(builder): return builder.EndObject()
@@ -1,46 +0,0 @@
# automatically generated by the FlatBuffers compiler, do not modify
# namespace: NetEncoding
import flatbuffers
class Int32Array(object):
__slots__ = ['_tab']
@classmethod
def GetRootAsInt32Array(cls, buf, offset):
n = flatbuffers.encode.Get(flatbuffers.packer.uoffset, buf, offset)
x = Int32Array()
x.Init(buf, n + offset)
return x
# Int32Array
def Init(self, buf, pos):
self._tab = flatbuffers.table.Table(buf, pos)
# Int32Array
def Data(self, j):
o = flatbuffers.number_types.UOffsetTFlags.py_type(self._tab.Offset(4))
if o != 0:
a = self._tab.Vector(o)
return self._tab.Get(flatbuffers.number_types.Int32Flags, a + flatbuffers.number_types.UOffsetTFlags.py_type(j * 4))
return 0
# Int32Array
def DataAsNumpy(self):
o = flatbuffers.number_types.UOffsetTFlags.py_type(self._tab.Offset(4))
if o != 0:
return self._tab.GetVectorAsNumpy(flatbuffers.number_types.Int32Flags, o)
return 0
# Int32Array
def DataLength(self):
o = flatbuffers.number_types.UOffsetTFlags.py_type(self._tab.Offset(4))
if o != 0:
return self._tab.VectorLen(o)
return 0
def Int32ArrayStart(builder): builder.StartObject(1)
def Int32ArrayAddData(builder, data): builder.PrependUOffsetTRelativeSlot(0, flatbuffers.number_types.UOffsetTFlags.py_type(data), 0)
def Int32ArrayStartDataVector(builder, numElems): return builder.StartVector(4, numElems, 4)
def Int32ArrayEnd(builder): return builder.EndObject()
@@ -1,46 +0,0 @@
# automatically generated by the FlatBuffers compiler, do not modify
# namespace: NetEncoding
import flatbuffers
class JSONEncodedArray(object):
__slots__ = ['_tab']
@classmethod
def GetRootAsJSONEncodedArray(cls, buf, offset):
n = flatbuffers.encode.Get(flatbuffers.packer.uoffset, buf, offset)
x = JSONEncodedArray()
x.Init(buf, n + offset)
return x
# JSONEncodedArray
def Init(self, buf, pos):
self._tab = flatbuffers.table.Table(buf, pos)
# JSONEncodedArray
def Data(self, j):
o = flatbuffers.number_types.UOffsetTFlags.py_type(self._tab.Offset(4))
if o != 0:
a = self._tab.Vector(o)
return self._tab.Get(flatbuffers.number_types.Uint8Flags, a + flatbuffers.number_types.UOffsetTFlags.py_type(j * 1))
return 0
# JSONEncodedArray
def DataAsNumpy(self):
o = flatbuffers.number_types.UOffsetTFlags.py_type(self._tab.Offset(4))
if o != 0:
return self._tab.GetVectorAsNumpy(flatbuffers.number_types.Uint8Flags, o)
return 0
# JSONEncodedArray
def DataLength(self):
o = flatbuffers.number_types.UOffsetTFlags.py_type(self._tab.Offset(4))
if o != 0:
return self._tab.VectorLen(o)
return 0
def JSONEncodedArrayStart(builder): builder.StartObject(1)
def JSONEncodedArrayAddData(builder, data): builder.PrependUOffsetTRelativeSlot(0, flatbuffers.number_types.UOffsetTFlags.py_type(data), 0)
def JSONEncodedArrayStartDataVector(builder, numElems): return builder.StartVector(1, numElems, 1)
def JSONEncodedArrayEnd(builder): return builder.EndObject()

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