Compare commits

...
28 Commits
Author SHA1 Message Date
Bruce Martin d8e3721846 bumpversion to 0.7.0 (#623) 2019-03-01 12:25:30 -08:00
Bruce Martin a876740a3c create helper file for controls reducer (#615)
* initial dataframe commit

* initial dataframe port of core app

* rename variables for clarity

* remove unused import

* comment out unused code

* fix array handling bug in crossfilter dimension creation

* allow creation of empty dataframes

* handle non-existent columns

* handle non-existent columns

* revise tests for new dataframe

* comments for clarity

* comments for clarity

* generate bulk add placeholder with real gene names

* fix bug in gene name adding

* more dataframe unit tests

* fix bug - subset from current world, not universe

* put cut and pasted code into a single function

* improve caching of crossfilter

* remove cascading update bug from graph

* more performance work

* improve state handling for scatterplot

* performance optimization of critical path

* add column summarization

* dataframe utils

* add callOnceLazy

* fix tests

* minor updates found during review

* fix misspelling

* remove RESTv02 from function names

* comment cleanup

* cut/icut col parameter defaults to null

* break up large test

* improve tests and comments on dataframe at/has functions

* add Dataframe withCol/dropCol

* expression varData now stored in a dataframe

* dead code cleanup

* use dataframe.summarize()

* test cases for Dataframe.col.summarize

* update test cases for new dataframe summarize

* improve naming

* use new hasCol API

* add comments

* add more Dataframe.withCol tests

* add ability to specify row index in cut operation

* retire subsetVarData function

* correctly handle expression subsetting

* lint and improve comments

* rename cut to subset

* create helper file for controls reducer
2019-02-28 09:21:19 -08:00
Bruce Martin e7ad6f5d1c [WIP DO NOT MERGE] correctly display graph legend for negative continuous metadata (#620)
correctly display graph legend for negative continuous metadata
2019-02-28 09:08:19 -08:00
Bruce Martin 2f1facaafb [WIP DO NOT MERGE] suppress display of continous annotation without a finite extent (#618)
* initial dataframe commit

* initial dataframe port of core app

* rename variables for clarity

* remove unused import

* comment out unused code

* fix array handling bug in crossfilter dimension creation

* allow creation of empty dataframes

* handle non-existent columns

* handle non-existent columns

* revise tests for new dataframe

* comments for clarity

* comments for clarity

* generate bulk add placeholder with real gene names

* fix bug in gene name adding

* more dataframe unit tests

* fix bug - subset from current world, not universe

* put cut and pasted code into a single function

* improve caching of crossfilter

* remove cascading update bug from graph

* more performance work

* improve state handling for scatterplot

* performance optimization of critical path

* add column summarization

* dataframe utils

* add callOnceLazy

* fix tests

* minor updates found during review

* fix misspelling

* remove RESTv02 from function names

* comment cleanup

* cut/icut col parameter defaults to null

* break up large test

* improve tests and comments on dataframe at/has functions

* add Dataframe withCol/dropCol

* expression varData now stored in a dataframe

* dead code cleanup

* use dataframe.summarize()

* test cases for Dataframe.col.summarize

* update test cases for new dataframe summarize

* improve naming

* use new hasCol API

* add comments

* add more Dataframe.withCol tests

* add ability to specify row index in cut operation

* retire subsetVarData function

* correctly handle expression subsetting

* lint and improve comments

* rename cut to subset

* suppress display of continous annotation withont a finite extent

* fix botched merge

* more fix of botched merged
2019-02-28 09:05:48 -08:00
Bruce Martin ffd6273419 Dataframe, part deux - add varData and summarize() (#608)
* initial dataframe commit

* initial dataframe port of core app

* rename variables for clarity

* remove unused import

* comment out unused code

* fix array handling bug in crossfilter dimension creation

* allow creation of empty dataframes

* handle non-existent columns

* handle non-existent columns

* revise tests for new dataframe

* comments for clarity

* comments for clarity

* generate bulk add placeholder with real gene names

* fix bug in gene name adding

* more dataframe unit tests

* fix bug - subset from current world, not universe

* put cut and pasted code into a single function

* improve caching of crossfilter

* remove cascading update bug from graph

* more performance work

* improve state handling for scatterplot

* performance optimization of critical path

* add column summarization

* dataframe utils

* add callOnceLazy

* fix tests

* minor updates found during review

* fix misspelling

* remove RESTv02 from function names

* comment cleanup

* cut/icut col parameter defaults to null

* break up large test

* improve tests and comments on dataframe at/has functions

* add Dataframe withCol/dropCol

* expression varData now stored in a dataframe

* dead code cleanup

* use dataframe.summarize()

* test cases for Dataframe.col.summarize

* update test cases for new dataframe summarize

* improve naming

* use new hasCol API

* add comments

* add more Dataframe.withCol tests

* add ability to specify row index in cut operation

* retire subsetVarData function

* correctly handle expression subsetting

* lint and improve comments

* rename cut to subset

* changes based on PR review
2019-02-28 08:34:22 -08:00
Charlotte Weaver 2bae696986 Smoke tests (#604)
smoke tests
2019-02-27 15:46:58 -08:00
Bruce Martin 6b33315cbe Dataframe (#576)
* initial dataframe commit

* initial dataframe port of core app

* rename variables for clarity

* remove unused import

* comment out unused code

* fix array handling bug in crossfilter dimension creation

* allow creation of empty dataframes

* handle non-existent columns

* handle non-existent columns

* revise tests for new dataframe

* comments for clarity

* comments for clarity

* generate bulk add placeholder with real gene names

* fix bug in gene name adding

* more dataframe unit tests

* fix bug - subset from current world, not universe

* put cut and pasted code into a single function

* improve caching of crossfilter

* remove cascading update bug from graph

* more performance work

* improve state handling for scatterplot

* performance optimization of critical path

* add column summarization

* dataframe utils

* add callOnceLazy

* fix tests

* minor updates found during review

* fix misspelling

* remove RESTv02 from function names

* comment cleanup

* cut/icut col parameter defaults to null

* break up large test

* improve tests and comments on dataframe at/has functions
2019-02-22 11:31:34 -08:00
Bruce Martin 57c4e9ff33 Flatbuffer cleanup (#598)
* dead code and route removal

* more dead code cleanup

* fix scanpy_engine tests

* lint

* add missing catch in filter parsing

* update scanpy NaN tests

* more fbs tests and dead test removal

* remove forced default for content type negotiation

* bit of cleanup

* more fbs test cleanup

* lint

* remove swagger

* swagger cleanup

* lint

* correctly handle lack of templates

* more dead code removal

* remove unused files

* fix dev build

* lint
2019-02-19 08:50:29 -08:00
Charlotte Weaver 4e67c645f8 bumped version (#602)
0.6.0 was burned on pypi
2019-02-14 10:00:16 -08:00
Charlotte Weaver 8b28d51dfa bump version (#601) 2019-02-13 16:29:14 -08:00
Charlotte Weaver 40ad283107 create server testing doc (#592) 2019-02-13 15:35:45 -08:00
Isaac Virshup 0f8d7a55de Set API path based on access address (#568)
* Make api paths relative

* Remove request import

* Set publicPath to be relative
2019-02-11 09:01:37 -08:00
Sidney Bell b6f946ec8a Add note about installing hdf5 to FAQ (#581) 2019-02-08 14:56:16 -08:00
Colin Megill dbb3a309a9 Lasso (#586)
* lasso working

* break out invert into own function

* action

* add spatial dimension to crossfilter, in support of polygon lasso

* improve comments on new dimension API

* lasso vs zoom
2019-02-08 11:48:51 -08:00
Bruce Martin 2e9525741f doc divergence warning (#591) 2019-02-08 11:00:51 -08:00
Charlotte Weaver 585a5808b9 check if accept type in content type string (#589) 2019-02-08 09:41:24 -08:00
Bruce Martin 6f464f4f92 package dependency updates (#585)
* lint

* update dev-related package dependencies
2019-02-06 12:57:09 -08:00
Colin Megill 08ea7d5137 Better input validation (#580) 2019-02-05 10:44:59 -05:00
Charlotte Weaver ad9be3cdd7 remove build-dev from .gitignore (#583) 2019-02-04 14:47:28 -08:00
Charlotte Weaver f737cc4ee4 Build improvements (#577) 2019-02-04 14:15:35 -08:00
Charlotte Weaver 1103272b95 De-dupe -d CLI option alias (#575)
removed from debug, diffexp gets to keep it
2019-01-30 16:11:21 -08:00
Colin Megill 2df7161cd8 Bulk add genes (#567)
* bulk add

* cleanup
2019-01-29 16:26:09 -05:00
Charlotte Weaver d31c05c970 Add backed script to package.json (#566)
* QOL script for FE devs to get & launch the backend

* ensure python3.6

* changed name to backend-dev
2019-01-29 11:41:05 -08:00
fionagriffin 07db2eb3ee Update data.md (#544) 2019-01-29 10:31:31 -08:00
Justin Kiggins a6d2a2e119 updates link to scanpy recipe docs. fixes #564 (#565) 2019-01-28 10:00:29 -08:00
Justin Kiggins f87e4bfbd3 home page fix (#551)
* explicit site.url in config

* infra for custom css

* moves home item to html layout

* adds baseurl to site config
2019-01-28 09:13:09 -08:00
Charlotte Weaver ecaa32cfb2 bump version (#563) 2019-01-17 17:14:44 -08:00
Charlotte Weaver 10693b08cc Add __init__ file so fbs can be imported (#562) 2019-01-17 17:11:31 -08:00
79 changed files with 6886 additions and 5513 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
[bumpversion] [bumpversion]
current_version = 0.5.0 current_version = 0.7.0
[bumpversion:file:setup.py] [bumpversion:file:setup.py]
search = version="{current_version}" search = version="{current_version}"
+17 -9
View File
@@ -8,14 +8,22 @@ cache:
install: install:
- set -eo pipefail - set -eo pipefail
- pip install flake8 - pip install flake8
- ./bin/build-client - make build
- pip install -e . - make install
- pip install -r server/requirements-dev.txt - pip install -r server/requirements-dev.txt
- docker build . - docker build .
script:
- set -eo pipefail jobs:
- flake8 server include:
- black --check - name: "Branch Tests"
- npm run --prefix client/ build script:
- npm run --prefix client/ test - set -eo pipefail
- pytest -s server/test - flake8 server
- black --check
- npm run --prefix client/ build
- npm run --prefix client/ unit-test
- pytest -s server/test
- name: "Smoke Tests"
if: branch = master AND type = cron
script:
- npm run --prefix client/ smoke-test
+7 -3
View File
@@ -70,7 +70,7 @@ To prepare from an existing `.h5ad` file use
cellxgene prepare dataset.h5ad --output=dataset-processed.h5ad cellxgene prepare dataset.h5ad --output=dataset-processed.h5ad
``` ```
This will load the input data, perform PCA and nearest neighbor calculations, compute `umap` and `tsne` layouts and `louvain` cluster assignments, and save the results in a new file called `dataset-processed.h5ad` that can be loaded using `cellxgene launch`. Data can be loaded from several formats, including `.h5ad` `.loom` and a `10-Genomics-formatted` `mtx` directory. Several options are available, including running one of the preprocessing `recipes` included with `scanpy`, which include steps like cell filtering and gene selection. To learn more about the `recipes` please see the `scanpy` [documentation](https://github.com/theislab/scanpy/blob/master/scanpy/preprocessing/recipes.py). This will load the input data, perform PCA and nearest neighbor calculations, compute `umap` and `tsne` layouts and `louvain` cluster assignments, and save the results in a new file called `dataset-processed.h5ad` that can be loaded using `cellxgene launch`. Data can be loaded from several formats, including `.h5ad` `.loom` and a `10-Genomics-formatted` `mtx` directory. Several options are available, including running one of the preprocessing `recipes` included with `scanpy`, which include steps like cell filtering and gene selection. To learn more about the `recipes` please see the `scanpy` [documentation](https://scanpy.readthedocs.io/en/latest/api/index.html#recipes).
Depending on the options chosen, `prepare` can take a long time to run (a few minutes for datasets with 10-100k cells, up to an hour or more for datasets with >100k cells). If you want `prepare` to run faster we recommend using the `sparse` option and only computing the layout for `umap`, using a call like this 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
@@ -191,9 +191,13 @@ Currently this is not supported directly, but you should be able to do this manu
<hr> <hr>
> I tried to `pip install cellxgene` and got a weird error I don't understand > I tried to `pip install cellxgene` and got a weird error about missing paths to an HDF5 library?
This may happen, especially as we work out bugs in our installation process! Please create a new [Github issue](https://github.com/chanzuckerberg/cellxgene/issues), explain what you did, and include all the error messages you saw. It'd also be super helpful if you call `pip freeze` and include the full output alongside your issue. You probably just need to install HDF5 first. If you're on a mac, you can simply `brew install hdf5` and then try `pip install cellxgene` again.
> I tried to `pip install cellxgene` and got another weird error I don't understand
This may happen, especially as we work out bugs in our installation process! Please create a new [Github issue](https://github.com/chanzuckerberg/cellxgene/issues), explain what you did, and include all the error messages you saw. It'd also be super helpful if you call `pip freeze` and include the full output alongside your issue.
<hr> <hr>
-15
View File
@@ -1,15 +0,0 @@
#!/bin/bash
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )"
CELLXGENE_DIR=$(dirname $DIR)
cd $CELLXGENE_DIR
npm install --prefix client/ client
npm run --prefix client build
rm -rf server/app/web/static
mkdir -p server/app/web/static/img
cp client/build/index.html server/app/web/templates/
cp -r client/build/static server/app/web/
cp client/build/favicon.png server/app/web/static/img
cp client/build/service-worker.js server/app/web/static/js/
-18
View File
@@ -1,18 +0,0 @@
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )"
CELLXGENE_DIR=$(dirname $DIR)
echo "Uninstalling cellxgene"
yes | pip uninstall cellxgene
echo "removing node_modules"
rm -rf $CELLXGENE_DIR/client/node_modules
echo "removing client_build"
rm -rf $CELLXGENE_DIR/client/build
echo "removing dist"
rm -rf $CELLXGENE_DIR/dist
echo "removing egg-info"
rm -rf $CELLXGENE_DIR/cellxgene.egg-info
echo "removing static files"
rm -f $CELLXGENE_DIR/server/app/web/templates/index.html
rm -rf $CELLXGENE_DIR/server/app/web/static
echo "cellxgene cleanup complete"
+185
View File
@@ -0,0 +1,185 @@
import puppeteer from "puppeteer";
const jest_env = process.env.JEST_ENV || "dev";
const appPort = process.env.JEST_CXG_PORT || 3000;
const appUrlBase = `http://localhost:${appPort}`;
const DEV = jest_env === "dev";
let browser;
let page;
const browserViewport = { width: 1280, height: 960 };
beforeAll(async () => {
const browser_params = DEV
? { headless: false, slowMo: 100, devtools: true }
: {};
browser = await puppeteer.launch(browser_params);
page = await browser.newPage();
page.setViewport(browserViewport);
if (DEV) page.on("console", msg => console.log("PAGE LOG:", msg.text()));
});
afterAll(() => {
if (!DEV) {
browser.close();
}
});
const getOneElementInnerHTML = async function(selector) {
let text = await page.$eval(selector, el => el.innerHTML);
return text;
};
const drag = async function(el_box, start, end, lasso = false) {
const x1 = el_box.content[0].x + start.x;
const x2 = el_box.content[0].x + end.x;
const y1 = el_box.content[0].y + start.y;
const y2 = el_box.content[0].y + end.y;
await page.mouse.move(x1, y1);
await page.mouse.down();
if (lasso) {
await page.mouse.move(x2, y1);
await page.mouse.move(x2, y2);
await page.mouse.move(x1, y2);
await page.mouse.move(x1, y1);
} else {
await page.mouse.move(x2, y2);
}
await page.mouse.up();
};
describe("did launch", () => {
test("page launched", async () => {
await page.goto(appUrlBase);
let el = await getOneElementInnerHTML("[data-testid='header']");
expect(el).toBe("cellxgene: pbmc3k");
});
});
describe("search for genes", () => {
test("search for known gene and add to metadata", async () => {
await page.goto(appUrlBase);
await page.waitForSelector("[ data-testid='gene-search']");
// blueprint's typeahead is treating typing weird, clicking & waiting first solves this
await page.click("[data-testid='gene-search']");
await page.waitFor(200);
await page.type("[data-testid='gene-search']", "ACD");
await page.keyboard.press("Enter");
await page.waitForSelector("[data-testid='histogram-ACD']");
});
});
describe("select cells and diffexp", () => {
test("selects cells from layout and adds to cell set 1", async () => {
await page.goto(appUrlBase);
const layout = await page.waitForSelector("[data-testid='layout']");
const size = await layout.boxModel();
const cellset1 = {
start: {
x: Math.floor(size.width * 0.25),
y: Math.floor(size.height * 0.25)
},
end: {
x: Math.floor(size.width * 0.35),
y: Math.floor(size.height * 0.35)
}
};
await drag(size, cellset1.start, cellset1.end, true);
await page.click("[data-testid='cellset-button-1");
let button = await getOneElementInnerHTML("[data-testid='cellset-button-1");
expect(button).toMatch(/26 cells/);
});
test("selects cells from layout and adds to cell set 2", async () => {
await page.goto(appUrlBase);
const layout = await page.waitForSelector("[data-testid='layout']");
const size = await layout.boxModel();
const cellset2 = {
start: {
x: Math.floor(size.width * 0.45),
y: Math.floor(size.height * 0.45)
},
end: {
x: Math.floor(size.width * 0.55),
y: Math.floor(size.height * 0.55)
}
};
await drag(size, cellset2.start, cellset2.end, true);
await page.click("[data-testid='cellset-button-2");
let button = await getOneElementInnerHTML("[data-testid='cellset-button-2");
expect(button).toMatch(/49 cells/);
});
test("selects cells, saves them and performs diffexp", async () => {
await page.goto(appUrlBase);
const layout = await page.waitForSelector("[data-testid='layout']");
const size = await layout.boxModel();
const cellset1 = {
start: {
x: Math.floor(size.width * 0.25),
y: Math.floor(size.height * 0.25)
},
end: {
x: Math.floor(size.width * 0.35),
y: Math.floor(size.height * 0.35)
}
};
await drag(size, cellset1.start, cellset1.end, true);
await page.click("[data-testid='cellset-button-1");
const cellset2 = {
start: {
x: Math.floor(size.width * 0.45),
y: Math.floor(size.height * 0.45)
},
end: {
x: Math.floor(size.width * 0.55),
y: Math.floor(size.height * 0.55)
}
};
await drag(size, cellset2.start, cellset2.end, true);
await page.click("[data-testid='cellset-button-2");
await page.click("[data-testid='diffexp-button");
await page.waitForSelector("[data-testclass='histogram-diffexp']");
const diffexps = await page.$$eval(
"[data-testclass='histogram-diffexp']",
divs => {
return divs.map(div =>
div.id.substring("histogram-".length, div.id.length)
);
}
);
expect(diffexps).toMatchObject([
"HLA-DPA1",
"HLA-DQA1",
"HLA-DRB1",
"HLA-DMA",
"CST3",
"HLA-DPB1",
"HLA-DQB1",
"LGALS2",
"FCER1A",
"LTB"
]);
});
});
describe("brushable histogram", () => {
test("can brush historgram", async () => {
await page.goto(appUrlBase);
const hist = await page.waitForSelector(
"[data-testid='histogram_n_genes_svg-brush'] > .overlay"
);
const hist_size = await hist.boxModel();
const draghist = {
start: {
x: Math.floor(hist_size.width * 0.25),
y: Math.floor(hist_size.height * 0.5)
},
end: {
x: Math.floor(hist_size.width * 0.55),
y: Math.floor(hist_size.height * 0.5)
}
};
await drag(hist_size, draghist.start, draghist.end);
});
});
@@ -0,0 +1,616 @@
import * as Dataframe from "../../../src/util/dataframe";
describe("dataframe constructor", () => {
test("empty dataframe", () => {
const df = new Dataframe.Dataframe([0, 0], []);
expect(df).toBeDefined();
expect(df.dims).toEqual([0, 0]);
expect(df).toHaveLength(0);
expect(df.icol(0)).not.toBeDefined();
});
test("create with default indices", () => {
const df = new Dataframe.Dataframe(
[3, 2],
[new Int32Array(3).fill(0), new Int32Array(3).fill(1)]
);
expect(df).toBeDefined();
expect(df.dims).toEqual([3, 2]);
expect(df.rowIndex).toBeInstanceOf(Dataframe.IdentityInt32Index);
expect(df.colIndex).toBeInstanceOf(Dataframe.IdentityInt32Index);
expect(df.at(0, 0)).toEqual(0);
expect(df.at(2, 1)).toEqual(1);
expect(df.iat(0, 0)).toEqual(0);
expect(df.iat(2, 1)).toEqual(1);
});
test("create with labelled indices", () => {
const df = new Dataframe.Dataframe(
[3, 2],
[new Int32Array([0, 1, 2]), new Int32Array([3, 4, 5])],
new Dataframe.DenseInt32Index([2, 1, 0]),
new Dataframe.KeyIndex(["A", "B"])
);
expect(df).toBeDefined();
expect(df.dims).toEqual([3, 2]);
expect(df.rowIndex).toBeInstanceOf(Dataframe.DenseInt32Index);
expect(df.colIndex).toBeInstanceOf(Dataframe.KeyIndex);
expect(df.rowIndex.keys()).toEqual(new Int32Array([2, 1, 0]));
expect(df.colIndex.keys()).toEqual(["A", "B"]);
expect(df.at(0, "A")).toEqual(2);
expect(df.at(2, "B")).toEqual(3);
expect(df.iat(0, 0)).toEqual(0);
expect(df.iat(2, 1)).toEqual(5);
});
});
describe("simple data access", () => {
const df = new Dataframe.Dataframe(
[4, 2],
[
new Float64Array([0.0, Number.NaN, Number.POSITIVE_INFINITY, 3.14159]),
["red", "blue", "green", "nan"]
],
new Dataframe.DenseInt32Index([3, 2, 1, 0]),
new Dataframe.KeyIndex(["numbers", "colors"])
);
test("iat", () => {
expect(df).toBeDefined();
// present
expect(df.iat(0, 0)).toEqual(0.0);
expect(df.iat(0, 1)).toEqual("red");
expect(df.iat(1, 0)).toEqual(Number.NaN);
expect(df.iat(1, 1)).toEqual("blue");
expect(df.iat(2, 0)).toEqual(Number.POSITIVE_INFINITY);
expect(df.iat(2, 1)).toEqual("green");
expect(df.iat(3, 0)).toEqual(3.14159);
expect(df.iat(3, 1)).toEqual("nan");
// labels out of range have no defined behavior
});
test("at", () => {
expect(df).toBeDefined();
// present
expect(df.at(3, "numbers")).toEqual(0.0);
expect(df.at(3, "colors")).toEqual("red");
expect(df.at(2, "numbers")).toEqual(Number.NaN);
expect(df.at(2, "colors")).toEqual("blue");
expect(df.at(1, "numbers")).toEqual(Number.POSITIVE_INFINITY);
expect(df.at(1, "colors")).toEqual("green");
expect(df.at(0, "numbers")).toEqual(3.14159);
expect(df.at(0, "colors")).toEqual("nan");
// labels out of range have no defined behavior
});
test("ihas", () => {
expect(df).toBeDefined();
// present
expect(df.ihas(0, 0)).toBeTruthy();
expect(df.ihas(1, 1)).toBeTruthy();
expect(df.ihas(3, 1)).toBeTruthy();
// not present
expect(df.ihas(-1, -1)).toBeFalsy();
expect(df.ihas(0, 99)).toBeFalsy();
expect(df.ihas(99, 0)).toBeFalsy();
expect(df.ihas(99, 99)).toBeFalsy();
expect(df.ihas(-1, 0)).toBeFalsy();
expect(df.ihas(0, -1)).toBeFalsy();
});
test("has", () => {
expect(df).toBeDefined();
// present
expect(df.has(3, "numbers")).toBeTruthy();
expect(df.has(0, "numbers")).toBeTruthy();
expect(df.has(3, "colors")).toBeTruthy();
expect(df.has(0, "colors")).toBeTruthy();
// not present
expect(df.has(3, "foo")).toBeFalsy();
expect(df.has(-1, "numbers")).toBeFalsy();
expect(df.has(-1, -1)).toBeFalsy();
expect(df.has(null, null)).toBeFalsy();
expect(df.has(0, "foo")).toBeFalsy();
expect(df.has(99, "numbers")).toBeFalsy();
expect(df.has(99, "foo")).toBeFalsy();
});
});
describe("dataframe subsetting", () => {
describe("subset", () => {
const sourceDf = new Dataframe.Dataframe(
[3, 4],
[
new Int32Array([0, 1, 2]),
["A", "B", "C"],
new Float32Array([4.4, 5.5, 6.6]),
["red", "green", "blue"]
],
null,
new Dataframe.KeyIndex(["int32", "string", "float32", "colors"])
);
test("all rows, one column", () => {
const dfA = sourceDf.subset(null, ["colors"]);
expect(dfA).toBeDefined();
expect(dfA.dims).toEqual([3, 1]);
expect(dfA.iat(0, 0)).toEqual("red");
expect(dfA.at(2, "colors")).toEqual("blue");
expect(dfA.col("colors").asArray()).toEqual(["red", "green", "blue"]);
expect(dfA.icol(0).asArray()).toEqual(["red", "green", "blue"]);
expect(dfA.col("colors").asArray()).toEqual(
sourceDf.col("colors").asArray()
);
expect(dfA.rowIndex.keys()).toEqual(sourceDf.rowIndex.keys());
expect(dfA.colIndex.keys()).toEqual(["colors"]);
});
test("all rows, two columns", () => {
const dfB = sourceDf.subset(null, ["colors", "float32"]);
expect(dfB).toBeDefined();
expect(dfB.dims).toEqual([3, 2]);
expect(dfB.iat(0, 0)).toBeCloseTo(4.4);
expect(dfB.iat(0, 1)).toEqual("red");
expect(dfB.at(2, "colors")).toEqual("blue");
expect(dfB.at(2, "float32")).toBeCloseTo(6.6);
expect(dfB.col("colors").asArray()).toEqual(["red", "green", "blue"]);
expect(dfB.col("float32").asArray()).toEqual(
new Float32Array([4.4, 5.5, 6.6])
);
expect(dfB.icol(0).asArray()).toEqual(dfB.col("float32").asArray());
expect(dfB.icol(1).asArray()).toEqual(dfB.col("colors").asArray());
expect(dfB.col("colors").asArray()).toEqual(
sourceDf.col("colors").asArray()
);
expect(dfB.col("float32").asArray()).toEqual(
sourceDf.col("float32").asArray()
);
expect(dfB.rowIndex.keys()).toEqual(sourceDf.rowIndex.keys());
expect(dfB.colIndex.keys()).toEqual(["float32", "colors"]);
});
test("one row, all columns", () => {
const dfC = sourceDf.subset([1], null);
expect(dfC).toBeDefined();
expect(dfC.dims).toEqual([1, 4]);
expect(dfC.iat(0, 0)).toEqual(1);
expect(dfC.iat(0, 1)).toEqual("B");
expect(dfC.iat(0, 2)).toBeCloseTo(5.5);
expect(dfC.iat(0, 3)).toEqual("green");
expect(dfC.rowIndex.keys()).toEqual(new Int32Array([1]));
expect(dfC.colIndex.keys()).toEqual(sourceDf.colIndex.keys());
});
test("two rows, all columns", () => {
const dfD = sourceDf.subset([0, 2], null);
expect(dfD).toBeDefined();
expect(dfD.dims).toEqual([2, 4]);
expect(dfD.icol(0).asArray()).toEqual(new Int32Array([0, 2]));
expect(dfD.icol(1).asArray()).toEqual(["A", "C"]);
expect(dfD.icol(2).asArray()).toEqual(new Float32Array([4.4, 6.6]));
expect(dfD.icol(3).asArray()).toEqual(["red", "blue"]);
expect(dfD.rowIndex.keys()).toEqual(new Int32Array([0, 2]));
expect(dfD.colIndex.keys()).toEqual(sourceDf.colIndex.keys());
});
test("all rows, all columns", () => {
const dfE = sourceDf.subset(null, null);
expect(dfE).toBeDefined();
expect(dfE.dims).toEqual([3, 4]);
expect(dfE.icol(0).asArray()).toEqual(sourceDf.icol(0).asArray());
expect(dfE.icol(1).asArray()).toEqual(sourceDf.icol(1).asArray());
expect(dfE.icol(2).asArray()).toEqual(sourceDf.icol(2).asArray());
expect(dfE.icol(3).asArray()).toEqual(sourceDf.icol(3).asArray());
expect(dfE.rowIndex.keys()).toEqual(sourceDf.rowIndex.keys());
expect(dfE.colIndex.keys()).toEqual(sourceDf.colIndex.keys());
});
test("two rows, two colums", () => {
const dfF = sourceDf.subset([0, 2], ["int32", "float32"]);
expect(dfF).toBeDefined();
expect(dfF.dims).toEqual([2, 2]);
expect(dfF.icol(0).asArray()).toEqual(new Int32Array([0, 2]));
expect(dfF.icol(1).asArray()).toEqual(new Float32Array([4.4, 6.6]));
expect(dfF.rowIndex.keys()).toEqual(new Int32Array([0, 2]));
expect(dfF.colIndex.keys()).toEqual(["int32", "float32"]);
});
test("withRowIndex", () => {
const df = sourceDf.subset(
null,
["int32", "float32"],
new Dataframe.DenseInt32Index([3, 2, 1])
);
expect(df.colIndex).toBeInstanceOf(Dataframe.KeyIndex);
expect(df.rowIndex).toBeInstanceOf(Dataframe.DenseInt32Index);
expect(df.at(3, "int32")).toEqual(df.iat(0, 0));
});
test("withRowIndex error checks", () => {
expect(() =>
sourceDf.subset(null, ["red"], new Dataframe.IdentityInt32Index(1))
).toThrow(RangeError);
expect(() =>
sourceDf.subset(null, ["red"], new Dataframe.DenseInt32Index([0, 1]))
).toThrow(RangeError);
expect(() =>
sourceDf.subset(null, ["red"], new Dataframe.KeyIndex([0, 1, 2, 3]))
).toThrow(RangeError);
});
});
test("isubsetMask", () => {
const sourceDf = new Dataframe.Dataframe(
[3, 4],
[
new Int32Array([0, 1, 2]),
["A", "B", "C"],
new Float32Array([4.4, 5.5, 6.6]),
["red", "green", "blue"]
],
new Dataframe.DenseInt32Index([2, 4, 6]),
new Dataframe.KeyIndex(["int32", "string", "float32", "colors"])
);
const dfA = sourceDf.isubsetMask(
new Uint8Array([0, 1, 1]),
new Uint8Array([1, 0, 0, 1])
);
expect(dfA.dims).toEqual([2, 2]);
expect(dfA.icol(0).asArray()).toEqual(new Int32Array([1, 2]));
expect(dfA.icol(1).asArray()).toEqual(["green", "blue"]);
expect(dfA.rowIndex.keys()).toEqual(new Int32Array([4, 6]));
expect(dfA.colIndex.keys()).toEqual(["int32", "colors"]);
});
});
describe("dataframe factories", () => {
test("create", () => {
const df = Dataframe.Dataframe.create(
[3, 3],
[
new Array(3).fill(0),
new Int16Array(3).fill(99),
new Float64Array(3).fill(1.1)
]
);
expect(df).toBeDefined();
expect(df.dims).toEqual([3, 3]);
expect(df).toHaveLength(3);
expect(df.iat(0, 0)).toEqual(0);
expect(df.iat(1, 1)).toEqual(99);
expect(df.iat(2, 2)).toBeCloseTo(1.1);
expect(df.iat(0, 0)).toEqual(df.at(0, 0));
expect(df.iat(1, 1)).toEqual(df.at(1, 1));
expect(df.iat(2, 2)).toEqual(df.at(2, 2));
});
test("clone", () => {
const dfA = new Dataframe.Dataframe(
[3, 2],
[new Int32Array([0, 1, 2]), new Int32Array([3, 4, 5])],
new Dataframe.DenseInt32Index([2, 1, 0]),
new Dataframe.KeyIndex(["A", "B"])
);
const dfB = dfA.clone();
expect(dfB).not.toBe(dfA);
expect(dfB.dims).toEqual(dfA.dims);
expect(dfB).toHaveLength(dfA.length);
expect(dfB.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
expect(dfB.colIndex.keys()).toEqual(dfA.colIndex.keys());
for (let i = 0, l = dfB.dims[1]; i < l; i += 1) {
expect(dfB.icol(i).asArray()).toEqual(dfA.icol(i).asArray());
}
});
describe("withCol", () => {
test("KeyIndex", () => {
const df = new Dataframe.Dataframe(
[2, 2],
[["red", "blue"], [true, false]],
null,
new Dataframe.KeyIndex(["colors", "bools"])
);
const dfA = df.withCol("numbers", [1, 0]);
expect(dfA).toBeDefined();
expect(dfA.dims).toEqual([2, 3]);
expect(dfA.icol(0).asArray()).toEqual(["red", "blue"]);
expect(dfA.icol(1).asArray()).toEqual([true, false]);
expect(dfA.icol(2).asArray()).toEqual([1, 0]);
expect(dfA.col("numbers").asArray()).toEqual([1, 0]);
expect(dfA.colIndex.keys()).toEqual(["colors", "bools", "numbers"]);
expect(df.colIndex.keys()).toEqual(["colors", "bools"]);
expect(df.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
});
test("DenseInt32Index", () => {
const df = new Dataframe.Dataframe(
[2, 2],
[["red", "blue"], [true, false]],
null,
new Dataframe.DenseInt32Index([74, 75])
);
const dfA = df.withCol(72, [1, 0]);
expect(dfA).toBeDefined();
expect(dfA.dims).toEqual([2, 3]);
expect(dfA.icol(0).asArray()).toEqual(["red", "blue"]);
expect(dfA.icol(1).asArray()).toEqual([true, false]);
expect(dfA.icol(2).asArray()).toEqual([1, 0]);
expect(dfA.col(74).asArray()).toEqual(["red", "blue"]);
expect(dfA.col(75).asArray()).toEqual([true, false]);
expect(dfA.col(72).asArray()).toEqual([1, 0]);
expect(dfA.colIndex.keys()).toEqual(new Int32Array([74, 75, 72]));
expect(df.colIndex.keys()).toEqual(new Int32Array([74, 75]));
expect(df.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
});
test("DenseInt32Index promote", () => {
const df = new Dataframe.Dataframe(
[2, 2],
[["red", "blue"], [true, false]],
null,
new Dataframe.DenseInt32Index([74, 75])
);
const dfA = df.withCol(999, [1, 0]);
expect(dfA).toBeDefined();
expect(dfA.dims).toEqual([2, 3]);
expect(dfA.icol(0).asArray()).toEqual(["red", "blue"]);
expect(dfA.icol(1).asArray()).toEqual([true, false]);
expect(dfA.icol(2).asArray()).toEqual([1, 0]);
expect(dfA.col(74).asArray()).toEqual(["red", "blue"]);
expect(dfA.col(75).asArray()).toEqual([true, false]);
expect(dfA.col(999).asArray()).toEqual([1, 0]);
expect(dfA.colIndex.keys()).toEqual(new Int32Array([74, 75, 999]));
expect(df.colIndex.keys()).toEqual(new Int32Array([74, 75]));
expect(df.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
});
test("IdentityInt32Index with last", () => {
const df = new Dataframe.Dataframe(
[2, 2],
[["red", "blue"], [true, false]],
null,
null
);
const dfA = df.withCol(2, [1, 0]);
expect(dfA).toBeDefined();
expect(dfA.dims).toEqual([2, 3]);
expect(dfA.icol(0).asArray()).toEqual(["red", "blue"]);
expect(dfA.icol(1).asArray()).toEqual([true, false]);
expect(dfA.icol(2).asArray()).toEqual([1, 0]);
expect(dfA.col(0).asArray()).toEqual(["red", "blue"]);
expect(dfA.col(1).asArray()).toEqual([true, false]);
expect(dfA.col(2).asArray()).toEqual([1, 0]);
expect(dfA.colIndex.keys()).toEqual(new Int32Array([0, 1, 2]));
expect(df.colIndex.keys()).toEqual(new Int32Array([0, 1]));
expect(df.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
});
test("IdentityInt32Index promote", () => {
const df = new Dataframe.Dataframe(
[2, 2],
[["red", "blue"], [true, false]],
null,
null
);
const dfA = df.withCol(99, [1, 0]);
expect(dfA).toBeDefined();
expect(dfA.dims).toEqual([2, 3]);
expect(dfA.icol(0).asArray()).toEqual(["red", "blue"]);
expect(dfA.icol(1).asArray()).toEqual([true, false]);
expect(dfA.icol(2).asArray()).toEqual([1, 0]);
expect(dfA.col(0).asArray()).toEqual(["red", "blue"]);
expect(dfA.col(1).asArray()).toEqual([true, false]);
expect(dfA.col(99).asArray()).toEqual([1, 0]);
expect(dfA.colIndex.keys()).toEqual(new Int32Array([0, 1, 99]));
expect(df.colIndex.keys()).toEqual(new Int32Array([0, 1]));
expect(df.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
});
describe("handle column dimensions correctly", () => {
/*
there are two conditions:
- empty dataframe - will accept an add of any dimensionality
- non-empty dataframe - added column must match row-count dimension
*/
test("empty.withCol", () => {
const edf = Dataframe.Dataframe.empty();
const df = edf.withCol("foo", [1, 2, 3]);
expect(edf).toBeDefined();
expect(df).toBeDefined();
expect(edf).not.toEqual(df);
expect(df.dims).toEqual([3, 1]);
expect(df.icol(0).asArray()).toEqual([1, 2, 3]);
});
test("withCol dimension check", () => {
const dfA = new Dataframe.Dataframe([1, 1], [["a"]]);
expect(() => {
dfA.withCol(1, []);
}).toThrow(RangeError);
});
});
});
describe("dropCol", () => {
test("KeyIndex", () => {
const df = new Dataframe.Dataframe(
[2, 3],
[["red", "blue"], [true, false], [1, 0]],
null,
new Dataframe.KeyIndex(["colors", "bools", "numbers"])
);
const dfA = df.dropCol("colors");
expect(dfA).toBeDefined();
expect(dfA.dims).toEqual([2, 2]);
expect(dfA.icol(0).asArray()).toEqual([true, false]);
expect(dfA.icol(1).asArray()).toEqual([1, 0]);
expect(dfA.col("numbers").asArray()).toEqual([1, 0]);
expect(dfA.colIndex.keys()).toEqual(["bools", "numbers"]);
expect(df.colIndex.keys()).toEqual(["colors", "bools", "numbers"]);
expect(df.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
});
test("IdentityInt32Index drop first", () => {
const df = new Dataframe.Dataframe(
[2, 3],
[["red", "blue"], [true, false], [1, 0]],
null,
null
);
const dfA = df.dropCol(0);
expect(dfA).toBeDefined();
expect(dfA.dims).toEqual([2, 2]);
expect(dfA.icol(0).asArray()).toEqual([true, false]);
expect(dfA.icol(1).asArray()).toEqual([1, 0]);
expect(df.col(1).asArray()).toEqual(dfA.col(1).asArray());
expect(df.col(2).asArray()).toEqual(dfA.col(2).asArray());
expect(dfA.colIndex.keys()).toEqual(new Int32Array([1, 2]));
expect(df.colIndex.keys()).toEqual(new Int32Array([0, 1, 2]));
expect(df.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
});
test("IdentityInt32Index drop last", () => {
const df = new Dataframe.Dataframe(
[2, 3],
[["red", "blue"], [true, false], [1, 0]],
null,
null
);
const dfA = df.dropCol(2);
expect(dfA).toBeDefined();
expect(dfA.dims).toEqual([2, 2]);
expect(dfA.icol(0).asArray()).toEqual(["red", "blue"]);
expect(dfA.icol(1).asArray()).toEqual([true, false]);
expect(df.col(0).asArray()).toEqual(dfA.col(0).asArray());
expect(df.col(1).asArray()).toEqual(dfA.col(1).asArray());
expect(dfA.colIndex.keys()).toEqual(new Int32Array([0, 1]));
expect(df.colIndex.keys()).toEqual(new Int32Array([0, 1, 2]));
expect(df.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
});
test("DenseInt32Index", () => {
const df = new Dataframe.Dataframe(
[2, 3],
[["red", "blue"], [true, false], [1, 0]],
null,
new Dataframe.DenseInt32Index([102, 101, 100])
);
const dfA = df.dropCol(101);
expect(dfA).toBeDefined();
expect(dfA.dims).toEqual([2, 2]);
expect(dfA.icol(0).asArray()).toEqual(["red", "blue"]);
expect(dfA.icol(1).asArray()).toEqual([1, 0]);
expect(dfA.col(100).asArray()).toEqual([1, 0]);
expect(dfA.col(102).asArray()).toEqual(["red", "blue"]);
expect(dfA.colIndex.keys()).toEqual(new Int32Array([102, 100]));
expect(df.colIndex.keys()).toEqual(new Int32Array([102, 101, 100]));
expect(df.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
});
});
});
describe("dataframe col", () => {
let df = null;
beforeEach(() => {
df = new Dataframe.Dataframe(
[2, 2],
[[true, false], [1, 0]],
null,
new Dataframe.KeyIndex(["A", "B"])
);
});
test("col", () => {
expect(df).toBeDefined();
expect(df.col("A")).toBe(df.icol(0));
expect(df.col("B")).toBe(df.icol(1));
expect(df.col("undefined")).toBeUndefined();
expect(df.icol("undefined")).toBeUndefined();
const colA = df.col("A");
expect(colA).toBeInstanceOf(Function);
expect(colA.asArray).toBeInstanceOf(Function);
expect(colA.has).toBeInstanceOf(Function);
expect(colA.ihas).toBeInstanceOf(Function);
expect(colA.indexOf).toBeInstanceOf(Function);
expect(colA.iget).toBeInstanceOf(Function);
});
test("col.asArray", () => {
expect(df).toBeDefined();
expect(df.col("A").asArray()).toEqual([true, false]);
expect(df.icol(0).asArray()).toEqual([true, false]);
expect(df.col("B").asArray()).toEqual([1, 0]);
expect(df.icol(1).asArray()).toEqual([1, 0]);
});
test("col.has", () => {
expect(df).toBeDefined();
expect(df.col("A").has(-1)).toBe(false);
expect(df.col("A").has(0)).toBe(true);
expect(df.col("A").has(1)).toBe(true);
expect(df.col("A").has(2)).toBe(false);
expect(df.col("B").has(-1)).toBe(false);
expect(df.col("B").has(0)).toBe(true);
expect(df.col("B").has(1)).toBe(true);
expect(df.col("B").has(2)).toBe(false);
});
test("col.ihas", () => {
expect(df).toBeDefined();
expect(df.col("A").ihas(-1)).toBe(false);
expect(df.col("A").ihas(0)).toBe(true);
expect(df.col("A").ihas(1)).toBe(true);
expect(df.col("A").ihas(2)).toBe(false);
expect(df.col("B").ihas(-1)).toBe(false);
expect(df.col("B").ihas(0)).toBe(true);
expect(df.col("B").ihas(1)).toBe(true);
expect(df.col("B").ihas(2)).toBe(false);
});
test("col.iget", () => {
expect(df).toBeDefined();
expect(df.col("A").iget(0)).toEqual(df.iat(0, 0));
expect(df.col("B").iget(1)).toEqual(df.iat(1, 1));
});
test("col.indexOf", () => {
expect(df).toBeDefined();
expect(df.col("A").indexOf(true)).toEqual(0);
expect(df.col("A").indexOf(false)).toEqual(1);
expect(df.col("A").indexOf(99)).toBeUndefined();
expect(df.col("A").indexOf(undefined)).toBeUndefined();
expect(df.col("A").indexOf(1)).toBeUndefined();
expect(df.col("B").indexOf(1)).toEqual(0);
expect(df.col("B").indexOf(0)).toEqual(1);
expect(df.col("B").indexOf(99)).toBeUndefined();
expect(df.col("B").indexOf(undefined)).toBeUndefined();
expect(df.col("B").indexOf(true)).toBeUndefined();
});
});
@@ -0,0 +1,253 @@
import * as Dataframe from "../../../src/util/dataframe";
function float32Conversion(f) {
return new Float32Array([f])[0];
}
describe("Dataframe column summary", () => {
test("empty column test", () => {
const df = Dataframe.Dataframe.create([0, 1], [[]]);
const summary = df.icol(0).summarize();
expect(summary).toEqual(
expect.objectContaining({
categorical: true,
categories: [],
categoryCounts: new Map(),
numCategories: 0
})
);
});
test("simple test", () => {
const df = new Dataframe.Dataframe(
[1, 6],
[
["n1"],
["hi"],
[true],
new Float32Array([39.3]),
new Int32Array([99]),
[1]
],
null,
new Dataframe.KeyIndex([
"name",
"nameString",
"nameBoolean",
"nameFloat32",
"nameInt32",
"nameCategorical"
])
);
expect(df.icol(0).summarize()).toEqual(
expect.objectContaining({
categorical: true,
categories: ["n1"],
categoryCounts: new Map([["n1", 1]]),
numCategories: 1
})
);
expect(df.icol(1).summarize()).toEqual(
expect.objectContaining({
categorical: true,
categories: ["hi"],
categoryCounts: new Map([["hi", 1]]),
numCategories: 1
})
);
expect(df.icol(2).summarize()).toEqual(
expect.objectContaining({
categorical: true,
categories: [true],
categoryCounts: new Map([[true, 1]]),
numCategories: 1
})
);
expect(df.icol(3).summarize()).toEqual(
expect.objectContaining({
categorical: false,
min: float32Conversion(39.3),
max: float32Conversion(39.3),
nan: 0,
ninf: 0,
pinf: 0
})
);
expect(df.icol(4).summarize()).toEqual(
expect.objectContaining({
categorical: false,
min: 99,
max: 99,
nan: 0,
ninf: 0,
pinf: 0
})
);
expect(df.icol(5).summarize()).toEqual(
expect.objectContaining({
categorical: true,
categories: [1],
categoryCounts: new Map([[1, 1]]),
numCategories: 1
})
);
});
test("multi test", () => {
const df = new Dataframe.Dataframe(
[3, 6],
[
["n0", "n1", "n2"],
["hi", "hi", "bye"],
[false, true, true],
new Float32Array([39.3, 39.3, 0]),
new Int32Array([99, 99, 99]),
[1, false, "0"]
],
null,
new Dataframe.KeyIndex([
"name",
"nameString",
"nameBoolean",
"nameFloat32",
"nameInt32",
"nameCategorical"
])
);
expect(df.icol(0).summarize()).toEqual(
expect.objectContaining({
categorical: true,
categories: expect.arrayContaining(["n0", "n1", "n2"]),
categoryCounts: new Map([["n0", 1], ["n1", 1], ["n2", 1]]),
numCategories: 3
})
);
expect(df.icol(1).summarize()).toEqual(
expect.objectContaining({
categorical: true,
categories: expect.arrayContaining(["hi", "bye"]),
categoryCounts: new Map([["hi", 2], ["bye", 1]]),
numCategories: 2
})
);
expect(df.icol(2).summarize()).toEqual(
expect.objectContaining({
categorical: true,
categories: expect.arrayContaining([true, false]),
categoryCounts: new Map([[true, 2], [false, 1]]),
numCategories: 2
})
);
expect(df.icol(3).summarize()).toEqual(
expect.objectContaining({
categorical: false,
min: 0,
max: float32Conversion(39.3),
nan: 0,
ninf: 0,
pinf: 0
})
);
expect(df.icol(4).summarize()).toEqual(
expect.objectContaining({
categorical: false,
min: 99,
max: 99,
nan: 0,
ninf: 0,
pinf: 0
})
);
expect(df.icol(5).summarize()).toEqual(
expect.objectContaining({
categorical: true,
categories: expect.arrayContaining([1, false, "0"]),
categoryCounts: new Map([[1, 1], [false, 1], ["0", 1]]),
numCategories: 3
})
);
});
test("non-finite numbers", () => {
const df = new Dataframe.Dataframe(
[4, 6],
[
["n0", "n1", "n2", "n2"],
["hi", "hi", "bye", "bye"],
[false, true, true, true],
new Float32Array([
39.3,
Number.NEGATIVE_INFINITY,
Number.NaN,
Number.POSITIVE_INFINITY
]),
new Int32Array([99, 99, 99, 99]),
[1, false, "0", "0"]
],
null,
new Dataframe.KeyIndex([
"name",
"nameString",
"nameBoolean",
"nameFloat32",
"nameInt32",
"nameCategorical"
])
);
expect(df.icol(0).summarize()).toEqual(
expect.objectContaining({
categorical: true,
categories: expect.arrayContaining(["n0", "n1", "n2"]),
categoryCounts: new Map([["n0", 1], ["n1", 1], ["n2", 2]]),
numCategories: 3
})
);
expect(df.icol(1).summarize()).toEqual(
expect.objectContaining({
categorical: true,
categories: expect.arrayContaining(["hi", "bye"]),
categoryCounts: new Map([["hi", 2], ["bye", 1]]),
numCategories: 2
})
);
expect(df.icol(2).summarize()).toEqual(
expect.objectContaining({
categorical: true,
categories: expect.arrayContaining([true, false]),
categoryCounts: new Map([[true, 2], [false, 1]]),
numCategories: 2
})
);
expect(df.icol(3).summarize()).toEqual(
expect.objectContaining({
categorical: false,
min: float32Conversion(39.3),
max: float32Conversion(39.3),
nan: 1,
ninf: 1,
pinf: 1
})
);
expect(df.icol(4).summarize()).toEqual(
expect.objectContaining({
categorical: false,
min: 99,
max: 99,
nan: 0,
ninf: 0,
pinf: 0
})
);
expect(df.icol(5).summarize()).toEqual(
expect.objectContaining({
categorical: true,
categories: expect.arrayContaining([1, false, "0"]),
categoryCounts: new Map([[1, 1], [false, 1], ["0", 1]]),
numCategories: 3
})
);
});
});
@@ -1,249 +0,0 @@
import _ from "lodash";
import * as kvCache from "../../../src/util/stateManager/keyvalcache";
/*
This is PRIVATE to keyvalcache and must be kept in sync with
any changs ot that module. Need to Know - to enable error handling test
*/
const cachePrivateKey = "__kvcachekey__";
/*
helper function - promisify setTimeout()
*/
function timeout(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
describe("kvcache API", () => {
/*
test the happy path create/set/get API
*/
test("simple create", () => {
/* with defaults */
const kvc = kvCache.create();
expect(kvc).toBeDefined();
expect(kvc).toEqual(expect.objectContaining({}));
expect(kvCache.get(kvc, "test")).toBeUndefined();
/* with params */
const kvc1 = kvCache.create(/* lowWatermark */ 99, /* minTTL */ 0);
expect(kvc1).toBeDefined();
expect(kvc1).toEqual(expect.objectContaining({}));
});
test("set/get", () => {
/*
- check basic get/set functionality
- check set does not mutate source cache
*/
const keyName = "foo";
const kvc1 = kvCache.create();
expect(kvc1).toBeDefined();
expect(kvCache.get(kvc1, keyName)).toBeUndefined();
const val2 = [2];
const kvc2 = kvCache.set(kvc1, keyName, val2);
expect(kvc2).toBeDefined();
expect(kvc2).not.toBe(kvc1);
expect(kvCache.get(kvc1, keyName)).toBeUndefined();
expect(kvCache.get(kvc2, keyName)).toBe(val2);
const val3 = [3];
const kvc3 = kvCache.set(kvc2, keyName, val3);
expect(kvc3).toBeDefined();
expect(kvc3).not.toBe(kvc1);
expect(kvc3).not.toBe(kvc2);
expect(kvCache.get(kvc1, keyName)).toBeUndefined();
expect(kvCache.get(kvc2, keyName)).toBe(val2);
expect(kvCache.get(kvc3, keyName)).toBe(val3);
});
});
describe("common error handling", () => {
/*
Test common error handlers
*/
test("set() protection from namespace pollution", () => {
/*
Test that set() will not allow use of the private cache key
*/
const kvc = kvCache.create();
expect(() => {
kvCache.set(kvc, cachePrivateKey, {});
}).toThrow();
});
test("create() does not accept bogus config", () => {
expect(() => {
kvCache.create([], {});
}).toThrow();
expect(() => {
kvCache.create(-99, 0);
}).toThrow();
expect(() => {
kvCache.create(100, -1);
}).toThrow();
expect(() => {
kvCache.create(1000, "foobar");
}).toThrow();
expect(() => {
kvCache.create(null, 8);
}).toThrow();
});
});
describe("map", () => {
/*
Test kvCache.map() - create new cache that is a transformation of an
existing cache
*/
test("map of empty cache", () => {
const kvc = kvCache.create();
const callback = jest.fn();
const kvcMapped = kvCache.map(kvc, callback);
expect(callback).not.toHaveBeenCalled();
expect(kvcMapped).toBeDefined();
expect(kvcMapped).not.toBe(kvc); // immutable operation
expect(kvcMapped).toEqual(kvc);
});
test("map of non-empty cache", () => {
const key = "aKey";
const val = [0, 1, 2];
let kvc = kvCache.create();
kvc = kvCache.set(kvc, key, val);
const mockCB = jest.fn().mockImplementation(v => [...v]);
const kvcMapped = kvCache.map(kvc, mockCB);
expect(kvcMapped).toBeDefined();
expect(kvcMapped).not.toBe(kvc); // immutable operation
expect(_.isEqual(kvc, kvcMapped)).toBe(true);
expect(mockCB).toHaveBeenCalledTimes(1);
expect(mockCB).toHaveBeenLastCalledWith(val, key);
});
});
describe("flush", () => {
/*
test various cache flush behavior
*/
test("flush - lowWatermark, disable minTTL", () => {
/*
verify lowWatermark functions correctly
*/
// set lowWatermark to 2, set three times - only the final two
// should remain.
let kvc = kvCache.create(2, 0);
["a", "b", "c"].forEach(k => {
kvc = kvCache.set(kvc, k, []);
});
expect(kvc).toEqual(
expect.objectContaining({
b: expect.arrayContaining([]),
c: expect.arrayContaining([])
})
);
expect(kvc).toEqual(
expect.not.objectContaining({
a: expect.arrayContaining([])
})
);
});
test("flush - minTTL, disable lowWatermark", async () => {
/*
verify minTTL functions correctly
*/
// set minTTL to 1 ms
let kvc = kvCache.create(0, 10);
kvc = kvCache.set(kvc, "a", []);
await timeout(20);
["b", "c"].forEach(k => {
kvc = kvCache.set(kvc, k, []);
});
expect(kvc).toEqual(
expect.objectContaining({
b: expect.arrayContaining([]),
c: expect.arrayContaining([])
})
);
expect(kvc).toEqual(
expect.not.objectContaining({
a: expect.arrayContaining([])
})
);
});
test("flush - minTTL and lowWatermark", async () => {
/*
verify minTTL functions correctly
*/
// set lowwatermark to 3, minTTL to 1 ms
let kvc = kvCache.create(3, 10);
kvc = kvCache.set(kvc, "a", []);
// delay
await timeout(20);
["b", "c"].forEach(k => {
kvc = kvCache.set(kvc, k, []);
});
expect(kvc).toEqual(
expect.objectContaining({
a: expect.arrayContaining([]),
b: expect.arrayContaining([]),
c: expect.arrayContaining([])
})
);
kvc = kvCache.set(kvc, "d", []);
expect(kvc).toEqual(
expect.objectContaining({
b: expect.arrayContaining([]),
c: expect.arrayContaining([]),
d: expect.arrayContaining([])
})
);
expect(kvc).toEqual(
expect.not.objectContaining({
a: expect.arrayContaining([])
})
);
});
test("manual flush", async () => {
let kvc = kvCache.create(1, 10);
["a", "b", "c", "d"].forEach(k => {
kvc = kvCache.set(kvc, k, []);
});
// Before TTL has expired, should have all values in cache.
expect(kvc).toEqual(
expect.objectContaining({
a: expect.arrayContaining([]),
b: expect.arrayContaining([]),
c: expect.arrayContaining([])
})
);
// let TTL expire
await timeout(10);
// manually flush
const postFlushKvc = kvCache.flush(kvc);
expect(postFlushKvc).toBeDefined();
expect(postFlushKvc).not.toBe(kvc);
expect(postFlushKvc).toEqual(
expect.objectContaining({
d: expect.arrayContaining([])
})
);
});
});
@@ -157,16 +157,6 @@ const anAnnotationsVarFBSResponse = (() => {
return encodeMatrix(columns, anAnnotationsVarJSONResponse.names); return encodeMatrix(columns, anAnnotationsVarJSONResponse.names);
})(); })();
const aLayoutJSONResponse = {
layout: {
ndims: 2,
coordinates: _()
.range(nObs)
.map(idx => [idx, Math.random(), Math.random()])
.value()
}
};
const aLayoutFBSResponse = (() => { const aLayoutFBSResponse = (() => {
const coords = [ const coords = [
new Float32Array(nObs).fill(Math.random()), new Float32Array(nObs).fill(Math.random()),
@@ -190,7 +180,7 @@ const aLayoutFBSResponse = (() => {
NetEncoding.Matrix.startMatrix(builder); NetEncoding.Matrix.startMatrix(builder);
NetEncoding.Matrix.addNRows(builder, nObs); NetEncoding.Matrix.addNRows(builder, nObs);
NetEncoding.Matrix.addNCols(builder, nVar); NetEncoding.Matrix.addNCols(builder, coords.length);
NetEncoding.Matrix.addColumns(builder, columns); NetEncoding.Matrix.addColumns(builder, columns);
const matrix = NetEncoding.Matrix.endMatrix(builder); const matrix = NetEncoding.Matrix.endMatrix(builder);
builder.finish(matrix); builder.finish(matrix);
@@ -1,280 +0,0 @@
import summarizeAnnotations from "../../../src/util/stateManager/summarizeAnnotations";
describe("summarizeAnnotations", () => {
const schema = {
annotations: {
obs: [
{ name: "name", type: "string" },
{ name: "nameString", type: "string" },
{ name: "nameBoolean", type: "boolean" },
{ name: "nameFloat32", type: "float32" },
{ name: "nameInt32", type: "int32" },
{
name: "nameCategorical",
type: "categorical",
categories: [true, false, 1, 0, 0.00001, 4383.4833, "test", "", "0"]
}
],
var: [{ name: "name", type: "string" }]
}
};
test("empty test", () => {
const summary = summarizeAnnotations(schema, [], []);
expect(summary).toEqual(
expect.objectContaining({
obs: {
nameString: {
categorical: true,
categories: [],
categoryCounts: new Map(),
numCategories: 0
},
nameBoolean: {
categorical: true,
categories: [],
categoryCounts: new Map(),
numCategories: 0
},
nameFloat32: {
categorical: false,
range: {
max: undefined,
min: undefined,
nan: 0,
ninf: 0,
pinf: 0
}
},
nameInt32: {
categorical: false,
range: {
max: undefined,
min: undefined,
nan: 0,
ninf: 0,
pinf: 0
}
},
nameCategorical: {
categorical: true,
categories: [],
categoryCounts: new Map(),
numCategories: 0
}
},
var: {}
})
);
});
test("simple test", () => {
const obsAnnotations = [
{
__index__: 0,
name: "n1",
nameString: "hi",
nameBoolean: true,
nameFloat32: 39.3,
nameInt32: 99,
nameCategorical: 1
}
];
const varAnnotations = [];
const summary = summarizeAnnotations(
schema,
obsAnnotations,
varAnnotations
);
expect(summary).toEqual(
expect.objectContaining({
obs: {
nameString: {
categorical: true,
categories: ["hi"],
categoryCounts: new Map([["hi", 1]]),
numCategories: 1
},
nameBoolean: {
categorical: true,
categories: [true],
categoryCounts: new Map([[true, 1]]),
numCategories: 1
},
nameFloat32: {
categorical: false,
range: { min: 39.3, max: 39.3, nan: 0, ninf: 0, pinf: 0 }
},
nameInt32: {
categorical: false,
range: { min: 99, max: 99, nan: 0, ninf: 0, pinf: 0 }
},
nameCategorical: {
categorical: true,
categories: [1],
categoryCounts: new Map([[1, 1]]),
numCategories: 1
}
},
var: {}
})
);
});
test("multi test", () => {
const obsAnnotations = [
{
__index__: 0,
name: "n0",
nameString: "hi",
nameBoolean: false,
nameFloat32: 39.3,
nameInt32: 99,
nameCategorical: 1
},
{
__index__: 1,
name: "n1",
nameString: "hi",
nameBoolean: true,
nameFloat32: 39.3,
nameInt32: 99,
nameCategorical: false
},
{
__index__: 2,
name: "n2",
nameString: "bye",
nameBoolean: true,
nameFloat32: 0,
nameInt32: 99,
nameCategorical: "0"
}
];
const varAnnotations = [];
const summary = summarizeAnnotations(
schema,
obsAnnotations,
varAnnotations
);
expect(summary).toMatchObject(
expect.objectContaining({
obs: {
nameString: {
categorical: true,
categories: expect.arrayContaining(["hi", "bye"]),
categoryCounts: new Map([["hi", 2], ["bye", 1]]),
numCategories: 2
},
nameBoolean: {
categorical: true,
categories: expect.arrayContaining([true, false]),
categoryCounts: new Map([[true, 2], [false, 1]]),
numCategories: 2
},
nameFloat32: {
categorical: false,
range: { min: 0, max: 39.3, nan: 0, ninf: 0, pinf: 0 }
},
nameInt32: {
categorical: false,
range: { min: 99, max: 99, nan: 0, ninf: 0, pinf: 0 }
},
nameCategorical: {
categorical: true,
categories: expect.arrayContaining([1, false, "0"]),
categoryCounts: new Map([[1, 1], [false, 1], ["0", 1]]),
numCategories: 3
}
},
var: {}
})
);
});
test("non-finite numbers", () => {
const obsAnnotations = [
{
__index__: 0,
name: "n0",
nameString: "hi",
nameBoolean: false,
nameFloat32: 39.3,
nameInt32: 99,
nameCategorical: 1
},
{
__index__: 1,
name: "n1",
nameString: "hi",
nameBoolean: true,
nameFloat32: Number.NEGATIVE_INFINITY,
nameInt32: 99,
nameCategorical: false
},
{
__index__: 2,
name: "n2",
nameString: "bye",
nameBoolean: true,
nameFloat32: Number.NaN,
nameInt32: 99,
nameCategorical: "0"
},
{
__index__: 3,
name: "n2",
nameString: "bye",
nameBoolean: true,
nameFloat32: Number.POSITIVE_INFINITY,
nameInt32: 99,
nameCategorical: "0"
}
];
const varAnnotations = [];
const summary = summarizeAnnotations(
schema,
obsAnnotations,
varAnnotations
);
expect(summary).toMatchObject(
expect.objectContaining({
obs: {
nameString: {
categorical: true,
categories: expect.arrayContaining(["hi", "bye"]),
categoryCounts: new Map([["hi", 2], ["bye", 1]]),
numCategories: 2
},
nameBoolean: {
categorical: true,
categories: expect.arrayContaining([true, false]),
categoryCounts: new Map([[true, 2], [false, 1]]),
numCategories: 2
},
nameFloat32: {
categorical: false,
range: { min: 39.3, max: 39.3, nan: 1, ninf: 1, pinf: 1 }
},
nameInt32: {
categorical: false,
range: { min: 99, max: 99, nan: 0, ninf: 0, pinf: 0 }
},
nameCategorical: {
categorical: true,
categories: expect.arrayContaining([1, false, "0"]),
categoryCounts: new Map([[1, 1], [false, 1], ["0", 1]]),
numCategories: 3
}
},
var: {}
})
);
});
});
@@ -1,13 +1,13 @@
import _ from "lodash";
import * as Universe from "../../../src/util/stateManager/universe"; import * as Universe from "../../../src/util/stateManager/universe";
import * as Dataframe from "../../../src/util/dataframe";
import * as REST from "./sampleResponses"; import * as REST from "./sampleResponses";
describe("createUniverseFromRestV02Response", () => { describe("createUniverseFromResponse", () => {
/* /*
test createUniverseFromRestV02Response - this function converts test createUniverseFromResponse - this function converts
a set of REST 0.2 responses into a "new" Universe. a set of REST 0.2 responses into a "new" Universe.
createUniverseFromRestV02Response( createUniverseFromResponse(
configResponse, configResponse,
schemaResponse, schemaResponse,
annotationsObsResponse, annotationsObsResponse,
@@ -30,7 +30,7 @@ describe("createUniverseFromRestV02Response", () => {
create a universe from sample data nad validate its shape & contents create a universe from sample data nad validate its shape & contents
*/ */
const { nObs, nVar } = REST.schema.schema.dataframe; const { nObs, nVar } = REST.schema.schema.dataframe;
const universe = Universe.createUniverseFromRestV02Response( const universe = Universe.createUniverseFromResponse(
REST.config, REST.config,
REST.schema, REST.schema,
REST.annotationsObs, REST.annotationsObs,
@@ -41,27 +41,26 @@ describe("createUniverseFromRestV02Response", () => {
expect(universe).toBeDefined(); expect(universe).toBeDefined();
expect(universe).toMatchObject( expect(universe).toMatchObject(
expect.objectContaining({ expect.objectContaining({
api: "0.2",
nObs, nObs,
nVar, nVar,
schema: REST.schema.schema, schema: REST.schema.schema,
obsAnnotations: expect.any(Array), obsAnnotations: expect.any(Dataframe.Dataframe),
varAnnotations: expect.any(Array), varAnnotations: expect.any(Dataframe.Dataframe),
obsNameToIndexMap: expect.any(Object), obsLayout: expect.any(Dataframe.Dataframe),
varNameToIndexMap: expect.any(Object), varData: expect.any(Dataframe.Dataframe)
obsLayout: expect.objectContaining({
X: expect.any(Float32Array),
Y: expect.any(Float32Array)
}),
varDataCache: expect.any(Object)
}) })
); );
expect(universe.obsAnnotations).toHaveLength(nObs); expect(universe.obsAnnotations.dims).toEqual([
expect(_.keys(universe.obsNameToIndexMap)).toHaveLength(nObs); nObs,
expect(universe.obsLayout.X).toHaveLength(nObs); REST.schema.schema.annotations.obs.length
expect(universe.obsLayout.Y).toHaveLength(nObs); ]);
expect(universe.varAnnotations).toHaveLength(nVar); expect(universe.obsLayout.dims).toEqual([nObs, 2]);
expect(_.keys(universe.varNameToIndexMap)).toHaveLength(nVar); expect(universe.obsLayout.colIndex.keys()).toEqual(["X", "Y"]);
expect(universe.varAnnotations.dims).toEqual([
nVar,
REST.schema.schema.annotations.var.length
]);
expect(universe.varData.isEmpty()).toBeTruthy();
}); });
}); });
+32 -110
View File
@@ -1,13 +1,13 @@
import _ from "lodash"; import _ from "lodash";
import * as Universe from "../../../src/util/stateManager/universe"; import * as Universe from "../../../src/util/stateManager/universe";
import * as World from "../../../src/util/stateManager/world"; import * as World from "../../../src/util/stateManager/world";
import * as Dataframe from "../../../src/util/dataframe";
import Crossfilter from "../../../src/util/typedCrossfilter"; import Crossfilter from "../../../src/util/typedCrossfilter";
import * as REST from "./sampleResponses"; import * as REST from "./sampleResponses";
import { import {
obsAnnoDimensionName, obsAnnoDimensionName,
layoutDimensionName layoutDimensionName
} from "../../../src/util/nameCreators"; } from "../../../src/util/nameCreators";
import * as kvCache from "../../../src/util/stateManager/keyvalcache";
/* /*
Helper - creates universe, world, corssfilter and dimensionMap from Helper - creates universe, world, corssfilter and dimensionMap from
@@ -16,7 +16,7 @@ the default REST test response.
const defaultBigBang = () => { const defaultBigBang = () => {
/* create unverse, world, crossfilter and dimensionMap */ /* create unverse, world, crossfilter and dimensionMap */
/* create universe */ /* create universe */
const universe = Universe.createUniverseFromRestV02Response( const universe = Universe.createUniverseFromResponse(
REST.config, REST.config,
REST.schema, REST.schema,
REST.annotationsObs, REST.annotationsObs,
@@ -40,7 +40,7 @@ const defaultBigBang = () => {
describe("createWorldFromEntireUniverse", () => { describe("createWorldFromEntireUniverse", () => {
test("create from REST sample", () => { test("create from REST sample", () => {
const universe = Universe.createUniverseFromRestV02Response( const universe = Universe.createUniverseFromResponse(
REST.config, REST.config,
REST.schema, REST.schema,
REST.annotationsObs, REST.annotationsObs,
@@ -54,31 +54,13 @@ describe("createWorldFromEntireUniverse", () => {
expect(world).toMatchObject( expect(world).toMatchObject(
expect.objectContaining({ expect.objectContaining({
api: "0.2",
nObs: universe.nObs, nObs: universe.nObs,
nVar: universe.nVar, nVar: universe.nVar,
schema: universe.schema, schema: universe.schema,
obsAnnotations: universe.obsAnnotations, obsAnnotations: universe.obsAnnotations,
varAnnotations: universe.varAnnotations, varAnnotations: universe.varAnnotations,
obsLayout: universe.obsLayout, obsLayout: universe.obsLayout,
varData: expect.any(Dataframe.Dataframe)
summary: expect.objectContaining({
obs: _(REST.schema.schema.annotations.obs)
.filter(v => v.name !== "name")
.keyBy("name")
.mapValues(() => expect.any(Object))
.value(),
var: _(REST.schema.schema.annotations.var)
.filter(v => v.name !== "name")
.keyBy("name")
.mapValues(() => expect.any(Object))
.value()
}),
varDataCache: expect.any(Object),
obsIndex: null, // null indicating full universe
obsBackIndex: null
}) })
); );
}); });
@@ -111,51 +93,38 @@ describe("createWorldFromCurrentSelection", () => {
*/ */
/* matchFilter must match the dimension filters above */ /* matchFilter must match the dimension filters above */
const matchFilter = val => val.field1 >= 0 && val.field1 < 5 && !val.field3; const matchFilter = (df, row) => {
const universeIndices = _() const field1 = df.at(row, "field1");
.range(universe.nObs) const field3 = df.at(row, "field3");
.filter(idx => matchFilter(universe.obsAnnotations[idx])) return field1 >= 0 && field1 < 5 && !field3;
.value();
const expected = {
nObs: universeIndices.length,
obsAnnotations: _.map(universeIndices, i => universe.obsAnnotations[i]),
obsLayout: {
X: new Float32Array(
_.map(universeIndices, i => universe.obsLayout.X[i])
),
Y: new Float32Array(
_.map(universeIndices, i => universe.obsLayout.Y[i])
)
},
obsBackIndex: _.transform(
universeIndices,
(result, univIdx, worldIdx) => {
result[univIdx] = worldIdx;
},
new Uint32Array(universe.nObs).fill(-1)
),
obsIndex: new Uint32Array(universeIndices)
}; };
const matchingIndices = _()
.range(universe.nObs)
.filter(idx => matchFilter(universe.obsAnnotations, idx))
.value();
expect(world).toMatchObject( expect(world).toMatchObject(
expect.objectContaining({ expect.objectContaining({
api: "0.2", nObs: matchingIndices.length,
nObs: expected.nObs,
nVar: universe.nVar, nVar: universe.nVar,
schema: universe.schema, schema: universe.schema,
obsAnnotations: expected.obsAnnotations, obsAnnotations: expect.any(Dataframe.Dataframe),
varAnnotations: universe.varAnnotations, varAnnotations: universe.varAnnotations,
obsLayout: expected.obsLayout, obsLayout: expect.any(Dataframe.Dataframe),
summary: { varData: expect.any(Dataframe.Dataframe)
obs: expect.any(Object) /* we could do better! */,
var: expect.any(Object) /* we could do better! */
},
varDataCache: expect.any(Object),
obsIndex: expected.obsIndex,
obsBackIndex: expected.obsBackIndex
}) })
); );
expect(world.obsAnnotations.rowIndex.keys()).toEqual(
new Int32Array(matchingIndices)
);
expect(world.obsAnnotations.colIndex.keys()).toEqual(
universe.obsAnnotations.colIndex.keys()
);
expect(world.obsLayout.rowIndex.keys()).toEqual(
new Int32Array(matchingIndices)
);
expect(world.obsLayout.colIndex.keys()).toEqual(["X", "Y"]);
}); });
}); });
@@ -187,67 +156,20 @@ describe("createObsDimensionMap", () => {
} }
} }
}); });
expect(dimensionMap[layoutDimensionName("X")]).toBeInstanceOf( expect(dimensionMap[layoutDimensionName("XY")]).toBeInstanceOf(
Crossfilter.ScalarDimension Crossfilter.SpatialDimension
);
expect(dimensionMap[layoutDimensionName("Y")]).toBeInstanceOf(
Crossfilter.ScalarDimension
); );
}); });
}); });
describe("subsetVarData", () => { describe("createVarDataDimension", () => {
test("when world eq universe", () => {
const { universe, world } = defaultBigBang();
/* create a mock varData array for subsetting */
const sourceVarData = new Float32Array(universe.nObs);
/* expect literally the same object back */
const result = World.subsetVarData(world, universe, sourceVarData);
expect(result).toBe(sourceVarData);
});
test("when world neq universe", () => {
const { universe, world, crossfilter, dimensionMap } = defaultBigBang();
/* create a mock varData array for subsetting */
const sourceVarData = Float32Array.from(_.range(universe.nObs));
/* mock a selection */
dimensionMap[obsAnnoDimensionName("field1")].filterRange([0, 5]);
dimensionMap[obsAnnoDimensionName("field3")].filterExact(false);
/* create the world from the selection */
const newWorld = World.createWorldFromCurrentSelection(
universe,
world,
crossfilter
);
expect(newWorld.obsIndex).toMatchObject(new Uint32Array([0, 2]));
/* expect a subset */
const result = World.subsetVarData(newWorld, universe, sourceVarData);
expect(result).not.toBe(sourceVarData);
expect(result).toHaveLength(newWorld.nObs);
/* check that we have expected source var content */
expect(result).toMatchObject(new Float32Array([0, 2]));
});
});
describe("createVarDimension", () => {
/* create default universe */ /* create default universe */
const { world, crossfilter } = defaultBigBang(); const { world, crossfilter } = defaultBigBang();
/* create a mock var data cache */ world.varData = world.varData.withCol(
const varDataCache = kvCache.set(
kvCache.create(),
"GENE", "GENE",
Float32Array.from(_.range(world.nObs)) Float32Array.from(_.range(world.nObs))
); );
const result = World.createVarDimension( const result = World.createVarDataDimension(world, crossfilter, "GENE");
world,
varDataCache,
crossfilter,
"GENE"
);
expect(result).toBeInstanceOf(Crossfilter.ScalarDimension); expect(result).toBeInstanceOf(Crossfilter.ScalarDimension);
}); });
@@ -2,16 +2,27 @@ import {
countCategoryValues2D, countCategoryValues2D,
clearCaches clearCaches
} from "../../../src/util/stateManager/worldUtil"; } from "../../../src/util/stateManager/worldUtil";
import * as Dataframe from "../../../src/util/dataframe";
describe("WorldUtil cache management", () => { describe("WorldUtil cache management", () => {
test("empty", () => { test("empty", () => {
const count = countCategoryValues2D("a", "b", []); const count = countCategoryValues2D(
"a",
"b",
new Dataframe.Dataframe([0, 0], [])
);
expect(count).toMatchObject(new Map()); expect(count).toMatchObject(new Map());
expect(count.size).toBe(0);
}); });
test("simple couts", () => { test("simple couts", () => {
const rows = [{ a: 0, b: false }, { a: 0, b: true }, { a: 1, b: false }]; const df = new Dataframe.Dataframe(
const count = countCategoryValues2D("a", "b", rows); [3, 2],
[[0, 0, 1], [false, true, false]],
null,
new Dataframe.KeyIndex(["a", "b"])
);
const count = countCategoryValues2D("a", "b", df);
expect(count).toMatchObject( expect(count).toMatchObject(
new Map([ new Map([
[0, new Map([[true, 1], [false, 1]])], [0, new Map([[true, 1], [false, 1]])],
@@ -22,16 +33,22 @@ describe("WorldUtil cache management", () => {
test("memo cache clear", () => { test("memo cache clear", () => {
clearCaches(); clearCaches();
const row1 = []; const df1 = new Dataframe.Dataframe([0, 0], []);
const row2 = [{ a: 0, b: false }, { a: 0, b: true }, { a: 1, b: false }]; const df2 = new Dataframe.Dataframe(
const count1 = countCategoryValues2D("a", "b", row1); [3, 2],
const count2 = countCategoryValues2D("a", "b", row1); [[0, 0, 1], [false, true, false]],
const count3 = countCategoryValues2D("a", "b", []); null,
const count4 = countCategoryValues2D("a", "b", row2); 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(); clearCaches();
const count10 = countCategoryValues2D("a", "b", row1); const count10 = countCategoryValues2D("a", "b", df1);
const count11 = countCategoryValues2D("a", "b", row2); const count11 = countCategoryValues2D("a", "b", df2);
expect(count1).toEqual(count2); expect(count1).toEqual(count2);
expect(count1).toEqual(count3); expect(count1).toEqual(count3);
@@ -118,16 +118,16 @@ describe("selectionCount", () => {
const dim2 = ba.allocDimension(); const dim2 = ba.allocDimension();
expect(dim2).toBeDefined(); expect(dim2).toBeDefined();
expect(ba.selectionCount).toEqual(0); expect(ba.selectionCount()).toEqual(0);
ba.selectAll(dim1); ba.selectAll(dim1);
expect(ba.selectionCount).toEqual(0); expect(ba.selectionCount()).toEqual(0);
ba.selectAll(dim2); ba.selectAll(dim2);
expect(ba.selectionCount).toEqual(defaultTestLength); expect(ba.selectionCount()).toEqual(defaultTestLength);
for (let i = 0; i < defaultTestLength; i += 1) { for (let i = 0; i < defaultTestLength; i += 1) {
ba.deselectOne(dim1, i); ba.deselectOne(dim1, i);
expect(ba.selectionCount).toEqual(defaultTestLength - i - 1); expect(ba.selectionCount()).toEqual(defaultTestLength - i - 1);
expect(ba.selectionCount).toEqual(ba.countAllOnes()); expect(ba.selectionCount()).toEqual(ba.countAllOnes());
} }
ba.freeDimension(dim1); ba.freeDimension(dim1);
@@ -102,33 +102,39 @@ const someData = [
]; ];
function groupReduce(data, valueMap, valueReduce, valueInit) { function groupReduce(data, valueMap, valueReduce, valueInit) {
return _ return _.reduce(
.reduce( data,
data, (acc, value) => {
(acc, value) => { const k = valueMap(value);
const k = valueMap(value); let r = _.find(acc, o => o.key === k);
let r = _.find(acc, o => o.key === k); if (!r) {
if (!r) { r = { key: k, value: valueInit() };
r = { key: k, value: valueInit() }; acc.push(r);
acc.push(r); }
} r.value = valueReduce(r.value, value);
r.value = valueReduce(r.value, value); return acc;
return acc; },
}, []
[] ).sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
)
.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
} }
function groupCount(data, map) { function groupCount(data, map) {
return groupReduce(data, map, (p, v) => p + 1, () => 0); return groupReduce(data, map, p => p + 1, () => 0);
} }
function groupSum(data, map) { function groupSum(data, map) {
return groupReduce(data, map, (p, v) => (p += map(v)), () => 0); return groupReduce(
data,
map,
(p, v) => {
p += map(v);
return p;
},
() => 0
);
} }
var payments = null; let payments = null;
beforeEach(() => { beforeEach(() => {
payments = crossfilter(someData); payments = crossfilter(someData);
}); });
@@ -139,7 +145,11 @@ describe("typedCrossfilter", () => {
expect(payments.size()).toEqual(someData.length); expect(payments.size()).toEqual(someData.length);
expect(payments.all()).toEqual(someData); expect(payments.all()).toEqual(someData);
const quantity = payments.dimension(r => r.quantity, Int32Array); const quantity = payments.dimension(
crossfilter.ScalarDimension,
(i, data) => data[i].quantity,
Int32Array
);
expect(quantity).toBeDefined(); expect(quantity).toBeDefined();
expect(quantity.id()).toBeDefined(); expect(quantity.id()).toBeDefined();
@@ -150,10 +160,25 @@ describe("typedCrossfilter", () => {
test("filterAll and filterNone", () => { test("filterAll and filterNone", () => {
expect(payments).toBeDefined(); expect(payments).toBeDefined();
const quantity = payments.dimension(r => r.quantity, Int32Array); const quantity = payments.dimension(
const tip = payments.dimension(r => r.tip, Float32Array); crossfilter.ScalarDimension,
const total = payments.dimension(r => r.total, Float32Array); (i, data) => data[i].quantity,
const type = payments.dimension(r => r.type, "enum"); Int32Array
);
const tip = payments.dimension(
crossfilter.ScalarDimension,
(i, data) => data[i].tip,
Float32Array
);
const total = payments.dimension(
crossfilter.ScalarDimension,
(i, data) => data[i].total,
Float32Array
);
const type = payments.dimension(
crossfilter.EnumDimension,
(i, data) => data[i].type
);
expect(quantity).toBeDefined(); expect(quantity).toBeDefined();
expect(tip).toBeDefined(); expect(tip).toBeDefined();
@@ -198,10 +223,20 @@ describe("typedCrossfilter", () => {
test("filterExact", () => { test("filterExact", () => {
expect(payments).toBeDefined(); expect(payments).toBeDefined();
const quantity = payments.dimension(r => r.quantity, Int32Array); const quantity = payments.dimension(
const tip = payments.dimension(r => r.tip, Float32Array); crossfilter.ScalarDimension,
const total = payments.dimension(r => r.total, Float32Array); (i, data) => data[i].quantity,
const type = payments.dimension(r => r.type, "enum"); Int32Array
);
const tip = payments.dimension(
crossfilter.ScalarDimension,
(i, data) => data[i].tip,
Float32Array
);
const type = payments.dimension(
crossfilter.EnumDimension,
(i, data) => data[i].type
);
quantity.filterExact(1); quantity.filterExact(1);
expect(payments.countFiltered()).toEqual( expect(payments.countFiltered()).toEqual(
@@ -222,10 +257,25 @@ describe("typedCrossfilter", () => {
test("filterRange", () => { test("filterRange", () => {
expect(payments).toBeDefined(); expect(payments).toBeDefined();
const quantity = payments.dimension(r => r.quantity, Int32Array); const quantity = payments.dimension(
const tip = payments.dimension(r => r.tip, Float32Array); crossfilter.ScalarDimension,
const total = payments.dimension(r => r.total, Float32Array); (i, data) => data[i].quantity,
const type = payments.dimension(r => r.type, "enum"); Int32Array
);
const tip = payments.dimension(
crossfilter.ScalarDimension,
(i, data) => data[i].tip,
Float32Array
);
const total = payments.dimension(
crossfilter.ScalarDimension,
(i, data) => data[i].total,
Float32Array
);
const type = payments.dimension(
crossfilter.EnumDimension,
(i, data) => data[i].type
);
tip.filterRange([0, 91]); tip.filterRange([0, 91]);
expect(payments.allFiltered()).toEqual( expect(payments.allFiltered()).toEqual(
@@ -251,10 +301,25 @@ describe("typedCrossfilter", () => {
test("filterEnum", () => { test("filterEnum", () => {
expect(payments).toBeDefined(); expect(payments).toBeDefined();
const quantity = payments.dimension(r => r.quantity, Int32Array); const quantity = payments.dimension(
const tip = payments.dimension(r => r.tip, Float32Array); crossfilter.ScalarDimension,
const total = payments.dimension(r => r.total, Float32Array); (i, data) => data[i].quantity,
const type = payments.dimension(r => r.type, "enum"); Int32Array
);
const tip = payments.dimension(
crossfilter.ScalarDimension,
(i, data) => data[i].tip,
Float32Array
);
const total = payments.dimension(
crossfilter.ScalarDimension,
(i, data) => data[i].total,
Float32Array
);
const type = payments.dimension(
crossfilter.EnumDimension,
(i, data) => data[i].type
);
type.filterEnum(["tab", "cash"]); type.filterEnum(["tab", "cash"]);
expect(payments.allFiltered()).toEqual( expect(payments.allFiltered()).toEqual(
@@ -274,15 +339,34 @@ describe("typedCrossfilter", () => {
test("more than 32 dimensions", () => { test("more than 32 dimensions", () => {
expect(payments).toBeDefined(); expect(payments).toBeDefined();
const quantity = payments.dimension(r => r.quantity, Int32Array); const quantity = payments.dimension(
const tip = payments.dimension(r => r.tip, Float32Array); crossfilter.ScalarDimension,
const total = payments.dimension(r => r.total, Float32Array); (i, data) => data[i].quantity,
const type = payments.dimension(r => r.type, "enum"); Int32Array
);
const tip = payments.dimension(
crossfilter.ScalarDimension,
(i, data) => data[i].tip,
Float32Array
);
const total = payments.dimension(
crossfilter.ScalarDimension,
(i, data) => data[i].total,
Float32Array
);
const type = payments.dimension(
crossfilter.EnumDimension,
(i, data) => data[i].type
);
// Create a bunch of fake dimensions to ensure we can handle > 32 // Create a bunch of fake dimensions to ensure we can handle > 32
let dimMap = {}; let dimMap = {};
for (let i = 0; i < 65; i++) { for (let i = 0; i < 65; i++) {
dimMap[i] = payments.dimension(r => Math.random(), Float32Array); dimMap[i] = payments.dimension(
crossfilter.ScalarDimension,
() => Math.random(),
Float32Array
);
expect(dimMap[i]).toBeDefined(); expect(dimMap[i]).toBeDefined();
expect(dimMap[i].id()).toBeDefined(); expect(dimMap[i].id()).toBeDefined();
} }
@@ -304,10 +388,25 @@ describe("typedCrossfilter", () => {
test("group, default mapping, default reducer, no filter", () => { test("group, default mapping, default reducer, no filter", () => {
expect(payments).toBeDefined(); expect(payments).toBeDefined();
var quantity = payments.dimension(r => r.quantity, Int32Array); const quantity = payments.dimension(
var tip = payments.dimension(r => r.tip, Int32Array); crossfilter.ScalarDimension,
var type = payments.dimension(r => r.type, "enum"); (i, data) => data[i].quantity,
var total = payments.dimension(r => r.total, Int32Array); Int32Array
);
const tip = payments.dimension(
crossfilter.ScalarDimension,
(i, data) => data[i].tip,
Int32Array
);
const type = payments.dimension(
crossfilter.EnumDimension,
(i, data) => data[i].type
);
const total = payments.dimension(
crossfilter.ScalarDimension,
(i, data) => data[i].total,
Int32Array
);
_.each( _.each(
{ {
@@ -331,9 +430,20 @@ describe("typedCrossfilter", () => {
// custom mapping in groups only works for scalar types. Enums do not // custom mapping in groups only works for scalar types. Enums do not
// currently implement it. // currently implement it.
const tip = payments.dimension(r => r.tip, Int32Array); const tip = payments.dimension(
const totalX10 = payments.dimension(r => r.total * 10, Int32Array); crossfilter.ScalarDimension,
const type = payments.dimension(r => r.type, "enum"); (i, data) => data[i].tip,
Int32Array
);
const totalX10 = payments.dimension(
crossfilter.ScalarDimension,
(i, data) => data[i].total * 10,
Int32Array
);
const type = payments.dimension(
crossfilter.EnumDimension,
(i, data) => data[i].type
);
const paymentsByTip_A = tip.group(); const paymentsByTip_A = tip.group();
const paymentsByTip_B = tip.group(r => 10 * r); const paymentsByTip_B = tip.group(r => 10 * r);
@@ -370,8 +480,15 @@ describe("typedCrossfilter", () => {
test("group, default map, custom reducer, no filters", () => { test("group, default map, custom reducer, no filters", () => {
expect(payments).toBeDefined(); expect(payments).toBeDefined();
const total = payments.dimension(r => r.total, Float32Array); const total = payments.dimension(
const type = payments.dimension(r => r.type, "enum"); crossfilter.ScalarDimension,
(i, data) => data[i].total,
Float32Array
);
const type = payments.dimension(
crossfilter.EnumDimension,
(i, data) => data[i].type
);
const paymentsByTotal = total.group(); const paymentsByTotal = total.group();
const paymentsByType = type.group(); const paymentsByType = type.group();
@@ -407,9 +524,20 @@ describe("typedCrossfilter", () => {
expect(payments).toBeDefined(); expect(payments).toBeDefined();
const tip = payments.dimension(r => r.tip, Int32Array); const tip = payments.dimension(
const total = payments.dimension(r => r.total, Int32Array); crossfilter.ScalarDimension,
const type = payments.dimension(r => r.type, "enum"); (i, data) => data[i].tip,
Int32Array
);
const total = payments.dimension(
crossfilter.ScalarDimension,
(i, data) => data[i].total,
Int32Array
);
const type = payments.dimension(
crossfilter.EnumDimension,
(i, data) => data[i].type
);
const paymentsByTip = tip.group(); const paymentsByTip = tip.group();
const paymentsByTotal = total.group(); const paymentsByTotal = total.group();
@@ -10,7 +10,7 @@ const nodeModules = path.resolve("node_modules");
const babelOptions = require("../babel/babel.prod"); const babelOptions = require("../babel/babel.prod");
const publicPath = "/"; const publicPath = "";
module.exports = { module.exports = {
mode: "production", mode: "production",
+1 -1
View File
@@ -24,7 +24,7 @@
<script type="text/javascript"> <script type="text/javascript">
window.CELLXGENE = {}; window.CELLXGENE = {};
window.CELLXGENE.API = { window.CELLXGENE.API = {
prefix: "{{ prefix | safe }}", prefix: window.location.href + "api/",
version: "v0.2/" version: "v0.2/"
}; };
</script> </script>
+2888 -1981
View File
File diff suppressed because it is too large Load Diff
+24 -21
View File
@@ -1,16 +1,20 @@
{ {
"name": "cellxgene", "name": "cellxgene",
"version": "0.5.0", "version": "0.7.0",
"license": "MIT", "license": "MIT",
"description": "cellxgene is a web application for the interactive exploration of single cell sequence data.", "description": "cellxgene is a web application for the interactive exploration of single cell sequence data.",
"repository": "https://github.com/chanzuckerberg/cellxgene", "repository": "https://github.com/chanzuckerberg/cellxgene",
"scripts": { "scripts": {
"backend-dev": "python3.6 -m venv cellxgene && source cellxgene/bin/activate && yes | pip uninstall cellxgene || true && pip install -e .. && cellxgene launch ",
"build": "npm run clean && webpack --config configuration/webpack/webpack.config.prod.js", "build": "npm run clean && webpack --config configuration/webpack/webpack.config.prod.js",
"dev": "npm run clean && webpack --config configuration/webpack/webpack.config.dev.js",
"clean": "rimraf build", "clean": "rimraf build",
"start": "node server/development.js", "dev": "npm run clean && webpack --config configuration/webpack/webpack.config.dev.js",
"e2e": "jest e2e",
"lint": "eslint src", "lint": "eslint src",
"test": "jest" "smoke-test": "start-server-and-test start-server-for-test :5000 e2e",
"start": "node server/development.js",
"start-server-for-test": "cellxgene launch -p 5000 ../example-dataset/pbmc3k.h5ad",
"unit-test": "jest --testPathIgnorePatterns e2e"
}, },
"engineStrict": true, "engineStrict": true,
"engines": { "engines": {
@@ -19,10 +23,9 @@
"eslintConfig": { "eslintConfig": {
"extends": "./configuration/eslint/eslint.js" "extends": "./configuration/eslint/eslint.js"
}, },
"nyc": { "eslintIgnore": [
"sourceMap": false, "src/util/stateManager/matrix_generated.js"
"instrument": false ],
},
"resolutions": { "resolutions": {
"eslint-scope": "3.7.1" "eslint-scope": "3.7.1"
}, },
@@ -76,31 +79,31 @@
"babel-eslint": "^10.0.1", "babel-eslint": "^10.0.1",
"babel-jest": "^23.6.0", "babel-jest": "^23.6.0",
"babel-loader": "^8.0.0", "babel-loader": "^8.0.0",
"babel-plugin-istanbul": "^5.1.0",
"babel-preset-modern-browsers": "^12.0.0", "babel-preset-modern-browsers": "^12.0.0",
"chalk": "^2.4.1", "chalk": "^2.4.2",
"connect-history-api-fallback": "^1.3.0", "connect-history-api-fallback": "^1.6.0",
"copy-webpack-plugin": "^4.6.0", "copy-webpack-plugin": "^4.6.0",
"css-loader": "^1.0.1", "css-loader": "^1.0.1",
"eslint": "^5.8.0", "eslint": "^5.13.0",
"eslint-config-airbnb": "^17.1.0", "eslint-config-airbnb": "^17.1.0",
"eslint-config-prettier": "^3.1.0", "eslint-config-prettier": "^4.0.0",
"eslint-loader": "^2.1.1", "eslint-loader": "^2.1.2",
"eslint-plugin-filenames": "^1.3.2", "eslint-plugin-filenames": "^1.3.2",
"eslint-plugin-import": "^2.14.0", "eslint-plugin-import": "^2.16.0",
"eslint-plugin-jest": "^21.27.2", "eslint-plugin-jest": "^22.2.2",
"eslint-plugin-jsx-a11y": "^6.1.1", "eslint-plugin-jsx-a11y": "^6.2.1",
"eslint-plugin-react": "^7.11.1", "eslint-plugin-react": "^7.12.4",
"express": "^4.14.0", "express": "^4.14.0",
"file-loader": "^2.0.0", "file-loader": "^2.0.0",
"html-webpack-inline-source-plugin": "0.0.10", "html-webpack-inline-source-plugin": "0.0.10",
"html-webpack-plugin": "^3.2.0", "html-webpack-plugin": "^3.2.0",
"jest": "^23.5.0", "jest": "^24.1.0",
"json-loader": "^0.5.4", "json-loader": "^0.5.4",
"mini-css-extract-plugin": "^0.4.1", "mini-css-extract-plugin": "^0.4.1",
"nyc": "^13.0.1", "puppeteer": "^1.12.1",
"rimraf": "^2.5.4", "rimraf": "^2.6.3",
"serve-favicon": "^2.3.0", "serve-favicon": "^2.3.0",
"start-server-and-test": "^1.7.11",
"style-loader": "^0.23.1", "style-loader": "^0.23.1",
"sw-precache-webpack-plugin": "^0.11.5", "sw-precache-webpack-plugin": "^0.11.5",
"url-loader": "^1.1.0", "url-loader": "^1.1.0",
+23 -16
View File
@@ -1,12 +1,11 @@
// jshint esversion: 6 // jshint esversion: 6
import _ from "lodash"; import _ from "lodash";
import * as globals from "../globals"; import * as globals from "../globals";
import { Universe, kvCache } from "../util/stateManager"; import { Universe } from "../util/stateManager";
import { import {
catchErrorsWrap, catchErrorsWrap,
doJsonRequest, doJsonRequest,
doBinaryRequest, doBinaryRequest,
rangeEncodeIndices,
dispatchNetworkErrorMessageToUser dispatchNetworkErrorMessageToUser
} from "../util/actionHelpers"; } from "../util/actionHelpers";
@@ -40,7 +39,7 @@ const doInitialDataLoad = () =>
/* set config defaults */ /* set config defaults */
const config = { ...globals.configDefaults, ...results[0].config }; const config = { ...globals.configDefaults, ...results[0].config };
const [, schema, obsAnno, varAnno, obsLayout] = [...results]; const [, schema, obsAnno, varAnno, obsLayout] = [...results];
const universe = Universe.createUniverseFromRestV02Response( const universe = Universe.createUniverseFromResponse(
config, config,
schema, schema,
obsAnno, obsAnno,
@@ -130,9 +129,9 @@ async function _doRequestExpressionData(dispatch, getState, genes) {
let expressionData = _.transform( let expressionData = _.transform(
genes, genes,
(expData, g) => { (expData, g) => {
const data = kvCache.get(universe.varDataCache, g); const data = universe.varData.col(g);
if (data) { if (data) {
expData[g] = data; expData[g] = data.asArray();
} }
}, },
{} {}
@@ -170,7 +169,7 @@ function requestSingleGeneExpressionCountsForColoringPOST(gene) {
type: "color by expression", type: "color by expression",
gene, gene,
data: { data: {
[gene]: kvCache.get(world.varDataCache, gene) [gene]: world.varData.col(gene).asArray()
} }
}); });
} catch (error) { } catch (error) {
@@ -193,7 +192,7 @@ const requestUserDefinedGene = gene => async (dispatch, getState) => {
type: "request user defined gene success", type: "request user defined gene success",
data: { data: {
genes: [gene], genes: [gene],
expression: kvCache.get(world.varDataCache, gene) expression: world.varData.col(gene).asArray()
} }
}); });
} catch (error) { } catch (error) {
@@ -242,12 +241,18 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async (
*/ */
const state = getState(); const state = getState();
const { universe } = state.controls; const { universe } = state.controls;
const set1ByIndex = rangeEncodeIndices(
_.map(set1, s => universe.obsNameToIndexMap[s]) // Legal values are null, Array or TypedArray. Null is initial state.
); if (!set1) set1 = [];
const set2ByIndex = rangeEncodeIndices( if (!set2) set2 = [];
_.map(set2, s => universe.obsNameToIndexMap[s])
); // These lines ensure that we convert any TypedArray to an Array.
// This is necessary because JSON.stringify() does some very strange
// things with TypedArrays (they are marshalled to JSON objects, rather
// than being marshalled as a JSON array).
set1 = Array.isArray(set1) ? set1 : Array.from(set1);
set2 = Array.isArray(set2) ? set2 : Array.from(set2);
const res = await fetch( const res = await fetch(
`${globals.API.prefix}${globals.API.version}diffexp/obs`, `${globals.API.prefix}${globals.API.version}diffexp/obs`,
{ {
@@ -259,8 +264,8 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async (
body: JSON.stringify({ body: JSON.stringify({
mode: "topN", mode: "topN",
count: num_genes, count: num_genes,
set1: { filter: { obs: { index: set1ByIndex } } }, set1: { filter: { obs: { index: set1 } } },
set2: { filter: { obs: { index: set2ByIndex } } } set2: { filter: { obs: { index: set2 } } }
}) })
} }
); );
@@ -271,7 +276,9 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async (
const data = await res.json(); const data = await res.json();
// result is [ [varIdx, ...], ... ] // result is [ [varIdx, ...], ... ]
const topNGenes = _.map(data, r => universe.varAnnotations[r[0]].name); const topNGenes = _.map(data, r =>
universe.varAnnotations.at(r[0], "name")
);
/* /*
Kick off secondary action to fetch all of the expression data for the Kick off secondary action to fetch all of the expression data for the
@@ -10,7 +10,6 @@ import { Button, ButtonGroup, Tooltip } from "@blueprintjs/core";
import { connect } from "react-redux"; import { connect } from "react-redux";
import * as d3 from "d3"; import * as d3 from "d3";
import memoize from "memoize-one"; import memoize from "memoize-one";
import { kvCache } from "../../util/stateManager";
import * as globals from "../../globals"; import * as globals from "../../globals";
import actions from "../../actions"; import actions from "../../actions";
import finiteExtent from "../../util/finiteExtent"; import finiteExtent from "../../util/finiteExtent";
@@ -21,7 +20,6 @@ import finiteExtent from "../../util/finiteExtent";
scatterplotYYaccessor: state.controls.scatterplotYYaccessor, scatterplotYYaccessor: state.controls.scatterplotYYaccessor,
crossfilter: state.controls.crossfilter, crossfilter: state.controls.crossfilter,
differential: state.differential, differential: state.differential,
initializeRanges: _.get(state.controls.world, "summary.obs"),
colorAccessor: state.controls.colorAccessor, colorAccessor: state.controls.colorAccessor,
colorScale: state.controls.colorScale, colorScale: state.controls.colorScale,
obsAnnotations: _.get(state.controls.world, "obsAnnotations", null) obsAnnotations: _.get(state.controls.world, "obsAnnotations", null)
@@ -35,9 +33,11 @@ class HistogramBrush extends React.Component {
.scaleLinear() .scaleLinear()
.range([this.height - this.marginBottom, 0]); .range([this.height - this.marginBottom, 0]);
if (obsAnnotations[0][field] !== undefined) { if (obsAnnotations.hasCol(field)) {
// recalculate expensive stuff // recalculate expensive stuff
const allValuesForContinuousFieldAsArray = _.map(obsAnnotations, field); const allValuesForContinuousFieldAsArray = obsAnnotations
.col(field)
.asArray();
histogramCache.x = d3 histogramCache.x = d3
.scaleLinear() .scaleLinear()
@@ -50,9 +50,8 @@ class HistogramBrush extends React.Component {
.thresholds(40)(allValuesForContinuousFieldAsArray); .thresholds(40)(allValuesForContinuousFieldAsArray);
histogramCache.numValues = allValuesForContinuousFieldAsArray.length; histogramCache.numValues = allValuesForContinuousFieldAsArray.length;
} else if (kvCache.get(world.varDataCache, field)) { } else if (world.varData.hasCol(field)) {
/* it's not in observations, so it's a gene, but let's check to make sure */ const varValues = world.varData.col(field).asArray();
const varValues = kvCache.get(world.varDataCache, field);
histogramCache.x = d3 histogramCache.x = d3
.scaleLinear() .scaleLinear()
@@ -141,21 +140,15 @@ class HistogramBrush extends React.Component {
} }
handleColorAction() { handleColorAction() {
const { const { obsAnnotations, dispatch, field, world, ranges } = this.props;
obsAnnotations,
dispatch,
field,
world,
initializeRanges
} = this.props;
if (obsAnnotations[0][field]) { if (obsAnnotations.hasCol(field)) {
dispatch({ dispatch({
type: "color by continuous metadata", type: "color by continuous metadata",
colorAccessor: field, colorAccessor: field,
rangeMaxForColorAccessor: initializeRanges[field].range.max rangeForColorAccessor: ranges
}); });
} else if (kvCache.get(world.varDataCache, field)) { } else if (world.varData.hasCol(field)) {
dispatch(actions.requestSingleGeneExpressionCountsForColoringPOST(field)); dispatch(actions.requestSingleGeneExpressionCountsForColoringPOST(field));
} }
} }
@@ -235,6 +228,7 @@ class HistogramBrush extends React.Component {
d3.select(svgRef) d3.select(svgRef)
.append("g") .append("g")
.attr("class", "brush") .attr("class", "brush")
.attr("data-testid", `${svgRef.id}-brush`)
.call( .call(
d3 d3
.brushX() .brushX()
@@ -279,6 +273,8 @@ class HistogramBrush extends React.Component {
return ( return (
<div <div
id={`histogram_${field}`} id={`histogram_${field}`}
data-testid={`histogram-${field}`}
data-testclass={isDiffExp ? `histogram-diffexp` : ""}
style={{ style={{
padding: globals.leftSidebarSectionPadding, padding: globals.leftSidebarSectionPadding,
backgroundColor: zebra ? globals.lightestGrey : "white" backgroundColor: zebra ? globals.lightestGrey : "white"
+5 -1
View File
@@ -65,7 +65,11 @@ class CategoryValue extends React.Component {
})[0].categories; })[0].categories;
} }
if (colorAccessor && !isColorBy) { if (
colorAccessor &&
!isColorBy &&
categoricalSelectionState[colorAccessor]
) {
occupancy = countCategoryValues2D( occupancy = countCategoryValues2D(
metadataField, metadataField,
colorAccessor, colorAccessor,
+33 -23
View File
@@ -9,8 +9,7 @@ import * as globals from "../../globals";
import HistogramBrush from "../brushableHistogram"; import HistogramBrush from "../brushableHistogram";
@connect(state => ({ @connect(state => ({
ranges: _.get(state.controls.world, "summary.obs", null), obsAnnotations: _.get(state.controls.world, "obsAnnotations", null),
metadata: _.get(state.controls.world, "obsAnnotations", null),
colorAccessor: state.controls.colorAccessor, colorAccessor: state.controls.colorAccessor,
colorScale: state.controls.colorScale, colorScale: state.controls.colorScale,
selectionUpdate: _.get(state.controls, "crossfilter.updateTime", null), selectionUpdate: _.get(state.controls, "crossfilter.updateTime", null),
@@ -29,17 +28,18 @@ class Continuous extends React.Component {
handleColorAction(key) { handleColorAction(key) {
return () => { return () => {
const { dispatch, ranges } = this.props; const { dispatch, obsAnnotations } = this.props;
const summary = obsAnnotations.col(key).summarize();
dispatch({ dispatch({
type: "color by continuous metadata", type: "color by continuous metadata",
colorAccessor: key, colorAccessor: key,
rangeMaxForColorAccessor: ranges[key].range.max rangeForColorAccessor: summary
}); });
}; };
} }
render() { render() {
const { ranges, obsAnnotations, schema } = this.props; const { obsAnnotations, schema } = this.props;
if (schema && !this.continuousChecked) { if (schema && !this.continuousChecked) {
this.hasContinuous = _.some( this.hasContinuous = _.some(
schema.annotations.obs, schema.annotations.obs,
@@ -63,24 +63,34 @@ class Continuous extends React.Component {
Continuous metadata Continuous metadata
</p> </p>
) : null} ) : null}
{_.map(ranges, (value, key) => { {obsAnnotations
const isColorField = key.includes("color") || key.includes("Color"); ? _.map(obsAnnotations.colIndex.keys(), key => {
zebra += 1; const summary = obsAnnotations.col(key).summarize();
if (value.range && key !== "name" && !isColorField) { const isColorField =
return ( key.includes("color") || key.includes("Color");
<HistogramBrush const nonFiniteExtent =
key={key} summary.min === undefined || summary.max === undefined;
field={key} zebra += 1;
isObs if (
zebra={zebra % 2 === 0} !summary.categorical &&
fieldValues={obsAnnotations} key !== "name" &&
ranges={value.range} !isColorField &&
handleColorAction={this.handleColorAction(key).bind(this)} !nonFiniteExtent
/> ) {
); return (
} <HistogramBrush
return null; key={key}
})} field={key}
isObs
zebra={zebra % 2 === 0}
ranges={summary}
handleColorAction={this.handleColorAction(key).bind(this)}
/>
);
}
return null;
})
: null}
</div> </div>
); );
} }
@@ -13,6 +13,13 @@ A "user" error - eg, bad input
export const postUserErrorToast = message => export const postUserErrorToast = message =>
ErrorToastTopCenter.show({ message, intent: Intent.WARNING }); ErrorToastTopCenter.show({ message, intent: Intent.WARNING });
/*
A toast the user must dismiss manually, because they need to act on its information,
ie., 8 bulk add genes out of 40 were bad. Manually see which ones and fix.
*/
export const keepAroundErrorToast = message =>
ErrorToastTopCenter.show({ message, timeout: 0, intent: Intent.WARNING });
/* /*
a hard network error a hard network error
*/ */
@@ -1,8 +1,8 @@
// jshint esversion: 6 // jshint esversion: 6
import React from "react"; import React from "react";
import _ from "lodash";
import { AnchorButton, Tooltip } from "@blueprintjs/core"; import { AnchorButton, Tooltip } from "@blueprintjs/core";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { World } from "../../util/stateManager";
@connect() @connect()
class CellSetButton extends React.Component { class CellSetButton extends React.Component {
@@ -14,7 +14,11 @@ class CellSetButton extends React.Component {
eitherCellSetOneOrTwo eitherCellSetOneOrTwo
} = this.props; } = this.props;
const set = _.map(crossfilter.allFiltered(), "name"); // Reducer and components assume that value will be null if
// no selection made. World..getSelectedByIndex() returns a
// zero length TypedArray when nothing is selected.
let set = World.getSelectedByIndex(crossfilter);
if (set.length === 0) set = null;
if (!differential.diffExp) { if (!differential.diffExp) {
/* diffexp needs to be cleared before we store a new set */ /* diffexp needs to be cleared before we store a new set */
@@ -38,6 +42,7 @@ class CellSetButton extends React.Component {
type="button" type="button"
disabled={differential.diffExp} disabled={differential.diffExp}
onClick={this.set.bind(this)} onClick={this.set.bind(this)}
data-testid={`cellset-button-${eitherCellSetOneOrTwo}`}
> >
{eitherCellSetOneOrTwo} {eitherCellSetOneOrTwo}
{": "} {": "}
@@ -68,6 +68,7 @@ class Expression extends React.Component {
style={{ marginTop: 10 }} style={{ marginTop: 10 }}
disabled={!haveBothCellSets} disabled={!haveBothCellSets}
intent="primary" intent="primary"
data-testid="diffexp-button"
loading={differential.loading} loading={differential.loading}
fill fill
type="button" type="button"
+200 -56
View File
@@ -6,12 +6,21 @@ import _ from "lodash";
import fuzzysort from "fuzzysort"; import fuzzysort from "fuzzysort";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { MenuItem, Button } from "@blueprintjs/core"; import {
MenuItem,
Button,
FormGroup,
InputGroup,
ControlGroup
} from "@blueprintjs/core";
import { Suggest } from "@blueprintjs/select"; import { Suggest } from "@blueprintjs/select";
import HistogramBrush from "../brushableHistogram"; import HistogramBrush from "../brushableHistogram";
import * as globals from "../../globals"; import * as globals from "../../globals";
import actions from "../../actions"; import actions from "../../actions";
import { postUserErrorToast } from "../framework/toasters"; import {
postUserErrorToast,
keepAroundErrorToast
} from "../framework/toasters";
import ExpressionButtons from "./expressionButtons"; import ExpressionButtons from "./expressionButtons";
import finiteExtent from "../../util/finiteExtent"; import finiteExtent from "../../util/finiteExtent";
@@ -20,8 +29,7 @@ const renderGene = (fuzzySortResult, { handleClick, modifiers, query }) => {
return null; return null;
} }
/* the fuzzysort wraps the object with other properties, like a score */ /* the fuzzysort wraps the object with other properties, like a score */
const gene = fuzzySortResult.obj; const geneName = fuzzySortResult.target;
const text = gene.name;
return ( return (
<MenuItem <MenuItem
@@ -30,43 +38,73 @@ const renderGene = (fuzzySortResult, { handleClick, modifiers, query }) => {
// Use of annotations in this way is incorrect and dataset specific. // Use of annotations in this way is incorrect and dataset specific.
// See https://github.com/chanzuckerberg/cellxgene/issues/483 // See https://github.com/chanzuckerberg/cellxgene/issues/483
// label={gene.n_counts} // label={gene.n_counts}
key={gene.name} key={geneName}
onClick={g => { onClick={g =>
/* this fires when user clicks a menu item */ /* this fires when user clicks a menu item */
handleClick(g); handleClick(g)
}} }
text={text} text={geneName}
/> />
); );
}; };
const filterGenes = (query, genes) => { const filterGenes = (query, genes) =>
/* fires on load, once, and then for each character typed into the input */ /* fires on load, once, and then for each character typed into the input */
return fuzzysort.go(query, genes, { fuzzysort.go(query, genes, {
key: "name",
limit: 5, limit: 5,
threshold: -10000 // don't return bad results threshold: -10000 // don't return bad results
}); });
};
@connect(state => { @connect(state => {
const metadata = _.get(state.controls.world, "obsAnnotations", null);
const ranges = _.get(state.controls.world, "summary.obs", null);
const initializeRanges = _.get(state.controls.world, "summary.obs");
return { return {
ranges, obsAnnotations: _.get(state.controls.world, "obsAnnotations", null),
metadata,
initializeRanges,
userDefinedGenes: state.controls.userDefinedGenes, userDefinedGenes: state.controls.userDefinedGenes,
userDefinedGenesLoading: state.controls.userDefinedGenesLoading, userDefinedGenesLoading: state.controls.userDefinedGenesLoading,
world: state.controls.world, world: state.controls.world,
colorAccessor: state.controls.colorAccessor, colorAccessor: state.controls.colorAccessor,
allGeneNames: state.controls.allGeneNames,
differential: state.differential differential: state.differential
}; };
}) })
class GeneExpression extends React.Component { class GeneExpression extends React.Component {
constructor(props) {
super(props);
this.state = {
bulkAdd: "",
tab: "autosuggest"
};
}
placeholderGeneNames() {
/*
return a string containing gene name suggestions for use as a user hint.
Eg., Apod, Cd74, ...
Will return a max of 3 genes, totalling 15 characters in length.
Randomly selects gene names.
NOTE: the random selection means it will re-render constantly.
*/
const { world } = this.props;
const { varAnnotations } = world;
const geneNames = varAnnotations.col("name").asArray();
if (geneNames.length > 0) {
const placeholder = [];
let len = geneNames.length;
const maxGeneNameCount = 3;
const maxStrLength = 15;
len = len < maxGeneNameCount ? len : maxGeneNameCount;
for (let i = 0, strLen = 0; i < len && strLen < maxStrLength; i += 1) {
const deal = Math.floor(Math.random() * geneNames.length);
const geneName = geneNames[deal];
placeholder.push(geneName);
strLen += geneName.length + 2; // '2' is the length of a comma and space
}
placeholder.push("...");
return placeholder.join(", ");
}
// default - should never happen.
return "Apod, Cd74, ...";
}
handleClick(g) { handleClick(g) {
const { world, dispatch, userDefinedGenes } = this.props; const { world, dispatch, userDefinedGenes } = this.props;
const gene = g.target; const gene = g.target;
@@ -76,7 +114,7 @@ class GeneExpression extends React.Component {
postUserErrorToast( postUserErrorToast(
"That's too many genes, you can have at most 15 user defined genes" "That's too many genes, you can have at most 15 user defined genes"
); );
} else if (!_.find(world.varAnnotations, { name: gene })) { } else if (world.varAnnotations.col("name").indexOf(gene) === undefined) {
postUserErrorToast("That doesn't appear to be a valid gene name."); postUserErrorToast("That doesn't appear to be a valid gene name.");
} else { } else {
dispatch(actions.requestUserDefinedGene(gene)); dispatch(actions.requestUserDefinedGene(gene));
@@ -87,6 +125,41 @@ class GeneExpression extends React.Component {
} }
} }
handleBulkAddClick() {
const { world, dispatch, userDefinedGenes } = this.props;
const { bulkAdd } = this.state;
/*
test:
Apod,,, Cd74,, ,,, Foo, Bar-2,,
*/
if (bulkAdd !== "") {
const genes = _.pull(_.uniq(bulkAdd.split(/[ ,]+/)), "");
genes.forEach(gene => {
if (gene.length === 0) {
keepAroundErrorToast("Must enter a gene name.");
} else if (userDefinedGenes.indexOf(gene) !== -1) {
keepAroundErrorToast("That gene already exists");
} else if (
world.varAnnotations.col("name").indexOf(gene) === undefined
) {
keepAroundErrorToast(
`${gene} doesn't appear to be a valid gene name.`
);
} else {
dispatch(actions.requestUserDefinedGene(gene));
dispatch({
type: "user defined gene",
data: gene
});
}
});
}
this.setState({ bulkAdd: "" });
}
render() { render() {
const { const {
world, world,
@@ -95,6 +168,8 @@ class GeneExpression extends React.Component {
differential differential
} = this.props; } = this.props;
const { tab, bulkAdd } = this.state;
return ( return (
<div> <div>
<div <div
@@ -111,51 +186,120 @@ class GeneExpression extends React.Component {
Selected Genes Selected Genes
</p> </p>
<div <div
style={{ padding: globals.leftSidebarSectionPadding }} style={{
className="bp3-control-group" padding: globals.leftSidebarSectionPadding
}}
> >
<Suggest
disabled={true}
closeOnSelect
openOnKeyDown
resetOnSelect
itemDisabled={userDefinedGenesLoading ? () => true : () => false}
noResults={<MenuItem disabled text="No matching genes." />}
onItemSelect={g => {
/* this happens on 'enter' */
this.handleClick(g);
}}
inputValueRenderer={g => {
return "";
}}
itemListPredicate={filterGenes}
itemRenderer={renderGene.bind(this)}
items={
world && world.varAnnotations
? world.varAnnotations
: [{ name: "No genes", n_counts: "" }]
}
popoverProps={{ minimal: true }}
/>
<Button <Button
className="bp3-button bp3-intent-primary" active={tab === "autosuggest"}
loading={userDefinedGenesLoading} style={{ marginRight: 5 }}
minimal
small
onClick={() => {
this.setState({ tab: "autosuggest" });
}}
> >
Add Autosuggest
</Button>
<Button
active={tab === "bulkadd"}
minimal
small
onClick={() => {
this.setState({ tab: "bulkadd" });
}}
>
Bulk add genes
</Button> </Button>
</div> </div>
{tab === "autosuggest" ? (
<ControlGroup
style={{
paddingLeft: globals.leftSidebarSectionPadding,
paddingBottom: globals.leftSidebarSectionPadding
}}
>
<Suggest
closeOnSelect
openOnKeyDown
resetOnSelect
itemDisabled={
userDefinedGenesLoading ? () => true : () => false
}
noResults={<MenuItem disabled text="No matching genes." />}
onItemSelect={g => {
/* this happens on 'enter' */
this.handleClick(g);
}}
inputProps={{ "data-testid": "gene-search" }}
inputValueRenderer={g => {
return "";
}}
itemListPredicate={filterGenes}
itemRenderer={renderGene.bind(this)}
items={
world && world.varAnnotations
? world.varAnnotations.col("name").asArray()
: ["No genes"]
}
popoverProps={{ minimal: true }}
/>
<Button
className="bp3-button bp3-intent-primary"
data-testid={"add-gene"}
loading={userDefinedGenesLoading}
>
Add
</Button>
</ControlGroup>
) : null}
{tab === "bulkadd" ? (
<div style={{ paddingLeft: globals.leftSidebarSectionPadding }}>
<form
onSubmit={e => {
e.preventDefault();
this.handleBulkAddClick();
}}
>
<FormGroup
helperText="Add a list of genes (comma delimited)"
labelFor="text-input-bulk-add"
>
<ControlGroup>
<InputGroup
onChange={e => {
this.setState({ bulkAdd: e.target.value });
}}
id="text-input-bulk-add"
placeholder={this.placeholderGeneNames()}
value={bulkAdd}
/>
<Button
intent="primary"
onClick={this.handleBulkAddClick.bind(this)}
loading={userDefinedGenesLoading}
>
Add
</Button>
</ControlGroup>
</FormGroup>
</form>
</div>
) : null}
{world && userDefinedGenes.length > 0 {world && userDefinedGenes.length > 0
? _.map(userDefinedGenes, (geneName, index) => { ? _.map(userDefinedGenes, (geneName, index) => {
const values = world.varDataCache[geneName]; const values = world.varData.col(geneName);
if (!values) { if (!values) {
return null; return null;
} }
const summary = values.summarize();
return ( return (
<HistogramBrush <HistogramBrush
key={geneName} key={geneName}
field={geneName} field={geneName}
zebra={index % 2 === 0} zebra={index % 2 === 0}
ranges={finiteExtent(values)} ranges={summary}
isUserDefined isUserDefined
/> />
); );
@@ -174,18 +318,18 @@ class GeneExpression extends React.Component {
<ExpressionButtons /> <ExpressionButtons />
{differential.diffExp {differential.diffExp
? _.map(differential.diffExp, (value, index) => { ? _.map(differential.diffExp, (value, index) => {
const annotations = world.varAnnotations[value[0]]; const name = world.varAnnotations.at(value[0], "name");
const { name } = annotations; const values = world.varData.col(name);
const values = world.varDataCache[name];
if (!values) { if (!values) {
return null; return null;
} }
const summary = values.summarize();
return ( return (
<HistogramBrush <HistogramBrush
key={name} key={name}
field={name} field={name}
zebra={index % 2 === 0} zebra={index % 2 === 0}
ranges={finiteExtent(values)} ranges={summary}
isDiffExp isDiffExp
logFoldChange={value[1]} logFoldChange={value[1]}
pval={value[2]} pval={value[2]}
+99 -80
View File
@@ -35,12 +35,13 @@ class Graph extends React.Component {
this.graphPaddingRight = globals.leftSidebarWidth; this.graphPaddingRight = globals.leftSidebarWidth;
this.renderCache = { this.renderCache = {
positions: null, positions: null,
colors: null colors: null,
sizes: null
}; };
this.state = { this.state = {
svg: null, svg: null,
brush: null, brush: null,
mode: "brush" mode: "lasso"
}; };
} }
@@ -83,12 +84,13 @@ class Graph extends React.Component {
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
const { renderCache } = this;
const { const {
world, world,
crossfilter, crossfilter,
selectionUpdate,
colorRGB, colorRGB,
responsive responsive,
selectionUpdate
} = this.props; } = this.props;
const { const {
reglRender, reglRender,
@@ -109,35 +111,27 @@ class Graph extends React.Component {
if (regl && world) { if (regl && world) {
/* update the regl state */ /* update the regl state */
const { obsLayout } = world; const { obsLayout, nObs } = world;
const cellCount = crossfilter.size(); const X = obsLayout.col("X").asArray();
const Y = obsLayout.col("Y").asArray();
// X/Y positions for each point - a cached value that only // X/Y positions for each point - a cached value that only
// changes if we have loaded entirely new cell data // changes if we have loaded entirely new cell data
// //
if ( if (!renderCache.positions || world !== prevProps.world) {
!this.renderCache.positions || renderCache.positions = new Float32Array(2 * nObs);
selectionUpdate !== prevProps.selectionUpdate
) {
if (!this.renderCache.positions) {
this.renderCache.positions = new Float32Array(2 * cellCount);
}
const glScaleX = scaleLinear([0, 1], [-1, 1]); const glScaleX = scaleLinear([0, 1], [-1, 1]);
const glScaleY = scaleLinear([0, 1], [1, -1]); const glScaleY = scaleLinear([0, 1], [1, -1]);
const offset = [d3.mean(obsLayout.X) - 0.5, d3.mean(obsLayout.Y) - 0.5]; const offset = [d3.mean(X) - 0.5, d3.mean(Y) - 0.5];
for ( for (let i = 0, { positions } = renderCache; i < nObs; i += 1) {
let i = 0, { positions } = this.renderCache; positions[2 * i] = glScaleX(X[i] - offset[0]);
i < cellCount; positions[2 * i + 1] = glScaleY(Y[i] - offset[1]);
i += 1
) {
positions[2 * i] = glScaleX(obsLayout.X[i] - offset[0]);
positions[2 * i + 1] = glScaleY(obsLayout.Y[i] - offset[1]);
} }
pointBuffer({ pointBuffer({
data: this.renderCache.positions, data: renderCache.positions,
dimension: 2 dimension: 2
}); });
@@ -152,30 +146,28 @@ class Graph extends React.Component {
// could have changed for some other reason, but for now color is // could have changed for some other reason, but for now color is
// the only metadata that changes client-side. If this is problematic, // the only metadata that changes client-side. If this is problematic,
// we could add some sort of color-specific indicator to the app state. // we could add some sort of color-specific indicator to the app state.
if (!this.renderCache.colors || colorRGB !== prevProps.colorRGB) { if (!renderCache.colors || colorRGB !== prevProps.colorRGB) {
const rgb = colorRGB; const rgb = colorRGB;
if (!this.renderCache.colors) { if (!renderCache.colors) {
this.renderCache.colors = new Float32Array(3 * rgb.length); renderCache.colors = new Float32Array(3 * rgb.length);
} }
for (let i = 0, { colors } = this.renderCache; i < rgb.length; i += 1) { for (let i = 0, { colors } = renderCache; i < rgb.length; i += 1) {
colors.set(rgb[i], 3 * i); colors.set(rgb[i], 3 * i);
} }
colorBuffer({ data: this.renderCache.colors, dimension: 3 }); colorBuffer({ data: renderCache.colors, dimension: 3 });
} }
// Sizes for each point - this is presumed to change each time the // Sizes for each point - updates are triggered only when selected
// component receives new props. Almost always a true assumption, as // obs change
// most property upates are due to changes driving a crossfilter if (!renderCache.sizes || selectionUpdate !== prevProps.selectionUpdate) {
// selection set change. if (!renderCache.sizes) {
// renderCache.sizes = new Float32Array(nObs);
if (!this.renderCache.sizes) { }
this.renderCache.sizes = new Float32Array(cellCount); crossfilter.fillByIsFiltered(renderCache.sizes, 4, 0.2);
sizeBuffer({ data: renderCache.sizes, dimension: 1 });
} }
crossfilter.fillByIsFiltered(this.renderCache.sizes, 4, 0.2); this.count = nObs;
sizeBuffer({ data: this.renderCache.sizes, dimension: 1 });
this.count = cellCount;
regl._refresh(); regl._refresh();
this.reglDraw( this.reglDraw(
@@ -202,7 +194,9 @@ class Graph extends React.Component {
this.handleBrushSelectAction.bind(this), this.handleBrushSelectAction.bind(this),
this.handleBrushDeselectAction.bind(this), this.handleBrushDeselectAction.bind(this),
responsive, responsive,
this.graphPaddingRight this.graphPaddingRight,
this.handleLassoStart.bind(this),
this.handleLassoEnd.bind(this)
); );
this.setState({ svg: newSvg, brush }); this.setState({ svg: newSvg, brush });
} }
@@ -251,54 +245,54 @@ class Graph extends React.Component {
}); });
} }
invertPoint(pin) {
const { responsive } = this.props;
const { regl, camera, offset } = this.state;
const gl = regl._gl;
// get aspect ratio
const aspect = gl.drawingBufferWidth / gl.drawingBufferHeight;
// compute inverse view matrix
const inverse = mat4.invert([], camera.view());
// transform screen coordinates -> cell coordinates
const x = (2 * pin[0]) / (responsive.width - this.graphPaddingRight) - 1;
const y = 2 * (1 - pin[1] / (responsive.height - this.graphPaddingTop)) - 1;
const pout = [
x * inverse[14] * aspect + inverse[12],
y * inverse[14] + inverse[13]
];
return [(pout[0] + 1) / 2 + offset[0], (pout[1] + 1) / 2 + offset[1]];
}
handleBrushSelectAction() { handleBrushSelectAction() {
/* /*
This conditional handles procedural brush deselect. Brush emits This conditional handles procedural brush deselect. Brush emits
an event on procedural deselect because it is move: null an event on procedural deselect because it is move: null
*/ */
/*
const { camera, offset } = this.state;
const { dispatch, responsive } = this.props;
if (d3.event.sourceEvent !== null) {
/*
No idea why d3 event scope works like this
but apparently
it does
https://bl.ocks.org/EfratVil/0e542f5fc426065dd1d4b6daaa345a9f
*/
const s = d3.event.selection;
const gl = this.state.regl._gl;
/*
event describing brush position: event describing brush position:
@-------| @-------|
| | | |
| | | |
|-------@ |-------@
*/ */
/*
No idea why d3 event scope works like this
but apparently
it does
https://bl.ocks.org/EfratVil/0e542f5fc426065dd1d4b6daaa345a9f
*/
const { dispatch } = this.props;
// get aspect ratio if (d3.event.sourceEvent !== null) {
const aspect = gl.drawingBufferWidth / gl.drawingBufferHeight; const s = d3.event.selection;
// compute inverse view matrix
const inverse = mat4.invert([], camera.view());
// transform screen coordinates -> cell coordinates
const invert = pin => {
const x =
(2 * pin[0]) / (responsive.width - this.graphPaddingRight) - 1;
const y =
2 * (1 - pin[1] / (responsive.height - this.graphPaddingTop)) - 1;
const pout = [
x * inverse[14] * aspect + inverse[12],
y * inverse[14] + inverse[13]
];
return [(pout[0] + 1) / 2 + offset[0], (pout[1] + 1) / 2 + offset[1]];
};
const brushCoords = { const brushCoords = {
northwest: invert([s[0][0], s[0][1]]), northwest: this.invertPoint([s[0][0], s[0][1]]),
southeast: invert([s[1][0], s[1][1]]) southeast: this.invertPoint([s[1][0], s[1][1]])
}; };
dispatch({ dispatch({
@@ -330,6 +324,25 @@ class Graph extends React.Component {
} }
} }
handleLassoStart() {
const { dispatch } = this.props;
// reset selected points when starting a new polygon
// making it easier for the user to make the next selection
dispatch({
type: "lasso started"
});
}
// when a lasso is completed, filter to the points within the lasso polygon
handleLassoEnd(polygon) {
const { dispatch } = this.props;
dispatch({
type: "lasso selection",
polygon: polygon.map(xy => this.invertPoint(xy)) // transform the polygon
});
}
handleOpacityRangeChange(e) { handleOpacityRangeChange(e) {
const { dispatch } = this.props; const { dispatch } = this.props;
dispatch({ dispatch({
@@ -412,13 +425,18 @@ class Graph extends React.Component {
</Tooltip> </Tooltip>
<div> <div>
<div className="bp3-button-group"> <div className="bp3-button-group">
<Tooltip content="Lasso cells" position="left"> <Tooltip content="Lasso selection" position="left">
<Button <Button
className="bp3-button bp3-icon-select"
type="button" type="button"
active={mode === "brush"} className="bp3-button bp3-icon-polygon-filter"
active={mode === "lasso"}
onClick={() => { onClick={() => {
this.setState({ mode: "brush" }); this.handleBrushDeselectAction();
// this.restartReglLoop();
this.setState({ mode: "lasso" });
}}
style={{
cursor: "pointer"
}} }}
/> />
</Tooltip> </Tooltip>
@@ -451,7 +469,7 @@ class Graph extends React.Component {
> >
<div <div
style={{ style={{
display: mode === "brush" ? "inherit" : "none" display: mode === "lasso" ? "inherit" : "none"
}} }}
id="graphAttachPoint" id="graphAttachPoint"
/> />
@@ -459,6 +477,7 @@ class Graph extends React.Component {
<canvas <canvas
width={responsive.width - this.graphPaddingRight} width={responsive.width - this.graphPaddingRight}
height={responsive.height - this.graphPaddingTop} height={responsive.height - this.graphPaddingTop}
data-testid="layout"
ref={canvas => { ref={canvas => {
this.reglCanvas = canvas; this.reglCanvas = canvas;
}} }}
+127
View File
@@ -0,0 +1,127 @@
// https://bl.ocks.org/pbeshai/8008075f9ce771ee8be39e8c38907570
import * as d3 from "d3";
const Lasso = () => {
const dispatch = d3.dispatch("start", "end");
const polygonToPath = polygon =>
`M${polygon.map(d => d.join(",")).join("L")}`;
const distance = (pt1, pt2) =>
Math.sqrt((pt2[0] - pt1[0]) ** 2 + (pt2[1] - pt1[1]) ** 2);
// distance last point has to be to first point before it auto closes when mouse is released
const closeDistance = 75;
const lasso = svg => {
let lassoPolygon;
let lassoPath;
let closePath;
const handleDragStart = () => {
lassoPolygon = [d3.mouse(svg.node())]; // current x y of mouse within element
if (lassoPath) {
lassoPath.remove();
}
lassoPath = g
.append("path")
.attr("fill", "#0bb")
.attr("fill-opacity", 0.1)
.attr("stroke", "#0bb")
.attr("stroke-dasharray", "3, 3");
closePath = g
.append("line")
.attr("x2", lassoPolygon[0][0])
.attr("y2", lassoPolygon[0][1])
.attr("stroke", "#0bb")
.attr("stroke-dasharray", "3, 3")
.attr("opacity", 0);
dispatch.call("start", lasso, lassoPolygon);
};
const handleDrag = () => {
const point = d3.mouse(svg.node());
lassoPolygon.push(point);
lassoPath.attr("d", polygonToPath(lassoPolygon));
// indicate if we are within closing distance
if (
distance(lassoPolygon[0], lassoPolygon[lassoPolygon.length - 1]) <
closeDistance
) {
closePath
.attr("x1", point[0])
.attr("y1", point[1])
.attr("opacity", 1);
} else {
closePath.attr("opacity", 0);
}
};
const handleDragEnd = () => {
// remove the close path
closePath.remove();
closePath = null;
// succesfully closed
if (
distance(lassoPolygon[0], lassoPolygon[lassoPolygon.length - 1]) <
closeDistance
) {
lassoPath.attr("d", `${polygonToPath(lassoPolygon)}Z`);
dispatch.call("end", lasso, lassoPolygon);
// otherwise cancel
} else {
lassoPath.remove();
lassoPath = null;
lassoPolygon = null;
}
};
// append a <g> with a rect
const g = svg.append("g").attr("class", "lasso-group");
const bbox = svg.node().getBoundingClientRect();
const area = g
.append("rect")
.attr("width", bbox.width)
.attr("height", bbox.height)
.attr("fill", "tomato")
.attr("opacity", 0);
const drag = d3
.drag()
.on("start", handleDragStart)
.on("drag", handleDrag)
.on("end", handleDragEnd);
area.call(drag);
lasso.reset = () => {
if (lassoPath) {
lassoPath.remove();
lassoPath = null;
}
lassoPolygon = null;
if (closePath) {
closePath.remove();
closePath = null;
}
};
};
lasso.on = (type, callback) => {
dispatch.on(type, callback);
return lasso;
};
return lasso;
};
export default Lasso;
@@ -1,6 +1,7 @@
// jshint esversion: 6 // jshint esversion: 6
import * as d3 from "d3"; import * as d3 from "d3";
import styles from "./graph.css"; import styles from "./graph.css";
import Lasso from "./setupLasso";
/****************************************** /******************************************
******************************************* *******************************************
@@ -12,11 +13,14 @@ export default (
handleBrushSelectAction, handleBrushSelectAction,
handleBrushDeselectAction, handleBrushDeselectAction,
responsive, responsive,
graphPaddingRight graphPaddingRight,
handleLassoStart,
handleLassoEnd
) => { ) => {
const svg = d3 const svg = d3
.select("#graphAttachPoint") .select("#graphAttachPoint")
.append("svg") .append("svg")
.attr("data-testid", "layout-overlay")
.attr("width", responsive.width - graphPaddingRight) .attr("width", responsive.width - graphPaddingRight)
.attr("height", responsive.height) .attr("height", responsive.height)
.attr("class", `${styles.graphSVG}`); .attr("class", `${styles.graphSVG}`);
@@ -32,9 +36,16 @@ export default (
.attr("class", "graph_brush") .attr("class", "graph_brush")
.call(brush); .call(brush);
const lassoInstance = Lasso()
.on("end", handleLassoEnd)
.on("start", handleLassoStart);
const lasso = svg.call(lassoInstance);
return { return {
svg, svg,
brushContainer, brushContainer,
brush brush,
lasso
}; };
}; };
+1
View File
@@ -40,6 +40,7 @@ class LeftSideBar extends React.Component {
}} }}
> >
<p <p
data-testid="header"
style={{ style={{
position: "fixed", position: "fixed",
top: globals.cellxgeneTitleTopPadding, top: globals.cellxgeneTitleTopPadding,
@@ -19,7 +19,6 @@ import _drawPoints from "./drawPointsRegl";
import scaleLinear from "../../util/scaleLinear"; import scaleLinear from "../../util/scaleLinear";
import { margin, width, height } from "./util"; import { margin, width, height } from "./util";
import { kvCache } from "../../util/stateManager";
import finiteExtent from "../../util/finiteExtent"; import finiteExtent from "../../util/finiteExtent";
@connect(state => { @connect(state => {
@@ -30,12 +29,16 @@ import finiteExtent from "../../util/finiteExtent";
scatterplotYYaccessor scatterplotYYaccessor
} = state.controls; } = state.controls;
const expressionX = const expressionX =
world && scatterplotXXaccessor world &&
? kvCache.get(world.varDataCache, scatterplotXXaccessor) scatterplotXXaccessor &&
world.varData.hasCol(scatterplotXXaccessor)
? world.varData.col(scatterplotXXaccessor).asArray()
: null; : null;
const expressionY = const expressionY =
world && scatterplotYYaccessor world &&
? kvCache.get(world.varDataCache, scatterplotYYaccessor) scatterplotYYaccessor &&
world.varData.hasCol(scatterplotYYaccessor)
? world.varData.col(scatterplotYYaccessor).asArray()
: null; : null;
return { return {
@@ -65,12 +68,17 @@ class Scatterplot extends React.Component {
super(props); super(props);
this.count = 0; this.count = 0;
this.axes = false; this.axes = false;
this.state = { this.renderCache = {
svg: null, positions: null,
minimized: null, colors: null,
sizes: null,
xScale: null, xScale: null,
yScale: null yScale: null
}; };
this.state = {
svg: null,
minimized: null
};
} }
componentDidMount() { componentDidMount() {
@@ -81,6 +89,7 @@ class Scatterplot extends React.Component {
if (svg && expressionX && expressionY) { if (svg && expressionX && expressionY) {
scales = Scatterplot.setupScales(expressionX, expressionY); scales = Scatterplot.setupScales(expressionX, expressionY);
this.drawAxesSVG(scales.xScale, scales.yScale, svg); this.drawAxesSVG(scales.xScale, scales.yScale, svg);
this.renderCache = { ...this.renderCache, ...scales };
} }
const camera = _camera(this.reglCanvas, { scale: true, rotate: false }); const camera = _camera(this.reglCanvas, { scale: true, rotate: false });
@@ -113,8 +122,6 @@ class Scatterplot extends React.Component {
pointBuffer, pointBuffer,
colorBuffer, colorBuffer,
svg, svg,
xScale: scales ? scales.xScale : null,
yScale: scales ? scales.yScale : null,
reglRender, reglRender,
camera, camera,
drawPoints drawPoints
@@ -129,12 +136,11 @@ class Scatterplot extends React.Component {
scatterplotYYaccessor, scatterplotYYaccessor,
expressionX, expressionX,
expressionY, expressionY,
colorRGB colorRGB,
selectionUpdate
} = this.props; } = this.props;
const { const {
reglRender, reglRender,
xScale,
yScale,
regl, regl,
pointBuffer, pointBuffer,
colorBuffer, colorBuffer,
@@ -145,17 +151,12 @@ class Scatterplot extends React.Component {
} = this.state; } = this.state;
if ( if (
world && scatterplotXXaccessor !== prevProps.scatterplotXXaccessor || // was CLU now FTH1 etc
svg && scatterplotYYaccessor !== prevProps.scatterplotYYaccessor // was CLU now FTH1 etc
xScale &&
yScale &&
scatterplotXXaccessor &&
scatterplotYYaccessor &&
(scatterplotXXaccessor !== prevProps.scatterplotXXaccessor || // was CLU now FTH1 etc
scatterplotYYaccessor !== prevProps.scatterplotYYaccessor || // was CLU now FTH1 etc
!this.axes) // clicked off the tab and back again, rerender
) { ) {
this.drawAxesSVG(xScale, yScale, svg); const scales = Scatterplot.setupScales(expressionX, expressionY);
this.drawAxesSVG(scales.xScale, scales.yScale, svg);
this.renderCache = { ...this.renderCache, ...scales };
} }
if (reglRender && this.reglRenderState === "rendering") { if (reglRender && this.reglRenderState === "rendering") {
@@ -172,35 +173,51 @@ class Scatterplot extends React.Component {
expressionX && expressionX &&
expressionY && expressionY &&
scatterplotXXaccessor && scatterplotXXaccessor &&
scatterplotYYaccessor && scatterplotYYaccessor
xScale &&
yScale
) { ) {
const { renderCache } = this;
const { xScale, yScale } = this.renderCache;
const cellCount = expressionX.length; const cellCount = expressionX.length;
const positionsBuf = new Float32Array(2 * cellCount);
const colorsBuf = new Float32Array(3 * cellCount);
const sizesBuf = new Float32Array(cellCount);
const glScaleX = scaleLinear([0, width], [-0.95, 0.95]); // Points change when expressionX or expressionY change.
const glScaleY = scaleLinear([0, height], [-1, 1]); if (
!renderCache.positions ||
/* expressionX !== prevProps.expressionX ||
Construct Vectors expressionY !== prevProps.expressionY
*/ ) {
for (let i = 0; i < cellCount; i += 1) { if (!renderCache.positions) {
positionsBuf[2 * i] = glScaleX(xScale(expressionX[i])); renderCache.positions = new Float32Array(2 * cellCount);
positionsBuf[2 * i + 1] = glScaleY(yScale(expressionY[i])); }
const glScaleX = scaleLinear([0, width], [-0.95, 0.95]);
const glScaleY = scaleLinear([0, height], [-1, 1]);
for (let i = 0, { positions } = renderCache; i < cellCount; i += 1) {
positions[2 * i] = glScaleX(xScale(expressionX[i]));
positions[2 * i + 1] = glScaleY(yScale(expressionY[i]));
}
pointBuffer({ data: renderCache.positions, dimension: 2 });
} }
for (let i = 0; i < cellCount; i += 1) { // Colors for each point - change only when props.colorsRGB change.
colorsBuf.set(colorRGB[i], 3 * i); 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 });
} }
crossfilter.fillByIsFiltered(sizesBuf, 4, 0.2); // Sizes for each point - updates are triggered only when selected
// obs change
if (!renderCache.sizes || selectionUpdate !== prevProps.selctionUpdate) {
if (!renderCache.sizes) {
renderCache.sizes = new Float32Array(cellCount);
}
crossfilter.fillByIsFiltered(renderCache.sizes, 4, 0.2);
sizeBuffer({ data: renderCache.sizes, dimension: 1 });
}
pointBuffer({ data: positionsBuf, dimension: 2 });
colorBuffer({ data: colorsBuf, dimension: 3 });
sizeBuffer({ data: sizesBuf, dimension: 1 });
this.count = cellCount; this.count = cellCount;
regl._refresh(); regl._refresh();
@@ -213,16 +230,6 @@ class Scatterplot extends React.Component {
camera camera
); );
} }
if (
expressionX &&
expressionY &&
(scatterplotXXaccessor !== prevProps.scatterplotXXaccessor || // was CLU now FTH1 etc
scatterplotYYaccessor !== prevProps.scatterplotYYaccessor)
) {
const scales = Scatterplot.setupScales(expressionX, expressionY);
this.setState(scales);
}
} }
static setupScales(expressionX, expressionY) { static setupScales(expressionX, expressionY) {
+4 -2
View File
@@ -1,3 +1,5 @@
import { Colors } from "@blueprintjs/core";
// jshint esversion: 6 // jshint esversion: 6
/* these will be either (preferably) specified or inferred */ /* these will be either (preferably) specified or inferred */
export const categories = [ export const categories = [
@@ -44,8 +46,8 @@ export const configDefaults = {
}; };
/* colors */ /* colors */
export const blue = "#4a90e2"; export const blue = Colors.BLUE3;
export const hcaBlue = "#1c7cc7"; export const linkBlue = Colors.BLUE5;
export const lightestGrey = "rgb(249,249,249)"; export const lightestGrey = "rgb(249,249,249)";
export const lighterGrey = "rgb(245,245,245)"; export const lighterGrey = "rgb(245,245,245)";
export const lightGrey = "rgb(211,211,211)"; export const lightGrey = "rgb(211,211,211)";
+7 -6
View File
@@ -41,13 +41,13 @@ const updateCellColorsMiddleware = store => next => action => {
action.type === "color by continuous metadata" || action.type === "color by continuous metadata" ||
action.type === "color by categorical metadata"; action.type === "color by categorical metadata";
if (!filterJustChanged || !s.controls.world.obsAnnotations) { const obsAnnotations = _.get(s.controls, "world.obsAnnotations", null);
if (!filterJustChanged || !obsAnnotations) {
return next( return next(
action action
); /* if the cells haven't loaded or the action wasn't a color change, bail */ ); /* if the cells haven't loaded or the action wasn't a color change, bail */
} }
const { obsAnnotations } = s.controls.world;
let colorScale; let colorScale;
const colorsByRGB = new Array(obsAnnotations.length); const colorsByRGB = new Array(obsAnnotations.length);
@@ -73,16 +73,16 @@ const updateCellColorsMiddleware = store => next => action => {
}); });
const key = action.colorAccessor; const key = action.colorAccessor;
const col = obsAnnotations.col(key).asArray();
for (let i = 0, len = obsAnnotations.length; i < len; i += 1) { for (let i = 0, len = obsAnnotations.length; i < len; i += 1) {
const obs = obsAnnotations[i]; const cat = col[i];
const cat = obs[key];
colorsByRGB[i] = colors[cat]; colorsByRGB[i] = colors[cat];
} }
} }
if (action.type === "color by continuous metadata") { if (action.type === "color by continuous metadata") {
const colorBins = 100; const colorBins = 100;
const [min, max] = [0, action.rangeMaxForColorAccessor]; const { min, max } = action.rangeForColorAccessor;
colorScale = d3 colorScale = d3
.scaleQuantile() .scaleQuantile()
.domain([min, max]) .domain([min, max])
@@ -96,8 +96,9 @@ const updateCellColorsMiddleware = store => next => action => {
const key = action.colorAccessor; const key = action.colorAccessor;
const nonFiniteColor = parseRGB(globals.nonFiniteCellColor); const nonFiniteColor = parseRGB(globals.nonFiniteCellColor);
const col = obsAnnotations.col(key).asArray();
for (let i = 0, len = obsAnnotations.length; i < len; i += 1) { for (let i = 0, len = obsAnnotations.length; i < len; i += 1) {
const val = obsAnnotations[i][key]; const val = col[i];
if (Number.isFinite(val)) { if (Number.isFinite(val)) {
const c = colorScale(val); const c = colorScale(val);
colorsByRGB[i] = colors[c]; colorsByRGB[i] = colors[c];
+138 -214
View File
@@ -1,7 +1,8 @@
// jshint esversion: 6 // jshint esversion: 6
import _ from "lodash"; import _ from "lodash";
import { World, kvCache, WorldUtil } from "../util/stateManager";
import { World, WorldUtil, ControlsHelper } from "../util/stateManager";
import parseRGB from "../util/parseRGB"; import parseRGB from "../util/parseRGB";
import Crossfilter from "../util/typedCrossfilter"; import Crossfilter from "../util/typedCrossfilter";
import * as globals from "../globals"; import * as globals from "../globals";
@@ -12,91 +13,6 @@ import {
diffexpDimensionName, diffexpDimensionName,
makeContinuousDimensionName makeContinuousDimensionName
} from "../util/nameCreators"; } from "../util/nameCreators";
import { fillRange } from "../util/typedCrossfilter/util";
/*
Selection state for categoricals are tracked in an Object that
has two main components for each category:
1. mapping of option value to an index
2. array of bool selection state by index
Remember that option values can be ANY js type, except undefined/null.
{
_category_name_1: {
// map of option value to index
categoryIndices: Map([
catval1: index,
...
])
// index->selection true/false state
categorySelected: [ true/false, true/false, ... ]
// number of options
numCategories: number,
// isTruncated - true if the options for selection has
// been truncated (ie, was too large to implement)
}
}
*/
function topNCategories(summary) {
const counts = _.map(summary.categories, cat =>
summary.categoryCounts.get(cat)
);
const sortIndex = fillRange(new Array(summary.numCategories)).sort(
(a, b) => counts[b] - counts[a]
);
const sortedCategories = _.map(sortIndex, i => summary.categories[i]);
const sortedCounts = _.map(sortIndex, i => counts[i]);
const N = globals.maxCategoricalOptionsToDisplay;
if (sortedCategories.length < N) {
return [sortedCategories, sortedCounts];
}
return [sortedCategories.slice(0, N), sortedCounts.slice(0, N)];
}
function createCategoricalSelectionState(state, world) {
const res = {};
_.forEach(world.summary.obs, (value, key) => {
if (value.categories) {
const isColorField = key.includes("color") || key.includes("Color");
const isSelectableCategory =
!isColorField &&
key !== "name" &&
value.categories.length < state.maxCategoryItems;
if (isSelectableCategory) {
const [categoryValues, categoryCounts] = topNCategories(value);
const categoryIndices = new Map(categoryValues.map((v, i) => [v, i]));
const numCategories = categoryIndices.size;
const categorySelected = new Array(numCategories).fill(true);
const isTruncated = categoryValues.length < value.numCategories;
res[key] = {
categoryValues, // array: of natively typed category values
categoryIndices, // map: category value (native type) -> category index
categorySelected, // array: t/f selection state
numCategories, // number: of categories
isTruncated, // bool: true if list was truncated
categoryCounts // array: cardinality of each category
};
}
}
});
return res;
}
/*
given a categoricalSelectionState, return the list of all category values
where selection state is true (ie, they are selected).
*/
function selectedValuesForCategory(categorySelectionState) {
const selectedValues = _([...categorySelectionState.categoryIndices])
.filter(tuple => categorySelectionState.categorySelected[tuple[1]])
.map(tuple => tuple[0])
.value();
return selectedValues;
}
const Controls = ( const Controls = (
state = { state = {
@@ -109,6 +25,7 @@ const Controls = (
// the whole big bang // the whole big bang
universe: null, universe: null,
fullUniverseCache: null,
// all of the data + selection state // all of the data + selection state
world: null, world: null,
@@ -162,16 +79,14 @@ const Controls = (
case "initial data load start": { case "initial data load start": {
return { ...state, loading: true }; return { ...state, loading: true };
} }
case "initial data load complete (universe exists)": case "initial data load complete (universe exists)": {
case "reset World to eq Universe": {
const { userDefinedGenes, diffexpGenes } = state;
/* first light - create world & other data-driven defaults */ /* first light - create world & other data-driven defaults */
const { universe } = action; const { universe } = action;
const world = World.createWorldFromEntireUniverse(universe); const world = World.createWorldFromEntireUniverse(universe);
const colorRGB = new Array(universe.nObs).fill( const colorRGB = new Array(universe.nObs).fill(
parseRGB(globals.defaultCellColor) parseRGB(globals.defaultCellColor)
); );
const categoricalSelectionState = createCategoricalSelectionState( const categoricalSelectionState = ControlsHelper.createCategoricalSelectionState(
state, state,
world world
); );
@@ -179,51 +94,58 @@ const Controls = (
const dimensionMap = World.createObsDimensionMap(crossfilter, world); const dimensionMap = World.createObsDimensionMap(crossfilter, world);
WorldUtil.clearCaches(); WorldUtil.clearCaches();
const worldVarDataCache = world.varDataCache;
// dimensionMap = {
// layout_X: dim-for-X,
// obsAnno_name: dim for an annotation,
// varData_userDefined_genename: dim for user defined expression,
// varData_diffexp_genename: dim for diff-exp added gene expression
// }
/* var dimensions */
if (userDefinedGenes.length > 0) {
/*
verbose & slightly confusing that we also access this as an object
in controls rather than an array, should be abstracted into
util ie., createDimensionsFromBothListsOfGenes(userGenes, diffExp)
*/
_.forEach(userDefinedGenes, gene => {
dimensionMap[
userDefinedDimensionName(gene)
] = World.createVarDimension(
/* "__var__" + */
world,
worldVarDataCache,
crossfilter,
gene
);
});
}
if (diffexpGenes.length > 0) {
_.forEach(diffexpGenes, gene => {
dimensionMap[diffexpDimensionName(gene)] = World.createVarDimension(
/* "__var__" + */
world,
worldVarDataCache,
crossfilter,
gene
);
});
}
return { return {
...state, ...state,
loading: false, loading: false,
error: null, error: null,
universe, universe,
fullUniverseCache: { world, crossfilter, dimensionMap },
world,
colorRGB,
categoricalSelectionState,
crossfilter,
dimensionMap,
colorAccessor: null,
resettingInterface: false
};
}
case "reset World to eq Universe": {
const {
userDefinedGenes,
diffexpGenes,
universe,
fullUniverseCache
} = state;
const { world, crossfilter } = fullUniverseCache;
// reset all crossfilter dimensions
_.forEach(fullUniverseCache.dimensionMap, dim => dim.filterAll());
const colorRGB = new Array(universe.nObs).fill(
parseRGB(globals.defaultCellColor)
);
const categoricalSelectionState = ControlsHelper.createCategoricalSelectionState(
state,
world
);
/* free dimensions not in cache (otherwise they leak) */
_.forEach(state.dimensionMap, (dim, dimName) => {
if (!fullUniverseCache.dimensionMap[dimName]) {
dim.dispose();
}
});
const dimensionMap = {
...fullUniverseCache.dimensionMap,
...ControlsHelper.createGenesDimMap(
userDefinedGenes,
diffexpGenes,
world,
crossfilter
)
};
WorldUtil.clearCaches();
return {
...state,
world, world,
colorRGB, colorRGB,
categoricalSelectionState, categoricalSelectionState,
@@ -245,48 +167,22 @@ const Controls = (
const colorRGB = new Array(world.nObs).fill( const colorRGB = new Array(world.nObs).fill(
parseRGB(globals.defaultCellColor) parseRGB(globals.defaultCellColor)
); );
const categoricalSelectionState = createCategoricalSelectionState( const categoricalSelectionState = ControlsHelper.createCategoricalSelectionState(
state, state,
world world
); );
const crossfilter = Crossfilter(world.obsAnnotations); const crossfilter = Crossfilter(world.obsAnnotations);
const dimensionMap = World.createObsDimensionMap(crossfilter, world); const dimensionMap = {
...World.createObsDimensionMap(crossfilter, world),
...ControlsHelper.createGenesDimMap(
userDefinedGenes,
diffexpGenes,
world,
crossfilter
)
};
WorldUtil.clearCaches(); WorldUtil.clearCaches();
const worldVarDataCache = world.varDataCache;
/* var dimensions */
if (userDefinedGenes.length > 0) {
/*
verbose & slightly confusing that we also access this as an object
in controls rather than an array, should be abstracted into
util ie., createDimensionsFromBothListsOfGenes(userGenes, diffExp)
*/
_.forEach(userDefinedGenes, gene => {
dimensionMap[
userDefinedDimensionName(gene)
] = World.createVarDimension(
/* "__var__" + */
world,
worldVarDataCache,
crossfilter,
gene
);
});
}
if (diffexpGenes.length > 0) {
_.forEach(diffexpGenes, gene => {
dimensionMap[diffexpDimensionName(gene)] = World.createVarDimension(
/* "__var__" + */
world,
worldVarDataCache,
crossfilter,
gene
);
});
}
return { return {
...state, ...state,
loading: false, loading: false,
@@ -301,28 +197,66 @@ const Controls = (
} }
case "expression load success": { case "expression load success": {
const { world, universe } = state; const { world, universe } = state;
let universeVarDataCache = universe.varDataCache; let universeVarData = universe.varData;
let worldVarDataCache = world.varDataCache; let worldVarData = world.varData;
// Load new expression data into the varData dataframes, if
// not already present.
_.forEach(action.expressionData, (val, key) => { _.forEach(action.expressionData, (val, key) => {
universeVarDataCache = kvCache.set(universeVarDataCache, key, val); // If not already in universe.varData, save entire expression column
if (kvCache.get(worldVarDataCache, key) === undefined) { if (!universeVarData.hasCol(key)) {
worldVarDataCache = kvCache.set( universeVarData = universeVarData.withCol(key, val);
worldVarDataCache, }
// If not already in world.varData, save sliced expression column
if (!worldVarData.hasCol(key)) {
// Slice if world !== universe, else just use whole column.
// Use the obsAnnotation index as the cut key, as we keep
// all world dataframes in sync.
let worldValSlice = val;
if (!World.worldEqUniverse(world, universe)) {
worldValSlice = universeVarData
.subset(world.obsAnnotations.rowIndex.keys(), [key], null)
.icol(0)
.asArray();
}
// Now build world's varData dataframe
worldVarData = worldVarData.withCol(
key, key,
World.subsetVarData(world, universe, val) worldValSlice,
world.obsAnnotations.rowIndex
); );
} }
}); });
// Prune size of varData "cache" if getting out of hand....
const { userDefinedGenes, diffexpGenes } = state;
const allTheGenesWeNeed = _.uniq(
[].concat(
userDefinedGenes,
diffexpGenes,
Object.keys(action.expressionData)
)
);
universeVarData = ControlsHelper.pruneVarDataCache(
universeVarData,
allTheGenesWeNeed
);
worldVarData = ControlsHelper.pruneVarDataCache(
worldVarData,
allTheGenesWeNeed
);
return { return {
...state, ...state,
universe: { universe: {
...universe, ...universe,
varDataCache: universeVarDataCache varData: universeVarData
}, },
world: { world: {
...world, ...world,
varDataCache: worldVarDataCache varData: worldVarData
} }
}; };
} }
@@ -340,17 +274,12 @@ const Controls = (
} }
case "request user defined gene success": { case "request user defined gene success": {
const { world, crossfilter, dimensionMap, userDefinedGenes } = state; const { world, crossfilter, dimensionMap, userDefinedGenes } = state;
const worldVarDataCache = world.varDataCache;
const _userDefinedGenes = userDefinedGenes.slice(); const _userDefinedGenes = userDefinedGenes.slice();
const gene = action.data.genes[0]; const gene = action.data.genes[0];
dimensionMap[userDefinedDimensionName(gene)] = World.createVarDimension( dimensionMap[
/* "__var__" + */ userDefinedDimensionName(gene)
world, ] = World.createVarDataDimension(world, crossfilter, gene);
worldVarDataCache,
crossfilter,
gene
);
return { return {
...state, ...state,
@@ -361,18 +290,15 @@ const Controls = (
} }
case "request differential expression success": { case "request differential expression success": {
const { world, crossfilter, dimensionMap } = state; const { world, crossfilter, dimensionMap } = state;
const worldVarDataCache = world.varDataCache;
const _diffexpGenes = []; const _diffexpGenes = [];
action.data.forEach(d => { action.data.forEach(d => {
_diffexpGenes.push(world.varAnnotations[d[0]].name); _diffexpGenes.push(world.varAnnotations.at(d[0], "name"));
}); });
_.forEach(_diffexpGenes, gene => { _.forEach(_diffexpGenes, gene => {
dimensionMap[diffexpDimensionName(gene)] = World.createVarDimension( dimensionMap[diffexpDimensionName(gene)] = World.createVarDataDimension(
/* "__var__" + */
world, world,
worldVarDataCache,
crossfilter, crossfilter,
gene gene
); );
@@ -385,13 +311,10 @@ const Controls = (
}; };
} }
case "clear differential expression": { case "clear differential expression": {
const { world, universe, dimensionMap } = state; const { world, dimensionMap } = state;
const _dimensionMap = dimensionMap; const _dimensionMap = dimensionMap;
const universeVarDataCache = universe.varDataCache;
const worldVarDataCache = world.varDataCache;
_.forEach(action.diffExp, values => { _.forEach(action.diffExp, values => {
const { name } = world.varAnnotations[values[0]]; const name = world.varAnnotations.at(values[0], "name");
// clean up crossfilter dimensions // clean up crossfilter dimensions
const dimension = dimensionMap[diffexpDimensionName(name)]; const dimension = dimensionMap[diffexpDimensionName(name)];
dimension.dispose(); dimension.dispose();
@@ -400,15 +323,7 @@ const Controls = (
return { return {
...state, ...state,
dimensionMap: _dimensionMap, dimensionMap: _dimensionMap,
diffexpGenes: [], diffexpGenes: []
universe: {
...universe,
varDataCache: universeVarDataCache
},
world: {
...world,
varDataCache: worldVarDataCache
}
}; };
} }
case "user defined gene": { case "user defined gene": {
@@ -479,27 +394,36 @@ const Controls = (
User Events User Events
*******************************/ *******************************/
case "graph brush selection change": { case "graph brush selection change": {
state.dimensionMap[layoutDimensionName("X")].filterRange([ state.dimensionMap[layoutDimensionName("XY")].filterWithinRect(
action.brushCoords.northwest[0], action.brushCoords.northwest,
action.brushCoords.southeast[0] action.brushCoords.southeast
]); );
state.dimensionMap[layoutDimensionName("Y")].filterRange([
action.brushCoords.southeast[1],
action.brushCoords.northwest[1]
]);
return { return {
...state, ...state,
graphBrushSelection: action.brushCoords graphBrushSelection: action.brushCoords
}; };
} }
case "lasso deselect":
case "graph brush deselect": { case "graph brush deselect": {
state.dimensionMap[layoutDimensionName("X")].filterAll(); state.dimensionMap[layoutDimensionName("XY")].filterAll();
state.dimensionMap[layoutDimensionName("Y")].filterAll();
return { return {
...state, ...state,
graphBrushSelection: null graphBrushSelection: null
}; };
} }
case "lasso selection": {
const { polygon } = action;
const dXY = state.dimensionMap[layoutDimensionName("XY")];
if (polygon.length < 3) {
// single point or a line is not a polygon, and is therefore a deselect
dXY.filterAll();
} else {
dXY.filterWithinPolygon(polygon);
}
return {
...state
};
}
case "continuous metadata histogram brush": { case "continuous metadata histogram brush": {
const name = makeContinuousDimensionName( const name = makeContinuousDimensionName(
action.continuousNamespace, action.continuousNamespace,
@@ -552,7 +476,7 @@ const Controls = (
// update the filter to match all selected options // update the filter to match all selected options
const cat = newCategoricalSelectionState[action.metadataField]; const cat = newCategoricalSelectionState[action.metadataField];
state.dimensionMap[obsAnnoDimensionName(action.metadataField)].filterEnum( state.dimensionMap[obsAnnoDimensionName(action.metadataField)].filterEnum(
selectedValuesForCategory(cat) ControlsHelper.selectedValuesForCategory(cat)
); );
return { return {
@@ -576,7 +500,7 @@ const Controls = (
// update the filter to match all selected options // update the filter to match all selected options
const cat = newCategoricalSelectionState[action.metadataField]; const cat = newCategoricalSelectionState[action.metadataField];
state.dimensionMap[obsAnnoDimensionName(action.metadataField)].filterEnum( state.dimensionMap[obsAnnoDimensionName(action.metadataField)].filterEnum(
selectedValuesForCategory(cat) ControlsHelper.selectedValuesForCategory(cat)
); );
return { return {
+1 -1
View File
@@ -1,9 +1,9 @@
// jshint esversion: 6 // jshint esversion: 6
import { combineReducers, createStore, applyMiddleware } from "redux"; import { combineReducers, createStore, applyMiddleware } from "redux";
import thunk from "redux-thunk"; import thunk from "redux-thunk";
import { composeWithDevTools } from "redux-devtools-extension";
import updateURLMiddleware from "../middleware/updateURLMiddleware"; import updateURLMiddleware from "../middleware/updateURLMiddleware";
import updateCellColors from "../middleware/updateCellColors"; import updateCellColors from "../middleware/updateCellColors";
import { composeWithDevTools } from "redux-devtools-extension";
import config from "./config"; import config from "./config";
import differential from "./differential"; import differential from "./differential";
+1 -1
View File
@@ -35,7 +35,7 @@ const doFetch = async (url, acceptType) => {
Accept: acceptType Accept: acceptType
}) })
}); });
if (res.ok && res.headers.get("Content-Type") === acceptType) { if (res.ok && res.headers.get("Content-Type").includes(acceptType)) {
return res; return res;
} }
// else an error // else an error
+574
View File
@@ -0,0 +1,574 @@
import { IdentityInt32Index, isLabelIndex } from "./labelIndex";
// weird cross-dependency that we should clean up someday...
import { sort } from "../typedCrossfilter/sort";
import { isTypedArray, isArrayOrTypedArray, callOnceLazy } from "./util";
import { summarizeContinuous, summarizeCategorical } from "./summarize";
/*
Dataframe is an immutable 2D matrix similiar to Python Pandas Dataframe,
but (currently) without all of the surrounding support functions.
Data is stored in column-major layout, and each column is monomorphic.
It supports:
* Relatively efficient creation, cloning and subsetting
* Very efficient columnar access (eg, sum down a column), and access
to the underlying column arrays.
* Data access by row/col offset or label. Labels are reasonably well
optimized for both numeric lables and arbitrary (eg, sting) labels.
It does not currently support:
* Views on matrix subset - for currently known access patterns,
it is more effiicent to copy on subsetting, optimizing for access
speed over memory use.
* JS iterators - they are too slow. Use explicit iteration over
offest or labels.
Important assumptions embedded in the API:
* Columns are implicitly categorical if they are a JS Array and numeric
(aka continuous) if they are a TypedArray.
There are three index types for row/col indexing:
* IdentityInt32Index - noop index, where the index label is the offset.
* KeyIndex - index arbitrary JS objects.
* DenseInt32Index - integer indexing. Optimization over KeyIndex as it uses
Int32Array as a back-map to offsets. This means that the index array
must be sized to [minLabel, maxLabel), so this is only useful when the label
range is relatively close the underlying offset range [minOffset, maxOffset).
All private functions/methods/fields are prefixed by '__', eg, __compile().
Don't use them outside of this file.
Simple example:
// default indexing is integer offset.
const df = Dataframe.create([2,2], [['a', 'b'], [0, 1]])
console.log(df.at(0,0)); // outputs: a
console.log(df.col(1).asArray()); // outputs: [0, 1]
// KeyIndex
const df = new Dataframe([1,2], [['a'], ['b']], null, new KeyIndex(['A', 'B']))
console.log(df.at(0, 'A')); // outputs: a
console.log(df.col('A').asArray(); // outputs: ['a']
Performance tuning is primarily focused on columnar access patterns, which is the
dominant pattern in cellxgene.
*/
/**
Dataframe
**/
class Dataframe {
/**
Constructors & factories
**/
constructor(dims, columnarData, rowIndex = null, colIndex = null) {
/*
The base constructor is relatively hard to use - as an alternative,
see factory methods and clone/slice, below.
Parameters:
* dims - 2D array describing intendend dimensionality: [nRows,nCols].
* columnarData - JS array, nCols in length, containing array
or TypedArray of length nRows.
* rowIndex/colIndex - null (create default index using offsets as key),
or a caller-provided index.
All columns and indices must have appropriate dimensionality.
*/
const [nRows, nCols] = dims;
if (nRows < 0 || nCols < 0) {
throw new RangeError("Dataframe dimensions must be positive");
}
if (!rowIndex) {
rowIndex = new IdentityInt32Index(nRows);
}
if (!colIndex) {
colIndex = new IdentityInt32Index(nCols);
}
Dataframe.__errorChecks(dims, columnarData, rowIndex, colIndex);
this.__columns = Array.from(columnarData);
this.dims = dims;
this.length = nRows; // convenience accessor for row dimension
this.rowIndex = rowIndex;
this.colIndex = colIndex;
this.__compile();
}
static __errorChecks(dims, columnarData, rowIndex, colIndex) {
const [nRows, nCols] = dims;
/* check for expected types */
if (!Array.isArray(columnarData)) {
throw new TypeError("Dataframe constructor requires array of columns");
}
if (!columnarData.every(c => isArrayOrTypedArray(c))) {
throw new TypeError("Dataframe columns must all be Array or TypedArray");
}
if (!isLabelIndex(rowIndex)) {
throw new TypeError("Dataframe rowIndex is an unsupported type.");
}
if (!isLabelIndex(colIndex)) {
throw new TypeError("Dataframe colIndex is an unsupported type.");
}
/* check for expected dimensionality / size */
if (
nCols !== columnarData.length ||
!columnarData.every(c => c.length === nRows)
) {
throw new RangeError(
"Dataframe dimension does not match provided data shape"
);
}
if (nRows !== rowIndex.size()) {
throw new RangeError(
"Dataframe rowIndex must have same size as underlying data"
);
}
if (nCols !== colIndex.size()) {
throw new RangeError(
"Dataframe colIndex must have same size as underlying data"
);
}
}
__compile() {
/*
Compile data accessors for each column.
Each column accessor is a function which will lookup data by
index (ie, is equivalent to dataframe.get(row, col), where 'col'
is fixed.
In addition, each column accessor has several functions:
asArray() -- return the entire column as a native Array or TypedArray.
Crucially, this native array only supports label indexing.
Example:
const arr = df.col('a').asArray();
has(rlabel) -- return boolean indicating of the row label
is contained within the column. Example:
const isInColumn = df.col('a').includes(99)
For the default offset indexing, this is identical to:
const isInColumn = (99 > 0) && (99 < df.nRows);
ihas(roffset) -- same as has(), but accepts a row offset
instead of a row label.
indexOf(value) -- return the label (not offset) of the first instance of
'value' in the column. If you want the offset, just use the builtin JS
indexOf() function, available on both Array and TypedArray.
iget(offset) -- return the value at 'offset'
*/
const { getOffset, getLabel } = this.rowIndex;
this.__columnsAccessor = this.__columns.map(column => {
const { length } = column;
/* get value by row label */
const get = function get(rlabel) {
return column[getOffset(rlabel)];
};
/* get value by row offset */
const iget = function iget(roffset) {
return column[roffset];
};
/* full column array access */
const asArray = function asArray() {
return column;
};
/* test for row label inclusion in column */
const has = function has(rlabel) {
const offset = getOffset(rlabel);
return offset >= 0 && offset < length;
};
const ihas = function ihas(offset) {
return offset >= 0 && offset < length;
};
/*
return first label (index) at which the value is found in this column,
or undefined if not found.
NOTE: not found return is DIFFERENT than the default Array.indexOf as
-1 is a plausible Dataframe row/col label.
*/
const indexOf = function indexOf(value) {
const offset = column.indexOf(value);
if (offset === -1) {
return undefined;
}
return getLabel(offset);
};
/*
Summarize the column data. Lazy eval;
*/
const summarize = callOnceLazy(() =>
isTypedArray(column)
? summarizeContinuous(column)
: summarizeCategorical(column)
);
get.summarize = summarize;
get.asArray = asArray;
get.has = has;
get.ihas = ihas;
get.indexOf = indexOf;
get.iget = iget;
return get;
});
}
clone() {
/*
Clone this dataframe
*/
return new this.constructor(
this.dims,
[...this.__columns],
this.rowIndex,
this.colIndex
);
}
withCol(label, colData, withRowIndex = null) {
/*
Create a new DF, which is `this` plus the new column. Example:
const newDf = df.withCol("foo", [1,2,3]);
Dimensionality of new column must match existing dataframe.
Special case: empty dataframe will accept any size column. Example:
const newDf = Dataframe.empty().withCol("foo", [1,2,3]);
If `withRowIndex` specified, the provided index will become the
rowIndex for the newly created dataframe. If not specified,
the rowIndex from `this` will be used (ie, the rowIndex is
unchanged).
*/
let dims;
let rowIndex;
if (this.isEmpty()) {
dims = [colData.length, 1];
rowIndex = null;
} else {
dims = [this.dims[0], this.dims[1] + 1];
({ rowIndex } = this);
}
if (withRowIndex) {
rowIndex = withRowIndex;
}
const columns = [...this.__columns];
columns.push(colData);
const colIndex = this.colIndex.withLabel(label);
return new this.constructor(dims, columns, rowIndex, colIndex);
}
dropCol(label) {
/*
Create a new dataframe, omitting one columns.
const newDf = df.dropCol("colors");
*/
const dims = [this.dims[0], this.dims[1] - 1];
const coffset = this.colIndex.getOffset(label);
const columns = [...this.__columns];
columns.splice(coffset, 1);
const colIndex = this.colIndex.dropLabel(label);
return new this.constructor(dims, columns, this.rowIndex, colIndex);
}
static empty(rowIndex = null, colIndex = null) {
return new Dataframe([0, 0], [], rowIndex, colIndex);
}
static create(dims, columnarData) {
/*
Create a dataframe from raw columnar data. All column arrays
must have the same length. Identity indexing will be used.
Example:
const df = Dataframe.create([2,2], [new Uint32Array(2), new Float32Array(2)]);
*/
return new Dataframe(dims, columnarData, null, null);
}
__subset(rowOffsets, colOffsets, withRowIndex) {
const dims = [...this.dims];
const getSortedLabelAndOffsets = (offsets, index) => {
/*
Given offsets, return both offsets and associated lables,
sorted by offset.
*/
if (!offsets) {
return [null, null];
}
const sortedOffsets = sort(offsets);
const sortedLabels = new Array(sortedOffsets.length);
for (let i = 0, l = sortedOffsets.length; i < l; i += 1) {
sortedLabels[i] = index.getLabel(sortedOffsets[i]);
}
return [sortedLabels, sortedOffsets];
};
let { colIndex } = this;
if (colOffsets) {
let colLabels;
[colLabels, colOffsets] = getSortedLabelAndOffsets(
colOffsets,
this.colIndex
);
dims[1] = colOffsets.length;
colIndex = this.colIndex.subsetLabels(colLabels);
}
let { rowIndex } = this;
if (withRowIndex) rowIndex = withRowIndex;
if (rowOffsets) {
let rowLabels;
[rowLabels, rowOffsets] = getSortedLabelAndOffsets(
rowOffsets,
this.rowIndex
);
dims[0] = rowLabels.length;
if (!withRowIndex) rowIndex = this.rowIndex.subsetLabels(rowLabels);
}
/* subset columns */
let columns = this.__columns;
if (colOffsets) {
columns = new Array(colOffsets.length);
for (let i = 0, l = colOffsets.length; i < l; i += 1) {
columns[i] = this.__columns[colOffsets[i]];
}
}
/* subset rows */
if (rowOffsets) {
columns = columns.map(col => {
const newCol = new col.constructor(rowOffsets.length);
for (let i = 0, l = rowOffsets.length; i < l; i += 1) {
newCol[i] = col[rowOffsets[i]];
}
return newCol;
});
}
return new Dataframe(dims, columns, rowIndex, colIndex);
}
subset(rowLabels, colLabels = null, withRowIndex = null) {
/*
Subset by row/col labels.
withRowIndex allows assignment of new row index during subset operation.
If withRowIndex === null, it will reset the index to identity (offset)
indexing. if withRowIndex is a label index object, it will be used
for the new dataframe.
*/
const toOffsets = (labels, index) => {
if (!labels) {
return null;
}
return labels.map(label => {
const off = index.getOffset(label);
if (off === undefined) {
throw new RangeError(`unknown label: ${label}`);
}
return off;
});
};
const rowOffsets = toOffsets(rowLabels, this.rowIndex);
const colOffsets = toOffsets(colLabels, this.colIndex);
return this.__subset(rowOffsets, colOffsets, withRowIndex);
}
isubset(rowOffsets, colOffsets = null, withRowIndex = null) {
/*
Subset by row/col offset.
withRowIndex allows assignment of new row index during subset operation.
If withRowIndex === null, it will reset the index to identity (offset)
indexing. if withRowIndex is a label index object, it will be used
for the new dataframe.
*/
return this.__subset(rowOffsets, colOffsets, withRowIndex);
}
isubsetMask(rowMask, colMask = null, withRowIndex = null) {
/*
Subset on row/column based upon a truthy/falsey array (a mask).
withRowIndex allows assignment of new row index during subset operation.
If withRowIndex === null, it will reset the index to identity (offset)
indexing. if withRowIndex is a label index object, it will be used
for the new dataframe.
*/
const [nRows, nCols] = this.dims;
if (
(rowMask && rowMask.length !== nRows) ||
(colMask && colMask.length !== nCols)
) {
throw new RangeError("boolean arrays must match row/col dimensions");
}
/* convert masks to lists - method wastes space, but is fast */
const toList = (mask, maxSize) => {
if (!mask) {
return null;
}
const list = new Int32Array(maxSize);
let elems = 0;
for (let i = 0, l = mask.length; i < l; i += 1) {
if (mask[i]) {
list[elems] = i;
elems += 1;
}
}
return new Int32Array(list.buffer, 0, elems);
};
const rowOffsets = toList(rowMask, nRows);
const colOffsets = toList(colMask, nCols);
return this.__subset(rowOffsets, colOffsets, withRowIndex);
}
/**
Data access with row/col.
**/
col(columnLabel) {
/*
Return accessor bound to a column. Allows random row access
based upon the row indexing. Returns undefined if the
columnLabel is not present in the dataframe.
Example for a dataframe with string labeled columns, and
default (offset) indices for rows (eg, [0, 'foo'])
const getValue = df.col('foo');
for (let r = 0; r < df.nRows; r += 1) {
console.log(r, getValue(r));
}
See __compile() for the functions available in a column accessor.
*/
const coff = this.colIndex.getOffset(columnLabel);
return this.__columnsAccessor[coff];
}
icol(columnOffset) {
/*
Return column accessor by offset.
*/
return this.__columnsAccessor[columnOffset];
}
at(r, c) {
/*
Access a single value, for a row/col label pair.
For performance reasons, there are no bounds or existance
checks on labels, and no defined behavior when these are supplied.
May return undefined, throw an Error, or do something else for
non-existant labels. If you want predictable out-of-bounds
behavior, use has(), eg,
const myVal = df.has(r,l) ? df.at(r,l) : undefined;
*/
const coff = this.colIndex.getOffset(c);
const roff = this.rowIndex.getOffset(r);
return this.__columns[coff][roff];
}
iat(r, c) {
/*
Access a single value, for a row/col offset (integer) position.
For performance reasons, there are no bounds checks on row/col offsets
or other well-defined behavior for out-of-bounds values. If you want
well-defined bounds checking, use ihas(), eg,
const myVal = df.ihas(r, c) ? df.iat(r, c) : undefined;
*/
return this.__columns[c][r];
}
has(r, c) {
/*
Test if row/col labels exist in the dataframe - returns true/false
*/
const [nRows, nCols] = this.dims;
const coff = this.colIndex.getOffset(c);
const roff = this.rowIndex.getOffset(r);
return coff >= 0 && coff < nCols && roff >= 0 && roff < nRows;
}
ihas(r, c) {
/*
Test if row/col offset (integer) position exists in the
dataframe - returns true/false
*/
const [nRows, nCols] = this.dims;
return c >= 0 && c < nCols && r >= 0 && r < nRows;
}
hasCol(c) {
/*
Test if col label exists - return true/false
*/
return !!this.col(c);
}
isEmpty() {
/*
Return true if this is an empty dataframe, ie, has dimensions [0,0]
*/
const [rows, cols] = this.dims;
return rows === 0 && cols === 0;
}
/****
Functional (map/reduce/etc) data access
XXX: not yet implemented, as there is no clear use case. Can easily
add these as useful.
****/
/*
Map & reduce of column or row
XXX TODO remainder of map/reduce functions: mapCol, mapRow, reduceRow, ...
*/
/* comment out until we have a use for this
reduceCol(clabel, callback, initialValue) {
const coff = this.colIndex.getOffset(clabel);
const column = this.__columns[coff];
let start = 0;
let acc = initialValue;
if (initialValue === undefined) {
acc = column[0];
start = 1;
}
for (let i = start, l = column.length; i < l; i += 1) {
acc = callback(acc, column[i]);
}
return acc;
}
*/
}
export default Dataframe;
+2
View File
@@ -0,0 +1,2 @@
export { default as Dataframe } from "./dataframe";
export { DenseInt32Index, IdentityInt32Index, KeyIndex } from "./labelIndex";
+244
View File
@@ -0,0 +1,244 @@
/**
Label indexing - map a label to & from an integer offset. See Dataframe
for how this is used.
**/
/*
Private utility functions
*/
function extent(tarr) {
let min = 0x7fffffff;
let max = ~min; // eslint-disable-line no-bitwise
for (let i = 0, l = tarr.length; i < l; i += 1) {
const v = tarr[i];
if (v < min) {
min = v;
}
if (v > max) {
max = v;
}
}
return [min, max];
}
function fillRange(arr, start = 0) {
const larr = arr;
for (let i = 0, l = larr.length; i < l; i += 1) {
larr[i] = i + start;
}
return larr;
}
/* eslint-disable class-methods-use-this */
class IdentityInt32Index {
/*
identity/noop index, with small assumptions that labels are int32
*/
constructor(maxOffset) {
this.maxOffset = maxOffset;
}
keys() {
// memoize
const k = fillRange(new Int32Array(this.maxOffset));
this.keys = function keys() {
return k;
};
return k;
}
getOffset(i) {
// label to offset
return i;
}
getLabel(i) {
// offset to label
return i;
}
size() {
return this.maxOffset;
}
__promote(labelArray) {
/*
time/space decision - based on the resulting density
*/
const [minLabel, maxLabel] = extent(labelArray);
const labelSpaceSize = maxLabel - minLabel + 1;
const density = labelSpaceSize / this.maxOffset;
/* 0.1 is a magic number, that needs testing to optimize */
if (density < 0.1) {
return new KeyIndex(labelArray);
}
return new DenseInt32Index(labelArray, [minLabel, maxLabel]);
}
subsetLabels(labelArray) {
return this.__promote(labelArray);
}
withLabel(label) {
if (label === this.maxOffset) {
return new IdentityInt32Index(label + 1);
}
return this.__promote([...this.keys(), label]);
}
dropLabel(label) {
if (label === this.maxOffset - 1) {
return new IdentityInt32Index(label);
}
const labelArray = [...this.keys()];
labelArray.splice(labelArray.indexOf(label), 1);
return this.__promote(labelArray);
}
}
/* eslint-enable class-methods-use-this */
/* eslint-disable class-methods-use-this */
class DenseInt32Index {
/*
DenseInt32Index indexes integer labels, and uses Int32Array typed arrays
for both forward and reverse indexing. This means that the min/max range
of the forward index labels must be known a priori (so that the index
array can be pre-allocated).
*/
constructor(labels, labelRange = null) {
if (labels.constructor !== Int32Array) {
labels = new Int32Array(labels);
}
if (!labelRange) {
labelRange = extent(labels);
}
const [minLabel, maxLabel] = labelRange;
const labelSpaceSize = maxLabel - minLabel + 1;
const index = new Int32Array(labelSpaceSize).fill(-1);
for (let i = 0, l = labels.length; i < l; i += 1) {
const label = labels[i];
index[label - minLabel] = i;
}
this.minLabel = minLabel;
this.rindex = labels;
this.index = index;
this.__compile();
}
__compile() {
const { minLabel, index, rindex } = this;
this.getOffset = function getOffset(l) {
return index[l - minLabel];
};
this.getLabel = function getLabel(i) {
return rindex[i];
};
}
keys() {
return this.rindex;
}
size() {
return this.rindex.length;
}
__promote(labelArray) {
/*
time/space decision - if we are going to use less than 10% of the
dense index space, switch to a KeyIndex (which is slower, but uses
less memory for sparse label spaces).
*/
const [minLabel, maxLabel] = extent(labelArray);
const labelSpaceSize = maxLabel - minLabel + 1;
const density = labelSpaceSize / this.rindex.length;
/* 0.1 is a magic number, that needs testing to optimize */
if (density < 0.1) {
return new KeyIndex(labelArray);
}
return new DenseInt32Index(labelArray, [minLabel, maxLabel]);
}
subsetLabels(labelArray) {
return this.__promote(labelArray);
}
withLabel(label) {
return this.__promote([...this.keys(), label]);
}
dropLabel(label) {
const labelArray = [...this.keys()];
labelArray.splice(labelArray.indexOf(label), 1);
return this.__promote(labelArray);
}
}
/* eslint-enable class-methods-use-this */
/* eslint-disable class-methods-use-this */
class KeyIndex {
/*
KeyIndex indexes arbitrary JS primitive types, and uses a Map()
as its core data structure.
*/
constructor(labels) {
const index = new Map();
if (labels === undefined) {
labels = [];
}
const rindex = labels;
labels.forEach((v, i) => {
index.set(v, i);
});
this.index = index;
this.rindex = rindex;
this.__compile();
}
__compile() {
const { index, rindex } = this;
this.getOffset = function getOffset(k) {
return index.get(k);
};
this.getLabel = function getLabel(i) {
return rindex[i];
};
}
keys() {
return this.rindex;
}
size() {
return this.rindex.length;
}
subsetLabels(labelArray) {
return new KeyIndex(labelArray);
}
withLabel(label) {
return new KeyIndex([...this.rindex, label]);
}
dropLabel(label) {
const idx = this.rindex.indexOf(label);
const labelArray = [...this.rindex];
labelArray.splice(idx, 1);
return new KeyIndex(labelArray);
}
}
/* eslint-enable class-methods-use-this */
function isLabelIndex(i) {
return (
i instanceof IdentityInt32Index ||
i instanceof DenseInt32Index ||
i instanceof KeyIndex
);
}
export { DenseInt32Index, IdentityInt32Index, KeyIndex, isLabelIndex };
+57
View File
@@ -0,0 +1,57 @@
/*
Private dataframe support functions
*/
export function summarizeContinuous(col) {
let min;
let max;
let nan = 0;
let pinf = 0;
let ninf = 0;
if (col) {
for (let r = 0, l = col.length; r < l; r += 1) {
const val = Number(col[r]);
if (Number.isFinite(val)) {
if (min === undefined) {
min = val;
max = val;
} else {
min = val < min ? val : min;
max = val > max ? val : max;
}
} else if (Number.isNaN(val)) {
nan += 1;
} else if (val > 0) {
pinf += 1;
} else {
ninf += 1;
}
}
}
return {
categorical: false,
min,
max,
nan,
pinf,
ninf
};
}
export function summarizeCategorical(col) {
const categoryCounts = new Map();
if (col) {
for (let r = 0, l = col.length; r < l; r += 1) {
const val = col[r];
let curCount = categoryCounts.get(val);
if (curCount === undefined) curCount = 0;
categoryCounts.set(val, curCount + 1);
}
}
return {
categorical: true,
categories: [...categoryCounts.keys()],
categoryCounts,
numCategories: categoryCounts.size
};
}
+28
View File
@@ -0,0 +1,28 @@
/*
Private utility code for dataframe
*/
export function isTypedArray(x) {
return (
ArrayBuffer.isView(x) &&
Object.prototype.toString.call(x) !== "[object DataView]"
);
}
export function isArrayOrTypedArray(x) {
return Array.isArray(x) || isTypedArray(x);
}
export function callOnceLazy(f) {
let value;
let calledOnce = false;
const result = function result(...args) {
if (!calledOnce) {
value = f(...args);
calledOnce = true;
}
return value;
};
return result;
}
@@ -0,0 +1,164 @@
/*
Helper functions for the controls reducer
*/
import _ from "lodash";
import * as globals from "../../globals";
import { fillRange } from "../typedCrossfilter/util";
import {
userDefinedDimensionName,
diffexpDimensionName
} from "../nameCreators";
import * as World from "./world";
/*
Selection state for categoricals are tracked in an Object that
has two main components for each category:
1. mapping of option value to an index
2. array of bool selection state by index
Remember that option values can be ANY js type, except undefined/null.
{
_category_name_1: {
// map of option value to index
categoryIndices: Map([
catval1: index,
...
])
// index->selection true/false state
categorySelected: [ true/false, true/false, ... ]
// number of options
numCategories: number,
// isTruncated - true if the options for selection has
// been truncated (ie, was too large to implement)
}
}
*/
function topNCategories(summary) {
const counts = _.map(summary.categories, cat =>
summary.categoryCounts.get(cat)
);
const sortIndex = fillRange(new Array(summary.numCategories)).sort(
(a, b) => counts[b] - counts[a]
);
const sortedCategories = _.map(sortIndex, i => summary.categories[i]);
const sortedCounts = _.map(sortIndex, i => counts[i]);
const N = globals.maxCategoricalOptionsToDisplay;
if (sortedCategories.length < N) {
return [sortedCategories, sortedCounts];
}
return [sortedCategories.slice(0, N), sortedCounts.slice(0, N)];
}
export function createCategoricalSelectionState(state, world) {
const res = {};
_.forEach(world.obsAnnotations.colIndex.keys(), key => {
const summary = world.obsAnnotations.col(key).summarize();
if (summary.categories) {
const isColorField = key.includes("color") || key.includes("Color");
const isSelectableCategory =
!isColorField &&
key !== "name" &&
summary.categories.length < state.maxCategoryItems;
if (isSelectableCategory) {
const [categoryValues, categoryCounts] = topNCategories(summary);
const categoryIndices = new Map(categoryValues.map((v, i) => [v, i]));
const numCategories = categoryIndices.size;
const categorySelected = new Array(numCategories).fill(true);
const isTruncated = categoryValues.length < summary.numCategories;
res[key] = {
categoryValues, // array: of natively typed category values
categoryIndices, // map: category value (native type) -> category index
categorySelected, // array: t/f selection state
numCategories, // number: of categories
isTruncated, // bool: true if list was truncated
categoryCounts // array: cardinality of each category
};
}
}
});
return res;
}
/*
given a categoricalSelectionState, return the list of all category values
where selection state is true (ie, they are selected).
*/
export function selectedValuesForCategory(categorySelectionState) {
const selectedValues = _([...categorySelectionState.categoryIndices])
.filter(tuple => categorySelectionState.categorySelected[tuple[1]])
.map(tuple => tuple[0])
.value();
return selectedValues;
}
/*
build a crossfilter dimension map for all gene expression related dimensions.
*/
export function createGenesDimMap(
userDefinedGenes,
diffexpGenes,
world,
crossfilter
) {
function _createGenesDimMap(genes, nameCreator) {
return genes.reduce((acc, gene) => {
acc[nameCreator(gene)] = World.createVarDataDimension(
world,
crossfilter,
gene
);
return acc;
}, {});
}
return {
..._createGenesDimMap(userDefinedGenes, userDefinedDimensionName),
..._createGenesDimMap(diffexpGenes, diffexpDimensionName)
};
}
export function pruneVarDataCache(varData, needed) {
/*
Remove any unneeded columns from the varData dataframe. Will only
prune / remove if the total column count exceeds VarDataCacheLowWatermark
Note: this code leverages the fact that dataframe offsets indicate
the order in which the columns were added. This crudely provides
LRU semantics, so we can delete "older" columns first.
*/
/*
VarDataCacheLowWatermark - this cofig value sets the minimum cache size,
in columns, below which we don't throw away data.
The value should be high enough so we are caching the maximum which will
"typically" be used in the UI (currently: 10 for diffexp, and N for user-
specified genes), and low enough to account for memory use (any single
column size is 4 bytes * numObs, so a column can be multi-megabyte in common
use cases).
*/
const VarDataCacheLowWatermark = 32;
const numOverWatermark = varData.dims[1] - VarDataCacheLowWatermark;
if (numOverWatermark <= 0) return varData;
const { colIndex } = varData;
const all = colIndex.keys();
const unused = _.difference(all, needed);
if (unused.length > 0) {
// sort by offset in the dataframe - ie, psuedo-LRU
unused.sort((a, b) => colIndex.getOffset(a) - colIndex.getOffset(b));
const numToDrop =
unused.length < numOverWatermark ? unused.length : numOverWatermark;
for (let i = 0; i < numToDrop; i += 1) {
varData = varData.dropCol(unused[i]);
}
}
return varData;
}
+1 -1
View File
@@ -16,5 +16,5 @@ exists to support those concepts.
export * as Universe from "./universe"; export * as Universe from "./universe";
export * as World from "./world"; export * as World from "./world";
export * as kvCache from "./keyvalcache";
export * as WorldUtil from "./worldUtil"; export * as WorldUtil from "./worldUtil";
export * as ControlsHelper from "./controlsHelpers";
-122
View File
@@ -1,122 +0,0 @@
// jshint esversion: 6
import _ from "lodash";
/*
Very simple key/value cache for use by World & Universe. Cache keys must
be a string, and values are any JS non-primitive value.
* constructor(lowWatermark, minTTL):
- lowWatermark defines the number of cache elements below which
flushing will not occur.
- minTTL defines minimum time in milliseconds that cache entries will live.
A value of -1 disables automatic flushing (flush() can still
be called by external user).
* set() - add a key/val pair.
* get() - get a value or undefined if not present.
* flush(minAgeMs) - flush cache entries in excess of lowWatermark if those
entries are older than minAgeMs.
*/
const cachePrivateKey = "__kvcachekey__";
const defaultLowWatermark = 32;
const defaultMinTTL = 1000;
function create(lowWatermark = defaultLowWatermark, minTTL = defaultMinTTL) {
if (typeof minTTL !== "number" || typeof lowWatermark !== "number") {
throw new TypeError(
"minTTL and lowWatermark parameters must be a primitive number"
);
}
if (lowWatermark < 0 || minTTL < 0) {
throw new RangeError(
"minTTL and lowWatermark parameters must be number greater than zero"
);
}
return {
[cachePrivateKey]: {
lowWatermark,
minTTL
}
};
}
function get(kvcache, key) {
if (key === cachePrivateKey) {
throw new RangeError(`key parameter may not have value ${cachePrivateKey}`);
}
const val = kvcache[key];
if (val) {
val[cachePrivateKey] = Date.now();
}
return val;
}
function set(kvcache, key, val) {
if (key === cachePrivateKey) {
throw new RangeError(`key parameter may not have value ${cachePrivateKey}`);
}
const newKvCache = { ...kvcache };
newKvCache[key] = val;
val[cachePrivateKey] = Date.now();
flushInPlace(newKvCache);
return newKvCache;
}
function flush(kvcache) {
const newKvCache = { ...kvcache };
flushInPlace(newKvCache);
return newKvCache;
}
/*
Flush elements from cache IF cache size is greater than lowWatermark, and
those elements are older than minAgeMS
*/
function flushInPlace(kvCache) {
const { lowWatermark, minTTL } = kvCache[cachePrivateKey];
const eol = Date.now() - minTTL;
const allKeys = _(kvCache)
.keys()
.filter(k => k !== cachePrivateKey)
.sortBy([k => kvCache[k][cachePrivateKey]])
.value();
if (allKeys.length > lowWatermark) {
const keysToDelete = _(allKeys)
.slice(0, allKeys.length - lowWatermark)
.filter(k => kvCache[k][cachePrivateKey] <= eol)
.value();
_.forEach(keysToDelete, k => delete kvCache[k]);
}
return kvCache;
}
/*
use to create a cache that is a transformation of another cache.
*/
function map(srcKvCache, cb, createOptions) {
const keysInSrcKvCache = _(srcKvCache)
.keys()
.filter(k => k !== cachePrivateKey)
.value();
const lowWatermark = _.get(
createOptions,
"lowWatermark",
defaultLowWatermark
);
const minTTL = _.get(createOptions, "minTTL", defaultMinTTL);
const newKvCache = create(lowWatermark, minTTL);
_.forEach(keysInSrcKvCache, key => {
const val = cb(get(srcKvCache, key), key);
newKvCache[key] = val;
val[cachePrivateKey] = Date.now();
});
return newKvCache;
}
export { create, get, set, flush, map };
@@ -1,122 +0,0 @@
import _ from "lodash";
import finiteExtent from "../finiteExtent";
/*
Build and return obs/var summary using any annotation in the schema
Summary information for each annotation, keyed by annotation name.
Value will be an object, containing summary information.
For continuous annotations (int, float, etc):
<annotation_name>: {
categorical: false,
range {
min: <number>,
max: <number>
}
}
For categorical annotations (boolean, string, category):
<annotation_name>: {
categorical: true,
categories: [ <category1>, <category2>, ... ]
categoryCounts: Map {
<category1>: <number>,
...
},
numCategories: <number>
}
Summarize will be returned for BOTH obs and var annotations.
Example:
{
"Splice_sites_Annotated": {
categorical: false,
range: {
"min": 26,
"max": 1075869
}
},
"Selection": {
categorical: true,
numCategories, 3,
categories: [ "Astrocytes(HEPACAM)", "Endothelial(BSC)", "Unpanned" ],
categoryCounts: Map {
"Astrocytes(HEPACAM)": 714,
"Endothelial(BSC)": 123,
"Unpanned": 665
}
}
}
NOTE: will not summarize the required 'name' annotation, as that is
specified as unique per element.
*/
function _summarizeAnnotations(_schema, annotations) {
const summary = _(_schema) // lodash wrapping: https://lodash.com/docs/4.17.11#lodash
.filter(v => v.name !== "name")
.keyBy("name")
.mapValues(anno => {
const { name, type } = anno;
const continuous = type === "int32" || type === "float32";
if (continuous) {
let min;
let max;
let nan = 0;
let pinf = 0;
let ninf = 0;
for (let r = 0; r < annotations.length; r += 1) {
const val = Number(annotations[r][name]);
if (Number.isFinite(val)) {
if (min === undefined) {
min = val;
max = val;
} else {
min = val < min ? val : min;
max = val > max ? val : max;
}
} else if (Number.isNaN(val)) {
nan += 1;
} else if (val > 0) {
pinf += 1;
} else {
ninf += 1;
}
}
return {
categorical: false,
range: { min, max, nan, pinf, ninf }
};
}
/* else categorical */
const categoryCounts = new Map();
for (let r = 0; r < annotations.length; r += 1) {
const val = annotations[r][name];
let curCount = categoryCounts.get(val);
if (curCount === undefined) curCount = 0;
categoryCounts.set(val, curCount + 1);
}
return {
categorical: true,
categories: [...categoryCounts.keys()],
categoryCounts,
numCategories: categoryCounts.size
};
})
.value();
return summary;
}
export default function summarizeAnnotations(
schema,
obsAnnotations,
varAnnotations
) {
return {
obs: _summarizeAnnotations(schema.annotations.obs, obsAnnotations),
var: _summarizeAnnotations(schema.annotations.var, varAnnotations)
};
}
+39 -112
View File
@@ -2,24 +2,15 @@
import _ from "lodash"; import _ from "lodash";
import * as kvCache from "./keyvalcache";
import summarizeAnnotations from "./summarizeAnnotations";
import decodeMatrixFBS from "./matrix"; import decodeMatrixFBS from "./matrix";
import * as Dataframe from "../dataframe";
/* /*
Private helper function - create and return a template Universe Private helper function - create and return a template Universe
*/ */
function templateUniverse() { function templateUniverse() {
/* default universe template */ /* default universe template */
/* varDataCache config - see kvCache for semantics */
const VarDataCacheLowWatermark = 32; // cache element count
const VarDataCacheTTLMs = 1000; // min cache time in MS
return { return {
api: null,
finalized: false, // XXX: may not be needed
nObs: 0, nObs: 0,
nVar: 0, nVar: 0,
schema: {}, schema: {},
@@ -27,21 +18,14 @@ function templateUniverse() {
/* /*
Annotations Annotations
*/ */
obsAnnotations: [] /* all obs annotations, by obs index */, obsAnnotations: Dataframe.Dataframe.empty(),
varAnnotations: [] /* all var annotations, by var index */, varAnnotations: Dataframe.Dataframe.empty(),
obsNameToIndexMap: {} /* reverse map 'name' to index */, obsLayout: Dataframe.Dataframe.empty(),
varNameToIndexMap: {} /* reverse map 'name' to index */,
summary: null /* derived data summaries XXX: consider exploding in place */,
obsLayout: { X: [], Y: [] } /* xy layout */,
/* /*
Cache of var data (expression), by var annotation name. Data can be Var data columns - subset of all
accesses as a POJO, but if you want caching semantics, use the kvCache
API (eg., kvCache.get(), kvCache.set(), ...), which will maintain the
LRU semantics.
*/ */
varDataCache: kvCache.create(VarDataCacheLowWatermark, VarDataCacheTTLMs) varData: Dataframe.Dataframe.empty(null, new Dataframe.KeyIndex())
}; };
} }
@@ -53,81 +37,29 @@ These functions are used exclusively by the actions and reducers to
build an internal POJO for use by the rendering components. build an internal POJO for use by the rendering components.
*/ */
/* function AnnotationsFBSToDataframe(arrayBuffer) {
generate any client-side transformations or summarization that
is independent of REST API response formats.
*/
function finalize(universe) {
/* A bit of sanity checking! */
const { nObs, nVar } = universe;
if (
nObs !== universe.obsAnnotations.length ||
nObs !== universe.obsLayout.X.length ||
nObs !== universe.obsLayout.Y.length ||
nVar !== universe.varAnnotations.length
) {
throw new Error("Universe dimensionality mismatch - failed to load");
}
// TODO: add more sanity checks, such as:
// - all annotations in the schema
// - layout has supported number of dimensions
// - ...
/* /*
Create all derived (convenience) data structures. Convert a Matrix FBS to a Dataframe.
*/
universe.obsNameToIndexMap = _.transform(
universe.obsAnnotations,
(acc, value, idx) => {
acc[value.name] = idx;
},
{}
);
universe.varNameToIndexMap = _.transform(
universe.varAnnotations,
(acc, value, idx) => {
acc[value.name] = idx;
},
{}
);
universe.finalized = true;
return universe;
}
function RESTv02AnotationsFBSResponseToInternal(arrayBuffer) {
/*
Convert a Matrix FBS to our internal format -- row-major array of
observations/cells, stored as an object. Each obs has a key for each
annotation, plus __index__ containing its obsIndex.
Example:
[
{ __index__: 0, tissue_type: "lung", sex: "F", ... },
...
]
XXX TODO: we could make use of the columns in building crossfilter
dimensions (they have to be recreated). Future optimization.
*/ */
const fbs = decodeMatrixFBS(arrayBuffer); const fbs = decodeMatrixFBS(arrayBuffer);
const keys = fbs.colIdx; const df = new Dataframe.Dataframe(
const result = Array(fbs.nRows); [fbs.nRows, fbs.nCols],
for (let row = 0; row < fbs.nRows; row += 1) { fbs.columns,
const rec = { __index__: row }; null,
for (let col = 0; col < fbs.nCols; col += 1) { new Dataframe.KeyIndex(fbs.colIdx)
rec[keys[col]] = fbs.columns[col][row]; );
} return df;
result[row] = rec;
}
return result;
} }
function RESTv02LayoutFBSResponseToInternal(arrayBuffer) { function LayoutFBSToDataframe(arrayBuffer) {
const fbs = decodeMatrixFBS(arrayBuffer, true); const fbs = decodeMatrixFBS(arrayBuffer, true);
return { const df = new Dataframe.Dataframe(
X: fbs.columns[0], [fbs.nRows, fbs.nCols],
Y: fbs.columns[1] fbs.columns,
}; null,
new Dataframe.KeyIndex(["X", "Y"])
);
return df;
} }
function reconcileSchemaCategoriesWithSummary(universe) { function reconcileSchemaCategoriesWithSummary(universe) {
@@ -149,14 +81,14 @@ function reconcileSchemaCategoriesWithSummary(universe) {
) { ) {
const categories = _.union( const categories = _.union(
_.get(s, "categories", []), _.get(s, "categories", []),
_.get(universe.summary.obs[s.name], "categories", []) _.get(universe.obsAnnotations.col(s.name).summarize(), "categories", [])
); );
s.categories = categories; s.categories = categories;
} }
}); });
} }
export function createUniverseFromRestV02Response( export function createUniverseFromResponse(
configResponse, configResponse,
schemaResponse, schemaResponse,
annotationsObsResponse, annotationsObsResponse,
@@ -169,33 +101,28 @@ export function createUniverseFromRestV02Response(
const { schema } = schemaResponse; const { schema } = schemaResponse;
const universe = templateUniverse(); const universe = templateUniverse();
/* constants */
universe.api = "0.2";
/* schema related */ /* schema related */
universe.schema = schema; universe.schema = schema;
universe.nObs = schema.dataframe.nObs; universe.nObs = schema.dataframe.nObs;
universe.nVar = schema.dataframe.nVar; universe.nVar = schema.dataframe.nVar;
/* annotations */ /* annotations */
universe.obsAnnotations = RESTv02AnotationsFBSResponseToInternal( universe.obsAnnotations = AnnotationsFBSToDataframe(annotationsObsResponse);
annotationsObsResponse universe.varAnnotations = AnnotationsFBSToDataframe(annotationsVarResponse);
);
universe.varAnnotations = RESTv02AnotationsFBSResponseToInternal(
annotationsVarResponse
);
/* layout */ /* layout */
universe.obsLayout = RESTv02LayoutFBSResponseToInternal(layoutFBSResponse); universe.obsLayout = LayoutFBSToDataframe(layoutFBSResponse);
universe.summary = summarizeAnnotations( /* sanity check */
universe.schema, if (
universe.obsAnnotations, universe.nObs !== universe.obsLayout.length ||
universe.varAnnotations universe.nObs !== universe.obsAnnotations.length ||
); universe.nVar !== universe.varAnnotations.length
) {
throw new Error("Universe dimensionality mismatch - failed to load");
}
reconcileSchemaCategoriesWithSummary(universe); reconcileSchemaCategoriesWithSummary(universe);
return finalize(universe); return universe;
} }
export function convertDataFBStoObject(universe, arrayBuffer) { export function convertDataFBStoObject(universe, arrayBuffer) {
@@ -214,8 +141,8 @@ export function convertDataFBStoObject(universe, arrayBuffer) {
const result = {}; const result = {};
for (let c = 0; c < colIdx.length; c += 1) { for (let c = 0; c < colIdx.length; c += 1) {
const gene = universe.varAnnotations[colIdx[c]].name; const varName = universe.varAnnotations.at(colIdx[c], "name");
result[gene] = columns[c]; result[varName] = columns[c];
} }
return result; return result;
} }
+80 -143
View File
@@ -1,12 +1,12 @@
// jshint esversion: 6 // jshint esversion: 6
import _ from "lodash"; import _ from "lodash";
import * as kvCache from "./keyvalcache";
import summarizeAnnotations from "./summarizeAnnotations";
import { layoutDimensionName, obsAnnoDimensionName } from "../nameCreators"; import { layoutDimensionName, obsAnnoDimensionName } from "../nameCreators";
import { sliceByIndex } from "../typedCrossfilter/util"; import Crossfilter from "../typedCrossfilter";
import * as Dataframe from "../dataframe";
/* /*
World is a subset of universe. Most code should use world, and should World is a subset of universe. Most code should use world, and should
(generally) not use Universe. World contains any per-obs or per-var data (generally) not use Universe. World contains any per-obs or per-var data
that must be consistent acorss the app when we view/manipulate subsets that must be consistent acorss the app when we view/manipulate subsets
@@ -15,120 +15,76 @@ of Universe.
Private API indicated by leading underscore in key name (eg, _foo). Anything else Private API indicated by leading underscore in key name (eg, _foo). Anything else
is public. is public.
World contains several public keys, obsAnnotations, and obsLayout, which are Notable keys in the world object:
arrays contianing information about an OBS in the same order/offset. In
other words, world.obsAnnotations[0] and world.obsLayout.X[0] refer to the same * nObs, nVar: dimensions
obs/cell.
* schema: data schema from the server
* obsAnnotations: * obsAnnotations:
obsAnnotations will return an array of objects. Each object contains all annotation Dataframe containing obs annotations. Columns are indexed by annotation
values for a given observation/cell, keyed by annotation name, PLUS a key name (eg, 'tissue type'), and rows are indexed by the REST API obsIndex
'__cellId__', containing a REST API ID for this obs/cell (referred to as the (ie, the offset into the underlying server-side dataframe).
obsIndex in the REST 0.2 spec or cellIndex in the 0.1 spec.
Example: [ { __cellId__: 99, cluster: 'blue', numReads: 93933 } ] This indexing means that you can access data by _either_ the server's
obxIndex, or the offset into the client-side column array . Be careful
NOTE: world.obsAnnotation should be identical to the old state.cells value, to know which you want and are using.
EXCEPT that
* __cellIndex__ renamed to __index__
* __x__ and __y__ are now in world.obsLayout
* __color__ and __colorRBG__ should be moved to controls reducer
* obsLayout: * obsLayout:
obsLayout will return an object containing two arrays, containing X and Y A dataframe containing the X/Y layout for all obs. Columns are named
coordinates respectively. 'X' and 'Y', and rows are indexed in the same way as obsAnnotation.
Example: { X: [ 0.33, 0.23, ... ], Y: [ 0.8, 0.777, ... ]} * varData: a cache of expression columns, stored in a Dataframe. Cache
managed by controls reducer.
* crossfilter - a crossfilter object across world.obsAnnotations
* dimensionMap - an object mapping annotation names to dimensions on
the crossfilter
*/ */
/* varDataCache config - see kvCache for semantics */
const VarDataCacheLowWatermark = 32; // cache element count
const VarDataCacheTTLMs = 1000; // min cache time in MS
function templateWorld() { function templateWorld() {
return { return {
// map from universe obsIndex to world offset.
// Undefined / null indicates identity mapping.
obsIndex: null,
obsBackIndex: null,
/* schema/version related */ /* schema/version related */
api: null,
schema: null, schema: null,
nObs: 0, nObs: 0,
nVar: 0, nVar: 0,
/* annotations */ /* annotations */
obsAnnotations: null, obsAnnotations: Dataframe.Dataframe.empty(),
varAnnotations: null, varAnnotations: Dataframe.Dataframe.empty(),
/* layout of graph */ /* layout of graph. Dataframe. */
obsLayout: null, obsLayout: Dataframe.Dataframe.empty(),
/* derived data summaries XXX: consider exploding in place */ /*
summary: null, Var data columns - subset of all data (may be empty)
*/
varDataCache: kvCache.create( varData: Dataframe.Dataframe.empty(null, new Dataframe.KeyIndex())
VarDataCacheLowWatermark,
VarDataCacheTTLMs
) /* cache of var data (expression) */
}; };
} }
export function createWorldFromEntireUniverse(universe) { export function createWorldFromEntireUniverse(universe) {
if (!universe.finalized) {
throw new Error("World can't be created from an partial Universe");
}
const world = templateWorld(); const world = templateWorld();
// map from the universe obsIndex to our world offset.
// undefined/null indicates identity map.
// In other words obsBackIndex[universeIdx] -> worldIdx
world.obsBackIndex = null;
// Map to the universe index for each element in world.
// Null indicates identity map (aka world === universe)
// In other wrods obsIndex[worldIdx] -> universeIdx
world.obsIndex = null;
/* /*
public interface follows public interface follows
*/ */
/* Schema related */ /* Schema related */
world.api = universe.api;
world.schema = universe.schema; world.schema = universe.schema;
world.nObs = universe.nObs; world.nObs = universe.nObs;
world.nVar = universe.nVar; world.nVar = universe.nVar;
/* annotations */ /* annotation dataframes */
world.obsAnnotations = universe.obsAnnotations; world.obsAnnotations = universe.obsAnnotations;
world.varAnnotations = universe.varAnnotations; world.varAnnotations = universe.varAnnotations;
/* layout and display characteristics */ /* layout and display characteristics dataframe */
world.obsLayout = universe.obsLayout; world.obsLayout = universe.obsLayout;
/* derived data & summaries */ /*
world.summary = summarizeAnnotations( Var data columns - subset of all
world.schema, */
world.obsAnnotations, world.varData = universe.varData.clone();
world.varAnnotations
);
/* build the varDataCache */
world.varDataCache = kvCache.map(
universe.varDataCache,
val => subsetVarData(world, universe, val),
{ lowWatermark: VarDataCacheLowWatermark, minTTL: VarDataCacheTTLMs }
);
return world; return world;
} }
@@ -137,54 +93,24 @@ export function createWorldFromCurrentSelection(universe, world, crossfilter) {
const newWorld = templateWorld(); const newWorld = templateWorld();
/* these don't change as only OBS are selected in our current implementation */ /* these don't change as only OBS are selected in our current implementation */
newWorld.api = universe.api;
newWorld.nVar = universe.nVar; newWorld.nVar = universe.nVar;
newWorld.schema = universe.schema; newWorld.schema = universe.schema;
newWorld.varAnnotations = universe.varAnnotations; newWorld.varAnnotations = universe.varAnnotations;
/* build index maps and back maps based upon current selection state */ /* now subset/cut obs */
const obsBackIndex = new Uint32Array(universe.nObs); const mask = crossfilter.allFilteredMask();
obsBackIndex.fill(-1); // default - aka unused newWorld.obsAnnotations = world.obsAnnotations.isubsetMask(mask);
const notSelected = obsBackIndex[0]; newWorld.obsLayout = world.obsLayout.isubsetMask(mask);
let nObs = 0; newWorld.nObs = newWorld.obsAnnotations.dims[0];
for (let i = 0; i < universe.nObs; i += 1) {
if (crossfilter.isElementFiltered(i)) { /*
obsBackIndex[i] = nObs; Var data columns - subset of all
nObs += 1; */
} if (world.varData.isEmpty()) {
newWorld.varData = world.varData.clone();
} else {
newWorld.varData = world.varData.isubsetMask(mask);
} }
const obsIndex = new Uint32Array(nObs);
for (let i = 0; i < universe.nObs; i += 1) {
const worldIdx = obsBackIndex[i];
if (worldIdx !== notSelected) {
obsIndex[worldIdx] = i;
}
}
newWorld.nObs = nObs;
newWorld.obsIndex = obsIndex;
newWorld.obsBackIndex = obsBackIndex;
/* now slice */
newWorld.obsAnnotations = sliceByIndex(universe.obsAnnotations, obsIndex);
newWorld.obsLayout = {
X: sliceByIndex(universe.obsLayout.X, obsIndex),
Y: sliceByIndex(universe.obsLayout.Y, obsIndex)
};
/* derived data & summaries */
newWorld.summary = summarizeAnnotations(
newWorld.schema,
newWorld.obsAnnotations,
newWorld.varAnnotations
);
/* build the varDataCache */
newWorld.varDataCache = kvCache.map(
universe.varDataCache,
val => subsetVarData(newWorld, universe, val),
{ lowWatermark: VarDataCacheLowWatermark, minTTL: VarDataCacheTTLMs }
);
return newWorld; return newWorld;
} }
@@ -224,14 +150,12 @@ function deduceDimensionType(attributes, fieldName) {
when it is no longer needed when it is no longer needed
(it will not be garbage collected without this call) (it will not be garbage collected without this call)
*/ */
export function createVarDataDimension(world, crossfilter, name) {
export function createVarDimension( return crossfilter.dimension(
world, Crossfilter.ScalarDimension,
_worldVarDataCache, world.varData.col(name).asArray(),
crossfilter, Float32Array
geneName );
) {
return crossfilter.dimension(_worldVarDataCache[geneName], Float32Array);
} }
export function createObsDimensionMap(crossfilter, world) { export function createObsDimensionMap(crossfilter, world) {
@@ -239,17 +163,23 @@ export function createObsDimensionMap(crossfilter, world) {
create and return a crossfilter dimension for every obs annotation create and return a crossfilter dimension for every obs annotation
for which we have a supported type. for which we have a supported type.
*/ */
const { schema, obsLayout } = world; const { schema, obsLayout, obsAnnotations } = world;
// Create a crossfilter dimension for all obs annotations *except* 'name' // Create a crossfilter dimension for all obs annotations *except* 'name'
const dimensionMap = _(schema.annotations.obs) const dimensionMap = _(schema.annotations.obs)
.filter(anno => anno.name !== "name") .filter(anno => anno.name !== "name")
.transform((result, anno) => { .transform((result, anno) => {
const dimType = deduceDimensionType(anno, anno.name); const dimType = deduceDimensionType(anno, anno.name);
// XXX if dimtype is a scalar, we may be able to do better? const colData = obsAnnotations.col(anno.name).asArray();
if (dimType) { if (dimType === "enum") {
result[obsAnnoDimensionName(anno.name)] = crossfilter.dimension( result[obsAnnoDimensionName(anno.name)] = crossfilter.dimension(
r => r[anno.name], Crossfilter.EnumDimension,
colData
);
} else if (dimType) {
result[obsAnnoDimensionName(anno.name)] = crossfilter.dimension(
Crossfilter.ScalarDimension,
colData,
dimType dimType
); );
} // else ignore the annotation } // else ignore the annotation
@@ -259,13 +189,10 @@ export function createObsDimensionMap(crossfilter, world) {
/* /*
Add crossfilter dimensions allowing filtering on layout Add crossfilter dimensions allowing filtering on layout
*/ */
dimensionMap[layoutDimensionName("X")] = crossfilter.dimension( dimensionMap[layoutDimensionName("XY")] = crossfilter.dimension(
obsLayout.X, Crossfilter.SpatialDimension,
Float32Array obsLayout.col("X").asArray(),
); obsLayout.col("Y").asArray()
dimensionMap[layoutDimensionName("Y")] = crossfilter.dimension(
obsLayout.Y,
Float32Array
); );
return dimensionMap; return dimensionMap;
@@ -275,10 +202,20 @@ export function worldEqUniverse(world, universe) {
return world.obsAnnotations === universe.obsAnnotations; return world.obsAnnotations === universe.obsAnnotations;
} }
export function subsetVarData(world, universe, varData) { export function getSelectedByIndex(crossfilter) {
// If world === universe, just return the entire varData array /*
if (worldEqUniverse(world, universe)) { return array of obsIndex, containing all selected obs/cells.
return varData; */
const selected = crossfilter.allFilteredMask(); // array of bool-ish
const keys = crossfilter.data.rowIndex.keys(); // row keys, aka universe rowIndex
const set = new Int32Array(selected.length);
let numElems = 0;
for (let i = 0, l = selected.length; i < l; i += 1) {
if (selected[i]) {
set[numElems] = keys[i];
numElems += 1;
}
} }
return sliceByIndex(varData, world.obsIndex); return new Int32Array(set.buffer, 0, numElems);
} }
+15 -5
View File
@@ -18,13 +18,23 @@ Map {
... ...
} }
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, rows) { function _countCategoryValues2D(dim1, dim2, df) {
const dimMap = new Map(); const dimMap = new Map();
for (let r = 0; r < rows.length; r += 1) { const col1 = df.col(dim1) ? df.col(dim1).asArray() : null;
const row = rows[r]; const col2 = df.col(dim2) ? df.col(dim2).asArray() : null;
const val1 = row[dim1]; if (!col1 || !col2) {
const val2 = row[dim2]; 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); let d2Map = dimMap.get(val1);
if (d2Map === undefined) { if (d2Map === undefined) {
d2Map = new Map(); d2Map = new Map();
+28 -15
View File
@@ -40,7 +40,7 @@ class BitArray {
// Return the number of records that are selected, ie, have a one bit in // Return the number of records that are selected, ie, have a one bit in
// all allocated dimensions. // all allocated dimensions.
// //
get selectionCount() { selectionCount() {
return this.countAllOnes(); return this.countAllOnes();
} }
@@ -48,16 +48,27 @@ class BitArray {
// //
countAllOnes() { countAllOnes() {
let count = 0; let count = 0;
const { bitarray, bitmask, length, width } = this; const { bitarray, length, width } = this;
for (let l = 0; l < length; l += 1) { if (width === 1) {
let dimensionsSet = 0; // special case, width === 1, for performance
for (let w = 0; w < width; w += 1) { const bitmask = this.bitmask[0];
if (bitarray[w * length + l] === bitmask[w]) { for (let l = 0; l < length; l += 1) {
dimensionsSet += 1; if (bitarray[l] === bitmask) {
count += 1;
} }
} }
if (dimensionsSet === width) { } else {
count += 1; const { bitmask } = this;
for (let l = 0; l < length; l += 1) {
let dimensionsSet = 0;
for (let w = 0; w < width; w += 1) {
if (bitarray[w * length + l] === bitmask[w]) {
dimensionsSet += 1;
}
}
if (dimensionsSet === width) {
count += 1;
}
} }
} }
return count; return count;
@@ -233,12 +244,14 @@ class BitArray {
fillBySelection(result, selectedValue, deselectedValue) { fillBySelection(result, selectedValue, deselectedValue) {
// special case (width === 1) for performance // special case (width === 1) for performance
if (this.width === 1) { if (this.width === 1) {
const bitmask = this.bitmask[0]; const { bitmask, bitarray } = this;
for (let i = 0, len = this.length; i < len; i += 1) { const mask = bitmask[0];
result[i] = if (!mask) {
bitmask && this.bitarray[i] === bitmask result.fill(deselectedValue);
? selectedValue } else {
: deselectedValue; for (let i = 0, len = this.length; i < len; i += 1) {
result[i] = bitarray[i] === mask ? selectedValue : deselectedValue;
}
} }
} else { } else {
for (let i = 0, len = this.length; i < len; i += 1) { for (let i = 0, len = this.length; i < len; i += 1) {
+214 -38
View File
@@ -27,6 +27,8 @@ more complex API. In a few cases, elements of that API were incorporated.
https://github.com/square/crossfilter/ https://github.com/square/crossfilter/
*/ */
// XXX replace
import { polygonContains } from "d3";
import PositiveIntervals from "./positiveIntervals"; import PositiveIntervals from "./positiveIntervals";
import BitArray from "./bitArray"; import BitArray from "./bitArray";
@@ -37,6 +39,14 @@ import {
upperBoundIndirect upperBoundIndirect
} from "./util"; } from "./util";
function isArrayOrTypedArray(x) {
return (
Array.isArray(x) ||
(ArrayBuffer.isView(x) &&
Object.prototype.toString.call(x) !== "[object DataView]")
);
}
class NotImplementedError extends Error { class NotImplementedError extends Error {
constructor(...params) { constructor(...params) {
super(...params); super(...params);
@@ -50,6 +60,11 @@ class NotImplementedError extends Error {
class TypedCrossfilter { class TypedCrossfilter {
constructor(data) { constructor(data) {
/*
Typically, data is one of:
- Array of objects/records
- Dataframe (util/dataframe)
*/
this.data = data; this.data = data;
// filters: array of { id, dimension } // filters: array of { id, dimension }
@@ -66,14 +81,22 @@ class TypedCrossfilter {
return this.data; return this.data;
} }
dimension(value, valueArrayType) { /*
Create a crossfilter dimension, upon which filtering (subselection) can
be done. Each dimension is typed, and has a particular set of filtering
semantics.
* ScalarDimension - backed by TypedArray values, supporting filtering
by value (within a value range, or one or more exact values)
* EnumDimension - backed by an enumeration (eg, strings, bools), filtering
by one or more enum categories.
* SpatialDimension - backed by 2D points, filter by containment within
various shapes (currently supports within Rectangle and within Polygon).
Call this method to create a dimension, passing arguments appropriate for
the dimension constructor.
*/
dimension(DimensionType, ...rest) {
const id = this.selection.allocDimension(); const id = this.selection.allocDimension();
let dim; const dim = new DimensionType(this, id, ...rest);
if (valueArrayType === "enum") {
dim = new EnumDimension(value, this, id);
} else {
dim = new ScalarDimension(value, valueArrayType, this, id);
}
this.filters.push({ id, dim }); this.filters.push({ id, dim });
dim.filterAll(); dim.filterAll();
return dim; return dim;
@@ -87,18 +110,32 @@ class TypedCrossfilter {
// return array of all records that are selected/filtered // return array of all records that are selected/filtered
// by all dimensions. // by all dimensions.
allFiltered() { allFiltered() {
const { selection } = this; const { data, selection } = this;
const res = []; if (Array.isArray(data)) {
for (let i = 0, len = this.data.length; i < len; i += 1) { const res = [];
if (selection.isSelected(i)) { for (let i = 0, len = data.length; i < len; i += 1) {
res.push(this.data[i]); if (selection.isSelected(i)) {
res.push(data[i]);
}
} }
return res;
} }
return res; /* else, Dataframe-like */
return data.isubsetMask(this.allFilteredMask());
}
// return Uint8array containing selection state (truthy/falsey) for each record.
//
allFilteredMask() {
return this.selection.fillBySelection(
new Uint8Array(this.data.length),
1,
0
);
} }
countFiltered() { countFiltered() {
return this.selection.selectionCount; return this.selection.selectionCount();
} }
isElementFiltered(i) { isElementFiltered(i) {
@@ -115,13 +152,34 @@ class TypedCrossfilter {
} }
} }
// Base dimension type - value must be a scalar type (eg, int, float), // Base dimension type - not exported.
// and value array must be a TypedArray. class _Dimension {
// constructor(xfltr, id) {
class ScalarDimension {
constructor(value, ValueArrayType, xfltr, id) {
this.crossfilter = xfltr; this.crossfilter = xfltr;
this._id = id; this._id = id;
this.groups = [];
}
dispose() {
this.crossfilter._freeDimension(this._id);
return this;
}
id() {
return this._id;
}
_filterUpdate() {
this.crossfilter.updateTime += 1;
}
}
// Scalar dimension type - value must be a scalar type (eg, int, float),
// and value array must be a TypedArray.
//
class ScalarDimension extends _Dimension {
constructor(xfltr, id, value, ValueArrayType) {
super(xfltr, id);
// current selection filter, expressed as PostiveIntervals. // current selection filter, expressed as PostiveIntervals.
this.currentFilter = []; this.currentFilter = [];
@@ -130,6 +188,7 @@ class ScalarDimension {
// or a map function which will create it. // or a map function which will create it.
let array; let array;
if (value instanceof ValueArrayType) { if (value instanceof ValueArrayType) {
// user has provided the final typed array - just use it
if (value.length !== this.crossfilter.data.length) { if (value.length !== this.crossfilter.data.length) {
throw new RangeError( throw new RangeError(
"ScalarDimension values length must equal crossfilter data record count" "ScalarDimension values length must equal crossfilter data record count"
@@ -137,11 +196,18 @@ class ScalarDimension {
} }
array = value; array = value;
} else if (value instanceof Function) { } else if (value instanceof Function) {
// Create value array // Create value array from user-provided map function.
array = this._createValueArray( array = this._createValueArray(
value, value,
new ValueArrayType(this.crossfilter.data.length) new ValueArrayType(this.crossfilter.data.length)
); );
} else if (isArrayOrTypedArray(value)) {
// Create value array from user-provided array. Typically used
// only by enumerated dimensions
array = this._createValueArray(
i => value[i],
new ValueArrayType(this.crossfilter.data.length)
);
} else { } else {
throw new NotImplementedError( throw new NotImplementedError(
"dimension value must be function or value array type" "dimension value must be function or value array type"
@@ -151,9 +217,6 @@ class ScalarDimension {
// create sort index // create sort index
this.index = makeSortIndex(array); this.index = makeSortIndex(array);
// groups, if any
this.groups = [];
} }
_createValueArray(value, array) { _createValueArray(value, array) {
@@ -162,20 +225,11 @@ class ScalarDimension {
const len = data.length; const len = data.length;
const larray = array; const larray = array;
for (let i = 0; i < len; i += 1) { for (let i = 0; i < len; i += 1) {
larray[i] = value(data[i]); larray[i] = value(i, data);
} }
return larray; return larray;
} }
dispose() {
this.crossfilter._freeDimension(this._id);
return this;
}
id() {
return this._id;
}
// Argument is an array of intervals indicating records newly selected/filtered // Argument is an array of intervals indicating records newly selected/filtered
// //
_updateFilters(newFilter) { _updateFilters(newFilter) {
@@ -209,7 +263,7 @@ class ScalarDimension {
); );
this.currentFilter = cNewFilter; this.currentFilter = cNewFilter;
this.crossfilter.updateTime += 1; this._filterUpdate();
} }
// filter by value - exact match // filter by value - exact match
@@ -355,8 +409,8 @@ class ScalarDimension {
// strings, which can be mapped into an fixed numeric range [0..n). // strings, which can be mapped into an fixed numeric range [0..n).
// //
class EnumDimension extends ScalarDimension { class EnumDimension extends ScalarDimension {
constructor(value, xfltr, id) { constructor(xfltr, id, value) {
super(value, Uint32Array, xfltr, id); super(xfltr, id, value, Uint32Array);
} }
_createValueArray(value, array) { _createValueArray(value, array) {
@@ -368,7 +422,7 @@ class EnumDimension extends ScalarDimension {
// and the enum. // and the enum.
const s = new Set(); const s = new Set();
for (let i = 0; i < len; i += 1) { for (let i = 0; i < len; i += 1) {
s.add(value(data[i])); s.add(value(i, data));
} }
this.enumIndex = Array.from(s); this.enumIndex = Array.from(s);
this.enumIndex.sort(); this.enumIndex.sort();
@@ -376,7 +430,7 @@ class EnumDimension extends ScalarDimension {
// create dimension value array // create dimension value array
const enumLen = this.enumIndex.length; const enumLen = this.enumIndex.length;
for (let i = 0; i < len; i += 1) { for (let i = 0; i < len; i += 1) {
const v = value(data[i]); const v = value(i, data);
const e = lowerBound(this.enumIndex, v, 0, enumLen); const e = lowerBound(this.enumIndex, v, 0, enumLen);
larray[i] = e; larray[i] = e;
} }
@@ -408,6 +462,127 @@ class EnumDimension extends ScalarDimension {
} }
} }
/*
Super simple 2D spatial dimension, supporting basic "filter within"
operations.
*/
class SpatialDimension extends _Dimension {
constructor(xfltr, id, X, Y) {
super(xfltr, id);
if (X.length !== Y.length && X.length !== this.crossfilter.data.length) {
throw new RangeError(
"SpatialDimension values must have same dimensionality as crossfilter"
);
}
this.X = X;
this.Y = Y;
this.Xindex = makeSortIndex(X);
this.Yindex = makeSortIndex(Y);
}
filterAll() {
this.crossfilter.selection.selectAll(this._id);
this._filterUpdate();
}
filterNone() {
this.crossfilter.selection.deselectAll(this._id);
this._filterUpdate();
}
/*
this could be smarter, but we don't currently use it...
*/
filterWithinRect(northwest, southeast) {
const [x0, y0] = northwest;
const [x1, y1] = southeast;
const { X, Y } = this;
const seln = this.crossfilter.selection;
const { _id } = this;
seln.deselectAll(_id);
for (let i = 0, l = this.X.length; i < l; i += 1) {
const x = X[i];
const y = Y[i];
if (x0 <= x && x < x1 && y0 <= y && y < y1) {
seln.selectOne(_id, i);
}
}
this._filterUpdate();
}
/*
Relatively brute force filter by polygon. Polygon is array of points, where
each point is [x,y]. Eg, [[x0,y0], [x1,y1], ...].
Currently uses d3.polygonContains() to test for polygon inclusion, which itself
uses a ray casting (crossing number) algorithm. There are a series of optimizations
to make this faster:
* first sliced by X or Y, using an index on the axis
* then the polygon bounding box is used for trivial rejection
* then the polygon test is applied
*/
filterWithinPolygon(polygon) {
/* return bounding box of the polygon */
function polygonBoundingBox(pg) {
let minX = Number.MAX_VALUE;
let minY = Number.MAX_VALUE;
let maxX = Number.MIN_VALUE;
let maxY = Number.MIN_VALUE;
for (let i = 0, l = pg.length; i < l; i += 1) {
const p = pg[i];
const x = p[0];
const y = p[1];
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
}
return [minX, minY, maxX, maxY];
}
const [minX, minY, maxX, maxY] = polygonBoundingBox(polygon);
const { X, Y } = this;
let slice;
let index;
if (maxY - minY > maxX - minX) {
slice = [
lowerBoundIndirect(X, this.Xindex, minX, 0, X.length),
upperBoundIndirect(X, this.Xindex, maxX, 0, X.length)
];
index = this.Xindex;
} else {
slice = [
lowerBoundIndirect(Y, this.Yindex, minY, 0, Y.length),
upperBoundIndirect(Y, this.Yindex, maxY, 0, Y.length)
];
index = this.Yindex;
}
const seln = this.crossfilter.selection;
const { _id } = this;
const testWithin = polygonContains; // d3.polygonContains()
seln.deselectAll(_id);
for (let i = slice[0], e = slice[1]; i < e; i += 1) {
const rid = index[i];
const x = X[rid];
const y = Y[rid];
if (
minX <= x &&
x < maxX &&
minY <= y &&
y < maxY &&
testWithin(polygon, [x, y])
) {
seln.selectOne(_id, rid);
}
}
this._filterUpdate();
}
}
// Groups! Map/reduce // Groups! Map/reduce
// //
class ScalarGroup { class ScalarGroup {
@@ -611,5 +786,6 @@ crossfilter.BitArray = BitArray;
crossfilter.TypedCrossfilter = TypedCrossfilter; crossfilter.TypedCrossfilter = TypedCrossfilter;
crossfilter.ScalarDimension = ScalarDimension; crossfilter.ScalarDimension = ScalarDimension;
crossfilter.EnumDimension = EnumDimension; crossfilter.EnumDimension = EnumDimension;
crossfilter.SpatialDimension = SpatialDimension;
export default crossfilter; export default crossfilter;
+5 -1
View File
@@ -1,6 +1,10 @@
# cellxgene REST API 0.2 specification # cellxgene REST API 0.2 specification
_Note:_ this document lacks any information about the binary encoding utilized by various routes. This will be added at a later date. _Note:_ this document is increasingly divergent from the code base and should be read with great suspicion. For example, it lacks any information
about the binary encoding used by various routes, and has incorrect information about "required" routes and features. We may update it at a
later date when the protocol stabilizes a bit.
---
Items marked as (_future_) are intended for future implementation, and are included in the design to round out the concept, and highlight what we would do when/if we needed more functionality. The (_future_) items are not currently used by the cellxgene web application, and may be omitted from any backend - see [Current Front-End Dependencies](#current-front-end-dependencies) for more details. Items marked as (_future_) are intended for future implementation, and are included in the design to round out the concept, and highlight what we would do when/if we needed more functionality. The (_future_) items are not currently used by the cellxgene web application, and may be omitted from any backend - see [Current Front-End Dependencies](#current-front-end-dependencies) for more details.
+19 -15
View File
@@ -20,18 +20,14 @@ Follow these steps to create a release.
1. Preparation: 1. Preparation:
- python3.6 environment, and a cellxgene clone - python3.6 environment, and a cellxgene clone
- install required tools: `pip install -r requirements-dev.txt`
- Define the release version number, using [semantic versioning](https://semver.org/), - Define the release version number, using [semantic versioning](https://semver.org/),
and specifying all three digits (eg, 0.3.0) and specifying all three digits (eg, 0.3.0)
- Write the release title and release notes and add to - Write the release title and release notes and add to
[release notes document](https://docs.google.com/document/d/1KnHwkYfhyWO5H8BDcMu7y3ogjvq5Yi4OwpmZ8DB6w0Y/edit) [release notes document](https://docs.google.com/document/d/1KnHwkYfhyWO5H8BDcMu7y3ogjvq5Yi4OwpmZ8DB6w0Y/edit)
2. Create a release branch, eg, `release-version` 2. Create a release branch, eg, `release-version`
3. In the release branch: 3. In the release branch:
- Run `bumpversion --config-file .bumpversion.cfg [major | minor | patch]`, - Run `make release-stage-1 PART=[major | minor | patch]` where you choose major/minor/patch depending on which part of the version
where you choose major/minor/patch depending on which part of the version
is being bumped (eg, 0.2.9->0.3 is minor). is being bumped (eg, 0.2.9->0.3 is minor).
- Clean up existing environment using `bin/clean`
- Build the JS asserts using `bin/build-client`
4. Commit and push the new branch 4. Commit and push the new branch
5. Create a PR for the release. 5. Create a PR for the release.
- [optional] As needed, conduct PR review. - [optional] As needed, conduct PR review.
@@ -44,18 +40,26 @@ Follow these steps to create a release.
- Type title `Release {version num}` - Type title `Release {version num}`
- [optional] Check pre-release if this release is not ready for production - [optional] Check pre-release if this release is not ready for production
- Publish Release - Publish Release
8. Publish to pypi by performing the following steps (assumes you have `setuptools` 8. Publish to pypi by performing the following steps (assumes you that you have registered for pypi,
and `twine` installed, that you have registered for pypi, and that you have and that you have write access to the cellxgene pypi package):
write access to the cellxgene pypi package): - Build the distribution and upload to test pypi `make release-stage-2`
- Build the distribution by calling `python setup.py sdist` - [optional] Test the test installation in a fresh virtual environment using `make install-release-test`
inside the top-level directory - Upload the package to real pypi using `make release-stage-final`
- [optional] Upload the package to test pypi
`twine upload --repository-url https://test.pypi.org/legacy/ dist/*`
- [optional] Test the test installation in a fresh virtual environment using
`pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple cellxgene`
- Upload the package to real pypi using `twine upload dist/*`
- [optional] Test the installation in a fresh virtual environment using - [optional] Test the installation in a fresh virtual environment using
`pip install cellxgene` `pip install cellxgene`
- **Troubleshooting**:
- Fails to upload to test.pypi: pypi doesn't allow you to reupload a release with the same version number,
if you accidentally burned a release number you want to use on prod, you have a couple options.
1) OPTION 1: Create distribution `make pydist`; test release locally `pip install dist/<release tarball>`;
then upload to prod `make release-stage-final`.
2) OPTION 2: (DANGER) release directly to prod: `make release-burned`.
3) OPTION 3: If the release was burned on prod as well run from Step 3 again with option
PART=patch until you get to an unburned version.
- The release doesn't install or fails your tests when you install it: Delete it from pypi - Go to pypi.org, sign in,
go to the cellxgene package, click manage, then in the options drop down, click delete and
follow the instructions. You will not be able to use that release number again. If it is a minor bug
and not a major regression, you can just release a patch.
The optional steps are for testing purposes, and are recommended The optional steps are for testing purposes, and are recommended
for publishing any major releases, and any releases that significantly for publishing any major releases, and any releases that significantly
@@ -0,0 +1,69 @@
### How to set up a testing environment for changes related to web hosting.
We often get PRs related to someone using a server to host cellxgene externally or on a local network (ex. https://github.com/chanzuckerberg/cellxgene/pull/568 ). Here is how you can test these changes locally.
We are going to run docker containers for cellxgene and an apache server running a reverse proxy on a local docker network. We run the cellxgene container without exposing any ports so that we cannot access it directly, only through the apache server. We can also update our cellxgene Dockerfile so that we can install a local build instead of having to deploy to pypi.
1 Create Docker network, this allows the containers to communicate with each other.
```
docker network create cxg
```
2 Create and run cellxgene container
(optional) To install cellxgene from the local codebase
a Create sdist file
`make pydist`
b Update Dockerfile to install from dist
```
FROM ubuntu:bionic
ENV LC_ALL=C.UTF-8
ENV LANG=C.UTF-8
COPY [ "dist/", "/cellxgene/dist/" ]
RUN apt-get update && \
apt-get install -y build-essential libxml2-dev python3-dev python3-pip zlib1g-dev && \
pip3 install /cellxgene/dist/cellxgene-0.5.1.tar.gz
ENTRYPOINT ["cellxgene"]
```
(required) Build container
`docker build . -t cellxgene`
3 Create the proxy container
In a separate directory create these two files
Dockerfile
```
FROM rgoyard/apache-proxy:latest
ADD proxy.conf /conf/
```
proxy.conf
```
ProxyPass "/data/" http://cellxgene:5005/
ProxyPassReverse "/data/" http://cellxgene:5005/
```
Build the container
`docker build -t proxy .`
4 Run containers and attach to network
```
docker run -d -p 80:80 --network cxg --name proxy proxy
docker run -v "$PWD/example-dataset/:/data/" --name cellxgene --network cxg cellxgene launch --host 0.0.0.0 data/pbmc3k.h5ad
```
5 Go to served site
http://localhost/data/
+1 -2
View File
@@ -1,9 +1,8 @@
theme: jekyll-theme-cayman theme: jekyll-theme-cayman
show_downloads: false show_downloads: false
baseurl: /cellxgene
nav: nav:
- title: Home
url: /
- title: Data - title: Data
url: data.html url: data.html
- title: FAQ - title: FAQ
+1
View File
@@ -25,6 +25,7 @@
<h1 class="project-name">{{ site.title | default: site.github.repository_name }}</h1> <h1 class="project-name">{{ site.title | default: site.github.repository_name }}</h1>
<h2 class="project-tagline">{{ site.description | default: site.github.project_tagline }}</h2> <h2 class="project-tagline">{{ site.description | default: site.github.project_tagline }}</h2>
{% if site.nav %} {% if site.nav %}
<a href="{{ site.baseurl }}/" class="btn">Home</a>
{% for item in site.nav %} {% for item in site.nav %}
<a href="{{ item.url }}" class="btn">{{ item.title }}</a> <a href="{{ item.url }}" class="btn">{{ item.title }}</a>
{% endfor %} {% endfor %}
+4
View File
@@ -0,0 +1,4 @@
---
---
@import "{{ site.theme }}";
+3 -3
View File
@@ -14,14 +14,14 @@ description: Data
### Examination of single cells from primary human pancreas tissue ### Examination of single cells from primary human pancreas tissue
cells: 2,544 cells: 2,544
tissue(s): pancreas tissue(s): pancreas
data: [GEO Series GSE81547](https://www.ncbi.nlm.nih.gov/geo/query/acc.cgi?acc=GSE81547) data: [Human Cell Atlas Data Portal](https://prod.data.humancellatlas.org/explore/projects?filter=%5B%7B%22facetName%22%3A%22organ%22%2C%22terms%22%3A%5B%22pancreas%22%5D%7D%2C%7B%22facetName%22%3A%22project%22%2C%22terms%22%3A%5B%22Single+cell+transcriptome+analysis+of+human+pancreas%22%5D%7D%5D)
paper: [Enge, Martin, et al.](https://www.cell.com/cell/fulltext/S0092-8674(17)31053-X?_returnURL=https%3A%2F%2Flinkinghub.elsevier.com%2Fretrieve%2Fpii%2FS009286741731053X%3Fshowall%3Dtrue) paper: [Enge, Martin, et al.](https://www.cell.com/cell/fulltext/S0092-8674(17)31053-X?_returnURL=https%3A%2F%2Flinkinghub.elsevier.com%2Fretrieve%2Fpii%2FS009286741731053X%3Fshowall%3Dtrue)
### Tabula Muris ### Tabula Muris
cells: 53,800 cells: 53,800
tissue(s): muscle, pancreas, bone, large intestine, heart, brain, fat, mammary gland, tongue , diaphragm, bladder, spleen, thymus, lung , skin, liver, trachea, kidney tissue(s): muscle, pancreas, bone, large intestine, heart, brain, fat, mammary gland, tongue , diaphragm, bladder, spleen, thymus, lung , skin, liver, trachea, kidney
data: [Tabula Muris Data for Python](https://github.com/czbiohub/tabula-muris-vignettes/tree/master/data) data: [Tabula Muris Data](https://github.com/czbiohub/tabula-muris-vignettes/tree/master/data)
paper: [Tabula Muris Consortium.](https://www.nature.com/articles/s41586-018-0590-4) paper: [Tabula Muris Consortium](https://www.nature.com/articles/s41586-018-0590-4)
### Transcriptional profiling of 1.3 million brain cells ### Transcriptional profiling of 1.3 million brain cells
cells: 1,330,000 cells: 1,330,000
+125
View File
@@ -0,0 +1,125 @@
BUILDDIR := build
CLIENTBUILD := $(BUILDDIR)/client
SERVERBUILD := $(BUILDDIR)/server
CLEANFILES := $(BUILDDIR)/ client/build dist cellxgene.egg-info
PART ?= patch
# BUILDING PACKAGE
build : clean build-server
@echo "done"
build-server : build-client
mkdir -p $(SERVERBUILD)
cp -r server/* $(SERVERBUILD)
cp -r client/build/ $(CLIENTBUILD)
mkdir -p $(SERVERBUILD)/app/web/static/img
mkdir -p $(SERVERBUILD)/app/web/templates/
cp $(CLIENTBUILD)/index.html $(SERVERBUILD)/app/web/templates/
cp -r $(CLIENTBUILD)/static $(SERVERBUILD)/app/web/
cp $(CLIENTBUILD)/favicon.png $(SERVERBUILD)/app/web/static/img
cp $(CLIENTBUILD)/service-worker.js $(SERVERBUILD)/app/web/static/js/
cp MANIFEST.in README.md setup.cfg setup.py $(BUILDDIR)
build-client :
npm install --prefix client/ client
npm run --prefix client build
# If you are actively developing in the server folder use this, dirties the source tree
build-for-server-dev : clean-server build-client
mkdir -p server/app/web/static/img
mkdir -p server/app/web/static/js
mkdir -p server/app/web/templates/
cp client/build/index.html server/app/web/templates/
cp -r client/build/static server/app/web/
cp client/build/favicon.png server/app/web/static/img
cp client/build/service-worker.js server/app/web/static/js/
clean : clean-lite clean-server
rm -rf client/node_modules
# cleaning node_modules is the longest one, so we avoid that if possible
clean-lite :
rm -rf $(CLEANFILES)
clean-server :
rm -f server/app/web/templates/index.html
rm -rf server/app/web/static
.PHONY : build build-server build-client build-for-server-dev clean clean-lite clean-server
# CREATING DISTRIBUTION RELEASE
pydist : build
cd $(BUILDDIR); python setup.py sdist -d ../dist
@echo "done"
.PHONY : pydist
# RELEASE HELPERS
# create new version to commit to master
release-stage-1 : dev-env bump clean-lite gen-package-lock
@echo "Version bumped part:$(PART) and client built. Ready to commit and push"
# build dist and release to dev pypi
release-stage-2 : dev-env pydist twine
@echo "Dist built and uploaded to test.pypi.org"
@echo "Test the install `make install-release-test` and then upload to Pypi prod"
@echo "`make twine-prod`"
release-stage-final: twine-prod
@echo "Release uploaded to pypi.org"
# DANGER: releases directly to prod
# use this if you accidently burned a test release version number,
release-burned : dev-env pydist twine-prod
@echo "Dist built and uploaded to pypi.org"
@echo "Test the install `make install-release`"
dev-env :
pip install -r server/requirements-dev.txt
# give PART=[major, minor, part] as param to make bump
bump :
bumpversion --config-file .bumpversion.cfg $(PART)
twine :
twine upload --repository-url https://test.pypi.org/legacy/ dist/*
twine-prod :
twine upload dist/*
# quicker than re-building client
gen-package-lock :
npm install --prefix client/ client
.PHONY : release-stage-1 release-stage-2 release-stage-final release-burned dev-env bump twine twine-prod gen-package-lock
# INSTALL
# setup.py sucks when you have your library in a separate folder, adding these in to help setup envs
# install from build directory
install : uninstall
cd $(BUILDDIR); pip install -e .
# install from source tree for development
install-dev : uninstall
pip install -e .
# install from test.pypi to test your release
install-release-test : uninstall
pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple cellxgene
@echo "Installed cellxgene from test.pypi.org, now run and smoke test"
# install from pypi to test your release
install-release : uninstall
pip install cellxgene
@echo "Installed cellxgene from pypi.org"
uninstall :
yes | pip uninstall cellxgene || true
.PHONY : install install-dev install-release-test install-release uninstall
-15
View File
@@ -4,7 +4,6 @@ from flask import Flask
from flask_caching import Cache from flask_caching import Cache
from flask_compress import Compress from flask_compress import Compress
from flask_cors import CORS from flask_cors import CORS
from flask_restful_swagger_2 import get_swagger_blueprint
from .rest_api.rest import get_api_resources from .rest_api.rest import get_api_resources
from .util.utils import Float32JSONEncoder from .util.utils import Float32JSONEncoder
@@ -26,21 +25,7 @@ app.config.update(SECRET_KEY=SECRET_KEY)
# Application Data # Application Data
data = None data = None
# A list of swagger document objects
docs = []
resources = get_api_resources() resources = get_api_resources()
docs.append(resources.get_swagger_doc())
app.register_blueprint(webapp.bp) app.register_blueprint(webapp.bp)
app.register_blueprint(resources.blueprint) app.register_blueprint(resources.blueprint)
app.register_blueprint(
get_swagger_blueprint(
docs,
"/api/swagger",
produces=["application/json"],
title="cellxgene rest api",
description="An API connecting ExpressionMatrix2 clustering algorithm to cellxgene",
)
)
app.add_url_rule("/", endpoint="index") app.add_url_rule("/", endpoint="index")
+2 -45
View File
@@ -42,45 +42,12 @@ class CXGDriver(metaclass=ABCMeta):
pass pass
@abstractmethod @abstractmethod
def filter_dataframe(self, filter): def annotation_to_fbs_matrix(self, axis, field=None):
"""
Filter cells from data and return a subset of the data. They can operate on both obs and var dimension with
indexing and filtering by annotation value. Filters are combined with the and operator.
See REST specs for info on filter format:
https://github.com/chanzuckerberg/cellxgene/blob/master/docs/REST_API.md
:param filter: dictionary with filter params
:return: View into scanpy object with cells/genes filtered
"""
pass
@abstractmethod
def annotation(self, filter, axis, fields=None):
""" """
Gets annotation value for each observation Gets annotation value for each observation
:param filter: filter: dictionary with filter params
:param axis: string obs or var :param axis: string obs or var
:param fields: list of keys for annotation to return, returns all annotation values if not set. :param fields: list of keys for annotation to return, returns all annotation values if not set.
:return: dict: names - list of fields in order, data - list of lists or metadata :return: flatbuffer: in fbs/matrix.fbs encoding
[observation ids, val1, val2...]
"""
pass
@abstractmethod
def annotation_to_fbs_matrix(self, axis, field=None):
""" Same as annotation(), except returns a flatbuffer, and does not support filtering. """
pass
@abstractmethod
def data_frame(self, filter, axis):
"""
Retrieves data for each variable for observations in data frame
:param filter: filter: dictionary with filter params
:param axis: string obs or var
:return: {
"var": list of variable ids,
"obs": [cellid, var1 expression, var2 expression, ...],
}
""" """
pass pass
@@ -104,16 +71,6 @@ class CXGDriver(metaclass=ABCMeta):
""" """
pass pass
@abstractmethod
def layout(self, filter, interactive_limit=None):
"""
Computes a n-d layout for cells through dimensionality reduction.
:param filter: filter: dictionary with filter params
:param interactive_limit: -- don't compute if total # genes in dataframes are larger than this
:return: [cellid, x, y, ...]
"""
pass
@abstractmethod @abstractmethod
def layout_to_fbs_matrix(self, filter): def layout_to_fbs_matrix(self, filter):
""" same as layout, except returns a flatbuffer """ """ same as layout, except returns a flatbuffer """
+13 -674
View File
@@ -3,22 +3,17 @@ import pkg_resources
import warnings import warnings
from flask import Blueprint, current_app, jsonify, make_response, request from flask import Blueprint, current_app, jsonify, make_response, request
from flask_restful_swagger_2 import Api, swagger, Resource from flask_restful import Api, Resource
from werkzeug.datastructures import ImmutableMultiDict
from server.app.util.constants import ( from server.app.util.constants import (
Axis, Axis,
DiffExpMode, DiffExpMode,
JSON_NaN_to_num_warning_msg, JSON_NaN_to_num_warning_msg,
) )
from server.app.util.filter import parse_filter, QueryStringError
from server.app.util.models import FilterModel
from server.app.util.utils import get_mime_type
from server.app.util.errors import ( from server.app.util.errors import (
FilterError, FilterError,
InteractiveError, InteractiveError,
JSONEncodingValueError, JSONEncodingValueError,
MimeTypeError,
PrepareError, PrepareError,
) )
@@ -31,47 +26,6 @@ Sort order for routes
class SchemaAPI(Resource): class SchemaAPI(Resource):
@swagger.doc(
{
"summary": "get schema for dataframe and annotations",
"tags": ["initialize"],
"parameters": [],
"responses": {
"200": {
"description": "schema",
"examples": {
"application/json": {
"schema": {
"dataframe": {
"nObs": 383,
"nVar": 19944,
"type": "float32",
},
"annotations": {
"obs": [
{"name": "name", "type": "string"},
{"name": "tissue_type", "type": "string"},
{"name": "num_reads", "type": "int32"},
{"name": "sample_name", "type": "string"},
{
"name": "clusters",
"type": "categorical",
"categories": [99, 1, "unknown cluster"],
},
{"name": "QScore", "type": "float32"},
],
"var": [
{"name": "name", "type": "string"},
{"name": "gene", "type": "string"},
],
},
}
}
},
}
},
}
)
def get(self): def get(self):
return make_response( return make_response(
jsonify({"schema": current_app.data.schema}), HTTPStatus.OK jsonify({"schema": current_app.data.schema}), HTTPStatus.OK
@@ -79,47 +33,6 @@ class SchemaAPI(Resource):
class ConfigAPI(Resource): class ConfigAPI(Resource):
@swagger.doc(
{
"summary": "Configuration information to assist in front-end adaptation"
" to underlying engine, available functionality, interactive time limits, etc",
"tags": ["initialize"],
"parameters": [],
"responses": {
"200": {
"description": "schema",
"examples": {
"application/json": {
"config": {
"features": [
{
"method": "POST",
"path": "/cluster/",
"available": False,
},
{
"method": "POST",
"path": "/layout/obs",
"available": True,
"interactiveLimit": 10000,
},
{
"method": "POST",
"path": "/layout/var",
"available": False,
},
],
"displayNames": {
"engine": "ScanPy version 1.33",
"dataset": "/home/joe/mouse/blorth.csv",
},
}
}
},
}
},
}
)
def get(self): def get(self):
config = { config = {
"config": { "config": {
@@ -158,51 +71,13 @@ class ConfigAPI(Resource):
class AnnotationsObsAPI(Resource): class AnnotationsObsAPI(Resource):
@swagger.doc(
{
"summary": "Fetch annotations (metadata) for all observations.",
"tags": ["annotations"],
"parameters": [
{
"in": "query",
"name": "annotation-name",
"type": "string",
"description": "list of 1 or more annotation names",
}
],
"responses": {
"200": {
"description": "annotations",
"examples": {
"application/json": {
"names": ["tissue_type", "sex", "num_reads", "clusters"],
"data": [
[0, "lung", "F", 39844, 99],
[1, "heart", "M", 83, 1],
[49, "spleen", None, 2, "unknown cluster"],
],
}
},
},
"400": {
"description": "one or more of the annotation-name identifiers were not associated with an "
"annotation name"
},
},
}
)
def get(self): def get(self):
fields = request.args.getlist("annotation-name", None) fields = request.args.getlist("annotation-name", None)
preferred_mimetype = request.accept_mimetypes.best_match( preferred_mimetype = request.accept_mimetypes.best_match(
["application/json", "application/octet-stream"], ["application/octet-stream"]
"application/json"
) )
try: try:
if preferred_mimetype == "application/json": if preferred_mimetype == "application/octet-stream":
return make_response(
current_app.data.annotation({}, "obs", fields), HTTPStatus.OK, {"Content-Type": "application/json"}
)
elif preferred_mimetype == "application/octet-stream":
return make_response(current_app.data.annotation_to_fbs_matrix("obs", fields), return make_response(current_app.data.annotation_to_fbs_matrix("obs", fields),
HTTPStatus.OK, HTTPStatus.OK,
{"Content-Type": "application/octet-stream"}) {"Content-Type": "application/octet-stream"})
@@ -210,119 +85,18 @@ class AnnotationsObsAPI(Resource):
return make_response(f"Unsupported MIME type '{request.accept_mimetypes}'", HTTPStatus.NOT_ACCEPTABLE) return make_response(f"Unsupported MIME type '{request.accept_mimetypes}'", HTTPStatus.NOT_ACCEPTABLE)
except KeyError: except KeyError:
return make_response(f"Error bad key in {fields}", HTTPStatus.BAD_REQUEST) return make_response(f"Error bad key in {fields}", HTTPStatus.BAD_REQUEST)
except JSONEncodingValueError as e:
# JSON encoding failure, usually due to bad data
warnings.warn(JSON_NaN_to_num_warning_msg)
return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR)
except ValueError as e:
return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR)
@swagger.doc(
{
"summary": "Fetch annotations (metadata) for filtered subset of observations.",
"tags": ["annotations"],
"parameters": [
{
"in": "query",
"name": "annotation-name",
"type": "string",
"description": "list of 1 or more annotation names",
},
{
"name": "filter",
"description": "Complex Filter",
"in": "body",
"schema": FilterModel,
},
],
"responses": {
"200": {
"description": "annotations",
"examples": {
"application/json": {
"names": ["tissue_type", "sex", "num_reads", "clusters"],
"data": [
[0, "lung", "F", 39844, 99],
[1, "heart", "M", 83, 1],
[49, "spleen", None, 2, "unknown cluster"],
],
}
},
},
"400": {
"description": "malformed filter or one or more of the annotation-name identifiers were"
"not associated with an annotation name"
},
},
}
)
def put(self):
fields = request.args.getlist("annotation-name", None)
try:
annotation_response = current_app.data.annotation(
request.get_json()["filter"], "obs", fields
)
return make_response(
annotation_response, HTTPStatus.OK, {"Content-Type": "application/json"}
)
except KeyError:
return make_response(f"Error bad key in {fields}", HTTPStatus.BAD_REQUEST)
except FilterError as e:
return make_response(e.message, HTTPStatus.BAD_REQUEST)
except JSONEncodingValueError as e:
# JSON encoding failure, usually due to bad data
warnings.warn(JSON_NaN_to_num_warning_msg)
return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR)
except ValueError as e: except ValueError as e:
return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR) return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR)
class AnnotationsVarAPI(Resource): class AnnotationsVarAPI(Resource):
@swagger.doc(
{
"summary": "Fetch annotations (metadata) for all variables.",
"tags": ["annotations"],
"parameters": [
{
"in": "query",
"name": "annotation-name",
"type": "string",
"description": "list of 1 or more annotation names",
}
],
"responses": {
"200": {
"description": "annotations",
"examples": {
"application/json": {
"names": ["name", "category"],
"data": [
[0, "ATAD3C", 1],
[1, "RER1", None],
[49, "S100B", 6],
],
}
},
},
"400": {
"description": "one or more of the annotation-name identifiers were not associated with an"
" annotation name"
},
},
}
)
def get(self): def get(self):
fields = request.args.getlist("annotation-name", None) fields = request.args.getlist("annotation-name", None)
preferred_mimetype = request.accept_mimetypes.best_match( preferred_mimetype = request.accept_mimetypes.best_match(
["application/json", "application/octet-stream"], ["application/octet-stream"]
"application/json"
) )
try: try:
if preferred_mimetype == "application/json": if preferred_mimetype == "application/octet-stream":
return make_response(current_app.data.annotation({}, "var", fields),
HTTPStatus.OK,
{"Content-Type": "application/json"})
elif preferred_mimetype == "application/octet-stream":
return make_response(current_app.data.annotation_to_fbs_matrix("var", fields), return make_response(current_app.data.annotation_to_fbs_matrix("var", fields),
HTTPStatus.OK, HTTPStatus.OK,
{"Content-Type": "application/octet-stream"}) {"Content-Type": "application/octet-stream"})
@@ -330,317 +104,22 @@ class AnnotationsVarAPI(Resource):
return make_response(f"Unsupported MIME type '{request.accept_mimetypes}'", HTTPStatus.NOT_ACCEPTABLE) return make_response(f"Unsupported MIME type '{request.accept_mimetypes}'", HTTPStatus.NOT_ACCEPTABLE)
except KeyError: except KeyError:
return make_response(f"Error bad key in {fields}", HTTPStatus.BAD_REQUEST) return make_response(f"Error bad key in {fields}", HTTPStatus.BAD_REQUEST)
except JSONEncodingValueError as e:
# JSON encoding failure, usually due to bad data
warnings.warn(JSON_NaN_to_num_warning_msg)
return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR)
except ValueError as e:
return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR)
@swagger.doc(
{
"summary": "Fetch annotations (metadata) for filtered subset of variables.",
"tags": ["annotations"],
"parameters": [
{
"in": "query",
"name": "annotation-name",
"type": "string",
"description": "list of 1 or more annotation names",
},
{
"name": "filter",
"description": "Complex Filter",
"in": "body",
"schema": FilterModel,
},
],
"responses": {
"200": {
"description": "annotations",
"examples": {
"application/json": {
"names": ["name", "category"],
"data": [
[0, "ATAD3C", 1],
[1, "RER1", None],
[49, "S100B", 6],
],
}
},
},
"400": {
"description": "malformed filter or one or more of the annotation-name identifiers were"
"not associated with an annotation name"
},
},
}
)
def put(self):
fields = request.args.getlist("annotation-name", None)
try:
annotation_response = current_app.data.annotation(
request.get_json()["filter"], "var", fields
)
return make_response(
annotation_response, HTTPStatus.OK, {"Content-Type": "application/json"}
)
except KeyError:
return make_response(f"Error bad key in {fields}", HTTPStatus.BAD_REQUEST)
except FilterError:
return make_response("Malformed filter", HTTPStatus.BAD_REQUEST)
except JSONEncodingValueError as e:
# JSON encoding failure, usually due to bad data
warnings.warn(JSON_NaN_to_num_warning_msg)
return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR)
except ValueError as e:
return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR)
class DataObsAPI(Resource):
@swagger.doc(
{
"summary": "Get data (expression values) from the dataframe.",
"tags": ["data"],
"parameters": [
{
"in": "query",
"name": "filter",
"type": "string",
"description": "axis:key:value",
},
{
"in": "query",
"name": "accept-type",
"type": "string",
"description": "MIME type",
},
],
"responses": {
"200": {
"description": "expression",
"examples": {
"application/json": {
"var": [0, 20000],
"obs": [[1, 39483, 3902, 203, 0, 0, 28]],
}
},
},
"400": {"description": "Malformed filter"},
"406": {"description": "Unacceptable MIME type"},
},
}
)
def get(self):
accept_type = request.args.get("accept-type", None)
# request.args is immutable
args = request.args.copy()
args.pop("accept-type", None)
try:
filter_ = parse_filter(
ImmutableMultiDict(args), current_app.data.schema["annotations"]
)
except QueryStringError as e:
return make_response(e.message, HTTPStatus.BAD_REQUEST)
# TODO support CSV
try:
# TODO store mime_type when more than one is supported
get_mime_type(
acceptable_types=["application/json"],
query_param=accept_type,
header=request.accept_mimetypes,
)
except MimeTypeError as e:
return make_response(e.message, HTTPStatus.NOT_ACCEPTABLE)
try:
return make_response(
current_app.data.data_frame(filter_, axis=Axis.OBS),
HTTPStatus.OK,
{"Content-Type": "application/json"},
)
except FilterError as e:
return make_response(e.message, HTTPStatus.BAD_REQUEST)
except JSONEncodingValueError as e:
# JSON encoding failure, usually due to bad data
warnings.warn(JSON_NaN_to_num_warning_msg)
return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR)
except ValueError as e:
return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR)
@swagger.doc(
{
"summary": "Get data (expression values) from the dataframe.",
"tags": ["data"],
"parameters": [
{
"name": "filter",
"description": "Complex Filter",
"in": "body",
"schema": FilterModel,
}
],
"responses": {
"200": {
"description": "expression",
"examples": {
"application/json": {
"var": [0, 20000],
"obs": [[1, 39483, 3902, 203, 0, 0, 28]],
}
},
},
"400": {"description": "Malformed filter"},
"406": {"description": "Unacceptable MIME type"},
},
}
)
def put(self):
if not request.accept_mimetypes.best_match(["application/json", "text/csv"]):
return make_response(
f"Unsupported MIME type '{request.accept_mimetypes}'",
HTTPStatus.NOT_ACCEPTABLE,
)
try:
get_mime_type(
acceptable_types=["application/json"], header=request.accept_mimetypes
)
except MimeTypeError as e:
return make_response(e.message, HTTPStatus.NOT_ACCEPTABLE)
try:
return make_response(
(
current_app.data.data_frame(
request.get_json()["filter"], axis=Axis.OBS
)
),
HTTPStatus.OK,
{"Content-Type": "application/json"},
)
except FilterError as e:
return make_response(e.message, HTTPStatus.BAD_REQUEST)
except JSONEncodingValueError as e:
# JSON encoding failure, usually due to bad data
warnings.warn(JSON_NaN_to_num_warning_msg)
return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR)
except ValueError as e: except ValueError as e:
return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR) return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR)
class DataVarAPI(Resource): class DataVarAPI(Resource):
@swagger.doc(
{
"summary": "Get data (expression values) from the dataframe.",
"tags": ["data"],
"parameters": [
{
"in": "query",
"name": "filter",
"type": "string",
"description": "axis:key:value",
},
{
"in": "query",
"name": "accept-type",
"type": "string",
"description": "MIME type",
},
],
"responses": {
"200": {
"description": "expression",
"examples": {
"application/json": {
"obs": [0, 20000],
"var": [[1, 39483, 3902, 203, 0, 0, 28]],
}
},
},
"400": {"description": "Malformed filter"},
"406": {"description": "Unacceptable MIME type"},
},
}
)
def get(self):
accept_type = request.args.get("accept-type", None)
# request.args is immutable
args = request.args.copy()
args.pop("accept-type", None)
try:
filter_ = parse_filter(
ImmutableMultiDict(args), current_app.data.schema["annotations"]
)
except QueryStringError as e:
return make_response(e.message, HTTPStatus.BAD_REQUEST)
try:
get_mime_type(
acceptable_types=["application/json"],
query_param=accept_type,
header=request.accept_mimetypes,
)
except MimeTypeError as e:
return make_response(e.message, HTTPStatus.NOT_ACCEPTABLE)
try:
return make_response(
current_app.data.data_frame(filter_, axis=Axis.VAR),
HTTPStatus.OK,
{"Content-Type": "application/json"},
)
except FilterError as e:
return make_response(e.message, HTTPStatus.BAD_REQUEST)
except JSONEncodingValueError as e:
# JSON encoding failure, usually due to bad data
warnings.warn(JSON_NaN_to_num_warning_msg)
return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR)
except ValueError as e:
return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR)
@swagger.doc(
{
"summary": "Get data (expression values) from the dataframe.",
"tags": ["data"],
"parameters": [
{
"name": "filter",
"description": "Complex Filter",
"in": "body",
"schema": FilterModel,
}
],
"responses": {
"200": {
"description": "expression",
"examples": {
"application/json": {
"obs": [0, 20000],
"var": [[1, 39483, 3902, 203, 0, 0, 28]],
}
},
},
"400": {"description": "Malformed filter"},
"406": {"description": "Unacceptable MIME type"},
},
}
)
def put(self): def put(self):
preferred_mimetype = request.accept_mimetypes.best_match( preferred_mimetype = request.accept_mimetypes.best_match(
["application/json", "application/octet-stream"], ["application/octet-stream"]
"application/json"
) )
try: try:
if preferred_mimetype == "application/json": if preferred_mimetype == "application/octet-stream":
return make_response( filter_json = request.get_json()
( filter = filter_json["filter"] if filter_json else None
current_app.data.data_frame(
request.get_json()["filter"], axis=Axis.VAR
)
),
HTTPStatus.OK,
{"Content-Type": "application/json"},
)
elif preferred_mimetype == "application/octet-stream":
return make_response( return make_response(
current_app.data.data_frame_to_fbs_matrix( current_app.data.data_frame_to_fbs_matrix(
request.get_json()["filter"], axis=Axis.VAR filter, axis=Axis.VAR
), ),
HTTPStatus.OK, HTTPStatus.OK,
{"Content-Type": "application/octet-stream"}) {"Content-Type": "application/octet-stream"})
@@ -648,73 +127,11 @@ class DataVarAPI(Resource):
return make_response(f"Unsupported MIME type '{request.accept_mimetypes}'", HTTPStatus.NOT_ACCEPTABLE) return make_response(f"Unsupported MIME type '{request.accept_mimetypes}'", HTTPStatus.NOT_ACCEPTABLE)
except FilterError as e: except FilterError as e:
return make_response(e.message, HTTPStatus.BAD_REQUEST) return make_response(e.message, HTTPStatus.BAD_REQUEST)
except JSONEncodingValueError as e:
# JSON encoding failure, usually due to bad data
warnings.warn(JSON_NaN_to_num_warning_msg)
return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR)
except ValueError as e: except ValueError as e:
return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR) return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR)
class DiffExpObsAPI(Resource): class DiffExpObsAPI(Resource):
@swagger.doc(
{
"summary": "Generate differential expression (DE) statistics for two specified subsets of data, "
"as indicated by the two provided observation complex filters",
"tags": ["diffexp"],
# TODO sort out params
# "parameters": [
# # {
# # "in": "body",
# # "name": "mode",
# # "type": "string",
# # "required": True,
# # "description": "topN or varFilter"
# # },
# {
# "in": "query",
# "name": "count",
# "type": "int32",
# "description": "TopN mode: how many vars to return"
# },
# {
# "in": "body",
# "name": "varFilter",
# "schema": FilterModel,
# "description": "varFilter: Complex filter, only var for which vars to return"
# },
# {
# "in": "body",
# "name": "set1",
# "schema": FilterModel,
# "required": True,
# "description": "Complex filter, only obs - observations in set1"
# },
# {
# "in": "body",
# "name": "set2",
# "schema": FilterModel,
# "description": "Complex filter, only obs - observations in set2.
# If not included, inverse of set1."
# },
# ],
"responses": {
"200": {
"description": "Statistics are encoded as an array of arrays, with fields ordered as: "
"varIndex, logfoldchange, pVal, pValAdj",
"examples": {
"application/json": [
[328, -2.569_489, 2.655_706e-63, 3.642_036e-57],
[1250, -2.569_489, 2.655_706e-63, 3.642_036e-57],
]
},
},
"400": {"description": "malformed filter"},
"403": {"description": "non-interactive request"},
"501": {"description": "diffexp is not implemented"},
},
}
)
def post(self): def post(self):
args = request.get_json() args = request.get_json()
# confirm mode is present and legal # confirm mode is present and legal
@@ -783,40 +200,12 @@ class DiffExpObsAPI(Resource):
class LayoutObsAPI(Resource): class LayoutObsAPI(Resource):
@swagger.doc(
{
"summary": "Get the default layout for all observations.",
"tags": ["layout"],
"parameters": [],
"responses": {
"200": {
"description": "layout",
"examples": {
"application/json": {
"layout": {
"ndims": 2,
"coordinates": [
[0, 0.284_483, 0.983_744],
[1, 0.038_844, 0.739_444],
],
}
}
},
},
"400": {"description": "Data preparation error"},
},
}
)
def get(self): def get(self):
preferred_mimetype = request.accept_mimetypes.best_match( preferred_mimetype = request.accept_mimetypes.best_match(
["application/json", "application/octet-stream"], ["application/octet-stream"]
"application/json"
) )
try: try:
if preferred_mimetype == "application/json": if preferred_mimetype == "application/octet-stream":
return make_response(current_app.data.layout({}), HTTPStatus.OK, {"Content-Type": "application/json"})
elif preferred_mimetype == "application/octet-stream":
return make_response(current_app.data.layout_to_fbs_matrix(), return make_response(current_app.data.layout_to_fbs_matrix(),
HTTPStatus.OK, HTTPStatus.OK,
{"Content-Type": "application/octet-stream"}) {"Content-Type": "application/octet-stream"})
@@ -824,69 +213,19 @@ class LayoutObsAPI(Resource):
return make_response(f"Unsupported MIME type '{request.accept_mimetypes}'", HTTPStatus.NOT_ACCEPTABLE) return make_response(f"Unsupported MIME type '{request.accept_mimetypes}'", HTTPStatus.NOT_ACCEPTABLE)
except PrepareError as e: except PrepareError as e:
return make_response(e.message, HTTPStatus.INTERNAL_SERVER_ERROR) return make_response(e.message, HTTPStatus.INTERNAL_SERVER_ERROR)
except JSONEncodingValueError as e:
# JSON encoding failure, usually due to bad data
warnings.warn(JSON_NaN_to_num_warning_msg)
return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR)
except ValueError as e: except ValueError as e:
return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR) return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR)
# @swagger.doc({
# "summary": "Observation layout for filtered subset.",
# "tags": ["layout"],
# "parameters": [
# {
# "name": "filter",
# "description": "Complex Filter",
# "in": "body",
# "schema": FilterModel
# }
# ],
# "responses": {
# "200": {
# "description": "layout",
# "examples": {
# "application/json": {
# "layout": {
# "ndims": 2,
# "coordinates": [
# [0, 0.284483, 0.983744],
# [1, 0.038844, 0.739444]
# ]
# }
# }
# }
# },
# "400": {
# "description": "Malformed filter"
# },
# "403": {
# "description": "Non-interactive request"
# },
# }
# })
# def put(self):
# try:
# filter = request.get_json()["filter"]
# interactive_limit = current_app.data.features["layout"]["obs"]["interactiveLimit"]
# layout = current_app.data.layout(filter, interactive_limit=interactive_limit)
# return make_response(layout, HTTPStatus.OK, {"Content-Type": content_type})
# except FilterError as e:
# return make_response(e.message, HTTPStatus.BAD_REQUEST)
# except InteractiveError:
# return make_response("Non-interactive request", HTTPStatus.FORBIDDEN)
def get_api_resources(): def get_api_resources():
bp = Blueprint("api", __name__, url_prefix="/api/v0.2") bp = Blueprint("api", __name__, url_prefix="/api/v0.2")
api = Api(bp, add_api_spec_resource=False) api = Api(bp)
# Initialization routes # Initialization routes
api.add_resource(SchemaAPI, "/schema") api.add_resource(SchemaAPI, "/schema")
api.add_resource(ConfigAPI, "/config") api.add_resource(ConfigAPI, "/config")
# Data routes # Data routes
api.add_resource(AnnotationsObsAPI, "/annotations/obs") api.add_resource(AnnotationsObsAPI, "/annotations/obs")
api.add_resource(AnnotationsVarAPI, "/annotations/var") api.add_resource(AnnotationsVarAPI, "/annotations/var")
api.add_resource(DataObsAPI, "/data/obs")
api.add_resource(DataVarAPI, "/data/var") api.add_resource(DataVarAPI, "/data/var")
# Computation routes # Computation routes
api.add_resource(DiffExpObsAPI, "/diffexp/obs") api.add_resource(DiffExpObsAPI, "/diffexp/obs")
+1 -169
View File
@@ -1,16 +1,13 @@
import warnings import warnings
import numpy as np import numpy as np
from pandas import DataFrame
from pandas.core.dtypes.dtypes import CategoricalDtype from pandas.core.dtypes.dtypes import CategoricalDtype
import scanpy.api as sc import scanpy.api as sc
from scipy import sparse
from server.app.driver.driver import CXGDriver from server.app.driver.driver import CXGDriver
from server.app.util.constants import Axis, DEFAULT_TOP_N from server.app.util.constants import Axis, DEFAULT_TOP_N
from server.app.util.errors import ( from server.app.util.errors import (
FilterError, FilterError,
InteractiveError,
JSONEncodingValueError, JSONEncodingValueError,
PrepareError, PrepareError,
ScanpyFileError, ScanpyFileError,
@@ -197,23 +194,6 @@ class ScanpyEngine(CXGDriver):
f"to solve this problem. " f"to solve this problem. "
) )
def filter_dataframe(self, filter):
"""
Filter cells from data and return a subset of the data. They can operate on both obs and var dimension with
indexing and filtering by annotation value. Filters are combined with the and operator.
See REST specs for info on filter format:
# TODO update this link to swagger when it's done
https://docs.google.com/document/d/1Fxjp1SKtCk7l8QP9-7KAjGXL0eldi_qEnNT0NmlGzXI/edit#heading=h.8qc9q57amldx
:param filter: dictionary with filter params
:return: View into scanpy object with cells/genes filtered
"""
if not filter:
return self.data
obs_selector, var_selector = self._filter_to_mask(filter, use_slices=False)
data = self._slice(self.data, obs_selector, var_selector)
return data
@staticmethod @staticmethod
def _annotation_filter_to_mask(filter, d_axis, count): def _annotation_filter_to_mask(filter, d_axis, count):
mask = np.ones((count,), dtype=bool) mask = np.ones((count,), dtype=bool)
@@ -277,72 +257,6 @@ class ScanpyEngine(CXGDriver):
) )
return obs_selector, var_selector return obs_selector, var_selector
@staticmethod
def _slice(data, obs_selector=None, vars_selector=None):
"""
Slice date using any selector that the AnnData object
supprots for slicing. If selector is None, will not slice
on that axis.
This method exists to optimize filtering/slicing sparse data that has
access patterns which impact slicing performance.
https://docs.scipy.org/doc/scipy/reference/sparse.html
"""
prefer_row_access = (
sparse.isspmatrix_csr(data._X)
or sparse.isspmatrix_lil(data._X)
or sparse.isspmatrix_bsr(data._X)
)
if prefer_row_access:
# Row-major slicing
if obs_selector is not None:
data = data[obs_selector, :]
if vars_selector is not None:
data = data[:, vars_selector]
else:
# Col-major slicing
if vars_selector is not None:
data = data[:, vars_selector]
if obs_selector is not None:
data = data[obs_selector, :]
return data
def annotation(self, filter, axis, fields=None):
"""
Gets annotation value for each observation
:param filter: filter: dictionary with filter params
:param axis: string obs or var
:param fields: list of keys for annotation to return, returns all annotation values if not set.
:return: dict: names - list of fields in order, data - list of lists or metadata
[observation ids, val1, val2...]
"""
try:
obs_selector, var_selector = self._filter_to_mask(filter)
except (KeyError, IndexError) as e:
raise FilterError(f"Error parsing filter: {e}") from e
if axis == Axis.OBS:
obs = self.data.obs[obs_selector]
if not fields:
fields = obs.columns.tolist()
result = {
"names": fields,
"data": DataFrame(obs[fields]).to_records(index=True).tolist(),
}
else:
var = self.data.var[var_selector]
if not fields:
fields = var.columns.tolist()
result = {
"names": fields,
"data": DataFrame(var[fields]).to_records(index=True).tolist(),
}
try:
return jsonify_scanpy(result)
except ValueError:
raise JSONEncodingValueError("Error encoding annotations to JSON")
def annotation_to_fbs_matrix(self, axis, fields=None): def annotation_to_fbs_matrix(self, axis, fields=None):
if axis == Axis.OBS: if axis == Axis.OBS:
df = self.data.obs df = self.data.obs
@@ -352,44 +266,6 @@ class ScanpyEngine(CXGDriver):
df = df[fields] df = df[fields]
return encode_matrix_fbs(df, col_idx=df.columns) return encode_matrix_fbs(df, col_idx=df.columns)
def data_frame(self, filter, axis):
"""
Retrieves data for each variable for observations in data frame
:param filter: filter: dictionary with filter params
:param axis: string obs or var
:return: {
"var": list of variable ids,
"obs": [cellid, var1 expression, var2 expression, ...],
}
"""
try:
obs_selector, var_selector = self._filter_to_mask(filter)
except (KeyError, IndexError) as e:
raise FilterError(f"Error parsing filter: {e}") from e
_X = self.data._X[obs_selector, var_selector]
if sparse.issparse(_X):
_X = _X.toarray()
var_index_sliced = self.data.var.index[var_selector]
obs_index_sliced = self.data.obs.index[obs_selector]
if axis == Axis.OBS:
result = {
"var": var_index_sliced.tolist(),
"obs": DataFrame(_X, index=obs_index_sliced)
.to_records(index=True)
.tolist(),
}
else:
result = {
"obs": obs_index_sliced.tolist(),
"var": DataFrame(_X.T, index=var_index_sliced)
.to_records(index=True)
.tolist(),
}
try:
return jsonify_scanpy(result)
except ValueError:
raise JSONEncodingValueError("Error encoding dataframe to JSON")
def data_frame_to_fbs_matrix(self, filter, axis): def data_frame_to_fbs_matrix(self, filter, axis):
""" """
Retrieves data 'X' and returns in a flatbuffer Matrix. Retrieves data 'X' and returns in a flatbuffer Matrix.
@@ -405,7 +281,7 @@ class ScanpyEngine(CXGDriver):
raise ValueError("Only VAR dimension access is supported") raise ValueError("Only VAR dimension access is supported")
try: try:
obs_selector, var_selector = self._filter_to_mask(filter, use_slices=False) obs_selector, var_selector = self._filter_to_mask(filter, use_slices=False)
except (KeyError, IndexError) as e: except (KeyError, IndexError, TypeError) as e:
raise FilterError(f"Error parsing filter: {e}") from e raise FilterError(f"Error parsing filter: {e}") from e
if obs_selector is not None: if obs_selector is not None:
raise FilterError("filtering on obs unsupported") raise FilterError("filtering on obs unsupported")
@@ -440,50 +316,6 @@ class ScanpyEngine(CXGDriver):
"Error encoding differential expression to JSON" "Error encoding differential expression to JSON"
) )
def layout(self, filter, interactive_limit=None):
"""
Computes a n-d layout for cells through dimensionality reduction.
:param filter: filter: dictionary with filter params
:param interactive_limit: -- don't compute if total # genes in dataframes are larger than this
:return: [cellid, x, y, ...]
"""
try:
df = self.filter_dataframe(filter)
except (KeyError, IndexError) as e:
raise FilterError(f"Error parsing filter: {e}") from e
if interactive_limit and len(df.obs.index) > interactive_limit:
raise InteractiveError("Size data is too large for interactive computation")
# TODO Filtering cells is fine, but filtering genes does nothing because the neighbors are
# calculated using the original vars (geneset) and this doesn’t get updated when you use less.
# Need to recalculate neighbors (long) if user requests new layout filtered by var
# TODO for MVP we are pushing computation of layout to preprocessing and not allowing re-layout
# this will probably change after user feedback
# getattr(sc.tl, self.layout_method)(df, random_state=123)
try:
df_layout = df.obsm[f"X_{self.layout_method}"]
except ValueError as e:
raise PrepareError(
f"Layout has not been calculated using {self.layout_method}, "
f"please prepare your datafile and relaunch cellxgene"
) from e
normalized_layout = DataFrame(
(df_layout - df_layout.min()) / (df_layout.max() - df_layout.min()),
index=df.obs.index,
)
try:
return jsonify_scanpy(
{
"layout": {
"ndims": normalized_layout.shape[1],
"coordinates": normalized_layout.to_records(
index=True
).tolist(),
}
}
)
except ValueError:
raise JSONEncodingValueError("Error encoding layout to JSON")
def layout_to_fbs_matrix(self): def layout_to_fbs_matrix(self):
""" """
Return the default 2-D layout for cells as a FBS Matrix. Return the default 2-D layout for cells as a FBS Matrix.
View File
-86
View File
@@ -1,86 +0,0 @@
import json
from collections import defaultdict
from numpy import float32, int32
from server.app.util.constants import Axis
class QueryStringError(Exception):
def __init__(self, key, message):
self.key = key
self.message = message
def _convert_variable(datatype, variable):
"""
Convert variable to number (float/int)
Used for dataset metadata and for query string
:param datatype: type to convert to
:param variable (string or None): value of variable
:return: converted variable
:raises: AssertionError
"""
assert datatype in ["boolean", "categorical", "float32", "int32", "string"]
if variable is None:
return variable
if datatype == "int32":
variable = int32(variable)
elif datatype == "float32":
variable = float32(variable)
elif datatype == "boolean":
variable = json.loads(variable)
assert isinstance(variable, bool)
return variable
def parse_filter(query_filter, schema):
"""
The filter comes in as arguments from a GET request
For categorical metadata keys filter based on axis:key=value
For continuous metadata keys filter by axis:key=min,max
Either value can be replaced by a * To have only a minimum
value axis:key=min,* To have only a maximum value axis:key=*,max
They combine via AND so a cell's metadata would have to match every filter
The results is a matrix with the cells the pass the filter and at this point all the genes
:param query_filter: flask's request.args
:param schema: dictionary schema
:raises QueryStringError
:return:
"""
query = defaultdict(lambda: defaultdict(list))
for key in query_filter:
axis, annotation = key.split(":", 1)
try:
Axis(axis)
except ValueError:
raise QueryStringError(key, f"Error: key {key} not in metadata schema")
ann_filter = {"name": annotation}
for ann in schema[axis]:
if ann["name"] == annotation:
dtype = ann["type"]
break
else:
raise QueryStringError(key, f"Error: {annotation} not a valid annotation name")
if dtype in ["string", "categorical", "boolean"]:
ann_filter["values"] = [_convert_variable(dtype, i) for i in query_filter.getlist(key)]
else:
value = query_filter.get(key)
try:
min_, max_ = value.split(",")
except ValueError:
raise QueryStringError(key, f"Error: min,max format required for range for {annotation}, got {value}")
if min_ == "*":
min_ = None
if max_ == "*":
max_ = None
try:
ann_filter["min"] = _convert_variable(dtype, min_)
ann_filter["max"] = _convert_variable(dtype, max_)
except ValueError:
raise QueryStringError(key, f"Error: expected type {query[key]['type']} for key {key}, got {value}")
query[axis]["annotation_value"].append(ann_filter)
return query
-34
View File
@@ -1,34 +0,0 @@
from flask_restful_swagger_2 import Schema
class AnnotationModel(Schema):
type = "object"
description = "Filter by annotation key: value"
properties = {
"name": {"type": "string"},
# TODO update to OpenAPI v3.0 when a library is available that supports it
# Unfortunately 2.0 doesn't have a way to have a schema that accepts multiple types
# Overloading the type key with a list seems to work ok and makes it to the page
"values": {"type": "array", "items": {"type": ["float32", "string", "int32", "bool"]}},
"min": {"type": ["int32", "float32"]},
"max": {"type": ["int32", "float32"]},
}
required = ["name"]
class IndexModel(Schema):
type = "object"
description = "Filter by index of observation/variable ex. [0, 5, 15]"
properties = {"index": {"type": "array", "items": {"format": "int32", "type": "integer"}}}
class AxisModel(Schema):
type = "object"
description = "Axis of data -- obs or var"
properties = {"index": IndexModel, "annotation_value": AnnotationModel.array()}
class FilterModel(Schema):
type = "object"
description = "Complex filter"
properties = {"filter": {"type": "object", "properties": {"obs": AxisModel, "var": AxisModel}}}
-30
View File
@@ -1,10 +1,6 @@
import json import json
from argparse import ArgumentTypeError
from numpy import float32, integer from numpy import float32, integer
from server.app.util.errors import MimeTypeError
class Float32JSONEncoder(json.JSONEncoder): class Float32JSONEncoder(json.JSONEncoder):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -30,31 +26,5 @@ def custom_format_warning(msg, *args, **kwargs):
return f"[cellxgene] Warning: {msg} \n" return f"[cellxgene] Warning: {msg} \n"
def get_mime_type(
default="application/json", acceptable_types=["application/json", "text/csv"], query_param=None, header=None
):
mime_type = default
if query_param:
if query_param in acceptable_types:
mime_type = query_param
else:
raise MimeTypeError(f"Unsupported mime type {query_param} specified in query parameter 'accept-type'")
elif len(header):
mime_type = header.best_match(acceptable_types)
if not mime_type:
raise MimeTypeError(f"Unsupported mime type(s) {header} in HTTP Accept header")
return mime_type
def whole_number(value):
try:
value = int(value)
except ValueError as e:
raise ArgumentTypeError(f"{value} is not type int") from e
if value < 0:
raise ArgumentTypeError(f"{value} is not >= 0")
return value
def jsonify_scanpy(data): def jsonify_scanpy(data):
return json.dumps(data, cls=Float32JSONEncoder, allow_nan=False) return json.dumps(data, cls=Float32JSONEncoder, allow_nan=False)
-97
View File
@@ -1,97 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>cellxgene REST API - Swagger definition</title>
<link href="https://fonts.googleapis.com/css?family=Open+Sans:400,700|Source+Code+Pro:300,600|Titillium+Web:400,600,700"
rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/3.2.1/swagger-ui.css"
crossorigin="anonymous"/>
<style>
html {
box-sizing: border-box;
overflow: -moz-scrollbars-vertical;
overflow-y: scroll;
}
*,
*:before,
*:after {
box-sizing: inherit;
}
body {
margin: 0;
background: #fafafa;
}
</style>
</head>
<body>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
style="position:absolute;width:0;height:0">
<defs>
<symbol viewBox="0 0 20 20" id="unlocked">
<path d="M15.8 8H14V5.6C14 2.703 12.665 1 10 1 7.334 1 6 2.703 6 5.6V6h2v-.801C8 3.754 8.797 3 10 3c1.203 0 2 .754 2 2.199V8H4c-.553 0-1 .646-1 1.199V17c0 .549.428 1.139.951 1.307l1.197.387C5.672 18.861 6.55 19 7.1 19h5.8c.549 0 1.428-.139 1.951-.307l1.196-.387c.524-.167.953-.757.953-1.306V9.199C17 8.646 16.352 8 15.8 8z"></path>
</symbol>
<symbol viewBox="0 0 20 20" id="locked">
<path d="M15.8 8H14V5.6C14 2.703 12.665 1 10 1 7.334 1 6 2.703 6 5.6V8H4c-.553 0-1 .646-1 1.199V17c0 .549.428 1.139.951 1.307l1.197.387C5.672 18.861 6.55 19 7.1 19h5.8c.549 0 1.428-.139 1.951-.307l1.196-.387c.524-.167.953-.757.953-1.306V9.199C17 8.646 16.352 8 15.8 8zM12 8H8V5.199C8 3.754 8.797 3 10 3c1.203 0 2 .754 2 2.199V8z"/>
</symbol>
<symbol viewBox="0 0 20 20" id="close">
<path d="M14.348 14.849c-.469.469-1.229.469-1.697 0L10 11.819l-2.651 3.029c-.469.469-1.229.469-1.697 0-.469-.469-.469-1.229 0-1.697l2.758-3.15-2.759-3.152c-.469-.469-.469-1.228 0-1.697.469-.469 1.228-.469 1.697 0L10 8.183l2.651-3.031c.469-.469 1.228-.469 1.697 0 .469.469.469 1.229 0 1.697l-2.758 3.152 2.758 3.15c.469.469.469 1.229 0 1.698z"/>
</symbol>
<symbol viewBox="0 0 20 20" id="large-arrow">
<path d="M13.25 10L6.109 2.58c-.268-.27-.268-.707 0-.979.268-.27.701-.27.969 0l7.83 7.908c.268.271.268.709 0 .979l-7.83 7.908c-.268.271-.701.27-.969 0-.268-.269-.268-.707 0-.979L13.25 10z"/>
</symbol>
<symbol viewBox="0 0 20 20" id="large-arrow-down">
<path d="M17.418 6.109c.272-.268.709-.268.979 0s.271.701 0 .969l-7.908 7.83c-.27.268-.707.268-.979 0l-7.908-7.83c-.27-.268-.27-.701 0-.969.271-.268.709-.268.979 0L10 13.25l7.418-7.141z"/>
</symbol>
<symbol viewBox="0 0 24 24" id="jump-to">
<path d="M19 7v4H5.83l3.58-3.59L8 6l-6 6 6 6 1.41-1.41L5.83 13H21V7z"/>
</symbol>
<symbol viewBox="0 0 24 24" id="expand">
<path d="M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z"/>
</symbol>
</defs>
</svg>
<div id="swagger-ui"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/3.2.1/swagger-ui-bundle.js"
crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/3.2.1/swagger-ui-standalone-preset.js"
crossorigin="anonymous"></script>
<script>
window.onload = function () {
const ui = SwaggerUIBundle({
url: window.location.origin + "/api/swagger.json",
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
layout: "StandaloneLayout"
});
window.ui = ui
}
</script>
</body>
</html>
+2 -10
View File
@@ -1,5 +1,5 @@
import os import os
from flask import Blueprint, render_template, send_from_directory, current_app, request from flask import Blueprint, render_template, send_from_directory, current_app
bp = Blueprint("webapp", __name__, template_folder="templates") bp = Blueprint("webapp", __name__, template_folder="templates")
@@ -7,18 +7,10 @@ bp = Blueprint("webapp", __name__, template_folder="templates")
@bp.route("/") @bp.route("/")
def index(): def index():
url_base = request.url_root + "api/"
dataset_title = current_app.config["DATASET_TITLE"] dataset_title = current_app.config["DATASET_TITLE"]
return render_template("index.html", prefix=url_base, datasetTitle=dataset_title) return render_template("index.html", datasetTitle=dataset_title)
# renders swagger documentation
@bp.route("/swagger")
def swag():
return render_template("swagger.html")
# renders swagger documentation
@bp.route("/favicon.png") @bp.route("/favicon.png")
def favicon(): def favicon():
return send_from_directory(os.path.join(bp.root_path, "static/img/"), "favicon.png") return send_from_directory(os.path.join(bp.root_path, "static/img/"), "favicon.png")
+1 -1
View File
@@ -5,7 +5,7 @@ from .prepare import prepare
@click.group(name="cellxgene", context_settings=dict(max_content_width=85)) @click.group(name="cellxgene", context_settings=dict(max_content_width=85))
@click.version_option(version="0.5.0", prog_name="cellxgene", message="[%(prog)s] Version %(version)s") @click.version_option(version="0.7.0", prog_name="cellxgene", message="[%(prog)s] Version %(version)s")
def cli(): def cli():
pass pass
+1 -1
View File
@@ -33,7 +33,7 @@ from server.app.util.utils import custom_format_warning
show_default=True, show_default=True,
help="Provide verbose output, including warnings and all server requests.", help="Provide verbose output, including warnings and all server requests.",
) )
@click.option("--debug", "-d", is_flag=True, default=False, show_default=True, help="Run in debug mode.") @click.option("--debug", is_flag=True, default=False, show_default=True, help="Run in debug mode.")
@click.option( @click.option(
"--open", "--open",
"-o", "-o",
-1
View File
@@ -5,7 +5,6 @@ Flask-Caching>=1.4.0
Flask-Compress>=1.4.0 Flask-Compress>=1.4.0
Flask-Cors>=3.0.6 Flask-Cors>=3.0.6
Flask-RESTful>=0.3.6 Flask-RESTful>=0.3.6
flask-restful-swagger-2>=0.35
flatbuffers>=1.10.0 flatbuffers>=1.10.0
matplotlib>=2.2 matplotlib>=2.2
numpy>=1.14.5 numpy>=1.14.5
+71 -292
View File
@@ -57,16 +57,6 @@ class EndPoints(unittest.TestCase):
self.assertEqual(result_data["config"]["displayNames"]["dataset"], "pbmc3k") self.assertEqual(result_data["config"]["displayNames"]["dataset"], "pbmc3k")
self.assertEqual(len(result_data["config"]["features"]), 4) self.assertEqual(len(result_data["config"]["features"]), 4)
def test_get_layout(self):
endpoint = "layout/obs"
url = f"{URL_BASE}{endpoint}"
result = self.session.get(url)
self.assertEqual(result.status_code, HTTPStatus.OK)
self.assertEqual(result.headers["Content-Type"], "application/json")
result_data = result.json()
self.assertEqual(result_data["layout"]["ndims"], 2)
self.assertEqual(len(result_data["layout"]["coordinates"]), 2638)
def test_get_layout_fbs(self): def test_get_layout_fbs(self):
endpoint = "layout/obs" endpoint = "layout/obs"
url = f"{URL_BASE}{endpoint}" url = f"{URL_BASE}{endpoint}"
@@ -82,53 +72,11 @@ class EndPoints(unittest.TestCase):
self.assertIsNone(df['row_idx']) self.assertIsNone(df['row_idx'])
self.assertEqual(len(df['columns']), df['n_cols']) self.assertEqual(len(df['columns']), df['n_cols'])
# def test_put_layout(self):
# endpoint = "layout/obs"
# url = f"{URL_BASE}{endpoint}"
# obs_filter = {
# "filter": {
# "obs": {
# "annotation_value": [
# {"name": "louvain", "values": ["NK cells", "CD8 T cells"]},
# {"name": "n_counts", "min": 3000},
# ],
# "index": [1, 99, [1000, 2000]]
# }
# }
# }
# result = self.session.put(url, json=obs_filter)
# self.assertEqual(result.status_code, HTTPStatus.OK)
# result_data = result.json()
# self.assertEqual(len(result_data["layout"]["coordinates"]), 15)
def test_bad_filter(self): def test_bad_filter(self):
endpoints = ["annotations/obs", "annotations/var", "data/obs", "data/var"] endpoint = "data/var"
for endpoint in endpoints:
url = f"{URL_BASE}{endpoint}"
result = self.session.put(url, json=BAD_FILTER)
self.assertEqual(result.status_code, HTTPStatus.BAD_REQUEST)
def test_get_annotations_obs(self):
endpoint = "annotations/obs"
url = f"{URL_BASE}{endpoint}" url = f"{URL_BASE}{endpoint}"
result = self.session.get(url) result = self.session.put(url, json=BAD_FILTER)
self.assertEqual(result.status_code, HTTPStatus.OK) self.assertEqual(result.status_code, HTTPStatus.BAD_REQUEST)
self.assertEqual(result.headers["Content-Type"], "application/json")
result_data = result.json()
self.assertEqual(result_data["names"], ["name", "n_genes", "percent_mito", "n_counts", "louvain"])
self.assertEqual(len(result_data["data"]), 2638)
self.assertEqual(len(result_data["data"][0]), 6)
def test_get_annotations_obs_keys(self):
endpoint = "annotations/obs"
query = "annotation-name=n_genes&annotation-name=percent_mito"
url = f"{URL_BASE}{endpoint}?{query}"
result = self.session.get(url)
self.assertEqual(result.headers["Content-Type"], "application/json")
self.assertEqual(result.status_code, HTTPStatus.OK)
result_data = result.json()
self.assertEqual(result_data["names"], ["n_genes", "percent_mito"])
self.assertEqual(len(result_data["data"][0]), 3)
def test_get_annotations_obs_fbs(self): def test_get_annotations_obs_fbs(self):
endpoint = "annotations/obs" endpoint = "annotations/obs"
@@ -146,6 +94,23 @@ class EndPoints(unittest.TestCase):
self.assertEqual(len(df['columns']), df['n_cols']) self.assertEqual(len(df['columns']), df['n_cols'])
self.assertListEqual(df['col_idx'], ['name', 'n_genes', 'percent_mito', 'n_counts', 'louvain']) self.assertListEqual(df['col_idx'], ['name', 'n_genes', 'percent_mito', 'n_counts', 'louvain'])
def test_get_annotations_obs_keys_fbs(self):
endpoint = "annotations/obs"
query = "annotation-name=n_genes&annotation-name=percent_mito"
url = f"{URL_BASE}{endpoint}?{query}"
header = {"Accept": "application/octet-stream"}
result = self.session.get(url, headers=header)
self.assertEqual(result.status_code, HTTPStatus.OK)
self.assertEqual(result.headers["Content-Type"], "application/octet-stream")
df = decode_fbs.decode_matrix_FBS(result.content)
self.assertEqual(df['n_rows'], 2638)
self.assertEqual(df['n_cols'], 2)
self.assertIsNotNone(df['columns'])
self.assertIsNotNone(df['col_idx'])
self.assertIsNone(df['row_idx'])
self.assertEqual(len(df['columns']), df['n_cols'])
self.assertListEqual(df['col_idx'], ['n_genes', 'percent_mito'])
def test_get_annotations_obs_error(self): def test_get_annotations_obs_error(self):
endpoint = "annotations/obs" endpoint = "annotations/obs"
query = "annotation-name=notakey" query = "annotation-name=notakey"
@@ -153,50 +118,6 @@ class EndPoints(unittest.TestCase):
result = self.session.get(url) result = self.session.get(url)
self.assertEqual(result.status_code, HTTPStatus.BAD_REQUEST) self.assertEqual(result.status_code, HTTPStatus.BAD_REQUEST)
def test_put_annotations_obs(self):
endpoint = "annotations/obs"
url = f"{URL_BASE}{endpoint}"
obs_filter = {
"filter": {
"obs": {
"annotation_value": [
{"name": "louvain", "values": ["NK cells", "CD8 T cells"]},
{"name": "n_counts", "min": 3000},
],
"index": [1, 99, [1000, 2000]],
}
}
}
result = self.session.put(url, json=obs_filter)
self.assertEqual(result.status_code, HTTPStatus.OK)
self.assertEqual(result.headers["Content-Type"], "application/json")
result_data = result.json()
self.assertEqual(result_data["names"], ["name", "n_genes", "percent_mito", "n_counts", "louvain"])
self.assertEqual(len(result_data["data"]), 15)
def test_filter_put_annotations_obs(self):
endpoint = "annotations/obs"
query = "annotation-name=n_genes&annotation-name=percent_mito"
url = f"{URL_BASE}{endpoint}?{query}"
obs_filter = {
"filter": {
"obs": {
"annotation_value": [
{"name": "louvain", "values": ["NK cells", "CD8 T cells"]},
{"name": "n_counts", "min": 3000},
],
"index": [1, 99, [1000, 2000]],
}
}
}
result = self.session.put(url, json=obs_filter)
self.assertEqual(result.status_code, HTTPStatus.OK)
self.assertEqual(result.headers["Content-Type"], "application/json")
result_data = result.json()
self.assertEqual(result_data["names"], ["n_genes", "percent_mito"])
self.assertEqual(len(result_data["data"][0]), 3)
self.assertEqual(len(result_data["data"]), 15)
def test_diff_exp(self): def test_diff_exp(self):
endpoint = "diffexp/obs" endpoint = "diffexp/obs"
url = f"{URL_BASE}{endpoint}" url = f"{URL_BASE}{endpoint}"
@@ -227,28 +148,6 @@ class EndPoints(unittest.TestCase):
result_data = result.json() result_data = result.json()
self.assertEqual(len(result_data), 10) self.assertEqual(len(result_data), 10)
def test_get_annotations_var(self):
endpoint = "annotations/var"
url = f"{URL_BASE}{endpoint}"
result = self.session.get(url)
self.assertEqual(result.status_code, HTTPStatus.OK)
self.assertEqual(result.headers["Content-Type"], "application/json")
result_data = result.json()
self.assertEqual(result_data["names"], ["name", "n_cells"])
self.assertEqual(len(result_data["data"]), 1838)
self.assertEqual(len(result_data["data"][0]), 3)
def test_get_annotations_var_keys(self):
endpoint = "annotations/var"
query = "annotation-name=n_cells"
url = f"{URL_BASE}{endpoint}?{query}"
result = self.session.get(url)
self.assertEqual(result.status_code, HTTPStatus.OK)
self.assertEqual(result.headers["Content-Type"], "application/json")
result_data = result.json()
self.assertEqual(result_data["names"], ["n_cells"])
self.assertEqual(len(result_data["data"][0]), 2)
def test_get_annotations_var_fbs(self): def test_get_annotations_var_fbs(self):
endpoint = "annotations/var" endpoint = "annotations/var"
url = f"{URL_BASE}{endpoint}" url = f"{URL_BASE}{endpoint}"
@@ -265,6 +164,23 @@ class EndPoints(unittest.TestCase):
self.assertEqual(len(df['columns']), df['n_cols']) self.assertEqual(len(df['columns']), df['n_cols'])
self.assertListEqual(df['col_idx'], ['name', 'n_cells']) self.assertListEqual(df['col_idx'], ['name', 'n_cells'])
def test_get_annotations_var_keys_fbs(self):
endpoint = "annotations/var"
query = "annotation-name=n_cells"
url = f"{URL_BASE}{endpoint}?{query}"
header = {"Accept": "application/octet-stream"}
result = self.session.get(url, headers=header)
self.assertEqual(result.status_code, HTTPStatus.OK)
self.assertEqual(result.headers["Content-Type"], "application/octet-stream")
df = decode_fbs.decode_matrix_FBS(result.content)
self.assertEqual(df['n_rows'], 1838)
self.assertEqual(df['n_cols'], 1)
self.assertIsNotNone(df['columns'])
self.assertIsNotNone(df['col_idx'])
self.assertIsNone(df['row_idx'])
self.assertEqual(len(df['columns']), df['n_cols'])
self.assertListEqual(df['col_idx'], ['n_cells'])
def test_get_annotations_var_error(self): def test_get_annotations_var_error(self):
endpoint = "annotations/var" endpoint = "annotations/var"
query = "annotation-name=notakey" query = "annotation-name=notakey"
@@ -272,95 +188,36 @@ class EndPoints(unittest.TestCase):
result = self.session.get(url) result = self.session.get(url)
self.assertEqual(result.status_code, HTTPStatus.BAD_REQUEST) self.assertEqual(result.status_code, HTTPStatus.BAD_REQUEST)
def test_put_annotations_var(self):
endpoint = "annotations/var"
url = f"{URL_BASE}{endpoint}"
var_filter = {"filter": {"var": {"annotation_value": [{"name": "name", "values": ["ATAD3C", "RER1"]}]}}}
result = self.session.put(url, json=var_filter)
self.assertEqual(result.status_code, HTTPStatus.OK)
self.assertEqual(result.headers["Content-Type"], "application/json")
result_data = result.json()
self.assertEqual(result_data["names"], ["name", "n_cells"])
self.assertEqual(len(result_data["data"]), 2)
def test_filter_put_annotations_var(self):
endpoint = "annotations/var"
query = "annotation-name=n_cells"
url = f"{URL_BASE}{endpoint}?{query}"
var_filter = {"filter": {"var": {"annotation_value": [{"name": "name", "values": ["ATAD3C", "RER1"]}]}}}
result = self.session.put(url, json=var_filter)
self.assertEqual(result.status_code, HTTPStatus.OK)
self.assertEqual(result.headers["Content-Type"], "application/json")
result_data = result.json()
self.assertEqual(result_data["names"], ["n_cells"])
self.assertEqual(len(result_data["data"][0]), 2)
self.assertEqual(len(result_data["data"]), 2)
def test_get_data(self):
for axis in ["obs", "var"]:
endpoint = f"data/{axis}"
query = "accept-type=application/json"
url = f"{URL_BASE}{endpoint}?{query}"
result = self.session.get(url)
self.assertEqual(result.status_code, HTTPStatus.OK)
self.assertEqual(result.headers["Content-Type"], "application/json")
result_data = result.json()
self.assertEqual(len(result_data["obs"]), 2638)
def test_data_mimetype_error(self): def test_data_mimetype_error(self):
for axis in ["obs", "var"]: endpoint = f"data/var"
endpoint = f"data/{axis}" header = {"Accept": "xxx"}
query = "accept-type=xxx" url = f"{URL_BASE}{endpoint}"
url = f"{URL_BASE}{endpoint}?{query}" result = self.session.put(url, headers=header)
result = self.session.get(url) self.assertEqual(result.status_code, HTTPStatus.NOT_ACCEPTABLE)
self.assertEqual(result.status_code, HTTPStatus.NOT_ACCEPTABLE)
url = f"{URL_BASE}{endpoint}"
header = {"Accept": "sdkljfa;dsjalkj"}
result = self.session.get(url, headers=header)
self.assertEqual(result.status_code, HTTPStatus.NOT_ACCEPTABLE)
def test_json_default(self): def test_fbs_default(self):
for axis in ["obs", "var"]: endpoint = f"data/var"
endpoint = f"data/{axis}" url = f"{URL_BASE}{endpoint}"
url = f"{URL_BASE}{endpoint}" result = self.session.put(url)
result = self.session.get(url) self.assertEqual(result.status_code, HTTPStatus.OK)
self.assertEqual(result.status_code, HTTPStatus.OK) self.assertEqual(result.headers["Content-Type"], "application/octet-stream")
self.assertEqual(result.headers["Content-Type"], "application/json")
def test_data_filter(self):
for axis in ["obs", "var"]:
endpoint = f"data/{axis}"
query = "accept-type=application/json&obs:louvain=NK cells&obs:louvain=CD8 T cells&obs:n_counts=3000,*"
url = f"{URL_BASE}{endpoint}?{query}"
result = self.session.get(url)
self.assertEqual(result.status_code, HTTPStatus.OK)
self.assertEqual(result.headers["Content-Type"], "application/json")
result_data = result.json()
self.assertEqual(len(result_data["obs"]), 38)
def test_data_json_put(self):
for axis in ["obs", "var"]:
endpoint = f"data/{axis}"
url = f"{URL_BASE}{endpoint}"
header = {"Accept": "application/json"}
obs_filter = {
"filter": {
"obs": {
"annotation_value": [
{"name": "louvain", "values": ["NK cells", "CD8 T cells"]},
{"name": "n_counts", "min": 3000},
],
"index": [1, 99, [1000, 2000]],
}
}
}
result = self.session.put(url, headers=header, json=obs_filter)
self.assertEqual(result.status_code, HTTPStatus.OK)
self.assertEqual(result.headers["Content-Type"], "application/json")
result_data = result.json()
self.assertEqual(len(result_data["obs"]), 15)
def test_data_put_fbs(self): def test_data_put_fbs(self):
endpoint = f"data/var"
url = f"{URL_BASE}{endpoint}"
header = {"Accept": "application/octet-stream"}
result = self.session.put(url, headers=header)
self.assertEqual(result.status_code, HTTPStatus.OK)
self.assertEqual(result.headers["Content-Type"], "application/octet-stream")
df = decode_fbs.decode_matrix_FBS(result.content)
self.assertEqual(df['n_rows'], 2638)
self.assertEqual(df['n_cols'], 1838)
self.assertIsNotNone(df['columns'])
self.assertListEqual(df['col_idx'].tolist(), [])
self.assertIsNone(df['row_idx'])
self.assertEqual(len(df['columns']), df['n_cols'])
def test_data_put_filter_fbs(self):
endpoint = f"data/var" endpoint = f"data/var"
url = f"{URL_BASE}{endpoint}" url = f"{URL_BASE}{endpoint}"
header = {"Accept": "application/octet-stream"} header = {"Accept": "application/octet-stream"}
@@ -384,94 +241,16 @@ class EndPoints(unittest.TestCase):
self.assertListEqual(df['col_idx'].tolist(), [0, 1, 4]) self.assertListEqual(df['col_idx'].tolist(), [0, 1, 4])
def test_data_put_single_var(self): def test_data_put_single_var(self):
for axis in ["obs", "var"]: endpoint = f"data/var"
endpoint = f"data/{axis}"
url = f"{URL_BASE}{endpoint}"
header = {"Accept": "application/json"}
var_filter = {"filter": {"var": {"annotation_value": [{"name": "name", "values": ["RER1"]}]}}}
result = self.session.put(url, headers=header, json=var_filter)
self.assertEqual(result.status_code, HTTPStatus.OK)
self.assertEqual(result.headers["Content-Type"], "application/json")
result_data = result.json()
if axis == "obs":
self.assertEqual(len(result_data["obs"][0]), 2)
self.assertEqual(len(result_data["var"]), 1)
elif axis == "var":
self.assertEqual(len(result_data["obs"]), 2638)
self.assertEqual(len(result_data["var"][0]), 2639)
def test_cache(self):
endpoint = "annotations/var"
url = f"{URL_BASE}{endpoint}" url = f"{URL_BASE}{endpoint}"
f1 = { header = {"Accept": "application/octet-stream"}
"filter": { var_filter = {"filter": {"var": {"annotation_value": [{"name": "name", "values": ["RER1"]}]}}}
"var": { result = self.session.put(url, headers=header, json=var_filter)
"annotation_value": [
{
"name": "name",
"values": [
"HLA-DRB1",
"HLA-DQA1",
"HLA-DQB1",
"HLA-DPA1",
"HLA-DPB1",
"MS4A1",
"IL32",
"CCL5",
"CD79B",
"CD79A",
],
}
]
}
}
}
result = self.session.put(url, json=f1)
self.assertEqual(result.status_code, HTTPStatus.OK) self.assertEqual(result.status_code, HTTPStatus.OK)
self.assertEqual(result.headers["Content-Type"], "application/json") self.assertEqual(result.headers["Content-Type"], "application/octet-stream")
result_data1 = result.json() df = decode_fbs.decode_matrix_FBS(result.content)
f2 = { self.assertEqual(df["n_rows"], 2638)
"filter": { self.assertEqual(df["n_cols"], 1)
"var": {
"annotation_value": [
{
"name": "name",
"values": ["FGFBP2", "GZMA", "LTB", "PRF1", "CTSW", "GZMH", "CCL5", "CCL4", "CST7", "NKG7"],
}
]
}
}
}
result = self.session.put(url, json=f2)
self.assertEqual(result.status_code, HTTPStatus.OK)
self.assertEqual(result.headers["Content-Type"], "application/json")
result_data2 = result.json()
self.assertNotEqual(result_data1, result_data2)
def test_cache_nofilter(self):
endpoint = "annotations/var"
url = f"{URL_BASE}{endpoint}"
f1 = {"filter": {}}
result = self.session.put(url, json=f1)
self.assertEqual(result.status_code, HTTPStatus.OK)
result_data1 = result.json()
f2 = {
"filter": {
"var": {
"annotation_value": [
{
"name": "name",
"values": ["FGFBP2", "GZMA", "LTB", "PRF1", "CTSW", "GZMH", "CCL5", "CCL4", "CST7", "NKG7"],
}
]
}
}
}
result = self.session.put(url, json=f2)
self.assertEqual(result.status_code, HTTPStatus.OK)
self.assertEqual(result.headers["Content-Type"], "application/json")
result_data2 = result.json()
self.assertNotEqual(result_data1, result_data2)
def test_static(self): def test_static(self):
endpoint = "static" endpoint = "static"
-82
View File
@@ -1,82 +0,0 @@
import json
from os import path
import unittest
from numpy import float32, int32
from werkzeug.datastructures import ImmutableMultiDict
from server.app.util.filter import _convert_variable, parse_filter, QueryStringError
class UtilTest(unittest.TestCase):
"""Test Case for endpoints"""
def setUp(self):
with open(path.join(path.dirname(__file__), "schema.json")) as fh:
schema = json.load(fh)
self.schema = schema["annotations"]
def test_convert(self):
five = _convert_variable("int32", "5")
self.assertEqual(five, int32(5))
def test_convert_zero(self):
zero = _convert_variable("int32", "0")
self.assertEqual(zero, 0)
def test_convert_float(self):
str_to_convert = "4.38719237129"
val = _convert_variable("float32", str_to_convert)
self.assertAlmostEqual(val, float32(str_to_convert))
def test_convert_bool(self):
str_to_convert = "false"
val = _convert_variable("boolean", str_to_convert)
self.assertFalse(val)
str_to_convert = "true"
val = _convert_variable("boolean", str_to_convert)
self.assertTrue(val)
str_to_convert = "0"
with self.assertRaises(AssertionError):
val = _convert_variable("boolean", str_to_convert)
def test_empty_convert(self):
empty = _convert_variable("int32", None)
self.assertIsNone(empty)
def test_bad_convert(self):
with self.assertRaises(ValueError):
_convert_variable("int32", "5.5")
def test_bad_datatype(self):
with self.assertRaises(AssertionError):
_convert_variable("jkasdslkja", 1)
def test_complex_filter(self):
filter_dict = ImmutableMultiDict(
[("obs:louvain", "NK cells"), ("obs:louvain", "CD8 T cells"), ("obs:n_counts", "3000,*")]
)
filter_ = parse_filter(filter_dict, self.schema)
self.assertIn("obs", filter_)
self.assertEqual(
filter_["obs"]["annotation_value"],
[
{"name": "louvain", "values": ["NK cells", "CD8 T cells"]},
{"name": "n_counts", "max": None, "min": 3000.0},
],
)
def test_bad_filter(self):
bad_annotation_type = ImmutableMultiDict([("obs:tissue", "lung")])
with self.assertRaises(QueryStringError):
parse_filter(bad_annotation_type, self.schema)
bad_axis = ImmutableMultiDict([("xyz:n_genes", "100,1000")])
with self.assertRaises(QueryStringError):
parse_filter(bad_axis, self.schema)
def test_boolean_filter(self):
schema = {"obs": [{"name": "bool_filter", "type": "boolean"}]}
filter_dict = ImmutableMultiDict([("obs:bool_filter", "false")])
filter_ = parse_filter(filter_dict, schema)
self.assertIn("obs", filter_)
self.assertEqual(filter_["obs"]["annotation_value"], [{"name": "bool_filter", "values": [False]}])
+29 -6
View File
@@ -2,6 +2,9 @@ from http import HTTPStatus
from subprocess import Popen from subprocess import Popen
import unittest import unittest
import time import time
import math
import decode_fbs
import requests import requests
@@ -43,9 +46,29 @@ class WithNaNs(unittest.TestCase):
result = self.session.get(url) result = self.session.get(url)
self.assertEqual(result.status_code, HTTPStatus.OK) self.assertEqual(result.status_code, HTTPStatus.OK)
def test_errors(self): def test_data(self):
endpoints = ["annotations/obs", "annotations/var", "data/obs", "data/var"] endpoint = "data/var"
for endpoint in endpoints: url = f"{URL_BASE}{endpoint}"
url = f"{URL_BASE}{endpoint}" result = self.session.put(url)
result = self.session.get(url) self.assertEqual(result.status_code, HTTPStatus.OK)
self.assertEqual(result.status_code, HTTPStatus.INTERNAL_SERVER_ERROR) self.assertEqual(result.headers["Content-Type"], "application/octet-stream")
df = decode_fbs.decode_matrix_FBS(result.content)
self.assertTrue(math.isnan(df["columns"][3][3]))
def test_annotation_obs(self):
endpoint = "annotations/obs"
url = f"{URL_BASE}{endpoint}"
result = self.session.get(url)
self.assertEqual(result.status_code, HTTPStatus.OK)
self.assertEqual(result.headers["Content-Type"], "application/octet-stream")
df = decode_fbs.decode_matrix_FBS(result.content)
self.assertTrue(math.isnan(df["columns"][2][0]))
def test_annotation_var(self):
endpoint = "annotations/var"
url = f"{URL_BASE}{endpoint}"
result = self.session.get(url)
self.assertEqual(result.status_code, HTTPStatus.OK)
self.assertEqual(result.headers["Content-Type"], "application/octet-stream")
df = decode_fbs.decode_matrix_FBS(result.content)
self.assertTrue(math.isnan(df["columns"][2][0]))
+10 -11
View File
@@ -1,4 +1,3 @@
import json
import pytest import pytest
import unittest import unittest
import warnings import warnings
@@ -7,7 +6,7 @@ import math
import decode_fbs import decode_fbs
from server.app.scanpy_engine.scanpy_engine import ScanpyEngine from server.app.scanpy_engine.scanpy_engine import ScanpyEngine
from server.app.util.errors import JSONEncodingValueError from server.app.util.errors import FilterError
class NaNTest(unittest.TestCase): class NaNTest(unittest.TestCase):
@@ -42,10 +41,15 @@ class NaNTest(unittest.TestCase):
self.assertEqual(data_frame_var["n_cols"], 100) self.assertEqual(data_frame_var["n_cols"], 100)
self.assertTrue(math.isnan(data_frame_var["columns"][3][3])) self.assertTrue(math.isnan(data_frame_var["columns"][3][3]))
with pytest.raises(JSONEncodingValueError): with pytest.raises(FilterError):
json.loads(self.data.data_frame(None, "obs")) self.data.data_frame_to_fbs_matrix("an erroneous filter", "var")
with pytest.raises(JSONEncodingValueError): with pytest.raises(FilterError):
json.loads(self.data.data_frame(None, "var")) filter_ = {
"filter": {
"obs": {"index": [1, 99, [200, 300]]}
}
}
self.data.data_frame_to_fbs_matrix(filter_["filter"], "var")
def test_dataframe_obs_not_implemented(self): def test_dataframe_obs_not_implemented(self):
with self.assertRaises(ValueError) as cm: with self.assertRaises(ValueError) as cm:
@@ -65,8 +69,3 @@ class NaNTest(unittest.TestCase):
self.assertEqual(annotations["col_idx"], ["name", "n_cells", "var_with_nans"]) self.assertEqual(annotations["col_idx"], ["name", "n_cells", "var_with_nans"])
self.assertEqual(annotations["n_rows"], 100) self.assertEqual(annotations["n_rows"], 100)
self.assertTrue(math.isnan(annotations["columns"][2][0])) self.assertTrue(math.isnan(annotations["columns"][2][0]))
with pytest.raises(JSONEncodingValueError):
json.loads(self.data.annotation(None, "obs"))
with pytest.raises(JSONEncodingValueError):
json.loads(self.data.annotation(None, "var"))
+81 -114
View File
@@ -3,11 +3,13 @@ from os import path
import pytest import pytest
import time import time
import unittest import unittest
import decode_fbs
import numpy as np import numpy as np
from pandas import Series from pandas import Series
from server.app.scanpy_engine.scanpy_engine import ScanpyEngine from server.app.scanpy_engine.scanpy_engine import ScanpyEngine
from server.app.util.errors import FilterError
class UtilTest(unittest.TestCase): class UtilTest(unittest.TestCase):
@@ -45,55 +47,29 @@ class UtilTest(unittest.TestCase):
def test_filter_idx(self): def test_filter_idx(self):
filter_ = { filter_ = {
"filter": { "filter": {
"var": {"index": [1, 99, [200, 300]]}, "var": {"index": [1, 99, [200, 300]]}
"obs": {"index": [1, 99, [1000, 2000]]},
} }
} }
data = self.data.filter_dataframe(filter_["filter"]) fbs = self.data.data_frame_to_fbs_matrix(filter_["filter"], "var")
self.assertEqual(data.shape, (1002, 102)) data = decode_fbs.decode_matrix_FBS(fbs)
self.assertEqual(data["n_rows"], 2638)
def test_filter_annotation(self): self.assertEqual(data["n_cols"], 102)
filter_ = {
"filter": {
"obs": {
"annotation_value": [
{"name": "louvain", "values": ["NK cells", "CD8 T cells"]}
]
}
}
}
data = self.data.filter_dataframe(filter_["filter"])
self.assertEqual(data.shape, (470, 1838))
filter_ = {
"filter": {"obs": {"annotation_value": [{"name": "n_counts", "min": 3000}]}}
}
data = self.data.filter_dataframe(filter_["filter"])
self.assertEqual(data.shape, (497, 1838))
def test_filter_annotation_no_uns(self):
filter_ = {
"filter": {
"var": {"annotation_value": [{"name": "name", "values": ["RER1"]}]}
}
}
data = self.data.filter_dataframe(filter_["filter"])
self.assertEqual(data.shape[1], 1)
def test_filter_complex(self): def test_filter_complex(self):
filter_ = { filter_ = {
"filter": { "filter": {
"var": {"index": [1, 99, [200, 300]]}, "var": {
"obs": {
"annotation_value": [ "annotation_value": [
{"name": "louvain", "values": ["NK cells", "CD8 T cells"]}, {"name": "n_cells", "min": 10}
{"name": "n_counts", "min": 3000},
], ],
"index": [1, 99, [1000, 2000]], "index": [1, 99, [200, 300]]
}, }
} }
} }
data = self.data.filter_dataframe(filter_["filter"]) fbs = self.data.data_frame_to_fbs_matrix(filter_["filter"], "var")
self.assertEqual(data.shape, (15, 102)) data = decode_fbs.decode_matrix_FBS(fbs)
self.assertEqual(data["n_rows"], 2638)
self.assertEqual(data["n_cols"], 91)
def test_obs_and_var_names(self): def test_obs_and_var_names(self):
self.assertEqual(np.sum(self.data.data.var["name"].isna()), 0) self.assertEqual(np.sum(self.data.data.var["name"].isna()), 0)
@@ -119,60 +95,42 @@ class UtilTest(unittest.TestCase):
) )
def test_layout(self): def test_layout(self):
layout = json.loads(self.data.layout(None)) fbs = self.data.layout_to_fbs_matrix()
self.assertEqual(layout["layout"]["ndims"], 2) layout = decode_fbs.decode_matrix_FBS(fbs)
self.assertEqual(len(layout["layout"]["coordinates"]), 2638) self.assertEqual(layout["n_cols"], 2)
self.assertEqual(layout["layout"]["coordinates"][0][0], 0) self.assertEqual(layout["n_rows"], 2638)
for idx, val in enumerate(layout["layout"]["coordinates"]):
self.assertLessEqual(val[1], 1) X = layout["columns"][0]
self.assertLessEqual(val[2], 1) self.assertTrue((X >= 0).all() and (X <= 1).all())
Y = layout["columns"][1]
self.assertTrue((Y >= 0).all() and (Y <= 1).all())
def test_annotations(self): def test_annotations(self):
annotations = json.loads(self.data.annotation(None, "obs")) fbs = self.data.annotation_to_fbs_matrix("obs")
annotations = decode_fbs.decode_matrix_FBS(fbs)
self.assertEqual(annotations["n_rows"], 2638)
self.assertEqual(annotations["n_cols"], 5)
self.assertEqual( self.assertEqual(
annotations["names"], annotations["col_idx"],
["name", "n_genes", "percent_mito", "n_counts", "louvain"], ["name", "n_genes", "percent_mito", "n_counts", "louvain"],
) )
self.assertEqual(len(annotations["data"]), 2638)
annotations = json.loads(self.data.annotation(None, "var")) fbs = self.data.annotation_to_fbs_matrix("var")
self.assertEqual(annotations["names"], ["name", "n_cells"]) annotations = decode_fbs.decode_matrix_FBS(fbs)
self.assertEqual(len(annotations["data"]), 1838) self.assertEqual(annotations['n_rows'], 1838)
self.assertEqual(annotations['n_cols'], 2)
self.assertEqual(annotations["col_idx"], ["name", "n_cells"])
def test_annotation_fields(self): def test_annotation_fields(self):
annotations = json.loads( fbs = self.data.annotation_to_fbs_matrix("obs", ["n_genes", "n_counts"])
self.data.annotation(None, "obs", ["n_genes", "n_counts"]) annotations = decode_fbs.decode_matrix_FBS(fbs)
) self.assertEqual(annotations["n_rows"], 2638)
self.assertEqual(annotations["names"], ["n_genes", "n_counts"]) self.assertEqual(annotations['n_cols'], 2)
self.assertEqual(len(annotations["data"]), 2638)
annotations = json.loads(self.data.annotation(None, "var", ["name"]))
self.assertEqual(annotations["names"], ["name"])
self.assertEqual(len(annotations["data"]), 1838)
def test_filtered_annotation(self): fbs = self.data.annotation_to_fbs_matrix("var", ["name"])
filter_ = { annotations = decode_fbs.decode_matrix_FBS(fbs)
"filter": { self.assertEqual(annotations['n_rows'], 1838)
"obs": {"annotation_value": [{"name": "n_counts", "min": 3000}]}, self.assertEqual(annotations['n_cols'], 1)
"var": {
"annotation_value": [{"name": "name", "values": ["ATAD3C", "RER1"]}]
},
}
}
annotations = json.loads(self.data.annotation(filter_["filter"], "obs"))
self.assertEqual(
annotations["names"],
["name", "n_genes", "percent_mito", "n_counts", "louvain"],
)
self.assertEqual(len(annotations["data"]), 497)
annotations = json.loads(self.data.annotation(filter_["filter"], "var"))
self.assertEqual(annotations["names"], ["name", "n_cells"])
self.assertEqual(len(annotations["data"]), 2)
def test_filtered_layout(self):
filter_ = {
"filter": {"obs": {"annotation_value": [{"name": "n_counts", "min": 3000}]}}
}
layout = json.loads(self.data.layout(filter_["filter"]))
self.assertEqual(len(layout["layout"]["coordinates"]), 497)
def test_diffexp_topN(self): def test_diffexp_topN(self):
f1 = {"filter": {"obs": {"index": [[0, 500]]}}} f1 = {"filter": {"obs": {"index": [[0, 500]]}}}
@@ -183,42 +141,51 @@ class UtilTest(unittest.TestCase):
self.assertEqual(len(result), 20) self.assertEqual(len(result), 20)
def test_data_frame(self): def test_data_frame(self):
data_frame_obs = json.loads(self.data.data_frame(None, "obs")) fbs = self.data.data_frame_to_fbs_matrix(None, "var")
self.assertEqual(len(data_frame_obs["var"]), 1838) data = decode_fbs.decode_matrix_FBS(fbs)
self.assertEqual(len(data_frame_obs["obs"]), 2638) self.assertEqual(data["n_rows"], 2638)
data_frame_var = json.loads(self.data.data_frame(None, "var")) self.assertEqual(data["n_cols"], 1838)
self.assertEqual(len(data_frame_var["var"]), 1838)
self.assertEqual(len(data_frame_var["obs"]), 2638) with self.assertRaises(ValueError):
self.data.data_frame_to_fbs_matrix(None, "obs")
def test_filtered_data_frame(self): def test_filtered_data_frame(self):
filter_ = {
"filter": {"var": {"annotation_value": [{"name": "n_cells", "min": 100}]}}
}
fbs = self.data.data_frame_to_fbs_matrix(filter_["filter"], "var")
data = decode_fbs.decode_matrix_FBS(fbs)
self.assertEqual(data["n_rows"], 2638)
self.assertEqual(data["n_cols"], 1040)
filter_ = { filter_ = {
"filter": {"obs": {"annotation_value": [{"name": "n_counts", "min": 3000}]}} "filter": {"obs": {"annotation_value": [{"name": "n_counts", "min": 3000}]}}
} }
data_frame_obs = json.loads(self.data.data_frame(filter_["filter"], "obs")) with self.assertRaises(FilterError):
self.assertEqual(len(data_frame_obs["var"]), 1838) self.data.data_frame_to_fbs_matrix(filter_["filter"], "var")
self.assertEqual(len(data_frame_obs["obs"]), 497)
self.assertIsInstance(data_frame_obs["obs"][0], (list, tuple))
self.assertEqual(type(data_frame_obs["var"][0]), int)
data_frame_var = json.loads(self.data.data_frame(filter_["filter"], "var"))
self.assertEqual(len(data_frame_var["var"]), 1838)
self.assertEqual(len(data_frame_var["obs"]), 497)
self.assertIsInstance(data_frame_var["var"][0], (list, tuple))
self.assertEqual(type(data_frame_var["obs"][0]), int)
def test_data_single_gene(self): def test_data_named_gene(self):
for axis in ["obs", "var"]: filter_ = {
filter_ = { "filter": {
"filter": { "var": {"annotation_value": [{"name": "name", "values": ["RER1"]}]}
"var": {"annotation_value": [{"name": "name", "values": ["RER1"]}]}
}
} }
data_frame_var = json.loads(self.data.data_frame(filter_["filter"], axis)) }
if axis == "obs": fbs = self.data.data_frame_to_fbs_matrix(filter_["filter"], "var")
self.assertEqual(type(data_frame_var["var"][0]), int) data = decode_fbs.decode_matrix_FBS(fbs)
self.assertIsInstance(data_frame_var["obs"][0], (list, tuple)) self.assertEqual(data["n_rows"], 2638)
elif axis == "var": self.assertEqual(data["n_cols"], 1)
self.assertEqual(type(data_frame_var["obs"][0]), int) self.assertEqual(data["col_idx"], [4])
self.assertIsInstance(data_frame_var["var"][0], (list, tuple))
filter_ = {
"filter": {
"var": {"annotation_value": [{"name": "name", "values": ["SPEN", "TYMP", "PRMT2"]}]}
}
}
fbs = self.data.data_frame_to_fbs_matrix(filter_["filter"], "var")
data = decode_fbs.decode_matrix_FBS(fbs)
self.assertEqual(data["n_rows"], 2638)
self.assertEqual(data["n_cols"], 3)
self.assertTrue((data["col_idx"] == [15, 1818, 1837]).all())
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+1 -1
View File
@@ -17,7 +17,7 @@ with open("server/requirements.txt") as fh:
setup( setup(
name="cellxgene", name="cellxgene",
version="0.5.0", version="0.7.0",
packages=find_packages(), packages=find_packages(),
url="https://github.com/chanzuckerberg/cellxgene", url="https://github.com/chanzuckerberg/cellxgene",
license="MIT", license="MIT",