mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-09-26 07:18:11 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2612707bb | ||
|
|
138d30909a | ||
|
|
2b5094665f | ||
|
|
a81258bc0d | ||
|
|
141f802824 | ||
|
|
3475f3f12e | ||
|
|
dff2526077 | ||
|
|
2b4aa92f67 | ||
|
|
3151306d7e | ||
|
|
ef80c8df2a | ||
|
|
1a5f49239a | ||
|
|
dfc04d8de5 |
+1
-2
@@ -1,6 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 0.0.2
|
||||
|
||||
current_version = 0.2.1
|
||||
|
||||
[bumpversion:file:setup.py]
|
||||
search = version="{current_version}"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
recursive-include server/app/web/templates *
|
||||
recursive-include server/app/web/static *
|
||||
|
||||
include server/requirements.txt
|
||||
@@ -1,111 +1,228 @@
|
||||
# cellxgene
|
||||
|
||||
### An interactive, performant explorer for single cell transcriptomics data.
|
||||
> an interactive explorer for single-cell transcriptomics data
|
||||
|
||||
<img align="right" width="350" height="218" src="./example-dataset/cellxgene-demo.gif" pad="50px">
|
||||
cellxgene is an open-source experiment in how to bring powerful tools from modern web development to visualize and explore large single-cell transcriptomics datasets.
|
||||
Started in the context of the Human Cell Atlas Consortium, cellxgene hopes to both enable scientists to explore their data and to equip developers with scalable, reusable patterns and frameworks for visualizing large scientific datasets.
|
||||
`cellxgene` is an interactive data explorer for single-cell transcriptomics datasets, such as those coming from the [Human Cell Atlas](https://humancellatlas.org). Leveraging modern web development techniques to enable fast visualizations of at least 1 million cells, we hope to enable biologists and computational researchers to explore their data, and to demonstrate general, scalable, and reusable patterns for scientific data visualization.
|
||||
|
||||
## Features
|
||||
<img src="https://github.com/chanzuckerberg/cellxgene/blob/master/docs/cellxgene-demo-1.gif" width="200" height="200" hspace="30"><img src="https://github.com/chanzuckerberg/cellxgene/blob/master/docs/cellxgene-demo-2.gif" width="200" height="200" hspace="30"><img src="https://github.com/chanzuckerberg/cellxgene/blob/master/docs/cellxgene-demo-3.gif" width="200" height="200" hspace="30">
|
||||
|
||||
- **Visualization at scale:** built with [WebGL](https://www.khronos.org/webgl/), [React](https://reactjs.org/) & [Redux](https://redux.js.org/) to handle visualization of at least 1 million cells.
|
||||
## getting started
|
||||
|
||||
- **Interactive exploration:** select, cross-filter, and compare subsets of your data with performant indexing and data handling.
|
||||
You'll need **python 3.6** and **Google Chrome**. The web UI is tested on OSX and Windows using Chrome, and the python CLI is tested on OSX and Ubuntu (via WSL/Windows). It should work on other platforms, but if you run into trouble let us know (see [help](#help-and-contact) below).
|
||||
|
||||
- **Flexible API:** the cellxgene client-server model is designed to support a range of existing analysis packages for backend computational tasks (eg scanpy), integrated with client-side visualization via a [REST API](https://restfulapi.net/).
|
||||
To install run
|
||||
|
||||
## Getting Started
|
||||
```
|
||||
pip install cellxgene
|
||||
```
|
||||
|
||||
**Requirements**
|
||||
To start exploring a dataset call
|
||||
|
||||
```
|
||||
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://github.com/chanzuckerberg/cellxgene/blob/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.
|
||||
|
||||
Depending on the options chosen, `prepare` can take a long time to run (a few minutes for datasets with 10-100k cells, up to an hour or more for datasets with >100k cells). If you want `prepare` to run faster we recommend using the `sparse` option and only computing the layout for `umap`, using a call like this
|
||||
|
||||
```
|
||||
cellxgene prepare dataset.h5ad --output=dataset-processed.h5ad --layout=umap --sparse
|
||||
```
|
||||
|
||||
To see all options call
|
||||
|
||||
```
|
||||
cellxgene prepare --help
|
||||
```
|
||||
|
||||
**Note**: `cellxgene prepare` will only perform `louvain` clustering if you have the `python-igraph` and `louvain` packages installed. To make sure they are installed alongside `cellxgene` use
|
||||
|
||||
```
|
||||
pip install cellxgene[louvain]
|
||||
```
|
||||
|
||||
## conda and virtual environments
|
||||
|
||||
If you use conda and want to create a conda environment for `cellxgene` you can use the following commands
|
||||
|
||||
```
|
||||
conda create --yes -n cellxgene python=3.6
|
||||
conda activate cellxgene
|
||||
pip install cellxgene
|
||||
```
|
||||
|
||||
Or you can create a virtual environment by using
|
||||
|
||||
```
|
||||
ENV_NAME=cellxgene
|
||||
python3 -m venv ${ENV_NAME}
|
||||
source ${ENV_NAME}/bin/activate
|
||||
pip install cellxgene
|
||||
```
|
||||
|
||||
## FAQ
|
||||
|
||||
> Someone sent me a directory of `10X-Genomics` data with a `mtx` file and I've never used `scanpy`, can I use `cellxgene`?
|
||||
|
||||
Yep! This should only take a couple steps. We'll assume your data is in a folder called `data/` and you've successfully installed `cellxgene` with the `louvain` packages as described above. Just run
|
||||
|
||||
```
|
||||
cellxgene prepare data/ --output=data-processed.h5ad --layout=umap
|
||||
```
|
||||
|
||||
Depending on the size of the dataset, this may take some time. Once it's done, call
|
||||
|
||||
```
|
||||
cellxgene launch data-processed.h5ad --layout=umap --open
|
||||
```
|
||||
|
||||
And your web browser should open with an interactive view of your data.
|
||||
|
||||
> In my `prepare` command I received the following error `Warning: louvain module is not installed, no clusters will be calculated. To fix this please install cellxgene with the optional feature louvain enabled`
|
||||
|
||||
Louvain clustering requires additional dependencies that are somewhat complex, so we don't include them by default. For now, you need to specify that you want these packages by using
|
||||
|
||||
```
|
||||
pip install cellxgene[louvain]
|
||||
```
|
||||
|
||||
> I ran `prepare` and I'm getting results that look unexpected
|
||||
|
||||
You might want to try running one of the preprocessing recipes included with `scanpy` (read more about them [here](https://scanpy.readthedocs.io/en/latest/api/index.html#recipes)). You can specify this with the `--recipe` option, such as
|
||||
|
||||
```
|
||||
cellxgene prepare data/ --output=data-processed.h5ad --recipe=zheng17
|
||||
```
|
||||
|
||||
It should be easy to run `prepare` then call `cellxgene launch` a few times with different settings to explore different behaviors. We may explore adding other preprocessing options in the future.
|
||||
|
||||
> I have extra metadata that I want to add to my dataset
|
||||
|
||||
Currently this is not supported directly, but you should be able to do this manually using `scanpy`. For example, this [notebook](https://github.com/falexwolf/fun-analyses/blob/master/tabula_muris/tabula_muris.ipynb) shows adding the contents of a `csv` file with metadata to an `anndata` object. For now, you could do this manually on your data in the same way and then save out the result before loading into `cellxgene`.
|
||||
|
||||
> I tried to `pip install cellxgene` and got a weird error I don't understand
|
||||
|
||||
This may happen, especially as we work out bugs in our installation process! Please create a new [Github issue](https://github.com/chanzuckerberg/cellxgene/issues), explain what you did, and include all the error messages you saw. It'd also be super helpful if you call `pip freeze` and include the full output alongside your issue.
|
||||
|
||||
> How are you computing and sorting differential expression results?
|
||||
|
||||
Currently we use a [Welch's *t*-test](https://en.wikipedia.org/wiki/Welch%27s_t-test) implementation including the same variance overestimation correction as used in `scanpy`. We sort the `tscore` to identify the top N genes, and then filter to remove any that fall below a cutoff log fold change value, which can help remove spurious test results. The default threshold is `0.01` and can be changed using the option `--diffexp-lfc-cutoff`. We can explore adding support for other test types in the future.
|
||||
|
||||
> I'm following the developer instructions and get an error about "missing files and directories” when trying to build the client
|
||||
|
||||
This is likely because you do not have node and npm installed, we recommend using [nvm](https://github.com/creationix/nvm) if you're new to using these tools.
|
||||
|
||||
## developer guide
|
||||
|
||||
This project has made a few key design choices
|
||||
|
||||
- The front-end is built with [`regl`](https://github.com/regl-project/regl) (a webgl library), [`react`](https://reactjs.org/), [`redux`](https://redux.js.org/), [`d3`](https://github.com/d3/d3), and [`blueprint`](https://blueprintjs.com/docs/#core) to handle rendering large numbers of cells with lots of complex interactivity
|
||||
- The app is designed with a client-server model that can support a range of existing analysis packages for backend computational tasks (currently built for [scanpy](https://github.com/theislab/scanpy))
|
||||
- The client uses fast cross-filtering to handle selections and comparisons across subsets of data
|
||||
|
||||
Depending on your background and interests, you might want to contribute to the frontend, or backend, or both!
|
||||
|
||||
If you are interested in working on `cellxgene` development, we recommend cloning the project from Gitub. First you'll need the following installed on your machine
|
||||
|
||||
- OS: OSX, Windows, Linux -- the developers are currently testing on OSX and Windows (via WSL using Ubuntu). It should work on other platforms but if you are using something different and need help, please let us know.
|
||||
- python 3.6
|
||||
- python3 tkinter
|
||||
- npm
|
||||
- Google Chrome
|
||||
- node and npm (we recommend using [nvm](https://github.com/creationix/nvm) if this is your first time with node)
|
||||
|
||||
**Clone project**
|
||||
Then clone the project
|
||||
|
||||
git clone https://github.com/chanzuckerberg/cellxgene.git
|
||||
```
|
||||
git clone https://github.com/chanzuckerberg/cellxgene.git
|
||||
```
|
||||
|
||||
**Install client**
|
||||
Build the client web assets by calling this from inside the `cellxgene` folder
|
||||
|
||||
cd cellxgene
|
||||
./bin/build-client
|
||||
```
|
||||
./bin/build-client
|
||||
```
|
||||
|
||||
**To use with virtual env for python**
|
||||
(optional, but recommended)
|
||||
Install all requirements (we recommend doing this inside a virtual environment)
|
||||
|
||||
ENV_NAME=cellxgene
|
||||
python3 -m venv ${ENV_NAME}
|
||||
source ${ENV_NAME}/bin/activate
|
||||
```
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
**Install server**
|
||||
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.
|
||||
|
||||
pip install -e .
|
||||
If you have any questions about developing or contributing, come hang out with us by joining the [CZI Science Slack](https://cziscience.slack.com/messages/CCTA8DF1T) and posting in the `#cellxgene-dev` channel.
|
||||
|
||||
**Run (with demo data)**
|
||||
## development roadmap
|
||||
|
||||
cellxgene launch --title PBMC3K example-dataset/pbmc3k.h5ad
|
||||
`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)
|
||||
|
||||
**Help**
|
||||
- **Visualizaling spatial metadata** Image-based transcriptomics methods also generate large cell by gene matrices, alongside rich metadata about spatial location; we would like to render this information in `cellxgene`
|
||||
- **Visualizing trajectories** Trajectory analyses infer progression along some ordering or pseudotime; we would like `cellxgene ` to render the results of these analyses when they have been performed
|
||||
- **Deploy to web** Many projects release public data browser websites alongside their publicatons; we would like to make it easy for anyone to deploy `cellxgene` to a custom URL with their own dataset that they own and operate
|
||||
- **HCA Integration** The [Human Cell Atlas](https://humancellatlas.org) is generating a large corpus of single-cell expression data and will make it available through the Data Coordination Platform; we would like `cellxgene` to be one of several different portals for browsing these data
|
||||
|
||||
cellxgene --help
|
||||
## contributing
|
||||
|
||||
_For help with the scanpy engine_
|
||||
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!
|
||||
|
||||
cellxgene scanpy --help
|
||||
## inspiration and collaboration
|
||||
|
||||
## Using your own data
|
||||
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.
|
||||
|
||||
### Scanpy
|
||||
We were inspired by Mike Bostock and the [crossfilter](https://github.com/crossfilter) team for the design of our filtering implementation.
|
||||
|
||||
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 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.
|
||||
|
||||
1. [Load data into scanpy](https://scanpy.readthedocs.io/en/latest/api/index.html#reading)
|
||||
We are eager to explore integrations with other computational backends such as [`Seurat`](https://github.com/satijalab/seurat) or [`Bioconductor`](https://github.com/Bioconductor)
|
||||
|
||||
- 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()`.
|
||||
## help and contact
|
||||
|
||||
2. Calculate PCA
|
||||
Have questions, suggestions, or comments? You can come hang out with us by joining the [CZI Science Slack](https://cziscience.slack.com/messages/CCTA8DF1T) and posting in the `#cellxgene-users` channel. As mentioned above, please submit any feature requests or bugs as [Github issues](https://github.com/chanzuckerberg/cellxgene/issues). We'd love to hear from you!
|
||||
|
||||
sc.pp.pca(data) ## sc is scanpy.api
|
||||
## reuse
|
||||
|
||||
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.
|
||||
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).
|
||||
|
||||
@@ -7,6 +7,8 @@ echo "removing node_modules"
|
||||
rm -rf $CELLXGENE_DIR/client/node_modules
|
||||
echo "removing client_build"
|
||||
rm -rf $CELLXGENE_DIR/client/build
|
||||
echo "removing dist"
|
||||
rm -rf $CELLXGENE_DIR/dist
|
||||
echo "removing egg-info"
|
||||
rm -rf $CELLXGENE_DIR/cellxgene.egg-info
|
||||
echo "removing static files"
|
||||
|
||||
@@ -34,7 +34,8 @@ module.exports = {
|
||||
"object-curly-newline": ["error", { consistent: true }],
|
||||
"react/prop-types": [0],
|
||||
"space-before-function-paren": "off",
|
||||
"function-paren-newline": "off"
|
||||
"function-paren-newline": "off",
|
||||
"prefer-destructuring": ["error", { object: true, array: false }]
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
|
||||
Generated
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cellxgene",
|
||||
"version": "0.0.2",
|
||||
"version": "0.2.1",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cellxgene",
|
||||
"version": "0.0.2",
|
||||
"version": "0.2.1",
|
||||
"license": "MIT",
|
||||
"description": "cellxgene is a web application for the interactive exploration of single cell sequence data.",
|
||||
"repository": "https://github.com/chanzuckerberg/cellxgene",
|
||||
|
||||
@@ -5,34 +5,13 @@ import { connect } from "react-redux";
|
||||
import * as globals from "../../globals";
|
||||
import Category from "./category";
|
||||
|
||||
/* Cap the max number of displayed categories */
|
||||
const truncateCategories = options => {
|
||||
const numOptions = _.size(options);
|
||||
if (numOptions <= globals.maxCategoricalOptionsToDisplay) {
|
||||
return options;
|
||||
}
|
||||
return _(options)
|
||||
.map((v, k) => ({ name: k, val: v }))
|
||||
.sortBy("val")
|
||||
.slice(numOptions - globals.maxCategoricalOptionsToDisplay)
|
||||
.transform((r, v) => {
|
||||
r[v.name] = v.val;
|
||||
}, {})
|
||||
.value();
|
||||
};
|
||||
|
||||
@connect(state => ({
|
||||
ranges: _.get(state.controls.world, "summary.obs", null),
|
||||
categorySelectionLimit: _.get(
|
||||
state.config,
|
||||
"parameters.max-category-items",
|
||||
globals.configDefaults.parameters["max-category-items"]
|
||||
)
|
||||
categoricalSelectionState: state.controls.categoricalSelectionState
|
||||
}))
|
||||
class Categories extends React.Component {
|
||||
render() {
|
||||
const { ranges, categorySelectionLimit } = this.props;
|
||||
if (!ranges) return null;
|
||||
const { categoricalSelectionState } = this.props;
|
||||
if (!categoricalSelectionState) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -47,27 +26,9 @@ class Categories extends React.Component {
|
||||
>
|
||||
Categorical Metadata
|
||||
</p>
|
||||
{_.map(ranges, (value, key) => {
|
||||
const isColorField = key.includes("color") || key.includes("Color");
|
||||
const isSelectableCategory =
|
||||
value.options &&
|
||||
!isColorField &&
|
||||
key !== "name" &&
|
||||
value.numOptions < categorySelectionLimit;
|
||||
|
||||
if (isSelectableCategory) {
|
||||
const categoryOptions = truncateCategories(value.options);
|
||||
return (
|
||||
<Category
|
||||
key={key}
|
||||
metadataField={key}
|
||||
values={categoryOptions}
|
||||
isTruncated={categoryOptions !== value.options}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
})}
|
||||
{_.map(categoricalSelectionState, (catState, catName) => (
|
||||
<Category key={catName} metadataField={catName} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,29 +2,15 @@ import React from "react";
|
||||
import _ from "lodash";
|
||||
import { connect } from "react-redux";
|
||||
import { FaChevronRight, FaChevronDown } from "react-icons/fa";
|
||||
import memoize from "memoize-one";
|
||||
import { Button, Tooltip, Position } from "@blueprintjs/core";
|
||||
import { Button, Tooltip } from "@blueprintjs/core";
|
||||
|
||||
import * as globals from "../../globals";
|
||||
import Value from "./value";
|
||||
import alphabeticallySortedValues from "./util";
|
||||
|
||||
const countCategories = (values, optsAsBools) =>
|
||||
_.reduce(
|
||||
values,
|
||||
(r, v, k) => {
|
||||
r.total += 1;
|
||||
if (optsAsBools[k]) {
|
||||
r.on += 1;
|
||||
}
|
||||
return r;
|
||||
},
|
||||
{ total: 0, on: 0 }
|
||||
);
|
||||
|
||||
@connect(state => ({
|
||||
colorAccessor: state.controls.colorAccessor,
|
||||
categoricalAsBooleansMap: state.controls.categoricalAsBooleansMap
|
||||
categoricalSelectionState: state.controls.categoricalSelectionState
|
||||
}))
|
||||
class Category extends React.Component {
|
||||
constructor(props) {
|
||||
@@ -33,24 +19,30 @@ class Category extends React.Component {
|
||||
isChecked: true,
|
||||
isExpanded: false
|
||||
};
|
||||
this.countCategories = memoize((values, optsAsBools) =>
|
||||
countCategories(values, optsAsBools)
|
||||
);
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
const { categoricalAsBooleansMap, metadataField, values } = this.props;
|
||||
const categoryCount = this.countCategories(
|
||||
values,
|
||||
categoricalAsBooleansMap[metadataField]
|
||||
);
|
||||
if (categoryCount.on === categoryCount.total) {
|
||||
const { categoricalSelectionState, metadataField } = this.props;
|
||||
const cat = categoricalSelectionState[metadataField];
|
||||
const categoryCount = {
|
||||
// total number of options in this category
|
||||
totalOptionCount: cat.numOptions,
|
||||
// number of selected options in this category
|
||||
selectedOptionCount: _.reduce(
|
||||
cat.optionSelected,
|
||||
(res, cond) => (cond ? res + 1 : res),
|
||||
0
|
||||
)
|
||||
};
|
||||
if (categoryCount.selectedOptionCount === categoryCount.totalOptionCount) {
|
||||
/* everything is on, so not indeterminate */
|
||||
this.checkbox.indeterminate = false;
|
||||
} else if (categoryCount.on === 0) {
|
||||
} else if (categoryCount.selectedOptionCount === 0) {
|
||||
/* nothing is on, so no */
|
||||
this.checkbox.indeterminate = false;
|
||||
} else if (categoryCount.on < categoryCount.total) {
|
||||
} else if (
|
||||
categoryCount.selectedOptionCount < categoryCount.totalOptionCount
|
||||
) {
|
||||
/* to be explicit... */
|
||||
this.checkbox.indeterminate = true;
|
||||
}
|
||||
@@ -74,11 +66,10 @@ class Category extends React.Component {
|
||||
}
|
||||
|
||||
toggleNone() {
|
||||
const { dispatch, metadataField, value } = this.props;
|
||||
const { dispatch, metadataField } = this.props;
|
||||
dispatch({
|
||||
type: "categorical metadata filter none of these",
|
||||
metadataField,
|
||||
value
|
||||
metadataField
|
||||
});
|
||||
this.setState({ isChecked: false });
|
||||
}
|
||||
@@ -94,13 +85,15 @@ class Category extends React.Component {
|
||||
}
|
||||
|
||||
renderCategoryItems() {
|
||||
const { values, metadataField } = this.props;
|
||||
return _.map(alphabeticallySortedValues(values), (v, i) => (
|
||||
const { categoricalSelectionState, metadataField } = this.props;
|
||||
|
||||
const cat = categoricalSelectionState[metadataField];
|
||||
const optTuples = alphabeticallySortedValues([...cat.optionIndex]);
|
||||
return _.map(optTuples, (tuple, i) => (
|
||||
<Value
|
||||
key={v}
|
||||
key={tuple[1]}
|
||||
metadataField={metadataField}
|
||||
count={values[v]}
|
||||
value={v}
|
||||
optionIndex={tuple[1]}
|
||||
i={i}
|
||||
/>
|
||||
));
|
||||
@@ -108,12 +101,15 @@ class Category extends React.Component {
|
||||
|
||||
render() {
|
||||
const { isExpanded, isChecked } = this.state;
|
||||
const { metadataField, colorAccessor, isTruncated } = this.props;
|
||||
const {
|
||||
metadataField,
|
||||
colorAccessor,
|
||||
categoricalSelectionState
|
||||
} = this.props;
|
||||
const { isTruncated } = categoricalSelectionState[metadataField];
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
// display: "flex",
|
||||
// alignItems: "baseline",
|
||||
maxWidth: globals.maxControlsWidth
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
// jshint esversion: 6
|
||||
|
||||
// values is [ [optVal, optIdx], ...]
|
||||
// index is range array
|
||||
// return sorted index
|
||||
export default values =>
|
||||
Object.keys(values).sort((a, b) => {
|
||||
const textA = a.toUpperCase();
|
||||
const textB = b.toUpperCase();
|
||||
values.sort((a, b) => {
|
||||
const textA = String(a[0]).toUpperCase();
|
||||
const textB = String(b[0]).toUpperCase();
|
||||
return textA < textB ? -1 : textA > textB ? 1 : 0;
|
||||
});
|
||||
|
||||
@@ -1,47 +1,61 @@
|
||||
// jshint esversion: 6
|
||||
import { connect } from "react-redux";
|
||||
import React from "react";
|
||||
import _ from "lodash";
|
||||
|
||||
@connect(state => ({
|
||||
categoricalAsBooleansMap: state.controls.categoricalAsBooleansMap,
|
||||
categoricalSelectionState: state.controls.categoricalSelectionState,
|
||||
colorScale: state.controls.colorScale,
|
||||
colorAccessor: state.controls.colorAccessor
|
||||
colorAccessor: state.controls.colorAccessor,
|
||||
schema: _.get(state.controls.world, "schema", null)
|
||||
}))
|
||||
class CategoryValue extends React.Component {
|
||||
toggleOff() {
|
||||
const { dispatch, metadataField, value } = this.props;
|
||||
const { dispatch, metadataField, optionIndex } = this.props;
|
||||
dispatch({
|
||||
type: "categorical metadata filter deselect",
|
||||
metadataField,
|
||||
value
|
||||
optionIndex
|
||||
});
|
||||
}
|
||||
|
||||
toggleOn() {
|
||||
const { dispatch, metadataField, value } = this.props;
|
||||
const { dispatch, metadataField, optionIndex } = this.props;
|
||||
dispatch({
|
||||
type: "categorical metadata filter select",
|
||||
metadataField,
|
||||
value
|
||||
optionIndex
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
categoricalAsBooleansMap,
|
||||
categoricalSelectionState,
|
||||
metadataField,
|
||||
count,
|
||||
value,
|
||||
optionIndex,
|
||||
colorAccessor,
|
||||
colorScale,
|
||||
i
|
||||
i,
|
||||
schema
|
||||
} = this.props;
|
||||
|
||||
if (!categoricalAsBooleansMap) return null;
|
||||
if (!categoricalSelectionState) return null;
|
||||
|
||||
const category = categoricalSelectionState[metadataField];
|
||||
const selected = category.optionSelected[optionIndex];
|
||||
const count = category.optionCount[optionIndex];
|
||||
const value = category.optionValue[optionIndex];
|
||||
const displayString = String(category.optionValue[optionIndex]).valueOf();
|
||||
|
||||
const selected = categoricalAsBooleansMap[metadataField][value];
|
||||
/* this is the color scale, so add swatches below */
|
||||
const c = metadataField === colorAccessor;
|
||||
let categories = null;
|
||||
|
||||
if (c && schema) {
|
||||
categories = _.filter(schema.annotations.obs, {
|
||||
name: colorAccessor
|
||||
})[0].categories;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -68,7 +82,7 @@ class CategoryValue extends React.Component {
|
||||
type="checkbox"
|
||||
/>
|
||||
<span className="bp3-control-indicator" />
|
||||
{value}
|
||||
{displayString}
|
||||
</label>
|
||||
</div>
|
||||
<span>
|
||||
@@ -78,7 +92,10 @@ class CategoryValue extends React.Component {
|
||||
marginLeft: 5,
|
||||
width: 11,
|
||||
height: 11,
|
||||
backgroundColor: c ? colorScale(value) : "inherit"
|
||||
backgroundColor:
|
||||
c && categories
|
||||
? colorScale(categories.indexOf(value))
|
||||
: "inherit"
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import * as d3 from "d3";
|
||||
import { interpolateViridis } from "d3-scale-chromatic";
|
||||
import { interpolateViridis, interpolateCool } from "d3-scale-chromatic";
|
||||
|
||||
// create continuous color legend
|
||||
// http://bl.ocks.org/syntagmatic/e8ccca52559796be775553b467593a9f
|
||||
@@ -121,12 +121,12 @@ class ContinuousLegend extends React.Component {
|
||||
.remove();
|
||||
}
|
||||
|
||||
if (colorAccessor && colorScale) {
|
||||
if (colorAccessor && colorScale && colorScale.range) {
|
||||
/* fragile! continuous range is 0 to 1, not [#fa4b2c, ...], make this a flag? */
|
||||
if (colorScale.range()[0][0] !== "#") {
|
||||
continuous(
|
||||
"#continuous_legend",
|
||||
d3.scaleSequential(interpolateViridis).domain(colorScale.domain()),
|
||||
d3.scaleSequential(interpolateCool).domain(colorScale.domain()),
|
||||
colorAccessor
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
// jshint esversion: 6
|
||||
import _ from "lodash";
|
||||
import * as d3 from "d3";
|
||||
import { interpolateViridis } from "d3-scale-chromatic";
|
||||
import {
|
||||
interpolateViridis,
|
||||
interpolateSpectral,
|
||||
interpolateRainbow,
|
||||
interpolateBlues,
|
||||
interpolateCool
|
||||
} from "d3-scale-chromatic";
|
||||
import * as globals from "../globals";
|
||||
import parseRGB from "../util/parseRGB";
|
||||
|
||||
@@ -59,11 +65,17 @@ const updateCellColorsMiddleware = store => next => action => {
|
||||
*/
|
||||
|
||||
if (action.type === "color by categorical metadata") {
|
||||
colorScale = d3.scaleOrdinal().range(globals.ordinalColors);
|
||||
const categories = _.filter(s.controls.world.schema.annotations.obs, {
|
||||
name: action.colorAccessor
|
||||
})[0].categories;
|
||||
|
||||
colorScale = d3
|
||||
.scaleSequential(interpolateRainbow)
|
||||
.domain([0, categories.length]);
|
||||
|
||||
for (let i = 0; i < obsAnnotations.length; i += 1) {
|
||||
const obs = obsAnnotations[i];
|
||||
const c = colorScale(obs[action.colorAccessor]);
|
||||
const c = colorScale(categories.indexOf(obs[action.colorAccessor]));
|
||||
colorsByName[i] = c;
|
||||
colorsByRGB[i] = parseRGB(c);
|
||||
}
|
||||
@@ -77,7 +89,7 @@ const updateCellColorsMiddleware = store => next => action => {
|
||||
|
||||
for (let i = 0; i < obsAnnotations.length; i += 1) {
|
||||
const obs = obsAnnotations[i];
|
||||
const c = interpolateViridis(colorScale(obs[action.colorAccessor]));
|
||||
const c = interpolateCool(colorScale(obs[action.colorAccessor]));
|
||||
colorsByName[i] = c;
|
||||
colorsByRGB[i] = parseRGB(c);
|
||||
}
|
||||
@@ -95,7 +107,7 @@ const updateCellColorsMiddleware = store => next => action => {
|
||||
]); /* invert viridis... probably pass this scale through to others */
|
||||
|
||||
for (let i = 0, len = expression.length; i < len; i += 1) {
|
||||
const c = interpolateViridis(colorScale(expression[i]));
|
||||
const c = interpolateCool(colorScale(expression[i]));
|
||||
colorsByName[i] = c;
|
||||
colorsByRGB[i] = parseRGB(c);
|
||||
}
|
||||
|
||||
Vendored
+151
-55
@@ -12,34 +12,109 @@ import {
|
||||
diffexpDimensionName,
|
||||
makeContinuousDimensionName
|
||||
} from "../util/nameCreators";
|
||||
import { fillRange } from "../util/typedCrossfilter/util";
|
||||
|
||||
function createCategoricalAsBooleansMap(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
|
||||
optionIndex: Map([
|
||||
optval1: index,
|
||||
...
|
||||
])
|
||||
|
||||
// index->selection true/false state
|
||||
optionSelected: [ true/false, true/false, ... ]
|
||||
|
||||
// number of options
|
||||
numOptions: number,
|
||||
|
||||
// isTruncated - true if the options for selection has
|
||||
// been truncated (ie, was too large to implement)
|
||||
}
|
||||
}
|
||||
*/
|
||||
function topNoptions(summary) {
|
||||
const counts = _.map(summary.categories, cat => summary.options[cat]);
|
||||
const sortIndex = fillRange(new Array(summary.numOptions)).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)];
|
||||
}
|
||||
|
||||
function createCategoricalSelectionState(state, 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;
|
||||
_.forEach(world.summary.obs, (value, key) => {
|
||||
if (value.categories) {
|
||||
const isColorField = key.includes("color") || key.includes("Color");
|
||||
const isSelectableCategory =
|
||||
!isColorField &&
|
||||
key !== "name" &&
|
||||
value.categories.length < state.maxCategoryItems;
|
||||
if (isSelectableCategory) {
|
||||
const [optionValue, optionCount] = topNoptions(value);
|
||||
// const optionCount = Object.values(value.options);
|
||||
|
||||
const optionIndex = new Map(optionValue.map((v, i) => [v, i]));
|
||||
const numOptions = optionIndex.size;
|
||||
const optionSelected = new Array(numOptions).fill(true);
|
||||
const isTruncated = optionValue.length < value.numOptions;
|
||||
res[key] = {
|
||||
optionValue, // array: of natively typed option values
|
||||
optionIndex, // map: option value (native type) -> option index
|
||||
optionSelected, // array: t/f selection state
|
||||
numOptions, // number: of options
|
||||
isTruncated, // bool: true if list was truncated
|
||||
optionCount // array: cardinality of each option
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
/*
|
||||
given a categoricalSelectionState, return the list of all option values
|
||||
where selection state is true (ie, they are selected).
|
||||
*/
|
||||
function selectedValuesForCategory(categorySelectionState) {
|
||||
const selectedValues = _([...categorySelectionState.optionIndex])
|
||||
.filter(tuple => categorySelectionState.optionSelected[tuple[1]])
|
||||
.map(tuple => tuple[0])
|
||||
.value();
|
||||
return selectedValues;
|
||||
}
|
||||
|
||||
const Controls = (
|
||||
state = {
|
||||
// data loading flag
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
// configuration
|
||||
maxCategoryItems: globals.configDefaults.parameters["max-category-items"],
|
||||
|
||||
// the whole big bang
|
||||
universe: null,
|
||||
|
||||
// all of the data + selection state
|
||||
world: null,
|
||||
colorName: null,
|
||||
colorRGB: null,
|
||||
categoricalAsBooleansMap: null,
|
||||
categoricalSelectionState: null,
|
||||
crossfilter: null,
|
||||
dimensionMap: null,
|
||||
userDefinedGenes: [],
|
||||
@@ -72,6 +147,17 @@ const Controls = (
|
||||
Initialization, World/Universe management
|
||||
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": {
|
||||
return { ...state, loading: true };
|
||||
}
|
||||
@@ -83,7 +169,10 @@ const Controls = (
|
||||
const world = World.createWorldFromEntireUniverse(universe);
|
||||
const colorName = new Array(universe.nObs).fill(globals.defaultCellColor);
|
||||
const colorRGB = _.map(colorName, c => parseRGB(c));
|
||||
const categoricalAsBooleansMap = createCategoricalAsBooleansMap(world);
|
||||
const categoricalSelectionState = createCategoricalSelectionState(
|
||||
state,
|
||||
world
|
||||
);
|
||||
const crossfilter = Crossfilter(world.obsAnnotations);
|
||||
const dimensionMap = World.createObsDimensionMap(crossfilter, world);
|
||||
|
||||
@@ -135,7 +224,7 @@ const Controls = (
|
||||
world,
|
||||
colorName,
|
||||
colorRGB,
|
||||
categoricalAsBooleansMap,
|
||||
categoricalSelectionState,
|
||||
crossfilter,
|
||||
dimensionMap,
|
||||
colorAccessor: null
|
||||
@@ -152,7 +241,10 @@ const Controls = (
|
||||
);
|
||||
const colorName = new Array(world.nObs).fill(globals.defaultCellColor);
|
||||
const colorRGB = _.map(colorName, c => parseRGB(c));
|
||||
const categoricalAsBooleansMap = createCategoricalAsBooleansMap(world);
|
||||
const categoricalSelectionState = createCategoricalSelectionState(
|
||||
state,
|
||||
world
|
||||
);
|
||||
const crossfilter = Crossfilter(world.obsAnnotations);
|
||||
const dimensionMap = World.createObsDimensionMap(crossfilter, world);
|
||||
|
||||
@@ -197,7 +289,7 @@ const Controls = (
|
||||
world,
|
||||
colorName,
|
||||
colorRGB,
|
||||
categoricalAsBooleansMap,
|
||||
categoricalSelectionState,
|
||||
crossfilter,
|
||||
dimensionMap,
|
||||
colorAccessor: null
|
||||
@@ -422,83 +514,87 @@ const Controls = (
|
||||
Categorical metadata
|
||||
*******************************/
|
||||
case "categorical metadata filter select": {
|
||||
const newCategoricalAsBooleansMap = {
|
||||
...state.categoricalAsBooleansMap,
|
||||
const newOptionSelected = Array.from(
|
||||
state.categoricalSelectionState[action.metadataField].optionSelected
|
||||
);
|
||||
newOptionSelected[action.optionIndex] = true;
|
||||
const newCategoricalSelectionState = {
|
||||
...state.categoricalSelectionState,
|
||||
[action.metadataField]: {
|
||||
...state.categoricalAsBooleansMap[action.metadataField],
|
||||
[action.value]: true
|
||||
...state.categoricalSelectionState[action.metadataField],
|
||||
optionSelected: newOptionSelected
|
||||
}
|
||||
};
|
||||
// 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(
|
||||
_.filter(
|
||||
_.map(
|
||||
newCategoricalAsBooleansMap[action.metadataField],
|
||||
(val, key) => (val ? key : false)
|
||||
)
|
||||
)
|
||||
selectedValuesForCategory(cat)
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
categoricalAsBooleansMap: newCategoricalAsBooleansMap
|
||||
categoricalSelectionState: newCategoricalSelectionState
|
||||
};
|
||||
}
|
||||
case "categorical metadata filter deselect": {
|
||||
const newCategoricalAsBooleansMap = {
|
||||
...state.categoricalAsBooleansMap,
|
||||
const newOptionSelected = Array.from(
|
||||
state.categoricalSelectionState[action.metadataField].optionSelected
|
||||
);
|
||||
newOptionSelected[action.optionIndex] = false;
|
||||
const newCategoricalSelectionState = {
|
||||
...state.categoricalSelectionState,
|
||||
[action.metadataField]: {
|
||||
...state.categoricalAsBooleansMap[action.metadataField],
|
||||
[action.value]: false
|
||||
...state.categoricalSelectionState[action.metadataField],
|
||||
optionSelected: newOptionSelected
|
||||
}
|
||||
};
|
||||
// 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(
|
||||
_.filter(
|
||||
_.map(
|
||||
newCategoricalAsBooleansMap[action.metadataField],
|
||||
(val, key) => (val ? key : false)
|
||||
)
|
||||
)
|
||||
selectedValuesForCategory(cat)
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
categoricalAsBooleansMap: newCategoricalAsBooleansMap
|
||||
categoricalSelectionState: newCategoricalSelectionState
|
||||
};
|
||||
}
|
||||
case "categorical metadata filter none of these": {
|
||||
const newCategoricalAsBooleansMap = {
|
||||
...state.categoricalAsBooleansMap
|
||||
};
|
||||
_.forEach(
|
||||
newCategoricalAsBooleansMap[action.metadataField],
|
||||
(v, k, c) => {
|
||||
c[k] = false;
|
||||
const newCategoricalSelectionState = {
|
||||
...state.categoricalSelectionState,
|
||||
[action.metadataField]: {
|
||||
...state.categoricalSelectionState[action.metadataField],
|
||||
optionSelected: Array.from(
|
||||
state.categoricalSelectionState[action.metadataField].optionSelected
|
||||
).fill(false)
|
||||
}
|
||||
);
|
||||
};
|
||||
state.dimensionMap[
|
||||
obsAnnoDimensionName(action.metadataField)
|
||||
].filterNone();
|
||||
return {
|
||||
...state,
|
||||
categoricalAsBooleansMap: newCategoricalAsBooleansMap
|
||||
categoricalSelectionState: newCategoricalSelectionState
|
||||
};
|
||||
}
|
||||
case "categorical metadata filter all of these": {
|
||||
const newCategoricalAsBooleansMap = {
|
||||
...state.categoricalAsBooleansMap
|
||||
};
|
||||
_.forEach(
|
||||
newCategoricalAsBooleansMap[action.metadataField],
|
||||
(v, k, c) => {
|
||||
c[k] = true;
|
||||
const newCategoricalSelectionState = {
|
||||
...state.categoricalSelectionState,
|
||||
[action.metadataField]: {
|
||||
...state.categoricalSelectionState[action.metadataField],
|
||||
optionSelected: Array.from(
|
||||
state.categoricalSelectionState[action.metadataField].optionSelected
|
||||
).fill(true)
|
||||
}
|
||||
);
|
||||
};
|
||||
state.dimensionMap[
|
||||
obsAnnoDimensionName(action.metadataField)
|
||||
].filterAll();
|
||||
return {
|
||||
...state,
|
||||
categoricalAsBooleansMap: newCategoricalAsBooleansMap
|
||||
categoricalSelectionState: newCategoricalSelectionState
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,12 @@ Example:
|
||||
|
||||
NOTE: will not summarize the required 'name' annotation, as that is
|
||||
specified as unique per element.
|
||||
|
||||
TODO: XXX - this data structure coerces all metadata categories into a string
|
||||
(ie, stores values as an Object property in the `options` field). This looses
|
||||
information (eg, type) for category types which are not strings. Consider an
|
||||
alterative data structure that does not use the object property for non-string
|
||||
data types (and does not use _.countBy to summarize).
|
||||
*/
|
||||
function summarizeDimension(schema, annotations) {
|
||||
return _(schema)
|
||||
@@ -58,11 +64,13 @@ function summarizeDimension(schema, annotations) {
|
||||
const continuous = type === "int32" || type === "float32";
|
||||
|
||||
if (!continuous) {
|
||||
const categories = _.uniq(_.flatMap(annotations, name));
|
||||
const options = _.countBy(annotations, name);
|
||||
const numOptions = _.size(options);
|
||||
return {
|
||||
numOptions,
|
||||
options
|
||||
options,
|
||||
categories
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -161,6 +161,32 @@ function RESTv02LayoutResponseToInternal(response) {
|
||||
return layout;
|
||||
}
|
||||
|
||||
function reconcileSchemaCategoriesWithSummary(universe) {
|
||||
/*
|
||||
where we treat types as (essentially) categorical metadata, update
|
||||
the schema with data-derived categories (in addition to those in
|
||||
the server declared schema).
|
||||
|
||||
For example, boolean defined fields in the schema do not contain
|
||||
explicit declaration of categories (nor do string fields). In these
|
||||
cases, add a 'categories' field to the schema so it is accessible.
|
||||
*/
|
||||
|
||||
_.forEach(universe.schema.annotations.obs, s => {
|
||||
if (
|
||||
s.type === "string" ||
|
||||
s.type === "boolean" ||
|
||||
s.type === "categorical"
|
||||
) {
|
||||
const categories = _.union(
|
||||
_.get(s, "categories", []),
|
||||
_.get(universe.summary.obs[s.name], "categories", [])
|
||||
);
|
||||
s.categories = categories;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function createUniverseFromRestV02Response(
|
||||
configResponse,
|
||||
schemaResponse,
|
||||
@@ -199,6 +225,7 @@ export function createUniverseFromRestV02Response(
|
||||
universe.varAnnotations
|
||||
);
|
||||
|
||||
reconcileSchemaCategoriesWithSummary(universe);
|
||||
return finalize(universe);
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -568,14 +568,14 @@ If differential expression is not supported by the server, must return an HTTP 5
|
||||
|
||||
**Response body:**
|
||||
|
||||
- For 200 Success, differential expression statistics returned as array of arrays sorted by varindex, where each contains the following values:
|
||||
- For 200 Success, differential expression statistics returned as array of arrays, where each contains the following values:
|
||||
|
||||
- **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,
|
||||
- **pVal**: unadjusted p-value,
|
||||
- **pValAdj**: adjusted p-value
|
||||
|
||||
Statistics are encoded as an array of arrays, with fields ordered as:
|
||||
Values ordered as:
|
||||
|
||||
_varIndex_, _logfoldchange_, _pVal_, _pValAdj_
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 312 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 285 KiB |
|
Before Width: | Height: | Size: 6.0 MiB After Width: | Height: | Size: 6.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 644 KiB |
+10
-2
@@ -24,12 +24,20 @@ Follow these steps to create a release.
|
||||
2. Create a release branch, eg, `release-version`
|
||||
3. In the release branch:
|
||||
- run `bumpversion --config-file .bumpversion.cfg [major | minor | patch]`
|
||||
- clean up existing environment using `bin/clean`
|
||||
- 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
|
||||
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 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
|
||||
|
||||
@@ -17,6 +17,7 @@ class CXGDriver(metaclass=ABCMeta):
|
||||
self.layout_method = args["layout"]
|
||||
self.diffexp_method = args["diffexp"]
|
||||
self.max_category_items = args["max_category_items"]
|
||||
self.diffexp_lfc_cutoff = args["diffexp_lfc_cutoff"]
|
||||
self.cluster = None
|
||||
|
||||
@property
|
||||
|
||||
@@ -346,7 +346,7 @@ class DataObsAPI(Resource):
|
||||
def get(self):
|
||||
accept_type = request.args.get("accept-type", None)
|
||||
# request.args is immutable
|
||||
args = dict(request.args)
|
||||
args = request.args.copy()
|
||||
args.pop("accept-type", None)
|
||||
try:
|
||||
filter_ = parse_filter(ImmutableMultiDict(args), current_app.data.schema['annotations'])
|
||||
@@ -450,7 +450,7 @@ class DataVarAPI(Resource):
|
||||
def get(self):
|
||||
accept_type = request.args.get("accept-type", None)
|
||||
# request.args is immutable
|
||||
args = dict(request.args)
|
||||
args = request.args.copy()
|
||||
args.pop("accept-type", None)
|
||||
try:
|
||||
filter_ = parse_filter(ImmutableMultiDict(args), current_app.data.schema['annotations'])
|
||||
|
||||
@@ -25,25 +25,42 @@ def _mean_var_n(X):
|
||||
return mean, v, n
|
||||
|
||||
|
||||
def diffexp_ttest(adata, maskA, maskB, top_n=8):
|
||||
def diffexp_ttest(adata, maskA, maskB, top_n=8, diffexp_lfc_cutoff=0.01):
|
||||
"""
|
||||
Return differential expression statistics for top N variables, sorted by
|
||||
t statistic. Implemented as a unequal variance t-test.
|
||||
Return differential expression statistics for top N variables.
|
||||
|
||||
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 maskA: observation selection mask for set 1
|
||||
:param maskB: observation selection mask for set 2
|
||||
: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 ]
|
||||
"""
|
||||
|
||||
# mean, variance, N
|
||||
if top_n > adata.n_obs:
|
||||
top_n = adata.n_obs
|
||||
|
||||
# mean, variance, N - calculate for both selections
|
||||
meanA, vA, nA = _mean_var_n(adata._X[maskA])
|
||||
meanB, vB, nB = _mean_var_n(adata._X[maskB])
|
||||
|
||||
# variance / N
|
||||
vnA = vA / nA
|
||||
vnB = vB / nB
|
||||
vnA = vA / min(nA, nB) # overestimate variance, would normally be nA
|
||||
vnB = vB / min(nA, nB) # overestimate variance, would normally be nB
|
||||
sum_vn = vnA + vnB
|
||||
|
||||
# degrees of freedom for Welch's t-test
|
||||
@@ -59,18 +76,31 @@ def diffexp_ttest(adata, maskA, maskB, top_n=8):
|
||||
# p-value
|
||||
pvals = stats.t.sf(np.abs(tscores), dof) * 2
|
||||
pvals_adj = pvals * adata._X.shape[1]
|
||||
pvals_adj[pvals_adj > 1] = 1 # cap adjusted p-value at 1
|
||||
|
||||
# logfoldchanges: log2(meanA / meanB)
|
||||
logfoldchanges = np.log2(np.abs((meanA + 1e-9) / (meanB + 1e-9)))
|
||||
|
||||
# top n sort
|
||||
# find all with lfc > cutoff
|
||||
lfc_above_cutoff_idx = np.nonzero(np.abs(logfoldchanges) > diffexp_lfc_cutoff)[0]
|
||||
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]
|
||||
|
||||
# top n slice
|
||||
# derive sort order
|
||||
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]
|
||||
pvals_top_n = pvals[sort_order]
|
||||
pvals_adj_top_n = pvals_adj[sort_order]
|
||||
|
||||
@@ -65,6 +65,22 @@ class ScanpyEngine(CXGDriver):
|
||||
else:
|
||||
raise KeyError(f"Annotation name {name}, specified in --{ax_name}_name does not exist.")
|
||||
|
||||
@staticmethod
|
||||
def _can_cast_to_float32(ann):
|
||||
if ann.dtype.kind == "f" and np.can_cast(ann.dtype, np.float32):
|
||||
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):
|
||||
self.schema = {
|
||||
"dataframe": {
|
||||
@@ -81,16 +97,18 @@ class ScanpyEngine(CXGDriver):
|
||||
curr_axis = getattr(self.data, str(ax))
|
||||
for ann in curr_axis:
|
||||
ann_schema = {"name": ann}
|
||||
data_kind = curr_axis[ann].dtype.kind
|
||||
if data_kind == "f":
|
||||
dtype = curr_axis[ann].dtype
|
||||
data_kind = dtype.kind
|
||||
|
||||
if self._can_cast_to_float32(curr_axis[ann]):
|
||||
ann_schema["type"] = "float32"
|
||||
elif data_kind in ["i", "u"]:
|
||||
elif self._can_cast_to_int32(curr_axis[ann]):
|
||||
ann_schema["type"] = "int32"
|
||||
elif data_kind == "?":
|
||||
elif dtype == np.bool_:
|
||||
ann_schema["type"] = "boolean"
|
||||
elif data_kind == "O" and curr_axis[ann].dtype == "object":
|
||||
elif data_kind == "O" and dtype == "object":
|
||||
ann_schema["type"] = "string"
|
||||
elif data_kind == "O" and curr_axis[ann].dtype == "category":
|
||||
elif data_kind == "O" and dtype == "category":
|
||||
ann_schema["type"] = "categorical"
|
||||
ann_schema["categories"] = curr_axis[ann].dtype.categories.tolist()
|
||||
else:
|
||||
@@ -325,8 +343,8 @@ class ScanpyEngine(CXGDriver):
|
||||
raise FilterError(f"Error parsing filter: {e}") from e
|
||||
if top_n is None:
|
||||
top_n = DEFAULT_TOP_N
|
||||
result = diffexp_ttest(self.data, obs_mask_A, obs_mask_B, top_n)
|
||||
return sorted(result, key=lambda r: r[0])
|
||||
result = diffexp_ttest(self.data, obs_mask_A, obs_mask_B, top_n, self.diffexp_lfc_cutoff)
|
||||
return result
|
||||
|
||||
def layout(self, filter, interactive_limit=None):
|
||||
"""
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ from .prepare import prepare
|
||||
|
||||
|
||||
@click.group(name="cellxgene", context_settings=dict(max_content_width=85))
|
||||
@click.version_option(version="0.0.2", prog_name="cellxgene", message="[%(prog)s] Version %(version)s")
|
||||
@click.version_option(version="0.2.1", prog_name="cellxgene", message="[%(prog)s] Version %(version)s")
|
||||
def cli():
|
||||
pass
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import sys
|
||||
import click
|
||||
import logging
|
||||
from os import devnull
|
||||
from os.path import splitext, basename
|
||||
import webbrowser
|
||||
|
||||
@@ -27,8 +28,10 @@ from server.app.util.errors import ScanpyFileError
|
||||
help="Bind to all interfaces (this makes the server accessible beyond this computer).")
|
||||
@click.option("--max-category-items", default=100, metavar="", show_default=True,
|
||||
help="Limits the number of categorical annotation items displayed.")
|
||||
@click.option("--diffexp-lfc-cutoff", default=0.01, show_default=True,
|
||||
help="Relative expression cutoff used when selecting top N differentially expressed genes")
|
||||
def launch(data, layout, diffexp, title, verbose, debug, obs_names, var_names,
|
||||
open_browser, port, listen_all, max_category_items):
|
||||
open_browser, port, listen_all, max_category_items, diffexp_lfc_cutoff):
|
||||
"""Launch the cellxgene data viewer.
|
||||
This web app lets you explore single-cell expression data.
|
||||
Data must be in a format that cellxgene expects, read the
|
||||
@@ -92,6 +95,7 @@ def launch(data, layout, diffexp, title, verbose, debug, obs_names, var_names,
|
||||
"layout": layout,
|
||||
"diffexp": diffexp,
|
||||
"max_category_items": max_category_items,
|
||||
"diffexp_lfc_cutoff": diffexp_lfc_cutoff,
|
||||
"obs_names": obs_names,
|
||||
"var_names": var_names
|
||||
}
|
||||
@@ -109,4 +113,8 @@ def launch(data, layout, diffexp, title, verbose, debug, obs_names, var_names,
|
||||
|
||||
click.echo("[cellxgene] Type CTRL-C at any time to exit.")
|
||||
|
||||
if not verbose:
|
||||
f = open(devnull, 'w')
|
||||
sys.stdout = f
|
||||
|
||||
app.run(host=host, debug=debug, port=port, threaded=True)
|
||||
|
||||
@@ -14,7 +14,7 @@ from server.app.scanpy_engine.scanpy_engine import ScanpyEngine
|
||||
class UtilTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
args = {'layout': 'umap', 'diffexp': 'ttest', 'max_category_items': 100,
|
||||
'obs_names': None, 'var_names': None}
|
||||
'obs_names': None, 'var_names': None, 'diffexp_lfc_cutoff': 0.01}
|
||||
|
||||
self.data = ScanpyEngine("example-dataset/pbmc3k.h5ad", args)
|
||||
self.data._create_schema()
|
||||
@@ -201,8 +201,6 @@ class UtilTest(unittest.TestCase):
|
||||
}
|
||||
result = self.data.diffexp_topN(f1["filter"], f2["filter"])
|
||||
self.assertEqual(len(result), 10)
|
||||
var_idx = [i[0] for i in result]
|
||||
self.assertEqual(var_idx, sorted(var_idx))
|
||||
result = self.data.diffexp_topN(f1["filter"], f2["filter"], 20)
|
||||
self.assertEqual(len(result), 20)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user