Compare commits

..
2 Commits
Author SHA1 Message Date
Severiano Badajoz 27c34f4c1e bump version 2019-08-14 13:24:07 -07:00
Charlotte Weaver 1bec49a943 fix bad matplotlib library by installing scanpy first (#881) 2019-08-14 13:20:39 -07:00
106 changed files with 7204 additions and 8200 deletions
+3 -3
View File
@@ -1,5 +1,5 @@
[bumpversion]
current_version = 0.12.0
current_version = 0.11.1
[bumpversion:file:setup.py]
search = version="{current_version}"
@@ -14,6 +14,6 @@ search = "version": "{current_version}"
replace = "version": "{new_version}"
[bumpversion:file:server/__init__.py]
search = __version__ = "{current_version}"
replace = __version__ = "{new_version}"
search = "__version__ = "{current_version}"
replace = "__version__ = "{new_version}"
+1 -2
View File
@@ -1,5 +1,4 @@
recursive-include server/app/web/templates *
recursive-include server/app/web/static *
include server/requirements.txt
include server/requirements-prepare.txt
include server/requirements.txt
+39 -34
View File
@@ -1,41 +1,38 @@
<img src="./docs/cellxgene-logo.svg" width="300">
# cellxgene
_an interactive explorer for single-cell transcriptomics data_
> an interactive explorer for single-cell transcriptomics data
[![DOI](https://zenodo.org/badge/105615409.svg)](https://zenodo.org/badge/latestdoi/105615409) [![PyPI](https://img.shields.io/pypi/v/cellxgene)](https://pypi.org/project/cellxgene/) [![PyPI - Downloads](https://img.shields.io/pypi/dm/cellxgene)](https://pypistats.org/packages/cellxgene) [![GitHub last commit](https://img.shields.io/github/last-commit/chanzuckerberg/cellxgene)](https://github.com/chanzuckerberg/cellxgene/pulse)
[![DOI](https://zenodo.org/badge/105615409.svg)](https://zenodo.org/badge/latestdoi/105615409)
cellxgene (pronounced "cell-by-gene") is an interactive data explorer for single-cell transcriptomics datasets, such as those coming from the [Human Cell Atlas](https://humancellatlas.org). Leveraging modern web development techniques to enable fast visualizations of at least 1 million cells, we hope to enable biologists and computational researchers to explore their data.
_cellxgene_ (pronounced "sell-by-jean") is an interactive data explorer for single-cell transcriptomics datasets, such as those coming from the [Human Cell Atlas](https://humancellatlas.org). Leveraging modern web development techniques to enable fast visualizations of at least 1 million cells, we hope to enable biologists and computational researchers to explore their data, and to demonstrate general, scalable, and reusable patterns for scientific data visualization.
<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">
- Want to install and use cellxgene? Visit the [cellxgene docs](https://chanzuckerberg.github.io/cellxgene/).
- Want to see where we are going? Check out [our roadmap](https://github.com/chanzuckerberg/cellxgene/blob/master/ROADMAP.md).
- Want to contribute? See our [contributors guide](https://github.com/chanzuckerberg/cellxgene/blob/master/CONTRIBUTING.md).
- Want to see where we are going? Check out [our roadmap](ROADMAP.md).
- Want to contribute? See our [contributors guide](CONTRIBUTING.md).
## quick start
To install cellxgene you need Python 3.6+. We recommend [installing cellxgene into a conda or virtual environment.](https://chanzuckerberg.github.io/cellxgene/faq.html#how-do-i-create-a-python-36-environment-for-cellxgene)
To install _cellxgene_ you need Python 3.6+. We recommend [installing _cellxgene_ into a conda or virtual environment.](https://chanzuckerberg.github.io/cellxgene/faq.html#how-do-i-create-a-python-36-environment-for-cellxgene)
Install the package.
```bash
``` bash
pip install cellxgene
```
Download an example [anndata](https://anndata.readthedocs.io/en/latest/) file
```bash
curl -O https://cellxgene-example-data.czi.technology/pbmc3k.h5ad.zip
unzip pbmc3k.h5ad
``` bash
curl -o pbmc3k.h5ad https://raw.githubusercontent.com/chanzuckerberg/cellxgene/master/example-dataset/pbmc3k.h5ad
```
Launch cellxgene
```bash
Launch _cellxgene_
``` bash
cellxgene launch pbmc3k.h5ad --open
```
To learn more about what you can do with cellxgene, see the [Getting Started](https://chanzuckerberg.github.io/cellxgene/getting-started.html) guide.
To learn more about what you can do with _cellxgene_, see the [Getting Started](https://chanzuckerberg.github.io/cellxgene/getting-started.html) guide.
## get in touch
@@ -43,24 +40,10 @@ Have questions, suggestions, or comments? You can come hang out with us by joini
## contributing
We warmly welcome contributions from the community! Please see our [contributing guide](https://github.com/chanzuckerberg/cellxgene/blob/master/CONTRIBUTING.md) and don't hesitate to open an issue or send a pull request to improve cellxgene.
We warmly welcome contributions from the community! Please see our [contributing guide](CONTRIBUTING.md) and don't hesitate to open an issue or send a pull request to improve cellxgene.
This project adheres to the Contributor Covenant [code of conduct](https://github.com/chanzuckerberg/.github/blob/master/CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to opensource@chanzuckerberg.com.
## core team
The current core team:
- Colin Megill, frontend & product design
- Bruce Martin, software engineer
- Sidney Bell, computational biologist
- Lia Prins, designer
- Severiano Badajoz, software engineer
We would also like to gratefully acknowledge contributions from past core team members:
- Charlotte Weaver, software engineer
## where we are going
Our goal is to enable teams of computational and experimental
@@ -73,7 +56,21 @@ There are 4 key features we plan to implement in the near term.
- Toggle embeddings
- Gene information
For more detail on these features and where we are going, see [our roadmap](https://github.com/chanzuckerberg/cellxgene/blob/master/ROADMAP.md).
For more detail on these features and where we are going, see [our roadmap](ROADMAP.md).
## risks of hosting cellxgene
_cellxgene_ is built on standard web technologies, but is currently designed as a single-user desktop application.
We've done this so we can prioritize the features on [our roadmap](ROADMAP.md).
Some of our users have experimented with hosting _cellxgene_ either for their lab or for public use, but please note that the _cellxgene_ team does not officially support, troubleshoot, or maintain any web deployments at this time.
If do you choose setup _cellxgene_ as a hosted service, you should be aware of the following risks:
- `$ cellxgene launch` uses Flask's development server, which is not recommended for hosted deployment (see the [Flask documentation](http://flask.pocoo.org/docs/1.0/tutorial/deploy/#run-with-a-production-server))
- We have no testing or official support for deployments where multiple users are accessing the same _cellxgene_ instance.
- Your _cellxgene_ instance is likely to hang or crash if too many people access it at the same time, especially if they using functions that call the Python backend (such as differential expression, updating the layout, or coloring by gene).
- _cellxgene_ only supports one instance per dataset
## inspiration
@@ -81,9 +78,17 @@ We've been heavily inspired by several other related single-cell visualization p
We were inspired by Mike Bostock and the [crossfilter](https://github.com/crossfilter) team for the design of our filtering implementation.
We have been working closely with the [scanpy](https://github.com/theislab/scanpy) team to integrate with their awesome analysis tools. Special thanks to Alex Wolf, Fabian Theis, and the rest of the team for their help during development and for providing an example dataset.
We have been working closely with the [`scanpy`](https://github.com/theislab/scanpy) team to integrate with their awesome analysis tools. Special thanks to Alex Wolf, Fabian Theis, and the rest of the team for their help during development and for providing an example dataset.
We are eager to explore integrations with other computational backends such as [Seurat](https://github.com/satijalab/seurat) or [Bioconductor](https://github.com/Bioconductor)
We are eager to explore integrations with other computational backends such as [`Seurat`](https://github.com/satijalab/seurat) or [`Bioconductor`](https://github.com/Bioconductor)
## core team
- Colin Megill, frontend & product design
- Charlotte Weaver, software engineer
- Bruce Martin, software engineer
- Sidney Bell, computational biologist
- Justin Kiggins, product manager
## reuse
-21
View File
@@ -1,21 +0,0 @@
{
"name": "cellxgene",
"description": "An interactive explorer for single-cell transcriptomics data",
"repository": "https://github.com/chanzuckerberg/cellxgene/",
"logo": "https://cellxgene-example-data.czi.technology/favicon.png",
"keywords": [
"scientific",
"visualization",
"scrna-seq",
"transcriptomics",
"dataviz"
],
"stack": "container",
"env": {
"DATASET": {
"description": "Link to dataset",
"value": "https://cellxgene-example-data.czi.technology/pbmc3k.h5ad",
"required": "true"
}
}
}
+3 -9
View File
@@ -27,7 +27,7 @@ export const datasets = {
lasso: [
{
"coordinates-as-percent": { x1: 0.05, y1: 0.25, x2: 0.15, y2: 0.35 },
count: "88"
count: "71"
}
],
categorical: [
@@ -87,18 +87,12 @@ export const datasets = {
categorical: {
louvain: {
"B cells": "342",
"CD14+ Monocytes": "0",
"CD4 T cells": "0",
"CD8 T cells": "0",
"Dendritic cells": "0",
"FCGR3A+ Monocytes": "0",
Megakaryocytes: "15",
"NK cells": "0"
Megakaryocytes: "15"
}
},
lasso: {
"coordinates-as-percent": { x1: 0.45, y1: 0.05, x2: 0.65, y2: 0.15 },
count: "39"
count: "36"
}
},
scatter: {
@@ -452,59 +452,6 @@ describe("dataframe factories", () => {
});
});
describe("withColsFrom", () => {
test("error conditions", () => {
/*
make sure we catch common errors:
- duplicate column names
- dimensionality difference
*/
const dfA = new Dataframe.Dataframe(
[2, 3],
[["red", "blue"], [true, false], [1, 0]],
null,
new Dataframe.KeyIndex(["colors", "bools", "numbers"])
);
/* different dimensionality should throw error */
const dfB = new Dataframe.Dataframe(
[3, 1],
[["red", "blue", "green"]],
null,
new Dataframe.KeyIndex(["colorsA"])
);
expect(() => dfA.withColsFrom(dfB)).toThrow(RangeError);
/* duplicate labels should throw an error */
expect(() => dfA.withColsFrom(dfA)).toThrow(Error);
});
test("simple", () => {
/* simple test that it works as expected in common case */
const dfA = new Dataframe.Dataframe(
[2, 1],
[["red", "blue"]],
null,
new Dataframe.KeyIndex(["colors"])
);
const dfB = new Dataframe.Dataframe(
[2, 1],
[[true, false]],
null,
new Dataframe.KeyIndex(["bools"])
);
const dfC = dfA.withColsFrom(dfB);
expect(dfC).toBeDefined();
expect(dfC.dims).toEqual([2, 2]);
expect(dfC.colIndex.keys()).toEqual(["colors", "bools"]);
expect(dfC.rowIndex).toEqual(dfA.rowIndex);
expect(dfC.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
expect(dfC.col("colors").asArray()).toEqual(["red", "blue"]);
expect(dfC.col("bools").asArray()).toEqual([true, false]);
});
});
describe("dropCol", () => {
test("KeyIndex", () => {
const df = new Dataframe.Dataframe(
@@ -620,32 +567,6 @@ describe("dataframe factories", () => {
expect(dfB.iat(0, 1)).toEqual(1);
expect(dfB.iat(0, 2)).toEqual(1);
});
test("columns", () => {
const df = Dataframe.Dataframe.create(
[3, 3],
[new Array(3).fill(0), new Array(3).fill(0), new Array(3).fill(0)]
);
expect(df).toBeDefined();
expect(df.columns()).toHaveLength(3);
expect(df.columns()[0]).toEqual(df.icol(0));
expect(df.columns()[2]).toEqual(df.icol(2));
});
test("renameCol", () => {
const dfA = new Dataframe.Dataframe(
[2, 2],
[[true, false], [1, 0]],
null,
new Dataframe.KeyIndex(["A", "B"])
);
const dfB = dfA.renameCol("B", "C");
expect(dfA.colIndex.keys()).toEqual(["A", "B"]);
expect(dfB.colIndex.keys()).toEqual(["A", "C"]);
expect(dfA.dims).toMatchObject(dfB.dims);
expect(dfA.columns()).toMatchObject(dfB.columns());
});
});
});
@@ -66,28 +66,4 @@ describe("Dataframe column histogram", () => {
// memoized?
expect(df.col("value").histogram(3, [0, 2])).toMatchObject(h1);
});
test("continuous thesholds correct", () => {
const vals = [0, 1, 9, 10, 11, 20, 99, 100];
const df = new Dataframe.Dataframe(
[8, 2],
[new Int32Array(vals), new Float32Array(vals)]
);
expect(df.col(0).histogram(5, [0, 100])).toEqual([5, 1, 0, 0, 2]);
expect(df.col(1).histogram(5, [0, 100])).toEqual([5, 1, 0, 0, 2]);
expect(df.col(0).histogram(2, [0, 10])).toEqual([2, 2]);
expect(df.col(0).histogram(10, [0, 100])).toEqual([
3,
2,
1,
0,
0,
0,
0,
0,
0,
2
]);
});
});
@@ -1,34 +0,0 @@
/*
test FBS encode/decode API
*/
import { Dataframe, KeyIndex } from "../../../src/util/dataframe";
import {
decodeMatrixFBS,
encodeMatrixFBS
} from "../../../src/util/stateManager/matrix";
describe("encode/decode", () => {
test("round trip", () => {
const columns = [
["red", "green", "blue"],
new Int32Array(3).fill(0),
new Uint32Array(3).fill(1),
new Float32Array(3).fill(2)
];
const dfNoColIdx = new Dataframe([3, 4], columns);
const dfA = decodeMatrixFBS(encodeMatrixFBS(dfNoColIdx));
expect([dfA.nRows, dfA.nCols]).toEqual(dfNoColIdx.dims);
expect(dfA.colIdx).toBeNull();
expect(dfA.rowIdx).toBeNull();
expect(dfA.columns).toEqual(columns);
const colIndex = new KeyIndex(["a", "b", "c", "d"]);
const dfWithColIdx = new Dataframe([3, 4], columns, null, colIndex);
const dfB = decodeMatrixFBS(encodeMatrixFBS(dfWithColIdx));
expect([dfB.nRows, dfB.nCols]).toEqual(dfWithColIdx.dims);
expect(dfB.colIdx).toEqual(colIndex.keys());
expect(dfB.rowIdx).toBeNull();
expect(dfB.columns).toEqual(columns);
});
});
@@ -110,14 +110,6 @@ function encodeTypedArray(builder, uType, uData) {
}
function encodeMatrix(columns, colIndex = undefined) {
/*
IMPORTANT: this is not a general purpose encoder. in particular,
it doesn't correctly handle all column index types, nor does it
handle all column typedarray types.
encodeMatrixFBS in matrix.py is more general. This is used only
as a testing santity check (alt implementation).
*/
const utf8Encoder = new TextEncoder("utf-8");
const builder = new flatbuffers.Builder(1024);
const cols = _.map(columns, carr => {
@@ -18,11 +18,11 @@ const defaultBigBang = () => {
/* create unverse, world, crossfilter and dimensionMap */
/* create universe */
const universe = Universe.createUniverseFromResponse(
_.cloneDeep(REST.config),
_.cloneDeep(REST.schema),
_.cloneDeep(REST.annotationsObs),
_.cloneDeep(REST.annotationsVar),
_.cloneDeep(REST.layoutObs)
REST.config,
REST.schema,
REST.annotationsObs,
REST.annotationsVar,
REST.layoutObs
);
/* create world */
const world = World.createWorldFromEntireUniverse(universe);
@@ -43,11 +43,11 @@ const defaultBigBang = () => {
describe("createWorldFromEntireUniverse", () => {
test("create from REST sample", () => {
const universe = Universe.createUniverseFromResponse(
_.cloneDeep(REST.config),
_.cloneDeep(REST.schema),
_.cloneDeep(REST.annotationsObs),
_.cloneDeep(REST.annotationsVar),
_.cloneDeep(REST.layoutObs)
REST.config,
REST.schema,
REST.annotationsObs,
REST.annotationsVar,
REST.layoutObs
);
expect(universe).toBeDefined();
@@ -0,0 +1,62 @@
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);
});
});
@@ -180,7 +180,6 @@ describe("lowerBound", () => {
expect(lowerBound([0, 1, 2, 3], 1, 0, 4)).toEqual(1);
expect(lowerBound([0, 1, 2, 3], 3, 0, 4)).toEqual(3);
expect(lowerBound([0, 1, 2, 3], 4, 0, 4)).toEqual(4);
expect(lowerBound([0, 1, 2, 3], 4, 0, 3)).toEqual(3);
expect(lowerBound([0, 1, 2, 3, 4], -1, 0, 5)).toEqual(0);
expect(lowerBound([0, 1, 2, 3, 4], 0, 0, 5)).toEqual(0);
@@ -205,7 +204,6 @@ describe("lowerBound", () => {
expect(lowerBound(new Float32Array([0, 1, 2, 3]), 1, 0, 4)).toEqual(1);
expect(lowerBound(new Float32Array([0, 1, 2, 3]), 3, 0, 4)).toEqual(3);
expect(lowerBound(new Float32Array([0, 1, 2, 3]), 4, 0, 4)).toEqual(4);
expect(lowerBound(new Float32Array([0, 1, 2, 3]), 4, 0, 3)).toEqual(3);
expect(lowerBound(new Float32Array([0, 1, 2, 3, 4]), -1, 0, 5)).toEqual(0);
expect(lowerBound(new Float32Array([0, 1, 2, 3, 4]), 0, 0, 5)).toEqual(0);
@@ -37,9 +37,8 @@ module.exports = {
{
loader: "css-loader",
options: {
modules: {
localIdentName: "[name]__[local]___[hash:base64:5]"
}
modules: true,
localIdentName: "[name]__[local]___[hash:base64:5]"
}
}
]
@@ -40,10 +40,9 @@ module.exports = {
{
loader: "css-loader",
options: {
modules: {
localIdentName: "[name]__[local]___[hash:base64:5]"
},
importLoaders: 1
modules: true,
importLoaders: 1,
localIdentName: "[name]__[local]___[hash:base64:5]"
}
}
]
+5791 -2782
View File
File diff suppressed because it is too large Load Diff
+58 -50
View File
@@ -1,12 +1,11 @@
{
"name": "cellxgene",
"version": "0.12.0",
"version": "0.11.1",
"license": "MIT",
"description": "cellxgene is a web application for the interactive exploration of single cell sequence data.",
"repository": "https://github.com/chanzuckerberg/cellxgene",
"scripts": {
"backend-dev": "python3.6 -m venv cellxgene && source cellxgene/bin/activate && yes | pip uninstall cellxgene || true && pip install -e .. && cellxgene launch ",
"backend-dev-anno": "python3.6 -m venv cellxgene && source cellxgene/bin/activate && yes | pip uninstall cellxgene || true && pip install -e .. && cellxgene launch --experimental-label-file labels.csv ",
"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",
@@ -32,78 +31,87 @@
"eslint-scope": "3.7.1"
},
"dependencies": {
"@blueprintjs/core": "^3.18.1",
"@blueprintjs/icons": "^3.10.0",
"@blueprintjs/select": "^3.10.0",
"@blueprintjs/core": "^3.15.1",
"@blueprintjs/icons": "^3.8.0",
"@blueprintjs/select": "^3.8.0",
"canvas-fit": "^1.5.0",
"d3": "^4.10.0",
"d3-scale-chromatic": "^1.5.0",
"flatbuffers": "^1.11.0",
"d3-scale-chromatic": "^1.3.0",
"flatbuffers": "^1.10.2",
"font-color-contrast": "^1.0.3",
"fuzzysort": "^1.1.4",
"gl-mat4": "^1.1.4",
"gl-matrix": "^3.1.0",
"gl-matrix": "^3.0.0",
"gl-vec3": "^1.1.3",
"is-number": "^7.0.0",
"lodash": "^4.17.15",
"memoize-one": "^5.1.1",
"react": "^16.9.0",
"key-pressed": "0.0.1",
"lodash": "^4.17.14",
"memoize-one": "^5.0.4",
"mouse-position": "^2.0.1",
"mouse-pressed": "^1.0.0",
"orbit-camera": "^1.0.0",
"query-string": "^6.5.0",
"react": "^16.8.6",
"react-autocomplete": "^1.7.2",
"react-dom": "^16.9.0",
"react-dom": "^16.8.6",
"react-helmet": "^5.2.1",
"react-icons": "^3.7.0",
"react-redux": "^7.1.1",
"redux": "^4.0.4",
"react-redux": "^7.0.3",
"redux": "^4.0.1",
"redux-thunk": "^2.2.0",
"regl": "^1.3.13"
"regl": "^1.3.11",
"scroll-speed": "^1.0.0",
"urijs": "^1.19.0"
},
"devDependencies": {
"@babel/core": "^7.6.0",
"@babel/plugin-proposal-class-properties": "^7.5.5",
"@babel/plugin-proposal-decorators": "^7.6.0",
"@babel/plugin-proposal-export-namespace-from": "^7.5.2",
"@babel/core": "^7.4.4",
"@babel/plugin-proposal-class-properties": "^7.4.4",
"@babel/plugin-proposal-decorators": "^7.4.4",
"@babel/plugin-proposal-export-namespace-from": "^7.2.0",
"@babel/plugin-proposal-function-bind": "^7.2.0",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.4.4",
"@babel/plugin-proposal-optional-chaining": "^7.6.0",
"@babel/plugin-transform-react-constant-elements": "^7.6.0",
"@babel/plugin-transform-runtime": "^7.6.0",
"@babel/preset-env": "^7.6.0",
"@babel/plugin-proposal-optional-chaining": "^7.2.0",
"@babel/plugin-transform-react-constant-elements": "^7.2.0",
"@babel/plugin-transform-runtime": "^7.4.4",
"@babel/preset-env": "^7.4.4",
"@babel/preset-react": "^7.0.0",
"@babel/register": "^7.6.0",
"@babel/runtime": "^7.6.0",
"babel-eslint": "^10.0.3",
"babel-jest": "^24.9.0",
"@babel/register": "^7.4.4",
"@babel/runtime": "^7.4.4",
"babel-eslint": "^10.0.1",
"babel-jest": "^24.8.0",
"babel-loader": "^8.0.6",
"babel-preset-modern-browsers": "^14.0.0",
"chalk": "^2.4.2",
"connect-history-api-fallback": "^1.6.0",
"copy-webpack-plugin": "^5.0.4",
"css-loader": "^3.2.0",
"eslint": "^6.4.0",
"eslint-config-airbnb": "^18.0.1",
"eslint-config-prettier": "^6.3.0",
"eslint-loader": "^3.0.0",
"copy-webpack-plugin": "^5.0.3",
"css-loader": "^2.1.1",
"eslint": "^5.16.0",
"eslint-config-airbnb": "^17.1.0",
"eslint-config-prettier": "^4.2.0",
"eslint-loader": "^2.1.2",
"eslint-plugin-filenames": "^1.3.2",
"eslint-plugin-import": "^2.18.2",
"eslint-plugin-jest": "^22.17.0",
"eslint-plugin-jsx-a11y": "^6.2.3",
"eslint-plugin-react": "^7.14.3",
"express": "^4.17.1",
"file-loader": "^4.2.0",
"eslint-plugin-import": "^2.17.2",
"eslint-plugin-jest": "^22.5.1",
"eslint-plugin-jsx-a11y": "^6.2.1",
"eslint-plugin-react": "^7.13.0",
"express": "^4.14.0",
"file-loader": "^3.0.1",
"html-webpack-inline-source-plugin": "0.0.10",
"html-webpack-plugin": "^3.2.0",
"jest": "^24.9.0",
"jest-puppeteer": "^4.3.0",
"jest": "^24.8.0",
"jest-puppeteer": "^4.1.1",
"json-loader": "^0.5.4",
"mini-css-extract-plugin": "^0.8.0",
"puppeteer": "^1.20.0",
"rimraf": "^3.0.0",
"mini-css-extract-plugin": "^0.6.0",
"puppeteer": "^1.16.0",
"rimraf": "^2.6.3",
"serve-favicon": "^2.3.0",
"start-server-and-test": "^1.10.2",
"style-loader": "^1.0.0",
"start-server-and-test": "^1.9.0",
"style-loader": "^0.23.1",
"sw-precache-webpack-plugin": "^0.11.5",
"url-loader": "^2.1.0",
"webpack": "^4.40.2",
"webpack-cli": "^3.3.8",
"webpack-dev-middleware": "^3.7.1"
"url-loader": "^1.1.0",
"webpack": "^4.31.0",
"webpack-cli": "^3.3.2",
"webpack-dev-middleware": "^3.6.2"
},
"jest": {
"testMatch": [
+2 -48
View File
@@ -1,7 +1,7 @@
// jshint esversion: 6
import _ from "lodash";
import * as globals from "../globals";
import { Universe, MatrixFBS } from "../util/stateManager";
import { Universe } from "../util/stateManager";
import {
catchErrorsWrap,
doJsonRequest,
@@ -340,57 +340,11 @@ const resetInterface = () => (dispatch, getState) => {
});
};
const saveObsAnnotations = () => async (dispatch, getState) => {
const { universe } = getState();
const { obsAnnotations, schema } = universe;
dispatch({
type: "writable obs annotations - save started"
});
const writableAnnotations = schema.annotations.obs.columns
.filter(s => s.writable)
.map(s => s.name);
const df = obsAnnotations.subset(null, writableAnnotations);
const matrix = MatrixFBS.encodeMatrixFBS(df);
try {
const res = await fetch(
`${globals.API.prefix}${globals.API.version}annotations/obs`,
{
method: "PUT",
body: matrix,
headers: new Headers({
"Content-Type": "application/octet-stream"
})
}
);
if (res.ok) {
dispatch({
type: "writable obs annotations - save complete",
obsAnnotations
});
} else {
dispatch({
type: "writable obs annotations - save error",
message: `HTTP error ${res.status} - ${res.statusText}`,
res
});
}
} catch (error) {
dispatch({
type: "writable obs annotations - save error",
message: error.toString(),
error
});
}
};
export default {
regraph,
resetInterface,
requestSingleGeneExpressionCountsForColoringPOST,
requestDifferentialExpression,
requestUserDefinedGene,
doInitialDataLoad,
saveObsAnnotations
doInitialDataLoad
};
-2
View File
@@ -8,7 +8,6 @@ import LeftSideBar from "./leftSidebar";
import Legend from "./continuousLegend";
import Graph from "./graph/graph";
import MenuBar from "./menubar";
import Autosave from "./autosave";
import actions from "../actions";
@@ -90,7 +89,6 @@ class App extends React.Component {
{loading ? null : <LeftSideBar />}
{loading ? null : <MenuBar />}
{loading ? null : <Graph key={graphRenderCounter} />}
{loading ? null : <Autosave />}
<Legend />
</div>
</Container>
-81
View File
@@ -1,81 +0,0 @@
import React from "react";
import { connect } from "react-redux";
import actions from "../../actions";
@connect(state => ({
universe: state.universe,
obsAnnotations: state.universe.obsAnnotations,
saveInProgress: state.autosave?.saveInProgress ?? false,
lastSavedObsAnnotations: state.autosave?.lastSavedObsAnnotations,
error: state.autosave?.error,
writableCategoriesEnabled: state.config?.parameters?.["label_file"] ?? false
}))
class Autosave extends React.Component {
constructor(props) {
super(props);
this.state = {
timer: null
};
}
componentDidMount() {
const { writableCategoriesEnabled } = this.props;
let { timer } = this.state;
if (timer) clearInterval(timer);
if (writableCategoriesEnabled) {
timer = setInterval(this.tick, 2500);
} else {
timer = null;
}
this.setState({ timer });
}
componentWillUnmount() {
const { timer } = this.state;
if (timer) this.clearInterval(timer);
}
tick = () => {
const { dispatch, saveInProgress } = this.props;
if (this.needToSave() && !saveInProgress) {
dispatch(actions.saveObsAnnotations());
}
};
needToSave = () => {
/* return true if we need to save, false if we don't */
const { obsAnnotations, lastSavedObsAnnotations } = this.props;
return (
lastSavedObsAnnotations && obsAnnotations !== lastSavedObsAnnotations
);
};
statusMessage() {
const { error } = this.props;
if (error) {
return `Autosave error: ${error}`;
}
return this.needToSave() ? "Unsaved" : "All saved";
}
render() {
const { writableCategoriesEnabled } = this.props;
return writableCategoriesEnabled ? (
<div
id="autosave"
style={{
position: "fixed",
display: "inherit",
right: 5,
bottom: 5
}}
>
{this.statusMessage()}
</div>
) : null;
}
}
export default Autosave;
@@ -13,25 +13,19 @@ import * as globals from "../../globals";
import actions from "../../actions";
import { makeContinuousDimensionName } from "../../util/nameCreators";
@connect((state, ownProps) => {
const { isObs, isUserDefined, isDiffExp, field } = ownProps;
const myName = makeContinuousDimensionName(
{ isObs, isUserDefined, isDiffExp },
field
);
return {
world: state.world,
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
scatterplotYYaccessor: state.controls.scatterplotYYaccessor,
continuousSelectionRange: state.continuousSelection[myName],
colorAccessor: state.colors.colorAccessor
};
})
class HistogramBrush extends React.PureComponent {
@connect(state => ({
world: state.world,
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
scatterplotYYaccessor: state.controls.scatterplotYYaccessor,
continuousSelection: state.continuousSelection,
differential: state.differential,
colorAccessor: state.colors.colorAccessor
}))
class HistogramBrush extends React.Component {
static getColumn(world, field, clipped = true) {
/*
Return the underlying Dataframe column for our field. By default,
returns the clipped column. If clipped===false, will return the
Return the underlying Dataframe column for our field. By default,
returns the clipped column. If clipped===false, will return the
unclipped column.
*/
const obsAnnotations = clipped
@@ -90,7 +84,7 @@ class HistogramBrush extends React.PureComponent {
}
componentDidUpdate(prevProps) {
const { field, world } = this.props;
const { field, world, continuousSelection } = this.props;
const { x, y, bins, svgRef } = this._histogram;
let { brushXselection, brushX } = this.state;
let forceBrushUpdate = false;
@@ -118,8 +112,16 @@ class HistogramBrush extends React.PureComponent {
if the selection has changed, ensure that the brush correctly reflects
the underlying selection.
*/
const { continuousSelectionRange: range } = this.props;
if (forceBrushUpdate || range !== prevProps.continuousSelectionRange) {
if (
forceBrushUpdate ||
continuousSelection !== prevProps.continuousSelection
) {
const { isObs, isUserDefined, isDiffExp } = this.props;
const myName = makeContinuousDimensionName(
{ isObs, isUserDefined, isDiffExp },
field
);
const range = continuousSelection[myName];
if (brushXselection) {
const selection = d3.brushSelection(brushXselection.node());
if (!range && selection) {
@@ -355,24 +357,14 @@ class HistogramBrush extends React.PureComponent {
.append("g")
.attr("class", "axis axis--x")
.attr("transform", `translate(0,${this.height - this.marginBottom})`)
.call(
d3
.axisBottom(x)
.ticks(5)
.tickFormat(d3.format(".0s"))
);
.call(d3.axisBottom(x).ticks(5));
/* Y AXIS */
svg
.append("g")
.attr("class", "axis axis--y")
.attr("transform", `translate(${this.width - this.marginRight},0)`)
.call(
d3
.axisRight(y)
.ticks(3)
.tickFormat(d3.format(".0s"))
);
.call(d3.axisRight(y).ticks(3));
/* axis style */
svg.selectAll(".axis text").style("fill", "rgb(80,80,80)");
@@ -469,11 +461,7 @@ class HistogramBrush extends React.PureComponent {
remove
</Button>
) : null}
<Tooltip
content="Use as color scale"
position="bottom"
hoverOpenDelay={globals.tooltipHoverOpenDelay}
>
<Tooltip content="Use as color scale" position="bottom">
<Button
onClick={this.handleColorAction.bind(this)}
active={colorAccessor === field}
@@ -1,176 +1,27 @@
// jshint esversion: 6
import React from "react";
import _ from "lodash";
import {
Button,
Tooltip,
InputGroup,
Dialog,
Classes,
MenuItem
} from "@blueprintjs/core";
import { Select } from "@blueprintjs/select";
import { connect } from "react-redux";
import * as globals from "../../globals";
import Category from "./category";
@connect(state => ({
categoricalSelection: state.categoricalSelection,
writableCategoriesEnabled: state.config?.parameters?.["label_file"] ?? false,
schema: state.world?.schema
categoricalSelection: state.categoricalSelection
}))
class Categories extends React.Component {
constructor(props) {
super(props);
this.state = {
createAnnoModeActive: false,
newCategoryText: "",
categoryToDuplicate: null
};
}
handleCreateUserAnno = () => {
const { dispatch } = this.props;
const { newCategoryText, categoryToDuplicate } = this.state;
dispatch({
type: "annotation: create category",
data: newCategoryText,
categoryToDuplicate
});
this.setState({
createAnnoModeActive: false,
categoryToDuplicate: null,
newCategoryText: ""
});
};
handleEnableAnnoMode = () => {
this.setState({ createAnnoModeActive: true });
};
handleDisableAnnoMode = () => {
this.setState({ createAnnoModeActive: false });
};
handleModalDuplicateCategorySelection = d => {
this.setState({ categoryToDuplicate: d });
};
render() {
const { createAnnoModeActive, categoryToDuplicate } = this.state;
const {
categoricalSelection,
writableCategoriesEnabled,
schema
} = this.props;
const { categoricalSelection } = this.props;
if (!categoricalSelection) return null;
/* all names, sorted in display order. Will be rendered in this order */
const allCategoryNames = Object.keys(categoricalSelection).sort();
return (
<div
style={{
padding: globals.leftSidebarSectionPadding
}}
>
{/* READ ONLY CATEGORICAL FIELDS */}
{/* this is duplicative but flat, could be abstracted */}
{_.map(allCategoryNames, catName =>
!schema.annotations.obsByName[catName].writable ? (
<Category
key={catName}
metadataField={catName}
createAnnoModeActive={createAnnoModeActive}
isUserAnno={false}
/>
) : null
)}
{/* WRITEABLE FIELDS */}
{_.map(allCategoryNames, catName =>
schema.annotations.obsByName[catName].writable ? (
<Category
key={catName}
metadataField={catName}
createAnnoModeActive={createAnnoModeActive}
isUserAnno
/>
) : null
)}
{writableCategoriesEnabled ? (
<div>
<Dialog
icon="tag"
title="Create new category"
isOpen={createAnnoModeActive}
onClose={this.handleDisableAnnoMode}
>
<form
onSubmit={e => {
e.preventDefault();
this.handleCreateUserAnno();
}}
>
<div className={Classes.DIALOG_BODY}>
<div style={{ marginBottom: 20 }}>
<p>New, unique category name:</p>
<InputGroup
autoFocus
onChange={e =>
this.setState({ newCategoryText: e.target.value })
}
leftIcon="tag"
/>
</div>
<p>
Optionally duplicate all labels & cell assignments from
existing category into new category:
</p>
<Select
items={allCategoryNames}
filterable={false}
itemRenderer={(d, { handleClick }) => {
return (
<MenuItem onClick={handleClick} key={d} text={d} />
);
}}
noResults={<MenuItem disabled text="No results." />}
onItemSelect={d => {
this.handleModalDuplicateCategorySelection(d);
}}
>
{/* children become the popover target; render value here */}
<Button
text={
categoryToDuplicate || "None (all cells 'unassigned')"
}
rightIcon="double-caret-vertical"
/>
</Select>
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Tooltip content="Close this dialog without creating a category.">
<Button onClick={this.handleDisableAnnoMode}>
Cancel
</Button>
</Tooltip>
<Button
onClick={this.handleCreateUserAnno}
intent="primary"
type="submit"
>
Create new category
</Button>
</div>
</div>
</form>
</Dialog>
<Button onClick={this.handleEnableAnnoMode} intent="primary">
Create new category
</Button>
</div>
) : null}
{_.map(categoricalSelection, (catState, catName) => (
<Category key={catName} metadataField={catName} />
))}
</div>
);
}
+19 -256
View File
@@ -2,19 +2,7 @@ import React from "react";
import _ from "lodash";
import { connect } from "react-redux";
import { FaChevronRight, FaChevronDown } from "react-icons/fa";
import {
Button,
Tooltip,
InputGroup,
Menu,
Dialog,
MenuItem,
Popover,
Classes,
Icon,
Position,
PopoverInteractionKind
} from "@blueprintjs/core";
import { Button, Tooltip } from "@blueprintjs/core";
import * as globals from "../../globals";
import Value from "./value";
@@ -22,18 +10,14 @@ import sortedCategoryValues from "./util";
@connect(state => ({
colorAccessor: state.colors.colorAccessor,
categoricalSelection: state.categoricalSelection,
annotations: state.annotations,
universe: state.universe
categoricalSelection: state.categoricalSelection
}))
class Category extends React.Component {
constructor(props) {
super(props);
this.state = {
isChecked: true,
isExpanded: false,
newCategoryText: "",
newLabelText: ""
isExpanded: false
};
}
@@ -67,74 +51,6 @@ class Category extends React.Component {
}
}
activateAddNewLabelMode = () => {
const { dispatch, metadataField } = this.props;
dispatch({
type: "annotation: activate add new label mode",
data: metadataField
});
};
disableAddNewLabelMode = () => {
const { dispatch } = this.props;
dispatch({
type: "annotation: disable add new label mode"
});
};
handleAddNewLabelToCategory = () => {
const { dispatch, metadataField } = this.props;
const { newLabelText } = this.state;
/*
XXX TODO - temporary code generates random label string. Remove
when the label creation UI is implemented.
const { newLabelText } = this.state;
*/
// const newLabelText = `label${Math.random()}`;
dispatch({
type: "annotation: add new label to category",
metadataField,
newLabelText
});
this.setState({ newLabelText: "" });
};
activateEditCategoryMode = () => {
const { dispatch, metadataField } = this.props;
dispatch({
type: "annotation: activate category edit mode",
data: metadataField
});
};
disableEditCategoryMode = () => {
const { dispatch } = this.props;
dispatch({
type: "annotation: disable category edit mode"
});
};
handleEditCategory = () => {
const { dispatch, metadataField } = this.props;
const { newCategoryText } = this.state;
dispatch({
type: "annotation: category edited",
metadataField,
newCategoryText,
data: newCategoryText
});
};
handleDeleteCategory = () => {
const { dispatch, metadataField } = this.props;
dispatch({
type: "annotation: delete category",
metadataField
});
};
handleColorChange = () => {
const { dispatch, metadataField } = this.props;
dispatch({
@@ -172,13 +88,12 @@ class Category extends React.Component {
}
renderCategoryItems() {
const { categoricalSelection, metadataField, isUserAnno } = this.props;
const { categoricalSelection, metadataField } = this.props;
const cat = categoricalSelection[metadataField];
const optTuples = sortedCategoryValues([...cat.categoryValueIndices]);
return _.map(optTuples, (tuple, i) => (
<Value
isUserAnno={isUserAnno}
optTuples={optTuples}
key={tuple[1]}
metadataField={metadataField}
@@ -189,17 +104,9 @@ class Category extends React.Component {
}
render() {
const { isExpanded, isChecked, newLabelText, newCategoryText } = this.state;
const {
metadataField,
colorAccessor,
categoricalSelection,
isUserAnno,
annotations,
universe
} = this.props;
const { isExpanded, isChecked } = this.state;
const { metadataField, colorAccessor, categoricalSelection } = this.props;
const { isTruncated } = categoricalSelection[metadataField];
return (
<div
style={{
@@ -237,6 +144,7 @@ class Category extends React.Component {
<span className="bp3-control-indicator" />
{""}
</label>
<span
data-testid={`category-expand-${metadataField}`}
style={{
@@ -244,58 +152,10 @@ class Category extends React.Component {
display: "inline-block"
}}
onClick={() => {
const editingCategory =
annotations.isEditingCategoryName &&
annotations.categoryBeingEdited === metadataField;
if (!editingCategory) {
this.setState({ isExpanded: !isExpanded });
}
this.setState({ isExpanded: !isExpanded });
}}
>
{isUserAnno ? (
<Icon style={{ marginRight: 5 }} icon="tag" iconSize={16} />
) : null}
{annotations.isEditingCategoryName &&
annotations.categoryBeingEdited === metadataField ? (
<form
style={{ display: "inline-block" }}
onSubmit={e => {
e.preventDefault();
this.handleEditCategory();
}}
>
<InputGroup
style={{ position: "relative", top: -1 }}
ref={input => {
this.editableCategoryInput = input;
}}
small
autoFocus
onChange={e => {
this.setState({
newCategoryText: e.target.value
});
}}
defaultValue={metadataField}
rightElement={
<Button
minimal
disabled={newCategoryText.length === 0}
style={{ position: "relative", top: -1 }}
type="button"
icon="small-tick"
data-testclass="submitCategoryNameEdit"
data-testid="submitCategoryNameEdit"
onClick={this.handleEditCategory}
/>
}
/>
</form>
) : (
metadataField
)}
{metadataField}
{isExpanded ? (
<FaChevronDown
data-testclass="category-expand-is-expanded"
@@ -309,113 +169,16 @@ class Category extends React.Component {
)}
</span>
</div>
<div>
{isUserAnno ? (
<>
<Dialog
icon="tag"
title="Add new label"
isOpen={annotations.isAddingNewLabel}
onClose={this.disableAddNewLabelMode}
>
<form
onSubmit={e => {
e.preventDefault();
this.handleAddNewLabelToCategory();
}}
>
<div className={Classes.DIALOG_BODY}>
<div style={{ marginBottom: 20 }}>
<p>New, unique label name:</p>
<InputGroup
autoFocus
onChange={e =>
this.setState({ newLabelText: e.target.value })
}
leftIcon="tag"
/>
</div>
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Tooltip content="Close this dialog without adding a label.">
<Button onClick={this.disableAddNewLabelMode}>
Cancel
</Button>
</Tooltip>
<Button
disabled={
newLabelText.length === 0 ||
universe.schema.annotations.obsByName[
metadataField
].categories.indexOf(newLabelText) !== -1
}
onClick={this.handleAddNewLabelToCategory}
intent="primary"
type="submit"
>
Add new label to category
</Button>
</div>
</div>
</form>
</Dialog>
<Popover
interactionKind={PopoverInteractionKind.HOVER}
boundary="window"
position={Position.RIGHT_TOP}
content={
<Menu>
<MenuItem
icon="tag"
data-testclass="handleAddNewLabelToCategory"
data-testid={`handleAddNewLabelToCategory-${metadataField}`}
onClick={this.activateAddNewLabelMode}
text="Add a new label to this category"
/>
<MenuItem
icon="edit"
data-testclass="activateEditCategoryMode"
data-testid={`activateEditCategoryMode-${metadataField}`}
onClick={this.activateEditCategoryMode}
text="Edit this category's name"
/>
<MenuItem
icon="delete"
intent="danger"
data-testclass="handleDeleteCategory"
data-testid={`handleDeleteCategory-${metadataField}`}
onClick={this.handleDeleteCategory}
text="Delete this category, all associated labels, and remove all cell assignments"
/>
</Menu>
}
>
<Button
style={{ marginLeft: 0 }}
data-testclass="seeActions"
data-testid={`seeActions-${metadataField}`}
icon="more"
minimal
/>
</Popover>
</>
) : null}
<Tooltip
content="Use as color scale"
position="bottom"
hoverOpenDelay={globals.tooltipHoverOpenDelay}
>
<Button
data-testclass="colorby"
data-testid={`colorby-${metadataField}`}
onClick={this.handleColorChange}
active={colorAccessor === metadataField}
intent={colorAccessor === metadataField ? "primary" : "none"}
icon="tint"
/>
</Tooltip>
</div>
<Tooltip content="Use as color scale" position="bottom">
<Button
data-testclass="colorby"
data-testid={`colorby-${metadataField}`}
onClick={this.handleColorChange}
active={colorAccessor === metadataField}
intent={colorAccessor === metadataField ? "primary" : "none"}
icon="tint"
/>
</Tooltip>
</div>
<div style={{ marginLeft: 26 }}>
{isExpanded ? this.renderCategoryItems() : null}
+22 -29
View File
@@ -45,10 +45,7 @@ class Occupancy extends React.Component {
groupBy
); /* Because the signature changes we really need different names for histogram to differentiate signatures */
const categoryValue = category.categoryValues[categoryIndex];
const bins = histogramMap.has(categoryValue)
? histogramMap.get(categoryValue)
: new Array(50).fill(0);
const bins = histogramMap.get(category.categoryValues[categoryIndex]);
const xScale = d3
.scaleLinear()
@@ -105,34 +102,30 @@ class Occupancy extends React.Component {
const occupancy = occupancyMap.get(category.categoryValues[categoryIndex]);
if (occupancy && occupancy.size > 0) {
// not all categories have occupancy, so occupancy may be undefined.
const x = d3
.scaleLinear()
/* get all the keys d[1] as an array, then find the sum */
.domain([0, d3.sum(Array.from(occupancy.values()))])
.range([0, this._WIDTH]);
const categories =
schema.annotations.obsByName[colorAccessor]?.categories;
const x = d3
.scaleLinear()
/* get all the keys d[1] as an array, then find the sum */
.domain([0, d3.sum(Array.from(occupancy.values()))])
.range([0, this._WIDTH]);
const categories = schema.annotations.obsByName[colorAccessor]?.categories;
let currentOffset = 0;
const dfColumn = world.obsAnnotations.col(colorAccessor);
const categoryValues = dfColumn.summarize().categories;
let currentOffset = 0;
const dfColumn = world.obsAnnotations.col(colorAccessor);
const categoryValues = dfColumn.summarize().categories;
let o;
let scaledValue;
let value;
let o;
let scaledValue;
let value;
for (let i = 0, { length } = categoryValues; i < length; i += 1) {
value = categoryValues[i];
o = occupancy.get(value);
scaledValue = x(o);
ctx.fillStyle = o
? colorScale(categories.indexOf(value))
: "rgb(255,255,255)";
ctx.fillRect(currentOffset, 0, o ? scaledValue : 0, this._HEIGHT);
currentOffset += o ? scaledValue : 0;
}
for (let i = 0, { length } = categoryValues; i < length; i += 1) {
value = categoryValues[i];
o = occupancy.get(value);
scaledValue = x(o);
ctx.fillStyle = o
? colorScale(categories.indexOf(value))
: "rgb(255,255,255)";
ctx.fillRect(currentOffset, 0, o ? scaledValue : 0, this._HEIGHT);
currentOffset += o ? scaledValue : 0;
}
};
+12 -270
View File
@@ -1,109 +1,18 @@
// jshint esversion: 6
import { connect } from "react-redux";
import React from "react";
import {
Button,
InputGroup,
Menu,
MenuItem,
Popover,
Position,
Icon,
PopoverInteractionKind
} from "@blueprintjs/core";
import Occupancy from "./occupancy";
import * as globals from "../../globals";
import styles from "./categorical.css";
import { Tooltip } from "@blueprintjs/core";
@connect(state => ({
categoricalSelection: state.categoricalSelection,
annotations: state.annotations,
colorScale: state.colors.scale,
colorAccessor: state.colors.colorAccessor,
schema: state.world?.schema,
world: state.world
}))
class CategoryValue extends React.Component {
constructor(props) {
super(props);
this.state = {
editedLabelText: ""
};
}
handleDeleteValue = () => {
const {
dispatch,
metadataField,
categoryIndex,
categoricalSelection
} = this.props;
const category = categoricalSelection[metadataField];
const label = category.categoryValues[categoryIndex];
dispatch({
type: "annotation: delete label",
metadataField,
label
});
};
handleAddCurrentSelectionToThisLabel = () => {
const {
dispatch,
metadataField,
categoryIndex,
categoricalSelection
} = this.props;
const category = categoricalSelection[metadataField];
const label = category.categoryValues[categoryIndex];
dispatch({
type: "annotation: label current cell selection",
metadataField,
categoryIndex,
label
});
};
handleEditValue = () => {
const {
dispatch,
metadataField,
categoryIndex,
categoricalSelection
} = this.props;
const { editedLabelText } = this.state;
const category = categoricalSelection[metadataField];
const label = category.categoryValues[categoryIndex];
dispatch({
type: "annotation: label edited",
editedLabel: editedLabelText,
metadataField,
categoryIndex,
label
});
this.setState({ editedLabelText: "" });
};
activateEditLabelMode = () => {
const { dispatch, metadataField, categoryIndex } = this.props;
dispatch({
type: "annotation: activate edit label mode",
metadataField,
categoryIndex
});
};
cancelEdit = () => {
const { dispatch, metadataField, categoryIndex } = this.props;
dispatch({
type: "annotation: cancel edit label mode",
metadataField,
categoryIndex
});
};
toggleOff = () => {
const { dispatch, metadataField, categoryIndex } = this.props;
dispatch({
@@ -114,12 +23,12 @@ class CategoryValue extends React.Component {
};
shouldComponentUpdate = nextProps => {
/*
/*
Checks to see if at least one of the following changed:
* world state
* the color accessor (what is currently being colored by)
* if this catagorical value's selection status has changed
If and only if true, update the component
*/
const { props } = this;
@@ -136,14 +45,8 @@ class CategoryValue extends React.Component {
const worldChange = props.world !== nextProps.world;
const colorAccessorChange = props.colorAccessor !== nextProps.colorAccessor;
const annotationsChange = props.annotations !== nextProps.annotations;
return (
valueSelectionChange ||
worldChange ||
colorAccessorChange ||
annotationsChange
);
return valueSelectionChange || worldChange || colorAccessorChange;
};
toggleOn = () => {
@@ -181,9 +84,7 @@ class CategoryValue extends React.Component {
colorAccessor,
colorScale,
i,
schema,
isUserAnno,
annotations
schema
} = this.props;
if (!categoricalSelection) return null;
@@ -204,30 +105,6 @@ class CategoryValue extends React.Component {
categories = schema.annotations.obsByName[colorAccessor]?.categories;
}
let truncatedString = null;
if (
colorAccessor &&
!isColorBy &&
displayString.length > globals.categoryLabelDisplayStringShortLength
) {
truncatedString = `${displayString.slice(
0,
globals.categoryLabelDisplayStringShortLength / 2
)}…${displayString.slice(
-globals.categoryLabelDisplayStringShortLength / 2
)}`;
} else if (
displayString.length > globals.categoryLabelDisplayStringLongLength
) {
truncatedString = `${displayString.slice(
0,
globals.categoryLabelDisplayStringLongLength / 2
)}…${displayString.slice(
-globals.categoryLabelDisplayStringLongLength / 2
)}`;
}
return (
<div
key={i}
@@ -254,7 +131,7 @@ class CategoryValue extends React.Component {
justifyContent: "space-between"
}}
>
<div style={{ display: "flex", alignItems: "baseline" }}>
<div style={{ display: "flex" }}>
<label className="bp3-control bp3-checkbox" style={{ margin: 0 }}>
<input
onChange={selected ? this.toggleOff : this.toggleOn}
@@ -269,88 +146,16 @@ class CategoryValue extends React.Component {
onMouseLeave={this.handleMouseEnter}
/>
</label>
<Tooltip
content={displayString}
disabled={truncatedString === null}
hoverOpenDelay={globals.tooltipHoverOpenDelayQuick}
<span
data-testid={`categorical-value-${metadataField}-${displayString}`}
data-testclass="categorical-value"
style={{ wordBreak: "break-all" }}
>
<span
data-testid={`categorical-value-${metadataField}-${displayString}`}
data-testclass="categorical-value"
style={{
color:
displayString === globals.unassignedCategoryLabel
? "#ababab"
: "black",
fontStyle:
displayString === globals.unassignedCategoryLabel
? "italic"
: "normal",
display: "inline-block",
overflow: "hidden",
lineHeight: "1.1em",
height: "1.1em",
wordBreak: "break-all",
verticalAlign: "middle"
}}
>
{annotations.isEditingLabelName &&
annotations.labelEditable.category === metadataField &&
annotations.labelEditable.label === categoryIndex
? null
: truncatedString || displayString}
</span>
</Tooltip>
{isUserAnno &&
annotations.labelEditable.category === metadataField &&
annotations.isEditingLabelName &&
annotations.labelEditable.label === categoryIndex ? (
<form
onSubmit={e => {
e.preventDefault();
this.handleEditValue();
}}
>
<InputGroup
style={{ position: "relative", top: -1 }}
ref={input => {
this.editableInput = input;
}}
small
autoFocus
onChange={e => {
this.setState({ editedLabelText: e.target.value });
}}
defaultValue={displayString}
rightElement={
<Button
minimal
style={{ position: "relative", top: -1 }}
type="button"
icon="small-tick"
data-testclass="submitEdit"
data-testid="submitEdit"
onClick={this.handleEditValue}
/>
}
/>
</form>
) : null}
{/*
CANCEL IT, WITH BUTTON, ESCAPE KEY, CLICK OUT, UNDO?
<Button
minimal
style={{ position: "relative", top: -1 }}
type="button"
icon="cross"
data-testclass="submitEdit"
data-testid="submitEdit"
onClick={this.cancelEdit}
/> */}
{displayString}
</span>
</div>
<span style={{ flexShrink: 0 }}>
{colorAccessor && !isColorBy && !annotations.isEditingLabelName ? (
{colorAccessor && !isColorBy ? (
<Occupancy category={category} {...this.props} />
) : null}
</span>
@@ -359,22 +164,10 @@ class CategoryValue extends React.Component {
<span
data-testclass="categorical-value-count"
data-testid={`categorical-value-count-${metadataField}-${displayString}`}
style={{
color:
displayString === globals.unassignedCategoryLabel
? "#ababab"
: "black",
fontStyle:
displayString === globals.unassignedCategoryLabel
? "italic"
: "auto"
}}
>
{count}
</span>
<svg
display={isColorBy && categories ? "auto" : "none"}
style={{
marginLeft: 5,
width: 11,
@@ -385,57 +178,6 @@ class CategoryValue extends React.Component {
: "inherit"
}}
/>
{isUserAnno ? (
<span
onMouseEnter={this.handleMouseExit}
onMouseLeave={this.handleMouseEnter}
>
<Popover
interactionKind={PopoverInteractionKind.HOVER}
boundary="window"
position={Position.RIGHT_TOP}
content={
<Menu>
<MenuItem
icon="plus"
data-testclass="handleAddCurrentSelectionToThisLabel"
data-testid={`handleAddCurrentSelectionToThisLabel-${metadataField}`}
onClick={this.handleAddCurrentSelectionToThisLabel}
text={`Re-label currently selected cells as ${displayString}`}
/>
{displayString !== globals.unassignedCategoryLabel ? (
<MenuItem
icon="edit"
text="Edit this label's name"
data-testclass="handleEditValue"
data-testid={`handleEditValue-${metadataField}`}
onClick={this.activateEditLabelMode}
/>
) : null}
{displayString !== globals.unassignedCategoryLabel ? (
<MenuItem
icon="delete"
intent="danger"
data-testclass="handleDeleteValue"
data-testid={`handleDeleteValue-${metadataField}`}
onClick={this.handleDeleteValue}
text="Delete this label, and reassign all cells to type 'unknown'"
/>
) : null}
</Menu>
}
>
<Button
style={{ marginLeft: 0, position: "relative", top: -1 }}
data-testclass="seeActions"
data-testid={`seeActions-${metadataField}`}
icon="more"
small
minimal
/>
</Popover>
</span>
) : null}
</span>
</div>
);
@@ -0,0 +1,83 @@
// jshint esversion: 6
import * as d3 from "d3";
import styles from "./parallelCoordinates.css";
import { yAxis, brushstart } from "./util";
const drawAxes = (
svg,
ctx,
dimensions,
xscale,
height,
width,
handleBrushAction,
handleColorAction
) => {
/*****************************************
******************************************
Handles a brush event, toggling the display of foreground lines.
******************************************
******************************************/
function brush() {
const actives = [];
svg
.selectAll(".parcoords_axis .parcoords_brush")
.filter(() => d3.brushSelection(this))
.each(d => {
actives.push({
dimension: d,
extent: d3.brushSelection(this)
});
});
/* fire action, with selected dimensions & their values */
handleBrushAction(actives);
}
const axes = svg
.selectAll(".parcoords_axis")
.data(dimensions)
.enter()
.append("g")
.attr("class", `${styles.axis} parcoords_axis`)
.attr("transform", (d, i) => `translate(${xscale(i)})`);
axes
.append("g")
.each(d => {
const renderAxis =
"axis" in d
? d.axis.scale(d.scale) // custom axis
: yAxis.scale(d.scale); // default axis
d3.select(this).call(renderAxis);
})
.append("text")
.on("click", d => {
handleColorAction(d.key);
})
.attr("class", styles.title)
.attr("text-anchor", "start")
.text(d => ("description" in d ? `${d.description} 🖌️` : `${d.key} 🖌️`));
// Add and store a brush for each axis.
axes
.append("g")
.attr("class", `${styles.brush} parcoords_brush`)
.each(d => {
d3.select(this).call(
(d.brush = d3
.brushY()
.extent([[-10, 0], [10, height]])
.on("start", brushstart)
.on("brush", brush)
.on("end", brush))
);
})
.selectAll("rect")
.attr("x", -8)
.attr("width", 16);
return axes;
};
export default drawAxes;
@@ -1,16 +0,0 @@
/* https://github.com/palantir/blueprint/issues/2348 */
<defs>
<clipPath id="clip0">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
<g clip-path="url(#clip0)">
<rect width="16" height="16" fill="white"/>
<path d="M1.33415 8.75877C0.939491 8.36411 0.727699 7.82249 0.749957 7.2648L0.926361 2.84501C0.967947 1.80308 1.80308 0.967947 2.84501 0.926361L7.2648 0.749958C7.82249 0.727699 8.36411 0.939492 8.75877 1.33415L14.3595 6.93485C15.1405 7.7159 15.1405 8.98223 14.3595 9.76328L9.76328 14.3595C8.98223 15.1405 7.7159 15.1405 6.93485 14.3595L1.33415 8.75877Z" fill="black"/>
<circle cx="4.5" cy="4.5" r="1.5" fill="white"/>
<circle cx="4.5" cy="11.5" r="3.75" stroke="white" stroke-width="0.5"/>
<circle cx="4.5" cy="11.5" r="3.5" fill="black"/>
<line x1="4.5" y1="10" x2="4.5" y2="13" stroke="white"/>
<line x1="3" y1="11.5" x2="6" y2="11.5" stroke="white"/>
</g>
+12 -45
View File
@@ -22,8 +22,6 @@ import {
keepAroundErrorToast
} from "../framework/toasters";
import { memoize } from "../../util/dataframe/util";
const renderGene = (fuzzySortResult, { handleClick, modifiers, query }) => {
if (!modifiers.matchesPredicate) {
return null;
@@ -71,24 +69,10 @@ class GeneExpression extends React.Component {
super(props);
this.state = {
bulkAdd: "",
tab: "autosuggest",
activeItem: null
tab: "autosuggest"
};
}
_genesToUpper = listGenes => {
// Has to be a Map to preserve index
const upperGenes = new Map();
for (let i = 0, { length } = listGenes; i < length; i += 1) {
upperGenes.set(listGenes[i].toUpperCase(), i);
}
return upperGenes;
};
// eslint-disable-next-line react/sort-comp
_memoGenesToUpper = memoize(this._genesToUpper, arr => arr);
placeholderGeneNames() {
/*
return a string containing gene name suggestions for use as a user hint.
@@ -124,7 +108,6 @@ class GeneExpression extends React.Component {
handleClick(g) {
const { world, dispatch, userDefinedGenes } = this.props;
const varIndexName = world.schema.annotations.var.index;
if (!g) return;
const gene = g.target;
if (userDefinedGenes.indexOf(gene) !== -1) {
postUserErrorToast("That gene already exists");
@@ -156,37 +139,24 @@ class GeneExpression extends React.Component {
*/
if (bulkAdd !== "") {
const genes = _.pull(_.uniq(bulkAdd.split(/[ ,]+/)), "");
if (genes.length === 0) {
return keepAroundErrorToast("Must enter a gene name.");
}
const worldGenes = world.varAnnotations.col(varIndexName).asArray();
// These gene lists are unique enough where memoization is useless
const upperGenes = this._genesToUpper(genes);
const upperUserDefinedGenes = this._genesToUpper(userDefinedGenes);
const upperWorldGenes = this._memoGenesToUpper(worldGenes);
dispatch({ type: "bulk user defined gene start" });
Promise.all(
[...upperGenes.keys()].map(upperGene => {
if (upperUserDefinedGenes.get(upperGene) !== undefined) {
genes.map(gene => {
if (gene.length === 0) {
return keepAroundErrorToast("Must enter a gene name.");
}
if (userDefinedGenes.indexOf(gene) !== -1) {
return keepAroundErrorToast("That gene already exists");
}
const indexOfGene = upperWorldGenes.get(upperGene);
if (indexOfGene === undefined) {
if (
world.varAnnotations.col(varIndexName).indexOf(gene) === undefined
) {
return keepAroundErrorToast(
`${
genes[upperGenes.get(upperGene)]
} doesn't appear to be a valid gene name.`
`${gene} doesn't appear to be a valid gene name.`
);
}
return dispatch(
actions.requestUserDefinedGene(worldGenes[indexOfGene])
);
return dispatch(actions.requestUserDefinedGene(gene));
})
).then(
() => dispatch({ type: "bulk user defined gene complete" }),
@@ -205,7 +175,7 @@ class GeneExpression extends React.Component {
differential
} = this.props;
const varIndexName = world?.schema?.annotations?.var?.index;
const { tab, bulkAdd, activeItem } = this.state;
const { tab, bulkAdd } = this.state;
return (
<div>
@@ -259,13 +229,11 @@ class GeneExpression extends React.Component {
/* this happens on 'enter' */
this.handleClick(g);
}}
initialContent={<MenuItem disabled text="Enter a gene…" />}
inputProps={{ "data-testid": "gene-search" }}
inputValueRenderer={g => {
return "";
}}
itemListPredicate={filterGenes}
onActiveItemChange={item => this.setState({ activeItem: item })}
itemRenderer={renderGene.bind(this)}
items={
world && world.varAnnotations
@@ -278,7 +246,6 @@ class GeneExpression extends React.Component {
className="bp3-button bp3-intent-primary"
data-testid={"add-gene"}
loading={userDefinedGenesLoading}
onClick={() => this.handleClick(activeItem)}
>
Add gene
</Button>
+29 -38
View File
@@ -1,67 +1,58 @@
import { glPointFlags, glPointSize } from "../../util/glHelpers";
// jshint esversion: 6
const mat4 = require("gl-mat4");
const vec3 = require("gl-vec3");
// opacity: https://github.com/spacetx/starfish/blob/master/viz/draw/regions.js
export default function(regl) {
return regl({
vert: `
precision mediump float;
attribute vec2 position;
attribute vec3 color;
attribute float flag;
attribute float size;
uniform float distance;
uniform mat3 projView;
uniform float nPoints;
uniform float minViewportDimension;
varying vec4 fragColor;
const float zBottom = 0.99;
const float zMiddle = 0.;
const float zTop = -1.;
// import getFlags()
${glPointFlags}
// get pointSize()
${glPointSize}
uniform mat4 projection, view;
varying vec3 fragColor;
void main() {
bool isNaN, isSelected, isHighlight;
getFlags(flag, isNaN, isSelected, isHighlight);
float size = pointSize(nPoints, minViewportDimension, isSelected, isHighlight);
gl_PointSize = size * pow(distance, 0.5);
float z = isNaN ? zBottom : (isHighlight ? zTop : zMiddle);
vec3 xy = projView * vec3(position, 1.);
gl_Position = vec4(xy.xy, z, 1.);
float alpha = isNaN ? 0.9 : 1.0;
fragColor = vec4(color, alpha);
gl_PointSize = 7.0 / pow(distance, 2.5) + size;
gl_Position = projection * view * vec4(position.x, -position.y, 0, 1);
fragColor = color;
}`,
frag: `
precision mediump float;
varying vec4 fragColor;
varying vec3 fragColor;
void main() {
if (length(gl_PointCoord.xy - 0.5) > 0.5) {
discard;
}
gl_FragColor = fragColor;
gl_FragColor = vec4(fragColor, 1);
}`,
attributes: {
position: regl.prop("position"),
color: regl.prop("color"),
flag: regl.prop("flag")
size: regl.prop("size")
},
uniforms: {
distance: regl.prop("distance"),
projView: regl.prop("projView"),
nPoints: regl.prop("nPoints"),
minViewportDimension: regl.prop("minViewportDimension")
view: regl.prop("view"),
projection: ({ viewportWidth, viewportHeight }) => {
const aspectRatio = viewportWidth / viewportHeight;
let m = mat4.perspective(
[],
Math.PI / 2,
viewportWidth / viewportHeight,
0.01,
1000
);
if (aspectRatio < 1) {
m = mat4.scale(m, m, vec3.fromValues(1, 1, 1 / aspectRatio));
}
return m;
}
},
count: regl.prop("count"),
+252 -346
View File
@@ -2,76 +2,21 @@
import React from "react";
import * as d3 from "d3";
import { connect } from "react-redux";
import { mat3, vec2 } from "gl-matrix";
import mat4 from "gl-mat4";
import _regl from "regl";
import memoize from "memoize-one";
import * as globals from "../../globals";
import setupSVGandBrushElements from "./setupSVGandBrush";
import setupCentroidSVG from "./setupCentroidSVG";
import actions from "../../actions";
import _camera from "../../util/camera";
import _drawPoints from "./drawPointsRegl";
import { isTypedArray } from "../../util/typeHelpers";
import scaleLinear from "../../util/scaleLinear";
/*
Simple 2D transforms control all point painting. There are three:
* model - convert from underlying per-point coordinate to a layout.
Currently used to move from data to webgl coordinate system.
* camera - apply a 2D camera transformation (pan, zoom)
* projection - apply any transformation required for screen size and layout
*/
function createProjectionTF(viewportWidth, viewportHeight) {
/*
the projection transform accounts for the screen size & other layout
*/
const fractionToUse = 0.95; // fraction of min dimension to use
const topGutterSizePx = 32; // toolbar box height
const heightMinusGutter = viewportHeight - topGutterSizePx;
const minDim = Math.min(viewportWidth, heightMinusGutter);
const aspectScale = [
(fractionToUse * minDim) / viewportWidth,
(fractionToUse * minDim) / viewportHeight
];
const m = mat3.create();
mat3.fromTranslation(m, [
0,
-topGutterSizePx / viewportHeight / aspectScale[1]
]);
mat3.scale(m, m, aspectScale);
return m;
}
function createModelTF() {
/*
preallocate coordinate system transformation between data and gl.
Data arrives in a [0,1] range, and we operate elsewhere in [-1,1].
*/
const m = mat3.fromScaling(mat3.create(), [2, 2]);
mat3.translate(m, m, [-0.5, -0.5]);
return m;
}
function renderThrottle(callback) {
/*
This wraps a call to requestAnimationFrame(), enforcing a single
render callback at any given time (ie, you can call this any number
of times, and it will coallesce multiple inter-frame calls into a
single render).
*/
let rafCurrentlyInProgress = null;
return function f() {
if (rafCurrentlyInProgress) return;
const context = this;
rafCurrentlyInProgress = window.requestAnimationFrame(() => {
callback.apply(context);
rafCurrentlyInProgress = null;
});
};
}
/* https://bl.ocks.org/mbostock/9078690 - quadtree for onClick / hover selections */
@connect(state => ({
universe: state.universe,
world: state.world,
crossfilter: state.crossfilter,
responsive: state.responsive,
@@ -83,17 +28,15 @@ function renderThrottle(callback) {
graphInteractionMode: state.controls.graphInteractionMode,
colorAccessor: state.colors.colorAccessor
}))
class Graph extends React.PureComponent {
computePointPositions = memoize((X, Y, modelTF) => {
class Graph extends React.Component {
computePointPositions = memoize((X, Y, scaleX, scaleY) => {
/*
compute the model coordinate for each point
compute webgl coordinate buffer for each point
*/
const positions = new Float32Array(2 * X.length);
for (let i = 0, len = X.length; i < len; i += 1) {
const p = vec2.fromValues(X[i], Y[i]);
vec2.transformMat3(p, p, modelTF);
positions[2 * i] = p[0];
positions[2 * i + 1] = p[1];
positions[2 * i] = scaleX(X[i]);
positions[2 * i + 1] = scaleY(Y[i]);
}
return positions;
});
@@ -109,65 +52,38 @@ class Graph extends React.PureComponent {
return colors;
});
computeSelectedFlags = memoize(
(crossfilter, flagSelected, flagUnselected) => {
const x = crossfilter.fillByIsSelected(
new Float32Array(crossfilter.size()),
flagSelected,
flagUnselected
);
return x;
}
);
computePointSizesFromCrossfilter = memoize((len, crossfilter) => {
const sizes = new Float32Array(len);
crossfilter.fillByIsSelected(sizes, 4, 0.2);
computePointFlags = memoize(
(world, crossfilter, colorAccessor, centroidLabel) => {
return sizes;
});
computePointSizes = memoize(
(len, crossfilter, metadataField, categoryField) => {
/*
We communicate with the shader using three flags:
- isNaN -- the value is a NaN. Only makes sense when we have a colorAccessor
- isSelected -- the value is selected
- isHightlighted -- the value is highlighted in the UI (orthogonal from selection highlighting)
compute webgl dot size for each point
*/
Due to constraints in webgl vertex shader attributes, these are encoded in a float, "kinda"
like bitmasks.
const selectionSizes = this.computePointSizesFromCrossfilter(
len,
crossfilter
);
let sizes;
We also have separate code paths for generating flags for categorical and
continuous metadata, as they rely on different tests, and some of the flags
(eg, isNaN) are meaningless in the face of categorical metadata.
*/
if (metadataField && categoryField) {
sizes = selectionSizes.slice();
const valuesArr = crossfilter.data.col(metadataField).asArray();
const flagSelected = 1;
const flagNaN = 2;
const flagHighlight = 4;
const flags = this.computeSelectedFlags(
crossfilter,
flagSelected,
0
).slice();
const { metadataField, categoryField } = centroidLabel;
const highlightData = metadataField
? world.obsAnnotations.col(metadataField)?.asArray()
: null;
const colorByColumn = colorAccessor
? world.obsAnnotations.col(colorAccessor)?.asArray() ||
world.varData.col(colorAccessor)?.asArray()
: null;
const colorByData =
colorByColumn && isTypedArray(colorByColumn) ? colorByColumn : null;
if (colorByData || highlightData) {
for (let i = 0, len = flags.length; i < len; i += 1) {
if (highlightData) {
flags[i] += highlightData[i] === categoryField ? flagHighlight : 0;
}
if (colorByData) {
flags[i] += Number.isFinite(colorByData[i]) ? 0 : flagNaN;
for (let i = 0; i < len; i += 1) {
if (valuesArr[i] === categoryField) {
sizes[i] = 10;
}
}
} else {
sizes = selectionSizes;
}
return flags;
return sizes;
}
);
@@ -181,8 +97,7 @@ class Graph extends React.PureComponent {
Y: null,
positions: null,
colors: null,
sizes: null,
flags: null
sizes: null
};
this.state = {
toolSVG: null,
@@ -193,44 +108,55 @@ class Graph extends React.PureComponent {
}
componentDidMount() {
// setup canvas, webgl draw function and camera
const camera = _camera(this.reglCanvas);
// setup canvas and camera
const camera = _camera(this.reglCanvas, { scale: true, rotate: false });
const regl = _regl(this.reglCanvas);
const drawPoints = _drawPoints(regl);
// preallocate webgl buffers
// preallocate buffers
const pointBuffer = regl.buffer();
const colorBuffer = regl.buffer();
const flagBuffer = regl.buffer();
const sizeBuffer = regl.buffer();
// create all default rendering transformations
const modelTF = createModelTF();
const projectionTF = createProjectionTF(
this.reglCanvas.width,
this.reglCanvas.height
);
// preallocate coordinate system transformation between data and gl
const fractionToUse = 0.93; // fraction of dimension to use
const shiftForMenuBar = 0.05;
const transform = {
glScaleX: scaleLinear([0, 1], [-1 * fractionToUse, 1 * fractionToUse]),
glScaleY: scaleLinear(
[0, 1],
[
(1 + shiftForMenuBar) * fractionToUse,
(-1 + shiftForMenuBar) * fractionToUse
]
)
};
// initial draw to canvas
this.renderPoints(
regl,
drawPoints,
colorBuffer,
pointBuffer,
flagBuffer,
camera,
projectionTF
);
/* first time, but this duplicates above function, should be possile to avoid this */
const reglRender = regl.frame(() => {
this.reglDraw(
regl,
drawPoints,
sizeBuffer,
colorBuffer,
pointBuffer,
camera
);
camera.tick();
});
this.reglRenderState = "rendering";
this.setState({
regl,
drawPoints,
pointBuffer,
colorBuffer,
flagBuffer,
sizeBuffer,
camera,
modelTF,
modelInvTF: mat3.invert([], modelTF),
projectionTF
reglRender,
transform
});
}
@@ -248,47 +174,50 @@ class Graph extends React.PureComponent {
colorAccessor,
centroidLabel
} = this.props;
const { regl, toolSVG, centroidSVG } = this.state;
const { reglRender, mode, regl, toolSVG, centroidSVG } = this.state;
let stateChanges = {};
if (reglRender) {
if (
// If it IS RENDERING and it is NOT IN ZOOM mode, stop rendering.
this.reglRenderState === "rendering" &&
graphInteractionMode !== "zoom"
) {
reglRender.cancel();
this.reglRenderState = "paused";
}
if (
// If it is NOT RENDERING and it IS IN ZOOM mode, start rendering
this.reglRenderState !== "rendering" &&
graphInteractionMode === "zoom"
) {
this.restartReglLoop();
this.reglRenderState = "rendering";
}
}
if (regl && world) {
/* update the regl and point rendering state */
/* update the regl state */
const { obsLayout, nObs } = world;
const {
drawPoints,
transform,
camera,
pointBuffer,
colorBuffer,
flagBuffer,
modelTF
sizeBuffer
} = this.state;
let { projectionTF } = this.state;
let needsRepaint = false;
if (
prevProps.responsive.height !== responsive.height ||
prevProps.responsive.width !== responsive.width
) {
projectionTF = createProjectionTF(
this.reglCanvas.width,
this.reglCanvas.height
);
needsRepaint = true;
stateChanges = {
...stateChanges,
projectionTF
};
}
/* coordinates for each point */
const { glScaleX, glScaleY } = transform;
const X = obsLayout.col(layoutChoice.currentDimNames[0]).asArray();
const Y = obsLayout.col(layoutChoice.currentDimNames[1]).asArray();
const newPositions = this.computePointPositions(X, Y, modelTF);
const newPositions = this.computePointPositions(X, Y, glScaleX, glScaleY);
if (renderCache.positions !== newPositions) {
/* update our cache & GL if the buffer changes */
renderCache.positions = newPositions;
pointBuffer({ data: newPositions, dimension: 2 });
needsRepaint = true;
}
/* colors for each point */
@@ -297,52 +226,105 @@ class Graph extends React.PureComponent {
/* update our cache & GL if the buffer changes */
renderCache.colors = newColors;
colorBuffer({ data: newColors, dimension: 3 });
needsRepaint = true;
}
/* flags for each point */
const newFlags = this.computePointFlags(
world,
/* sizes for each point */
const { metadataField, categoryField } = centroidLabel;
const newSizes = this.computePointSizes(
nObs,
crossfilter,
colorAccessor,
centroidLabel
metadataField,
categoryField
);
if (renderCache.flags !== newFlags) {
renderCache.flags = newFlags;
flagBuffer({ data: newFlags, dimension: 1 });
needsRepaint = true;
if (renderCache.sizes !== newSizes) {
/* update our cache & GL if the buffer changes */
renderCache.size = newSizes;
sizeBuffer({ data: newSizes, dimension: 1 });
}
this.count = nObs;
if (needsRepaint) {
this.renderPoints(
regl,
drawPoints,
colorBuffer,
pointBuffer,
flagBuffer,
camera,
projectionTF
);
}
regl._refresh();
this.reglDraw(
regl,
drawPoints,
sizeBuffer,
colorBuffer,
pointBuffer,
camera
);
}
// Centroid SVG creation is disabled for now but should go into the
// first and third cases if enabled
const createToolSVG = () => {
/* clear out whatever was on the div, even if nothing, but usually the brushes etc */
d3.select("#graphAttachPoint")
.select("#tool")
.remove();
let handleStart;
let handleDrag;
let handleEnd;
let handleCancel;
if (selectionTool === "brush") {
handleStart = this.handleBrushStartAction.bind(this);
handleDrag = this.handleBrushDragAction.bind(this);
handleEnd = this.handleBrushEndAction.bind(this);
} else {
handleStart = this.handleLassoStart.bind(this);
handleEnd = this.handleLassoEnd.bind(this);
handleCancel = this.handleLassoCancel.bind(this);
}
const { svg: newToolSVG, tool, container } = setupSVGandBrushElements(
selectionTool,
handleStart,
handleDrag,
handleEnd,
handleCancel,
responsive,
this.graphPaddingRight,
graphInteractionMode
);
stateChanges = { ...stateChanges, toolSVG: newToolSVG, tool, container };
};
const createCentroidSVG = () => {
d3.select("#graphAttachPoint")
.select("#centroid-container")
.remove();
if (centroidLabel.metadataField === "" || !centroidLabel.centroidXY) {
return;
}
const centroidScreen = this.mapPointToScreen(centroidLabel.centroidXY);
const newCentroidSVG = setupCentroidSVG(
responsive,
this.graphPaddingRight,
centroidScreen,
centroidLabel.categoryField,
colorAccessor
);
stateChanges = { ...stateChanges, centroidSVG: newCentroidSVG };
};
// Centroid SVG creation is disabled for now but should go into the first and third cases if enabled
if (
prevProps.responsive.height !== responsive.height ||
prevProps.responsive.width !== responsive.width
) {
// If the window size has changed we want to recreate all SVGs
stateChanges = { ...stateChanges, ...this.createToolSVG() };
createToolSVG();
} else if (
(responsive.height && responsive.width && !toolSVG) ||
selectionTool !== prevProps.selectionTool ||
prevProps.graphInteractionMode !== graphInteractionMode
) {
// first time or change of selection tool6
stateChanges = { ...stateChanges, ...this.createToolSVG() };
createToolSVG();
} else if (
centroidLabel !== prevProps.centroidLabel ||
(responsive.height && responsive.width && !centroidSVG)
@@ -371,83 +353,6 @@ class Graph extends React.PureComponent {
}
}
handleCanvasEvent = e => {
const { camera, projectionTF } = this.state;
if (e.type !== "wheel") e.preventDefault();
if (camera.handleEvent(e, projectionTF)) {
this.renderCanvas();
}
};
createToolSVG() {
/*
Called from componentDidUpdate. Create the tool SVG, and return any
state changes that should be passed to setState().
*/
const { responsive, selectionTool, graphInteractionMode } = this.props;
/* clear out whatever was on the div, even if nothing, but usually the brushes etc */
d3.select("#graphAttachPoint")
.select("#tool")
.remove();
let handleStart;
let handleDrag;
let handleEnd;
let handleCancel;
if (selectionTool === "brush") {
handleStart = this.handleBrushStartAction.bind(this);
handleDrag = this.handleBrushDragAction.bind(this);
handleEnd = this.handleBrushEndAction.bind(this);
} else {
handleStart = this.handleLassoStart.bind(this);
handleEnd = this.handleLassoEnd.bind(this);
handleCancel = this.handleLassoCancel.bind(this);
}
const { svg: newToolSVG, tool, container } = setupSVGandBrushElements(
selectionTool,
handleStart,
handleDrag,
handleEnd,
handleCancel,
responsive,
this.graphPaddingRight,
graphInteractionMode
);
return { toolSVG: newToolSVG, tool, container };
}
createCentroidSVG() {
/*
Called from componentDidUpdate. Create the centroid SVG, and return any
state changes that should be passed to setState().
CURRENTLY UNUSED
*/
const { responsive, centroidLabel, colorAccessor } = this.props;
d3.select("#graphAttachPoint")
.select("#centroid-container")
.remove();
if (centroidLabel.metadataField === "" || !centroidLabel.centroidXY) {
return {};
}
const centroidScreen = this.mapPointToScreen(centroidLabel.centroidXY);
const newCentroidSVG = setupCentroidSVG(
responsive,
this.graphPaddingRight,
centroidScreen,
centroidLabel.categoryField,
colorAccessor
);
return { centroidSVG: newCentroidSVG };
}
brushToolUpdate(tool, container) {
/*
this is called from componentDidUpdate(), so be very careful using
@@ -528,25 +433,76 @@ class Graph extends React.PureComponent {
}
}
reglDraw(regl, drawPoints, sizeBuffer, colorBuffer, pointBuffer, camera) {
regl.clear({
depth: 1,
color: [1, 1, 1, 1]
});
drawPoints({
size: sizeBuffer,
distance: camera.distance,
color: colorBuffer,
position: pointBuffer,
count: this.count,
view: camera.view()
});
}
restartReglLoop() {
const {
regl,
drawPoints,
sizeBuffer,
colorBuffer,
pointBuffer,
camera
} = this.state;
const reglRender = regl.frame(() => {
this.reglDraw(
regl,
drawPoints,
sizeBuffer,
colorBuffer,
pointBuffer,
camera
);
camera.tick();
});
this.reglRenderState = "rendering";
this.setState({
reglRender
});
}
mapScreenToPoint(pin) {
/*
Map an XY coordinates from screen domain to cell/point range,
accounting for current pan/zoom camera.
*/
const { responsive } = this.props;
const { camera, projectionTF, modelInvTF } = this.state;
const cameraInvTF = camera.invView();
const { regl, camera, transform } = this.state;
const { glScaleX, glScaleY } = transform;
/* screen -> gl */
const gl = regl._gl;
// get aspect ratio
const aspect = gl.drawingBufferWidth / gl.drawingBufferHeight;
const scale = aspect < 1 ? 1 / aspect : 1;
// compute inverse view matrix
const inverse = mat4.invert([], camera.view());
// transform screen coordinates -> cell coordinates
const x = (2 * pin[0]) / (responsive.width - this.graphPaddingRight) - 1;
const y = 2 * (1 - pin[1] / (responsive.height - this.graphPaddingTop)) - 1;
const pout = [
x * inverse[14] * aspect * scale + inverse[12],
-(y * inverse[14] * scale + inverse[13])
];
const xy = vec2.fromValues(x, y);
const projectionInvTF = mat3.invert(mat3.create(), projectionTF);
vec2.transformMat3(xy, xy, projectionInvTF);
vec2.transformMat3(xy, xy, cameraInvTF);
vec2.transformMat3(xy, xy, modelInvTF);
const xy = [glScaleX.invert(pout[0]), glScaleY.invert(pout[1])];
return xy;
}
@@ -555,21 +511,29 @@ class Graph extends React.PureComponent {
Map an XY coordinate from cell/point domain to screen range. Inverse
of mapScreenToPoint()
*/
const { responsive } = this.props;
const { camera, projectionTF, modelTF } = this.state;
const cameraTF = camera.view();
const { regl, camera, transform } = this.state;
const { glScaleX, glScaleY } = transform;
const xy = vec2.transformMat3(vec2.create(), xyCell, modelTF);
vec2.transformMat3(xy, xy, cameraTF);
vec2.transformMat3(xy, xy, projectionTF);
const gl = regl._gl;
// get aspect ratio
const aspect = gl.drawingBufferWidth / gl.drawingBufferHeight;
const scale = aspect < 1 ? 1 / aspect : 1;
// compute inverse view matrix
const inverse = mat4.invert([], camera.view());
// variable names are choosen to reflect inverse of those used
// in mapScreenToPoint().
const pout = [glScaleX(xyCell[0]), glScaleY(xyCell[1])];
const x = (pout[0] - inverse[12]) / aspect / scale / inverse[14];
const y = (-pout[1] - inverse[13]) / scale / inverse[14];
const pin = [
Math.round(((x + 1) * (responsive.width - this.graphPaddingRight)) / 2),
Math.round(
((xy[0] + 1) * (responsive.width - this.graphPaddingRight)) / 2
),
Math.round(
-((xy[1] + 1) / 2 - 1) * (responsive.height - this.graphPaddingTop)
-((y + 1) / 2 - 1) * (responsive.height - this.graphPaddingTop)
)
];
return pin;
@@ -690,61 +654,8 @@ class Graph extends React.PureComponent {
});
}
renderPoints(
regl,
drawPoints,
colorBuffer,
pointBuffer,
flagBuffer,
camera,
projectionTF
) {
const { universe } = this.props;
if (!this.reglCanvas || !universe) return;
const cameraTF = camera.view();
const projView = mat3.multiply(mat3.create(), projectionTF, cameraTF);
const { width, height } = this.reglCanvas;
regl.poll();
regl.clear({
depth: 1,
color: [1, 1, 1, 1]
});
drawPoints({
distance: camera.distance(),
color: colorBuffer,
position: pointBuffer,
flag: flagBuffer,
count: this.count,
projView,
nPoints: universe.nObs,
minViewportDimension: Math.min(width || 800, height || 600)
});
regl._gl.flush();
}
renderCanvas = renderThrottle(() => {
const {
regl,
drawPoints,
colorBuffer,
pointBuffer,
flagBuffer,
camera,
projectionTF
} = this.state;
this.renderPoints(
regl,
drawPoints,
colorBuffer,
pointBuffer,
flagBuffer,
camera,
projectionTF
);
});
render() {
const { responsive } = this.props;
const { responsive, graphInteractionMode } = this.props;
return (
<div id="graphWrapper">
@@ -765,11 +676,6 @@ class Graph extends React.PureComponent {
ref={canvas => {
this.reglCanvas = canvas;
}}
onMouseDown={this.handleCanvasEvent}
onMouseUp={this.handleCanvasEvent}
onMouseMove={this.handleCanvasEvent}
onDoubleClick={this.handleCanvasEvent}
onWheel={this.handleCanvasEvent}
/>
</div>
</div>
@@ -7,26 +7,15 @@ import Logo from "../framework/logo";
@connect(state => ({
responsive: state.responsive,
datasetTitle: state.config?.displayNames?.dataset ?? "",
aboutURL: state.config?.links?.["about-dataset"],
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
scatterplotYYaccessor: state.controls.scatterplotYYaccessor
}))
class LeftSideBar extends React.Component {
render() {
const { datasetTitle, aboutURL } = this.props;
const { datasetTitle } = this.props;
const paddingToAvoidScrollBar = 15;
const displayTitle =
datasetTitle.length > globals.datasetTitleMaxCharacterCount
? `${datasetTitle.substring(
0,
Math.floor(globals.datasetTitleMaxCharacterCount / 2)
)}…${datasetTitle.slice(
-Math.floor(globals.datasetTitleMaxCharacterCount / 2)
)}`
: datasetTitle;
return (
<div
style={{
@@ -52,8 +41,7 @@ class LeftSideBar extends React.Component {
userSelect: "none"
}}
>
cell
<span
cell<span
style={{
position: "relative",
top: 1,
@@ -62,26 +50,19 @@ class LeftSideBar extends React.Component {
}}
>
×
</span>
gene
</span>gene
</span>
<div
<span
data-testid="header"
style={{
fontSize: 14,
position: "relative",
top: -6,
display: "inline-block",
width: "190px",
marginLeft: "7px",
height: "1.1em",
overflow: "hidden",
wordBreak: "break-all"
marginLeft: 7,
top: -8
}}
title={datasetTitle}
>
{aboutURL ? <a href={aboutURL}>{displayTitle}</a> : displayTitle}
</div>
{datasetTitle}
</span>
</div>
);
}
@@ -3,10 +3,9 @@ import React from "react";
import { AnchorButton, Tooltip } from "@blueprintjs/core";
import { connect } from "react-redux";
import { World } from "../../util/stateManager";
import { tooltipHoverOpenDelay } from "../../globals";
@connect()
class CellSetButton extends React.PureComponent {
class CellSetButton extends React.Component {
set() {
const {
differential,
@@ -39,8 +38,7 @@ class CellSetButton extends React.PureComponent {
return (
<Tooltip
content="Save current selection for differential expression computation"
position="bottom"
hoverOpenDelay={tooltipHoverOpenDelay}
position="top"
>
<AnchorButton
type="button"
+9 -17
View File
@@ -5,10 +5,8 @@ import {
Button,
Popover,
NumericInput,
Icon,
Tooltip
Icon
} from "@blueprintjs/core";
import { tooltipHoverOpenDelay } from "../../globals";
function Clip(props) {
const {
@@ -42,20 +40,14 @@ function Clip(props) {
>
<Popover
target={
<Tooltip
content="Clip all continuous values to a percentile range"
position="bottom"
hoverOpenDelay={tooltipHoverOpenDelay}
>
<Button
type="button"
data-testid="visualization-settings"
className={`bp3-button bp3-icon-timeline-bar-chart ${activeClipClass}`}
style={{
cursor: "pointer"
}}
/>
</Tooltip>
<Button
type="button"
data-testid="visualization-settings"
className={`bp3-button bp3-icon-timeline-bar-chart ${activeClipClass}`}
style={{
cursor: "pointer"
}}
/>
}
position={Position.BOTTOM_RIGHT}
onOpening={handleClipOpening}
+57 -101
View File
@@ -16,10 +16,6 @@ import CellSetButton from "./cellSetButtons";
import InformationMenu from "./infoMenu";
import UndoRedoReset from "./undoRedoReset";
import Clip from "./clip";
import {
tooltipHoverOpenDelay,
tooltipHoverOpenDelayQuick
} from "../../globals";
@connect(state => ({
universe: state.universe,
@@ -41,10 +37,7 @@ import {
celllist2: state.differential.celllist2,
libraryVersions: state.config?.library_versions, // eslint-disable-line camelcase
undoDisabled: state["@@undoable/past"].length === 0,
redoDisabled: state["@@undoable/future"].length === 0,
aboutLink: state.config?.links?.["about-dataset"],
disableDiffexp: state.config?.parameters?.["disable-diffexp"] ?? false,
diffexpMayBeSlow: state.config?.parameters?.["diffexp-may-be-slow"] ?? false
redoDisabled: state["@@undoable/future"].length === 0
}))
class MenuBar extends React.Component {
static isValidDigitKeyEvent(e) {
@@ -260,65 +253,6 @@ class MenuBar extends React.Component {
});
};
renderDiffExp() {
/* diffexp-related buttons may be disabled */
const { disableDiffexp, differential, diffexpMayBeSlow } = this.props;
if (disableDiffexp) return null;
const haveBothCellSets =
!!differential.celllist1 && !!differential.celllist2;
const tipMessage =
"See top 10 differentially expressed genes" +
(diffexpMayBeSlow
? " (CAUTION: large dataset - may take longer or fail)"
: "");
return (
<div className="bp3-button-group" style={{ marginRight: 10 }}>
<CellSetButton {...this.props} eitherCellSetOneOrTwo={1} />
<CellSetButton {...this.props} eitherCellSetOneOrTwo={2} />
{!differential.diffExp ? (
<Tooltip
content={tipMessage}
position="bottom"
hoverOpenDelay={tooltipHoverOpenDelayQuick}
>
<AnchorButton
disabled={!haveBothCellSets}
intent="primary"
data-testid="diffexp-button"
loading={differential.loading}
icon="left-join"
fill
type="button"
onClick={this.computeDiffExp}
>
Compute Differential Expression
</AnchorButton>
</Tooltip>
) : null}
{differential.diffExp ? (
<Tooltip
content="Remove differentially expressed gene list and clear cell selections"
position="bottom"
hoverOpenDelay={tooltipHoverOpenDelayQuick}
>
<Button
type="button"
fill
intent="warning"
onClick={this.clearDifferentialExpression}
>
Clear Differential Expression
</Button>
</Tooltip>
) : null}
</div>
);
}
render() {
const {
dispatch,
@@ -332,11 +266,13 @@ class MenuBar extends React.Component {
clipPercentileMin,
clipPercentileMax,
layoutChoice,
graphInteractionMode,
aboutLink
graphInteractionMode
} = this.props;
const { pendingClipPercentiles } = this.state;
const haveBothCellSets =
!!differential.celllist1 && !!differential.celllist2;
// constants used to create selection tool button
let selectionTooltip;
let selectionButtonClass;
@@ -356,11 +292,48 @@ class MenuBar extends React.Component {
top: 8
}}
>
{this.renderDiffExp()}
<div className="bp3-button-group" style={{ marginRight: 10 }}>
<CellSetButton {...this.props} eitherCellSetOneOrTwo={1} />
<CellSetButton {...this.props} eitherCellSetOneOrTwo={2} />
{!differential.diffExp ? (
<Tooltip
content="Add two cells selections, see the top 15 differentially expressed genes between them"
position="bottom"
>
<AnchorButton
disabled={!haveBothCellSets}
intent="primary"
data-testid="diffexp-button"
loading={differential.loading}
icon="left-join"
fill
type="button"
onClick={this.computeDiffExp}
>
Compute Differential Expression
</AnchorButton>
</Tooltip>
) : null}
{differential.diffExp ? (
<Tooltip
content="Remove differentially expressed gene list and clear cell selections"
position="bottom"
>
<Button
type="button"
fill
intent="warning"
onClick={this.clearDifferentialExpression}
>
Clear Differential Expression
</Button>
</Tooltip>
) : null}
</div>
<Tooltip
content="Show only metadata and cells which are currently selected"
position="bottom"
hoverOpenDelay={tooltipHoverOpenDelay}
position="left"
>
<AnchorButton
type="button"
@@ -382,11 +355,7 @@ class MenuBar extends React.Component {
</AnchorButton>
</Tooltip>
<div className="bp3-button-group">
<Tooltip
content={selectionTooltip}
position="bottom"
hoverOpenDelay={tooltipHoverOpenDelay}
>
<Tooltip content={selectionTooltip} position="left">
<Button
type="button"
data-testid="mode-lasso"
@@ -403,11 +372,7 @@ class MenuBar extends React.Component {
}}
/>
</Tooltip>
<Tooltip
content="Drag to pan, scroll to zoom"
position="bottom"
hoverOpenDelay={tooltipHoverOpenDelay}
>
<Tooltip content="Pan and zoom" position="left">
<Button
type="button"
data-testid="mode-pan-zoom"
@@ -433,20 +398,14 @@ class MenuBar extends React.Component {
>
<Popover
target={
<Tooltip
content="Select embedding for visualization"
position="bottom"
hoverOpenDelay={tooltipHoverOpenDelay}
>
<Button
type="button"
data-testid="layout-choice"
className="bp3-button bp3-icon-heatmap"
style={{
cursor: "pointer"
}}
/>
</Tooltip>
<Button
type="button"
data-testid="layout-choice"
className="bp3-button bp3-icon-heatmap"
style={{
cursor: "pointer"
}}
/>
}
position={Position.BOTTOM_RIGHT}
content={
@@ -460,7 +419,7 @@ class MenuBar extends React.Component {
}}
>
<RadioGroup
label="Embedding Choice"
label="Layout Choice"
onChange={this.handleLayoutChoiceChange}
selectedValue={layoutChoice.current}
>
@@ -496,10 +455,7 @@ class MenuBar extends React.Component {
undoDisabled={undoDisabled}
redoDisabled={redoDisabled}
/>
<InformationMenu
libraryVersions={libraryVersions}
aboutLink={aboutLink}
/>
<InformationMenu libraryVersions={libraryVersions} />
</div>
);
}
+1 -12
View File
@@ -3,23 +3,12 @@ import React from "react";
import { Button, Popover, Menu, MenuItem, Position } from "@blueprintjs/core";
function InformationMenu(props) {
const { libraryVersions, aboutLink } = props;
const { libraryVersions } = props;
return (
<div style={{ marginLeft: 10 }} className="bp3-button-group">
<Popover
content={
<Menu>
{aboutLink ? (
<MenuItem
href={aboutLink}
target="_blank"
icon="document-open"
text="About this dataset"
/>
) : (
""
)}
<MenuItem
href="https://chanzuckerberg.github.io/cellxgene/faq.html"
target="_blank"
+3 -13
View File
@@ -1,7 +1,6 @@
// jshint esversion: 6
import React from "react";
import { AnchorButton, Tooltip } from "@blueprintjs/core";
import { tooltipHoverOpenDelay } from "../../globals";
function InformationMenu(props) {
const {
@@ -14,11 +13,7 @@ function InformationMenu(props) {
} = props;
return (
<div style={{ marginLeft: 10 }} className="bp3-button-group">
<Tooltip
content="Undo"
position="bottom"
hoverOpenDelay={tooltipHoverOpenDelay}
>
<Tooltip content="Undo" position="left">
<AnchorButton
type="button"
className="bp3-button bp3-icon-undo"
@@ -31,11 +26,7 @@ function InformationMenu(props) {
}}
/>
</Tooltip>
<Tooltip
content="Redo"
position="bottom"
hoverOpenDelay={tooltipHoverOpenDelay}
>
<Tooltip content="Redo" position="left">
<AnchorButton
type="button"
className="bp3-button bp3-icon-redo"
@@ -50,8 +41,7 @@ function InformationMenu(props) {
</Tooltip>
<Tooltip
content="Reset cellxgene, clearing all selections"
position="bottom"
hoverOpenDelay={tooltipHoverOpenDelay}
position="left"
>
<AnchorButton
disabled={isResetDisabled()}
@@ -1,64 +1,44 @@
import { glPointFlags, glPointSize } from "../../util/glHelpers";
// jshint esversion: 6
const mat4 = require("gl-mat4");
// opacity: https://github.com/spacetx/starfish/blob/master/viz/draw/regions.js
export default function(regl) {
return regl({
vert: `
precision mediump float;
attribute vec2 position;
attribute vec3 color;
attribute float flag;
uniform mat3 projection;
uniform float nPoints;
uniform float minViewportDimension;
varying vec4 fragColor;
const float zBottom = 0.99;
const float zMiddle = 0.;
const float zTop = -1.;
// import getFlags()
${glPointFlags}
// get pointSize()
${glPointSize}
attribute float size;
uniform float distance;
uniform mat4 projection, view;
varying vec3 fragColor;
void main() {
bool isNaN, isSelected, isHighlight;
getFlags(flag, isNaN, isSelected, isHighlight);
gl_PointSize = pointSize(nPoints, minViewportDimension, isSelected, isHighlight);
float z = isNaN ? zBottom : (isHighlight ? zTop : zMiddle);
vec3 xy = projection * vec3(position, 1.);
gl_Position = vec4(xy.xy, z, 1.);
float alpha = isNaN ? 0.9 : 1.0;
fragColor = vec4(color, alpha);
gl_PointSize = 7.0 / pow(distance, 2.5) + size;
gl_Position = projection * view * vec4(position.x, -position.y, 0, 1);
fragColor = color;
}`,
frag: `
precision mediump float;
varying vec4 fragColor;
varying vec3 fragColor;
void main() {
if (length(gl_PointCoord.xy - 0.5) > 0.5) {
discard;
}
gl_FragColor = fragColor;
gl_FragColor = vec4(fragColor, 1);
}`,
attributes: {
position: regl.prop("position"),
color: regl.prop("color"),
flag: regl.prop("flag")
size: regl.prop("size")
},
uniforms: {
projection: regl.prop("projection"),
nPoints: regl.prop("nPoints"),
minViewportDimension: regl.prop("minViewportDimension")
distance: regl.prop("distance"),
view: regl.prop("view"),
projection: () => mat4.perspective([], Math.PI / 2, 1, 0.01, 1000)
},
count: regl.prop("count"),
+129 -195
View File
@@ -1,47 +1,48 @@
// jshint esversion: 6
// https://bl.ocks.org/Jverma/076377dd0125b1a508621441752735fc
// https://peterbeshai.com/scatterplot-in-d3-with-voronoi-interaction.html
import React from "react";
import { connect } from "react-redux";
import { Button, ButtonGroup } from "@blueprintjs/core";
import _regl from "regl";
import * as d3 from "d3";
import { mat3 } from "gl-matrix";
import memoize from "memoize-one";
import { isTypedArray } from "../../util/typeHelpers";
import * as globals from "../../globals";
import _camera from "../../util/camera";
import setupScatterplot from "./setupScatterplot";
import styles from "./scatterplot.css";
import _drawPoints from "./drawPointsRegl";
import scaleLinear from "../../util/scaleLinear";
import { margin, width, height } from "./util";
import finiteExtent from "../../util/finiteExtent";
function createProjectionTF(viewportWidth, viewportHeight) {
/*
the projection transform accounts for the screen size & other layout
*/
const m = mat3.create();
return mat3.projection(m, viewportWidth, viewportHeight);
}
@connect(state => {
const { world, crossfilter, universe } = state;
const { world, crossfilter } = state;
const { scatterplotXXaccessor, scatterplotYYaccessor } = state.controls;
const expressionX = scatterplotXXaccessor
? world.varData.col(scatterplotXXaccessor)?.asArray()
: null;
const expressionY = scatterplotYYaccessor
? world.varData.col(scatterplotYYaccessor)?.asArray()
: null;
const expressionX =
world &&
scatterplotXXaccessor &&
world.varData.hasCol(scatterplotXXaccessor)
? world.varData.col(scatterplotXXaccessor).asArray()
: null;
const expressionY =
world &&
scatterplotYYaccessor &&
world.varData.hasCol(scatterplotYYaccessor)
? world.varData.col(scatterplotYYaccessor).asArray()
: null;
return {
world,
universe,
colorRGB: state.colors.rgb,
colorScale: state.colors.scale,
colorAccessor: state.colors.colorAccessor,
centroidLabel: state.centroidLabel,
// Accessors are var/gene names (strings)
scatterplotXXaccessor,
scatterplotYYaccessor,
@@ -52,80 +53,10 @@ function createProjectionTF(viewportWidth, viewportHeight) {
expressionX,
expressionY,
crossfilter,
responsive: state.responsive
crossfilter
};
})
class Scatterplot extends React.PureComponent {
computePointPositions = memoize((X, Y, xScale, yScale) => {
const positions = new Float32Array(2 * X.length);
for (let i = 0, len = X.length; i < len; i += 1) {
positions[2 * i] = xScale(X[i]);
positions[2 * i + 1] = yScale(Y[i]);
}
return positions;
});
computePointColors = memoize(rgb => {
/*
compute webgl colors for each point
*/
const colors = new Float32Array(3 * rgb.length);
for (let i = 0, len = rgb.length; i < len; i += 1) {
colors.set(rgb[i], 3 * i);
}
return colors;
});
computeSelectedFlags = memoize(
(crossfilter, flagSelected, flagUnselected) => {
const x = crossfilter.fillByIsSelected(
new Float32Array(crossfilter.size()),
flagSelected,
flagUnselected
);
return x;
}
);
computePointFlags = memoize(
(world, crossfilter, colorAccessor, centroidLabel) => {
const flagSelected = 1;
const flagNaN = 2;
const flagHighlight = 4;
const flags = this.computeSelectedFlags(
crossfilter,
flagSelected,
0
).slice();
const { metadataField, categoryField } = centroidLabel;
const highlightData = metadataField
? world.obsAnnotations.col(metadataField)?.asArray()
: null;
const colorByColumn = colorAccessor
? world.obsAnnotations.col(colorAccessor)?.asArray() ||
world.varData.col(colorAccessor)?.asArray()
: null;
const colorByData =
colorByColumn && isTypedArray(colorByColumn) ? colorByColumn : null;
if (colorByData || highlightData) {
for (let i = 0, len = flags.length; i < len; i += 1) {
if (highlightData) {
flags[i] += highlightData[i] === categoryField ? flagHighlight : 0;
}
if (colorByData) {
flags[i] += Number.isFinite(colorByData[i]) ? 0 : flagNaN;
}
}
}
return flags;
}
);
class Scatterplot extends React.Component {
constructor(props) {
super(props);
this.count = 0;
@@ -133,7 +64,7 @@ class Scatterplot extends React.PureComponent {
this.renderCache = {
positions: null,
colors: null,
flags: null,
sizes: null,
xScale: null,
yScale: null
};
@@ -154,37 +85,39 @@ class Scatterplot extends React.PureComponent {
this.renderCache = { ...this.renderCache, ...scales };
}
const camera = _camera(this.reglCanvas, { scale: true, rotate: false });
const regl = _regl(this.reglCanvas);
const drawPoints = _drawPoints(regl);
// Create render transform
const projectionTF = createProjectionTF(
this.reglCanvas.width,
this.reglCanvas.height
);
const drawPoints = _drawPoints(regl);
// preallocate buffers
const pointBuffer = regl.buffer();
const colorBuffer = regl.buffer();
const flagBuffer = regl.buffer();
const sizeBuffer = regl.buffer();
this.renderPoints(
regl,
drawPoints,
flagBuffer,
colorBuffer,
pointBuffer,
projectionTF
);
const reglRender = regl.frame(() => {
this.reglDraw(
regl,
drawPoints,
sizeBuffer,
colorBuffer,
pointBuffer,
camera
);
camera.tick();
});
this.reglRenderState = "rendering";
this.setState({
regl,
flagBuffer,
sizeBuffer,
pointBuffer,
colorBuffer,
svg,
drawPoints,
projectionTF
reglRender,
camera,
drawPoints
});
}
@@ -196,23 +129,22 @@ class Scatterplot extends React.PureComponent {
scatterplotYYaccessor,
expressionX,
expressionY,
colorRGB,
colorAccessor,
centroidLabel
colorRGB
} = this.props;
const {
reglRender,
regl,
pointBuffer,
colorBuffer,
flagBuffer,
sizeBuffer,
svg,
drawPoints,
projectionTF
camera
} = this.state;
if (
scatterplotXXaccessor !== prevProps.scatterplotXXaccessor ||
scatterplotYYaccessor !== prevProps.scatterplotYYaccessor ||
scatterplotXXaccessor !== prevProps.scatterplotXXaccessor || // was CLU now FTH1 etc
scatterplotYYaccessor !== prevProps.scatterplotYYaccessor || // was CLU now FTH1 etc
world !== prevProps.world // shape or clip of world changed
) {
const scales = Scatterplot.setupScales(expressionX, expressionY);
@@ -220,55 +152,76 @@ class Scatterplot extends React.PureComponent {
this.renderCache = { ...this.renderCache, ...scales };
}
if (world && regl) {
if (reglRender && this.reglRenderState === "rendering") {
reglRender.cancel();
this.reglRenderState = "paused";
}
if (
world &&
regl &&
pointBuffer &&
colorBuffer &&
sizeBuffer &&
expressionX &&
expressionY &&
scatterplotXXaccessor &&
scatterplotYYaccessor
) {
const { renderCache } = this;
const { xScale, yScale } = this.renderCache;
let needsRepaint = false;
const cellCount = expressionX.length;
const newPositions = this.computePointPositions(
expressionX,
expressionY,
xScale,
yScale
);
if (renderCache.positions !== newPositions) {
renderCache.positions = newPositions;
// Points change when expressionX or expressionY change.
if (
!renderCache.positions ||
expressionX !== prevProps.expressionX ||
expressionY !== prevProps.expressionY
) {
if (!renderCache.positions) {
renderCache.positions = new Float32Array(2 * cellCount);
}
const glScaleX = scaleLinear([0, width], [-0.95, 0.95]);
const glScaleY = scaleLinear([0, height], [-1, 1]);
for (let i = 0, { positions } = renderCache; i < cellCount; i += 1) {
positions[2 * i] = glScaleX(xScale(expressionX[i]));
positions[2 * i + 1] = glScaleY(yScale(expressionY[i]));
}
pointBuffer({ data: renderCache.positions, dimension: 2 });
needsRepaint = true;
}
/* colors for each point */
const newColors = this.computePointColors(colorRGB);
if (renderCache.colors !== newColors) {
renderCache.colors = newColors;
// Colors for each point - change only when props.colorsRGB change.
if (!renderCache.colors || colorRGB !== prevProps.colorRGB) {
if (!renderCache.colors) {
renderCache.colors = new Float32Array(3 * cellCount);
}
for (let i = 0, { colors } = renderCache; i < cellCount; i += 1) {
colors.set(colorRGB[i], 3 * i);
}
colorBuffer({ data: renderCache.colors, dimension: 3 });
needsRepaint = true;
}
const newFlags = this.computePointFlags(
world,
crossfilter,
colorAccessor,
centroidLabel
// Sizes for each point - updates are triggered only when selected
// obs change
if (!renderCache.sizes || crossfilter !== prevProps.crossfilter) {
if (!renderCache.sizes) {
renderCache.sizes = new Float32Array(cellCount);
}
crossfilter.fillByIsSelected(renderCache.sizes, 4, 0.2);
sizeBuffer({ data: renderCache.sizes, dimension: 1 });
}
this.count = cellCount;
regl._refresh();
this.reglDraw(
regl,
drawPoints,
sizeBuffer,
colorBuffer,
pointBuffer,
camera
);
if (renderCache.flags !== newFlags) {
renderCache.flags = newFlags;
flagBuffer({ data: renderCache.flags, dimension: 1 });
needsRepaint = true;
}
this.count = expressionX.length;
if (needsRepaint) {
this.renderPoints(
regl,
drawPoints,
flagBuffer,
colorBuffer,
pointBuffer,
projectionTF
);
}
}
}
@@ -288,6 +241,22 @@ class Scatterplot extends React.PureComponent {
};
}
reglDraw(regl, drawPoints, sizeBuffer, colorBuffer, pointBuffer, camera) {
regl.clear({
depth: 1,
color: [1, 1, 1, 1]
});
drawPoints({
size: sizeBuffer,
distance: camera.distance,
color: colorBuffer,
position: pointBuffer,
count: this.count,
view: camera.view()
});
}
drawAxesSVG(xScale, yScale, svg) {
const { scatterplotYYaccessor, scatterplotXXaccessor } = this.props;
svg.selectAll("*").remove();
@@ -338,41 +307,6 @@ class Scatterplot extends React.PureComponent {
.text(scatterplotXXaccessor);
}
renderPoints(
regl,
drawPoints,
flagBuffer,
colorBuffer,
pointBuffer,
projectionTF
) {
if (!this.reglCanvas) return;
const { universe, responsive } = this.props;
// The viewport dimension is used to scale points, so we want to pass
// the dimension of the MAIN viewport, not the scatterplot viewport.
// Slightly hacky, but we want all points to scale uniformly. Perhaps
// this should move to the redux state and be shared?
const { width: cvWidth, height: cvHeight } = responsive;
regl.poll();
regl.clear({
depth: 1,
color: [1, 1, 1, 1]
});
drawPoints({
flag: flagBuffer,
color: colorBuffer,
position: pointBuffer,
projection: projectionTF,
count: this.count,
nPoints: universe.nObs,
minViewportDimension: Math.min(
cvWidth - globals.leftSidebarWidth || width,
cvHeight || height
)
});
regl._gl.flush();
}
render() {
const { dispatch } = this.props;
const { minimized } = this.state;
@@ -433,7 +367,7 @@ class Scatterplot extends React.PureComponent {
height={height}
data-testid="scatterplot"
style={{
marginLeft: margin.left,
marginLeft: margin.left - 7,
marginTop: margin.top
}}
ref={canvas => {
+2 -16
View File
@@ -3,9 +3,6 @@ import { Colors } from "@blueprintjs/core";
/* if a categorical metadata field has more options than this, truncate */
export const maxCategoricalOptionsToDisplay = 100;
/* default "unassigned" value for user-created categorical metadata */
export const unassignedCategoryLabel = "unassigned";
/*
these are default values for configuration the CLI may supply.
See the REST API and CLI specs for more info.
@@ -14,11 +11,8 @@ export const configDefaults = {
features: {},
displayNames: {},
parameters: {
"max-category-items": 1000,
"disable-diffexp": false,
"diffexp-may-be-slow": false
},
links: {}
"max-category-items": 1000
}
};
/* colors */
@@ -53,8 +47,6 @@ export const maxParagraphWidth = 600;
export const cellxgeneTitleLeftPadding = 14;
export const cellxgeneTitleTopPadding = 7;
export const datasetTitleMaxCharacterCount = 25;
export const maxControlsWidth = 800;
export const graphMargin = { top: 20, right: 10, bottom: 30, left: 40 };
@@ -70,12 +62,6 @@ export const leftSidebarSectionHeading = {
letterSpacing: ".05em"
};
export const leftSidebarSectionPadding = 10;
export const categoryLabelDisplayStringLongLength = 35;
export const categoryLabelDisplayStringShortLength = 15;
/* various timing-related behaviors */
export const tooltipHoverOpenDelay = 1000; /* ms delay before a tooltip displays */
export const tooltipHoverOpenDelayQuick = 500;
let _API = {
// prefix: "http://api.clustering.czi.technology/api/",
-79
View File
@@ -1,79 +0,0 @@
/*
Reducers for annotation UI-state.
*/
const Annotations = (
state = {
isEditingCategoryName: false,
isEditingLabelName: false,
categoryBeingEdited: false,
labelEditable: { category: null, label: null }
},
action
) => {
switch (action.type) {
/* CATEGORY */
case "annotation: activate add new label mode":
return {
...state,
isAddingNewLabel: true,
categoryAddingNewLabel: action.data
};
case "annotation: disable add new label mode":
return {
...state,
isAddingNewLabel: false,
categoryAddingNewLabel: null
};
case "annotation: add new label to category":
return {
...state,
isAddingNewLabel: false,
categoryAddingNewLabel: null
};
case "annotation: activate category edit mode":
return {
...state,
isEditingCategoryName: true,
categoryBeingEdited: action.data
};
case "annotation: disable category edit mode":
return {
...state,
isEditingCategoryName: false,
categoryBeingEdited: null
};
case "annotation: category edited":
return {
...state,
isEditingCategoryName: true,
categoryBeingEdited: null
};
/* LABEL */
case "annotation: activate edit label mode":
return {
...state,
isEditingLabelName: true,
labelEditable: {
category: action.metadataField,
label: action.categoryIndex
}
};
case "annotation: cancel edit label mode":
return {
...state,
isEditingLabelName: false,
labelEditable: { category: null, label: null }
};
case "annotation: label edited":
return {
...state,
isEditingLabelName: false,
labelEditable: { category: null, label: null }
};
default:
return state;
}
};
export default Annotations;
-53
View File
@@ -1,53 +0,0 @@
const Autosave = (
state = {
saveInProgress: false,
error: false,
lastSavedObsAnnotations: null
},
action,
nextSharedState
) => {
switch (action.type) {
case "initial data load complete (universe exists)": {
/* don't save on init */
const { universe } = nextSharedState;
return {
...state,
error: false,
saveInProgress: false,
lastSavedObsAnnotations: universe.obsAnnotations
};
}
case "writable obs annotations - save started": {
return {
...state,
saveInProgress: true
};
}
case "writable obs annotations - save error": {
const { message } = action;
return {
...state,
error: message,
saveInProgress: false
};
}
case "writable obs annotations - save complete": {
const lastSavedObsAnnotations = action.obsAnnotations;
return {
...state,
saveInProgress: false,
error: false,
lastSavedObsAnnotations
};
}
default:
return { ...state };
}
};
export default Autosave;
+4 -42
View File
@@ -1,4 +1,4 @@
import { ControlsHelpers as CH } from "../util/stateManager";
import { ControlsHelpers } from "../util/stateManager";
import * as globals from "../globals";
function maxCategoryItems(state) {
@@ -20,11 +20,10 @@ const CategoricalSelection = (
case "reset World to eq Universe":
case "set clip quantiles": {
const { world } = nextSharedState;
const newState = CH.createCategoricalSelection(
world,
CH.selectableCategoryNames(world, maxCategoryItems(prevSharedState))
return ControlsHelpers.createCategoricalSelection(
maxCategoryItems(prevSharedState),
world
);
return newState;
}
case "categorical metadata filter select": {
@@ -97,43 +96,6 @@ const CategoricalSelection = (
return newCategoricalSelection;
}
case "annotation: create category": {
const { world } = nextSharedState;
const name = action.data;
return {
...state,
...CH.createCategoricalSelection(world, [name])
};
}
case "annotation: category edited": {
const name = action.metadataField;
const newName = action.newCategoryText;
const { [name]: catSeln, ...newState } = state;
newState[newName] = catSeln;
return newState;
}
case "annotation: delete category": {
const name = action.metadataField;
const { [name]: _, ...newState } = state;
return newState;
}
case "annotation: label current cell selection":
case "annotation: add new label to category":
case "annotation: label edited":
case "annotation: delete label": {
/* need to rebuild the state for this annotation */
const { world } = nextSharedState;
const name = action.metadataField;
const { [name]: _, ...partialState } = state;
return {
...partialState,
...CH.createCategoricalSelection(world, [name])
};
}
default: {
return state;
}
-21
View File
@@ -108,27 +108,6 @@ const ColorsReducer = (
};
}
case "annotation: add new label to category":
case "annotation: label current cell selection":
case "annotation: delete label": {
const { world } = nextSharedState;
const { colorMode, colorAccessor } = state;
const { metadataField } = action;
if (
colorMode !== "color by categorical metadata" ||
colorAccessor !== metadataField
)
return state;
/* else, we need to rebuild colors as labels have changed! */
const { rgb, scale } = ColorHelpers.createColors(
world,
colorMode,
colorAccessor
);
return { ...state, rgb, scale };
}
case "clear differential expression": {
const { world: prevWorld, controls: prevControls } = prevSharedState;
const resetColorState = ColorHelpers.checkIfColorByDiffexpAndResetColors(
+6 -1
View File
@@ -2,10 +2,12 @@
import _ from "lodash";
import { WorldUtil } from "../util/stateManager";
const Controls = (
state = {
// data loading flag
loading: true,
loading: false,
error: null,
// all of the data + selection state
@@ -43,6 +45,7 @@ const Controls = (
}
case "initial data load complete (universe exists)": {
/* first light - create world & other data-driven defaults */
WorldUtil.clearCaches();
return {
...state,
loading: false,
@@ -51,12 +54,14 @@ const Controls = (
};
}
case "reset World to eq Universe": {
WorldUtil.clearCaches();
return {
...state,
resettingInterface: false
};
}
case "set World to current selection": {
WorldUtil.clearCaches();
return {
...state,
loading: false,
+8 -70
View File
@@ -1,11 +1,7 @@
import _ from "lodash";
import Crossfilter from "../util/typedCrossfilter";
import {
World,
ControlsHelpers as CH,
AnnotationsHelpers as AH
} from "../util/stateManager";
import { World, ControlsHelpers } from "../util/stateManager";
import {
layoutDimensionName,
obsAnnoDimensionName,
@@ -16,7 +12,7 @@ import {
const XYDimName = layoutDimensionName("XY");
const CrossfilterReducerBase = (
const CrossfilterReducer = (
state = null,
action,
nextSharedState,
@@ -36,14 +32,12 @@ const CrossfilterReducerBase = (
case "reset World to eq Universe": {
const { userDefinedGenes, diffexpGenes } = prevSharedState.controls;
const { world } = nextSharedState;
let { crossfilter } = prevSharedState.resetCache;
crossfilter = CH.createGeneDimensions(
const crossfilter = ControlsHelpers.createGeneDimensions(
userDefinedGenes,
diffexpGenes,
world,
crossfilter
prevSharedState.resetCache.crossfilter
);
crossfilter = AH.createWritableAnnotationDimensions(world, crossfilter);
return crossfilter;
}
@@ -57,7 +51,7 @@ const CrossfilterReducerBase = (
world,
layoutChoice.currentDimNames
);
crossfilter = CH.createGeneDimensions(
crossfilter = ControlsHelpers.createGeneDimensions(
userDefinedGenes,
diffexpGenes,
world,
@@ -142,37 +136,6 @@ const CrossfilterReducerBase = (
return crossfilter;
}
case "annotation: create category": {
const name = action.data;
const { world } = nextSharedState;
const colData = world.obsAnnotations.col(name).asArray();
return state.addDimension(obsAnnoDimensionName(name), "enum", colData);
}
case "annotation: category edited": {
const name = action.metadataField;
const newName = action.newCategoryText;
return state.renameDimension(
obsAnnoDimensionName(name),
obsAnnoDimensionName(newName)
);
}
case "annotation: delete category": {
return state.delDimension(obsAnnoDimensionName(action.metadataField));
}
case "annotation: label current cell selection":
case "annotation: label edited":
case "annotation: delete label": {
/* we need to reindex the dimension. For now, just drop it and add another */
const name = action.metadataField;
const dimName = obsAnnoDimensionName(name);
const { world } = nextSharedState;
const colData = world.obsAnnotations.col(name).asArray();
return state.delDimension(dimName).addDimension(dimName, "enum", colData);
}
case "graph brush end":
case "graph brush change": {
const [minX, maxY] = action.brushCoords.northwest;
@@ -222,12 +185,12 @@ const CrossfilterReducerBase = (
case "categorical metadata filter select":
case "categorical metadata filter deselect": {
const { categoricalSelection } = nextSharedState;
const { world } = prevSharedState;
const cat = categoricalSelection[action.metadataField];
const { categoryValues, categoryValueSelected } = cat;
const values = categoryValues.filter((v, i) => categoryValueSelected[i]);
const col = world.obsAnnotations.col(action.metadataField);
return state.select(obsAnnoDimensionName(action.metadataField), {
mode: "exact",
values
values: ControlsHelpers.selectedValuesForCategory(cat, col)
});
}
@@ -249,29 +212,4 @@ const CrossfilterReducerBase = (
}
};
/*
IMPORTANT: the system assumes that crossfilter.data() will point at the
same value as world.obsAnnotations. For actions handled in this reducer,
make sure that this remains true.
This wrapper performs only this function.
*/
const CrossfilterReducer = (
state,
action,
nextSharedState,
prevSharedState
) => {
const nextState = CrossfilterReducerBase(
state,
action,
nextSharedState,
prevSharedState
);
if (!nextState || nextState.all() === nextSharedState.world.obsAnnotations) {
return nextState;
}
return nextState.setData(nextSharedState.world.obsAnnotations);
};
export default CrossfilterReducer;
+28 -34
View File
@@ -17,44 +17,38 @@ import responsive from "./responsive";
import controls from "./controls";
import resetCache from "./resetCache";
import centroidLabel from "./centroidLabel";
import annotations from "./annotations";
import autosave from "./autosave";
import undoableConfig from "./undoableConfig";
const Reducer = undoable(
cascadeReducers([
["config", config],
["universe", universe],
["world", world],
["annotations", annotations],
["layoutChoice", layoutChoice],
["categoricalSelection", categoricalSelection],
["continuousSelection", continuousSelection],
["graphSelection", graphSelection],
["crossfilter", crossfilter],
["colors", colors],
["controls", controls],
["differential", differential],
["responsive", responsive],
["centroidLabel", centroidLabel],
["autosave", autosave],
["resetCache", resetCache]
]),
[
"universe",
"world",
"categoricalSelection",
"continuousSelection",
"graphSelection",
"crossfilter",
"colors",
"controls",
"differential",
"layoutChoice",
"annotations"
],
undoableConfig
cascadeReducers([
["config", config],
["universe", universe],
["world", world],
["layoutChoice", layoutChoice],
["categoricalSelection", categoricalSelection],
["continuousSelection", continuousSelection],
["graphSelection", graphSelection],
["crossfilter", crossfilter],
["colors", colors],
["controls", controls],
["differential", differential],
["responsive", responsive],
["centroidLabel", centroidLabel],
["resetCache", resetCache]
]),
[
"world",
"categoricalSelection",
"continuousSelection",
"graphSelection",
"crossfilter",
"colors",
"controls",
"differential",
"layoutChoice"
],
undoableConfig
);
const store = createStore(Reducer, applyMiddleware(thunk));
+6 -5
View File
@@ -1,13 +1,13 @@
/*
Reducer which caches derived state to be used in a reset or other
recomputation. Add stuff here you want stashed at init time (or whenever),
for later use.
recomputation.
Currently this only caches the baseline (full universe) crossfilter,
which improves Reset UI performance.
Currently this only caches the baseline (full universe) world & crossfilter,
for use in a Reset.
*/
const ResetCacheReducer = (
state = {
world: null,
crossfilter: null
},
action,
@@ -15,9 +15,10 @@ const ResetCacheReducer = (
) => {
switch (action.type) {
case "initial data load complete (universe exists)": {
const { crossfilter } = nextSharedState;
const { world, crossfilter } = nextSharedState;
return {
...state,
world,
crossfilter
};
}
+1 -9
View File
@@ -78,15 +78,7 @@ const saveOnActions = new Set([
"set clip quantiles",
"set layout choice",
"change graph interaction mode",
// user editable annotations
"annotation: create category",
"annotation: add new label to category",
"annotation: delete category",
"annotation: label edited",
"annotation: label current cell selection",
"annotation: delete label"
"change graph interaction mode"
]);
/**
+5 -184
View File
@@ -1,9 +1,4 @@
import { unassignedCategoryLabel } from "../globals";
import {
World,
ControlsHelpers as CH,
AnnotationsHelpers as AH
} from "../util/stateManager";
import { ControlsHelpers } from "../util/stateManager";
const Universe = (state = null, action, nextSharedState, prevSharedState) => {
switch (action.type) {
@@ -35,186 +30,12 @@ const Universe = (state = null, action, nextSharedState, prevSharedState) => {
Object.keys(action.expressionData)
)
];
varData = CH.pruneVarDataCache(varData, allTheGenesWeNeed);
return { ...state, varData };
}
varData = ControlsHelpers.pruneVarDataCache(varData, allTheGenesWeNeed);
case "annotation: create category": {
/* create a new annotation category, with all values set to 'unassigned' */
const name = action.data;
const { categoryToDuplicate } = action;
/* name must be a string, non-zero length */
if (typeof name !== "string" || name.length === 0)
throw new Error("user annotations require string name");
/* ensure the name isn't already in use! */
if (state.obsAnnotations.hasCol(name))
throw new Error("name collision on annotation category create");
/* ensure the duplicate col exists */
if (
categoryToDuplicate &&
!state.obsAnnotations.hasCol(categoryToDuplicate)
)
throw new Error("categoryToDuplicate does not exist");
let schema;
let data;
if (categoryToDuplicate) {
/* duplicate the named annotation */
schema = AH.dupObsAnnoSchema(state.schema, categoryToDuplicate, name, {
writable: true
});
/* if we are duplicating a non-writable annotation, it may not have an unassigned category */
const s = schema.annotations.obsByName[categoryToDuplicate];
if (s.categories.indexOf(unassignedCategoryLabel) === -1) {
s.categories = s.categories.concat(unassignedCategoryLabel);
}
data = state.obsAnnotations.col(categoryToDuplicate).asArray();
} else {
/* else, all are unassined */
const categories = [unassignedCategoryLabel];
schema = AH.addObsAnnoSchema(state.schema, name, {
name,
categories,
type: "categorical",
writable: true
});
data = new Array(state.nObs).fill(unassignedCategoryLabel);
}
const obsAnnotations = state.obsAnnotations.withCol(name, data);
return { ...state, obsAnnotations, schema };
}
case "annotation: category edited": {
/* change the name of an obs annotation category */
const name = action.metadataField;
const newName = action.newCategoryText;
if (!AH.isUserAnnotation(state, name))
throw new Error("unable to edit read-only annotation");
if (typeof newName !== "string" || newName.length === 0)
throw new Error("user annotations require string name");
const colSchema = {
...state.schema.annotations.obsByName[name],
name: newName
return {
...state,
varData
};
const schema = AH.addObsAnnoSchema(
AH.removeObsAnnoSchema(state.schema, name),
newName,
colSchema
);
const obsAnnotations = state.obsAnnotations.renameCol(name, newName);
return { ...state, schema, obsAnnotations };
}
case "annotation: delete category": {
/* delete annotation category from schema and obsAnnotations */
const name = action.metadataField;
if (!AH.isUserAnnotation(state, name))
throw new Error("unable to delete read-only annotation");
const schema = AH.removeObsAnnoSchema(state.schema, name);
const obsAnnotations = state.obsAnnotations.dropCol(name);
return { ...state, schema, obsAnnotations };
}
case "annotation: add new label to category": {
const annotationName = action.metadataField;
const newLabelName = action.newLabelText;
if (!AH.isUserAnnotation(state, annotationName))
throw new Error("unable to modify read-only annotation");
if (typeof newLabelName !== "string" || newLabelName.length === 0)
throw new Error(
"user annotations require a non-zero length string name"
);
/* add the new label to the annotation */
const schema = AH.addObsAnnoCategory(
state.schema,
annotationName,
newLabelName
);
return { ...state, schema };
}
case "annotation: label edited": {
const annotationName = action.metadataField;
const oldLabelName = action.label;
const newLabelName = action.editedLabel;
if (!AH.isUserAnnotation(state, annotationName))
throw new Error("unable to modify read-only annotation");
if (typeof newLabelName !== "string" || newLabelName.length === 0)
throw new Error(
"user annotations require a non-zero length string name"
);
/* remove old label, add new label */
const schema = AH.addObsAnnoCategory(
AH.removeObsAnnoCategory(state.schema, annotationName, oldLabelName),
annotationName,
newLabelName
);
/* change all values in obsAnnotation */
const obsAnnotations = AH.setLabelByValue(
state.obsAnnotations,
annotationName,
oldLabelName,
newLabelName
);
return { ...state, schema, obsAnnotations };
}
case "annotation: delete label": {
/* delete the label from the annotation, and set all cells with this value to unassigned */
const annotationName = action.metadataField;
const labelName = action.label;
if (!AH.isUserAnnotation(state, annotationName))
throw new Error("unable to modify read-only annotation");
if (labelName === unassignedCategoryLabel)
throw new Error("may not remove the unassigned label");
/* remove the category from the schema */
const schema = AH.removeObsAnnoCategory(
state.schema,
annotationName,
labelName
);
/* set all values to unassigned in obsAnnotations */
const obsAnnotations = AH.setLabelByValue(
state.obsAnnotations,
annotationName,
labelName,
unassignedCategoryLabel
);
return { ...state, schema, obsAnnotations };
}
case "annotation: label current cell selection": {
const { metadataField, label } = action;
const { world, crossfilter } = prevSharedState;
/*
selection state is relative to world. We need to convert it
to a mask for Universe before applying it.
*/
const worldMask = crossfilter.allSelectedMask();
const mask = World.worldEqUniverse(world, state)
? worldMask
: AH.worldToUniverseMask(worldMask, world.obsAnnotations, state.nObs);
const obsAnnotations = AH.setLabelByMask(
state.obsAnnotations,
metadataField,
mask,
label
);
return { ...state, obsAnnotations };
}
default: {
+7 -148
View File
@@ -1,22 +1,7 @@
import { unassignedCategoryLabel } from "../globals";
import {
World,
ControlsHelpers as CH,
AnnotationsHelpers as AH
} from "../util/stateManager";
import { World, ControlsHelpers } from "../util/stateManager";
import clip from "../util/clip";
import quantile from "../util/quantile";
/*
important note: much of this code assumes that wriable (user) annotations
will NOT contain scalar data (ie, will only contain categorical labelled
data), and therefore will never need to be clipped. Put another way, it
assumes that for these annotations, the clipped & unclipped data is equal.
If we ever start allowing user editable scalar data, this assumption will
need to be revisited.
*/
const WorldReducer = (
state = null,
action,
@@ -24,13 +9,16 @@ const WorldReducer = (
prevSharedState
) => {
switch (action.type) {
case "initial data load complete (universe exists)":
case "reset World to eq Universe": {
case "initial data load complete (universe exists)": {
const { universe } = nextSharedState;
const world = World.createWorldFromEntireUniverse(universe);
return world;
}
case "reset World to eq Universe": {
return prevSharedState.resetCache.world;
}
case "set World to current selection": {
/* Set viewable world to be the currently selected data */
const world = World.createWorldBySelection(
@@ -92,7 +80,7 @@ const WorldReducer = (
Object.keys(action.expressionData)
)
];
unclippedVarData = CH.pruneVarDataCache(
unclippedVarData = ControlsHelpers.pruneVarDataCache(
unclippedVarData,
allTheGenesWeNeed
);
@@ -134,135 +122,6 @@ const WorldReducer = (
};
}
case "annotation: create category": {
const name = action.data;
const { universe } = nextSharedState;
const { schema } = universe;
/*
if world !== universe, we have to subset the newly created annotation,
else, just use it as is.
*/
let newAnnotation = null;
if (!World.worldEqUniverse(state, universe)) {
newAnnotation = universe.obsAnnotations
.subset(state.obsAnnotations.rowIndex.keys(), [name], null)
.icol(0)
.asArray();
} else {
newAnnotation = universe.obsAnnotations.col(name).asArray();
}
const obsAnnotations = state.obsAnnotations.withCol(
name,
newAnnotation,
state.obsAnnotations.rowIndex
);
const unclipped = {
...state.unclipped,
obsAnnotations: state.unclipped.obsAnnotations.withCol(
name,
newAnnotation,
state.unclipped.obsAnnotations.rowIndex
)
};
return { ...state, schema, obsAnnotations, unclipped };
}
case "annotation: category edited": {
/* change the name of an obs annotation */
const name = action.metadataField;
const newName = action.newCategoryText;
const { schema } = nextSharedState.universe;
const obsAnnotations = state.obsAnnotations.renameCol(name, newName);
const unclipped = {
...state.unclipped,
obsAnnotations: state.unclipped.obsAnnotations.renameCol(name, newName)
};
return { ...state, schema, obsAnnotations, unclipped };
}
case "annotation: delete category": {
/* remove a category from obs annotation */
const { schema } = nextSharedState.universe;
const name = action.metadataField;
const obsAnnotations = state.obsAnnotations.dropCol(name);
const unclipped = {
...state.unclipped,
obsAnnotations: state.unclipped.obsAnnotations.dropCol(name)
};
return { ...state, schema, obsAnnotations, unclipped };
}
case "annotation: add new label to category": {
/* add a new label to the schema - schema updated by universe reducer, we just need to note it */
const { schema } = nextSharedState.universe;
return { ...state, schema };
}
case "annotation: label edited": {
const { schema } = nextSharedState.universe;
const { metadataField } = action;
const oldLabelName = action.label;
const newLabelName = action.editedLabel;
/* set all values to to new label */
const unclipped = {
...state.unclipped,
obsAnnotations: AH.setLabelByValue(
state.unclipped.obsAnnotations,
metadataField,
oldLabelName,
newLabelName
)
};
const obsAnnotations = state.obsAnnotations.replaceColData(
metadataField,
unclipped.obsAnnotations.col(metadataField).asArray()
);
return { ...state, schema, obsAnnotations, unclipped };
}
case "annotation: delete label": {
const { schema } = nextSharedState.universe;
const { label, metadataField } = action;
/* set all values to unassigned in obsAnnotations */
const unclipped = {
...state.unclipped,
obsAnnotations: AH.setLabelByValue(
state.unclipped.obsAnnotations,
metadataField,
label,
unassignedCategoryLabel
)
};
const obsAnnotations = state.obsAnnotations.replaceColData(
metadataField,
unclipped.obsAnnotations.col(metadataField).asArray()
);
return { ...state, schema, obsAnnotations, unclipped };
}
case "annotation: label current cell selection": {
const { metadataField, label } = action;
const { crossfilter } = prevSharedState;
const mask = crossfilter.allSelectedMask();
const unclipped = {
...state.unclipped,
obsAnnotations: AH.setLabelByMask(
state.unclipped.obsAnnotations,
metadataField,
mask,
label
)
};
const obsAnnotations = state.obsAnnotations.replaceColData(
metadataField,
unclipped.obsAnnotations.col(metadataField).asArray()
);
return { ...state, obsAnnotations, unclipped };
}
default: {
return state;
}
+51 -166
View File
@@ -1,181 +1,66 @@
import { vec2, mat3 } from "gl-matrix";
const EPSILON = 0.000001;
// jshint esversion: 6
const createCamera = require("orbit-camera");
const createScroll = require("scroll-speed");
const mp = require("mouse-position");
const mb = require("mouse-pressed");
const key = require("key-pressed");
const panSpeed = 1.0; // changed from 0.4 to 1.0 per issue #722
const scaleSpeed = 0.5;
const scaleMax = 3.0;
const scaleMin = 0.5;
const panBound = 0.8;
const scaleMax = 3;
const scaleMin = 1.03;
// private
const scratch0 = new Float32Array(16);
const scratch1 = new Float32Array(16);
function attachCamera(canvas, opts) {
opts = opts || {};
opts.pan = opts.pan !== false;
opts.scale = opts.scale !== false;
opts.rotate = opts.rotate !== false;
function clamp(val, rng) {
return Math.max(Math.min(val, rng[1]), rng[0]);
}
const scroll = createScroll(canvas, opts.scale);
const mbut = mb(canvas, opts.rotate);
const mpos = mp(canvas);
const camera = createCamera([0, 0, 1], [0, 0, -1], [0, 1, 0]);
class Camera {
constructor(canvas) {
this.prevEvent = {
clientX: 0,
clientY: 0,
type: 0
};
this.canvas = canvas;
this.viewMatrix = mat3.create();
this.viewMatrixInv = mat3.create();
}
camera.tick = tick;
view() {
return this.viewMatrix;
}
return camera;
invView() {
return this.viewMatrixInv;
}
function tick() {
const ctrl = key("<control>") || key("<alt>");
const alt = key("<shift>");
const { height, width } = canvas;
distance() {
return this.viewMatrix[0];
}
pan(dx, dy) {
const m = this.viewMatrix;
const dyRange = [
-panBound - (m[7] + 1) / m[4],
panBound - (m[7] - 1) / m[4]
];
const dxRange = [
-panBound - (m[6] + 1) / m[0],
panBound - (m[6] - 1) / m[0]
];
const dxClamped = clamp(dx, dxRange);
const dyClamped = clamp(dy, dyRange);
if (Math.abs(dxClamped) <= EPSILON && Math.abs(dyClamped) <= EPSILON)
return;
mat3.translate(m, m, [dxClamped, dyClamped]);
mat3.invert(this.viewMatrixInv, m);
}
zoomAt(d, x = 0, y = 0) {
/*
Camera zoom at [x,y]
*/
const m = this.viewMatrix;
const bounds = [-panBound, panBound];
x = clamp(x, bounds);
y = clamp(y, bounds);
const dClamped = clamp(d * m[0], [scaleMin, scaleMax]) / m[0];
if (Math.abs(1 - dClamped) <= EPSILON) return; // noop request
mat3.translate(m, m, [x, y]);
mat3.scale(m, m, [dClamped, dClamped]);
mat3.translate(m, m, [-x, -y]);
mat3.invert(this.viewMatrixInv, m);
}
/*
Event handling
*/
flush(e) {
this.prevEvent.type = e.type;
this.prevEvent.clientX = e.clientX;
this.prevEvent.clientY = e.clientY;
}
localPosition(target, canvasX, canvasY, projectionInvTF) {
/*
Convert mouse position to local
*/
const { height, width } = target;
const targetRect = target.getBoundingClientRect();
canvasX -= targetRect.left;
canvasY -= targetRect.top;
const pos = vec2.fromValues(
2 * (canvasX / width) - 1,
-2 * (canvasY / height) + 1
);
if (projectionInvTF) {
vec2.transformMat3(pos, pos, projectionInvTF);
if (opts.rotate && mbut.left && ctrl && !alt) {
camera.rotate(
[mpos.x / width - 0.5, mpos.y / height - 0.5],
[mpos.prevX / width - 0.5, mpos.prevY / height - 0.5]
);
}
vec2.transformMat3(pos, pos, this.invView());
return pos;
}
mousePan(e, projectionTF) {
const projectionInvTF = mat3.invert(scratch0, projectionTF);
const pos = this.localPosition(
this.canvas,
e.clientX,
e.clientY,
projectionInvTF
);
const prev = this.localPosition(
this.canvas,
this.prevEvent.clientX,
this.prevEvent.clientY,
projectionInvTF
);
const delta = vec2.sub(scratch1, pos, prev);
this.pan(delta[0], delta[1]);
return true;
}
wheelZoom(e, projectionTF) {
const { height } = this.canvas;
const { deltaY, deltaMode, clientX, clientY } = e;
const scale = scaleSpeed * (deltaMode === 1 ? 12 : 1) * (deltaY || 0);
const projectionInvTF = mat3.invert(scratch0, projectionTF);
const pos = this.localPosition(
this.canvas,
clientX,
clientY,
projectionInvTF
);
this.zoomAt(1 / Math.exp(scale / height), pos[0], pos[1]);
return true;
}
handleEvent(e, projectionTF) {
/*
process the event, and return true if camera view changed
*/
let viewChanged = false;
switch (e.type) {
case "mousemove": {
/* eslint-disable no-bitwise */
if (e.buttons & 0x1) {
viewChanged = this.mousePan(e, projectionTF);
}
/* eslint-enable no-bitwise */
this.flush(e);
break;
}
case "wheel": {
viewChanged = this.wheelZoom(e, projectionTF);
this.flush(e);
break;
}
default:
// noop
break;
if ((opts.pan && mbut.right) || (mbut.left && !ctrl && !alt)) {
camera.pan([
((panSpeed * (mpos[0] - mpos.prev[0])) / width) * camera.distance,
((panSpeed * (mpos[1] - mpos.prev[1])) / height) * camera.distance
]);
}
return viewChanged;
}
}
function attachCamera(canvas) {
return new Camera(canvas);
if (opts.scale && scroll[1]) {
camera.distance *= Math.exp((scroll[1] * scaleSpeed) / height);
}
if (opts.scale && (mbut.middle || (mbut.left && !ctrl && alt))) {
const d = mpos.y - mpos.prevY;
if (!d) return;
camera.distance *= Math.exp(d / height);
}
if (camera.distance > scaleMax) camera.distance = scaleMax;
if (camera.distance < scaleMin) camera.distance = scaleMin;
scroll.flush();
mpos.flush();
}
}
export default attachCamera;
-92
View File
@@ -127,7 +127,6 @@ class Dataframe {
this.__id = Dataframe.__getId();
this.__compile(__columnsAccessor);
Object.freeze(this);
}
static __errorChecks(dims, columnarData, rowIndex, colIndex) {
@@ -270,7 +269,6 @@ class Dataframe {
get.iget = iget;
get.__id = __id;
Object.freeze(get);
return get;
}
@@ -290,7 +288,6 @@ class Dataframe {
}
return Dataframe.__compileColumn(column, getRowByOffset, getRowByLabel);
});
Object.freeze(this.__columnsAccessor);
}
clone() {
@@ -348,50 +345,12 @@ class Dataframe {
);
}
withColsFrom(dataframe) {
/*
return a new dataframe containing all columns from both `this` and the
provided dataframe.
The row index from `this` will be used. Both dataframes must have identical
dimensionality, and no overlapping columns labels.
*/
const dims = [this.dims[0], this.dims[1] + dataframe.dims[1]];
const { rowIndex } = this;
const columns = [...this.__columns, ...dataframe.__columns];
const colIndex = this.colIndex.withLabels(dataframe.colIndex.keys());
const columnsAccessor = [
...this.__columnsAccessor,
...dataframe.__columnsAccessor
];
return new this.constructor(
dims,
columns,
rowIndex,
colIndex,
columnsAccessor
);
}
dropCol(label) {
/*
Create a new dataframe, omitting one columns.
const newDf = df.dropCol("colors");
Corner case to manage: if dropping the last column, return an empty dataframe.
*/
if (!this.hasCol(label)) {
throw new RangeError(`unknown label: ${label}`);
}
/*
Corner case to manage: if dropping the last column, return an empty dataframe.
*/
if (this.dims[1] === 1) {
return Dataframe.empty();
}
const dims = [this.dims[0], this.dims[1] - 1];
const coffset = this.colIndex.getOffset(label);
const columns = [...this.__columns];
@@ -408,50 +367,6 @@ class Dataframe {
);
}
renameCol(oldLabel, newLabel) {
/*
Accelerator for dropping a column and then adding it again with a new label
*/
const coffset = this.colIndex.getOffset(oldLabel);
const colIndex = this.colIndex.dropLabel(oldLabel).withLabel(newLabel);
const columns = [...this.__columns];
columns.push(columns[coffset]);
columns.splice(coffset, 1);
const columnsAccessor = [...this.__columnsAccessor];
columnsAccessor.push(columnsAccessor[coffset]);
columnsAccessor.splice(coffset, 1);
return new this.constructor(
this.dims,
columns,
this.rowIndex,
colIndex,
columnsAccessor
);
}
replaceColData(label, newColData) {
/*
Accelerator for dropping a column then adding it again with same
label and different values.
*/
const coffset = this.colIndex.getOffset(label);
const columns = [...this.__columns];
columns[coffset] = newColData;
const columnsAccessor = [...this.__columnsAccessor];
columnsAccessor[coffset] = null;
return new this.constructor(
this.dims,
columns,
this.rowIndex,
this.colIndex,
columnsAccessor
);
}
static empty(rowIndex = null, colIndex = null) {
return new Dataframe([0, 0], [], rowIndex, colIndex);
}
@@ -528,8 +443,6 @@ class Dataframe {
return newCol;
});
}
if (dims[0] === 0 || dims[1] === 0) return Dataframe.empty();
return new Dataframe(dims, columns, rowIndex, colIndex);
}
@@ -613,11 +526,6 @@ class Dataframe {
Data access with row/col.
**/
columns() {
/* return all column accessors as an array, in offset order */
return [...this.__columnsAccessor];
}
col(columnLabel) {
/*
Return accessor bound to a column. Allows random row access
+5 -5
View File
@@ -8,13 +8,13 @@ function _histogramContinuous(column, bins, min, max) {
if (!column) {
return valBins;
}
const binWidth = (max - min) / bins;
const binWidth = (max - min) / (bins - 1);
const colArray = column.asArray();
for (let r = 0, len = colArray.length; r < len; r += 1) {
const val = colArray[r];
if (val <= max && val >= min) {
// ensure test excludes NaN values
const valBin = Math.min(Math.floor((val - min) / binWidth), bins - 1);
const valBin = (val - min) / binWidth;
valBins[valBin] += 1;
}
}
@@ -26,7 +26,7 @@ function _histogramContinuousBy(column, bins, min, max, by) {
if (!column || !by) {
return byMap;
}
const binWidth = (max - min) / bins;
const binWidth = (max - min) / (bins - 1);
const byArray = by.asArray();
const colArray = column.asArray();
for (let r = 0, len = colArray.length; r < len; r += 1) {
@@ -39,8 +39,8 @@ function _histogramContinuousBy(column, bins, min, max, by) {
const val = colArray[r];
if (val <= max && val >= min) {
// ensure test excludes NaN values
const valBin = Math.min(Math.floor((val - min) / binWidth), bins - 1);
valBins[valBin] += 1;
const valBin = (val - min) / binWidth;
valBins[Math.floor(valBin)] += 1;
}
}
return byMap;
-17
View File
@@ -80,10 +80,6 @@ class IdentityInt32Index {
return this.__promote([...this.keys(), label]);
}
withLabels(labels) {
return this.__promote([...this.keys(), ...labels]);
}
dropLabel(label) {
if (label === this.maxOffset - 1) {
return new IdentityInt32Index(label);
@@ -167,10 +163,6 @@ class DenseInt32Index {
return this.__promote([...this.keys(), label]);
}
withLabels(labels) {
return this.__promote([...this.keys(), ...labels]);
}
dropLabel(label) {
const labelArray = [...this.keys()];
labelArray.splice(labelArray.indexOf(label), 1);
@@ -195,11 +187,6 @@ class KeyIndex {
index.set(v, i);
});
if (index.size !== rindex.length) {
/* if true, there was a duplicate in the keys */
throw new Error("duplicate label provided to KeyIndex");
}
this.index = index;
this.rindex = rindex;
this.__compile();
@@ -231,10 +218,6 @@ class KeyIndex {
return new KeyIndex([...this.rindex, label]);
}
withLabels(labels) {
return new KeyIndex([...this.rindex, ...labels]);
}
dropLabel(label) {
const idx = this.rindex.indexOf(label);
const labelArray = [...this.rindex];
+1 -13
View File
@@ -21,7 +21,7 @@ export function callOnceLazy(f) {
return result;
}
export function memoize(fn, hashFn, maxResultsCached = -1) {
export function memoize(fn, hashFn) {
/*
function memoization, with user-provided hash. hashFn must return a
key which will be unique as a Map key (ie, obeys "sameValueZero" algorithm
@@ -36,19 +36,7 @@ export function memoize(fn, hashFn, maxResultsCached = -1) {
}
const result = fn(...args);
cache.set(key, result);
if (maxResultsCached > -1 && cache.size > maxResultsCached) {
/* Least recent insertion deletion */
cache.delete(cache.keys().next().value);
}
return result;
};
wrap.clear = function clear() {
/* clear memoization cache */
cache.clear();
};
return wrap;
}
-81
View File
@@ -1,81 +0,0 @@
/*
Utility code for WebGL shaders
*/
/*
PointFlags:
Point flags are used in graph & scatter plots.
We want a bitmask-like flag structure, but due to webgl limitations
must emulate it with floats.
*/
// for JS
export const flagSelected = 1;
export const flagNaN = 2;
export const flagHighlight = 4;
// for GLSL
export const glPointFlags = `
const float flagSelected = 1.;
const float flagNaN = 2.;
const float flagHighlight = 4.;
bool isLowBitSet(float f) {
f = mod(f, 2.);
return (f > 0.9 && f <= 1.1);
}
float shiftRightOne(float f) {
return floor(f / 2.);
}
void getFlags(in float flag,
out bool isNaN,
out bool isSelected,
out bool isHighlight) {
isSelected = isLowBitSet(flag);
flag = shiftRightOne(flag);
isNaN = isLowBitSet(flag);
flag = shiftRightOne(flag);
isHighlight = isLowBitSet(flag);
}
`;
/*
Point Size:
Calculate point size for scatter plot based upon pseudo density.
Current approach: linear scaling of point size, clamped to [1,10],
between two points that are based on empirical testing.
- 1M points on a 500x500 canvas: 1M/(500*500) -> 0.5
- 1000 points on a 1440x1440 canvas: 1000/(1440*1440) -> 5
The domain is pseudo density (numPoints / minViewportDimension^2)
The range is web gl point size.
*/
// configuration
const domain = [1000000 / (500 * 500), 1000 / (1440 * 1440)];
const range = [0.5, 5];
// derived from configuration
const scale = (range[1] - range[0]) / (domain[1] - domain[0]);
const offset = scale * -domain[0] + range[0];
export const glPointSize = `
float pointSize(float nPoints, float minViewportDimension, bool isSelected, bool isHighlight) {
float density = nPoints / (minViewportDimension * minViewportDimension);
float pointSize = (${scale.toFixed(4)}*density) + ${offset.toFixed(4)};
pointSize = clamp(pointSize,
${range[0].toFixed(4)},
${range[1].toFixed(4)});
if (isHighlight) return 2. * pointSize;
if (isSelected) return pointSize;
return pointSize / 3.;
}
`;
@@ -1,168 +0,0 @@
/*
Helper functions for user-editable nnotations state management.
See also reducers/annotations.js
*/
import { unassignedCategoryLabel } from "../../globals";
import * as SchemaHelpers from "./schemaHelpers";
import { obsAnnoDimensionName } from "../nameCreators";
/*
There are a number of state constraints assumed throughout the
application:
- all obs annotations are in {world|universe}.obsAnnotations,
regardless of whether or not they are user editable.
- the {world|universe}.schema is always up to date and matches
the data
- the schema flag `writable` correctly indicates whether
the annotation is editable/mutable.
In addition, the current state management only allows for
categorical annotations to be writable.
*/
export function isCategoricalAnnotation(schema, name) {
/* we treat any string, categorical or boolean as a categorical */
const { type } = schema.annotations.obsByName[name];
return type === "string" || type === "boolean" || type === "categorical";
}
export function isContinuousAnnotation(schema, name) {
return !isCategoricalAnnotation(schema, name);
}
function _isUserAnnotation(schema, name) {
return schema.annotations.obsByName[name]?.writable;
}
export function isUserAnnotation(worldOrUniverse, name) {
return _isUserAnnotation(worldOrUniverse.schema, name);
}
export function removeObsAnnoSchema(schema, name) {
/*
remove named annotation from obs annotation schema
*/
/* only remove if it exists and is a user annotation */
if (!_isUserAnnotation(schema, name))
throw new Error("removing non-user-defined schema");
return SchemaHelpers.removeObsAnnoColumn(schema, name);
}
export function addObsAnnoSchema(schema, name, colSchema) {
/*
add a categorical type to the obs annotation schema
*/
/* collision detection */
if (schema.annotations.obs.columns.some(v => v.name === name))
throw Error("annotations may not contain duplicate category names");
if (name !== colSchema.name) throw Error("column schema does not match");
return SchemaHelpers.addObsAnnoColumn(schema, name, colSchema);
}
export function dupObsAnnoSchema(schema, sourceName, dupName, defaultSchema) {
/*
duplicate the obs annotation `sourceName` schema, but with the name `dupName`
*/
const colSchema = {
...schema.annotations.obsByName[sourceName],
...defaultSchema,
name: dupName
};
/* existance check */
if (!colSchema) throw Error("source annotation does not exist");
/* collision detection */
if (schema.annotations.obs.columns.some(v => v.name === dupName))
throw Error("annotations may not contain duplicate category names");
return SchemaHelpers.addObsAnnoColumn(schema, dupName, colSchema);
}
export function removeObsAnnoCategory(schema, name, category) {
/* don't allow deletion of unassigned category on writable annotations */
if (!_isUserAnnotation(schema, name))
throw new Error("unable to modify read-only schema");
if (category === unassignedCategoryLabel)
throw new Error("may not remove unassigned category label");
return SchemaHelpers.removeObsAnnoCategory(schema, name, category);
}
export function addObsAnnoCategory(schema, name, category) {
if (!_isUserAnnotation(schema, name))
throw new Error("unable to modify read-only schema");
return SchemaHelpers.addObsAnnoCategory(schema, name, category);
}
export function setLabelByValue(df, colName, fromLabel, toLabel) {
/*
in the dataframe column `colName`, set any value of `fromLabel` to `toLabel`
*/
const keys = df.colIndex.keys();
const ndf = df.mapColumns((col, colIdx) => {
if (colName !== keys[colIdx]) return col;
/* clone data and return it. */
const newCol = col.slice();
for (let i = 0, l = newCol.length; i < l; i += 1) {
if (newCol[i] === fromLabel) newCol[i] = toLabel;
}
return newCol;
});
return ndf;
}
export function setLabelByMask(df, colName, mask, label) {
/*
in the dataframe column `colName`, set the masked rows to 'label'
*/
const keys = df.colIndex.keys();
const ndf = df.mapColumns((col, colIdx) => {
if (colName !== keys[colIdx]) return col;
/* clone data and return it. */
const newCol = col.slice();
for (let i = 0, l = newCol.length; i < l; i += 1) {
if (mask[i]) newCol[i] = label;
}
return newCol;
});
return ndf;
}
export function worldToUniverseMask(worldMask, worldObsAnnotations, nObs) {
/*
given world seleciton mask, return a selection mask for entire universe
that has same selection state.
*/
const mask = new Uint8Array(nObs);
const { rowIndex } = worldObsAnnotations;
for (let i = 0, l = worldMask.length; i < l; i += 1) {
if (worldMask[i]) {
const label = rowIndex.getLabel(i);
mask[label] = 1;
}
}
return mask;
}
export function createWritableAnnotationDimensions(world, crossfilter) {
const { obsAnnotations, schema } = world;
const writableAnnotations = schema.annotations.obs.columns
.filter(s => s.writable)
.map(s => s.name);
crossfilter = writableAnnotations.reduce((xflt, anno) => {
const dimName = obsAnnoDimensionName(anno);
if (xflt.hasDimension(dimName)) xflt = xflt.delDimension(dimName);
return xflt.addDimension(
dimName,
"enum",
obsAnnotations.col(anno).asArray()
);
}, crossfilter);
return crossfilter;
}
+2 -3
View File
@@ -50,9 +50,8 @@ function createColorsByCategoricalMetadata(world, accessor) {
}, {});
const rgb = new Array(world.nObs);
const df = world.obsAnnotations;
const data = df.col(accessor).asArray();
for (let i = 0, len = df.length; i < len; i += 1) {
const data = world.obsAnnotations.col(accessor).asArray();
for (let i = 0, len = world.obsAnnotations.length; i < len; i += 1) {
const cat = data[i];
rgb[i] = colors[cat];
}
+63 -53
View File
@@ -37,14 +37,16 @@ Remember that option values can be ANY js type, except undefined/null.
}
}
*/
function topNCategories(colSchema, summary, N) {
const { categories } = colSchema;
const counts = _.map(categories, cat => summary.categoryCounts.get(cat) ?? 0);
const sortIndex = fillRange(new Array(categories.length)).sort(
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 => categories[i]);
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];
@@ -52,56 +54,64 @@ function topNCategories(colSchema, summary, N) {
return [sortedCategories.slice(0, N), sortedCounts.slice(0, N)];
}
export function selectableCategoryNames(world, maxCategoryItems) {
const { schema } = world;
const { index, columns } = schema.annotations.obs;
return columns
.filter(colSchema => {
const { name, categories } = colSchema;
return (
categories && categories.length < maxCategoryItems && name !== index
);
})
.map(v => v.name);
export function createCategoricalSelection(maxCategoryItems, world) {
const res = {};
const obsIndexName = world.schema.annotations.obs.index;
_.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 !== obsIndexName &&
summary.categories.length < maxCategoryItems;
if (isSelectableCategory) {
const [categoryValues, categoryValueCounts] = topNCategories(summary);
const categoryValueIndices = new Map(
categoryValues.map((v, i) => [v, i])
);
const numCategoryValues = categoryValueIndices.size;
const categoryValueSelected = new Array(numCategoryValues).fill(true);
const isTruncated = categoryValues.length < summary.numCategories;
res[key] = {
categoryValues, // array: of natively typed category values
categoryValueIndices, // map: category value (native type) -> category index
categoryValueSelected, // array: t/f selection state
numCategoryValues, // number: of values in the category
isTruncated, // bool: true if list was truncated
categoryValueCounts, // array: cardinality of each category,
categorySelected: true // bool - default state for entire category
};
}
}
});
return res;
}
export function createCategoricalSelection(world, names) {
const N = globals.maxCategoricalOptionsToDisplay;
const { obsAnnotations, schema } = world;
const res = names.reduce((acc, name) => {
const colSchema = schema.annotations.obsByName[name];
const { writable: isUserAnno } = colSchema;
/*
Summarize the annotation data currently in world. Must return categoryValues
in sorted order, and must include all category values even if they are not
actively used in the current world.
*/
const summary = obsAnnotations.col(name).summarize();
const [categoryValues, categoryValueCounts] = topNCategories(
colSchema,
summary,
N
);
const categoryValueIndices = new Map(categoryValues.map((v, i) => [v, i]));
const numCategoryValues = categoryValueIndices.size;
const categoryValueSelected = new Array(numCategoryValues).fill(true);
const isTruncated = categoryValues.length < summary.numCategories;
acc[name] = {
categoryValues, // array: of natively typed category values
categoryValueIndices, // map: category value (native type) -> category index
categoryValueSelected, // array: t/f selection state
numCategoryValues, // number: of values in the category
isTruncated, // bool: true if list was truncated
categoryValueCounts, // array: cardinality of each category,
categorySelected: true, // bool - default state for entire category
isUserAnno // bool
};
return acc;
}, {});
return res;
/*
given a categoricalSelection, return the list of all category values
where selection state is true (ie, they are selected).
*/
export function selectedValuesForCategory(categorySelectionState, dfColumn) {
const {
categorySelected,
categoryValueSelected,
categoryValueIndices
} = categorySelectionState;
let selectedValues;
if (categorySelected) {
selectedValues = new Set(dfColumn.summarize().categories);
} else {
selectedValues = new Set();
}
categoryValueIndices.forEach((catIndex, catValue) => {
if (!categoryValueSelected[catIndex]) {
selectedValues.delete(catValue);
} else {
selectedValues.add(catValue);
}
});
return [...selectedValues.values()];
}
/*
+1 -3
View File
@@ -17,7 +17,5 @@ exists to support those concepts.
export * as ColorHelpers from "./colorHelpers";
export * as Universe from "./universe";
export * as World from "./world";
export * as WorldUtil from "./worldUtil";
export * as ControlsHelpers from "./controlsHelpers";
export * as AnnotationsHelpers from "./annotationsHelpers";
export * as SchemaHelpers from "./schemaHelpers";
export * as MatrixFBS from "./matrix";
+9 -98
View File
@@ -1,7 +1,5 @@
import { flatbuffers } from "flatbuffers";
import { NetEncoding } from "./matrix_generated";
import { isTypedArray } from "../typeHelpers";
import { IdentityInt32Index, DenseInt32Index, KeyIndex } from "../dataframe";
const utf8Decoder = new TextDecoder("utf-8");
@@ -43,25 +41,25 @@ Returns: object containing decoded Matrix:
colIdx: []|null
}
*/
export function decodeMatrixFBS(arrayBuffer, inplace = false) {
function decodeMatrixFBS(arrayBuffer, inplace = false) {
const bb = new flatbuffers.ByteBuffer(new Uint8Array(arrayBuffer));
const matrix = NetEncoding.Matrix.getRootAsMatrix(bb);
const df = NetEncoding.Matrix.getRootAsMatrix(bb);
const nRows = matrix.nRows();
const nCols = matrix.nCols();
const nRows = df.nRows();
const nCols = df.nCols();
/* decode columns */
const columnsLength = matrix.columnsLength();
const columnsLength = df.columnsLength();
const columns = Array(columnsLength).fill(null);
for (let c = 0; c < columnsLength; c += 1) {
const col = matrix.columns(c);
const col = df.columns(c);
columns[c] = decodeTypedArray(col.uType(), col.u.bind(col), inplace);
}
/* decode col_idx */
const colIdx = decodeTypedArray(
matrix.colIndexType(),
matrix.colIndex.bind(matrix),
df.colIndexType(),
df.colIndex.bind(df),
inplace
);
@@ -74,91 +72,4 @@ export function decodeMatrixFBS(arrayBuffer, inplace = false) {
};
}
function encodeTypedArray(builder, uType, uData) {
const uTypeName = NetEncoding.TypedArray[uType];
const ArrayType = NetEncoding[uTypeName];
const dv = ArrayType.createDataVector(builder, uData);
builder.startObject(1);
builder.addFieldOffset(0, dv, 0);
return builder.endObject();
}
export function encodeMatrixFBS(df) {
/*
encode the dataframe as an FBS Matrix
*/
/* row indexing not supported currently */
if (df.rowIndex.constructor !== IdentityInt32Index) {
throw new Error("FBS does not support row index encoding at this time");
}
const shape = df.dims;
const utf8Encoder = new TextEncoder("utf-8");
const builder = new flatbuffers.Builder(1024);
let encColIndex;
let encColIndexUType;
let encColumns;
if (shape[0] > 0 && shape[1] > 0) {
const columns = df.columns().map(col => col.asArray());
const cols = columns.map(carr => {
let uType;
let tarr;
if (isTypedArray(carr)) {
uType = NetEncoding.TypedArray[carr.constructor.name];
tarr = encodeTypedArray(builder, uType, 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);
});
encColumns = NetEncoding.Matrix.createColumnsVector(builder, cols);
if (df.colIndex && shape[1] > 0) {
const colIndexType = df.colIndex.constructor;
if (colIndexType === IdentityInt32Index) {
encColIndex = undefined;
} else if (colIndexType === DenseInt32Index) {
encColIndexUType = NetEncoding.TypedArray.Int32Array;
encColIndex = encodeTypedArray(
builder,
encColIndexUType,
df.colIndex.keys()
);
} else if (colIndexType === KeyIndex) {
encColIndexUType = NetEncoding.TypedArray.JSONEncodedArray;
encColIndex = encodeTypedArray(
builder,
encColIndexUType,
utf8Encoder.encode(JSON.stringify(df.colIndex.keys()))
);
} else {
throw new Error("Index type FBS encoding unsupported");
}
}
}
NetEncoding.Matrix.startMatrix(builder);
NetEncoding.Matrix.addNRows(builder, shape[0]);
NetEncoding.Matrix.addNCols(builder, shape[1]);
if (encColumns) {
NetEncoding.Matrix.addColumns(builder, encColumns);
}
if (encColIndexUType) {
NetEncoding.Matrix.addColIndexType(builder, encColIndexUType);
NetEncoding.Matrix.addColIndex(builder, encColIndex);
}
const root = NetEncoding.Matrix.endMatrix(builder);
builder.finish(root);
return builder.asUint8Array();
}
export default decodeMatrixFBS;
@@ -1,96 +0,0 @@
/*
Helpers for schema management
*/
import _ from "lodash";
import fromEntries from "../fromEntries";
/*
System wide schema assumptions:
- schema and data wil be consistent (eg, for user-created annotations)
- schema will be internally self-consistent (eg, index matches columns)
- world & universe schema are same - only data is subset
*/
export function indexEntireSchema(schema) {
/* Index schema for ease of use */
schema.annotations.obsByName = fromEntries(
schema.annotations.obs.columns.map(v => [v.name, v])
);
schema.annotations.varByName = fromEntries(
schema.annotations.var.columns.map(v => [v.name, v])
);
schema.layout.obsByName = fromEntries(
schema.layout.obs.map(v => [v.name, v])
);
schema.layout.varByName = fromEntries(
schema.layout.var.map(v => [v.name, v])
);
return schema;
}
function _copy(schema) {
/* redux copy conventions - WARNING, only for modifyign obs annotations */
return {
...schema,
annotations: {
...schema.annotations,
obs: _.cloneDeep(schema.annotations.obs)
}
};
}
function _reindex(schema) {
/* reindex obs annotations ONLY */
schema.annotations.obsByName = fromEntries(
schema.annotations.obs.columns.map(v => [v.name, v])
);
return schema;
}
export function removeObsAnnoColumn(schema, name) {
const newSchema = _copy(schema);
newSchema.annotations.obs.columns = schema.annotations.obs.columns.filter(
v => v.name !== name
);
return _reindex(newSchema);
}
export function addObsAnnoColumn(schema, name, defn) {
const newSchema = _copy(schema);
newSchema.annotations.obs.columns.push(defn);
return _reindex(newSchema);
}
export function removeObsAnnoCategory(schema, name, category) {
/* remove a category from a categorical annotation */
const categories = schema.annotations.obsByName[name]?.categories;
if (!categories)
throw new Error("column does not exist or is not categorical");
const idx = categories.indexOf(category);
if (idx === -1) throw new Error("category does not exist");
const newSchema = _reindex(_copy(schema));
/* remove category */
newSchema.annotations.obsByName[name].categories.splice(idx, 1);
return newSchema;
}
export function addObsAnnoCategory(schema, name, category) {
/* add a category to a categorical annotation */
const categories = schema.annotations.obsByName[name]?.categories;
if (!categories)
throw new Error("column does not exist or is not categorical");
const idx = categories.indexOf(category);
if (idx !== -1) throw new Error("category already exists");
const newSchema = _reindex(_copy(schema));
/* remove category */
newSchema.annotations.obsByName[name].categories.push(category);
return newSchema;
}
+19 -29
View File
@@ -1,11 +1,11 @@
// jshint esversion: 6
import _ from "lodash";
import { unassignedCategoryLabel } from "../../globals";
import { decodeMatrixFBS } from "./matrix";
import decodeMatrixFBS from "./matrix";
import * as Dataframe from "../dataframe";
import fromEntries from "../fromEntries";
import { isFpTypedArray } from "../typeHelpers";
import { indexEntireSchema } from "./schemaHelpers";
import { isCategoricalAnnotation } from "./annotationsHelpers";
/*
Private helper function - create and return a template Universe
@@ -18,13 +18,10 @@ function templateUniverse() {
schema: {},
/*
annotations
Annotations
*/
obsAnnotations: Dataframe.Dataframe.empty(),
varAnnotations: Dataframe.Dataframe.empty(),
/*
layout
*/
obsLayout: Dataframe.Dataframe.empty(),
/*
@@ -125,10 +122,6 @@ function reconcileSchemaCategoriesWithSummary(universe) {
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.
In addition, we have a client-side convention (UI) that all writable
annotations must have an 'unassigned' category, even if it is not currently
in use.
*/
universe.schema.annotations.obs.columns.forEach(s => {
@@ -143,10 +136,6 @@ function reconcileSchemaCategoriesWithSummary(universe) {
);
s.categories = categories;
}
if (s.writable && s.categories.indexOf(unassignedCategoryLabel) === -1) {
s.categories = s.categories.concat(unassignedCategoryLabel);
}
});
}
@@ -177,7 +166,7 @@ export function createUniverseFromResponse(
/* layout */
universe.obsLayout = LayoutFBSToDataframe(layoutFBSResponse);
/* sanity checks */
/* sanity check */
if (
universe.nObs !== universe.obsLayout.length ||
universe.nObs !== universe.obsAnnotations.length ||
@@ -187,19 +176,20 @@ export function createUniverseFromResponse(
}
reconcileSchemaCategoriesWithSummary(universe);
indexEntireSchema(universe.schema);
/* sanity checks */
if (
schema.annotations.obs.columns.some(
s => s.writable && !isCategoricalAnnotation(schema, s.name)
)
) {
throw new Error(
"Writable continuous obs annotations are not supproted - failed to laod"
);
}
/* Index schema for ease of use */
universe.schema.annotations.obsByName = fromEntries(
universe.schema.annotations.obs.columns.map(v => [v.name, v])
);
universe.schema.annotations.varByName = fromEntries(
universe.schema.annotations.var.columns.map(v => [v.name, v])
);
universe.schema.layout.obsByName = fromEntries(
universe.schema.layout.obs.map(v => [v.name, v])
);
universe.schema.layout.varByName = fromEntries(
universe.schema.layout.var.map(v => [v.name, v])
);
return universe;
}
+11 -4
View File
@@ -1,7 +1,14 @@
// jshint esversion: 6
import clip from "../clip";
import { layoutDimensionName, obsAnnoDimensionName } from "../nameCreators";
import {
layoutDimensionName,
obsAnnoDimensionName,
diffexpDimensionName,
userDefinedDimensionName
} from "../nameCreators";
import * as Dataframe from "../dataframe";
import { isContinuousAnnotation } from "./annotationsHelpers";
import ImmutableTypedCrossfilter from "../typedCrossfilter/crossfilter";
/*
@@ -151,7 +158,7 @@ and world.varData.
function setClippedDataframes(world) {
const { schema } = world;
const isContinuousObsAnnotation = (df, idx, label) =>
isContinuousAnnotation(schema, label);
deduceDimensionType(schema.annotations.obsByName[label], label) !== "enum";
const obsQuantile = (label, q) =>
world.unclipped.obsAnnotations.col(label).summarize().percentiles[100 * q];
world.obsAnnotations = clipDataframe(
@@ -176,7 +183,7 @@ function setClippedDataframes(world) {
/*
Subset the current world based upon the current selection, maintaining any existing
clip. Returns new world. Parameters:
* universe
* unvierse
* world - the current world
* crossfilter - the selection state
*/
+75
View File
@@ -0,0 +1,75 @@
/* 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();
}
@@ -35,7 +35,6 @@ class BitArray {
this.bitmask = new Int32Array(this.width); // dimension allocation mask
this.bitarray = new Int32Array(this.width * this.length);
Object.seal(this);
}
// Return the number of records that are selected, ie, have a one bit in
@@ -5,7 +5,6 @@ import BitArray from "./bitArray";
import {
sortArray,
lowerBound,
binarySearch,
lowerBoundIndirect,
upperBoundIndirect
} from "./sort";
@@ -47,7 +46,6 @@ export default class ImmutableTypedCrossfilter {
this.data = data;
this.selectionCache = selectionCache; /* BitArray */
this.dimensions = dimensions; /* name: { id, dim, name, selection } */
Object.preventExtensions(this);
}
size() {
@@ -58,14 +56,6 @@ export default class ImmutableTypedCrossfilter {
return this.data;
}
setData(data) {
return new ImmutableTypedCrossfilter(
data,
this.dimensions,
this.selectionCache
);
}
dimensionNames() {
/* return array of all dimensions (by name) */
return Object.keys(this.dimensions);
@@ -95,7 +85,6 @@ export default class ImmutableTypedCrossfilter {
}
const DimensionType = DimTypes[type];
const dim = new DimensionType(name, data, ...rest);
Object.freeze(dim);
const dimensions = {
...this.dimensions,
[name]: {
@@ -124,23 +113,6 @@ export default class ImmutableTypedCrossfilter {
return new ImmutableTypedCrossfilter(data, dimensions, selectionCache);
}
renameDimension(oldName, newName) {
/*
rename a dimension
*/
const { [oldName]: dim, ...dimensions } = this.dimensions;
const { data, selectionCache } = this;
dim.dim.rename(newName);
return new ImmutableTypedCrossfilter(
data,
{
...dimensions,
[newName]: dim
},
selectionCache
);
}
select(name, spec) {
/*
select on named dimension, as indicated by `spec`. Spec is an object
@@ -316,10 +288,6 @@ class _ImmutableBaseDimension {
this.name = name;
}
rename(name) {
this.name = name;
}
select(spec) {
const { mode } = spec;
if (mode === undefined) {
@@ -468,7 +436,7 @@ class ImmutableEnumDimension extends ImmutableScalarDimension {
const { values } = spec;
return super.selectExact({
mode: spec.mode,
values: values.map(v => binarySearch(enumIndex, v, 0, enumIndex.length))
values: values.map(v => lowerBound(enumIndex, v, 0, enumIndex.length))
});
}
-13
View File
@@ -413,16 +413,3 @@ export function upperBoundIndirect(valueArray, indexArray, value, first, last) {
}
return upperBoundNonFloatIndirect(valueArray, indexArray, value, first, last);
}
// Search for `value` in the sorted array `arr`, in the range [first, last).
// Return the first index where arr[index] == value, OR if value not present,
// return `last`
//
// The same semantics/behavior as:
// C++: binary_search()
//
export function binarySearch(valueArray, value, first, last) {
const index = lowerBound(valueArray, value, first, last);
if (index !== last && value === valueArray[index]) return index;
return last;
}
-1
View File
@@ -17,7 +17,6 @@
<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="shortcut icon" type="image/x-icon" href="cellxgene-favicon.png">
<link rel="stylesheet" href="{{ '/assets/css/style.css?v=' | append: site.github.build_revision | relative_url }}">
</head>
<body>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 312 B

-52
View File
@@ -1,52 +0,0 @@
<svg width="315" height="106" viewBox="0 0 315 106" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="315" height="106" fill="white"/>
<path d="M93.9375 63.375C95.0469 63.375 95.9062 63.0781 96.5156 62.4844C97.125 61.875 97.4219 61.0312 97.4062 59.9531H104.812C104.812 62.7344 103.812 65.0156 101.812 66.7969C99.8281 68.5781 97.2656 69.4688 94.125 69.4688C90.4375 69.4688 87.5312 68.3125 85.4062 66C83.2812 63.6875 82.2188 60.4844 82.2188 56.3906V56.0625C82.2188 53.5 82.6875 51.2422 83.625 49.2891C84.5781 47.3203 85.9453 45.8125 87.7266 44.7656C89.5078 43.7031 91.6172 43.1719 94.0547 43.1719C97.3516 43.1719 99.9688 44.0859 101.906 45.9141C103.844 47.7422 104.812 50.2188 104.812 53.3438H97.4062C97.4062 52.0312 97.0859 51.0234 96.4453 50.3203C95.8047 49.6172 94.9453 49.2656 93.8672 49.2656C91.8203 49.2656 90.625 50.5703 90.2812 53.1797C90.1719 54.0078 90.1172 55.1484 90.1172 56.6016C90.1172 59.1484 90.4219 60.9141 91.0312 61.8984C91.6406 62.8828 92.6094 63.375 93.9375 63.375ZM120.422 69.4688C116.531 69.4688 113.383 68.3125 110.977 66C108.57 63.6719 107.367 60.6484 107.367 56.9297V56.2734C107.367 53.6797 107.844 51.3906 108.797 49.4062C109.766 47.4219 111.172 45.8906 113.016 44.8125C114.859 43.7188 117.047 43.1719 119.578 43.1719C123.141 43.1719 125.953 44.2812 128.016 46.5C130.078 48.7031 131.109 51.7812 131.109 55.7344V58.8047H115.406C115.688 60.2266 116.305 61.3438 117.258 62.1562C118.211 62.9688 119.445 63.375 120.961 63.375C123.461 63.375 125.414 62.5 126.82 60.75L130.43 65.0156C129.445 66.375 128.047 67.4609 126.234 68.2734C124.438 69.0703 122.5 69.4688 120.422 69.4688ZM119.531 49.2656C117.219 49.2656 115.844 50.7969 115.406 53.8594H123.375V53.25C123.406 51.9844 123.086 51.0078 122.414 50.3203C121.742 49.6172 120.781 49.2656 119.531 49.2656ZM142.641 69H134.719V33H142.641V69ZM155.859 69H147.938V33H155.859V69ZM185.414 56.1328C185.414 53.5703 185.836 51.3047 186.68 49.3359C187.523 47.3672 188.734 45.8516 190.312 44.7891C191.891 43.7109 193.727 43.1719 195.82 43.1719C198.461 43.1719 200.523 44.0625 202.008 45.8438L202.289 43.6406H209.461V68.0391C209.461 70.2734 208.938 72.2109 207.891 73.8516C206.859 75.5078 205.359 76.7734 203.391 77.6484C201.422 78.5391 199.141 78.9844 196.547 78.9844C194.688 78.9844 192.883 78.6328 191.133 77.9297C189.398 77.2266 188.07 76.3125 187.148 75.1875L190.453 70.5469C191.922 72.2812 193.859 73.1484 196.266 73.1484C199.766 73.1484 201.516 71.3516 201.516 67.7578V66.9609C200 68.6328 198.086 69.4688 195.773 69.4688C192.68 69.4688 190.18 68.2891 188.273 65.9297C186.367 63.5547 185.414 60.3828 185.414 56.4141V56.1328ZM193.336 56.625C193.336 58.7031 193.727 60.3516 194.508 61.5703C195.289 62.7734 196.383 63.375 197.789 63.375C199.492 63.375 200.734 62.8047 201.516 61.6641V51C200.75 49.8438 199.523 49.2656 197.836 49.2656C196.43 49.2656 195.328 49.8984 194.531 51.1641C193.734 52.4141 193.336 54.2344 193.336 56.625ZM226.219 69.4688C222.328 69.4688 219.18 68.3125 216.773 66C214.367 63.6719 213.164 60.6484 213.164 56.9297V56.2734C213.164 53.6797 213.641 51.3906 214.594 49.4062C215.562 47.4219 216.969 45.8906 218.812 44.8125C220.656 43.7188 222.844 43.1719 225.375 43.1719C228.938 43.1719 231.75 44.2812 233.812 46.5C235.875 48.7031 236.906 51.7812 236.906 55.7344V58.8047H221.203C221.484 60.2266 222.102 61.3438 223.055 62.1562C224.008 62.9688 225.242 63.375 226.758 63.375C229.258 63.375 231.211 62.5 232.617 60.75L236.227 65.0156C235.242 66.375 233.844 67.4609 232.031 68.2734C230.234 69.0703 228.297 69.4688 226.219 69.4688ZM225.328 49.2656C223.016 49.2656 221.641 50.7969 221.203 53.8594H229.172V53.25C229.203 51.9844 228.883 51.0078 228.211 50.3203C227.539 49.6172 226.578 49.2656 225.328 49.2656ZM247.359 43.6406L247.617 46.6172C249.367 44.3203 251.781 43.1719 254.859 43.1719C257.5 43.1719 259.469 43.9609 260.766 45.5391C262.078 47.1172 262.758 49.4922 262.805 52.6641V69H254.883V52.9922C254.883 51.7109 254.625 50.7734 254.109 50.1797C253.594 49.5703 252.656 49.2656 251.297 49.2656C249.75 49.2656 248.602 49.875 247.852 51.0938V69H239.953V43.6406H247.359ZM279.469 69.4688C275.578 69.4688 272.43 68.3125 270.023 66C267.617 63.6719 266.414 60.6484 266.414 56.9297V56.2734C266.414 53.6797 266.891 51.3906 267.844 49.4062C268.812 47.4219 270.219 45.8906 272.062 44.8125C273.906 43.7188 276.094 43.1719 278.625 43.1719C282.188 43.1719 285 44.2812 287.062 46.5C289.125 48.7031 290.156 51.7812 290.156 55.7344V58.8047H274.453C274.734 60.2266 275.352 61.3438 276.305 62.1562C277.258 62.9688 278.492 63.375 280.008 63.375C282.508 63.375 284.461 62.5 285.867 60.75L289.477 65.0156C288.492 66.375 287.094 67.4609 285.281 68.2734C283.484 69.0703 281.547 69.4688 279.469 69.4688ZM278.578 49.2656C276.266 49.2656 274.891 50.7969 274.453 53.8594H282.422V53.25C282.453 51.9844 282.133 51.0078 281.461 50.3203C280.789 49.6172 279.828 49.2656 278.578 49.2656Z" fill="#E9429A" fill-opacity="0.5"/>
<path d="M160.617 61.2891L168.352 53.4141L160.664 45.5625L163.453 42.6797L171.164 50.5312L178.875 42.6797L181.664 45.5625L173.977 53.4141L181.711 61.2891L178.922 64.1719L171.164 56.2734L163.406 64.1719L160.617 61.2891Z" fill="white" fill-opacity="0.5"/>
<g filter="url(#filter0_d)">
<rect x="22" y="27" width="48" height="48" rx="3" fill="#E9429A"/>
<rect x="22.5" y="27.5" width="47" height="47" rx="2.5" stroke="white"/>
</g>
<mask id="mask0" mask-type="alpha" maskUnits="userSpaceOnUse" x="22" y="27" width="48" height="48">
<rect x="22" y="27" width="48" height="48" rx="3" fill="white"/>
</mask>
<g mask="url(#mask0)">
<rect x="37" y="42" width="28" height="28" fill="white"/>
<rect x="42" y="47" width="18" height="18" fill="#E9429A"/>
</g>
<rect x="27" y="42" width="6" height="28" fill="white"/>
<rect x="37" y="32" width="28" height="6" fill="white"/>
<g filter="url(#filter1_d)">
<rect x="22" y="27" width="48" height="48" rx="3" fill="#E9429A"/>
<rect x="23" y="28" width="46" height="46" rx="2" stroke="white" stroke-width="2"/>
</g>
<mask id="mask1" mask-type="alpha" maskUnits="userSpaceOnUse" x="22" y="27" width="48" height="48">
<rect x="22" y="27" width="48" height="48" rx="3" fill="white"/>
</mask>
<g mask="url(#mask1)">
<rect x="41" y="46" width="22" height="22" fill="white"/>
<rect x="46" y="51" width="12" height="12" fill="#E9429A"/>
</g>
<rect x="29" y="46" width="7" height="22" fill="white"/>
<rect x="41" y="34" width="22" height="7" fill="white"/>
<path d="M160.586 64.2891L168.32 56.4141L160.633 48.5625L163.422 45.6797L171.133 53.5312L178.844 45.6797L181.633 48.5625L173.945 56.4141L181.68 64.2891L178.891 67.1719L171.133 59.2734L163.375 67.1719L160.586 64.2891Z" fill="#E9429A" fill-opacity="0.5"/>
<defs>
<filter id="filter0_d" x="18" y="23" width="60" height="60" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dx="2" dy="2"/>
<feGaussianBlur stdDeviation="3"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.572052 0 0 0 0 0.572052 0 0 0 0 0.572052 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
<filter id="filter1_d" x="18" y="23" width="60" height="60" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dx="2" dy="2"/>
<feGaussianBlur stdDeviation="3"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.572052 0 0 0 0 0.572052 0 0 0 0 0.572052 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 7.7 KiB

+6 -20
View File
@@ -14,14 +14,6 @@ description: Data
`cellxgene prepare` is not meant as a way to formally process or analyze your data. It's simply a utility for quickly wrangling your data into cellxgene-compatible format and computing a "vanilla" embedding so you can try out `cellxgene` and get a general sense of a dataset.
#### How do I install `cellxgene prepare`?
The `cellxgene prepare` command is an optional install that you can install alongside `cellxgene launch` by running
```
pip install cellxgene[prepare]
```
#### What input formats does it accept?
Currently, we accept `h5ad` and `loom` files, as well as `10x` directories, and are hoping to accept more formats in the future.
@@ -46,15 +38,8 @@ As a quick example, let's construct a command to use `prepare` to take a raw exp
We'll start off using the raw data from the pbmc3k dataset. This dataset is described [here](https://icb-scanpy.readthedocs-hosted.com/en/stable/api/scanpy.datasets.pbmc3k.html), and is available as part of the scanpy API. For this example, we'll assume this raw data is stored in a file called `pbmc3k-raw.h5ad`.
Our `prepare` compose our command looks like this:
```
cellxgene prepare pbmc3k-raw.h5ad \
--run-qc \ # (A)
--recipe seurat \ # (B)
--layout tsne --layout umap \ # (C)
--output pbmc3k-prepared.h5ad # (D)
```
Our `prepare` compose our command looks like this:
<img src="prepare-cmd-example.jpg" width="700" />
Let's look at what `prepare` is doing to our data, and how each step relates to the command above. You can see a walkthrough of what's going on under the hood for this example in [this notebook](https://github.com/chanzuckerberg/cellxgene-vignettes/blob/master/dataset-processing/pbmc3k-prepare-example.ipynb).
@@ -67,9 +52,10 @@ Let's look at what `prepare` is doing to our data, and how each step relates to
# Example datasets to use with cellxgene
**To download and use these datasets, run:**
`curl -O [URL]`
`unzip [filename.zip]`
**To download and use these datasets, run:**
`curl -O [URL]`
`unzip [filename.zip]`
`cellxgene launch [filename.h5ad] --open`
### Peripheral blood mononuclear cells
+12 -40
View File
@@ -12,22 +12,20 @@ Currently, you can go straight into `cellxgene launch` with your own analyzed da
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)).
To add `cellxgene prepare` to your cellxgene installation run `pip install cellxgene[prepare]`.
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 `prepare` packages as described above. Just run
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 --embedding=umap
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 --embedding=umap --open
cellxgene launch data-processed.h5ad --layout=umap --open
```
And your web browser should open with an interactive view of your data.
@@ -40,7 +38,7 @@ Currently this is not supported directly, but you should be able to do this your
- `.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 embedding(s). If an embedding has more than two components, the first two will be used for visualization.
- `.obsm` is used for layout. If an embedding has more than two components, the first two will be used for visualization.
#### I have a BIG dataset - how can I make cellxgene run as fast as possible?
@@ -77,6 +75,14 @@ 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
@@ -94,37 +100,3 @@ This may happen, especially as we work out bugs in our installation process! Ple
#### 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.
# Data access
#### Can I use a _s3:_ or _gs:_ URL with `cellxgene launch`?
Yes. Support for S3 and GCS is not enabled by default. If you wish to directly access S3 or GFS, install one or both of the following packages using `pip`:
- [s3fs](https://s3fs.readthedocs.io/en/latest/) for S3 support
- [gcsfs](https://gcsfs.readthedocs.io/en/latest/) for GCS support
For example:
```
pip install s3fs
cellxgene launch s3://mybucket.s3-us-west-2.amazonaws.com/mydata.h5ad
```
#### What does the command line option `--backed` do?
The `--backed` option instructs `cellxgene launch` to read the H5AD file in "backed" mode (for more information, see the
[anndata.read_h5ad() documentation](https://anndata.readthedocs.io/en/latest/anndata.read_h5ad.html#anndata.read_h5ad)).
By default, cellxgene will read the entire H5AD will be into memory at startup, improving application speed and performance.
Very large datasets may not fit in memory. The "--backed" mode will read the file incrementally, reducing memory
use, and for large files, improving startup speed. _However_, this option will also significantly slow
down access to gene expression histograms, and may render differential expression calculations too slow
to use (see `--disable-diffexp` for an option to disable this feature).
#### What does the command line option `--disable-diffexp` do?
The `--disable-diffexp` option will disable and hide the `Compute Differential Expression` feature.
For large datasets, or datasets loaded with the `--backed` option, computing differential expression may
be extremely slow or use excessive reources on the host computer (eg, memory thrasing).
Disabling the feature will ensure that the end-user does not accidentally initiate this computation.
+18 -24
View File
@@ -21,12 +21,6 @@ If you want an example dataset download [this file](https://github.com/chanzucke
cellxgene launch pbmc3k.h5ad --open
```
You can also directly specify URLs as a data source, and the data will be downloaded during launch
```
cellxgene launch https://raw.githubusercontent.com/chanzuckerberg/cellxgene/master/example-dataset/pbmc3k.h5ad
```
On Mac OS and Ubuntu, 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">
@@ -35,7 +29,7 @@ On Mac OS and Ubuntu, you should see your web browser open with the following
There are several options available, such as:
- `--embedding` to restrict available emdeddings in the UI, eg, `tsne`, `umap`, `diffmap`, `phate`, `draw_graph_fa`, or `draw_graph_fr`
- `--layout` to specify the layout as `tsne`, `umap`, `diffmap`, `phate`, `draw_graph_fa`, or `draw_graph_fr`
- `--title` to show a title on the explorer
- `--open` to automatically open the web browser after launching (OS X only)
@@ -45,7 +39,7 @@ 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`).
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
@@ -55,37 +49,25 @@ The `launch` command assumes that the data is stored in the `.h5ad` format from
- 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 embedding that you want to render (e.g. `X_umap` for the `umap` embedding)
- an `obsm` field contains the two-dimensional coordinates for the layout that you want to render (e.g. `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 install `cellxgene prepare` alongside `cellxgene launch`
```
pip install cellxgene[prepare]
```
If the aforementioned optional package installation fails, you can also install these packages directly:
```
pip install scanpy>=1.3.7 python-igraph louvain>=0.6
```
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` embeddings 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).
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 embedding for `umap`, using a call like this
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 --embedding=umap --sparse
cellxgene prepare dataset.h5ad --output=dataset-processed.h5ad --layout=umap --sparse
```
To see all options call
@@ -94,6 +76,18 @@ 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
+5 -8
View File
@@ -1,8 +1,5 @@
<img src="cellxgene-logo.svg" width="300">
_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.
_an interactive 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
@@ -13,7 +10,7 @@ Whether you need to visualize one thousand cells or one million, cellxgene helps
## quick start
To install cellxgene you need Python 3.6+. We recommend [installing cellxgene into a conda or virtual environment.](/faq.html#how-do-i-create-a-python-environment-for-cellxgene)
To install _cellxgene_ you need Python 3.6+. We recommend [installing _cellxgene_ into a conda or virtual environment.](/faq.html#how-do-i-create-a-python-environment-for-cellxgene)
Install the package.
``` bash
@@ -26,14 +23,14 @@ Download an example [anndata](https://anndata.readthedocs.io/en/latest/) file
curl -o pbmc3k.h5ad https://raw.githubusercontent.com/chanzuckerberg/cellxgene/master/example-dataset/pbmc3k.h5ad
```
Launch cellxgene
Launch _cellxgene_
``` bash
cellxgene launch pbmc3k.h5ad --open
```
To explore more datasets already formatted for cellxgene, see [Data](data) or
To explore more datasets already formatted for _cellxgene_, see [Data](data) or
visit [Getting Started](getting-started) to learn more about formatting your own
data for cellxgene.
data for _cellxgene_.
## getting help
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

-7
View File
@@ -1,7 +0,0 @@
FROM python:3.7
WORKDIR /usr/src/app
RUN pip3 install cellxgene
expose 5005
-58
View File
@@ -1,58 +0,0 @@
# cellxgene cloud deployment with Heroku
## Quickstart
Clicking on the following button will forward you to Heroku to begin the deployment process:
<a href="https://heroku.com/deploy?template=https://github.com/chanzuckerberg/cellxgene/tree/heroku">
<img src="https://www.herokucdn.com/deploy/button.svg" alt="Deploy">
</a>
If not already logged in to Heroku, there you will be prompted to log in or sign up for an account.
Once logged in you will be sent to the setup page. Here you can set some of the basic settings for the app:
#### Default settings
- `App name`: the unique name for your deployment
- This will also serve as the default URL (e.g. https://cellxgene.herokapp.com/)
- `App owner`: Who will own this app. Either you personally or an organization/team
- `Region`: Location of the server where the app will be deployed (EU or US)
#### Configuration
- `DATASET`: A _publicly_ accessible URL pointing to a .h5ad file to view
- This defaults to pbm3k.h5ad
After filling out the settings and pressing the `Deploy app` button Heroku will begin building your deployment. This process will take a few minutes, but once completed you will have a personal free hosted version of cellxgene!
## What is Heroku?
Heroku is a quick and easy way to host applications on the cloud.
A Heroku deployment of cellxgene means that the app is not running on your local machine. Instead, the app is installed, configured, and ran on the Heroku servers (read: cloud).
On Heroku's servers, applications run on a [dyno](https://www.heroku.com/dynos) which are Heroku's implementation and abstraction of containers.
Heroku is one of many options available for hosting instances of cellxgene on the web.
Some other options include: Amazon Web Services, Google Cloud Platform, Digital Ocean, and Microsoft Azure.
## Why use Heroku to deploy cellxgene?
What Heroku enables is a quick, non-technical method of setting up a cellxgene instance. No command line knowledge needed. This also allows machines to access the instance via the internet, so sharing a visualized dataset is as simple as sharing a link.
Because cellxgene currently heavily relies on its Python backend for providing the viewer with the necessary data and tooling, it is currently not possible to host cellxgene as a static webpage.
This is a good option if you want to quickly deploy an instance of cellxgene to the web. Heroku deployments are free for small datasets up to around 250MBs in size. See below regarding larger datasets.
## When should I not deploy with Heroku?
- The default free dyno offered by Heroku is limited in memory to 512 MBs
- The amount of memory needed for the dyno is roughly the same size as the h5ad file
- Heroku offers tiered paid dynos. More can be found [here](https://www.heroku.com/pricing)
- Note that this can get _very_ expensive for larger datasets (\$25+ a month)
- On the free dyno, after 30 minutes of inactivity, Heroku will put your app into a hibernation mode. On the next access, Heroku will need time to boot the dyno back online.
- Having multiple simultaneous users requires more memory. This means that the free container size is easily overwhelmed by multiple users, even with small datasets; this can be addressed by purchasing a larger container size
- For this facilitated Heroku deployment to work, your dataset must be hosted on a publicly accessible URL
- By default, Heroku publically shares your instance to anyone with the URL.
- There are many ways of securing your instance. One quick and simple way is by installing [wwwhisper](https://elements.heroku.com/addons/wwwhisper), a Heroku addon
-5
View File
@@ -1,5 +0,0 @@
build:
docker:
web: experiments/heroku/Dockerfile
run:
web: curl -o dataset.h5ad $DATASET && cellxgene launch dataset.h5ad --host 0.0.0.0 --port $PORT
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.12.0"
__version__ = ""__version__ = "0.11.1""
+2 -2
View File
@@ -33,6 +33,6 @@ class Server:
self.app.register_blueprint(resources.blueprint)
self.app.add_url_rule("/", endpoint="index")
def attach_data(self, data, title="Demo", about=""):
self.app.config.update(DATASET_TITLE=title, ABOUT_DATASET=about)
def attach_data(self, data, title="Demo"):
self.app.config.update(DATASET_TITLE=title)
self.app.data = data
+2 -18
View File
@@ -29,9 +29,7 @@ class CXGDriver(metaclass=ABCMeta):
return {
"layout": None,
"max_category_items": None,
"diffexp_lfc_cutoff": None,
"disable_diffexp": False,
"diffexp_may_be_slow": False
"diffexp_lfc_cutoff": None
}
@property
@@ -48,14 +46,7 @@ class CXGDriver(metaclass=ABCMeta):
return features
@abstractmethod
def get_schema(self):
"""
Return current schema
"""
pass
@abstractmethod
def _load_data(self, data_locator):
def _load_data(self, data):
pass
@abstractmethod
@@ -68,13 +59,6 @@ class CXGDriver(metaclass=ABCMeta):
"""
pass
@abstractmethod
def annotation_put_fbs(self, axis, fbs):
"""
Put/save FBS as user-defined labels
"""
pass
@abstractmethod
def data_frame_to_fbs_matrix(self, filter, axis):
pass
+2 -26
View File
@@ -1,6 +1,5 @@
from http import HTTPStatus
import warnings
from os.path import basename
from flask import Blueprint, current_app, jsonify, make_response, request
from flask_restful import Api, Resource
@@ -17,7 +16,6 @@ from server.app.util.errors import (
InteractiveError,
JSONEncodingValueError,
PrepareError,
DisabledFeatureError,
)
"""
@@ -31,7 +29,7 @@ Sort order for routes
class SchemaAPI(Resource):
def get(self):
return make_response(
jsonify({"schema": current_app.data.get_schema()}), HTTPStatus.OK
jsonify({"schema": current_app.data.schema}), HTTPStatus.OK
)
@@ -65,13 +63,8 @@ class ConfigAPI(Resource):
"engine": f"cellxgene Scanpy engine version ",
"dataset": current_app.config["DATASET_TITLE"],
},
"links": {
"about-dataset": current_app.config["ABOUT_DATASET"]
},
"parameters": {
"max-category-items": current_app.data.config["max_category_items"],
"disable-diffexp": current_app.data.config["disable_diffexp"],
"diffexp-may-be-slow": current_app.data.config["diffexp_may_be_slow"]
"max-category-items": current_app.data.config["max_category_items"]
},
"library_versions": {
"cellxgene": cellxgene_version,
@@ -79,11 +72,6 @@ class ConfigAPI(Resource):
}
}
}
label_file = current_app.data.config["label_file"]
if label_file:
config["config"]["parameters"]["label_file"] = basename(label_file)
return make_response(jsonify(config), HTTPStatus.OK)
@@ -105,18 +93,6 @@ class AnnotationsObsAPI(Resource):
except ValueError as e:
return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR)
def put(self):
try:
fbs = request.get_data()
res = current_app.data.annotation_put_fbs("obs", fbs)
return make_response(
res, HTTPStatus.OK, {"Content-Type": "application/json"}
)
except (ValueError, DisabledFeatureError, KeyError) as e:
return make_response(str(e), HTTPStatus.BAD_REQUEST)
except Exception as e:
return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR)
class AnnotationsVarAPI(Resource):
def get(self):
+4 -3
View File
@@ -62,12 +62,13 @@ def diffexp_ttest(adata, maskA, maskB, top_n=8, diffexp_lfc_cutoff=0.01):
:param diffexp_lfc_cutoff: minimum
:return: for top N genes, [ varindex, logfoldchange, pval, pval_adj ]
"""
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, :])
meanA, vA, nA = _mean_var_n(adata._X[maskA])
meanB, vB, nB = _mean_var_n(adata._X[maskB])
# variance / N
vnA = vA / min(nA, nB) # overestimate variance, would normally be nA
@@ -86,7 +87,7 @@ def diffexp_ttest(adata, maskA, maskB, top_n=8, diffexp_lfc_cutoff=0.01):
# p-value
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)
-49
View File
@@ -1,49 +0,0 @@
"""
Helpers for user annotations / label_file parameter
"""
from os.path import exists, splitext, getsize
from os import remove, rename
import pandas as pd
def read_labels(fname):
if exists(fname) and getsize(fname) > 0:
return pd.read_csv(fname, dtype='category', index_col=0)
else:
return pd.DataFrame()
def write_labels(fname, df):
rotate_fname(fname)
if not df.empty:
df.to_csv(fname)
else:
open(fname, 'a').close()
def rotate_fname(fname):
"""
save N backups of file.
fname -> fname-0
fname-0 -> fname->1
...
fname-(N-1) -> fname-N
"""
def rotate(src, dst):
if exists(src):
if exists(dst):
remove(dst)
rename(src, dst)
rotation_size = 9 # rotation size
name, ext = splitext(fname)
# rotate existing files
for i in range(rotation_size - 1, 0, -1):
src = f"{name}-{i}{ext}"
tgt = f"{name}-{i+1}{ext}"
rotate(src, tgt)
tgt = f"{name}-1{ext}"
rotate(fname, tgt)
-42
View File
@@ -1,42 +0,0 @@
from server.app.util.matrix_proxy import MatrixProxyView, ArrayProxyView
"""
AnnData/h5py are inconsistent in the API supported by various types of
X matrices. Sometimes you get a fully ndarray, sometims a Scipy sparse
matrix, sometimes h5py proxies with a subset of our needed interfaces.
This glue code paves over all of that, providing the core set of methods
that the cellxgene ScanPy driver assumes are in existance. Put another
way, all of the non-portable assumptions are here.
"""
class ArrayProxyView_anndata_h5py(ArrayProxyView):
"""
override to handle sparse getitem semantics, which differ
from numpy.
"""
def toarray(self):
""" sadly, sparse indexing doesn't drop dimensions like numpy! """
arr = self.m[self._index[0], self._index[1]]
if self._vdim == 0:
arr = arr.transpose()
return arr.toarray()[0]
class MatrixProxy_anndata_h5py(MatrixProxyView):
"""
AnnData sparse array stored in H5AD, or proxies for backed data.
None of these handle indexing very well, so we plop a proxy on top.
"""
@classmethod
def __supports__(cls):
return ("anndata.h5py.h5sparse.SparseDataset",
"anndata.h5py.h5sparse.backed_csc_matrix",
"anndata.h5py.h5sparse.backed_csr_matrix",
"h5py._hl.dataset.Dataset")
@classmethod
def create_array(cls, *args, **kwargs):
return ArrayProxyView_anndata_h5py(*args, **kwargs)
+51 -153
View File
@@ -1,6 +1,4 @@
import warnings
import copy
import threading
import numpy as np
import pandas
@@ -15,27 +13,24 @@ from server.app.util.errors import (
JSONEncodingValueError,
PrepareError,
ScanpyFileError,
DisabledFeatureError,
)
from server.app.util.utils import jsonify_scanpy, requires_data
from server.app.scanpy_engine.diffexp import diffexp_ttest
from server.app.util.fbs.matrix import encode_matrix_fbs, decode_matrix_fbs
from server.app.scanpy_engine.labels import read_labels, write_labels
import server.app.scanpy_engine.matrix_proxy # noqa: F401
from server.app.util.matrix_proxy import MatrixProxy
from server.app.util.fbs.matrix import encode_matrix_fbs
def has_method(o, name):
""" return True if `o` has callable method `name` """
op = getattr(o, name, None)
return op is not None and callable(op)
"""
Sort order for methods
1. Initialize
2. Helper
3. Filter
4. Data & Metadata
5. Computation
"""
class ScanpyEngine(CXGDriver):
def __init__(self, data=None, args={}):
super().__init__(data, args)
# lock used to protect label file write ops
self.label_lock = threading.Lock()
if self.data:
self._validate_and_initialize()
@@ -52,10 +47,6 @@ class ScanpyEngine(CXGDriver):
"obs_names": None,
"var_names": None,
"diffexp_lfc_cutoff": 0.01,
"label_file": None,
"backed": False,
"disable_diffexp": False,
"diffexp_may_be_slow": False
}
@staticmethod
@@ -83,8 +74,6 @@ class ScanpyEngine(CXGDriver):
index column name to the front-end via the obs_names and var_names config
(which is incorporated into the schema).
"""
self.original_obs_index = self.data.obs.index
for (ax_name, config_name) in ((Axis.OBS, "obs_names"), (Axis.VAR, "var_names")):
name = self.config[config_name]
df_axis = getattr(self.data, str(ax_name))
@@ -136,29 +125,6 @@ class ScanpyEngine(CXGDriver):
return True
return False
@staticmethod
def _get_col_type(col):
dtype = col.dtype
data_kind = dtype.kind
schema = {}
if ScanpyEngine._can_cast_to_float32(col):
schema["type"] = "float32"
elif ScanpyEngine._can_cast_to_int32(col):
schema["type"] = "int32"
elif dtype == np.bool_:
schema["type"] = "boolean"
elif data_kind == "O" and dtype == "object":
schema["type"] = "string"
elif data_kind == "O" and dtype == "category":
schema["type"] = "categorical"
schema["categories"] = dtype.categories.tolist()
else:
raise TypeError(
f"Annotations of type {dtype} are unsupported by cellxgene."
)
return schema
@requires_data
def _create_schema(self):
self.schema = {
@@ -182,8 +148,25 @@ class ScanpyEngine(CXGDriver):
for ax in Axis:
curr_axis = getattr(self.data, str(ax))
for ann in curr_axis:
ann_schema = {"name": ann, "writable": False}
ann_schema.update(self._get_col_type(curr_axis[ann]))
ann_schema = {"name": ann}
dtype = curr_axis[ann].dtype
data_kind = dtype.kind
if self._can_cast_to_float32(curr_axis[ann]):
ann_schema["type"] = "float32"
elif self._can_cast_to_int32(curr_axis[ann]):
ann_schema["type"] = "int32"
elif dtype == np.bool_:
ann_schema["type"] = "boolean"
elif data_kind == "O" and dtype == "object":
ann_schema["type"] = "string"
elif data_kind == "O" and dtype == "category":
ann_schema["type"] = "categorical"
ann_schema["categories"] = curr_axis[ann].dtype.categories.tolist()
else:
raise TypeError(
f"Annotations of type {curr_axis[ann].dtype} are unsupported by cellxgene."
)
self.schema["annotations"][ax]["columns"].append(ann_schema)
for layout in self.config['layout']:
@@ -194,34 +177,11 @@ class ScanpyEngine(CXGDriver):
}
self.schema["layout"]["obs"].append(layout_schema)
@requires_data
def get_schema(self):
schema = self.schema # base schema
# add label obs annotations as needed
if self.labels is not None:
schema = copy.deepcopy(schema)
for col in self.labels.columns:
col_schema = {
"name": col,
"writable": True,
}
col_schema.update(self._get_col_type(self.labels[col]))
schema["annotations"]["obs"]["columns"].append(col_schema)
return schema
def _load_data(self, data_locator):
def _load_data(self, data):
# as of AnnData 0.6.19, backed mode performs initial load fast, but at the
# cost of significantly slower access to X data.
try:
# there is no guarantee data_locator indicates a local file. The AnnData
# API will only consume local file objects. If we get a non-local object,
# make a copy in tmp, and delete it after we load into memory.
with data_locator.local_handle() as lh:
# as of AnnData 0.6.19, backed mode performs initial load fast, but at the
# cost of significantly slower access to X data.
backed = 'r' if self.config['backed'] else None
self.data = anndata.read_h5ad(lh, backed=backed)
self.data = anndata.read_h5ad(data)
except ValueError:
raise ScanpyFileError(
"File must be in the .h5ad format. Please read "
@@ -231,24 +191,14 @@ class ScanpyEngine(CXGDriver):
"information."
)
except MemoryError:
raise ScanpyFileError("Out of memory - file is too large for available memory.")
raise ScanpyFileError("Error while loading file: out of memory, file is too large"
" for memory available")
except Exception as e:
raise ScanpyFileError(
f"{e} - file not found or is inaccessible. File must be an .h5ad object. "
f"Please check your input and try again."
f"Error while loading file: {e}, File must be in the .h5ad format, please check "
f"that your input and try again."
)
if self.config["label_file"]:
try:
self.labels = read_labels(self.config["label_file"])
except Exception as e:
raise ScanpyFileError(
f"Error while loading label file: {e}, File must be in the .csv format, please check "
f"your input and try again."
)
else:
self.labels = None
@requires_data
def _validate_and_initialize(self):
# var and obs column names must be unique
@@ -260,14 +210,8 @@ class ScanpyEngine(CXGDriver):
self.cell_count = self.data.shape[0]
self.gene_count = self.data.shape[1]
self._default_and_validate_layouts()
self._validate_label_data()
self._create_schema()
# heuristic
n_values = self.data.shape[0] * self.data.shape[1]
if (n_values > 1e8 and self.config['backed'] is True) or (n_values > 5e8):
self.config.update({"diffexp_may_be_slow": True})
@requires_data
def _default_and_validate_layouts(self):
""" function:
@@ -349,37 +293,6 @@ class ScanpyEngine(CXGDriver):
f"annotations with more than 500 categories in the UI"
)
@requires_data
def _validate_label_data(self):
"""
labels is None if disabled, empty if enabled by no data
"""
if self.labels is None or self.labels.empty:
return
# all lables must have a name, which must be unique and not used in obs column names
if not self.labels.columns.is_unique:
raise KeyError(f"All column names specified in {self.config['label_file']} must be unique.")
# the label index must be unique, and must have same values the anndata obs index
if not self.labels.index.is_unique:
raise KeyError(f"All row index values specified in the label file "
f"`{self.config['label_file']}` must be unique.")
if not self.labels.index.equals(self.original_obs_index):
raise KeyError("Label file row index does not match H5AD file index. "
"Please ensure that column zero (0) in the label file contain the same "
"index values as the H5AD file.")
duplicate_columns = list(set(self.labels.columns) & set(self.data.obs.columns))
if len(duplicate_columns) > 0:
raise KeyError(f"Labels file may not contain column names which overlap "
f"with h5ad obs columns {duplicate_columns}")
# labels must have same count as obs annotations
if self.labels.shape[0] != self.data.obs.shape[0]:
raise ValueError("Labels file must have same number of rows as h5ad file.")
@staticmethod
def _annotation_filter_to_mask(filter, d_axis, count):
mask = np.ones((count,), dtype=bool)
@@ -447,42 +360,28 @@ class ScanpyEngine(CXGDriver):
@requires_data
def annotation_to_fbs_matrix(self, axis, fields=None):
if axis == Axis.OBS:
if self.labels is not None and not self.labels.empty:
df = self.data.obs.join(self.labels, self.config['obs_names'])
else:
df = self.data.obs
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)
@requires_data
def annotation_put_fbs(self, axis, fbs):
fname = self.config["label_file"]
if not fname or self.labels is None:
raise DisabledFeatureError("Writable annotations are not enabled")
if axis != Axis.OBS:
raise ValueError("Only OBS dimension access is supported")
new_label_df = decode_matrix_fbs(fbs)
new_label_df.index = self.original_obs_index
self._validate_label_data() # paranoia
# if any of the new column labels overlap with our existing labels, raise error
duplicate_columns = list(set(new_label_df.columns) & set(self.data.obs.columns))
if not new_label_df.columns.is_unique or len(duplicate_columns) > 0:
raise KeyError(f"Labels file may not contain column names which overlap "
f"with h5ad obs columns {duplicate_columns}")
# update our internal state and save it. Multi-threading often enabled,
# so treat this as a critical section critical section.
with self.label_lock:
self.labels = new_label_df
write_labels(fname, self.labels)
return jsonify_scanpy({"status": "OK"})
@staticmethod
def slice_columns(X, var_mask):
"""
Slice columns from the matrix X, as specified by the mask
Semantically equivalent to X[:, var_mask], but handles sparse
matrices in a more performant manner.
"""
if var_mask is None: # noop
return X
if sparse.issparse(X): # use tuned getcol/hstack for performance
indices = np.nonzero(var_mask)[0]
cols = [X.getcol(i) for i in indices]
return sparse.hstack(cols, format="csc")
else: # else, just use standard slicing, which is fine for dense arrays
return X[:, var_mask]
@requires_data
def data_frame_to_fbs_matrix(self, filter, axis):
@@ -506,8 +405,7 @@ class ScanpyEngine(CXGDriver):
raise FilterError("filtering on obs unsupported")
# Currently only handles VAR dimension
X = MatrixProxy.create(self.data.X if var_selector is None
else self.data.X[:, var_selector])
X = self.slice_columns(self.data._X, var_selector)
return encode_matrix_fbs(X, col_idx=np.nonzero(var_selector)[0], row_idx=None)
@requires_data
-84
View File
@@ -1,84 +0,0 @@
import os
import tempfile
import fsspec
class DataLocator():
"""
DataLocator is a simple wrapper around fsspec functionality, and provides a
set of functions to encapsulate a data location (URI or path), interogate
metadata about the object at that location (size, existance, etc) and
access the underlying data.
https://filesystem-spec.readthedocs.io/en/latest/index.html
Example:
dl = DataLocator("/tmp/foo.h5ad")
if dl.exists():
print(dl.size())
with dl.open() as f:
thecontents = f.read()
DataLocator will accept a URI or native path. Error handling is as defined
in fsspec.
"""
def __init__(self, uri_or_path):
self.uri_or_path = uri_or_path
self.protocol, self.path = DataLocator._get_protocol_and_path(uri_or_path)
# work-around for LocalFileSystem not treating file: and None as the same scheme/protocol
self.cname = self.path if self.protocol == 'file' else self.uri_or_path
# will throw RuntimeError if the protocol is unsupported
self.fs = fsspec.filesystem(self.protocol)
@staticmethod
def _get_protocol_and_path(uri_or_path):
if "://" in uri_or_path:
protocol, path = uri_or_path.split("://", 1)
# windows!!! Ignore single letter drive identifiers,
# eg, G:\foo.txt
if len(protocol) > 1:
return protocol, path
return None, uri_or_path
def exists(self):
return self.fs.exists(self.cname)
def size(self):
return self.fs.size(self.cname)
def isfile(self):
return self.fs.isfile(self.cname)
def open(self, *args):
return self.fs.open(self.uri_or_path, *args)
def islocal(self):
return self.protocol is None or self.protocol == 'file'
def local_handle(self):
if self.islocal():
return LocalFilePath(self.path)
# if not local, create a tmp file system object to contain the data,
# and clean it up when done.
with self.open() as src, tempfile.NamedTemporaryFile(prefix="cellxgene_", delete=False) as tmp:
tmp.write(src.read())
tmp.close()
src.close()
tmp_path = tmp.name
return LocalFilePath(tmp_path, delete=True)
class LocalFilePath():
def __init__(self, tmp_path, delete=False):
self.tmp_path = tmp_path
self.delete = delete
def __enter__(self):
return self.tmp_path
def __exit__(self, *args):
if self.delete:
os.unlink(self.tmp_path)
-9
View File
@@ -59,12 +59,3 @@ class DriverError(Exception):
def __init__(self, message):
self.message = message
class DisabledFeatureError(Exception):
"""
Raised when an attempt to use a disabled feature occurs
"""
def __init__(self, message):
self.message = message
+38 -117
View File
@@ -2,17 +2,10 @@ import flatbuffers
import numpy as np
from scipy import sparse
import pandas as pd
import json
import server.app.util.fbs.NetEncoding.Column as Column
import server.app.util.fbs.NetEncoding.TypedArray as TypedArray
import server.app.util.fbs.NetEncoding.Matrix as Matrix
import server.app.util.fbs.NetEncoding.Int32Array as Int32Array
import server.app.util.fbs.NetEncoding.Uint32Array as Uint32Array
import server.app.util.fbs.NetEncoding.Float32Array as Float32Array
import server.app.util.fbs.NetEncoding.Float64Array as Float64Array
import server.app.util.fbs.NetEncoding.JSONEncodedArray as JSONEncodedArray
from server.app.util.matrix_proxy import MatrixProxy
# Placeholder until recent enhancements to flatbuffers Python
@@ -25,7 +18,7 @@ def CreateNumpyVector(builder, x):
"""CreateNumpyVector writes a numpy array into the buffer."""
if not isinstance(x, np.ndarray):
raise TypeError(f"non-numpy-ndarray passed to CreateNumpyVector ({type(x)}")
raise TypeError("non-numpy-ndarray passed to CreateNumpyVector")
if x.dtype.kind not in ['b', 'i', 'u', 'f']:
raise TypeError("numpy-ndarray holds elements of unsupported datatype")
@@ -92,7 +85,7 @@ def serialize_typed_array(builder, source_array, encoding_info):
as_json = arr.to_json(orient='records')
arr = np.array(bytearray(as_json, 'utf-8'))
else:
if MatrixProxy.ismatrixproxy(arr) or sparse.issparse(arr):
if sparse.issparse(arr):
arr = arr.toarray()
elif isinstance(arr, pd.Series):
arr = arr.get_values()
@@ -100,11 +93,8 @@ def serialize_typed_array(builder, source_array, encoding_info):
arr = arr.astype(as_type)
# serialize the ndarray into a vector
if arr.ndim == 2:
if arr.shape[0] == 1:
arr = arr[0]
elif arr.shape[1] == 1:
arr = arr.T[0]
if arr.ndim == 2 and arr.shape[0] == 1:
arr = arr[0]
vec = CreateNumpyVector(builder, arr)
# serialize the typed array table
@@ -114,42 +104,38 @@ def serialize_typed_array(builder, source_array, encoding_info):
return (array_type, array_value)
column_encoding_type_map = {
# array protocol string: ( array_type, as_type )
np.dtype(np.float64).str: (TypedArray.TypedArray.Float32Array, np.float32),
np.dtype(np.float32).str: (TypedArray.TypedArray.Float32Array, np.float32),
np.dtype(np.float16).str: (TypedArray.TypedArray.Float32Array, np.float32),
np.dtype(np.int8).str: (TypedArray.TypedArray.Int32Array, np.int32),
np.dtype(np.int16).str: (TypedArray.TypedArray.Int32Array, np.int32),
np.dtype(np.int32).str: (TypedArray.TypedArray.Int32Array, np.int32),
np.dtype(np.int64).str: (TypedArray.TypedArray.Int32Array, np.int32),
np.dtype(np.uint8).str: (TypedArray.TypedArray.Uint32Array, np.uint32),
np.dtype(np.uint16).str: (TypedArray.TypedArray.Uint32Array, np.uint32),
np.dtype(np.uint32).str: (TypedArray.TypedArray.Uint32Array, np.uint32),
np.dtype(np.uint64).str: (TypedArray.TypedArray.Uint32Array, np.uint32)
}
column_encoding_default = (TypedArray.TypedArray.JSONEncodedArray, 'json')
def column_encoding(arr):
return column_encoding_type_map.get(arr.dtype.str, column_encoding_default)
type_map = {
# dtype: ( array_type, as_type )
np.float64: (TypedArray.TypedArray.Float32Array, np.float32),
np.float32: (TypedArray.TypedArray.Float32Array, np.float32),
np.float16: (TypedArray.TypedArray.Float32Array, np.float32),
np.int8: (TypedArray.TypedArray.Int32Array, np.int32),
np.int16: (TypedArray.TypedArray.Int32Array, np.int32),
np.int32: (TypedArray.TypedArray.Int32Array, np.int32),
np.int64: (TypedArray.TypedArray.Int32Array, np.int32),
index_encoding_type_map = {
# array protocol string: ( array_type, as_type )
np.dtype(np.int32).str: (TypedArray.TypedArray.Int32Array, np.int32),
np.dtype(np.int64).str: (TypedArray.TypedArray.Int32Array, np.int32),
np.dtype(np.uint32).str: (TypedArray.TypedArray.Uint32Array, np.uint32),
np.dtype(np.uint64).str: (TypedArray.TypedArray.Uint32Array, np.uint32)
}
index_encoding_default = (TypedArray.TypedArray.JSONEncodedArray, 'json')
np.uint8: (TypedArray.TypedArray.Uint32Array, np.uint32),
np.uint16: (TypedArray.TypedArray.Uint32Array, np.uint32),
np.uint32: (TypedArray.TypedArray.Uint32Array, np.uint32),
np.uint64: (TypedArray.TypedArray.Uint32Array, np.uint32)
}
type_map_default = (TypedArray.TypedArray.JSONEncodedArray, 'json')
return type_map.get(arr.dtype.type, type_map_default)
def index_encoding(arr):
return index_encoding_type_map.get(arr.dtype.str, index_encoding_default)
type_map = {
# dtype: ( array_type, as_type )
np.int32: (TypedArray.TypedArray.Int32Array, np.int32),
np.int64: (TypedArray.TypedArray.Int32Array, np.int32),
np.uint32: (TypedArray.TypedArray.Uint32Array, np.uint32),
np.uint64: (TypedArray.TypedArray.Uint32Array, np.uint32)
}
type_map_default = (TypedArray.TypedArray.JSONEncodedArray, 'json')
return type_map.get(arr.dtype.type, type_map_default)
def guess_at_mem_needed(matrix):
@@ -189,11 +175,16 @@ def encode_matrix_fbs(matrix, row_idx=None, col_idx=None):
# estimate size needed, so we don't unnecessarily realloc.
builder = flatbuffers.Builder(guess_at_mem_needed(matrix))
if isinstance(matrix, pd.DataFrame):
matrix_columns = reversed(tuple(matrix[name] for name in matrix))
else:
matrix_columns = reversed(tuple(c for c in matrix.T))
columns = []
for cidx in range(n_cols - 1, -1, -1):
# for idx in reversed(np.arange(n_cols)):
for c in matrix_columns:
# serialize the typed array
col = matrix.iloc[:, cidx] if isinstance(matrix, pd.DataFrame) else matrix[:, cidx]
typed_arr = serialize_typed_array(builder, col, column_encoding)
typed_arr = serialize_typed_array(builder, c, column_encoding)
# serialize the Column union
columns.append(serialize_column(builder, typed_arr))
@@ -214,73 +205,3 @@ def encode_matrix_fbs(matrix, row_idx=None, col_idx=None):
builder.Finish(matrix)
return builder.Output()
def deserialize_typed_array(tarr):
type_map = {
TypedArray.TypedArray.NONE: None,
TypedArray.TypedArray.Uint32Array: Uint32Array.Uint32Array,
TypedArray.TypedArray.Int32Array: Int32Array.Int32Array,
TypedArray.TypedArray.Float32Array: Float32Array.Float32Array,
TypedArray.TypedArray.Float64Array: Float64Array.Float64Array,
TypedArray.TypedArray.JSONEncodedArray: JSONEncodedArray.JSONEncodedArray
}
(u_type, u) = tarr
if u_type is TypedArray.TypedArray.NONE:
return None
TarType = type_map.get(u_type, None)
if TarType is None:
raise TypeError(f"FBS contains unknown data type: {u_type}")
arr = TarType()
arr.Init(u.Bytes, u.Pos)
narr = arr.DataAsNumpy()
if u_type == TypedArray.TypedArray.JSONEncodedArray:
narr = json.loads(narr.tostring().decode('utf-8'))
return narr
def decode_matrix_fbs(fbs):
"""
Given an FBS-encoded Matrix, return a Pandas DataFrame the contains the data
and indices.
"""
matrix = Matrix.Matrix.GetRootAsMatrix(fbs, 0)
n_rows = matrix.NRows()
n_cols = matrix.NCols()
if n_rows == 0 or n_cols == 0:
return pd.DataFrame()
if matrix.RowIndexType() is not TypedArray.TypedArray.NONE:
raise ValueError("row indexing not supported for FBS Matrix")
columns_length = matrix.ColumnsLength()
columns_index = deserialize_typed_array((matrix.ColIndexType(), matrix.ColIndex()))
if columns_index is None:
columns_index = range(0, n_cols)
# sanity checks
if len(columns_index) != n_cols or columns_length != n_cols:
raise ValueError("FBS column count does not match number of columns in underlying matrix")
columns_data = {}
columns_type = {}
for col_idx in range(0, columns_length):
col = matrix.Columns(col_idx)
tarr = (col.UType(), col.U())
data = deserialize_typed_array(tarr)
columns_data[columns_index[col_idx]] = data
if len(data) != n_rows:
raise ValueError("FBS column length does not match number of rows")
if col.UType() is TypedArray.TypedArray.JSONEncodedArray:
columns_type[columns_index[col_idx]] = "category"
df = pd.DataFrame.from_dict(data=columns_data).astype(columns_type, copy=False)
# more sanity checks
if not df.columns.is_unique or len(df.columns) != n_cols:
raise KeyError("FBS column indices are not unique")
return df
-425
View File
@@ -1,425 +0,0 @@
import abc
from itertools import zip_longest
from copy import copy
import numpy as np
"""
cellxgene deals with a variety of matrix data types, many of which do
not support a consistent API. This framework allows proxies to be created
to pave over some of this. Most significantly, AnnData.X does not guarantee
that much of its API (eg. .X.T) will work.
"""
INT_TYPES = (int, np.integer)
class _ArrayProxyBase(abc.ABC):
"""
Private base class for array or matrix proxy. This summarizes
the interface used by the rest of cellxgene.
"""
@property
@abc.abstractmethod
def dtype(self):
raise NotImplementedError()
@property
@abc.abstractmethod
def ndim(self):
raise NotImplementedError()
@property
@abc.abstractmethod
def shape(self):
raise NotImplementedError()
@property
@abc.abstractmethod
def T(self):
raise NotImplementedError()
@abc.abstractmethod
def __iter__(self):
raise NotImplementedError()
@abc.abstractmethod
def __getitem__(self, args):
raise NotImplementedError()
@abc.abstractmethod
def toarray():
raise NotImplementedError()
class MatrixProxy(_ArrayProxyBase):
"""
Abstract class - all interfaces we need, plus a factory method
to create a proxy based upon actual matrix type.
This class primarily provides the factory method and related support.
All other functionality is delegated to subclasses.
"""
"""
Registry of types to proxy class, where values are:
* None: unsupported
* True: self-supported
* string: proxy class
Sub-classes automatically register.
"""
base_proxy_registry = {
'pandas.core.frame.DataFrame': True,
'numpy.ndarray': True,
'scipy.sparse.csc.csc_matrix': True,
'scipy.sparse.csr.csr_matrix': True,
}
proxy_registry = None
last_cache_token = None
@staticmethod
def _register_subclasses(subclasses, registry):
for c in subclasses:
names = c.__supports__()
for name in names:
registry[name] = c
MatrixProxy._register_subclasses(c.__subclasses__(), registry)
@classmethod
def build_proxy_registry(cls):
if cls.proxy_registry and abc.get_cache_token() == cls.last_cache_token:
return
cls.last_cache_token = abc.get_cache_token()
registry = copy(cls.base_proxy_registry)
MatrixProxy._register_subclasses(cls.__subclasses__(), registry)
cls.proxy_registry = registry
@classmethod
def create(cls, matrix):
"""
Factory - call with a matrix and it will create a proxy if needed.
If the type already supports the necessary API, it is just returned
directly.
"""
cls.build_proxy_registry()
t = type(matrix)
fqtn = t.__module__ + '.' + t.__name__
proxy_cls = cls.proxy_registry.get(fqtn, None)
if proxy_cls is None:
raise Exception(f"Matrix format `{fqtn}` is unsupported by proxy.")
if proxy_cls is True:
return matrix
return proxy_cls(matrix)
def __init__(self, m):
self.m = m
@classmethod
@abc.abstractmethod
def __supports__(cls):
raise NotImplementedError()
@classmethod
def ismatrixproxy(cls, m):
return isinstance(m, _ArrayProxyBase)
class MatrixProxyView(MatrixProxy):
"""
2D matrix view to a 2D matrix
"""
def __init__(self, arg1, shape=None, index=(),
transposed=False, copy=False):
if not copy:
m = arg1
super().__init__(m)
if shape is None:
shape = m.shape
assert(len(shape) == 2)
index = tuple(
map(lambda s_i:
slice(0, s_i[0], 1) if s_i[1] is None else s_i[1],
zip_longest(shape, index))
)
self._shape = shape
self._index = index
self.transposed = transposed
else: # copy mode
super().__init__(arg1.m)
self._shape = arg1._shape
self._index = arg1._index
self.transposed = arg1.transposed
@classmethod
def create_array(cls, *args, **kwargs):
""" override if you use a different 1D array proxy """
return ArrayProxyView(*args, **kwargs)
def copy(self):
""" override if you need additional behaviors """
return self.__class__(self, copy=True)
@classmethod
def __supports__(cls):
return ()
def _swap(self, x):
return (x[1], x[0]) if self.transposed else x
@property
def dtype(self):
return self.m.dtype
@property
def ndim(self):
return len(self._shape)
@property
def shape(self):
return self._swap(self._shape)
@property
def T(self):
m = self.copy()
m.transposed = not m.transposed
return m
def __iter__(self):
M = self._swap(self._shape)[0]
s = self._swap((self._index))[0]
start, stop, step = s.indices(M)
for d in range(start, stop, step):
yield self[d]
def __getitem__(self, args):
"""
decompose into the indexing patterns we use, throw for the rest.
Subclassses implement specialized access.
"""
row, col = self._swap(_unpack_index(args, self.shape))
M, N = self._shape
allM, allN = self.m.shape
if isinstance(row, INT_TYPES):
row += self._index[0].start
elif isinstance(row, slice):
row = _slice_slice(self._index[0], allM, row, M)
if isinstance(col, INT_TYPES):
col += self._index[1].start
elif isinstance(col, slice):
col = _slice_slice(self._index[1], allN, col, N)
if isinstance(row, INT_TYPES):
if isinstance(col, INT_TYPES):
return self._getitem_intXint(row, col)
elif isinstance(col, slice):
return self._getitem_intXslice(row, col)
elif isinstance(row, slice):
if isinstance(col, INT_TYPES):
return self._getitem_sliceXint(row, col)
elif isinstance(col, slice):
return self._getitem_sliceXslice(row, col)
raise IndexError("unsupported column index types")
"""
These getitem signatures are separate so that they may be
overridden by subclasses as necessary. We don't do much
with them by default other than the obvious sub-slicing.
NOTE: these follow the numpy rules for dimensionality reduction
when an integer index is specified.
"""
def _getitem_intXint(self, row, col):
return self.m[row, col]
def _getitem_intXslice(self, row, col):
shape = (_slice_length(col, self.m.shape[1]), )
return self.__class__.create_array(self.m, shape=shape, index=(row, col))
def _getitem_sliceXint(self, row, col):
shape = (_slice_length(row, self.m.shape[0]), )
return self.__class__.create_array(self.m, shape=shape, index=(row, col))
def _getitem_sliceXslice(self, row, col):
shape = (_slice_length(row, self.m.shape[0]),
_slice_length(col, self.m.shape[1]))
return self.__class__(self.m, shape=shape, index=(row, col), transposed=self.transposed)
def toarray(self):
arr = self.m[self._index]
if self.transposed:
arr = arr.transpose()
return arr
class ArrayProxyView(_ArrayProxyBase):
"""
1D array view to a 2D matrix
"""
def __init__(self, arg1, shape=None, index=None, copy=False):
super().__init__()
if not copy:
m = arg1
# one index MUST be an integer and the other MUST be a slice
assert(len(index) == 2)
assert(all(isinstance(idx, INT_TYPES + (slice, )) for idx in index))
assert(isinstance(index[0], INT_TYPES) != isinstance(index[1], INT_TYPES))
if shape is None:
if isinstance(index[0], INT_TYPES):
shape = (m.shape[0], )
else:
shape = (m.shape[1], )
assert(len(shape) == 1)
self._shape = shape
self.m = m
self._index = index
self._vdim = 1 if isinstance(index[0], INT_TYPES) else 0
else:
self.m = arg1.m
self._shape = arg1._shape
self._index = arg1._index
self.fixed = arg1.fixed
def copy(self):
return self.__class__(self, copy=True)
@property
def dtype(self):
return self.m.dtype
@property
def ndim(self):
return len(self._shape)
@property
def shape(self):
return self._shape
@property
def T(self):
return self.copy()
def __iter__(self):
_vdim = self._vdim
M = self._shape[0]
for d in range(*self._index[_vdim].indices(M)):
yield self[d]
def __getitem__(self, args):
_vdim = self._vdim
index = _unpack_index(args, self.shape)[0]
M = self._shape[0]
allM = self.m.shape[_vdim]
if isinstance(index, INT_TYPES):
index += self._index[_vdim].start
elif isinstance(index, slice):
index = _slice_slice(self._index[_vdim], allM, index, M)
if _vdim == 0:
row, col = index, self._index[1]
else:
row, col = self._index[0], index
if isinstance(row, INT_TYPES):
if isinstance(col, INT_TYPES):
return self._getitem_intXint(row, col)
elif isinstance(col, slice):
return self._getitem_intXslice(row, col)
elif isinstance(row, slice):
assert(isinstance(col, INT_TYPES))
return self._getitem_sliceXint(row, col)
raise IndexError("unsupported column index types")
def _getitem_intXint(self, row, col):
return self.m[row, col]
def _getitem_intXslice(self, row, col):
shape = (_slice_length(col, self.m.shape[1]), )
return self.__class__(self.m, shape=shape, index=(row, col))
def _getitem_sliceXint(self, row, col):
shape = (_slice_length(row, self.m.shape[0]), )
return self.__class__(self.m, shape=shape, index=(row, col))
def toarray(self):
return self.m[self._index]
def _unpack_index(index, shape):
if not isinstance(index, tuple):
index = (index, )
if len(shape) < len(index):
raise IndexError("invalid index dimensionality - must be 2")
unpacked = ()
for shp, idx in zip_longest(shape, index):
idx = slice(None) if idx is None else idx
idx = _slice_defaults(idx, shp) if isinstance(idx, slice) else idx
unpacked += (idx, )
return unpacked
def _slice_slice(outer, outer_len, inner, inner_len):
"""
slice a slice - we take advantage of Python 3 range's support
for indexing.
"""
assert(outer_len >= inner_len)
outer_rng = range(*outer.indices(outer_len))
rng = outer_rng[inner]
start, stop, step = rng.start, rng.stop, rng.step
if step < 0 and stop < 0:
stop = None
return slice(start, stop, step)
def _range_length(start, stop, step):
""" return length of range """
assert(step != 0)
assert(start is not None and stop is not None and step is not None)
if step > 0 and start < stop:
return 1 + (stop - 1 - start) // step
elif step < 0 and start > stop:
return 1 + (start - 1 - stop) // -step
else:
return 0
def _slice_length(s, length):
""" return slice length """
return _range_length(*s.indices(length))
def _slice_defaults(s, length):
""" apply slice defaulting conventions """
assert(length >= 0)
step = 1 if s.step is None else s.step
if s.start is not None:
start = s.start
if start < 0:
start += length
else:
start = 0 if step > 0 else (length - 1)
if s.stop is not None:
stop = s.stop
if stop < 0:
stop += length
else:
stop = length if step > 0 else -length - 1
return slice(start, stop, step)
+1 -1
View File
@@ -5,7 +5,7 @@ from .prepare import prepare
@click.group(name="cellxgene", context_settings=dict(max_content_width=85))
@click.version_option(version="0.12.0", prog_name="cellxgene", message="[%(prog)s] Version %(version)s")
@click.version_option(version="0.11.1", prog_name="cellxgene", message="[%(prog)s] Version %(version)s")
def cli():
pass
+24 -98
View File
@@ -2,11 +2,10 @@ import errno
import functools
import logging
from os import devnull
from os.path import splitext, basename
from os.path import splitext, basename, getsize
import sys
import warnings
import webbrowser
from urllib.parse import urlparse
import click
@@ -14,7 +13,6 @@ from server.app.app import Server
from server.app.util.errors import ScanpyFileError
from server.app.util.utils import custom_format_warning
from server.utils.utils import find_available_port, is_port_available
from server.app.util.data_locator import DataLocator
# anything bigger than this will generate a special message
BIG_FILE_SIZE_THRESHOLD = 100 * 2 ** 20 # 100MB
@@ -24,18 +22,14 @@ def common_args(func):
"""
Decorator to contain CLI args that will be common to both CLI and GUI: title and engine args.
"""
@click.option("--title", "-t", help="Title to display (if omitted will use file name).")
@click.option("--about",
help="A URL to more information about the dataset."
"(This must be an absolute URL including HTTP(S) protocol)")
@click.option(
"--embedding",
"-e",
"--layout",
"-l",
default=[],
multiple=True,
show_default=False,
help="Embedding name, eg, 'umap'. Repeat option for multiple embeddings. Defaults to all."
show_default=True,
help="Layout name, eg, 'umap'."
)
@click.option("--obs-names", default=None, metavar="", help="Name of annotation field to use for observations.")
@click.option("--var-names", default=None, metavar="", help="Name of annotation to use for variables.")
@@ -52,52 +46,24 @@ def common_args(func):
show_default=True,
help="Relative expression cutoff used when selecting top N differentially expressed genes",
)
@click.option(
"--experimental-label-file",
default=None,
show_default=True,
multiple=False,
metavar="<user labels CSV file>",
help="CSV file containing user annotations; will be overwritten. Created if does not exist.",
)
@click.option(
"--backed",
is_flag=True,
default=False,
show_default=False,
help="Load data in file-backed mode, which may save memory, but result in slower overall performance."
)
@click.option(
"--disable-diffexp",
is_flag=True,
default=False,
show_default=False,
help="Disable on-demand differential expression."
)
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
def parse_engine_args(embedding, obs_names, var_names, max_category_items,
diffexp_lfc_cutoff, experimental_label_file, backed,
disable_diffexp):
def parse_engine_args(layout, obs_names, var_names, max_category_items, diffexp_lfc_cutoff):
return {
"layout": embedding,
"layout": layout,
"max_category_items": max_category_items,
"diffexp_lfc_cutoff": diffexp_lfc_cutoff,
"obs_names": obs_names,
"var_names": var_names,
"label_file": experimental_label_file,
"backed": backed,
"disable_diffexp": disable_diffexp
}
@click.command()
@click.argument("data", nargs=1, metavar="<data file>", required=True)
@click.argument("data", metavar="<data file>", type=click.Path(exists=True, file_okay=True, dir_okay=False))
@click.option(
"--verbose",
"-v",
@@ -134,17 +100,13 @@ def launch(
open_browser,
port,
host,
embedding,
layout,
obs_names,
var_names,
max_category_items,
diffexp_lfc_cutoff,
title,
scripts,
about,
experimental_label_file,
backed,
disable_diffexp
scripts
):
"""Launch the cellxgene data viewer.
This web app lets you explore single-cell expression data.
@@ -155,34 +117,16 @@ def launch(
> cellxgene launch example_dataset/pbmc3k.h5ad --title pbmc3k
> cellxgene launch <your data file> --title <your title>
> cellxgene launch <url>"""
e_args = parse_engine_args(embedding, obs_names, var_names, max_category_items,
diffexp_lfc_cutoff, experimental_label_file, backed,
disable_diffexp)
try:
data_locator = DataLocator(data)
except RuntimeError as re:
raise click.ClickException(f"Unable to access data at {data}. {str(re)}")
> cellxgene launch <your data file> --title <your title>"""
e_args = parse_engine_args(layout, obs_names, var_names, max_category_items, diffexp_lfc_cutoff)
# Startup message
click.echo("[cellxgene] Starting the CLI...")
# Argument checking
if data_locator.islocal():
# if data locator is local, apply file system conventions and other "cheap"
# validation checks. If a URI, defer until we actually fetch the data and
# try to read it. Many of these tests don't make sense for URIs (eg, extension-
# based typing).
if not data_locator.exists():
raise click.FileError(data, hint="file does not exist")
if not data_locator.isfile():
raise click.FileError(data, hint="data is not a file")
name, extension = splitext(data)
if extension != ".h5ad":
raise click.FileError(basename(data), hint="file type must be .h5ad")
name, extension = splitext(data)
if extension != ".h5ad":
raise click.FileError(basename(data), hint="file type must be .h5ad")
if debug:
verbose = True
@@ -220,25 +164,6 @@ def launch(
else:
port = find_available_port(host)
if experimental_label_file:
lf_name, lf_ext = splitext(experimental_label_file)
if lf_ext and lf_ext != ".csv":
raise click.FileError(basename(experimental_label_file), hint="label file type must be .csv")
if about:
def url_check(url):
try:
result = urlparse(url)
if all([result.scheme, result.netloc]):
return True
else:
return False
except ValueError:
return False
if not url_check(about):
raise click.ClickException("Must provide an absolute URL for --about. (Example format: http://example.com)")
# Setup app
cellxgene_url = f"http://{host}:{port}"
@@ -252,25 +177,26 @@ def launch(
log = logging.getLogger("werkzeug")
log.setLevel(logging.ERROR)
file_size = data_locator.size() if data_locator.islocal() else 0
file_size = getsize(data)
# if a big file, let the user know it may take a while to load.
if file_size > BIG_FILE_SIZE_THRESHOLD:
click.echo(f"[cellxgene] Loading data from {basename(data)}, this may take a while...")
click.echo(f"[cellxgene] Loading data from {basename(data)}, this may take awhile...")
else:
click.echo(f"[cellxgene] Loading data from {basename(data)}.")
# Fix for anaconda python. matplotlib typically expects python to be installed as a framework TKAgg is usually
# available and fixes this issue. See https://matplotlib.org/faq/virtualenv_faq.html
import matplotlib as mpl
mpl.use("TkAgg")
from server.app.scanpy_engine.scanpy_engine import ScanpyEngine
try:
server.attach_data(ScanpyEngine(data_locator, e_args), title=title, about=about)
server.attach_data(ScanpyEngine(data, e_args), title=title)
except ScanpyFileError as e:
raise click.ClickException(f"{e}")
if not disable_diffexp and server.app.data.config['diffexp_may_be_slow']:
click.echo(f"[cellxgene] CAUTION: due to the size of your dataset, "
f"running differential expression may take longer or fail.")
if open_browser:
click.echo(f"[cellxgene] Launching! Opening your browser to {cellxgene_url} now.")
webbrowser.open(cellxgene_url)
@@ -284,7 +210,7 @@ def launch(
sys.stdout = f
try:
server.app.run(host=host, debug=debug, port=port, threaded=False if debug else True, use_debugger=False)
server.app.run(host=host, debug=debug, port=port, threaded=True, use_debugger=False)
except OSError as e:
if e.errno == errno.EADDRINUSE:
raise click.ClickException("Port is in use, please specify an open port using the --port flag.") from e
+32 -31
View File
@@ -8,12 +8,12 @@ from scipy.sparse.csc import csc_matrix
@click.command()
@click.argument("data", nargs=1, metavar="<dataset: file or path to data>", required=True)
@click.option(
"--embedding",
"-e",
"--layout",
"-l",
default=["umap", "tsne"],
multiple=True,
type=click.Choice(["umap", "tsne"]),
help="Embedding algorithm",
help="Layout algorithm",
show_default=True,
)
@click.option(
@@ -41,40 +41,34 @@ from scipy.sparse.csc import csc_matrix
"--make-var-names-unique", default=True, is_flag=True, help="Ensure var index is unique.", show_default=True
)
def prepare(
data,
embedding,
recipe,
output,
plotting,
sparse,
overwrite,
set_obs_names,
set_var_names,
run_qc,
make_obs_names_unique,
make_var_names_unique,
data,
layout,
recipe,
output,
plotting,
sparse,
overwrite,
set_obs_names,
set_var_names,
run_qc,
make_obs_names_unique,
make_var_names_unique,
):
"""Preprocesses data for use with cellxgene.
This tool runs a series of scanpy routines for preparing a dataset
for use with cellxgene. It loads data from different formats
(h5ad, loom, or a 10x directory), runs dimensionality reduction,
computes nearest neighbors, computes an embedding, performs clustering,
computes nearest neighbors, computes a layout, performs clustering,
and saves the results. Includes additional options for naming
annotations, ensuring sparsity, and plotting results."""
# collect slow imports here to make CLI startup more responsive
click.echo("[cellxgene] Starting CLI...")
try:
import matplotlib
import matplotlib
matplotlib.use("Agg")
import scanpy as sc
except ImportError:
raise click.ClickException(
"[cellxgene] cellxgene prepare has not been installed. Please run `pip install cellxgene[prepare]` "
"to install the necessary requirements."
)
matplotlib.use("Agg")
import scanpy as sc
# scanpy settings
sc.settings.verbosity = 0
@@ -160,20 +154,27 @@ def prepare(
sc.pp.neighbors(adata)
def run_louvain(adata):
sc.tl.louvain(adata)
try:
sc.tl.louvain(adata)
except ModuleNotFoundError:
click.echo(
"\nWarning: louvain module is not installed, no clusters will be calculated. "
"To fix this please install cellxgene with the optional feature louvain enabled: "
"`pip install cellxgene[louvain]`"
)
def run_embedding(adata):
def run_layout(adata):
if len(unique(adata.obs["louvain"].values)) < 10:
palette = "tab10"
else:
palette = "tab20"
if "umap" in embedding:
if "umap" in layout:
sc.tl.umap(adata)
if plotting:
sc.pl.umap(adata, color="louvain", palette=palette, save="_louvain")
if "tsne" in embedding:
if "tsne" in layout:
sc.tl.tsne(adata)
if plotting:
sc.pl.tsne(adata, color="louvain", palette=palette, save="_louvain")
@@ -190,12 +191,12 @@ def prepare(
"run_pca": "Running PCA",
"run_neighbors": "Calculating neighbors",
"run_louvain": "Calculating clusters",
"run_embedding": "Computing embedding",
"run_layout": "Computing layout",
}
if item is not None:
return names[item.__name__]
steps = [calculate_qc_metrics, make_sparse, run_recipe, run_pca, run_neighbors, run_louvain, run_embedding]
steps = [calculate_qc_metrics, make_sparse, run_recipe, run_pca, run_neighbors, run_louvain, run_layout]
click.echo(f"[cellxgene] Loading data from {data}, please wait...")
adata = load_data(data)
-1
View File
@@ -1,6 +1,5 @@
black
bumpversion>=0.5
parameterized>=0.7.0
pytest>=3.6.3
requests>=2.18.4
twine>=1.12.1
-3
View File
@@ -1,3 +0,0 @@
scanpy>=1.3.7
python-igraph
louvain>=0.6
+4 -4
View File
@@ -6,10 +6,10 @@ Flask-Compress>=1.4.0
Flask-Cors>=3.0.6
Flask-RESTful>=0.3.6
flatbuffers>=1.10.0
fsspec>=0.4.4
scanpy>=1.3.7
matplotlib>=2.2
numpy>=1.15.2
pandas>=0.23.1
scipy>=1.3.0
scipy>=1.1.0
scikit-learn>=0.19.1,!=0.20.0
tables==3.5.1
# TEMP workaround for https://github.com/theislab/scanpy/issues/832 aka h5py regression
h5py==2.9.0
+7 -14
View File
@@ -10,23 +10,19 @@
"columns": [
{
"name": "name_0",
"type": "string",
"writable": false
"type": "string"
},
{
"name": "n_genes",
"type": "int32",
"writable": false
"type": "int32"
},
{
"name": "percent_mito",
"type": "float32",
"writable": false
"type": "float32"
},
{
"name": "n_counts",
"type": "float32",
"writable": false
"type": "float32"
},
{
"name": "louvain",
@@ -40,8 +36,7 @@
"FCGR3A+ Monocytes",
"Dendritic cells",
"Megakaryocytes"
],
"writable": false
]
}
]
},
@@ -50,13 +45,11 @@
"columns": [
{
"name": "name_0",
"type": "string",
"writable": false
"type": "string"
},
{
"name": "n_cells",
"type": "int32",
"writable": false
"type": "int32"
}
]
}
Binary file not shown.
Binary file not shown.
-93
View File
@@ -1,93 +0,0 @@
import unittest
import pandas as pd
import numpy as np
from scipy import sparse
import decode_fbs
from server.app.util.fbs.matrix import encode_matrix_fbs, decode_matrix_fbs
class FbsTests(unittest.TestCase):
"""Test Case for Matrix FBS data encode/decode """
def test_encode_boundary(self):
""" test various boundary checks """
# row indexing is unsupported
with self.assertRaises(ValueError):
encode_matrix_fbs(matrix=pd.DataFrame(), row_idx=[])
# matrix must be 2D
with self.assertRaises(ValueError):
encode_matrix_fbs(matrix=np.zeros((3, 2, 1)))
with self.assertRaises(ValueError):
encode_matrix_fbs(matrix=np.ones((10,)))
def fbs_checks(self, fbs, dims, expected_types, expected_column_idx):
d = decode_fbs.decode_matrix_FBS(fbs)
self.assertEqual(d["n_rows"], dims[0])
self.assertEqual(d["n_cols"], dims[1])
self.assertIsNone(d["row_idx"])
self.assertEqual(len(d["columns"]), dims[1])
for i in range(0, len(d["columns"])):
self.assertEqual(len(d["columns"][i]), dims[0])
self.assertIsInstance(d["columns"][i], expected_types[i][0])
if (expected_types[i][1] is not None):
self.assertEqual(d["columns"][i].dtype, expected_types[i][1])
if expected_column_idx is not None:
self.assertSetEqual(set(expected_column_idx), set(d["col_idx"]))
def test_encode_DataFrame(self):
df = pd.DataFrame(
data={
'a': np.zeros((10,), dtype=np.float32),
'b': np.ones((10,), dtype=np.int64),
'c': np.array([i for i in range(0, 10)], dtype=np.uint16),
'd': pd.Series(['x', 'y', 'z', 'x', 'y', 'z', 'a', 'x', 'y', 'z'], dtype='category')
})
expected_types = (
(np.ndarray, np.float32),
(np.ndarray, np.int32),
(np.ndarray, np.uint32),
(list, None)
)
fbs = encode_matrix_fbs(matrix=df, row_idx=None, col_idx=df.columns)
self.fbs_checks(fbs, (10, 4), expected_types, ['a', 'b', 'c', 'd'])
def test_encode_ndarray(self):
arr = np.zeros((3, 2), dtype=np.float32)
expected_types = (
(np.ndarray, np.float32),
(np.ndarray, np.float32),
(np.ndarray, np.float32)
)
fbs = encode_matrix_fbs(matrix=arr, row_idx=None, col_idx=None)
self.fbs_checks(fbs, (3, 2), expected_types, None)
def test_encode_sparse(self):
csc = sparse.csc_matrix(np.array([[0, 1, 2], [3, 0, 4]]))
expected_types = (
(np.ndarray, np.int32),
(np.ndarray, np.int32),
(np.ndarray, np.int32)
)
fbs = encode_matrix_fbs(matrix=csc, row_idx=None, col_idx=None)
self.fbs_checks(fbs, (2, 3), expected_types, None)
def test_roundtrip(self):
dfSrc = pd.DataFrame(
data={
'a': np.zeros((10,), dtype=np.float32),
'b': np.ones((10,), dtype=np.int64),
'c': np.array([i for i in range(0, 10)], dtype=np.uint16),
'd': pd.Series(['x', 'y', 'z', 'x', 'y', 'z', 'a', 'x', 'y', 'z'], dtype='category')
})
dfDst = decode_matrix_fbs(encode_matrix_fbs(matrix=dfSrc, col_idx=dfSrc.columns))
self.assertEqual(dfSrc.shape, dfDst.shape)
self.assertEqual(set(dfSrc.columns), set(dfDst.columns))
for c in dfSrc.columns:
self.assertTrue(c in dfDst.columns)
if isinstance(dfSrc[c], pd.Series):
self.assertTrue(np.all(dfSrc[c] == dfDst[c]))
else:
self.assertEqual(dfSrc[c], dfDst[c])

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